feat: M3 — 伏笔账本 + 节奏引擎 + 大纲(含并发记账 bugfix)

- 伏笔账本:纯函数状态机(OPEN/PARTIAL/CLOSED/OVERDUE) + ForeshadowLedger repo;验收后到期扫描(BackgroundTask 自建 session 置 OVERDUE);登记/状态变更端点
- 节奏 + 三审齐:foreshadow-analyst + pace-checker 并入 LangGraph 并行审(REVIEW_SPECS),collect 分列落 chapter_reviews(conflicts/foreshadow_sug/pace),review SSE 加 foreshadow/pace 事件
- 大纲:outliner Agent 产 OutlineResult(含 foreshadow_windows),POST /outline 逐章 upsert outline 表;GET /foreshadow?status= 看板
- 前端:伏笔四泳道看板(OVERDUE 琥珀) + 大纲编辑器(窗口徽标) + 节奏节拍图(▁▃▅) + 审稿页消费 foreshadow/pace SSE
- bugfix(T3.8):并行三审共用请求 session 记账触发 'Session is already flushing' → foreshadow/pace 静默丢失;SqlAlchemyLedgerSink.record 改 add-only(靠端点/事务 commit),加并发回归测试
- M3 E2E:真实 DB + mock 网关零 token 走通 埋设→进展→验收后扫描 OVERDUE→看板 + 大纲含窗口 + 三审齐 SSE/留痕;E2E 暴露并钉住上述 bug
- 门禁绿:mypy 111 / pytest 228(0 xfailed) / alembic 无漂移;前端 gen:api/lint/tsc/vitest 69/build
This commit is contained in:
Yaojia Wang
2026-06-18 14:21:17 +02:00
parent 68f194a043
commit 5fb7bfb1de
74 changed files with 6529 additions and 126 deletions

View File

@@ -13,16 +13,31 @@ from fakes_orchestrator import (
FailingRunGateway,
FakeReviewRepo,
FakeRunGateway,
SchemaRoutingRunGateway,
)
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver
from ww_agents import Conflict, ContinuityReview, continuity_spec
from ww_agents import (
Conflict,
ContinuityReview,
ForeshadowReview,
ForeshadowSuggestion,
PaceIssue,
PaceReview,
continuity_spec,
foreshadow_spec,
pace_spec,
)
from ww_core.orchestrator import (
CONTINUITY,
EVENT_CONFLICT,
EVENT_DONE,
EVENT_ERROR,
EVENT_FORESHADOW,
EVENT_PACE,
EVENT_SECTION,
FORESHADOW,
PACE,
REVIEW_INCOMPLETE,
REVIEW_OK,
SECTION_DONE,
@@ -33,6 +48,8 @@ from ww_core.orchestrator import (
build_review_request,
collect_reviews,
extract_conflicts,
extract_foreshadow_sug,
extract_pace,
make_review_node,
normalize_review,
run_review,
@@ -242,3 +259,177 @@ async def test_normalize_review_maps_unexpected_error_to_error_event() -> None:
assert events[-1].event == EVENT_ERROR
assert events[-1].data["code"] == ErrorCode.INTERNAL
assert events[-1].data["request_id"] == "req-9"
# ============================================================================
# T3.3 三审齐foreshadow + pace 并入并行审 / collect 列映射 / SSE surface
# ============================================================================
_FORESHADOW = ForeshadowReview(
planted=[
ForeshadowSuggestion(code="FS-MARK", title="胸口胎记", where="第2段", note="皇族暗示")
],
resolved=[ForeshadowSuggestion(code="FS-BIRTH", title="身世", where="第9段", note="回收")],
)
_PACE = PaceReview(
water=[PaceIssue(where="第4-6段", reason="信息密度低")],
hook=True,
beat_map=[1, 3, 5, 0, 4],
)
def _three_reviews() -> dict[str, Any]:
"""三审皆 ok 的 state['reviews']continuity/foreshadow/pace"""
return {
CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}},
FORESHADOW: {"status": REVIEW_OK, "result": _FORESHADOW.model_dump()},
PACE: {"status": REVIEW_OK, "result": _PACE.model_dump()},
}
# ---- collect 三审列映射 ----
def test_extract_foreshadow_sug_flattens_planted_and_resolved() -> None:
sug = extract_foreshadow_sug(_three_reviews())
assert len(sug) == 2 # 1 planted + 1 resolved 扁平
kinds = {s["kind"] for s in sug}
assert kinds == {"planted", "resolved"}
planted = next(s for s in sug if s["kind"] == "planted")
assert planted["code"] == "FS-MARK"
def test_extract_pace_returns_dict() -> None:
pace = extract_pace(_three_reviews())
assert pace is not None
assert pace["hook"] is True
assert pace["beat_map"] == [1, 3, 5, 0, 4]
def test_extract_foreshadow_and_pace_empty_when_incomplete() -> None:
reviews = {
FORESHADOW: {"status": REVIEW_INCOMPLETE, "result": None},
PACE: {"status": REVIEW_INCOMPLETE, "result": None},
}
assert extract_foreshadow_sug(reviews) == []
assert extract_pace(reviews) is None
async def test_collect_records_three_audits_to_their_columns() -> None:
repo = FakeReviewRepo()
await collect_reviews(_state(_three_reviews()), review_repo=repo)
assert len(repo.records) == 1 # 一次 record 落齐三审
rec = repo.records[0]
assert rec["conflicts"][0]["type"] == "能力不符" # continuity→conflicts
assert len(rec["foreshadow_sug"]) == 2 # foreshadow→foreshadow_sug扁平
assert rec["pace"]["hook"] is True # pace→pace
# ---- 三审并行图跑通mock 网关按 schema 路由产三 parsed----
async def test_three_review_graph_runs_all_audits_end_to_end() -> None:
gateway = SchemaRoutingRunGateway(
{
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
ForeshadowReview: _FORESHADOW,
PaceReview: _PACE,
}
)
repo = FakeReviewRepo()
graph = build_review_graph(
gateway,
repo,
review_specs=(continuity_spec, foreshadow_spec, pace_spec),
checkpointer=MemorySaver(),
)
config: RunnableConfig = {"configurable": {"thread_id": "rev-3"}}
final = await graph.ainvoke(_state(), config=config)
assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
assert final["reviews"][FORESHADOW]["status"] == REVIEW_OK
assert final["reviews"][PACE]["status"] == REVIEW_OK
assert len(gateway.calls) == 3 # 三审各调一次网关
# collect 一次 record 落齐三列
rec = repo.records[0]
assert rec["conflicts"] and rec["foreshadow_sug"] and rec["pace"]
async def test_three_review_graph_isolates_one_failing_audit() -> None:
# foreshadow schema 缺失 → SchemaRoutingRunGateway 抛 KeyError → run_review 隔离为 incomplete
gateway = SchemaRoutingRunGateway(
{
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
PaceReview: _PACE,
}
)
repo = FakeReviewRepo()
graph = build_review_graph(
gateway,
repo,
review_specs=(continuity_spec, foreshadow_spec, pace_spec),
checkpointer=MemorySaver(),
)
config: RunnableConfig = {"configurable": {"thread_id": "rev-4"}}
final = await graph.ainvoke(_state(), config=config)
# foreshadow 失败被隔离,其余两审照常 + collect 照常留痕foreshadow_sug 空)
assert final["reviews"][FORESHADOW]["status"] == REVIEW_INCOMPLETE
assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
assert final["reviews"][PACE]["status"] == REVIEW_OK
rec = repo.records[0]
assert rec["conflicts"] # continuity 落库
assert rec["foreshadow_sug"] == [] # 失败列留空,不阻塞其余
assert rec["pace"]["hook"] is True # pace 落库
# ---- SSEnormalize_review surface 三审section + conflict + foreshadow + pace + done----
async def test_normalize_review_surfaces_three_audits() -> None:
events = [e async for e in normalize_review(_three_reviews())]
kinds = [e.event for e in events]
# 字典序遍历continuity < foreshadow < pace
# continuity: section + 1 conflict
# foreshadow: section + 2planted+resolvedpace: section + 1
assert kinds == [
EVENT_SECTION,
EVENT_CONFLICT,
EVENT_SECTION,
EVENT_FORESHADOW,
EVENT_FORESHADOW,
EVENT_SECTION,
EVENT_PACE,
EVENT_DONE,
]
# foreshadow 事件带 kind + code
fs_events = [e for e in events if e.event == EVENT_FORESHADOW]
assert {e.data["kind"] for e in fs_events} == {"planted", "resolved"}
# pace 事件带节拍图
pace_evt = next(e for e in events if e.event == EVENT_PACE)
assert pace_evt.data["beat_map"] == [1, 3, 5, 0, 4]
assert pace_evt.data["hook"] is True
assert events[-1].data == {"length": 3} # 三审项
async def test_normalize_review_skips_results_for_incomplete_audit() -> None:
reviews = {
CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": []}},
FORESHADOW: {"status": REVIEW_INCOMPLETE, "result": None},
PACE: {"status": REVIEW_OK, "result": _PACE.model_dump()},
}
events = [e async for e in normalize_review(reviews)]
kinds = [e.event for e in events]
# foreshadow incomplete → 仅 section无 foreshadow 事件
assert kinds == [EVENT_SECTION, EVENT_SECTION, EVENT_SECTION, EVENT_PACE, EVENT_DONE]
fs_section = next(e for e in events if e.data.get("name") == FORESHADOW)
assert fs_section.data["status"] == SECTION_INCOMPLETE