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

@@ -0,0 +1,117 @@
"""T3.4 大纲 Agent 契约测试OutlineResult schema + outliner_spec 声明C6 扩)。
契约测试——构造符合 schema 的 mock 响应,校验伏笔窗口字段、章节结构、
声明式 writes验收/T3.5 才真写库,不变量 #3。不联网、无 DB。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from ww_agents import (
AgentSpec,
ForeshadowWindow,
OutlineChapter,
OutlineResult,
outliner_spec,
)
def test_outline_result_parses_mock_response() -> None:
# Arrange模拟网关 instructor 校验后的结构化大纲产出
mock = {
"chapters": [
{
"no": 1,
"beats": ["主角登场,立下目标", "埋下身世伏笔"],
"foreshadow_windows": [
{
"code": "FS-BIRTH",
"plant_chapter": 1,
"expected_close_from": 20,
"expected_close_to": 30,
}
],
},
{
"no": 2,
"beats": ["遭遇对手"],
"foreshadow_windows": [],
},
]
}
# Act
outline = OutlineResult.model_validate(mock)
# Assert
assert len(outline.chapters) == 2
assert outline.chapters[0].no == 1
assert outline.chapters[0].beats == ["主角登场,立下目标", "埋下身世伏笔"]
window = outline.chapters[0].foreshadow_windows[0]
assert window.code == "FS-BIRTH"
assert window.plant_chapter == 1
assert window.expected_close_from == 20
assert window.expected_close_to == 30
def test_outline_chapter_defaults_empty_windows() -> None:
chapter = OutlineChapter.model_validate({"no": 3, "beats": ["收束支线"]})
assert chapter.foreshadow_windows == []
def test_outline_result_defaults_to_empty_chapters() -> None:
assert OutlineResult().chapters == []
def test_foreshadow_window_optional_bounds_default_none() -> None:
window = ForeshadowWindow.model_validate({"code": "FS-X"})
assert window.plant_chapter is None
assert window.expected_close_from is None
assert window.expected_close_to is None
def test_foreshadow_window_requires_code() -> None:
with pytest.raises(ValidationError):
ForeshadowWindow.model_validate({"plant_chapter": 1})
def test_outline_chapter_requires_no() -> None:
with pytest.raises(ValidationError):
OutlineChapter.model_validate({"beats": ["x"]})
def test_outliner_spec_is_analyst_tier() -> None:
assert outliner_spec.tier == "analyst"
assert outliner_spec.name == "outliner"
def test_outliner_spec_declares_expected_reads() -> None:
assert outliner_spec.reads == [
"projects",
"foreshadow",
"characters",
"world_entities",
]
def test_outliner_spec_declares_outline_write() -> None:
# 声明式 writes经验收/T3.5 才真写库,不变量 #3
assert outliner_spec.writes == ["outline"]
def test_outliner_spec_output_schema_is_outline_result() -> None:
assert outliner_spec.output_schema is OutlineResult
def test_outliner_spec_has_nonempty_system_prompt() -> None:
assert outliner_spec.system_prompt.strip()
def test_outliner_spec_is_immutable() -> None:
with pytest.raises(ValidationError):
outliner_spec.tier = "writer"
def test_outliner_spec_is_agent_spec() -> None:
assert isinstance(outliner_spec, AgentSpec)

View File

@@ -0,0 +1,167 @@
"""T3.3 三审契约测试foreshadow / pace spec + 输出 schemaC6 扩)。
契约测试——构造符合 schema 的 mock 响应校验字段、tier、四审只读writes==[])。
不联网、无 DB。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from ww_agents import (
AgentSpec,
ForeshadowReview,
ForeshadowSuggestion,
PaceIssue,
PaceReview,
foreshadow_spec,
pace_spec,
)
# ---- ForeshadowReview schema ----
def test_foreshadow_review_parses_mock_response() -> None:
# Arrange模拟网关 instructor 校验后的结构化产出
mock = {
"planted": [
{
"code": "FS-MARK",
"title": "胸口的胎记",
"where": "第 2 段主角沐浴时被瞥见",
"note": "暗示皇族血统",
}
],
"resolved": [
{
"code": "FS-BIRTH",
"title": "身世之谜",
"where": "第 9 段老者道破来历",
"note": "回收第 1 章埋设",
}
],
}
# Act
review = ForeshadowReview.model_validate(mock)
# Assert
assert len(review.planted) == 1
assert review.planted[0].code == "FS-MARK"
assert review.resolved[0].code == "FS-BIRTH"
assert review.resolved[0].where == "第 9 段老者道破来历"
def test_foreshadow_review_defaults_to_empty_lists() -> None:
review = ForeshadowReview()
assert review.planted == []
assert review.resolved == []
def test_foreshadow_suggestion_only_title_required() -> None:
sug = ForeshadowSuggestion.model_validate({"title": "新埋的悬念"})
assert sug.title == "新埋的悬念"
assert sug.code is None
assert sug.where is None
assert sug.note is None
def test_foreshadow_suggestion_requires_title() -> None:
with pytest.raises(ValidationError):
ForeshadowSuggestion.model_validate({"code": "FS-X"})
# ---- PaceReview schema ----
def test_pace_review_parses_mock_response() -> None:
mock = {
"water": [{"where": "第 46 段反复描写天气", "reason": "信息密度低、与主线无关"}],
"hook": True,
"beat_map": [1, 3, 2, 0, 4, 5],
}
review = PaceReview.model_validate(mock)
assert len(review.water) == 1
assert review.water[0].reason == "信息密度低、与主线无关"
assert review.hook is True
assert review.beat_map == [1, 3, 2, 0, 4, 5]
def test_pace_review_defaults() -> None:
review = PaceReview()
assert review.water == []
assert review.hook is False
assert review.beat_map == []
def test_pace_issue_requires_where_and_reason() -> None:
with pytest.raises(ValidationError):
PaceIssue.model_validate({"where": "x"})
# ---- foreshadow_spec 声明 ----
def test_foreshadow_spec_is_analyst_tier() -> None:
assert foreshadow_spec.tier == "analyst"
assert foreshadow_spec.name == "foreshadow"
def test_foreshadow_spec_is_read_only() -> None:
# 不变量 #3四审只读
assert foreshadow_spec.writes == []
def test_foreshadow_spec_reads_foreshadow() -> None:
assert foreshadow_spec.reads == ["foreshadow"]
def test_foreshadow_spec_output_schema() -> None:
assert foreshadow_spec.output_schema is ForeshadowReview
def test_foreshadow_spec_has_nonempty_system_prompt() -> None:
assert foreshadow_spec.system_prompt.strip()
# ---- pace_spec 声明 ----
def test_pace_spec_is_light_tier() -> None:
# 不变量 #2节奏审用轻量档只声明 tier
assert pace_spec.tier == "light"
assert pace_spec.name == "pace"
def test_pace_spec_is_read_only() -> None:
assert pace_spec.writes == []
def test_pace_spec_reads_rules() -> None:
# genre 模板 DSL 经 rulesgenre 级)注入
assert pace_spec.reads == ["rules"]
def test_pace_spec_output_schema() -> None:
assert pace_spec.output_schema is PaceReview
def test_pace_spec_has_nonempty_system_prompt() -> None:
assert pace_spec.system_prompt.strip()
# ---- 共性immutable + 是 AgentSpec ----
def test_review_specs_are_agent_specs() -> None:
assert isinstance(foreshadow_spec, AgentSpec)
assert isinstance(pace_spec, AgentSpec)
def test_review_specs_are_immutable() -> None:
with pytest.raises(ValidationError):
foreshadow_spec.tier = "writer"
with pytest.raises(ValidationError):
pace_spec.tier = "writer"