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

@@ -7,6 +7,18 @@
---
- [2026-06-18] @llm **`SqlAlchemyLedgerSink.record` 改 add-onlyT3.8 修,去 `await flush()`**:原 add+flush 在并行审里 flush 重入炸(见下条)。改为只 `session.add(row)`——`add()` 同步不让步→并行协程不交错;持久化靠端点/事务 `commit()`(自动 flush。draft/review/accept/outline 端点末尾均已 commit记账不丢。**凡新增「并行用网关产 usage」的路径sink 必须保持 add-only不得在并行段 `await flush`。** 旧 gotcha「record 只 flush 不 commit」措辞已过时——现为「add-onlycommit 归调用方」commit 仍会 flush故记账语义不变
- [2026-06-18] @qa/@llm **并行审记账撞 sessionM3 真 bugT3.7 暴露→T3.8 修)**:三审同 LangGraph superstep 并行,各自 `gateway.run()`→共用**请求 session** 的 `SqlAlchemyLedgerSink.record`(`session.add`+`await session.flush()`)。`AsyncSession` 非并发安全→第二/三审 flush 撞 `Session is already flushing`→被 `run_review` 失败隔离吞成 `incomplete`→**foreshadow/pace 静默丢失**SSE 无事件、`chapter_reviews.foreshadow_sug`/`pace` 列空、日志 `review_node_incomplete error='Session is already flushing'`。M2 单审未触发。根因=并行路径里有 `await flush()`(唯一 await 的 DB-IO。修向审稿期记账避免并发 flushadd-only 靠端点 commit / 或缓冲后 collect 串行落 / 或并行审记账用独立 session
- [2026-06-18] @qa **E2E 验「验收后 BackgroundTask 扫描」时序**httpx `ASGITransport``await client.post(...)` 会等 ASGI app 协程(含 Starlette background tasks跑完才返回故 client 上下文退出后断言稳定不 flaky必 override `get_session_factory``e2e_sm`(真 sessionmaker同测试 engine/loop否则默认 `get_sessionmaker()` 另建 engine 绑别的 loop。
- [2026-06-18] @frontend **审稿 seed 扩成三态**`useReviewStream.seed` 入参由 `ReviewConflict[]``ReviewSeed{conflicts,foreshadow,pace}`(进页同时种伏笔建议/节奏留痕,免重审即可看)。`ReviewStreamState``foreshadow:ForeshadowSuggestion[]`(累加) / `pace:PaceReport|null`(**替换非累加**,对齐后端 collect「pace 整 dict 入列」)。
- [2026-06-18] @frontend **伏笔 transition 乐观更新只改 status、回滚存快照**`useForeshadow.transition` 本地先改 statusPATCH 失败 `setItems(snapshot)` 回滚 + 读 `error.details.reason`(duplicate/invalid_transition/empty_update) 映射文案。register **不乐观**(要服务端 code 唯一校验):成功才追加返回行,重复 code→422 友好提示不改 items。大纲页**无 GET 端点**:进页 `initialChapters=[]`,靠 `POST .../outline` 生成填充无凭据→503 引导去设置)。
- [2026-06-18] @backend **端点级测「无凭据→LLM_UNAVAILABLE」不要靠真实 `get_*_gateway` dep 解析**——`FakeSession` 不支持 `.execute``SqlCredentialStore` 会炸成 500。做法override 网关 dep 注一个 `raise AppError(LLM_UNAVAILABLE)` 的 async 函数(等价无凭据行为),与 review/accept/outline 测试一致。
- [2026-06-18] @backend **BackgroundTask 必须自建独立 session**FastAPI BackgroundTasks 在 response 发回、请求 session 关闭后才跑——复用 `Depends(get_session)` 的 session 已关闭会炸。验收后到期扫描经可注入 `SessionFactory`(=`get_sessionmaker()`)`run_overdue_scan``async with factory()` 开新 session 自己 commit再加 `repo_factory` 缝便于单测注 fake、纯函数 await 不起后台线程。**accept 端点新增 `get_session_factory` 依赖→所有 accept 测试 client 需 override 它**(否则 dep 解析会建真 engine
- [2026-06-18] @backend **伏笔登记重复 code / 非法转移 → `VALIDATION`(422) 非 409**:现有唯一 409 码 `CONFLICT_UNRESOLVED` 专指未决冲突禁验收,不复用;`details.reason``duplicate`/`invalid_transition`/`empty_update` 供前端区分。register 捕 SQLAlchemy `IntegrityError`→rollback→`AppError(VALIDATION)`transition 捕 `InvalidTransition`/`LookupError`(404)。
- [2026-06-18] @llm **三审列类型不齐**`chapter_reviews.foreshadow_sug` 是 JSONB **list**`pace` 是 JSONB **dict**——collect 把 `ForeshadowReview{planted,resolved}`(dict) **扁平成单 list**、每条加 `kind:"planted"|"resolved"``PaceReview` 整体入 dict 列。贴合既有 DB 列类型、不改 @db。新审种加入须同步collect 列映射 + `sse._section_result_events` 分支 + `normalize_review` 键名。`sse.py` import `.collect` 的 spec.name 常量做 section 分流无循环collect 不 import sse
- [2026-06-18] @llm **三审并行图测试按 `req.output_schema` 路由 parsed**`SchemaRoutingRunGateway`)——单 `FakeRunGateway` 对所有审返同一 parsed 会让三审拿错 schema。验失败隔离某 schema 不登记→网关抛 KeyError→`run_review` 隔离为 `incomplete`,无需改 gateway。
- [2026-06-18] @backend **伏笔 `record_progress` append JSONB 必须新建 list 重赋值**`row.progress = [*old, entry]`),不可原地 `.append()`——SQLAlchemy 默认不侦测可变 JSONB 原地突变原地改不脏标记→flush 丢失。`scan_overdue` 仅在有变更时 flush空扫描零写。状态机`transition` 同态(current==to)幂等放行、CLOSED 为终态(离开 CLOSED 全非法抛 `InvalidTransition`)`is_overdue` 严格大于(current==expected_close_to 仍在窗口内不逾期)、无 expected_close_to 永不逾期。
- [2026-06-18] @llm **orchestrator 内每模块各自声明 `GatewayRun` Protocol**`review_node.py``outline_node.py` 各一份,按模块最小依赖)——**不跨模块复用、不在 orchestrator `__init__` 重复导出**`__init__` 只导出 review_node 那个,避免 re-export 名冲突outline 的为模块内部用。
- [2026-06-18] @qa **M2 E2E 多档位假适配器**`config.tier_defaults` writer/analyst/light 默认同 provider(deepseek)→单个假适配器(`provider="deepseek"`)即覆盖三档位;据 `req.output_schema is ContinuityReview`(续审)/否则 digest facts schema 分支返回 `parsed`;三档位用不同 `input_tokens` 区分以断言各自落 `usage_ledger`。三端点记账闭环 = review 端点流末 commit + accept 验收事务末 commit 都把网关 ledger flush 真正提交M1 ledger bug 在 M2 无复发)。
- [2026-06-18] @qa **E2E 验证「digest 从终稿非草稿」(#4) 手法**final_text 注入草稿没有的标记串,假 light 适配器把它放进 digest facts 的 `summary`,断言 `chapter_digests.facts["summary"]==标记``标记 not in draft_text`。accept 409 gate 经 ASGITransport 正常返回(`AppError` 不上抛),断言 `resp.json()["error"]["details"]["missing_conflict_indices"]``ErrorCode` StrEnum → `"CONFLICT_UNRESOLVED"`)。
- [2026-06-18] @frontend **审稿历史 `conflicts` 在 OpenAPI 被标松散 `{[k]:unknown}[]`**(后端用 dict/JSONB 列)→ 前端 `lib/review/history.ts` 安全收窄成 `ReviewConflict{type,where,refs,suggestion}`,缺字段给默认、**保序**(顺序=冲突 gate 的 `conflict_index` 身份,不可重排,否则裁决错位)。