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:
@@ -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 落库
|
||||
|
||||
|
||||
# ---- SSE:normalize_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 + 2(planted+resolved);pace: 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
|
||||
|
||||
Reference in New Issue
Block a user