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

@@ -8,32 +8,40 @@
---
## 当前阶段Phase 2 (M2) · 一致性 + 验收
## 当前阶段Phase 3 (M3) · 伏笔 + 节奏
> 目标:写 → 审(一致性) → 裁决 → 验收(事务写回)**未决冲突禁验收**。**契约先行**C6 续审 AgentSpec + 结构化输出 schema → C4 扩(并行审图/SSE) → C3 扩(review/accept/reviews)。
> 执行波次依赖驱动A(T2.1‖T2.3) → B(T2.2) → C(T2.4+T2.5) → D(T2.6) → E(T2.7)。
> 计划已定的设计决策(见变更日志 + `memory/decisions.md` 待 Wave A 落地后回写)
> **R2** digest 提炼在事务外做、原子事务只写库(不在持开事务里跨网络调 LLM
> **R3** accept 为确定性事务代码、读领域表(`chapters`/`chapter_reviews`)重建真相checkpoint 仅控制流句柄(不变量 #5
> **R4** accept 取该章 `max(version)+1` 晋升 `status='accepted'`,草稿行保留;
> **R5** 裁决清单每个 conflict 必须 采纳/忽略/手改 之一,缺判决 → `CONFLICT_UNRESOLVED`。
> 目标:伏笔账本/到期提醒/看板 + 节奏引擎 + 大纲。**契约先行**C6 扩(foreshadow-analyst/pace-checker/outliner 三 spec + 输出 schema) → C4 扩(审图加两审分支 + outline 节点) → C3 扩(outline/foreshadow 端点)。
> 执行波次依赖驱动A(T3.1‖T3.4) → B(T3.2‖T3.3) → C(T3.5) → D(T3.6) → E(T3.7)。
> 计划已定的衔接决策
> **M3-a** `foreshadow` 表已在 T0.2 建齐(含 status/expected_close_from/to/progress/links 列)→ **M3 无新建表迁移**T3.1 仅 Repository + 纯函数状态机,落 `core/ww_core/domain/`@backend owned同 T2.3 先例,故 T3.1 负责据实记 @backend
> **M3-b** 到期扫描T3.2)挂在**验收后**触发——接 M2 验收事务 §5.5 步骤 3 当前留的 `TODO(M3)`(人物 latest_state/伏笔更新)占位。
> **M3-c** 第三审foreshadow/pace复用 M2 `build_review_graph` 的 `review_specs` 扩展点并入并行分支C4 已稳定);**文风第四审属 M4/T4.2**M3 先到三审齐。
> **M3-d** 到期判据为纯函数确定性代码(非 Agent`current_ch > expected_close_to AND status≠CLOSED → OVERDUE`。
| 任务 | 状态 | 负责 | 依赖 | 契约 | 备注 |
|---|---|---|---|---|---|
| T2.1 续审 AgentSpec + 结构化输出契约 + 网关 instructor 接线 | ✅ @llm 2026-06-18 | @llm | T1.1✅ | C6✅稳定 | `packages/agents/`+`packages/llm_gateway/``ContinuityReview/Conflict`(五类枚举)+`continuity_spec`(只读)`Gateway.run` 经 instructor 填 `parsed`(改 C1)25 tests 绿 |
| T2.2 LangGraph 并行审 + collect 留痕 + review SSE | ✅ @llm 2026-06-18 | @llm | T1.3✅, T2.1✅ | C4✅扩稳定 | `orchestrator/``build_review_graph`(continuity 并行,可扩四审)+`collect_reviews``chapter_reviews`+`normalize_review`(section/conflict)accept 接线留 T2.415 新测;只 flush 不 commit |
| T2.3 摘要/审稿留痕 Repository + 章节 version | ✅ @backend 2026-06-18 | @backend | T0.2✅ | | `core/ww_core/domain/``chapter_repo`(promote_to_accepted/max_version/latest_accepted)+`digest_repo`(append-only)+`review_repo`(record/list/set_decisions);纯 fake 17 新测;只 flush 不 commit交 T2.4 事务);唯一约束 DB 级→T2.7 E2E 覆盖 |
| T2.4 验收事务 + 冲突 gate | ✅ @backend 2026-06-18 | @backend | T2.2✅, T2.3✅ | C3✅扩 | `accept_service`(冲突 gate+R3/R4 单事务)+`digest_extraction`(R2 事务外提炼,light)`POST .../accept`缺判→409 `CONFLICT_UNRESOLVED`+missing_indices |
| T2.5 review API + 历史 | ✅ @backend 2026-06-18 | @backend | T2.2✅ | C3✅扩 | `POST .../review`(SSE section/conflict/done)+`GET .../reviews`(新→旧)analyst 网关;流耗尽 commit+9 测全仓 112 passed |
| T2.6 审稿报告页 + 冲突裁决 + 验收 gate | ✅ @frontend 2026-06-18 | @frontend | T2.4✅, T2.5✅ | — | `app/projects/[id]/review`+`components/review/*`+`lib/review/*`gen:api 纳三端点;就地标注(R6 段级锚点)+裁决+「将更新」清单+未决禁验收vitest 45(+21) |
| T2.7 M2 E2E | ✅ @qa 2026-06-18 | @qa | T2.6✅ | — | `tests/test_m2_e2e.py` 2 用例真跑(非 skip);闭环 + 零冲突直通DB 真源断言(accepted v2 / digest 来自终稿#4 / 裁决留痕 / 三档位 ledger);无新 bug |
| T3.1 foreshadow Repository + 纯函数状态机 | ✅ @backend 2026-06-18 | @backend | T0.2✅ | — | `foreshadow_state.py`(ForeshadowStatus+transition/is_overdue/apply_overdue_scan/InvalidTransition)+`foreshadow_repo.py`(`ForeshadowLedger*`register/list_by_status/transition/record_progress/scan_overdue只 flush)37 测;写侧加 Ledger 前缀避撞 C5 读侧 |
| T3.2 到期扫描(BackgroundTask) + 登记/状态接口 | ✅ @backend 2026-06-18 | @backend | T2.4✅, T3.1✅ | C3✅扩 | accept 后 BackgroundTask `run_overdue_scan`(自建 session 置 OVERDUE)`POST/PATCH .../foreshadow` 登记/转移(重复/非法→VALIDATION 422)9 测全仓 205 passed |
| T3.3 foreshadow-analyst + pace-checker 节点 | ✅ @llm 2026-06-18 | @llm | T2.2✅ | C6✅扩/C4✅扩 | `foreshadow_spec`+`pace_spec`(+schema)并入 `REVIEW_SPECS`(三审齐)collect 分列落 conflicts/foreshadow_sug/pace`normalize_review``foreshadow`/`pace` 事件;~30 测;文风审留 M4 |
| T3.4 outliner Agent + 伏笔窗口 | ✅ @llm 2026-06-18 | @llm | T1.1✅ | C6✅扩 | `packages/agents/`(outliner_spec+OutlineResult/OutlineChapter/ForeshadowWindow)+`orchestrator/outline_node.py`(run_outline 只读不写库)20 测C6 扩稳定 |
| T3.5 APIoutline + foreshadow | ✅ @backend 2026-06-18 | @backend | T3.1✅, T3.4✅ | C3✅扩 | `GET .../foreshadow?status=`(看板,续 foreshadow.router) + `POST .../outline`(独立 outline.pyrun_outline analyst→upsert outline 表+commit)outline 写侧 repo+15 测全仓 220 passed |
| T3.6 伏笔看板 + 大纲编辑器 + 节奏报告 | ✅ @frontend 2026-06-18 | @frontend | T3.3✅, T3.5✅ | — | `/foreshadow`(四泳道+登记/转移) + `/outline`(beats+窗口徽标) + 审稿页扩消费 foreshadow/pace(节拍图▁▃▅)gen:api 纳 M3 端点vitest 69(+24) |
| T3.7 M3 E2E | ✅ @qa 2026-06-18 | @qa | T3.6✅ | — | `tests/test_m3_e2e.py` 3 用例真跑(非 skip):伏笔埋设→进展→验收后扫描 OVERDUE✓ + 大纲 upsert 含窗口✓ + 三审 SSE(**strict-xfail 钉住并发记账 bug**)DB 真源断言 |
| T3.8 [bugfix] 并行审记账并发安全 | ✅ @llm 2026-06-18 | @llm | T3.7✅ | — | `SqlAlchemyLedgerSink.record` 改 add-only(去 `await flush`);并发回归测试(RED 已验旧实现炸)E2E case3 转 XPASS(三审齐落库 foreshadow_sug=2/pace✓)。**待 @qa 解钉 T3.7 strict-xfail 收尾** |
**M2 出口DoD✅ 全部达成**写→审(一致性)→裁决→验收(事务);未决冲突禁验收。后端门禁绿ruff / mypy **90 文件** / pytest **114 passed** / alembic **无漂移**前端门禁绿gen:api / lint / tsc / vitest **45** / buildE2E 真实 DB + mock 网关零 token 走通 写→审→裁决→验收→摘要入库DB 真源逐项断言。**M2 完成。** 下一阶段 M3伏笔 + 节奏)见 DEV_PLAN进入时拉入本表。
**M3 出口DoD✅ 全部达成**伏笔账本/到期提醒/看板;大纲含回收窗口;节奏报告(三审齐真持久化)。后端门禁绿ruff / mypy **111 文件** / pytest **228 passed** / alembic **无漂移**前端门禁绿gen:api / lint / tsc / vitest **69** / buildE2E 真实 DB + mock 网关零 token 走通 伏笔埋设→进展→验收后扫描 OVERDUE→看板 + 大纲含窗口 + 三审齐 SSE/留痕DB 真源逐项断言。T3.7 E2E 暴露并发记账 bug → T3.8 add-only 修复 → 解钉 xfail。**M3 完成。** 下一阶段 M4文风)见 DEV_PLAN进入时拉入本表。
---
## 已归档阶段
<details><summary>Phase 2 · 一致性 + 验收 (M2) — ✅ 全部完成2026-06-18</summary>
T2.1 续审 AgentSpec+结构化输出契约+网关 instructor 接线C6✅稳定改 C1`run()``parsed`@llm · T2.2 LangGraph 并行审+collect 留痕+review SSEC4✅扩@llm · T2.3 摘要/审稿留痕 Repository+章节 version@backend,表已 T0.2 建)· T2.4 验收事务+冲突 gate单原子事务 + R2 事务外提炼 digest@backend · T2.5 review API+历史C3✅扩@backend · T2.6 审稿报告页+冲突裁决+验收 gate @frontend · T2.7 M2 E2E真 DB+多档位 mock 网关零 token@qa
出口达成后端门禁绿ruff / mypy 90 / pytest 114 / alembic 无漂移前端门禁绿gen:api / lint / tsc / vitest 45 / buildE2E 写→审→裁决→验收→摘要入库闭环、DB 真源逐项断言。设计决策 R1R6 全兑现(见 `memory/decisions`)。
</details>
<details><summary>Phase 1 · 写章闭环骨架 (M1) — ✅ 全部完成2026-06-18</summary>
T1.1 LLM 网关接口+DeepSeek 适配器+usage_ledger 记账C1✅稳定@llm · T1.2 记忆 assemble 确定性选择C5✅稳定@backend · T1.3 LangGraph 写章节点+Postgres checkpointer+SSEC4✅稳定@llm · T1.4 立项+写章 draft(SSE)+PUT 自动保存C3✅稳定@backend · T1.5 AppShell+作品库+5 步立项向导 @frontend · T1.6 写作工作台(三栏+流式打字机+注入透明面板+自动保存)@frontend · T1.7 提供商凭据管理Fernet 加密+测试连接)@backend · T1.8 设置页(档位路由+凭据行)@frontend · T1.9 M1 E2E真实 DB+mock 网关零 token暴露并修复 ledger 提交 bug@qa
@@ -55,6 +63,14 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
- [2026-06-18] @orchestrator**🎉 M3 完成**T3.8✅ 修复 + @qa 解钉 xfail。T3.8 把 `SqlAlchemyLedgerSink.record` 改 add-only去并行路径里唯一的 `await flush()`,靠端点/事务 commit 持久化),并发回归测试 RED 已验旧实现抛 `Session is already flushing`@qa 移除 T3.7 case3 strict-xfail、收紧为严格正向断言三审 section 齐 + foreshadow/pace 真发事件 + `chapter_reviews.foreshadow_sug`/`pace` 列真填 + `beat_map` 精确断言。最终全收口门禁绿ruff/format 干净、mypy **111 文件**、pytest **228 passed**(0 xfailed/0 failed)、**alembic 无漂移**;前端 lint/tsc/vitest 69 绿。**M3 出口 DoD 全达成。** 下一阶段 M4文风
- [2026-06-18] @orchestrator**M3 Wave ET3.7✅ E2E 交付 + 暴露并发 bug**`tests/test_m3_e2e.py` 真 pg 跑通:① 伏笔 埋设→进展→验收后 BackgroundTask 扫描置 OVERDUE→看板过滤(DB 真源)✓ ② 大纲 `POST .../outline`→outline 表行+窗口+幂等 upsert+记账✓。③ 三审 SSE 用例 **strict-xfail 钉住真 bug**:三审同 LangGraph superstep 并行 + 共用请求 session 的 `SqlAlchemyLedgerSink.record`(add+`await flush`)→`AsyncSession` 非并发安全→第二/三审 flush 撞「Session is already flushing」→被 `run_review` 失败隔离静默吞成 `incomplete`→**foreshadow/pace 静默丢失**(SSE 无事件、`chapter_reviews` 列空)。M2 单审未触发。基线 **222 passed + 1 xfailed**。开 **T3.8(@llm)** 修复 → 修后 @qa 解钉 xfail → 才算 M3 达成。
- [2026-06-18] @orchestrator**M3 Wave D 完成**T3.6✅)。前端三块落地:伏笔四泳道看板(`/foreshadow`OVERDUE 琥珀 + 登记/转移乐观更新)、大纲编辑器(`/outline`,逐章 beats + 伏笔窗口徽标 + 接近回收提示)、审稿页扩消费 `foreshadow`/`pace` SSE伏笔建议区 + 节奏节拍图 ▁▃▅,三审齐显,文风第四审留 M4`pnpm gen:api` 纳入 M3 端点(看板/登记/转移/大纲),松散 JSONB 字段安全收窄不手写。前端门禁绿gen:api/lint/tsc/**vitest 69(+24)**/build。memory +1 decision +2 gotchas。下一步 **Wave ET3.7 M3 E2E**(@qa 真 DB + mock 网关零 token)。
- [2026-06-18] @orchestrator**M3 Wave C 完成**T3.5✅C3 扩稳定)。`GET .../foreshadow?status=` 看板(续 foreshadow.router四泳道+ `POST .../outline`(独立 outline.py`run_outline` analyst 网关 → 逐章 upsert `outline` 表含伏笔窗口 → 端点末尾 commit。outline 写侧 repo 加 `Write` 前缀避撞 C5 读侧。全仓门禁绿mypy **102 文件**、pytest **220 passed**(205→+15)。memory 登记 C3 扩 + 1 decision + 1 gotcha。下一步 **Wave DT3.6**(@frontend 伏笔看板 + 大纲编辑器 + 节奏节拍图 + 审稿页消费 foreshadow/pace SSE`pnpm gen:api`)。
- [2026-06-18] @orchestrator**M3 Wave B 完成**T3.2✅)。验收后到期扫描挂 BackgroundTask`run_overdue_scan` 自建独立 session 置 OVERDUE接 §5.5 `TODO(M3)`+ 伏笔登记/状态端点(`POST/PATCH .../foreshadow`,重复 code/非法转移→`VALIDATION` 422未动 shared 错误码。全仓门禁绿mypy **95 文件**、pytest **205 passed**(196→+9)。memory 登记 C3 扩 + 1 decision + 2 gotchas。下一步 **Wave CT3.5**(@backend `GET .../foreshadow?status=` 看板续接 foreshadow.router + `POST .../outline` 独立 outline.py 调 run_outline)。
- [2026-06-18] @orchestrator**M3 Wave B 半程**T3.3✅C4/C6 扩稳定。foreshadow-analyst + pace-checker 并入 `build_review_graph``REVIEW_SPECS` 三审齐,文风第四审留 M4collect 一次 record 分列落 `conflicts`/`foreshadow_sug`(扁平+kind 标记)/`pace`(整 dict)`normalize_review``foreshadow`/`pace` SSE 事件向后兼容。全仓门禁绿ruff/format 干净、mypy **91 文件**、pytest **196 passed**(169→+27)。memory 登记 C4/C6 扩 + 2 gotchas。**Wave B 顺序执行**T3.3 改完 orchestrator → 现启 **T3.2(@backend apps/api)**,对最终 orchestrator 构建、无编译竞争。
- [2026-06-18] @orchestrator**M3 Wave A 完成**T3.1✅ + T3.4✅)。两 agent 并行(@backend core/domain ‖ @llm agents+orchestrator隔离 MYPY_CACHE_DIR 避缓存竞争。T3.1:伏笔纯函数状态机(OPEN/PARTIAL/CLOSED/OVERDUE 全转移+到期判据)+写侧账本 repo(`ForeshadowLedger*`,加 Ledger 前缀避撞 C5 读侧)。T3.4outliner spec + `OutlineResult` schema(含 `foreshadow_windows`)+`run_outline` 节点(只读不写库C6 扩稳定)。全仓门禁绿ruff/format 干净、mypy **90 文件**、pytest **169 passed**(114→+37+18)。memory 登记 C6 扩 + 2 decisions + 2 gotchas。下一步 **Wave BT3.2(@backend 到期扫描+登记接口) ‖ T3.3(@llm foreshadow/pace 第三审并入 review_specs)**
- [2026-06-18] @orchestrator**进入 M3**:归档 Phase 2(M2) 至已归档Phase 3 七任务拉入台账。沿用 M2 节奏:契约先行 + 分目录并行 + 全仓门禁收口。核对:`foreshadow` 表已在 T0.2 建齐 → **M3 无新建表迁移**T3.1「Repository+状态机」据实记 @backend(落 `core/ww_core/domain/`,同 T2.3 先例T3.3 复用 M2 `build_review_graph``review_specs` 扩展点加 foreshadow/pace 两审(文风第四审留 M4。启动 **Wave AT3.1(@backend·core/domain) ‖ T3.4(@llm·packages/agents+orchestrator)**(不同目录,无写冲突)。
- [2026-06-18] @orchestrator**🎉 M2 完成**Wave ET2.7✅)。`tests/test_m2_e2e.py` 真实 pg + 多档位 mock 网关(零 token真跑通过 2 用例:主用例串 立项→写章 SSE→自动保存→**审稿 SSE(section/conflict)**→GET 历史→**残缺裁决触发 409 `CONFLICT_UNRESOLVED`+missing_indices(gate 事务前拦截未写库)**→全量裁决+终稿验收(200, accepted v2)→**DB 真源逐项断言**chapters draft v1 留存 + accepted v2(content==final_text)、`chapter_digests.facts` 追溯终稿标记(证 #4 digest 从终稿非草稿)、`chapter_reviews.decisions` 写回、`usage_ledger` 三档位(writer/analyst/light)各落一条且事务已 commit补充用例验证零冲突直通。**无新 bug**M1 曾暴露的 ledger 提交 bug 在 M2 三档位无复发——review 流末 + accept 事务末均 commit。最终全收口门禁绿ruff/format 干净、mypy **90 文件**、pytest **114 passed**、**alembic 无漂移**M2 全程零建表迁移。M2 出口 DoD 全达成。下一阶段 **M3伏笔+节奏)**
- [2026-06-18] @orchestrator**Wave D 完成**T2.6✅)。审稿报告页落地:`app/projects/[id]/review`RSC 取历史)+ `components/review/*`(报告卡/就地标注/AcceptPanel+ `lib/review/*`SSE reducer/裁决完整性/请求体组装,纯逻辑单测)。`pnpm gen:api` 纳入 review/accept/reviews 三端点类型不手写SSE 沿用 `useDraftStream` 的 fetch+ReadableStream 范式;五类冲突就地朱砂锚点(R6 段级降级)+裁决(采纳/忽略/手改)+**未决禁验收 gate**+409 missing_indices 高亮+「本次将更新」清单。前端门禁绿gen:api/lint/tsc/**vitest 45(+21)**/build。memory +1 decision +2 gotchas。下一步 **Wave ET2.7 M2 E2E**(@qa 真实 DB + mock 网关零 token)。
- [2026-06-18] @orchestrator**Wave C 完成**T2.4✅ + T2.5✅C3 扩稳定,单 @backend agent 顺序做)。三端点入 OpenAPI`POST .../review`(SSE section/conflict/done) + `GET .../reviews`(新→旧) + `POST .../accept`。验收:冲突 gate裁决 `conflict_index` 须覆盖 review.conflicts 全下标缺判→409 `CONFLICT_UNRESOLVED`+missing_indices→ 终稿 digest 提炼在事务外(R2light 档)→ 单原子事务 promote(R4)+digest.append(#4)+set_decisions、末尾一次 commit、失败全回滚(R3)。`build_gateway_for_tier` 统一多档位网关注入。全仓门禁绿ruff/format 干净、mypy **83 文件**、pytest **112 passed**+9。memory 登记 C3 扩 + 1 decision + 2 gotchas。下一步 **Wave DT2.6**(@frontend 审稿页+裁决+验收 gate`pnpm gen:api`)。

View File

@@ -8,10 +8,19 @@ from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
from pydantic import BaseModel
from ww_core.domain import ForeshadowLedgerView, OutlineWriteView
from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView
from ww_core.domain.foreshadow_state import (
ForeshadowStatus,
apply_overdue_scan,
)
from ww_core.domain.foreshadow_state import (
transition as apply_transition,
)
from ww_core.domain.project_repo import ProjectCreate, ProjectView
from ww_core.domain.repositories import DigestView
from ww_core.domain.review_repo import ReviewView
@@ -172,14 +181,156 @@ class FakeDigestAppendRepo:
class FakeSession:
"""最小 fake记录 commit 次数(验收事务边界断言)。"""
"""最小 fake记录 commit/rollback 次数(验收事务 + 端点提交边界断言)。"""
def __init__(self) -> None:
self.commits = 0
self.rollbacks = 0
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
class FakeForeshadowRepo:
"""实现 `ForeshadowLedgerRepo` Protocol内存状态机/进展/扫描复用纯函数)。
`raise_integrity_on` 模拟重复 code 的 DB 唯一约束冲突register 抛 IntegrityError
"""
def __init__(self, *, raise_integrity_on: set[str] | None = None) -> None:
self.rows: dict[tuple[uuid.UUID, str], ForeshadowLedgerView] = {}
self._raise_integrity_on = raise_integrity_on or set()
async def register(
self,
project_id: uuid.UUID,
*,
code: str,
title: str,
planted_at: int | None = None,
content: str | None = None,
expected_close_from: int | None = None,
expected_close_to: int | None = None,
importance: str | None = None,
) -> ForeshadowLedgerView:
if code in self._raise_integrity_on or (project_id, code) in self.rows:
from sqlalchemy.exc import IntegrityError
raise IntegrityError("duplicate code", None, Exception("unique"))
view = ForeshadowLedgerView(
code=code,
title=title,
status=ForeshadowStatus.OPEN.value,
planted_at=planted_at,
content=content,
expected_close_from=expected_close_from,
expected_close_to=expected_close_to,
importance=importance,
)
self.rows[(project_id, code)] = view
return view
async def get(self, project_id: uuid.UUID, code: str) -> ForeshadowLedgerView | None:
return self.rows.get((project_id, code))
async def list_by_status(
self, project_id: uuid.UUID, status: str | None = None
) -> list[ForeshadowLedgerView]:
rows = [v for (p, _), v in self.rows.items() if p == project_id]
if status is not None:
rows = [v for v in rows if v.status == status]
return sorted(rows, key=lambda v: v.code)
async def transition(
self, project_id: uuid.UUID, code: str, *, to_status: str
) -> ForeshadowLedgerView:
row = self.rows.get((project_id, code))
if row is None:
raise LookupError(f"foreshadow not found: {code}")
new_status = apply_transition(
ForeshadowStatus(row.status), ForeshadowStatus(to_status)
).value
updated = row.model_copy(update={"status": new_status})
self.rows[(project_id, code)] = updated
return updated
async def record_progress(
self, project_id: uuid.UUID, code: str, *, entry: dict[str, Any]
) -> ForeshadowLedgerView:
row = self.rows.get((project_id, code))
if row is None:
raise LookupError(f"foreshadow not found: {code}")
updated = row.model_copy(update={"progress": [*row.progress, dict(entry)]})
self.rows[(project_id, code)] = updated
return updated
async def scan_overdue(
self, project_id: uuid.UUID, *, current_chapter: int
) -> list[ForeshadowLedgerView]:
changed: list[ForeshadowLedgerView] = []
for key, row in list(self.rows.items()):
new_status = apply_overdue_scan(
ForeshadowStatus(row.status),
current_chapter=current_chapter,
expected_close_to=row.expected_close_to,
).value
if new_status != row.status:
updated = row.model_copy(update={"status": new_status})
self.rows[key] = updated
changed.append(updated)
return changed
class FakeOutlineWriteRepo:
"""实现 `OutlineWriteRepo` Protocol内存幂等覆盖同 (project_id, chapter_no))。"""
def __init__(self) -> None:
# (project_id, chapter_no) -> (volume, beats, foreshadow_windows)
self.rows: dict[tuple[uuid.UUID, int], tuple[int, list[str], list[dict[str, Any]]]] = {}
self.upsert_calls = 0
async def upsert_chapter(
self,
project_id: uuid.UUID,
*,
volume: int,
chapter_no: int,
beats: list[str],
foreshadow_windows: list[dict[str, Any]],
) -> OutlineWriteView:
self.upsert_calls += 1
windows = [dict(w) for w in foreshadow_windows]
self.rows[(project_id, chapter_no)] = (volume, list(beats), windows)
return OutlineWriteView(
chapter_no=chapter_no,
volume=volume,
beats=list(beats),
foreshadow_windows=windows,
)
class FakeSessionFactory:
"""到期扫描的独立 session 工厂替身:`()` → async-CM 产 `FakeSession`。
记录开了几次 session + 各次 commit供「扫描自建 session 并提交」断言。
"""
def __init__(self) -> None:
self.sessions: list[FakeSession] = []
def __call__(self) -> Any:
session = FakeSession()
self.sessions.append(session)
@asynccontextmanager
async def _cm() -> AsyncIterator[FakeSession]:
yield session
return _cm()
class FakeReviewGateway:
"""实现审稿/digest 节点最小依赖(`run`):吐固定结构化 `parsed`,绝不联网。"""

View File

@@ -0,0 +1,203 @@
"""T3.2 伏笔登记/状态变更端点 + 验收后到期扫描(内存替身,无 DB/无网络)。
覆盖:
- 登记 201 + 重复 code → 422 VALIDATION 信封;
- 状态机转移 200 / 非法转移 → 422 VALIDATION / 不存在 → 404
- 进展 append空更新 → 422
- 到期扫描纯函数 `run_overdue_scan` 直接 await埋 expected_close_to < 验收章号的伏笔
→ 置 OVERDUE、自建 session 提交(**绝不真起后台线程**)。
"""
from __future__ import annotations
import uuid
import httpx
import pytest
from fakes_projects import (
FakeForeshadowRepo,
FakeSession,
FakeSessionFactory,
)
from ww_api.services.foreshadow_scan import run_overdue_scan
from ww_core.domain.foreshadow_state import ForeshadowStatus
from ww_shared import ErrorCode
def _make_client(
*, repo: FakeForeshadowRepo | None = None, session: FakeSession | None = None
) -> tuple[httpx.AsyncClient, FakeForeshadowRepo, FakeSession]:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import get_foreshadow_repo
from ww_db import get_session
repo = repo or FakeForeshadowRepo()
session = session or FakeSession()
app = create_app()
app.dependency_overrides[get_foreshadow_repo] = lambda: repo
app.dependency_overrides[get_session] = lambda: session
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, repo, session
# ---- 登记 ----
@pytest.mark.asyncio
async def test_register_foreshadow_returns_201_open() -> None:
client, repo, session = _make_client()
pid = uuid.uuid4()
async with client:
resp = await client.post(
f"/projects/{pid}/foreshadow",
json={"code": "F1", "title": "神秘信物", "planted_at": 3, "expected_close_to": 10},
)
assert resp.status_code == 201
body = resp.json()
assert body["code"] == "F1"
assert body["status"] == ForeshadowStatus.OPEN.value
assert body["expected_close_to"] == 10
assert session.commits == 1
assert (pid, "F1") in repo.rows
@pytest.mark.asyncio
async def test_register_duplicate_code_maps_to_422_validation() -> None:
repo = FakeForeshadowRepo(raise_integrity_on={"F1"})
client, _, session = _make_client(repo=repo)
pid = uuid.uuid4()
async with client:
resp = await client.post(
f"/projects/{pid}/foreshadow", json={"code": "F1", "title": "重复"}
)
assert resp.status_code == 422
err = resp.json()["error"]
assert err["code"] == ErrorCode.VALIDATION
assert err["details"]["reason"] == "duplicate"
# 冲突后回滚、不提交。
assert session.commits == 0
assert session.rollbacks == 1
# ---- 状态变更 ----
@pytest.mark.asyncio
async def test_transition_open_to_partial_200() -> None:
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
await repo.register(pid, code="F1", title="t")
client, _, session = _make_client(repo=repo)
async with client:
resp = await client.patch(f"/projects/{pid}/foreshadow/F1", json={"to_status": "PARTIAL"})
assert resp.status_code == 200
assert resp.json()["status"] == "PARTIAL"
assert session.commits == 1
@pytest.mark.asyncio
async def test_invalid_transition_maps_to_422_validation() -> None:
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
await repo.register(pid, code="F1", title="t")
await repo.transition(pid, "F1", to_status="CLOSED") # 先到终态
client, _, session = _make_client(repo=repo)
async with client:
resp = await client.patch(
f"/projects/{pid}/foreshadow/F1",
json={"to_status": "PARTIAL"}, # CLOSED→PARTIAL 非法
)
assert resp.status_code == 422
err = resp.json()["error"]
assert err["code"] == ErrorCode.VALIDATION
assert err["details"]["reason"] == "invalid_transition"
assert session.rollbacks == 1
@pytest.mark.asyncio
async def test_transition_unknown_code_404() -> None:
client, _, _ = _make_client()
pid = uuid.uuid4()
async with client:
resp = await client.patch(f"/projects/{pid}/foreshadow/NOPE", json={"to_status": "PARTIAL"})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_record_progress_appends() -> None:
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
await repo.register(pid, code="F1", title="t")
client, _, session = _make_client(repo=repo)
async with client:
resp = await client.patch(
f"/projects/{pid}/foreshadow/F1",
json={"progress_entry": {"chapter_no": 5, "note": "首次照应"}},
)
assert resp.status_code == 200
assert resp.json()["progress"][0]["note"] == "首次照应"
assert session.commits == 1
@pytest.mark.asyncio
async def test_empty_update_maps_to_422() -> None:
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
await repo.register(pid, code="F1", title="t")
client, _, _ = _make_client(repo=repo)
async with client:
resp = await client.patch(f"/projects/{pid}/foreshadow/F1", json={})
assert resp.status_code == 422
assert resp.json()["error"]["details"]["reason"] == "empty_update"
# ---- 验收后到期扫描(纯函数直接 await不起后台线程----
@pytest.mark.asyncio
async def test_run_overdue_scan_marks_overdue_and_commits() -> None:
# Arrange一条 expected_close_to=5 的伏笔,验收章号 7 > 5 → 应置 OVERDUE。
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
await repo.register(pid, code="F1", title="逾期", expected_close_to=5)
await repo.register(pid, code="F2", title="窗口内", expected_close_to=9)
factory = FakeSessionFactory()
# Act经 repo_factory 缝注入 fake 账本 repo不联网/不真起后台线程)。
count = await run_overdue_scan(
factory,
project_id=pid,
chapter_no=7,
request_id="req-1",
repo_factory=lambda _session: repo,
)
# Assert
assert count == 1
assert repo.rows[(pid, "F1")].status == ForeshadowStatus.OVERDUE.value
assert repo.rows[(pid, "F2")].status == ForeshadowStatus.OPEN.value
# 自建 session 且有变更 → 提交一次。
assert len(factory.sessions) == 1
assert factory.sessions[0].commits == 1
@pytest.mark.asyncio
async def test_run_overdue_scan_no_change_skips_commit() -> None:
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
await repo.register(pid, code="F1", title="窗口内", expected_close_to=9)
factory = FakeSessionFactory()
count = await run_overdue_scan(
factory, project_id=pid, chapter_no=3, repo_factory=lambda _session: repo
)
assert count == 0
# 空扫描不提交。
assert factory.sessions[0].commits == 0

View File

@@ -0,0 +1,216 @@
"""T3.5 端点测试:大纲生成 + 持久化 + 伏笔看板(内存替身,无 DB/无网络)。
覆盖:
- POST /outlinemock 网关产 `OutlineResult` → 逐章 upsert含 foreshadow_windows→ 端点 commit
- 幂等:同 (project_id, chapter_no) 再生成覆盖;
- 项目不存在 → 404无凭据 → LLM_UNAVAILABLE503
- GET /foreshadow?status=:按状态过滤;非法 status → 422 VALIDATION。
"""
from __future__ import annotations
import os
import uuid
import httpx
import pytest
from fakes_projects import (
FakeForeshadowRepo,
FakeOutlineWriteRepo,
FakeProjectRepo,
FakeReviewGateway,
FakeSession,
)
from test_projects import _empty_memory_repos
from ww_agents import ForeshadowWindow, OutlineChapter, OutlineResult
from ww_core.domain.project_repo import ProjectCreate
from ww_shared import AppError, ErrorCode
def _outline_result() -> OutlineResult:
return OutlineResult(
chapters=[
OutlineChapter(
no=1,
beats=["开篇引入主角", "埋下信物伏笔"],
foreshadow_windows=[
ForeshadowWindow(code="F1", plant_chapter=1, expected_close_to=10)
],
),
OutlineChapter(no=2, beats=["冲突升级"], foreshadow_windows=[]),
]
)
def _make_client(
*,
project_repo: FakeProjectRepo | None = None,
foreshadow_repo: FakeForeshadowRepo | None = None,
outline_repo: FakeOutlineWriteRepo | None = None,
gateway: FakeReviewGateway | None = None,
session: FakeSession | None = None,
) -> tuple[httpx.AsyncClient, FakeOutlineWriteRepo, FakeSession]:
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_foreshadow_repo,
get_memory_repos,
get_outline_gateway,
get_outline_write_repo,
get_project_repo,
)
from ww_db import get_session
project_repo = project_repo or FakeProjectRepo()
foreshadow_repo = foreshadow_repo or FakeForeshadowRepo()
outline_repo = outline_repo or FakeOutlineWriteRepo()
gateway = gateway or FakeReviewGateway(parsed=_outline_result())
session = session or FakeSession()
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_foreshadow_repo] = lambda: foreshadow_repo
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
app.dependency_overrides[get_outline_write_repo] = lambda: outline_repo
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_outline_gateway] = lambda: gateway
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, outline_repo, session
async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
view = await repo.create(uuid.UUID(int=1), ProjectCreate(title="测试作品", genre="玄幻"))
return uuid.UUID(str(view.id))
# ---- 大纲生成 ----
@pytest.mark.asyncio
async def test_generate_outline_upserts_chapters_and_commits() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
client, outline_repo, session = _make_client(project_repo=project_repo)
async with client:
resp = await client.post(f"/projects/{pid}/outline", json={"volume": 1})
assert resp.status_code == 200
body = resp.json()
assert [c["no"] for c in body["chapters"]] == [1, 2]
ch1 = body["chapters"][0]
assert ch1["beats"] == ["开篇引入主角", "埋下信物伏笔"]
assert ch1["foreshadow_windows"][0]["code"] == "F1"
assert ch1["foreshadow_windows"][0]["expected_close_to"] == 10
# 逐章 upsert2 章)+ 端点末尾一次 commit。
assert outline_repo.upsert_calls == 2
assert (pid, 1) in outline_repo.rows
assert outline_repo.rows[(pid, 1)][1] == ["开篇引入主角", "埋下信物伏笔"]
assert session.commits == 1
@pytest.mark.asyncio
async def test_generate_outline_is_idempotent_overwrites_same_chapter() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
outline_repo = FakeOutlineWriteRepo()
client, _, _ = _make_client(project_repo=project_repo, outline_repo=outline_repo)
async with client:
await client.post(f"/projects/{pid}/outline", json={"volume": 1})
await client.post(f"/projects/{pid}/outline", json={"volume": 1})
# 同 (project_id, chapter_no) 覆盖,不堆叠 → 仍只 2 行。
assert len(outline_repo.rows) == 2
@pytest.mark.asyncio
async def test_generate_outline_unknown_project_404() -> None:
client, _, _ = _make_client()
async with client:
resp = await client.post(f"/projects/{uuid.uuid4()}/outline", json={})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_generate_outline_without_credentials_maps_to_llm_unavailable() -> None:
# 无凭据时 `build_gateway_for_tier` 抛 LLM_UNAVAILABLE503在网关 dep 解析阶段发生。
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_foreshadow_repo,
get_memory_repos,
get_outline_gateway,
get_outline_write_repo,
get_project_repo,
)
from ww_db import get_session
async def _no_creds() -> object:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_foreshadow_repo] = lambda: FakeForeshadowRepo()
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
app.dependency_overrides[get_outline_write_repo] = lambda: FakeOutlineWriteRepo()
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_outline_gateway] = _no_creds
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(f"/projects/{pid}/outline", json={})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
# 网关阶段失败 → 未生成、未提交。
assert session.commits == 0
@pytest.mark.asyncio
async def test_generate_outline_upper_error_propagates() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
gateway = FakeReviewGateway(error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
client, _, session = _make_client(project_repo=project_repo, gateway=gateway)
async with client:
resp = await client.post(f"/projects/{pid}/outline", json={})
assert resp.status_code == 503
assert session.commits == 0
# ---- 伏笔看板 ----
@pytest.mark.asyncio
async def test_foreshadow_board_filters_by_status() -> None:
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
await repo.register(pid, code="F1", title="开放伏笔")
await repo.register(pid, code="F2", title="部分回收")
await repo.transition(pid, "F2", to_status="PARTIAL")
from ww_api.main import create_app
from ww_api.services.project_deps import get_foreshadow_repo
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
app = create_app()
app.dependency_overrides[get_foreshadow_repo] = lambda: repo
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
all_resp = await client.get(f"/projects/{pid}/foreshadow")
open_resp = await client.get(f"/projects/{pid}/foreshadow", params={"status": "OPEN"})
bad_resp = await client.get(f"/projects/{pid}/foreshadow", params={"status": "NOPE"})
assert {f["code"] for f in all_resp.json()["foreshadow"]} == {"F1", "F2"}
open_codes = [f["code"] for f in open_resp.json()["foreshadow"]]
assert open_codes == ["F1"]
assert bad_resp.status_code == 422
assert bad_resp.json()["error"]["code"] == ErrorCode.VALIDATION
assert bad_resp.json()["error"]["details"]["reason"] == "invalid_status"

View File

@@ -0,0 +1,63 @@
"""T3.5 大纲注入上下文组装单测确定性纯函数ARCH §5.4)。
断言:四源(设定/伏笔/人物/世界观)都进文本、排序确定(同输入同输出)、空源不炸。
"""
from __future__ import annotations
from ww_api.services.outline_context import build_outline_context
from ww_core.domain import ForeshadowLedgerView
from ww_core.domain.project_repo import ProjectView
from ww_core.domain.repositories import CharacterView, WorldEntityView
def _project() -> ProjectView:
import uuid
return ProjectView(
id=uuid.UUID(int=1),
title="逆天剑帝",
genre="玄幻",
logline="少年逆袭",
selling_points=["爽点密集", "升级流"],
)
def test_build_context_includes_all_four_sources() -> None:
text = build_outline_context(
project=_project(),
foreshadow=[
ForeshadowLedgerView(
code="F1", title="神秘信物", status="OPEN", planted_at=3, expected_close_to=10
)
],
characters=[CharacterView(name="主角", role="protagonist", motive="复仇")],
world_entities=[WorldEntityView(type="location", name="剑冢")],
)
assert "逆天剑帝" in text
assert "F1" in text and "神秘信物" in text
assert "回收窗口 ?-10" in text
assert "主角" in text
assert "剑冢" in text
def test_build_context_is_deterministic_and_sorted() -> None:
project = _project()
fs = [
ForeshadowLedgerView(code="F2", title="b", status="OPEN"),
ForeshadowLedgerView(code="F1", title="a", status="OPEN"),
]
a = build_outline_context(project=project, foreshadow=fs, characters=[], world_entities=[])
b = build_outline_context(
project=project, foreshadow=list(reversed(fs)), characters=[], world_entities=[]
)
assert a == b
# F1 排在 F2 前(按 code 升序)。
assert a.index("[F1]") < a.index("[F2]")
def test_build_context_handles_empty_sources() -> None:
text = build_outline_context(
project=_project(), foreshadow=[], characters=[], world_entities=[]
)
assert "(无)" in text

View File

@@ -17,6 +17,7 @@ from fakes_projects import (
FakeReviewGateway,
FakeReviewRepo,
FakeSession,
FakeSessionFactory,
)
from test_projects import _empty_memory_repos
from ww_agents import ContinuityReview
@@ -50,6 +51,7 @@ def _make_client(
get_memory_repos,
get_review_gateway,
get_review_repo,
get_session_factory,
)
from ww_db import get_session
@@ -68,6 +70,7 @@ def _make_client(
app.dependency_overrides[get_review_gateway] = lambda: review_gateway
app.dependency_overrides[get_digest_gateway] = lambda: digest_gateway
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory()
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, chapter_repo, review_repo, digest_repo, session

View File

@@ -12,7 +12,14 @@ from ww_shared import AppError, ErrorBody, ErrorEnvelope
from ww_api.logging_config import configure_logging, get_logger
from ww_api.middleware import request_id_middleware
from ww_api.routers import health, jobs, projects, settings_providers
from ww_api.routers import (
foreshadow,
health,
jobs,
outline,
projects,
settings_providers,
)
from ww_api.services.project_deps import seed_stub_user
configure_logging()
@@ -48,6 +55,8 @@ def create_app() -> FastAPI:
app.include_router(health.router)
app.include_router(jobs.router)
app.include_router(projects.router)
app.include_router(foreshadow.router)
app.include_router(outline.router)
app.include_router(settings_providers.router)
return app

View File

@@ -0,0 +1,159 @@
"""伏笔登记 / 状态变更端点C3 扩 / ARCH §6.2, §7.2;不变量 #3
- POST /projects/:id/foreshadow 作者显式登记一条伏笔status=OPEN
- PATCH /projects/:id/foreshadow/:code 状态机转移 和/或 追加进展。
登记/改状态是**作者显式动作**(不变量 #3伏笔入库不经 AI 静默写库;验收后到期扫描
是确定性纯函数置位,见 `services/foreshadow_scan.py`)。
提交边界:`ForeshadowLedgerRepo` 写方法只 `flush()`,端点写后 `await session.commit()`。
**T3.5 续接点**:本 router 是 foreshadow 端点的归属处。T3.5 的看板查询
`GET /projects/:id/foreshadow?status=` 直接加到本 router调 `repo.list_by_status`
大纲 `POST /projects/:id/outline` 与伏笔无关,建议落独立 `routers/outline.py`
(或 projects router不混进本文件。
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain import ForeshadowLedgerRepo, ForeshadowLedgerView
from ww_core.domain.foreshadow_state import ForeshadowStatus, InvalidTransition
from ww_db import get_session
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.schemas.foreshadow import (
ForeshadowBoardResponse,
ForeshadowRegisterRequest,
ForeshadowTransitionRequest,
ForeshadowView,
)
from ww_api.services.project_deps import get_foreshadow_repo
log = get_logger("ww.api.foreshadow")
router = APIRouter(prefix="/projects", tags=["foreshadow"])
ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
def _to_view(v: ForeshadowLedgerView) -> ForeshadowView:
# ForeshadowLedgerView 与 ForeshadowView 字段同名逐字段映射snake_case 契约)。
return ForeshadowView.model_validate(v, from_attributes=True)
@router.post("/{project_id}/foreshadow", status_code=201)
async def register_foreshadow(
project_id: uuid.UUID,
body: ForeshadowRegisterRequest,
request: Request,
repo: ForeshadowRepoDep,
session: SessionDep,
) -> ForeshadowView:
"""登记一条伏笔status=OPEN。重复 `code` → DB 唯一约束冲突 → VALIDATION 信封。"""
request_id = getattr(request.state, "request_id", None)
try:
view = await repo.register(
project_id,
code=body.code,
title=body.title,
planted_at=body.planted_at,
content=body.content,
expected_close_from=body.expected_close_from,
expected_close_to=body.expected_close_to,
importance=body.importance,
)
await session.commit()
except IntegrityError as exc:
# `(project_id, code)` 唯一约束冲突register 仅 INSERT、不 upsert见 foreshadow_repo
await session.rollback()
raise AppError(
ErrorCode.VALIDATION,
f"伏笔代号已存在:{body.code}",
{"field": "code", "code": body.code, "reason": "duplicate"},
) from exc
log.info(
"foreshadow_registered",
project_id=str(project_id),
request_id=request_id,
code=body.code,
)
return _to_view(view)
@router.get("/{project_id}/foreshadow")
async def list_foreshadow(
project_id: uuid.UUID,
repo: ForeshadowRepoDep,
status: str | None = None,
) -> ForeshadowBoardResponse:
"""伏笔看板:按 `status` 过滤(缺省=全部),按 code 升序。
`status` 非法(不在 OPEN/PARTIAL/CLOSED/OVERDUE→ VALIDATION 信封。四泳道前端
据 `status` 分组OVERDUE 泳道 + 逾期标记用 `expected_close_to`(看板字段已齐)。
"""
if status is not None and status not in {s.value for s in ForeshadowStatus}:
raise AppError(
ErrorCode.VALIDATION,
f"非法状态过滤:{status}",
{"field": "status", "reason": "invalid_status"},
)
views = await repo.list_by_status(project_id, status)
return ForeshadowBoardResponse(foreshadow=[_to_view(v) for v in views])
@router.patch("/{project_id}/foreshadow/{code}")
async def update_foreshadow(
project_id: uuid.UUID,
code: str,
body: ForeshadowTransitionRequest,
request: Request,
repo: ForeshadowRepoDep,
session: SessionDep,
) -> ForeshadowView:
"""状态机转移 和/或 追加进展。非法转移 → VALIDATION 信封;不存在 → NOT_FOUND。
`to_status` 与 `progress_entry` 皆可选;两者都缺 → VALIDATION无操作。先转移、后追加。
"""
request_id = getattr(request.state, "request_id", None)
if body.to_status is None and body.progress_entry is None:
raise AppError(
ErrorCode.VALIDATION,
"至少提供 `to_status` 或 `progress_entry` 之一",
{"reason": "empty_update"},
)
view: ForeshadowLedgerView
try:
if body.to_status is not None:
view = await repo.transition(project_id, code, to_status=body.to_status)
if body.progress_entry is not None:
view = await repo.record_progress(project_id, code, entry=body.progress_entry)
await session.commit()
except InvalidTransition as exc:
await session.rollback()
raise AppError(
ErrorCode.VALIDATION,
str(exc),
{"field": "to_status", "code": code, "reason": "invalid_transition"},
) from exc
except LookupError as exc:
await session.rollback()
raise AppError(ErrorCode.NOT_FOUND, f"foreshadow not found: {code}") from exc
log.info(
"foreshadow_updated",
project_id=str(project_id),
request_id=request_id,
code=code,
to_status=body.to_status,
has_progress=body.progress_entry is not None,
)
return _to_view(view)

View File

@@ -0,0 +1,135 @@
"""大纲生成端点C3 扩 / ARCH §5.4 outliner / §7.2 POST /outline不变量 #2/#3
POST /projects/:id/outline作者显式发起大纲生成 + 持久化。
流程:组确定性上下文(设定 + 已登记伏笔 + 人物 + 世界观)→ `run_outline`analyst 网关,
结构化 `OutlineResult`)→ 逐章 upsert `outline` 表 → 端点末尾一次 `commit()`。
提交边界:网关 ledger 只 flushrun_outline 产 1 条 usage、`OutlineWriteRepo.upsert_chapter`
只 flush → 端点末尾 `await session.commit()`(否则 usage_ledger + outline 行静默丢失,见 gotcha
不变量 #2经 `build_gateway_for_tier(..., "analyst")` 注入outliner_spec 只声明档位不传 model。
不变量 #3outliner `writes=["outline"]` 经此**作者发起**端点落地,非 AI 静默写其它表;
run_outline 节点本身只读不写库。无凭据 → `LLM_UNAVAILABLE`(友好提示,仿 draft/review
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import outliner_spec
from ww_core.domain import ForeshadowLedgerRepo, OutlineWriteRepo, ProjectRepo
from ww_core.domain.repositories import MemoryRepos
from ww_core.orchestrator import run_outline
from ww_db import get_session
from ww_llm_gateway import Gateway
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.schemas.outline import (
ForeshadowWindowView,
OutlineChapterView,
OutlineGenerateRequest,
OutlineResponse,
)
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.outline_context import build_outline_context
from ww_api.services.project_deps import (
get_foreshadow_repo,
get_memory_repos,
get_outline_gateway,
get_outline_write_repo,
get_project_repo,
)
log = get_logger("ww.api.outline")
router = APIRouter(prefix="/projects", tags=["outline"])
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)]
OutlineGatewayDep = Annotated[Gateway, Depends(get_outline_gateway)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@router.post("/{project_id}/outline")
async def generate_outline(
project_id: uuid.UUID,
body: OutlineGenerateRequest,
request: Request,
project_repo: ProjectRepoDep,
foreshadow_repo: ForeshadowRepoDep,
memory_repos: MemoryReposDep,
outline_repo: OutlineWriteRepoDep,
gateway: OutlineGatewayDep,
session: SessionDep,
) -> OutlineResponse:
"""生成大纲并逐章持久化。无凭据 → LLM_UNAVAILABLE项目不存在 → NOT_FOUND。"""
request_id = getattr(request.state, "request_id", None)
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
foreshadow = await foreshadow_repo.list_by_status(project_id, None)
characters = await memory_repos.character.list_for_project(project_id)
world_entities = await memory_repos.world_entity.list_for_project(project_id)
context = build_outline_context(
project=project,
foreshadow=foreshadow,
characters=characters,
world_entities=world_entities,
)
log.info(
"outline_generate_start",
project_id=str(project_id),
request_id=request_id,
volume=body.volume,
context_len=len(context),
foreshadow_count=len(foreshadow),
)
# run_outline 产结构化大纲analyst 网关;网关失败上抛,无凭据 → LLM_UNAVAILABLE
result = await run_outline(
outliner_spec,
context=context,
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
chapters: list[OutlineChapterView] = []
for chapter in result.chapters:
windows = [w.model_dump() for w in chapter.foreshadow_windows]
await outline_repo.upsert_chapter(
project_id,
volume=body.volume,
chapter_no=chapter.no,
beats=chapter.beats,
foreshadow_windows=windows,
)
chapters.append(
OutlineChapterView(
no=chapter.no,
volume=body.volume,
beats=list(chapter.beats),
foreshadow_windows=[ForeshadowWindowView(**w) for w in windows],
)
)
# 提交边界:网关 ledger + outline upsert 均只 flush → 端点末尾一次 commit。
await session.commit()
log.info(
"outline_generate_done",
project_id=str(project_id),
request_id=request_id,
chapter_count=len(chapters),
)
return OutlineResponse(chapters=chapters)

View File

@@ -17,7 +17,7 @@ import uuid
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain.chapter_repo import ChapterRepo
@@ -59,6 +59,7 @@ from ww_api.services.accept_service import (
)
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.digest_extraction import extract_digest_facts
from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan
from ww_api.services.project_deps import (
get_chapter_repo,
get_digest_append_repo,
@@ -67,6 +68,7 @@ from ww_api.services.project_deps import (
get_project_repo,
get_review_gateway,
get_review_repo,
get_session_factory,
get_writer_gateway,
)
@@ -302,14 +304,23 @@ async def accept_chapter(
chapter_no: int,
body: AcceptRequest,
request: Request,
background_tasks: BackgroundTasks,
chapter_repo: ChapterRepoDep,
digest_repo: DigestRepoDep,
review_repo: ReviewRepoDep,
gateway: DigestGatewayDep,
session: Annotated[AsyncSession, Depends(get_session)],
session_factory: Annotated[SessionFactory, Depends(get_session_factory)],
) -> AcceptResponse:
"""验收事务 + 冲突 gate§5.5gate事务前→ 终稿提炼 digest事务外R2
单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit
单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit → 登记**验收后伏笔到期扫描**
BackgroundTaskM3-b/d§6.2)。
到期扫描接 §5.5 步骤 3 的 `TODO(M3)` 占位:验收提交成功**后**,把
`current_ch > expected_close_to AND status≠CLOSED` 的伏笔确定性置 OVERDUE。
伏笔登记/回收PARTIAL/CLOSED的「采纳建议」经作者经 foreshadow 端点显式确认,
不在验收事务里 AI 静默改库(不变量 #3。**§7.4 持久性局限**BackgroundTask 进程内
跑、重启丢失——原型可接受(见 `services/foreshadow_scan.py`,不引入 jobs 表)。
"""
request_id = getattr(request.state, "request_id", None)
# R3审稿真相从领域表重读最近一条 chapter_reviews不依赖 checkpoint。
@@ -350,6 +361,17 @@ async def accept_chapter(
latest_review=latest_review,
decisions=body.decisions,
)
# 验收**提交成功后**登记伏笔到期扫描 BackgroundTaskM3-b/d。current_chapter=刚验收章号;
# 任务在请求 session 关闭后跑,故 run_overdue_scan 自建新 session 并 commit§7.4 局限可接受)。
background_tasks.add_task(
run_overdue_scan,
session_factory,
project_id=project_id,
chapter_no=chapter_no,
request_id=request_id,
)
return AcceptResponse(
project_id=project_id,
chapter_no=chapter_no,

View File

@@ -0,0 +1,66 @@
"""伏笔登记 / 状态变更端点的请求/响应 schemaC3 扩 / ARCH §6.2, §7.2)。
snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
登记是**作者显式动作**(不变量 #3伏笔入库不经 AI 静默写库),到期扫描是验收后的
确定性纯函数置位M3-d二者都不在审稿/写章流水线里。
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class ForeshadowRegisterRequest(BaseModel):
"""POST /projects/:id/foreshadow作者显式登记一条伏笔status 落 OPEN"""
code: str = Field(min_length=1, description="伏笔代号,`(project_id, code)` 唯一")
title: str = Field(min_length=1, description="伏笔标题/一句话描述")
planted_at: int | None = Field(default=None, description="埋设章号")
content: str | None = Field(default=None, description="伏笔正文/线索")
expected_close_from: int | None = Field(default=None, description="预期回收窗口起始章")
expected_close_to: int | None = Field(
default=None, description="预期回收窗口结束章(到期判据用)"
)
importance: str | None = Field(default=None, description="重要度(自由文本,看板用)")
class ForeshadowTransitionRequest(BaseModel):
"""PATCH /projects/:id/foreshadow/:code状态机转移 和/或 追加一条进展。
两字段皆可选:`to_status` 走状态机(非法 → VALIDATION 信封);`progress_entry`
append-only 追加到 progress JSONB。二者可同时给先转移、后追加进展
"""
to_status: str | None = Field(
default=None, description="目标状态OPEN/PARTIAL/CLOSED/OVERDUE"
)
progress_entry: dict[str, Any] | None = Field(
default=None, description="追加一条进展记录append-only不覆盖历史"
)
class ForeshadowView(BaseModel):
"""伏笔账本视图(登记/状态变更/看板共用snake_case。形对齐 `ForeshadowLedgerView`。"""
code: str
title: str
status: str
planted_at: int | None = None
content: str | None = None
expected_close_from: int | None = None
expected_close_to: int | None = None
importance: str | None = None
links: list[dict[str, Any]] = Field(default_factory=list)
progress: list[dict[str, Any]] = Field(default_factory=list)
class ForeshadowBoardResponse(BaseModel):
"""GET /projects/:id/foreshadow?status=:伏笔看板(四泳道 + OVERDUE 字段齐)。
`status` 缺省返回全部;按 `code` 升序repo `list_by_status` 已排序)。前端按
`status` 分四泳道OPEN/PARTIAL/CLOSED/OVERDUE用 `expected_close_to` 标逾期。
"""
foreshadow: list[ForeshadowView] = Field(default_factory=list)

View File

@@ -0,0 +1,43 @@
"""大纲生成端点的请求/响应 schemaC3 扩 / ARCH §5.4, §7.2)。
snake_case前端经 OpenAPI 生成 TS 类型消费(改字段 → 前端 `pnpm gen:api`)。
大纲生成是**作者显式动作**(不变量 #3outliner `writes=["outline"]` 声明经此端点落地,
非 AI 静默写其它表)。窗口字段供前端 T3.6 渲染伏笔窗口徽标。
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class OutlineGenerateRequest(BaseModel):
"""POST /projects/:id/outline触发大纲生成 + 持久化。
M3 简单分卷:`volume` 缺省=1schema 无逐章卷号;本端点把整批章节落到同一卷)。
"""
volume: int = Field(default=1, ge=1, description="本批大纲所属卷号M3 默认全卷 1")
class ForeshadowWindowView(BaseModel):
"""单章关联的伏笔回收窗口snake_case形对齐 `ww_agents.ForeshadowWindow`)。"""
code: str
plant_chapter: int | None = None
expected_close_from: int | None = None
expected_close_to: int | None = None
class OutlineChapterView(BaseModel):
"""单章大纲视图:章号 + 卷 + 节拍 + 伏笔窗口。"""
no: int
volume: int
beats: list[str] = Field(default_factory=list)
foreshadow_windows: list[ForeshadowWindowView] = Field(default_factory=list)
class OutlineResponse(BaseModel):
"""大纲生成响应:逐章已持久化的大纲(含窗口,供前端 T3.6)。"""
chapters: list[OutlineChapterView] = Field(default_factory=list)

View File

@@ -114,9 +114,11 @@ async def run_accept_transaction(
)
review_id = updated.id
# 步骤 3(占位):人物 latest_state / 伏笔状态更新——M3 才正式接伏笔表
# 这里按 §5.5 步骤 3 留占位,不引入 M3 表逻辑(避免越界写未就绪的状态机)。
# TODO(M3): 按裁决应用 latest_state 变更 + 伏笔登记/到期扫描§6.2)。
# 步骤 3:伏笔到期扫描——验收提交**后**经端点登记的 BackgroundTask 跑M3-b/d
# `services/foreshadow_scan.run_overdue_scan`current_ch > expected_close_to 且未 CLOSED
# → 置 OVERDUE确定性纯函数非 AI不变量 #3。放在事务外/提交后是因为它是验收**结果**
# 的副作用、且自建独立 session请求 session 此时已关闭)。
# 人物 latest_state 更新仍留后续M4+):本事务只落晋升 + digest + 裁决留痕。
await session.commit() # type: ignore[attr-defined] # AsyncSession.commit()fake 同形)

View File

@@ -0,0 +1,90 @@
"""验收后伏笔到期扫描M3-b/dARCH §6.2, §7.4,不变量 #3
接 §5.5 验收事务后的 `TODO(M3)` 占位:验收**提交成功后**触发一次伏笔到期扫描,把
`current_ch > expected_close_to AND status≠CLOSED` 的伏笔确定性置 `OVERDUE`M3-d 纯
函数,非 Agent不变量 #3状态变更是确定性扫描而非 AI 静默写库)。
挂载方式端点FastAPI `BackgroundTasks` 在 accept 端点登记 `run_overdue_scan`。
BackgroundTask 在请求-response 发回、请求 session 关闭**之后**才跑——故本函数**自建新
session**(不复用请求 session并自己 `commit()`。
**持久性局限§7.4**BackgroundTasks 在 API 进程内跑,进程重启/崩溃会丢失未跑完的
任务——原型可接受,这里把触发/完成/失败都记结构化日志便于查错;**不引入 `jobs` 表**
(那是 M4/T4.1 的工作,本任务不越界)。
可测性:扫描逻辑抽成纯 async 函数 `run_overdue_scan(session_factory, ...)`,端点用
BackgroundTasks 调它;单测直接 `await` 它(注入 fake session 工厂)断言行被置 OVERDUE
**绝不真起不可控后台线程**。
"""
from __future__ import annotations
import uuid
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from typing import Protocol
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain import ForeshadowLedgerView, SqlForeshadowLedgerRepo
log = structlog.get_logger(__name__)
# 新建独立 session 的工厂:调用得到一个 async 上下文管理器(`async with` → AsyncSession
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
class OverdueScanRepo(Protocol):
"""到期扫描对账本 repo 的最小依赖(仅 `scan_overdue`)。"""
async def scan_overdue(
self, project_id: uuid.UUID, *, current_chapter: int
) -> list[ForeshadowLedgerView]: ...
# repo 工厂:从新 session 造账本 repo。默认建 SQL 实现;测试注入 fake避免真连 DB
RepoFactory = Callable[[AsyncSession], OverdueScanRepo]
def _default_repo_factory(session: AsyncSession) -> OverdueScanRepo:
return SqlForeshadowLedgerRepo(session)
async def run_overdue_scan(
session_factory: SessionFactory,
*,
project_id: uuid.UUID,
chapter_no: int,
request_id: str | None = None,
repo_factory: RepoFactory = _default_repo_factory,
) -> int:
"""验收后伏笔到期扫描:新建 session → `scan_overdue` 置 OVERDUE → `commit()`。
返回被置 OVERDUE 的伏笔条数(供日志/测试断言)。任何异常被捕获并记错误日志后
吞掉——后台任务失败不应冒泡崩进程也不影响已成功的验收事务§7.4 局限可接受)。
`repo_factory` 是可注入缝:默认建 SQL 账本 repo测试注 fake直接 await、不联网
"""
try:
async with session_factory() as session:
repo = repo_factory(session)
changed = await repo.scan_overdue(project_id, current_chapter=chapter_no)
if changed:
await session.commit()
log.info(
"foreshadow_overdue_scan",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
overdue_count=len(changed),
overdue_codes=[v.code for v in changed],
)
return len(changed)
except Exception as exc: # noqa: BLE001 — 后台任务边界记错误、不冒泡崩进程§7.4)。
log.error(
"foreshadow_overdue_scan_failed",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
error=str(exc),
)
return 0

View File

@@ -0,0 +1,87 @@
"""大纲生成的注入上下文组装确定性序列化文本ARCH §5.4 outliner reads / §6.2)。
把 projects 设定 + 已登记伏笔 + 人物 + 世界观确定性序列化成纯文本喂给 outliner——
**无时间戳/无 UUID**(缓存前缀稳定,不变量 #9逐项排序保证同输入同输出可单测
outliner 据此排出分章节拍 + 伏笔回收窗口关联本章与伏笔§6.2)。
reads=["projects","foreshadow","characters","world_entities"]C6 outliner_spec
本组装恰好覆盖这四源,不读 digest/outline排大纲是从设定生成、非续写既有章
"""
from __future__ import annotations
from collections.abc import Sequence
from ww_core.domain import ForeshadowLedgerView, ProjectView
from ww_core.domain.repositories import CharacterView, WorldEntityView
def _project_block(project: ProjectView) -> str:
lines = [f"标题:{project.title}"]
if project.genre:
lines.append(f"类型:{project.genre}")
if project.logline:
lines.append(f"一句话简介:{project.logline}")
if project.premise:
lines.append(f"前提:{project.premise}")
if project.theme:
lines.append(f"主题:{project.theme}")
if project.selling_points:
lines.append("卖点:" + "".join(project.selling_points))
if project.structure:
lines.append(f"结构:{project.structure}")
return "【作品设定】\n" + "\n".join(lines)
def _foreshadow_block(foreshadow: Sequence[ForeshadowLedgerView]) -> str:
if not foreshadow:
return "【已登记伏笔】\n(无)"
rows = sorted(foreshadow, key=lambda f: f.code)
lines = []
for f in rows:
window = ""
if f.expected_close_from is not None or f.expected_close_to is not None:
lo = f.expected_close_from if f.expected_close_from is not None else "?"
hi = f.expected_close_to if f.expected_close_to is not None else "?"
window = f"(回收窗口 {lo}-{hi}"
plant = f"埋设第{f.planted_at}" if f.planted_at is not None else ""
lines.append(f"- [{f.code}] {f.title} {plant}{window}".rstrip())
return "【已登记伏笔】\n" + "\n".join(lines)
def _characters_block(characters: Sequence[CharacterView]) -> str:
if not characters:
return "【人物】\n(无)"
rows = sorted(characters, key=lambda c: c.name)
lines = []
for c in rows:
role = f"{c.role}" if c.role else ""
motive = f" 动机:{c.motive}" if c.motive else ""
lines.append(f"- {c.name}{role}{motive}".rstrip())
return "【人物】\n" + "\n".join(lines)
def _world_block(entities: Sequence[WorldEntityView]) -> str:
if not entities:
return "【世界观】\n(无)"
rows = sorted(entities, key=lambda e: (e.type, e.name))
lines = [f"- [{e.type}] {e.name}" for e in rows]
return "【世界观】\n" + "\n".join(lines)
def build_outline_context(
*,
project: ProjectView,
foreshadow: Sequence[ForeshadowLedgerView],
characters: Sequence[CharacterView],
world_entities: Sequence[WorldEntityView],
) -> str:
"""组装 outliner 注入上下文(确定性文本,纯函数)。"""
return "\n\n".join(
[
_project_block(project),
_foreshadow_block(foreshadow),
_characters_block(characters),
_world_block(world_entities),
]
)

View File

@@ -16,13 +16,15 @@ from openai import AsyncOpenAI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
from ww_core.domain.outline_write_repo import OutlineWriteRepo, SqlOutlineWriteRepo
from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo
from ww_core.domain.repositories import MemoryRepos
from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo
from ww_core.memory.sql_repositories import sql_memory_repos
from ww_db import get_session
from ww_db import get_session, get_sessionmaker
from ww_db.models import User
from ww_llm_gateway import (
Gateway,
@@ -42,6 +44,7 @@ from ww_api.services.credentials import (
CredentialStore,
SqlCredentialStore,
)
from ww_api.services.foreshadow_scan import SessionFactory
from ww_api.services.provider_deps import _PROVIDER_BASE_URLS
# 单用户 stub 的占位邮箱(多租户化时由真实主体替换)。
@@ -95,6 +98,30 @@ def get_digest_append_repo(
return SqlDigestAppendRepo(session)
def get_foreshadow_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> ForeshadowLedgerRepo:
"""伏笔账本写侧 repo登记/状态变更端点;只 flush端点提交。测试经 override 注 fake。"""
return SqlForeshadowLedgerRepo(session)
def get_outline_write_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> OutlineWriteRepo:
"""大纲写侧 repo大纲生成端点逐章 upsert只 flush端点提交。测试经 override 注 fake。"""
return SqlOutlineWriteRepo(session)
def get_session_factory() -> SessionFactory:
"""验收后到期扫描的**独立 session 工厂**缝。
BackgroundTask 在请求 session 关闭后才跑,必须自建 session不复用 `get_session`)。
返回的工厂 `()` → `async with` 得一个新 `AsyncSession`。测试经 `app.dependency_overrides`
注入 fake 工厂(避免真起后台线程/真连 DB
"""
return get_sessionmaker()
async def build_gateway_for_tier(
session: AsyncSession, store: CredentialStore, tier: Tier
) -> Gateway:
@@ -160,3 +187,11 @@ async def get_digest_gateway(
"""验收终稿 digest 提炼light 档位)的可注入网关缝。测试经 override 注 mock。"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "light")
async def get_outline_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""大纲生成analyst 档位)的可注入网关缝。测试经 override 注 mock产 OutlineResult"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")

View File

@@ -0,0 +1,23 @@
import { notFound } from "next/navigation";
import { ForeshadowBoard } from "@/components/foreshadow/ForeshadowBoard";
import { fetchForeshadow, fetchProject } from "@/lib/api/server";
interface PageProps {
params: Promise<{ id: string }>;
}
// 伏笔看板页UX §6.8。Server Component 取项目 + 全量伏笔GET .../foreshadow
// ForeshadowBoardClient按 status 分四泳道并承载登记/状态变更交互。
export default async function ForeshadowPage({ params }: PageProps) {
const { id } = await params;
try {
const project = await fetchProject(id);
const board = await fetchForeshadow(id);
return (
<ForeshadowBoard project={project} initialItems={board.foreshadow ?? []} />
);
} catch {
notFound();
}
}

View File

@@ -0,0 +1,20 @@
import { notFound } from "next/navigation";
import { OutlineEditor } from "@/components/outline/OutlineEditor";
import { fetchProject } from "@/lib/api/server";
interface PageProps {
params: Promise<{ id: string }>;
}
// 大纲编辑器页UX §6.7。Server Component 取项目OutlineEditorClient按需
// 触发 POST .../outline 生成大纲M3 无 GET 大纲端点,进页空、生成后填充)。
export default async function OutlinePage({ params }: PageProps) {
const { id } = await params;
try {
const project = await fetchProject(id);
return <OutlineEditor project={project} initialChapters={[]} />;
} catch {
notFound();
}
}

View File

@@ -8,10 +8,20 @@ interface AppShellProps {
// 顶栏右侧的上下文信息(如作品名 / 进度)。
title?: string;
subtitle?: string;
// 项目内页给出 projectId → 左导航展开项目级入口(大纲/伏笔/审稿UX §3.1)。
projectId?: string;
// 当前激活的项目级入口键(用于高亮)。
activeNav?: "write" | "outline" | "foreshadow" | "review";
}
// 全局外壳:顶栏(64px) + 左导航 + 主区UX §5.1)。
export function AppShell({ children, title, subtitle }: AppShellProps) {
export function AppShell({
children,
title,
subtitle,
projectId,
activeNav,
}: AppShellProps) {
return (
<div className="min-h-screen">
<header className="flex h-16 items-center gap-4 border-b border-line bg-panel px-6">
@@ -38,7 +48,7 @@ export function AppShell({ children, title, subtitle }: AppShellProps) {
</nav>
</header>
<div className="flex">
<LeftNav />
<LeftNav projectId={projectId} activeNav={activeNav} />
<main className="min-h-[calc(100vh-4rem)] flex-1 bg-bg">{children}</main>
</div>
</div>

View File

@@ -3,37 +3,103 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
export type ActiveNav = "write" | "outline" | "foreshadow" | "review";
interface LeftNavProps {
// 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿UX §3.1)。
projectId?: string;
activeNav?: ActiveNav;
}
interface NavItem {
href: string;
label: string;
// M1 仅作品库 + 设置可达;其余为后续里程碑占位(禁用)。
enabled: boolean;
// 项目级入口的激活键(与 activeNav 比对高亮)。
key?: ActiveNav;
}
const NAV_ITEMS: NavItem[] = [
const GLOBAL_ITEMS: NavItem[] = [
{ href: "/", label: "作品库", enabled: true },
{ href: "/settings/providers", label: "设置", enabled: true },
{ href: "#outline", label: "大纲", enabled: false },
{ href: "#codex", label: "设定库", enabled: false },
{ href: "#foreshadow", label: "伏笔", enabled: false },
{ href: "#review", label: "审稿", enabled: false },
{ href: "#style", label: "文风", enabled: false },
];
// 左导航:当前项朱砂竖条 + 浅晕底UX §5.1)。
export function LeftNav() {
function projectItems(projectId: string): NavItem[] {
return [
{
href: `/projects/${projectId}/write`,
label: "写作",
enabled: true,
key: "write",
},
{
href: `/projects/${projectId}/outline`,
label: "大纲",
enabled: true,
key: "outline",
},
{
href: `/projects/${projectId}/foreshadow`,
label: "伏笔",
enabled: true,
key: "foreshadow",
},
{
href: `/projects/${projectId}/review`,
label: "审稿",
enabled: true,
key: "review",
},
{ href: "#codex", label: "设定库", enabled: false },
{ href: "#style", label: "文风", enabled: false },
];
}
// 左导航:当前项朱砂竖条 + 浅晕底UX §5.1 / §3.1)。
export function LeftNav({ projectId, activeNav }: LeftNavProps) {
const pathname = usePathname();
const scoped = projectId ? projectItems(projectId) : [];
return (
<nav
className="w-44 shrink-0 border-r border-line bg-panel py-4"
aria-label="主导航"
>
<ul className="flex flex-col gap-1 px-2">
{NAV_ITEMS.map((item) => {
const active = item.enabled && pathname === item.href;
{GLOBAL_ITEMS.map((item) => (
<NavLink
key={item.label}
item={item}
active={item.enabled && pathname === item.href}
/>
))}
{scoped.length > 0 ? (
<li
className="mt-3 px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-ink-soft/60"
aria-hidden="true"
>
</li>
) : null}
{scoped.map((item) => (
<NavLink
key={item.label}
item={item}
active={
item.key !== undefined &&
(item.key === activeNav || pathname === item.href)
}
/>
))}
</ul>
</nav>
);
}
function NavLink({ item, active }: { item: NavItem; active: boolean }) {
if (!item.enabled) {
return (
<li key={item.label}>
<li>
<span
className="block cursor-not-allowed rounded px-3 py-2 text-sm text-ink-soft/50"
aria-disabled="true"
@@ -45,7 +111,7 @@ export function LeftNav() {
);
}
return (
<li key={item.label}>
<li>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
@@ -59,8 +125,4 @@ export function LeftNav() {
</Link>
</li>
);
})}
</ul>
</nav>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useMemo } from "react";
import { AppShell } from "@/components/AppShell";
import type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
import {
LANES,
groupByStatus,
type ForeshadowStatus,
} from "@/lib/foreshadow/board";
import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
import { KanbanColumn } from "./KanbanColumn";
import { RegisterForm } from "./RegisterForm";
interface ForeshadowBoardProps {
project: ProjectResponse;
initialItems: ForeshadowView[];
}
// 伏笔看板主体UX §6.8):四泳道 + 登记/状态变更交互。
// RSC 取全量种入Client 承载登记/转移(乐观 + 回滚 + Toast
export function ForeshadowBoard({
project,
initialItems,
}: ForeshadowBoardProps) {
const { items, busy, register, transition } = useForeshadow(initialItems);
const lanes = useMemo(() => groupByStatus(items), [items]);
const onTransition = (
code: string,
toStatus: ForeshadowStatus | null,
progressNote: string,
): void => {
void transition(code, {
projectId: project.id,
toStatus,
progressEntry: progressNote ? { note: progressNote } : null,
});
};
return (
<AppShell
title={`${project.title}`}
subtitle="伏笔看板"
projectId={project.id}
activeNav="foreshadow"
>
<div className="flex h-[calc(100vh-4rem)] flex-col p-4">
<div className="mb-4 flex items-start justify-between gap-4">
<h1 className="font-serif text-lg text-ink"></h1>
<RegisterForm
projectId={project.id}
busy={busy}
onRegister={register}
/>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
{LANES.map((status) => (
<KanbanColumn
key={status}
status={status}
items={lanes[status]}
busy={busy}
onTransition={onTransition}
/>
))}
</div>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,133 @@
"use client";
import { useState } from "react";
import type { ForeshadowView } from "@/lib/api/types";
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
interface ForeshadowCardProps {
item: ForeshadowView;
overdue: boolean;
busy: boolean;
onTransition: (
code: string,
toStatus: ForeshadowStatus | null,
progressNote: string,
) => void;
}
const NEXT_STATUS: Record<string, ForeshadowStatus[]> = {
OPEN: ["PARTIAL", "CLOSED"],
PARTIAL: ["CLOSED"],
OVERDUE: ["PARTIAL", "CLOSED"],
CLOSED: [],
};
// 伏笔卡UX §6.8code/title/回收窗口/importance/progress 时间线 + 状态变更/加进展。
// OVERDUE 用琥珀强调(不单靠色:加 ⚠ 图标 + 文案a11y §10
export function ForeshadowCard({
item,
overdue,
busy,
onTransition,
}: ForeshadowCardProps) {
const [note, setNote] = useState("");
const transitions = NEXT_STATUS[item.status] ?? [];
const windowText = recoveryWindow(item);
return (
<li
className={`rounded border bg-panel p-3 ${
overdue ? "border-overdue ring-1 ring-overdue/40" : "border-line"
}`}
>
<div className="flex items-baseline gap-2">
<span className="font-mono text-xs text-cinnabar">{item.code}</span>
{overdue ? (
<span className="text-xs text-overdue" aria-label="逾期">
</span>
) : null}
</div>
<p className="mt-0.5 font-serif text-sm text-ink">{item.title}</p>
<dl className="mt-2 space-y-0.5 text-xs text-ink-soft">
{typeof item.planted_at === "number" ? (
<div> {item.planted_at} </div>
) : null}
{windowText ? <div> {windowText}</div> : null}
{item.importance ? <div>{item.importance}</div> : null}
</dl>
{item.progress && item.progress.length > 0 ? (
<ul className="mt-2 border-l-2 border-line pl-2 text-xs text-ink-soft">
{item.progress.map((p, i) => (
<li key={i}>· {progressLabel(p)}</li>
))}
</ul>
) : null}
{transitions.length > 0 || item.status !== "CLOSED" ? (
<div className="mt-3 space-y-2">
{transitions.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{transitions.map((to) => (
<button
key={to}
type="button"
disabled={busy}
onClick={() => onTransition(item.code, to, "")}
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar hover:text-cinnabar disabled:opacity-40"
>
{to === "CLOSED" ? "回收" : to}
</button>
))}
</div>
) : null}
{item.status !== "CLOSED" ? (
<div className="flex gap-1.5">
<label htmlFor={`prog-${item.code}`} className="sr-only">
{item.code}
</label>
<input
id={`prog-${item.code}`}
type="text"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="加进展23章发光"
className="min-w-0 flex-1 rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink focus:border-cinnabar focus:outline-none"
/>
<button
type="button"
disabled={busy || note.trim().length === 0}
onClick={() => {
onTransition(item.code, null, note.trim());
setNote("");
}}
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar disabled:opacity-40"
>
</button>
</div>
) : null}
</div>
) : null}
</li>
);
}
function recoveryWindow(item: ForeshadowView): string | null {
const from = item.expected_close_from;
const to = item.expected_close_to;
if (typeof from === "number" && typeof to === "number") return `${from}-${to}`;
if (typeof to === "number") return `${to}`;
if (typeof from === "number") return `${from}`;
return null;
}
function progressLabel(entry: Record<string, unknown>): string {
const chapter = entry["chapter"];
const note = entry["note"];
const noteStr = typeof note === "string" ? note : JSON.stringify(entry);
return typeof chapter === "number" ? `${chapter}章:${noteStr}` : noteStr;
}

View File

@@ -0,0 +1,61 @@
"use client";
import type { ForeshadowView } from "@/lib/api/types";
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
import { ForeshadowCard } from "./ForeshadowCard";
interface KanbanColumnProps {
status: ForeshadowStatus;
items: ForeshadowView[];
busy: boolean;
onTransition: (
code: string,
toStatus: ForeshadowStatus | null,
progressNote: string,
) => void;
}
// 单个状态泳道UX §6.8。OVERDUE 列整列琥珀强调(表头 + 卡片)。
export function KanbanColumn({
status,
items,
busy,
onTransition,
}: KanbanColumnProps) {
const overdue = status === "OVERDUE";
return (
<section
className="flex min-w-0 flex-col rounded border border-line bg-bg"
aria-label={`${status} 泳道`}
>
<header
className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${
overdue
? "border-overdue bg-overdue/10 text-overdue"
: "border-line bg-panel text-ink-soft"
}`}
>
<span className="font-mono text-xs font-semibold">
{overdue ? "⚠ " : ""}
{status}
</span>
<span className="font-mono text-xs">{items.length}</span>
</header>
<ul className="flex-1 space-y-2 overflow-auto p-2">
{items.length === 0 ? (
<li className="px-1 py-3 text-xs text-ink-soft/60"></li>
) : (
items.map((item) => (
<ForeshadowCard
key={item.code}
item={item}
overdue={overdue}
busy={busy}
onTransition={onTransition}
/>
))
)}
</ul>
</section>
);
}

View File

@@ -0,0 +1,185 @@
"use client";
import { useState } from "react";
import type { RegisterInput } from "@/lib/foreshadow/board";
interface RegisterFormProps {
projectId: string;
busy: boolean;
onRegister: (input: RegisterInput) => Promise<boolean>;
}
const EMPTY = {
code: "",
title: "",
plantedAt: "",
content: "",
expectedCloseFrom: "",
expectedCloseTo: "",
importance: "",
};
// 登记新伏笔UX §6.8)。受控表单 → 组装 RegisterInput成功后清空。
export function RegisterForm({
projectId,
busy,
onRegister,
}: RegisterFormProps) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ ...EMPTY });
const set = (key: keyof typeof EMPTY, value: string): void =>
setForm((prev) => ({ ...prev, [key]: value }));
const submit = async (): Promise<void> => {
const ok = await onRegister({
projectId,
code: form.code,
title: form.title,
plantedAt: toInt(form.plantedAt),
content: form.content || null,
expectedCloseFrom: toInt(form.expectedCloseFrom),
expectedCloseTo: toInt(form.expectedCloseTo),
importance: form.importance || null,
});
if (ok) {
setForm({ ...EMPTY });
setOpen(false);
}
};
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
>
</button>
);
}
const canSubmit =
form.code.trim().length > 0 && form.title.trim().length > 0 && !busy;
return (
<form
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
className="rounded border border-line bg-panel p-4"
>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<Field label="代号 *" id="f-code">
<input
id="f-code"
value={form.code}
onChange={(e) => set("code", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="标题 *" id="f-title" span2>
<input
id="f-title"
value={form.title}
onChange={(e) => set("title", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="埋设章" id="f-planted">
<input
id="f-planted"
inputMode="numeric"
value={form.plantedAt}
onChange={(e) => set("plantedAt", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="回收窗口起" id="f-from">
<input
id="f-from"
inputMode="numeric"
value={form.expectedCloseFrom}
onChange={(e) => set("expectedCloseFrom", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="回收窗口止" id="f-to">
<input
id="f-to"
inputMode="numeric"
value={form.expectedCloseTo}
onChange={(e) => set("expectedCloseTo", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="重要度" id="f-imp">
<input
id="f-imp"
value={form.importance}
onChange={(e) => set("importance", e.target.value)}
placeholder="主线/支线"
className={inputCls}
/>
</Field>
<Field label="线索内容" id="f-content" span3>
<input
id="f-content"
value={form.content}
onChange={(e) => set("content", e.target.value)}
className={inputCls}
/>
</Field>
</div>
<div className="mt-3 flex items-center gap-2">
<button
type="submit"
disabled={!canSubmit}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-40"
>
{busy ? "登记中…" : "登记"}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded border border-line px-3 py-1.5 text-sm text-ink-soft hover:border-cinnabar"
>
</button>
</div>
</form>
);
}
const inputCls =
"w-full rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none";
interface FieldProps {
label: string;
id: string;
span2?: boolean;
span3?: boolean;
children: React.ReactNode;
}
function Field({ label, id, span2, span3, children }: FieldProps) {
const span = span3 ? "col-span-2 sm:col-span-3" : span2 ? "col-span-2" : "";
return (
<div className={span}>
<label htmlFor={id} className="block text-xs text-ink-soft">
{label}
</label>
<div className="mt-0.5">{children}</div>
</div>
);
}
function toInt(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed.length === 0) return null;
const n = Number.parseInt(trimmed, 10);
return Number.isInteger(n) ? n : null;
}

View File

@@ -0,0 +1,51 @@
"use client";
import type { OutlineChapterView } from "@/lib/api/types";
import {
isCloseWindow,
windowBadgeLabel,
} from "@/lib/outline/outline";
interface OutlineChapterRowProps {
chapter: OutlineChapterView;
}
// 单章大纲行UX §6.7):章号 + beats + 伏笔窗口徽标(⚑)。
// 接近回收窗口的徽标用琥珀 + 「可回收」提示a11y图标 + 文案,不单靠色)。
export function OutlineChapterRow({ chapter }: OutlineChapterRowProps) {
const windows = chapter.foreshadow_windows ?? [];
return (
<li className="border-l-2 border-line py-2 pl-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-ink-soft">
{chapter.no}
</span>
{windows.map((w) => {
const close = isCloseWindow(chapter, w);
return (
<span
key={w.code}
title={close ? "进入回收窗口,建议本章安排回收" : undefined}
className={`rounded px-1.5 py-0.5 text-xs ${
close
? "bg-overdue/15 text-overdue"
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
}`}
>
{windowBadgeLabel(w)}
{close ? " · 可回收" : ""}
</span>
);
})}
</div>
{chapter.beats && chapter.beats.length > 0 ? (
<p className="mt-1 text-sm text-ink">
<span className="text-ink-soft">beats: </span>
{chapter.beats.join(" / ")}
</p>
) : (
<p className="mt-1 text-sm text-ink-soft/60"></p>
)}
</li>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types";
import { groupByVolume } from "@/lib/outline/outline";
import { useOutline } from "@/lib/outline/useOutline";
import { OutlineChapterRow } from "./OutlineChapterRow";
interface OutlineEditorProps {
project: ProjectResponse;
initialChapters: OutlineChapterView[];
}
// 大纲编辑器主体UX §6.7。RSC 种入已存大纲Client 承载生成POST .../outline
// 无凭据 → 503 LLM_UNAVAILABLE 经 error 检出、引导去设置。
export function OutlineEditor({
project,
initialChapters,
}: OutlineEditorProps) {
const { chapters, status, error, generate } = useOutline(initialChapters);
const [volume, setVolume] = useState(1);
const volumes = useMemo(() => groupByVolume(chapters), [chapters]);
const generating = status === "generating";
return (
<AppShell
title={`${project.title}`}
subtitle="大纲"
projectId={project.id}
activeNav="outline"
>
<div className="flex h-[calc(100vh-4rem)] flex-col p-6">
<div className="mb-4 flex items-center gap-3">
<h1 className="font-serif text-lg text-ink"></h1>
<label htmlFor="vol" className="ml-auto text-xs text-ink-soft">
</label>
<input
id="vol"
inputMode="numeric"
value={volume}
onChange={(e) => setVolume(Math.max(1, Number(e.target.value) || 1))}
className="w-16 rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none"
/>
<button
type="button"
disabled={generating}
onClick={() => void generate(project.id, volume)}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-40"
>
{generating ? "排大纲中…" : "✦ AI 排大纲"}
</button>
</div>
{error ? (
<p className="mb-4 text-sm text-conflict">
{error.code}{error.message}
{error.code === "LLM_UNAVAILABLE" ? (
<>
{" "}
<a
href="/settings/providers"
className="underline hover:text-cinnabar"
>
</a>
</>
) : null}
</p>
) : null}
<div className="min-h-0 flex-1 overflow-auto">
{volumes.length === 0 ? (
<p className="rounded border border-dashed border-line p-6 text-sm text-ink-soft">
AI
</p>
) : (
volumes.map((group) => (
<section key={group.volume} className="mb-6">
<h2 className="mb-2 font-serif text-base text-ink">
{group.volume}
</h2>
<ul className="space-y-1">
{group.chapters.map((ch) => (
<OutlineChapterRow key={ch.no} chapter={ch} />
))}
</ul>
</section>
))
)}
</div>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { toBeatCells, toSparkline } from "@/lib/pace/beatmap";
interface BeatMapProps {
beatMap: number[];
}
// 爽点节拍图UX §6.4beat_map(int 序列) → ▁▃▅ 柱状条。
// 渲染为定高柱CSS height%),同时给文本 sparkline 作无障碍标签a11y §10
// 静态高度,无动画 → 天然尊重 prefers-reduced-motion。
export function BeatMap({ beatMap }: BeatMapProps) {
const cells = toBeatCells(beatMap);
if (cells.length === 0) {
return <span className="text-xs text-ink-soft/60"></span>;
}
const spark = toSparkline(beatMap);
return (
<div
className="flex h-12 items-end gap-0.5"
role="img"
aria-label={`爽点节拍图 ${spark}`}
>
{cells.map((cell, i) => (
<div
key={i}
className="min-w-[4px] flex-1 rounded-t bg-cinnabar/70"
style={{ height: `${cell.heightPct}%` }}
title={`${i + 1}拍 强度${cell.value}`}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import type { ForeshadowSuggestion } from "@/lib/review/sse";
interface ForeshadowSuggestionsProps {
suggestions: ForeshadowSuggestion[];
// 审项是否未完成incomplete
incomplete: boolean;
}
// 伏笔建议区UX §6.4 ②planted「新埋待确认」/ resolved「疑似回收」只读建议。
// 不静默写库(不变量#3仅展示登记/回收经伏笔看板显式确认。
export function ForeshadowSuggestions({
suggestions,
incomplete,
}: ForeshadowSuggestionsProps) {
return (
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(foreshadow-analyst)
</h2>
{incomplete ? (
<p className="mt-1 text-xs text-overdue"> </p>
) : suggestions.length === 0 ? (
<p className="mt-1 text-xs text-ink-soft"></p>
) : (
<ul className="mt-2 space-y-2">
{suggestions.map((s, i) => (
<li
key={i}
className="rounded border border-line bg-panel p-2 text-xs"
>
<span
className={`mr-1 rounded px-1.5 py-0.5 ${
s.kind === "resolved"
? "bg-pass/15 text-pass"
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
}`}
>
{s.kind === "resolved" ? "⚑ 疑似回收" : " 新埋待确认"}
</span>
{s.code ? (
<span className="font-mono text-ink-soft"> {s.code}</span>
) : null}
<p className="mt-1 text-ink">{s.title}</p>
{s.where ? (
<span className="text-ink-soft">{s.where}</span>
) : null}
{s.note ? (
<p className="mt-0.5 text-ink-soft">{s.note}</p>
) : null}
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import type { PaceReport } from "@/lib/review/sse";
import { BeatMap } from "./BeatMap";
interface PacePanelProps {
pace: PaceReport | null;
incomplete: boolean;
}
// 节奏报告区UX §6.4 ④):节拍图 ▁▃▅ + 注水段列表 + 章末钩子徽标。只读建议。
export function PacePanel({ pace, incomplete }: PacePanelProps) {
return (
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(pace-checker)
</h2>
{incomplete ? (
<p className="mt-1 text-xs text-overdue"> </p>
) : pace === null ? (
<p className="mt-1 text-xs text-ink-soft"></p>
) : (
<div className="mt-2 space-y-2 text-xs">
<div>
<p className="mb-1 text-ink-soft"></p>
<BeatMap beatMap={pace.beat_map} />
</div>
<p>
{pace.hook ? (
<span className="text-pass"> </span>
) : (
<span className="text-overdue"> </span>
)}
</p>
{pace.water.length > 0 ? (
<div>
<p className="text-overdue"> </p>
<ul className="mt-1 space-y-1">
{pace.water.map((w, i) => (
<li key={i} className="text-ink-soft">
{w.where}{w.reason}
</li>
))}
</ul>
</div>
) : (
<p className="text-pass"> </p>
)}
</div>
)}
</section>
);
}

View File

@@ -13,13 +13,19 @@ import {
type DecisionDraft,
type Verdict,
} from "@/lib/review/decisions";
import { normalizeConflicts } from "@/lib/review/history";
import {
normalizeConflicts,
normalizeForeshadowSug,
normalizePace,
} from "@/lib/review/history";
import { useAccept } from "@/lib/review/useAccept";
import { useReviewStream } from "@/lib/review/useReviewStream";
import type { ReviewConflict } from "@/lib/review/sse";
import { AcceptPanel } from "./AcceptPanel";
import { AnnotatedText } from "./AnnotatedText";
import { ConflictCard } from "./ConflictCard";
import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
import { PacePanel } from "./PacePanel";
interface ReviewReportProps {
project: ProjectResponse;
@@ -48,19 +54,41 @@ export function ReviewReport({
const [missing, setMissing] = useState<Set<number>>(new Set());
const seededRef = useRef(false);
// 进页一次性把历史 conflicts 种入流状态(无需重审即可裁决)。
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
const seededConflicts = useMemo(
() => normalizeConflicts(initialReview),
[initialReview],
);
const seededForeshadow = useMemo(
() => normalizeForeshadowSug(initialReview),
[initialReview],
);
const seededPace = useMemo(
() => normalizePace(initialReview),
[initialReview],
);
useEffect(() => {
if (seededRef.current) return;
seededRef.current = true;
if (seededConflicts.length > 0) review.seed(seededConflicts);
}, [review, seededConflicts]);
const hasSeed =
seededConflicts.length > 0 ||
seededForeshadow.length > 0 ||
seededPace !== null;
if (hasSeed) {
review.seed({
conflicts: seededConflicts,
foreshadow: seededForeshadow,
pace: seededPace,
});
}
}, [review, seededConflicts, seededForeshadow, seededPace]);
// 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。
const conflicts: ReviewConflict[] = review.state.conflicts;
const foreshadow = review.state.foreshadow;
const pace = review.state.pace;
const sectionStatus = (name: string): string | undefined =>
review.state.sections.find((s) => s.name === name)?.status;
// 重审完成done 边沿)→ 重置裁决草稿对齐新冲突集(冲突可能整组变化)。
const conflictCount = conflicts.length;
@@ -127,6 +155,8 @@ export function ReviewReport({
<AppShell
title={`${project.title}`}
subtitle={`${chapterNo} 章 · 审稿报告`}
projectId={project.id}
activeNav="review"
>
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[1fr_24rem]">
{/* 左:终稿正文 + 就地标注 */}
@@ -240,6 +270,17 @@ export function ReviewReport({
)}
</section>
<div className="mt-4 border-t border-line pt-4">
<ForeshadowSuggestions
suggestions={foreshadow}
incomplete={sectionStatus("foreshadow") === "incomplete"}
/>
<PacePanel
pace={pace}
incomplete={sectionStatus("pace") === "incomplete"}
/>
</div>
<section className="mt-4">
<AcceptPanel
conflictCount={conflictCount}

View File

@@ -55,6 +55,8 @@ export function Workbench({ project }: WorkbenchProps) {
<AppShell
title={`${project.title}`}
subtitle={`${chapterNo}`}
projectId={project.id}
activeNav="write"
>
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[12rem_1fr_18rem]">
<ChapterList currentChapterNo={chapterNo} />
@@ -137,6 +139,18 @@ function Toolbar({
<span className="font-mono text-xs text-ink-soft">
{wordCount.toLocaleString()}
</span>
<Link
href={`/projects/${projectId}/outline`}
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
>
</Link>
<Link
href={`/projects/${projectId}/foreshadow`}
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
>
</Link>
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className="rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar hover:bg-[var(--color-cinnabar-wash)]"

View File

@@ -169,7 +169,14 @@ export interface paths {
/**
* Accept Chapter
* @description 验收事务 + 冲突 gate§5.5gate事务前→ 终稿提炼 digest事务外R2
* 单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit
* 单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit → 登记**验收后伏笔到期扫描**
* BackgroundTaskM3-b/d§6.2)。
*
* 到期扫描接 §5.5 步骤 3 的 `TODO(M3)` 占位:验收提交成功**后**,把
* `current_ch > expected_close_to AND status≠CLOSED` 的伏笔确定性置 OVERDUE。
* 伏笔登记/回收PARTIAL/CLOSED的「采纳建议」经作者经 foreshadow 端点显式确认,
* 不在验收事务里 AI 静默改库(不变量 #3。**§7.4 持久性局限**BackgroundTask 进程内
* 跑、重启丢失——原型可接受(见 `services/foreshadow_scan.py`,不引入 jobs 表)。
*/
post: operations["accept_chapter_projects__project_id__chapters__chapter_no__accept_post"];
delete?: never;
@@ -178,6 +185,75 @@ export interface paths {
patch?: never;
trace?: never;
};
"/projects/{project_id}/foreshadow": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Foreshadow
* @description 伏笔看板:按 `status` 过滤(缺省=全部),按 code 升序。
*
* `status` 非法(不在 OPEN/PARTIAL/CLOSED/OVERDUE→ VALIDATION 信封。四泳道前端
* 据 `status` 分组OVERDUE 泳道 + 逾期标记用 `expected_close_to`(看板字段已齐)。
*/
get: operations["list_foreshadow_projects__project_id__foreshadow_get"];
put?: never;
/**
* Register Foreshadow
* @description 登记一条伏笔status=OPEN。重复 `code` → DB 唯一约束冲突 → VALIDATION 信封。
*/
post: operations["register_foreshadow_projects__project_id__foreshadow_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/foreshadow/{code}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Update Foreshadow
* @description 状态机转移 和/或 追加进展。非法转移 → VALIDATION 信封;不存在 → NOT_FOUND。
*
* `to_status` 与 `progress_entry` 皆可选;两者都缺 → VALIDATION无操作。先转移、后追加。
*/
patch: operations["update_foreshadow_projects__project_id__foreshadow__code__patch"];
trace?: never;
};
"/projects/{project_id}/outline": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Generate Outline
* @description 生成大纲并逐章持久化。无凭据 → LLM_UNAVAILABLE项目不存在 → NOT_FOUND。
*/
post: operations["generate_outline_projects__project_id__outline_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/settings/providers": {
parameters: {
query?: never;
@@ -338,11 +414,164 @@ export interface components {
/** Text */
text: string;
};
/**
* ForeshadowBoardResponse
* @description GET /projects/:id/foreshadow?status=:伏笔看板(四泳道 + OVERDUE 字段齐)。
*
* `status` 缺省返回全部;按 `code` 升序repo `list_by_status` 已排序)。前端按
* `status` 分四泳道OPEN/PARTIAL/CLOSED/OVERDUE用 `expected_close_to` 标逾期。
*/
ForeshadowBoardResponse: {
/** Foreshadow */
foreshadow?: components["schemas"]["ForeshadowView"][];
};
/**
* ForeshadowRegisterRequest
* @description POST /projects/:id/foreshadow作者显式登记一条伏笔status 落 OPEN
*/
ForeshadowRegisterRequest: {
/**
* Code
* @description 伏笔代号,`(project_id, code)` 唯一
*/
code: string;
/**
* Title
* @description 伏笔标题/一句话描述
*/
title: string;
/**
* Planted At
* @description 埋设章号
*/
planted_at?: number | null;
/**
* Content
* @description 伏笔正文/线索
*/
content?: string | null;
/**
* Expected Close From
* @description 预期回收窗口起始章
*/
expected_close_from?: number | null;
/**
* Expected Close To
* @description 预期回收窗口结束章(到期判据用)
*/
expected_close_to?: number | null;
/**
* Importance
* @description 重要度(自由文本,看板用)
*/
importance?: string | null;
};
/**
* ForeshadowTransitionRequest
* @description PATCH /projects/:id/foreshadow/:code状态机转移 和/或 追加一条进展。
*
* 两字段皆可选:`to_status` 走状态机(非法 → VALIDATION 信封);`progress_entry`
* append-only 追加到 progress JSONB。二者可同时给先转移、后追加进展
*/
ForeshadowTransitionRequest: {
/**
* To Status
* @description 目标状态OPEN/PARTIAL/CLOSED/OVERDUE
*/
to_status?: string | null;
/**
* Progress Entry
* @description 追加一条进展记录append-only不覆盖历史
*/
progress_entry?: {
[key: string]: unknown;
} | null;
};
/**
* ForeshadowView
* @description 伏笔账本视图(登记/状态变更/看板共用snake_case。形对齐 `ForeshadowLedgerView`。
*/
ForeshadowView: {
/** Code */
code: string;
/** Title */
title: string;
/** Status */
status: string;
/** Planted At */
planted_at?: number | null;
/** Content */
content?: string | null;
/** Expected Close From */
expected_close_from?: number | null;
/** Expected Close To */
expected_close_to?: number | null;
/** Importance */
importance?: string | null;
/** Links */
links?: {
[key: string]: unknown;
}[];
/** Progress */
progress?: {
[key: string]: unknown;
}[];
};
/**
* ForeshadowWindowView
* @description 单章关联的伏笔回收窗口snake_case形对齐 `ww_agents.ForeshadowWindow`)。
*/
ForeshadowWindowView: {
/** Code */
code: string;
/** Plant Chapter */
plant_chapter?: number | null;
/** Expected Close From */
expected_close_from?: number | null;
/** Expected Close To */
expected_close_to?: number | null;
};
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/**
* OutlineChapterView
* @description 单章大纲视图:章号 + 卷 + 节拍 + 伏笔窗口。
*/
OutlineChapterView: {
/** No */
no: number;
/** Volume */
volume: number;
/** Beats */
beats?: string[];
/** Foreshadow Windows */
foreshadow_windows?: components["schemas"]["ForeshadowWindowView"][];
};
/**
* OutlineGenerateRequest
* @description POST /projects/:id/outline触发大纲生成 + 持久化。
*
* M3 简单分卷:`volume` 缺省=1schema 无逐章卷号;本端点把整批章节落到同一卷)。
*/
OutlineGenerateRequest: {
/**
* Volume
* @description 本批大纲所属卷号M3 默认全卷 1
* @default 1
*/
volume: number;
};
/**
* OutlineResponse
* @description 大纲生成响应:逐章已持久化的大纲(含窗口,供前端 T3.6)。
*/
OutlineResponse: {
/** Chapters */
chapters?: components["schemas"]["OutlineChapterView"][];
};
/**
* ProjectCreateRequest
* @description POST /projects立项向导字段owner_id 由后端补 stub不入参
@@ -898,6 +1127,145 @@ export interface operations {
};
};
};
list_foreshadow_projects__project_id__foreshadow_get: {
parameters: {
query?: {
status?: string | null;
};
header?: never;
path: {
project_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ForeshadowBoardResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
register_foreshadow_projects__project_id__foreshadow_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ForeshadowRegisterRequest"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ForeshadowView"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
update_foreshadow_projects__project_id__foreshadow__code__patch: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
code: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ForeshadowTransitionRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ForeshadowView"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
generate_outline_projects__project_id__outline_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["OutlineGenerateRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OutlineResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_providers_settings_providers_get: {
parameters: {
query?: never;

View File

@@ -1,5 +1,6 @@
import { serverApiBase } from "./config";
import type {
ForeshadowBoardResponse,
ProjectListResponse,
ProjectResponse,
ProvidersResponse,
@@ -35,3 +36,10 @@ export async function fetchReviews(
`/projects/${projectId}/chapters/${chapterNo}/reviews`,
);
}
// 伏笔看板(全量,前端按 status 分四泳道)。
export async function fetchForeshadow(
projectId: string,
): Promise<ForeshadowBoardResponse> {
return getJson<ForeshadowBoardResponse>(`/projects/${projectId}/foreshadow`);
}

View File

@@ -25,3 +25,18 @@ export type ReviewHistoryResponse =
export type ConflictDecision = components["schemas"]["ConflictDecision"];
export type AcceptRequest = components["schemas"]["AcceptRequest"];
export type AcceptResponse = components["schemas"]["AcceptResponse"];
// M3 伏笔 + 大纲C3 扩 T3.2/T3.5)。
export type ForeshadowView = components["schemas"]["ForeshadowView"];
export type ForeshadowBoardResponse =
components["schemas"]["ForeshadowBoardResponse"];
export type ForeshadowRegisterRequest =
components["schemas"]["ForeshadowRegisterRequest"];
export type ForeshadowTransitionRequest =
components["schemas"]["ForeshadowTransitionRequest"];
export type ForeshadowWindowView =
components["schemas"]["ForeshadowWindowView"];
export type OutlineChapterView = components["schemas"]["OutlineChapterView"];
export type OutlineGenerateRequest =
components["schemas"]["OutlineGenerateRequest"];
export type OutlineResponse = components["schemas"]["OutlineResponse"];

View File

@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import type { ForeshadowView } from "@/lib/api/types";
import {
buildRegisterRequest,
buildTransitionRequest,
extractReason,
groupByStatus,
isWindowApproaching,
validationReasonMessage,
} from "./board";
const view = (over: Partial<ForeshadowView>): ForeshadowView => ({
code: "F-001",
title: "线索",
status: "OPEN",
...over,
});
describe("groupByStatus", () => {
it("splits items into four lanes preserving order", () => {
const lanes = groupByStatus([
view({ code: "F-001", status: "OPEN" }),
view({ code: "F-002", status: "OVERDUE" }),
view({ code: "F-003", status: "OPEN" }),
view({ code: "F-004", status: "CLOSED" }),
view({ code: "F-005", status: "PARTIAL" }),
]);
expect(lanes.OPEN.map((v) => v.code)).toEqual(["F-001", "F-003"]);
expect(lanes.OVERDUE.map((v) => v.code)).toEqual(["F-002"]);
expect(lanes.CLOSED.map((v) => v.code)).toEqual(["F-004"]);
expect(lanes.PARTIAL.map((v) => v.code)).toEqual(["F-005"]);
});
it("falls back unknown status to OPEN and handles undefined", () => {
const lanes = groupByStatus([view({ status: "WEIRD" })]);
expect(lanes.OPEN).toHaveLength(1);
expect(groupByStatus(undefined).OPEN).toEqual([]);
});
});
describe("isWindowApproaching", () => {
it("is true when current chapter is inside [from,to] for OPEN/PARTIAL", () => {
const f = view({
status: "PARTIAL",
expected_close_from: 40,
expected_close_to: 60,
});
expect(isWindowApproaching(f, 50)).toBe(true);
expect(isWindowApproaching(f, 39)).toBe(false);
expect(isWindowApproaching(f, 61)).toBe(false);
});
it("never flags CLOSED/OVERDUE or items without a window", () => {
expect(
isWindowApproaching(
view({ status: "CLOSED", expected_close_to: 60 }),
50,
),
).toBe(false);
expect(isWindowApproaching(view({ status: "OPEN" }), 50)).toBe(false);
});
});
describe("buildRegisterRequest", () => {
it("trims and omits empty optionals", () => {
expect(
buildRegisterRequest({
projectId: "p1",
code: " F-007 ",
title: " 旧誓约 ",
content: " ",
expectedCloseTo: 110,
}),
).toEqual({ code: "F-007", title: "旧誓约", expected_close_to: 110 });
});
it("keeps numeric and non-empty string optionals", () => {
expect(
buildRegisterRequest({
projectId: "p1",
code: "F-1",
title: "t",
plantedAt: 8,
content: "线索",
expectedCloseFrom: 40,
importance: "主线",
}),
).toEqual({
code: "F-1",
title: "t",
planted_at: 8,
content: "线索",
expected_close_from: 40,
importance: "主线",
});
});
});
describe("buildTransitionRequest", () => {
it("includes only provided fields", () => {
expect(buildTransitionRequest({ projectId: "p1", toStatus: "PARTIAL" })).toEqual({
to_status: "PARTIAL",
});
expect(
buildTransitionRequest({
projectId: "p1",
progressEntry: { chapter: 23, note: "发光" },
}),
).toEqual({ progress_entry: { chapter: 23, note: "发光" } });
expect(buildTransitionRequest({ projectId: "p1" })).toEqual({});
expect(
buildTransitionRequest({ projectId: "p1", progressEntry: {} }),
).toEqual({});
});
});
describe("validationReasonMessage / extractReason", () => {
it("maps known reasons to friendly text", () => {
expect(validationReasonMessage("duplicate")).toContain("已存在");
expect(validationReasonMessage("invalid_transition")).toContain("终态");
expect(validationReasonMessage("empty_update")).toContain("进展");
expect(validationReasonMessage("invalid_status")).toContain("筛选");
expect(validationReasonMessage(undefined)).toContain("校验");
});
it("extracts details.reason from envelope", () => {
expect(
extractReason({ error: { details: { reason: "duplicate" } } }),
).toBe("duplicate");
expect(extractReason({ error: { details: null } })).toBeUndefined();
expect(extractReason("oops")).toBeUndefined();
});
});

View File

@@ -0,0 +1,141 @@
// 伏笔看板纯逻辑:四泳道分组、登记/转移请求体组装、422 reason 映射。
// 对齐 C3 扩GET/POST/PATCH .../foreshadow。纯逻辑便于 node 环境单测。
import type {
ForeshadowRegisterRequest,
ForeshadowTransitionRequest,
ForeshadowView,
} from "@/lib/api/types";
// 四状态泳道UX §6.8OVERDUE 用琥珀强调(渲染层处理)。
export type ForeshadowStatus = "OPEN" | "PARTIAL" | "CLOSED" | "OVERDUE";
export const LANES: readonly ForeshadowStatus[] = [
"OPEN",
"PARTIAL",
"CLOSED",
"OVERDUE",
] as const;
export const LANE_LABELS: Record<ForeshadowStatus, string> = {
OPEN: "OPEN",
PARTIAL: "PARTIAL",
CLOSED: "CLOSED",
OVERDUE: "OVERDUE",
};
export type ForeshadowLanes = Record<ForeshadowStatus, ForeshadowView[]>;
function emptyLanes(): ForeshadowLanes {
return { OPEN: [], PARTIAL: [], CLOSED: [], OVERDUE: [] };
}
function isStatus(v: string): v is ForeshadowStatus {
return v === "OPEN" || v === "PARTIAL" || v === "CLOSED" || v === "OVERDUE";
}
// 按 status 分四泳道(未知 status 落 OPEN 兜底);保持 repo 已排序的 code 顺序。
export function groupByStatus(
items: readonly ForeshadowView[] | undefined,
): ForeshadowLanes {
const lanes = emptyLanes();
for (const item of items ?? []) {
const status = isStatus(item.status) ? item.status : "OPEN";
lanes[status] = [...lanes[status], item];
}
return lanes;
}
// 接近回收窗口判定current 在 [from, to] 内,或已超 from 但尚未 CLOSED提示安排回收
// 仅对 OPEN/PARTIAL 提示CLOSED 已回收、OVERDUE 已逾期另有强调。
export function isWindowApproaching(
item: ForeshadowView,
currentChapter: number,
): boolean {
if (item.status === "CLOSED" || item.status === "OVERDUE") return false;
const to = item.expected_close_to;
if (typeof to !== "number") return false;
const from = item.expected_close_from ?? to;
return currentChapter >= from && currentChapter <= to;
}
export interface RegisterInput {
projectId: string;
code: string;
title: string;
plantedAt?: number | null;
content?: string | null;
expectedCloseFrom?: number | null;
expectedCloseTo?: number | null;
importance?: string | null;
}
// 组装登记请求体(去空白;空可选字段省略)。
export function buildRegisterRequest(
input: RegisterInput,
): ForeshadowRegisterRequest {
const body: ForeshadowRegisterRequest = {
code: input.code.trim(),
title: input.title.trim(),
};
if (typeof input.plantedAt === "number") body.planted_at = input.plantedAt;
const content = input.content?.trim();
if (content) body.content = content;
if (typeof input.expectedCloseFrom === "number")
body.expected_close_from = input.expectedCloseFrom;
if (typeof input.expectedCloseTo === "number")
body.expected_close_to = input.expectedCloseTo;
const importance = input.importance?.trim();
if (importance) body.importance = importance;
return body;
}
export interface TransitionInput {
projectId: string;
toStatus?: ForeshadowStatus | null;
// 一条进展记录(自由 dict如 {chapter, note})。
progressEntry?: Record<string, unknown> | null;
}
// 组装转移/进展请求体(两字段皆可选;至少给一项由调用方校验)。
export function buildTransitionRequest(
input: TransitionInput,
): ForeshadowTransitionRequest {
const body: ForeshadowTransitionRequest = {};
if (input.toStatus) body.to_status = input.toStatus;
if (input.progressEntry && Object.keys(input.progressEntry).length > 0)
body.progress_entry = input.progressEntry;
return body;
}
// 422 VALIDATION 信封 details.reasonC3 扩)→ 用户文案。
export type ValidationReason =
| "duplicate"
| "invalid_transition"
| "empty_update"
| "invalid_status";
export function validationReasonMessage(reason: string | undefined): string {
switch (reason) {
case "duplicate":
return "该伏笔代号已存在,请换一个。";
case "invalid_transition":
return "非法状态转移CLOSED 为终态,不可再改)。";
case "empty_update":
return "请填写目标状态或一条进展。";
case "invalid_status":
return "无效的筛选状态。";
default:
return "操作未通过校验,请检查输入。";
}
}
// 从错误信封安全提取 details.reason。
export function extractReason(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) return undefined;
const env = error as {
error?: { details?: { reason?: unknown } | null };
};
const reason = env.error?.details?.reason;
return typeof reason === "string" ? reason : undefined;
}

View File

@@ -0,0 +1,96 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import type { ForeshadowView } from "@/lib/api/types";
import {
buildRegisterRequest,
buildTransitionRequest,
extractReason,
validationReasonMessage,
type ForeshadowStatus,
type RegisterInput,
type TransitionInput,
} from "./board";
export interface UseForeshadow {
items: ForeshadowView[];
busy: boolean;
register: (input: RegisterInput) => Promise<boolean>;
transition: (code: string, input: TransitionInput) => Promise<boolean>;
}
// 伏笔登记/状态变更(乐观更新 + 回滚 + Toast
// register成功后把返回行追加进 items422 duplicate → 友好提示,不改 items。
// transition先乐观替换该行 status失败回滚到旧 items 并 toast读 details.reason
export function useForeshadow(initial: ForeshadowView[]): UseForeshadow {
const [items, setItems] = useState<ForeshadowView[]>(initial);
const [busy, setBusy] = useState(false);
const toast = useToast();
const register = useCallback<UseForeshadow["register"]>(
async (input) => {
setBusy(true);
try {
const { data, error } = await api.POST(
"/projects/{project_id}/foreshadow",
{
params: { path: { project_id: input.projectId } },
body: buildRegisterRequest(input),
},
);
if (error || !data) {
toast(validationReasonMessage(extractReason(error)), "error");
return false;
}
setItems((prev) => [...prev, data]);
toast(`已登记伏笔 ${data.code}`, "success");
return true;
} finally {
setBusy(false);
}
},
[toast],
);
const transition = useCallback<UseForeshadow["transition"]>(
async (code, input) => {
const snapshot = items;
// 乐观:本地先改 status若给了 toStatus
if (input.toStatus) {
const next = input.toStatus as ForeshadowStatus;
setItems((prev) =>
prev.map((it) => (it.code === code ? { ...it, status: next } : it)),
);
}
setBusy(true);
try {
const { data, error } = await api.PATCH(
"/projects/{project_id}/foreshadow/{code}",
{
params: {
path: { project_id: input.projectId, code },
},
body: buildTransitionRequest(input),
},
);
if (error || !data) {
setItems(snapshot); // 回滚
toast(validationReasonMessage(extractReason(error)), "error");
return false;
}
// 用服务端权威行替换status + progress 都对齐)。
setItems((prev) => prev.map((it) => (it.code === code ? data : it)));
toast(`已更新 ${data.code}`, "success");
return true;
} finally {
setBusy(false);
}
},
[items, toast],
);
return { items, busy, register, transition };
}

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type {
ForeshadowWindowView,
OutlineChapterView,
} from "@/lib/api/types";
import {
groupByVolume,
isCloseWindow,
windowBadgeLabel,
} from "./outline";
const ch = (over: Partial<OutlineChapterView>): OutlineChapterView => ({
no: 1,
volume: 1,
beats: [],
foreshadow_windows: [],
...over,
});
const win = (
over: Partial<ForeshadowWindowView>,
): ForeshadowWindowView => ({ code: "F-012", ...over });
describe("groupByVolume", () => {
it("groups by volume and sorts volumes and chapters ascending", () => {
const groups = groupByVolume([
ch({ no: 127, volume: 3 }),
ch({ no: 12, volume: 1 }),
ch({ no: 126, volume: 3 }),
]);
expect(groups.map((g) => g.volume)).toEqual([1, 3]);
expect(groups[1]?.chapters.map((c) => c.no)).toEqual([126, 127]);
});
it("handles undefined", () => {
expect(groupByVolume(undefined)).toEqual([]);
});
});
describe("windowBadgeLabel", () => {
it("renders a range, half-open, or bare badge", () => {
expect(
windowBadgeLabel(win({ expected_close_from: 40, expected_close_to: 60 })),
).toBe("⚑ F-012 (40-60)");
expect(windowBadgeLabel(win({ expected_close_to: 60 }))).toBe(
"⚑ F-012 (≤60)",
);
expect(windowBadgeLabel(win({ expected_close_from: 40 }))).toBe(
"⚑ F-012 (≥40)",
);
expect(windowBadgeLabel(win({}))).toBe("⚑ F-012");
});
});
describe("isCloseWindow", () => {
it("is true when chapter no is inside the recovery window", () => {
const w = win({ expected_close_from: 40, expected_close_to: 60 });
expect(isCloseWindow(ch({ no: 50 }), w)).toBe(true);
expect(isCloseWindow(ch({ no: 61 }), w)).toBe(false);
expect(isCloseWindow(ch({ no: 50 }), win({}))).toBe(false);
});
});

View File

@@ -0,0 +1,49 @@
// 大纲编辑器纯逻辑按卷分组、伏笔窗口徽标文案、接近回收窗口提示UX §6.7)。
// OutlineChapterView/ForeshadowWindowView 已由 OpenAPI 强类型,无需手动收窄。
import type { ForeshadowWindowView, OutlineChapterView } from "@/lib/api/types";
export interface VolumeGroup {
volume: number;
chapters: OutlineChapterView[];
}
// 按 volume 分组(保持章号升序);卷号升序。
export function groupByVolume(
chapters: readonly OutlineChapterView[] | undefined,
): VolumeGroup[] {
const byVolume = new Map<number, OutlineChapterView[]>();
for (const ch of chapters ?? []) {
const list = byVolume.get(ch.volume) ?? [];
byVolume.set(ch.volume, [...list, ch]);
}
return [...byVolume.entries()]
.sort(([a], [b]) => a - b)
.map(([volume, list]) => ({
volume,
chapters: [...list].sort((a, b) => a.no - b.no),
}));
}
// 伏笔窗口徽标文案:`⚑ F-012 (40-60)` / `⚑ F-012 (≤60)` / `⚑ F-012`。
export function windowBadgeLabel(window: ForeshadowWindowView): string {
const from = window.expected_close_from;
const to = window.expected_close_to;
if (typeof from === "number" && typeof to === "number") {
return `${window.code} (${from}-${to})`;
}
if (typeof to === "number") return `${window.code} (≤${to})`;
if (typeof from === "number") return `${window.code} (≥${from})`;
return `${window.code}`;
}
// 接近回收:本章号落在窗口 [from,to] 内提示「本章可回收」UX §6.7 ⚑ 可回收)。
export function isCloseWindow(
chapter: OutlineChapterView,
window: ForeshadowWindowView,
): boolean {
const to = window.expected_close_to;
if (typeof to !== "number") return false;
const from = window.expected_close_from ?? to;
return chapter.no >= from && chapter.no <= to;
}

View File

@@ -0,0 +1,63 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import type { OutlineChapterView } from "@/lib/api/types";
export type OutlineStatus = "idle" | "generating" | "ready" | "error";
export interface OutlineError {
code: string;
message: string;
}
export interface UseOutline {
chapters: OutlineChapterView[];
status: OutlineStatus;
error: OutlineError | null;
generate: (projectId: string, volume: number) => Promise<void>;
}
// 生成大纲POST .../outlinebody {volume})。无凭据/网关失败 → 503 LLM_UNAVAILABLE
// (流前 JSON 信封),经 error 检出、引导去设置(同 draft/review 范式)。
export function useOutline(initial: OutlineChapterView[]): UseOutline {
const [chapters, setChapters] = useState<OutlineChapterView[]>(initial);
const [status, setStatus] = useState<OutlineStatus>(
initial.length > 0 ? "ready" : "idle",
);
const [error, setError] = useState<OutlineError | null>(null);
const toast = useToast();
const generate = useCallback<UseOutline["generate"]>(
async (projectId, volume) => {
setStatus("generating");
setError(null);
const { data, error: apiError } = await api.POST(
"/projects/{project_id}/outline",
{
params: { path: { project_id: projectId } },
body: { volume },
},
);
if (apiError || !data) {
const env = apiError as
| { error?: { code?: string; message?: string } }
| undefined;
const code = env?.error?.code ?? "OUTLINE_FAILED";
const message = env?.error?.message ?? "大纲生成失败,请重试。";
setError({ code, message });
setStatus("error");
toast(message, "error");
return;
}
setChapters(data.chapters ?? []);
setStatus("ready");
toast("大纲已生成", "success");
},
[toast],
);
return { chapters, status, error, generate };
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { SPARK_CHARS, toBeatCells, toSparkline } from "./beatmap";
describe("toBeatCells", () => {
it("maps min→▁ and max→▇ via linear normalization", () => {
const cells = toBeatCells([1, 7]);
expect(cells[0]?.level).toBe(0);
expect(cells[0]?.char).toBe("▁");
expect(cells[1]?.level).toBe(SPARK_CHARS.length - 1);
expect(cells[1]?.char).toBe("▇");
});
it("centers all cells when every value is equal", () => {
const cells = toBeatCells([5, 5, 5]);
expect(cells.every((c) => c.level === 3)).toBe(true);
expect(cells.every((c) => c.char === "▄")).toBe(true);
});
it("gives every cell a visible min height", () => {
const cells = toBeatCells([0, 10]);
expect(cells[0]?.heightPct).toBeGreaterThanOrEqual(12);
expect(cells[1]?.heightPct).toBe(100);
});
it("returns [] for empty or non-finite-only input", () => {
expect(toBeatCells([])).toEqual([]);
expect(toBeatCells([Number.NaN, Number.POSITIVE_INFINITY])).toEqual([]);
});
});
describe("toSparkline", () => {
it("joins chars into a sparkline string", () => {
expect(toSparkline([1, 4, 7])).toBe("▁▄▇");
expect(toSparkline([])).toBe("");
});
});

View File

@@ -0,0 +1,53 @@
// 爽点节拍图:把 pace.beat_mapint 序列)映射成 ▁▃▅ 风格的 sparkline 格子UX §6.4)。
// 纯逻辑,便于 node 环境单测;渲染时配合高度类与 prefers-reduced-motion。
// 七级火花字符(低→高),与高度档位一一对应。
export const SPARK_CHARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇"] as const;
export interface BeatCell {
// 原始强度值。
value: number;
// 归一化档位 0..6(映射到 SPARK_CHARS / 高度百分比)。
level: number;
// 对应火花字符(▁..▇)。
char: string;
// 渲染用高度百分比12%..100%,最低也可见)。
heightPct: number;
}
const MIN_HEIGHT_PCT = 12;
const MAX_LEVEL = SPARK_CHARS.length - 1;
// 把 beat_map 映射成可渲染的格子序列。
// 归一化按「本序列 min..max」线性分桶到 0..6;全相等 → 全部居中档level 3
export function toBeatCells(beatMap: readonly number[]): BeatCell[] {
const values = beatMap.filter((v) => typeof v === "number" && Number.isFinite(v));
if (values.length === 0) return [];
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min;
return values.map((value) => {
const level =
span === 0
? Math.round(MAX_LEVEL / 2)
: Math.round(((value - min) / span) * MAX_LEVEL);
const clamped = Math.min(MAX_LEVEL, Math.max(0, level));
const heightPct =
MIN_HEIGHT_PCT + (clamped / MAX_LEVEL) * (100 - MIN_HEIGHT_PCT);
return {
value,
level: clamped,
char: SPARK_CHARS[clamped] ?? SPARK_CHARS[0],
heightPct: Math.round(heightPct),
};
});
}
// 文本版 sparkline无障碍/紧凑场景UX §6.4 示例 ▁▃▅▂▁▇)。
export function toSparkline(beatMap: readonly number[]): string {
return toBeatCells(beatMap)
.map((c) => c.char)
.join("");
}

View File

@@ -1,7 +1,12 @@
import { describe, expect, it } from "vitest";
import type { ReviewHistoryItem } from "@/lib/api/types";
import { latestReview, normalizeConflicts } from "./history";
import {
latestReview,
normalizeConflicts,
normalizeForeshadowSug,
normalizePace,
} from "./history";
const item = (
conflicts: Record<string, unknown>[],
@@ -43,6 +48,56 @@ describe("normalizeConflicts", () => {
});
});
describe("normalizeForeshadowSug", () => {
it("tightens flat list with kind, defaulting kind to planted", () => {
const out = normalizeForeshadowSug({
...item([]),
foreshadow_sug: [
{ kind: "resolved", code: "F-012", title: "神秘玉佩", where: "第4段" },
{ title: "残破地图" },
],
});
expect(out).toEqual([
{
kind: "resolved",
code: "F-012",
title: "神秘玉佩",
where: "第4段",
note: null,
},
{ kind: "planted", code: null, title: "残破地图", where: null, note: null },
]);
});
it("returns [] when missing", () => {
expect(normalizeForeshadowSug(item([]))).toEqual([]);
expect(normalizeForeshadowSug(undefined)).toEqual([]);
});
});
describe("normalizePace", () => {
it("tightens dict into PaceReport, filtering bad water/beats", () => {
const out = normalizePace({
...item([]),
pace: {
water: [{ where: "第3段", reason: "注水" }, "junk"],
hook: true,
beat_map: [1, 3, "x", 5],
},
});
expect(out).toEqual({
water: [{ where: "第3段", reason: "注水" }],
hook: true,
beat_map: [1, 3, 5],
});
});
it("returns null when pace is missing or not a dict", () => {
expect(normalizePace(item([]))).toBeNull();
expect(normalizePace(undefined)).toBeNull();
});
});
describe("latestReview", () => {
it("returns first item (history is newest-first)", () => {
const a = item([]);

View File

@@ -2,17 +2,32 @@
// 纯逻辑可单测schema 把 conflicts 标成 {[k]:unknown}[],这里安全收窄。
import type { ReviewHistoryItem } from "@/lib/api/types";
import type { ReviewConflict } from "./sse";
import type {
ForeshadowKind,
ForeshadowSuggestion,
PaceIssue,
PaceReport,
ReviewConflict,
} from "./sse";
function asString(v: unknown, fallback = ""): string {
return typeof v === "string" ? v : fallback;
}
function asOptionalString(v: unknown): string | null {
return typeof v === "string" ? v : null;
}
function asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === "string");
}
function asIntArray(v: unknown): number[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is number => typeof x === "number" && Number.isFinite(x));
}
// 把一条留痕的 conflicts 收紧(缺字段给安全默认;保持顺序=下标身份,对齐冲突 gate
export function normalizeConflicts(
item: ReviewHistoryItem | undefined,
@@ -26,6 +41,46 @@ export function normalizeConflicts(
}));
}
// 把一条留痕的 foreshadow_sug扁平 list每条带 kind收紧成建议缺字段给安全默认
// 对齐 collect 列映射C4 扩 T3.3planted/resolved 扁平成单 list、每条加 kind。
export function normalizeForeshadowSug(
item: ReviewHistoryItem | undefined,
): ForeshadowSuggestion[] {
const raw = item?.foreshadow_sug ?? [];
return raw.map((s) => ({
kind: asKind(s["kind"]),
code: asOptionalString(s["code"]),
title: asString(s["title"], "(未命名线索)"),
where: asOptionalString(s["where"]),
note: asOptionalString(s["note"]),
}));
}
function asKind(v: unknown): ForeshadowKind {
return v === "resolved" ? "resolved" : "planted";
}
// 把一条留痕的 pacedict收紧成节奏报告缺失/非 dict → null不渲染节拍图
export function normalizePace(
item: ReviewHistoryItem | undefined,
): PaceReport | null {
const raw = item?.pace;
if (typeof raw !== "object" || raw === null) return null;
const dict = raw as Record<string, unknown>;
const waterRaw = Array.isArray(dict["water"]) ? dict["water"] : [];
const water: PaceIssue[] = waterRaw
.filter((w): w is Record<string, unknown> => typeof w === "object" && w !== null)
.map((w) => ({
where: asString(w["where"]),
reason: asString(w["reason"]),
}));
return {
water,
hook: dict["hook"] === true,
beat_map: asIntArray(dict["beat_map"]),
};
}
// 最近一条留痕GET .../reviews 已按新→旧排序)。
export function latestReview(
items: readonly ReviewHistoryItem[] | undefined,

View File

@@ -49,6 +49,35 @@ describe("parseReviewBlock", () => {
});
});
it("parses foreshadow and pace frames (C4 扩 T3.3)", () => {
expect(
parseReviewBlock(
'event:foreshadow\ndata:{"kind":"resolved","code":"F-012","title":"神秘玉佩","where":"第4段","note":"疑似回收"}',
),
).toEqual({
event: "foreshadow",
data: {
kind: "resolved",
code: "F-012",
title: "神秘玉佩",
where: "第4段",
note: "疑似回收",
},
});
expect(
parseReviewBlock(
'event:pace\ndata:{"water":[{"where":"第3段","reason":"注水"}],"hook":false,"beat_map":[1,3,5,2,1,7]}',
),
).toEqual({
event: "pace",
data: {
water: [{ where: "第3段", reason: "注水" }],
hook: false,
beat_map: [1, 3, 5, 2, 1, 7],
},
});
});
it("returns null for unknown event or malformed json", () => {
expect(parseReviewBlock('event:token\ndata:{"text":"x"}')).toBeNull();
expect(parseReviewBlock("event:conflict\ndata:{not json")).toBeNull();
@@ -107,6 +136,39 @@ describe("reduceReview", () => {
]);
});
it("accumulates foreshadow suggestions and replaces pace report (last wins)", () => {
const events: ReviewSseEvent[] = [
{
event: "foreshadow",
data: { kind: "planted", title: "残破地图", where: "第5段" },
},
{
event: "foreshadow",
data: { kind: "resolved", code: "F-012", title: "神秘玉佩" },
},
{
event: "pace",
data: { water: [], hook: true, beat_map: [1, 2, 3] },
},
{
event: "pace",
data: {
water: [{ where: "第3段", reason: "注水" }],
hook: false,
beat_map: [3, 1, 5],
},
},
];
const state = events.reduce(reduceReview, initialReviewState);
expect(state.foreshadow.map((f) => f.kind)).toEqual([
"planted",
"resolved",
]);
expect(state.pace?.hook).toBe(false);
expect(state.pace?.beat_map).toEqual([3, 1, 5]);
expect(state.phase).toBe("reviewing");
});
it("marks done and captures error preserving collected data", () => {
let s = reduceReview(initialReviewState, {
event: "section",

View File

@@ -19,6 +19,29 @@ export interface ReviewConflict {
export type SectionStatus = "started" | "done" | "incomplete";
// 伏笔建议C4 扩 T3.3planted=新埋待确认 / resolved=疑似回收(只读建议)。
export type ForeshadowKind = "planted" | "resolved";
export interface ForeshadowSuggestion {
kind: ForeshadowKind;
code?: string | null;
title: string;
where?: string | null;
note?: string | null;
}
// 节奏报告C4 扩 T3.3):注水段 + 章末钩子 + 爽点节拍图。
export interface PaceIssue {
where: string;
reason: string;
}
export interface PaceReport {
water: PaceIssue[];
hook: boolean;
beat_map: number[];
}
export interface SectionEvent {
event: "section";
data: { name: string; status: SectionStatus };
@@ -27,6 +50,24 @@ export interface ConflictEvent {
event: "conflict";
data: ReviewConflict;
}
export interface ForeshadowEvent {
event: "foreshadow";
data: {
kind: ForeshadowKind;
code?: string | null;
title: string;
where?: string | null;
note?: string | null;
};
}
export interface PaceEvent {
event: "pace";
data: {
water: PaceIssue[];
hook: boolean;
beat_map: number[];
};
}
export interface DoneEvent {
event: "done";
data: { length: number };
@@ -38,10 +79,19 @@ export interface ErrorEvent {
export type ReviewSseEvent =
| SectionEvent
| ConflictEvent
| ForeshadowEvent
| PaceEvent
| DoneEvent
| ErrorEvent;
const KNOWN_EVENTS = new Set(["section", "conflict", "done", "error"]);
const KNOWN_EVENTS = new Set([
"section",
"conflict",
"foreshadow",
"pace",
"done",
"error",
]);
// 把一个完整 SSE 块(多行)解析成事件;无法解析则返回 null跳过
export function parseReviewBlock(block: string): ReviewSseEvent | null {
@@ -108,6 +158,8 @@ export interface ReviewStreamState {
phase: ReviewPhase;
sections: ReviewSection[];
conflicts: ReviewConflict[];
foreshadow: ForeshadowSuggestion[];
pace: PaceReport | null;
error: { code: string; message: string; request_id?: string | null } | null;
}
@@ -115,6 +167,8 @@ export const initialReviewState: ReviewStreamState = {
phase: "idle",
sections: [],
conflicts: [],
foreshadow: [],
pace: null,
error: null,
};
@@ -136,6 +190,18 @@ export function reduceReview(
phase: "reviewing",
conflicts: [...state.conflicts, event.data],
};
case "foreshadow":
return {
...state,
phase: "reviewing",
foreshadow: [...state.foreshadow, { ...event.data }],
};
case "pace":
return {
...state,
phase: "reviewing",
pace: { ...event.data },
};
case "done":
return { ...state, phase: "done" };
case "error":

View File

@@ -7,16 +7,24 @@ import {
ReviewFrameBuffer,
initialReviewState,
reduceReview,
type ForeshadowSuggestion,
type PaceReport,
type ReviewConflict,
type ReviewStreamState,
} from "./sse";
export interface ReviewSeed {
conflicts: ReviewConflict[];
foreshadow: ForeshadowSuggestion[];
pace: PaceReport | null;
}
type Action =
| { type: "start" }
| { type: "events"; events: ReturnType<ReviewFrameBuffer["push"]> }
| { type: "abort" }
| { type: "fail"; code: string; message: string }
| { type: "seed"; conflicts: ReviewConflict[] };
| { type: "seed"; seed: ReviewSeed };
function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
switch (action.type) {
@@ -33,7 +41,12 @@ function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
error: { code: action.code, message: action.message },
};
case "seed":
return { ...initialReviewState, conflicts: action.conflicts };
return {
...initialReviewState,
conflicts: action.seed.conflicts,
foreshadow: action.seed.foreshadow,
pace: action.seed.pace,
};
default:
return state;
}
@@ -45,8 +58,8 @@ export interface UseReviewStream {
// 用当前编辑器草稿重新审稿。
start: (projectId: string, chapterNo: number, draft: string) => Promise<void>;
stop: () => void;
// 进页用历史留痕冲突种入(无需重审即可裁决)。
seed: (conflicts: ReviewConflict[]) => void;
// 进页用历史留痕冲突 + 伏笔建议 + 节奏)种入(无需重审即可裁决/查看)。
seed: (seed: ReviewSeed) => void;
}
// 消费 POST .../review 的 SSE 流fetch+ReadableStreamEventSource 不支持 POST
@@ -62,8 +75,8 @@ export function useReviewStream(): UseReviewStream {
dispatch({ type: "abort" });
}, []);
const seed = useCallback((conflicts: ReviewConflict[]) => {
dispatch({ type: "seed", conflicts });
const seed = useCallback((seedData: ReviewSeed) => {
dispatch({ type: "seed", seed: seedData });
}, []);
const start = useCallback(

File diff suppressed because one or more lines are too long

View File

@@ -41,6 +41,18 @@
- `POST /projects/{id}/chapters/{no}/accept``AcceptRequest{final_text(min1), decisions:[ConflictDecision{conflict_index:int>=0, verdict:"accept"|"ignore"|"manual", note?}]}``AcceptResponse{project_id,chapter_no,accepted_version:int,digest_added:bool,decisions_recorded:int,review_id?:uuid}`。**冲突 gate**:裁决的 `conflict_index` 集合须覆盖 `range(len(最近一条 review.conflicts))`缺判→409 `CONFLICT_UNRESOLVED` + `details.missing_conflict_indices`/`conflict_count`无留痕或零冲突→直通。digest 提炼 tier=light`get_digest_gateway`在事务外R2单事务 promote(R4)+digest.append(#4)+set_decisions 末尾一次 commitR3
- 注入缝:`build_gateway_for_tier(session, store, tier)`(原 `build_writer_gateway` 退化为 writer 特例) + `get_review_gateway`/`get_digest_gateway`/`get_review_repo`/`get_digest_append_repo`;测试经 `app.dependency_overrides` 注 mock。
- **@frontend 行动**`cd apps/web && pnpm gen:api` 重生成客户端含此三端点T2.6)。
- **已落T3.2, 2026-06-18snake_case— 伏笔登记/状态端点 + 验收后到期扫描**(挂 `foreshadow.router`prefix `/projects`tag `foreshadow`,已注册):
- `POST /projects/{id}/foreshadow``ForeshadowRegisterRequest{code(min1),title(min1),planted_at?,content?,expected_close_from?,expected_close_to?,importance?}` → 201 `ForeshadowView{code,title,status,planted_at?,content?,expected_close_from?,expected_close_to?,importance?,links:list,progress:list}`(status=OPEN)。重复 code(DB 唯一 `(project_id,code)`)→ **422** `VALIDATION`+`details{field:"code",code,reason:"duplicate"}`
- `PATCH /projects/{id}/foreshadow/{code}``ForeshadowTransitionRequest{to_status?,progress_entry?}` → 200 `ForeshadowView`。非法转移→422 `VALIDATION`+`details{reason:"invalid_transition"}`二者都缺→422 `empty_update`code 不存在→404 `NOT_FOUND`。先转移后追加进展,端点写后 `commit()`(repo 只 flush)。
- **错误码取舍**:重复/非法转移映射 `VALIDATION`(422) 非 409——现有唯一 409 码 `CONFLICT_UNRESOLVED` 语义专指未决冲突禁验收,不复用;未动 `packages/shared/errors.py` 契约。若 T3.5/前端需真 409 须先加专用码。
- **验收后到期扫描**`POST .../accept` 单事务 commit 成功后经 `BackgroundTasks` 登记 `run_overdue_scan(session_factory, project_id, chapter_no=刚验收章号)`——**自建独立 session**(请求 session 已关闭)、应用 `current_ch>expected_close_to AND status≠CLOSED→OVERDUE`、有变更才 commit日志 `foreshadow_overdue_scan`(带 request_id/overdue_count/codes)失败吞不冒泡。§7.4 持久性局限(进程内/重启丢任务)原型接受、不引 jobs 表。
- **T3.5 续接**`GET /projects/{id}/foreshadow?status=` 看板加到本 `foreshadow.router`(注入 `get_foreshadow_repo``list_by_status`,建议响应包 `{foreshadow:[...]}``POST .../outline` 落**独立** `routers/outline.py`(别混进 foreshadow.py),调 `run_outline`(C6 扩)+`build_gateway_for_tier(...,"analyst")`,逐章 upsert `outline` 表、端点末尾 commit。
- **@frontend 行动**T3.6 前 `pnpm gen:api` 纳入伏笔登记/状态端点(+ T3.5 看板/outline
- **已落T3.5, 2026-06-18snake_case— 伏笔看板 + 大纲生成端点**
- `GET /projects/{id}/foreshadow?status=`tag `foreshadow`query `status?``OPEN|PARTIAL|CLOSED|OVERDUE`(缺省=全部非法→422 `VALIDATION`+`details{field:"status",reason:"invalid_status"}`) → 200 `ForeshadowBoardResponse{foreshadow:list[ForeshadowView]}`(按 code 升序)。
- `POST /projects/{id}/outline`tag `outline`,独立 `routers/outline.py`)← `OutlineGenerateRequest{volume:int=1,ge=1}`M3 简单分卷:整批同卷)→ 200 `OutlineResponse{chapters:list[OutlineChapterView{no,volume,beats:list[str],foreshadow_windows:list[ForeshadowWindowView{code,plant_chapter?,expected_close_from?,expected_close_to?}]}]}`项目不存在→404 `NOT_FOUND`;无凭据/网关失败→503 `LLM_UNAVAILABLE`(流前 JSON 信封)。调 `run_outline`(analyst 网关 `get_outline_gateway`)→逐章 upsert `outline` 表(写侧 `SqlOutlineWriteRepo`,只 flush)→端点末尾 `commit()`(含 1 条 usage_ledger
- **beats 存储形**DB `outline.beats` 是 JSONB dict→写侧包成 `{"beats":[...]}` 落库API 出参 `OutlineChapterView.beats` 解包成裸 `list[str]`
- **@frontend 行动**T3.6 `pnpm gen:api` 纳入看板/大纲端点。
## C4 · 编排器接口LangGraph 写章图 owner @llm 状态: 稳定M1/T1.3, 2026-06-18M2 扩四审/验收)
- 来源:`ARCHITECTURE.md §5.2`(图)/ §7.3SSE。**M1 仅单 `write` 节点**;并行四审/collect/interrupt(accept) 属 M2。
@@ -64,6 +76,12 @@
- **accept 不在本图**`interrupt_before=["accept"]`/accept 节点属 T2.4确定性事务代码从领域表重读R3/不变量#5)。
- 消费方行动T2.5 跑 review 子图 + `normalize_review` + 端点**流耗尽后 `await session.commit()`**(网关 ledger + collect 均只 flush不提交则记账/留痕静默丢失,同 M1 draft 坑);`GET .../reviews``review_repo.list_for_chapter`(新→旧)。T2.4 从 `review_repo.list_for_chapter` 读 collect 落的行(`decisions=None`)裁决。
### C4 扩展T3.3, 2026-06-18· 三审齐foreshadow + pace 并入)
- `REVIEW_SPECS = (continuity_spec, foreshadow_spec, pace_spec)``build_review_graph(..., review_specs=REVIEW_SPECS)` 默认三审齐(文风第四审留 M4`run_review`/`make_review_node` 对 spec 泛型(按 `req.output_schema` 产 parsed
- collect 列映射(一次 `review_repo.record(...)` 落齐continuity→`conflicts`(list)foreshadow→`foreshadow_sug`(list`planted`/`resolved` 扁平成单 list、每条加 `kind:"planted"|"resolved"`)pace→`pace`(dict`{water,hook,beat_map}` 入列)style 留 M4。任一审 incomplete→该列空/None不阻塞其余§5.2)。
- `normalize_review` SSE向后兼容`section`/`conflict` 不变)新增:`foreshadow{kind,code?,title,where?,note?}`(每条建议一条)、`pace{water:[{where,reason}],hook:bool,beat_map:[int]}`一条。键按字典序确定性遍历continuity<foreshadow<pace)。incomplete 审仅发 `section` 不发结果
- 消费方前端 T3.6冲突就地标注 / 伏笔看板联动 / 节拍图 ▁▃▅)、E2E T3.7
## C5 · 记忆服务 `assemble` / `select_relevant_entities` owner @backend 状态: 稳定T1.2, 2026-06-18
- 来源`ARCHITECTURE.md §3.4 / §5.3`确定性选择显式+主角+近况渲染卡片缓存断点)。
- 位置`packages/core/ww_core/memory/`+ `domain/repositories.py`)。
@@ -84,7 +102,19 @@
- **`ContinuityReview{ conflicts: list[Conflict] }`**仅冲突digest 不在审稿期产不变量#4)。
- **`Conflict{ type: ConflictType, where:str, refs:list[str]=[], suggestion:str }`**`ConflictType = Literal["性格漂移","能力不符","设定违例","地理矛盾","时间线倒错"]`ARCH §6.1 五类)。
- 续审节点调法`req = LlmRequest(tier="analyst", system=[Block(system_prompt, cache=True)], input=审稿上下文文本, output_schema=ContinuityReview, scope=...)` `resp = await gateway.run(req)` `resp.parsed` `ContinuityReview` 实例 schema 时必非 None)。续审用 `run()` `stream()`只读+产冲突不写库写在 T2.4 验收事务)。
- 消费方:编排器(T2.2 续审/collect 节点)、技能运行时(M5)、前端审稿页(经 C3)。foreshadow/style/pace 三审 spec 待 M3/M4 补入本契约
- 消费方编排器(T2.2 续审/collect 节点)、技能运行时(M5)、前端审稿页( C3)。foreshadow/style/pace 三审 spec M3-T3.3/M4 补入本契约
### C6 扩展T3.4, 2026-06-18· outliner Agent + 大纲/伏笔窗口 schema
- 位置`packages/agents/ww_agents/{schemas.py,specs.py}` `ww_agents` 导出节点在 `packages/core/ww_core/orchestrator/outline_node.py` orchestrator 导出)。
- **`outliner_spec`**(frozen `AgentSpec`)`name="outliner"``tier="analyst"``reads=["projects","foreshadow","characters","world_entities"]``writes=["outline"]`(声明式真写库经 T3.5不变量#3)、`input_schema=None``output_schema=OutlineResult`
- **`OutlineResult{chapters:list[OutlineChapter]}`****`OutlineChapter{no:int, beats:list[str], foreshadow_windows:list[ForeshadowWindow]}`****`ForeshadowWindow{code:str, plant_chapter?:int, expected_close_from?:int, expected_close_to?:int}`**(§6.2 关联章与伏笔)。
- 节点缝沿用 review_node `GatewayRun` Protocol `.run(req)->LlmResponse``build_outline_request(spec, *, context, user_id, project_id)->LlmRequest`(`output_schema=OutlineResult`,`stream=False`) + `async run_outline(spec, *, context, gateway, user_id, project_id)->OutlineResult`(`gateway.run().parsed`**只读不写库**大纲独立生成非并行审网关失败**直接上抛**不隔离parsed 违约抛 `ValueError`)。
- 消费方T3.5 端点调 `run_outline` `OutlineResult` 逐章 upsert `outline` `beats`/`foreshadow_windows` JSONB`volume` 由端点分卷逻辑提供schema 不含 volumeanalyst 网关复用 `build_gateway_for_tier(session,store,"analyst")`/`get_review_gateway`记账闭环同 M2端点末尾 `commit()`)。T3.6 前端大纲编辑器消费窗口
### C6 扩展T3.3, 2026-06-18· foreshadow-analyst + pace-checker spec
- `foreshadow_spec`(name="foreshadow", tier="analyst", reads=["foreshadow"], writes=[]只读, output=`ForeshadowReview{planted:[ForeshadowSuggestion],resolved:[ForeshadowSuggestion]}``ForeshadowSuggestion{code?,title,where?,note?}`)。
- `pace_spec`(name="pace", tier="light", reads=["rules"], writes=[]只读, output=`PaceReview{water:[PaceIssue{where,reason}],hook:bool,beat_map:[int]}`)。genre 模板 DSL = pace system_prompt 内描述黄金三章/章末钩子/爽点密度genre 级模板经 review_context 规则合并(`rules`)注入**未新建表/repo**。
- 三审只读留痕经 collectreview_repo写库 commit 归端点)。详细图/SSE 见上C4 扩展T3.3)」。
## C7 · 前端 ↔ 后端类型契约 owner @backend(产出 OpenAPI) / @frontend(生成) 状态: 待定义
- 机制FastAPI OpenAPI `apps/web/lib/api` TS 类型openapi-typescript/orval)。

View File

@@ -20,6 +20,37 @@
- 理由 / 取舍:保留既有「注入 client 便于测试」风格,结构化路径同样可注 fake。`ProviderResult.text` 对结构化路径置为 `parsed.model_dump_json()`(便于日志/留痕),程序消费走 `parsed`
- 影响:契约 C1`run()``parsed`;见 contracts 变更日志)、`ProviderResult``parsed` 字段T2.2 续审节点据此消费。
## [2026-06-18] 并行审记账并发安全选 add-only去网关 ledger flush— @llm (T3.8)
- 背景:三审同 superstep 并行共用请求 session`SqlAlchemyLedgerSink.record``await flush()` 是唯一让步点→第二/三审 flush 重入「Session is already flushing」→被 `run_review` 吞成 incomplete、foreshadow/pace 静默丢失T3.7 暴露)。
- 选择:`record`**add-only**(去 `await flush()`,只 `session.add(row)`)。`session.add()` 同步不让步→并行协程不交错;持久化仍靠端点/事务末尾 `commit()`(自动 flush 待决行)。
- 理由 / 取舍:最小改动、单 owner只动 `ledger.py`),不牵动 apps/@backend)。前提已校验:①并行审区间无 DB 查询触发 autoflushreview_context 取自 state、适配器不碰 session②draft/review/accept/outline 端点末尾均已 commit。缓冲/独立 session 方案跨 owner、更重不取。
- 影响C1 边界语义不变commit 责任方未变);凡新增「并行用网关产 usage」路径sink 必须保持 add-only、不得在并行段 await flush。新增并发回归测试 `test_ledger_concurrency.py`
## [2026-06-18] 节拍图用定高 CSS 柱 + 文本 sparkline不引图表库 — @frontend (T3.6)
- 选择:`beat_map`(int 序列) 按本序列 min..max 线性归一到 7 档 ▁..▇(全相等→居中档,最低柱给 12% 可见高度),渲染定高 CSS 柱 + `aria-label="爽点节拍图 ▁▃▅…"`。静态无动画→天然满足 prefers-reduced-motion。
- 理由KISS避免图表库依赖。影响T3.7 节拍图 `role="img"` selector。
## [2026-06-18] 大纲 beats 存储形 + 写侧 repo 命名 — @backend (T3.5)
- 背景:`outline.beats` DB 列是 JSONB dict但 outliner 产 `list[str]`;读侧 `OutlineRepo`(C5/memory)已占名。
- 选择:写侧 `SqlOutlineWriteRepo` 把 list 包成 `{"beats":[...]}` 落库(读侧同形 round-trip 一致API 出参 `OutlineChapterView.beats` 解包成裸 `list[str]`。写侧命名加 `Write` 前缀避撞 C5 读侧(同 DigestAppendRepo/ForeshadowLedgerRepo 先例)。`volume` 由端点提供schema 无逐章卷号M3 默认全卷 1`get_outline_gateway` 复用 `build_gateway_for_tier(...,"analyst")`、独立缝便于测试 override。
- 影响C3 扩(outline 端点)T3.6 渲染 beats 裸 list + 窗口徽标T3.7 断言 outline 行 beats 列形。
## [2026-06-18] 验收后伏笔到期扫描挂 BackgroundTask + 重复code/非法转移映射 VALIDATION(422) — @backend (T3.2)
- 背景§5.5 步骤3 `TODO(M3)` 要验收后置 OVERDUE登记/状态端点需映射 DB 唯一冲突与非法状态机转移到错误信封。
- 选择:(1) accept 端点 commit 成功后经 FastAPI BackgroundTasks 登记 `run_overdue_scan`(自建独立 session因请求 session 已关闭);扫描抽纯 async 函数 + 可注入 `repo_factory`/`session_factory` 缝,单测直接 await 注 fake、不起后台线程。(2) 重复 code(IntegrityError)/`InvalidTransition` 都映射 `ErrorCode.VALIDATION`(422) 非 409——现有 409 码 `CONFLICT_UNRESOLVED` 语义=未决冲突,复用不符;未新增 shared 错误码。
- 影响C3 扩(foreshadow 登记/状态端点)§7.4 持久性局限(进程内/重启丢任务)原型接受、不引 jobs 表T3.5 看板/outline 续接本 routerT3.7 真 pg 断言 OVERDUE。
## [2026-06-18] 伏笔写侧 Repository 加 `Ledger` 前缀避免与读侧同名 — @backend (T3.1)
- 背景:读侧 `ForeshadowRepo`/`ForeshadowView`/`SqlForeshadowRepo``domain/repositories.py`+`memory/`)已被 assemble(C5 稳定)占用且 View 最小(无 importance/links/progress。M3 写侧账本需更全 View + 写方法。
- 选择:写侧命名 `ForeshadowLedgerRepo`/`SqlForeshadowLedgerRepo`/`ForeshadowLedgerView`,落 `domain/foreshadow_repo.py`;读侧不动。状态机纯函数落 `domain/foreshadow_state.py``ForeshadowStatus` StrEnum + `transition`/`is_overdue`/`apply_overdue_scan`/`InvalidTransition`)。
- 理由:同 T2.3 `DigestAppendRepo` vs 读侧 `DigestRepo` 先例,读写分离免歧义、不破坏 C5。写方法只 flush 不 commitcommit 归 T3.2 扫描任务 / T3.5 端点)。
- 影响T3.2 验收后扫描 / T3.5 看板端点消费 `ForeshadowLedger*`
## [2026-06-18] 大纲节点不接进 review 图、不做失败隔离 — @llm (T3.4)
- 背景:续审在 `run_review` 内把网关失败隔离为 `incomplete`§5.2 任一审不阻塞其余)。
- 选择:大纲是**独立生成**(不在写章/审稿流水线),`run_outline` 裸函数、网关失败**直接上抛**由 T3.5 端点处理;带 `output_schema``parsed``OutlineResult``ValueError`C1 违约即显错,不返回空大纲)。
- 影响C6 扩outliner spec + OutlineResult + run_outline 缝T3.5 端点调 `run_outline` 持久化 `outline` 表。
## [2026-06-18] 审稿页终稿来源:页内可编辑 textarea无 GET draft 端点)— @frontend (T2.6)
- 背景:审稿/验收都需「终稿」文本,但当前无 `GET .../draft` 端点拉取已存草稿。
- 选择:审稿页用可折叠 textarea 编辑终稿(`initialDraft=''`),重审传它为 `{draft}`(空→后端回退已存草稿),验收 `final_text`=它。

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` 身份,不可重排,否则裁决错位)。

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"

View File

@@ -1,17 +1,44 @@
"""ww-agents内置 Agent 声明AgentSpec+ 结构化输出 schemaC6
对齐 ARCH §5.1AgentSpec/ §5.4I/O 契约)/ §6.1(冲突分类)。
对齐 ARCH §5.1AgentSpec/ §5.4I/O 契约)/ §6.1(冲突分类)/ §6.2 §6.4(伏笔/节奏)
"""
from __future__ import annotations
from .schemas import Conflict, ConflictType, ContinuityReview
from .specs import AgentSpec, continuity_spec
from .schemas import (
Conflict,
ConflictType,
ContinuityReview,
ForeshadowReview,
ForeshadowSuggestion,
ForeshadowWindow,
OutlineChapter,
OutlineResult,
PaceIssue,
PaceReview,
)
from .specs import (
AgentSpec,
continuity_spec,
foreshadow_spec,
outliner_spec,
pace_spec,
)
__all__ = [
"AgentSpec",
"Conflict",
"ConflictType",
"ContinuityReview",
"ForeshadowReview",
"ForeshadowSuggestion",
"ForeshadowWindow",
"OutlineChapter",
"OutlineResult",
"PaceIssue",
"PaceReview",
"continuity_spec",
"foreshadow_spec",
"outliner_spec",
"pace_spec",
]

View File

@@ -44,3 +44,106 @@ class ContinuityReview(BaseModel):
default_factory=list,
description="检出的一致性冲突;无冲突时为空列表",
)
# ---- 大纲 Agent 结构化输出ARCH §5.4 outliner 行 / §6.2 伏笔窗口)----
class ForeshadowWindow(BaseModel):
"""伏笔回收窗口关联本章与某条伏笔ARCH §6.2)。
排大纲时把伏笔的「埋设章 → 期望回收区间」绑到具体章节;接近回收窗口的章节
提示作者推进/收束。下界/上界可缺省(仅埋设、尚未排定回收时间)。
"""
code: str = Field(description="伏笔编码(关联 foreshadow.code")
plant_chapter: int | None = Field(
default=None, description="埋设章号(伏笔在本章/此章首次出现)"
)
expected_close_from: int | None = Field(default=None, description="期望回收区间下界(章号)")
expected_close_to: int | None = Field(default=None, description="期望回收区间上界(章号)")
class OutlineChapter(BaseModel):
"""单章大纲:章号 + 节拍清单 + 关联伏笔窗口。"""
no: int = Field(description="章号")
beats: list[str] = Field(default_factory=list, description="本章节拍/情节要点(有序)")
foreshadow_windows: list[ForeshadowWindow] = Field(
default_factory=list,
description="本章关联的伏笔回收窗口;无则空列表",
)
class OutlineResult(BaseModel):
"""大纲结构化产出分卷分章的章节大纲清单ARCH §5.4 writes=outline
声明式 writes不变量 #3本结构是排大纲的纯产物落 `outline` 表经验收/
端点T3.5),不在 agent 节点直接写库。
"""
chapters: list[OutlineChapter] = Field(
default_factory=list,
description="分章大纲;无则空列表",
)
# ---- 伏笔续审 Agent 结构化输出ARCH §5.4 foreshadow-analyst 行 / §6.2----
class ForeshadowSuggestion(BaseModel):
"""单条伏笔建议:本章新埋线 或 疑似回收ARCH §6.2)。
只读建议——作者在验收裁决里确认后才登记/改状态(不变量 #3本审不写库。
`code` 可缺(新埋线尚无编码时由作者命名);`where` 给本章定位。
"""
code: str | None = Field(
default=None, description="伏笔编码(关联已登记 foreshadow.code新埋可缺"
)
title: str = Field(description="伏笔标题/一句话描述")
where: str | None = Field(default=None, description="本章定位:哪一段埋下/疑似回收")
note: str | None = Field(default=None, description="建议说明(埋设要点 / 回收理由)")
class ForeshadowReview(BaseModel):
"""伏笔续审结构化产出:本章新埋 + 疑似回收两组建议ARCH §5.4 / §6.2)。
只读建议清单(不变量 #3登记/状态变更经验收裁决(作者确认)。
"""
planted: list[ForeshadowSuggestion] = Field(
default_factory=list,
description="本章疑似新埋的伏笔;无则空列表",
)
resolved: list[ForeshadowSuggestion] = Field(
default_factory=list,
description="本章疑似回收/收束的伏笔;无则空列表",
)
# ---- 节奏续审 Agent 结构化输出ARCH §5.4 pace-checker 行 / §6.4----
class PaceIssue(BaseModel):
"""单条节奏问题:注水段定位 + 原因ARCH §6.4)。"""
where: str = Field(description="本章定位:哪一段疑似注水/拖沓")
reason: str = Field(description="判定理由(信息密度低 / 重复 / 偏题等)")
class PaceReview(BaseModel):
"""节奏续审结构化产出:注水段 + 章末钩子 + 爽点节拍图ARCH §5.4 / §6.4)。
只读(不变量 #3。`beat_map` 是逐段节拍强度序列(整数),供前端 ▁▃▅ 可视化。
"""
water: list[PaceIssue] = Field(
default_factory=list,
description="疑似注水/拖沓段;无则空列表",
)
hook: bool = Field(default=False, description="章末是否有有效钩子(悬念/反转/期待)")
beat_map: list[int] = Field(
default_factory=list,
description="逐段爽点节拍强度序列(整数,供前端节拍图可视化)",
)

View File

@@ -12,7 +12,12 @@ from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from ww_llm_gateway.types import Tier
from .schemas import ContinuityReview
from .schemas import (
ContinuityReview,
ForeshadowReview,
OutlineResult,
PaceReview,
)
class AgentSpec(BaseModel):
@@ -63,3 +68,99 @@ continuity_spec = AgentSpec(
writes=[], # 只读(不变量 #3
scope="builtin",
)
OUTLINER_SYSTEM_PROMPT = """你是长篇连载小说的「大纲架构师」。职责:依据作品立意、\
人物与世界观,排出分卷分章的章节大纲,并为每条伏笔标注回收窗口。
输入材料:
- 作品设定projects题材、立意、主线、卖点
- 已登记伏笔foreshadow编码、标题、埋设/期望回收线索);
- 主要人物characters与世界观实体world_entities
产出要求(每章):
- 章号有序、按卷推进;
- beats本章核心节拍/情节要点,推动主线、服务人物弧光;
- foreshadow_windows本章关联的伏笔回收窗口——给出伏笔编码、埋设章号、\
期望回收区间(下界/上界章号)。把每条伏笔的「埋设 → 回收」绑到具体章节。
纪律:
- 接近某伏笔的回收窗口时,在对应章 beats 里安排推进/收束该伏笔的情节,避免伏笔悬置过久;
- 你只产结构化大纲(章节 + 节拍 + 伏笔窗口),**不改稿、不写库**(落 outline 表经验收/端点);
- 伏笔编码须与已登记 foreshadow.code 对应,便于后续按窗口提示与校验。"""
outliner_spec = AgentSpec(
name="outliner",
tier="analyst", # 不变量 #2只声明档位不写 model
system_prompt=OUTLINER_SYSTEM_PROMPT,
input_schema=None, # 注入材料为序列化文本(设定/伏笔/人物/世界观),非结构化入参
output_schema=OutlineResult,
reads=["projects", "foreshadow", "characters", "world_entities"],
writes=["outline"], # 声明式(经验收/T3.5 才真写库,不变量 #3
scope="builtin",
)
FORESHADOW_SYSTEM_PROMPT = """你是长篇连载小说的「伏笔续审」foreshadow-analyst\
职责:把本章草稿与作品已登记伏笔逐项比对,找出本章**新埋的伏笔**与**疑似回收/收束**\
的伏笔,产出结构化建议清单。
比对依据(注入材料):
- 已登记伏笔foreshadow编码、标题、状态、期望回收窗口
- 本章草稿正文。
产出两组建议:
- planted新埋本章疑似首次埋下的伏笔——给出标题、本章定位、要点说明\
若与某已登记编码相关则填 code否则留空由作者命名。
- resolved回收本章疑似回收/收束已登记伏笔——给出对应 code、本章定位、回收理由。
纪律:
- 你**只读、只产建议**,不改稿、不写库、不直接登记或改伏笔状态——\
登记/状态变更经作者在验收时裁决确认(不变量 #3 / #4
- 只报有据可依的;无则两组都返回空列表,不要臆造。
- 引用具体(伏笔编码、本章段落定位),便于作者就地确认。"""
foreshadow_spec = AgentSpec(
name="foreshadow",
tier="analyst", # 不变量 #2只声明档位不写 model
system_prompt=FORESHADOW_SYSTEM_PROMPT,
input_schema=None, # 注入材料为序列化文本(已登记伏笔 + 草稿),非结构化入参
output_schema=ForeshadowReview,
reads=["foreshadow"],
writes=[], # 只读(不变量 #3
scope="builtin",
)
PACE_SYSTEM_PROMPT = """你是长篇连载小说的「节奏续审」pace-checker。职责按题材的\
节奏模板审本章草稿,找出注水段、判定章末钩子有无、给出爽点节拍图。
题材节奏模板genre 级规则,注入材料里带本作题材的具体模板;无则用网文通用基线):
- 黄金三章:开篇前三章须快速立爽点/钩子/代入感,信息密度高、少铺垫慢热;
- 章末钩子:每章结尾应留悬念/反转/期待,驱动追更;
- 爽点密度:按题材基线维持爽点节拍,避免长段平淡注水。
产出:
- water注水段逐段审标出信息密度低/重复/偏题/拖沓的段落,给本章定位与原因;
- hook章末钩子本章结尾是否存在有效钩子true/false
- beat_map节拍图把本章按段切分给每段一个爽点强度整数如 05\
形成逐段强度序列,供前端 ▁▃▅ 可视化节奏起伏。
纪律:
- 你**只读、只报节奏诊断**,不改稿、不写库(不变量 #3
- 依据题材模板判定,不臆造;无注水段则 water 为空列表。
- beat_map 长度应与切分段数一致、顺序即正文顺序,便于前端对齐渲染。"""
pace_spec = AgentSpec(
name="pace",
tier="light", # 不变量 #2只声明档位不写 model节奏审用轻量档
system_prompt=PACE_SYSTEM_PROMPT,
input_schema=None, # 注入材料为序列化文本(题材模板 + 草稿),非结构化入参
output_schema=PaceReview,
reads=["rules"], # genre 模板 DSL 经 rulesgenre 级)注入,复用 review_context 规则合并
writes=[], # 只读(不变量 #3
scope="builtin",
)

View File

@@ -77,6 +77,29 @@ class FakeRunGateway:
)
class SchemaRoutingRunGateway:
"""按 `req.output_schema` 路由返对应 `parsed`——并行三审各拿自己 schema 的产物。
`by_schema`{schema 类型: 该 schema 的实例}。命中即回该 parsed未命中抛 KeyError
(测试要保证三审 schema 都登记)。统计每个 schema 的调用次数。
"""
def __init__(self, by_schema: dict[type[BaseModel], BaseModel]) -> None:
self._by_schema = by_schema
self.calls: list[type[BaseModel] | None] = []
async def run(self, req: LlmRequest) -> LlmResponse:
schema = req.output_schema
self.calls.append(schema)
parsed = self._by_schema[schema] if schema is not None else None
return LlmResponse(
text=parsed.model_dump_json() if parsed is not None else "",
parsed=parsed,
usage=_stub_usage(),
served_by=ServedBy(provider="fake", model="fake-analyst"),
)
class FailingRunGateway:
"""`run` 抛指定异常——验「某审失败不阻塞其余」+ 未完成占位归一。"""

View File

@@ -0,0 +1,293 @@
"""T3.1 伏笔账本写侧 repo 单测ARCH §6.2)。
纯内存 fake无 DB断言 register 唯一、list_by_status 过滤、record_progress append
不覆盖、transition 走状态机校验、scan_overdue 命中集合、按 project_id 隔离、frozen
View 不可变。`(project_id, code)` 唯一性是 **DB 级**models UniqueConstraint纯 fake
不引 pg 断言——真实 DB 路径由 T3.7 E2E 覆盖。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import Any
import pytest
from pydantic import ValidationError
from ww_core.domain.foreshadow_repo import ForeshadowLedgerRepo, ForeshadowLedgerView
from ww_core.domain.foreshadow_state import (
CLOSED,
OVERDUE,
PARTIAL,
InvalidTransition,
)
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
OTHER = uuid.UUID("00000000-0000-0000-0000-000000000002")
@dataclass
class _Row:
project_id: uuid.UUID
code: str
title: str
status: str
planted_at: int | None
content: str | None
expected_close_from: int | None
expected_close_to: int | None
importance: str | None
links: list[Any]
progress: list[Any]
def _view(row: _Row) -> ForeshadowLedgerView:
return ForeshadowLedgerView(
code=row.code,
title=row.title,
status=row.status,
planted_at=row.planted_at,
content=row.content,
expected_close_from=row.expected_close_from,
expected_close_to=row.expected_close_to,
importance=row.importance,
links=list(row.links),
progress=list(row.progress),
)
@dataclass
class FakeForeshadowLedgerRepo:
"""内存替身——镜像 SqlForeshadowLedgerRepo 语义(按 (project_id, code) 唯一)。"""
rows: list[_Row] = field(default_factory=list)
def _find(self, project_id: uuid.UUID, code: str) -> _Row | None:
return next((r for r in self.rows if r.project_id == project_id and r.code == code), None)
async def register(
self,
project_id: uuid.UUID,
*,
code: str,
title: str,
planted_at: int | None = None,
content: str | None = None,
expected_close_from: int | None = None,
expected_close_to: int | None = None,
importance: str | None = None,
) -> ForeshadowLedgerView:
if self._find(project_id, code) is not None:
raise ValueError(f"duplicate foreshadow code: {code}")
row = _Row(
project_id=project_id,
code=code,
title=title,
status="OPEN",
planted_at=planted_at,
content=content,
expected_close_from=expected_close_from,
expected_close_to=expected_close_to,
importance=importance,
links=[],
progress=[],
)
self.rows.append(row)
return _view(row)
async def get(self, project_id: uuid.UUID, code: str) -> ForeshadowLedgerView | None:
row = self._find(project_id, code)
return _view(row) if row is not None else None
async def list_by_status(
self, project_id: uuid.UUID, status: str | None = None
) -> list[ForeshadowLedgerView]:
return [
_view(r)
for r in self.rows
if r.project_id == project_id and (status is None or r.status == status)
]
async def transition(
self, project_id: uuid.UUID, code: str, *, to_status: str
) -> ForeshadowLedgerView:
from ww_core.domain.foreshadow_state import ForeshadowStatus
from ww_core.domain.foreshadow_state import transition as apply_transition
row = self._find(project_id, code)
if row is None:
raise LookupError(f"foreshadow not found: {code}")
row.status = apply_transition(ForeshadowStatus(row.status), ForeshadowStatus(to_status))
return _view(row)
async def record_progress(
self, project_id: uuid.UUID, code: str, *, entry: dict[str, Any]
) -> ForeshadowLedgerView:
row = self._find(project_id, code)
if row is None:
raise LookupError(f"foreshadow not found: {code}")
row.progress = [*row.progress, dict(entry)]
return _view(row)
async def scan_overdue(
self, project_id: uuid.UUID, *, current_chapter: int
) -> list[ForeshadowLedgerView]:
from ww_core.domain.foreshadow_state import ForeshadowStatus, apply_overdue_scan
changed: list[ForeshadowLedgerView] = []
for r in self.rows:
if r.project_id != project_id:
continue
new = apply_overdue_scan(
ForeshadowStatus(r.status),
current_chapter=current_chapter,
expected_close_to=r.expected_close_to,
)
if new != r.status:
r.status = new
changed.append(_view(r))
return changed
def _repo() -> FakeForeshadowLedgerRepo:
return FakeForeshadowLedgerRepo()
# ---- register ----
async def test_register_starts_open() -> None:
repo: ForeshadowLedgerRepo = _repo()
view = await repo.register(
PROJECT, code="F1", title="神秘玉佩", planted_at=3, expected_close_to=20
)
assert isinstance(view, ForeshadowLedgerView)
assert view.code == "F1"
assert view.status == "OPEN"
assert view.planted_at == 3
assert view.expected_close_to == 20
assert view.progress == []
async def test_register_duplicate_code_rejected() -> None:
repo: ForeshadowLedgerRepo = _repo()
await repo.register(PROJECT, code="F1", title="a")
with pytest.raises(ValueError):
await repo.register(PROJECT, code="F1", title="b")
async def test_register_same_code_other_project_ok() -> None:
repo: ForeshadowLedgerRepo = _repo()
await repo.register(PROJECT, code="F1", title="a")
other = await repo.register(OTHER, code="F1", title="b")
assert other.code == "F1"
# ---- get / list_by_status ----
async def test_get_returns_none_when_absent() -> None:
repo: ForeshadowLedgerRepo = _repo()
assert await repo.get(PROJECT, "nope") is None
async def test_list_by_status_filters() -> None:
repo: ForeshadowLedgerRepo = _repo()
await repo.register(PROJECT, code="A", title="a")
b = await repo.register(PROJECT, code="B", title="b")
assert b.status == "OPEN"
await repo.transition(PROJECT, "B", to_status=PARTIAL)
opens = await repo.list_by_status(PROJECT, "OPEN")
assert {v.code for v in opens} == {"A"}
partials = await repo.list_by_status(PROJECT, PARTIAL)
assert {v.code for v in partials} == {"B"}
async def test_list_by_status_none_returns_all() -> None:
repo: ForeshadowLedgerRepo = _repo()
await repo.register(PROJECT, code="A", title="a")
await repo.register(PROJECT, code="B", title="b")
assert len(await repo.list_by_status(PROJECT)) == 2
async def test_list_by_status_isolated_by_project() -> None:
repo: ForeshadowLedgerRepo = _repo()
await repo.register(PROJECT, code="A", title="a")
await repo.register(OTHER, code="B", title="b")
rows = await repo.list_by_status(PROJECT)
assert len(rows) == 1 and rows[0].code == "A"
# ---- transition (走状态机) ----
async def test_transition_applies_state_machine() -> None:
repo: ForeshadowLedgerRepo = _repo()
await repo.register(PROJECT, code="A", title="a")
v = await repo.transition(PROJECT, "A", to_status=PARTIAL)
assert v.status == "PARTIAL"
v2 = await repo.transition(PROJECT, "A", to_status=CLOSED)
assert v2.status == "CLOSED"
async def test_transition_rejects_illegal() -> None:
repo: ForeshadowLedgerRepo = _repo()
await repo.register(PROJECT, code="A", title="a")
await repo.transition(PROJECT, "A", to_status=CLOSED)
with pytest.raises(InvalidTransition):
await repo.transition(PROJECT, "A", to_status=PARTIAL)
# ---- record_progress (append-only) ----
async def test_record_progress_appends_not_overwrites() -> None:
repo: ForeshadowLedgerRepo = _repo()
await repo.register(PROJECT, code="A", title="a")
await repo.record_progress(PROJECT, "A", entry={"chapter": 5, "note": "first hint"})
v = await repo.record_progress(PROJECT, "A", entry={"chapter": 9, "note": "second"})
assert len(v.progress) == 2
assert v.progress[0]["chapter"] == 5
assert v.progress[1]["chapter"] == 9
# ---- scan_overdue ----
async def test_scan_overdue_marks_due_rows() -> None:
repo: ForeshadowLedgerRepo = _repo()
await repo.register(PROJECT, code="DUE", title="d", expected_close_to=10)
await repo.register(PROJECT, code="NOTYET", title="n", expected_close_to=30)
await repo.register(PROJECT, code="NOWIN", title="w") # 无窗口
changed = await repo.scan_overdue(PROJECT, current_chapter=15)
assert {v.code for v in changed} == {"DUE"}
assert all(v.status == "OVERDUE" for v in changed)
# NOTYET / NOWIN 保持 OPEN
assert (await repo.get(PROJECT, "NOTYET")).status == "OPEN" # type: ignore[union-attr]
async def test_scan_overdue_skips_closed() -> None:
repo: ForeshadowLedgerRepo = _repo()
await repo.register(PROJECT, code="C", title="c", expected_close_to=10)
await repo.transition(PROJECT, "C", to_status=CLOSED)
changed = await repo.scan_overdue(PROJECT, current_chapter=99)
assert changed == []
assert (await repo.get(PROJECT, "C")).status == "CLOSED" # type: ignore[union-attr]
async def test_scan_overdue_isolated_by_project() -> None:
repo: ForeshadowLedgerRepo = _repo()
await repo.register(OTHER, code="X", title="x", expected_close_to=5)
changed = await repo.scan_overdue(PROJECT, current_chapter=99)
assert changed == [] # OTHER 的行不受 PROJECT 扫描影响
# ---- frozen View ----
async def test_view_is_frozen() -> None:
repo: ForeshadowLedgerRepo = _repo()
v = await repo.register(PROJECT, code="A", title="a")
with pytest.raises(ValidationError):
v.status = OVERDUE

View File

@@ -0,0 +1,143 @@
"""T3.1 伏笔状态机纯函数单测ARCH §6.2)。
确定性纯函数,无 IO覆盖全转移登记/首次照应/完成回收)+ OVERDUE 到期判据边界
current ==/</> expected_close_toCLOSED 不被覆盖;无 expected_close_to 不 OVERDUE+
非法转移显式处理。
"""
from __future__ import annotations
import pytest
from ww_core.domain.foreshadow_state import (
CLOSED,
OPEN,
OVERDUE,
PARTIAL,
ForeshadowStatus,
InvalidTransition,
apply_overdue_scan,
is_overdue,
transition,
)
# ---- 合法转移登记→OPEN→PARTIAL→CLOSED----
def test_open_to_partial() -> None:
assert transition(OPEN, PARTIAL) == PARTIAL
def test_partial_to_closed() -> None:
assert transition(PARTIAL, CLOSED) == CLOSED
def test_open_to_closed_direct_recover() -> None:
# 一步回收(埋后直接收)也合法
assert transition(OPEN, CLOSED) == CLOSED
def test_overdue_to_closed_recover_after_due() -> None:
# 逾期后仍可回收
assert transition(OVERDUE, CLOSED) == CLOSED
def test_overdue_to_partial_partial_recover_after_due() -> None:
assert transition(OVERDUE, PARTIAL) == PARTIAL
# ---- 非法转移显式拒绝 ----
def test_closed_cannot_reopen() -> None:
with pytest.raises(InvalidTransition):
transition(CLOSED, OPEN)
def test_closed_cannot_become_overdue_via_transition() -> None:
with pytest.raises(InvalidTransition):
transition(CLOSED, OVERDUE)
def test_closed_cannot_become_partial() -> None:
with pytest.raises(InvalidTransition):
transition(CLOSED, PARTIAL)
def test_partial_cannot_go_back_to_open() -> None:
with pytest.raises(InvalidTransition):
transition(PARTIAL, OPEN)
def test_same_status_is_noop_allowed() -> None:
# 幂等:同态转移允许(看板手动设回同值不报错)
assert transition(OPEN, OPEN) == OPEN
assert transition(PARTIAL, PARTIAL) == PARTIAL
# ---- is_overdue 判据边界 ----
def test_is_overdue_strictly_past_window() -> None:
assert is_overdue(current_chapter=11, expected_close_to=10, status=OPEN) is True
def test_is_overdue_at_window_boundary_not_overdue() -> None:
# current == expected_close_to仍在窗口内未逾期
assert is_overdue(current_chapter=10, expected_close_to=10, status=OPEN) is False
def test_is_overdue_before_window_not_overdue() -> None:
assert is_overdue(current_chapter=5, expected_close_to=10, status=OPEN) is False
def test_is_overdue_closed_never_overdue() -> None:
assert is_overdue(current_chapter=99, expected_close_to=10, status=CLOSED) is False
def test_is_overdue_partial_can_be_overdue() -> None:
assert is_overdue(current_chapter=11, expected_close_to=10, status=PARTIAL) is True
def test_is_overdue_without_window_never_overdue() -> None:
# 无 expected_close_to → 永不逾期
assert is_overdue(current_chapter=999, expected_close_to=None, status=OPEN) is False
def test_is_overdue_already_overdue_stays() -> None:
assert is_overdue(current_chapter=11, expected_close_to=10, status=OVERDUE) is True
# ---- apply_overdue_scan ----
def test_apply_overdue_scan_sets_overdue_when_due() -> None:
new = apply_overdue_scan(OPEN, current_chapter=11, expected_close_to=10)
assert new == OVERDUE
def test_apply_overdue_scan_keeps_status_when_not_due() -> None:
assert apply_overdue_scan(OPEN, current_chapter=10, expected_close_to=10) == OPEN
def test_apply_overdue_scan_does_not_touch_closed() -> None:
# CLOSED 不被 OVERDUE 覆盖(不变量)
assert apply_overdue_scan(CLOSED, current_chapter=99, expected_close_to=10) == CLOSED
def test_apply_overdue_scan_no_window_keeps_status() -> None:
assert apply_overdue_scan(PARTIAL, current_chapter=99, expected_close_to=None) == PARTIAL
def test_apply_overdue_scan_idempotent_on_overdue() -> None:
assert apply_overdue_scan(OVERDUE, current_chapter=12, expected_close_to=10) == OVERDUE
# ---- 枚举值与 DB 文本一致 ----
def test_status_values_match_db_text() -> None:
assert OPEN == "OPEN"
assert PARTIAL == "PARTIAL"
assert CLOSED == "CLOSED"
assert OVERDUE == "OVERDUE"
assert {s.value for s in ForeshadowStatus} == {"OPEN", "PARTIAL", "CLOSED", "OVERDUE"}

View File

@@ -0,0 +1,137 @@
"""T3.4 大纲节点单测——build_outline_request + run_outlineC6 扩 / ARCH §5.4 §6.2)。
注入 mock 网关(产 `OutlineResult` parsed无真 LLM、无真 Postgres。
大纲节点是裸函数(独立生成,不在写章/审稿流水线);只产结构化大纲,**不写库**
(持久化属 T3.5 验收/端点,不变量 #3。asyncio_mode=auto。
"""
from __future__ import annotations
import uuid
import pytest
from fakes_orchestrator import FailingRunGateway, FakeRunGateway
from ww_agents import (
ForeshadowWindow,
OutlineChapter,
OutlineResult,
outliner_spec,
)
from ww_core.orchestrator import build_outline_request, run_outline
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
CONTEXT = "## 立意\n复仇成长\n## 已开伏笔\nFS-BIRTH: 身世之谜待第20-30章回收"
_OUTLINE = OutlineResult(
chapters=[
OutlineChapter(
no=1,
beats=["主角登场", "埋身世伏笔"],
foreshadow_windows=[
ForeshadowWindow(
code="FS-BIRTH",
plant_chapter=1,
expected_close_from=20,
expected_close_to=30,
)
],
),
OutlineChapter(no=2, beats=["遭遇对手"], foreshadow_windows=[]),
]
)
# ---- build_outline_requesttier 不变量、缓存断点、output_schema、run 非 stream ----
def test_build_outline_request_uses_spec_tier_and_cached_prompt() -> None:
req = build_outline_request(outliner_spec, context=CONTEXT, user_id=USER, project_id=PROJECT)
assert req.tier == "analyst" # 不变量②spec 档位透传,不传 model
assert req.stream is False # 排大纲用 run() 非 stream()
assert len(req.system) == 1
assert req.system[0].text == outliner_spec.system_prompt
assert req.system[0].cache is True # 不变量#9
assert req.input == CONTEXT
assert req.output_schema is OutlineResult
assert req.scope.user_id == USER
assert req.scope.project_id == PROJECT
# ---- run_outline返回结构化大纲含 foreshadow_windows只读不写库 ----
async def test_run_outline_returns_structured_outline() -> None:
gateway = FakeRunGateway(_OUTLINE)
result = await run_outline(
outliner_spec, context=CONTEXT, gateway=gateway, user_id=USER, project_id=PROJECT
)
assert isinstance(result, OutlineResult)
assert len(result.chapters) == 2
assert result.chapters[0].no == 1
window = result.chapters[0].foreshadow_windows[0]
assert window.code == "FS-BIRTH"
assert window.expected_close_to == 30
assert gateway.call_count == 1
async def test_run_outline_forwards_constructed_request() -> None:
gateway = FakeRunGateway(_OUTLINE)
await run_outline(
outliner_spec, context=CONTEXT, gateway=gateway, user_id=USER, project_id=PROJECT
)
req = gateway.last_request
assert req is not None
assert req.tier == "analyst"
assert req.output_schema is OutlineResult
assert req.input == CONTEXT
async def test_run_outline_raises_when_gateway_fails() -> None:
# 大纲是独立生成(非流水线并行审);网关失败直接上抛,端点/T3.5 处理。
gateway = FailingRunGateway(RuntimeError("provider down"))
with pytest.raises(RuntimeError):
await run_outline(
outliner_spec,
context=CONTEXT,
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
async def test_run_outline_raises_when_parsed_missing() -> None:
# 带 output_schema 时 parsed 必非 NoneC1若网关违约返回 None明确报错而非静默。
class _NoParseGateway:
async def run(self, req: object) -> object:
from ww_llm_gateway.types import LlmResponse, ServedBy, Usage
return LlmResponse(
text="{}",
parsed=None,
usage=Usage(
provider="fake",
model="fake-analyst",
input_tokens=1,
output_tokens=1,
cost_minor=0,
currency="USD",
),
served_by=ServedBy(provider="fake", model="fake-analyst"),
)
with pytest.raises(ValueError, match="parsed"):
await run_outline(
outliner_spec,
context=CONTEXT,
gateway=_NoParseGateway(), # type: ignore[arg-type]
user_id=USER,
project_id=PROJECT,
)

View File

@@ -0,0 +1,125 @@
"""T3.5 大纲写侧 repo 单测ARCH §5.4 / §7.2)。
纯内存 fake无 DB断言 upsert 幂等覆盖、按 project_id 隔离、frozen View 不可变、
`_to_view` 的 beats 包裹/解包 round-trip。`(project_id, chapter_no)` 唯一性是 **DB 级**
models UniqueConstraint纯 fake 不引 pg 断言——真实 DB 路径由 T3.7 E2E 覆盖。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import Any
import pytest
from pydantic import ValidationError
from ww_core.domain.outline_write_repo import (
OutlineWriteRepo,
OutlineWriteView,
_to_view,
)
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
OTHER = uuid.UUID("00000000-0000-0000-0000-000000000002")
@dataclass
class _Row:
"""模拟 ORM Outline 行beats 是 JSONB dict、foreshadow_windows 是 JSONB list"""
project_id: uuid.UUID
volume: int
chapter_no: int
beats: dict[str, Any] | None
foreshadow_windows: list[Any] = field(default_factory=list)
@dataclass
class FakeOutlineWriteRepo:
"""实现 `OutlineWriteRepo` Protocol内存幂等覆盖同 (project_id, chapter_no))。"""
rows: dict[tuple[uuid.UUID, int], _Row] = field(default_factory=dict)
async def upsert_chapter(
self,
project_id: uuid.UUID,
*,
volume: int,
chapter_no: int,
beats: list[str],
foreshadow_windows: list[dict[str, Any]],
) -> OutlineWriteView:
row = self.rows.get((project_id, chapter_no))
beats_payload = {"beats": list(beats)}
windows = [dict(w) for w in foreshadow_windows]
if row is None:
row = _Row(
project_id=project_id,
volume=volume,
chapter_no=chapter_no,
beats=beats_payload,
foreshadow_windows=windows,
)
else:
row.volume = volume
row.beats = beats_payload
row.foreshadow_windows = windows
self.rows[(project_id, chapter_no)] = row
return _to_view(row) # type: ignore[arg-type]
def _repo() -> OutlineWriteRepo:
return FakeOutlineWriteRepo()
@pytest.mark.asyncio
async def test_upsert_inserts_new_chapter() -> None:
repo = _repo()
view = await repo.upsert_chapter(
PROJECT,
volume=1,
chapter_no=1,
beats=["a", "b"],
foreshadow_windows=[{"code": "F1", "expected_close_to": 10}],
)
assert view.chapter_no == 1
assert view.volume == 1
assert view.beats == ["a", "b"]
assert view.foreshadow_windows[0]["code"] == "F1"
@pytest.mark.asyncio
async def test_upsert_overwrites_same_chapter() -> None:
repo = _repo()
await repo.upsert_chapter(PROJECT, volume=1, chapter_no=1, beats=["old"], foreshadow_windows=[])
view = await repo.upsert_chapter(
PROJECT, volume=2, chapter_no=1, beats=["new"], foreshadow_windows=[]
)
assert view.beats == ["new"]
assert view.volume == 2
@pytest.mark.asyncio
async def test_upsert_isolated_by_project() -> None:
repo = FakeOutlineWriteRepo()
await repo.upsert_chapter(PROJECT, volume=1, chapter_no=1, beats=["p"], foreshadow_windows=[])
await repo.upsert_chapter(OTHER, volume=1, chapter_no=1, beats=["o"], foreshadow_windows=[])
assert len(repo.rows) == 2
def test_to_view_unpacks_wrapped_beats() -> None:
row = _Row(project_id=PROJECT, volume=1, chapter_no=3, beats={"beats": ["x", "y"]})
view = _to_view(row) # type: ignore[arg-type]
assert view.beats == ["x", "y"]
def test_to_view_handles_null_beats() -> None:
row = _Row(project_id=PROJECT, volume=1, chapter_no=3, beats=None)
view = _to_view(row) # type: ignore[arg-type]
assert view.beats == []
def test_write_view_is_frozen() -> None:
view = OutlineWriteView(chapter_no=1, volume=1, beats=["a"])
with pytest.raises(ValidationError):
view.volume = 2

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

View File

@@ -9,6 +9,27 @@ from ww_core.domain.chapter_repo import (
SqlChapterRepo,
)
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
from ww_core.domain.foreshadow_repo import (
ForeshadowLedgerRepo,
ForeshadowLedgerView,
SqlForeshadowLedgerRepo,
)
from ww_core.domain.foreshadow_state import (
CLOSED,
OPEN,
OVERDUE,
PARTIAL,
ForeshadowStatus,
InvalidTransition,
apply_overdue_scan,
is_overdue,
transition,
)
from ww_core.domain.outline_write_repo import (
OutlineWriteRepo,
OutlineWriteView,
SqlOutlineWriteRepo,
)
from ww_core.domain.project_repo import (
ProjectCreate,
ProjectRepo,
@@ -26,7 +47,22 @@ __all__ = [
"DigestAppendRepo",
"SqlDigestAppendRepo",
"DigestView",
"ForeshadowLedgerRepo",
"ForeshadowLedgerView",
"SqlForeshadowLedgerRepo",
"ForeshadowStatus",
"InvalidTransition",
"OPEN",
"PARTIAL",
"CLOSED",
"OVERDUE",
"apply_overdue_scan",
"is_overdue",
"transition",
"MemoryRepos",
"OutlineWriteRepo",
"OutlineWriteView",
"SqlOutlineWriteRepo",
"ProjectCreate",
"ProjectRepo",
"ProjectView",

View File

@@ -0,0 +1,219 @@
"""伏笔账本**写侧** Repository登记/状态机/进展/到期扫描ARCH §6.2 / PRODUCT_SPEC §4.2)。
读侧(`list_for_codes`,供 assemble 注入伏笔窗口)已在 `ww_core.memory.sql_repositories`
+ `domain.repositories.ForeshadowRepo`/`ForeshadowView` 提供(最小 ViewC5 稳定,不动)。
本模块加**写**能力 + 看板查询,命名加 `Ledger` 前缀避免与读侧同名歧义
(同 T2.3 `DigestAppendRepo` 区别于读侧 `DigestRepo` 的先例,见 memory/decisions
`ForeshadowLedgerView` 比读侧 View 多 `importance/links/progress`(看板/账本需要)。
**提交边界**:所有写方法**只 `flush()` 不 `commit()`**——提交交调用方:
- T3.2 验收后扫描挂 BackgroundTask / 接 §5.5 `TODO(M3)`,由那条事务/任务提交;
- T3.5 登记/状态端点由端点事务提交。
与 M2 验收-side repos 一致(写库副作用归编排/事务层,见 memory/gotchas
含 nullable 列的唯一约束 `(project_id, code)` 不用 PG `ON CONFLICT`——`register` 仅
显式 INSERT重复 code 让 DB 唯一约束/调用方处理),不做 upsert见 gotcha
"""
from __future__ import annotations
import uuid
from typing import Any, Protocol
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import Foreshadow
from ww_core.domain.foreshadow_state import (
ForeshadowStatus,
apply_overdue_scan,
)
from ww_core.domain.foreshadow_state import (
transition as apply_transition,
)
class ForeshadowLedgerView(BaseModel):
"""伏笔账本只读快照snake_casefrozen——看板/账本用,含进展与重要度。"""
model_config = {"frozen": True}
code: str
title: str
status: str
planted_at: int | None = None
content: str | None = None
expected_close_from: int | None = None
expected_close_to: int | None = None
importance: str | None = None
links: list[dict[str, Any]] = Field(default_factory=list)
progress: list[dict[str, Any]] = Field(default_factory=list)
class ForeshadowLedgerRepo(Protocol):
"""伏笔账本写侧接口(按 project_id 隔离;写方法只 flush 不 commit"""
async def register(
self,
project_id: uuid.UUID,
*,
code: str,
title: str,
planted_at: int | None = None,
content: str | None = None,
expected_close_from: int | None = None,
expected_close_to: int | None = None,
importance: str | None = None,
) -> ForeshadowLedgerView:
"""登记一条伏笔status=OPEN`(project_id, code)` 唯一。"""
...
async def get(self, project_id: uuid.UUID, code: str) -> ForeshadowLedgerView | None:
"""按 code 取单条,无则 None。"""
...
async def list_by_status(
self, project_id: uuid.UUID, status: str | None = None
) -> list[ForeshadowLedgerView]:
"""看板查询按状态过滤None=全部)。供 T3.5 `GET /foreshadow?status=`。"""
...
async def transition(
self, project_id: uuid.UUID, code: str, *, to_status: str
) -> ForeshadowLedgerView:
"""应用状态机校验后写 `status`(非法转移抛 `InvalidTransition`)。"""
...
async def record_progress(
self, project_id: uuid.UUID, code: str, *, entry: dict[str, Any]
) -> ForeshadowLedgerView:
"""append 一条进展到 `progress` JSONBappend-only不覆盖历史"""
...
async def scan_overdue(
self, project_id: uuid.UUID, *, current_chapter: int
) -> list[ForeshadowLedgerView]:
"""到期扫描:命中行置 OVERDUE返回**被改的行**。供 T3.2 验收后扫描。"""
...
def _to_view(row: Foreshadow) -> ForeshadowLedgerView:
return ForeshadowLedgerView(
code=row.code,
title=row.title,
status=row.status,
planted_at=row.planted_at,
content=row.content,
expected_close_from=row.expected_close_from,
expected_close_to=row.expected_close_to,
importance=row.importance,
links=list(row.links or []),
progress=list(row.progress or []),
)
class SqlForeshadowLedgerRepo:
"""SQLAlchemy 实现:登记 + 状态机转移 + 进展 append + 到期扫描(只 flush 不 commit"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def _find(self, project_id: uuid.UUID, code: str) -> Foreshadow | None:
return (
await self._s.execute(
select(Foreshadow).where(
Foreshadow.project_id == project_id,
Foreshadow.code == code,
)
)
).scalar_one_or_none()
async def register(
self,
project_id: uuid.UUID,
*,
code: str,
title: str,
planted_at: int | None = None,
content: str | None = None,
expected_close_from: int | None = None,
expected_close_to: int | None = None,
importance: str | None = None,
) -> ForeshadowLedgerView:
# 仅 INSERT唯一约束 (project_id, code) 由 DB 强制(不做 ON CONFLICT upsert见模块注释
row = Foreshadow(
project_id=project_id,
code=code,
title=title,
status=ForeshadowStatus.OPEN.value,
planted_at=planted_at,
content=content,
expected_close_from=expected_close_from,
expected_close_to=expected_close_to,
importance=importance,
links=[],
progress=[],
)
self._s.add(row)
await self._s.flush()
await self._s.refresh(row)
return _to_view(row)
async def get(self, project_id: uuid.UUID, code: str) -> ForeshadowLedgerView | None:
row = await self._find(project_id, code)
return _to_view(row) if row is not None else None
async def list_by_status(
self, project_id: uuid.UUID, status: str | None = None
) -> list[ForeshadowLedgerView]:
stmt = select(Foreshadow).where(Foreshadow.project_id == project_id)
if status is not None:
stmt = stmt.where(Foreshadow.status == status)
rows = (await self._s.execute(stmt.order_by(Foreshadow.code))).scalars()
return [_to_view(r) for r in rows]
async def transition(
self, project_id: uuid.UUID, code: str, *, to_status: str
) -> ForeshadowLedgerView:
row = await self._find(project_id, code)
if row is None:
raise LookupError(f"foreshadow not found: {code}")
# 状态机校验(非法转移抛 InvalidTransition事务回滚由调用方控制
row.status = apply_transition(
ForeshadowStatus(row.status), ForeshadowStatus(to_status)
).value
await self._s.flush()
await self._s.refresh(row)
return _to_view(row)
async def record_progress(
self, project_id: uuid.UUID, code: str, *, entry: dict[str, Any]
) -> ForeshadowLedgerView:
row = await self._find(project_id, code)
if row is None:
raise LookupError(f"foreshadow not found: {code}")
# 不可变 append建新 list 重赋值,确保 ORM 侦测 JSONB 变更(避免原地 mutate 不脏标记)。
row.progress = [*(row.progress or []), dict(entry)]
await self._s.flush()
await self._s.refresh(row)
return _to_view(row)
async def scan_overdue(
self, project_id: uuid.UUID, *, current_chapter: int
) -> list[ForeshadowLedgerView]:
rows = (
await self._s.execute(select(Foreshadow).where(Foreshadow.project_id == project_id))
).scalars()
changed: list[ForeshadowLedgerView] = []
for row in rows:
new = apply_overdue_scan(
ForeshadowStatus(row.status),
current_chapter=current_chapter,
expected_close_to=row.expected_close_to,
).value
if new != row.status:
row.status = new
changed.append(_to_view(row))
if changed:
await self._s.flush()
return changed

View File

@@ -0,0 +1,105 @@
"""伏笔账本状态机——确定性纯函数ARCH §6.2 / PRODUCT_SPEC §4.2)。
状态机§6.2::
登记 首次照应 完成回收
─────▶ OPEN ──────▶ PARTIAL ─────────▶ CLOSED
│ │
└─────────────┴──▶ OVERDUE (章号 > expected_close_to 且未 CLOSED)
(到期扫描置位; 回收后可转 CLOSED)
- 登记 → OPENDB server_default
- 首次照应 → PARTIAL。
- 完成回收 → CLOSED可从 OPEN/PARTIAL/OVERDUE 一步到达)。
- 到期判定M3-d 纯函数,非 Agent
`current_chapter > expected_close_to AND status≠CLOSED → OVERDUE`。
- **CLOSED 终态**:不被 OVERDUE 覆盖、不可重开(非法转移显式抛 `InvalidTransition`)。
全部为纯函数:不读 DB、不可变、可单测。写库由 `foreshadow_repo` 落地(只 flush
枚举值与 DB `foreshadow.status` 文本严格一致(`StrEnum` → 直接当字符串用)。
"""
from __future__ import annotations
from enum import StrEnum
class ForeshadowStatus(StrEnum):
"""伏笔状态——值与 DB `status` 文本一致。"""
OPEN = "OPEN"
PARTIAL = "PARTIAL"
CLOSED = "CLOSED"
OVERDUE = "OVERDUE"
# 便捷常量(供 repo / 测试 / 调用方按名引用,避免散落字面量)。
OPEN = ForeshadowStatus.OPEN
PARTIAL = ForeshadowStatus.PARTIAL
CLOSED = ForeshadowStatus.CLOSED
OVERDUE = ForeshadowStatus.OVERDUE
class InvalidTransition(ValueError):
"""非法状态转移(如 CLOSED 重开)。"""
# 合法转移图from → 允许到达的状态集合(不含同态幂等,单独放行)。
_ALLOWED: dict[ForeshadowStatus, frozenset[ForeshadowStatus]] = {
OPEN: frozenset({PARTIAL, CLOSED, OVERDUE}),
PARTIAL: frozenset({CLOSED, OVERDUE}),
OVERDUE: frozenset({PARTIAL, CLOSED}), # 逾期后仍可(部分)回收
CLOSED: frozenset(), # 终态:不可再转移
}
def transition(current: ForeshadowStatus, to_status: ForeshadowStatus) -> ForeshadowStatus:
"""应用状态机校验后返回新状态(纯函数)。
- 同态current == to_status幂等放行。
- CLOSED 为终态:任何离开 CLOSED 的转移都非法。
- 其余按 `_ALLOWED` 校验;非法 → `InvalidTransition`。
"""
if current == to_status:
return to_status
if to_status not in _ALLOWED[current]:
raise InvalidTransition(f"非法伏笔转移: {current.value}{to_status.value}")
return to_status
def is_overdue(
*,
current_chapter: int,
expected_close_to: int | None,
status: ForeshadowStatus,
) -> bool:
"""到期判据M3-d§6.2`current_chapter > expected_close_to AND status≠CLOSED`。
- 无 `expected_close_to`(未设回收窗口)→ 永不逾期。
- CLOSED → 永不逾期(已回收)。
- `current_chapter == expected_close_to` → 仍在窗口内,未逾期(严格大于才算)。
"""
if expected_close_to is None:
return False
if status == CLOSED:
return False
return current_chapter > expected_close_to
def apply_overdue_scan(
status: ForeshadowStatus,
*,
current_chapter: int,
expected_close_to: int | None,
) -> ForeshadowStatus:
"""到期扫描应用验收后触发§6.2):命中判据 → 置 `OVERDUE`,否则保持原状态。
幂等:已 OVERDUE 且仍逾期 → 保持 OVERDUECLOSED 永不被改写is_overdue 已排除)。
"""
if is_overdue(
current_chapter=current_chapter,
expected_close_to=expected_close_to,
status=status,
):
return OVERDUE
return status

View File

@@ -0,0 +1,109 @@
"""大纲**写侧** Repository逐章 upsertARCH §5.4 outliner writes=outline / §7.2 POST /outline
读侧(`get`,供 assemble 注入本章 beats已在 `ww_core.memory.sql_repositories.SqlOutlineRepo`
+ `domain.repositories.OutlineRepo`/`OutlineView` 提供C5 稳定,不动)。本模块加**写**能力,
命名加 `Write` 前缀避免与读侧 `OutlineRepo` 同名歧义(同 `DigestAppendRepo`/`ForeshadowLedgerRepo`
区别于读侧的先例,见 memory/decisions
**提交边界**`upsert_chapter` 只 `flush()` 不 `commit()`——提交交端点事务T3.5 端点末尾
一次 `commit()`,与网关 ledger 一并落库;与 M2 验收-side repos 一致,见 memory/gotchas
唯一约束 `(project_id, chapter_no)`:用**显式 read-modify-write**(按 chapter_no 查→有则改、
无则插),不用 PG `ON CONFLICT`(保持与项目其它 upsert 路径一致、可纯 fake 单测,不引 pg 依赖)。
**beats 存储形**DB 列 `outline.beats` 是 JSONB `dict`,而 outliner 产 `list[str]`——
故包裹成 `{"beats": [...]}` 落库,读侧 `OutlineView.beats` 取到同形 dictround-trip 一致)。
`foreshadow_windows` 是 JSONB list直接落 `[w.model_dump() for w in chapter.foreshadow_windows]`。
"""
from __future__ import annotations
import uuid
from typing import Any, Protocol
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import Outline
class OutlineWriteView(BaseModel):
"""单章大纲写入后的只读快照snake_casefrozen"""
model_config = {"frozen": True}
chapter_no: int
volume: int
beats: list[str] = Field(default_factory=list)
foreshadow_windows: list[dict[str, Any]] = Field(default_factory=list)
class OutlineWriteRepo(Protocol):
"""大纲写侧接口(按 project_id 隔离;唯一 `(project_id, chapter_no)`;只 flush 不 commit"""
async def upsert_chapter(
self,
project_id: uuid.UUID,
*,
volume: int,
chapter_no: int,
beats: list[str],
foreshadow_windows: list[dict[str, Any]],
) -> OutlineWriteView:
"""逐章 upsert同 `(project_id, chapter_no)` 覆盖,否则插入。只 flush。"""
...
def _to_view(row: Outline) -> OutlineWriteView:
raw = row.beats or {}
beats = list(raw.get("beats", [])) if isinstance(raw, dict) else []
return OutlineWriteView(
chapter_no=row.chapter_no,
volume=row.volume,
beats=beats,
foreshadow_windows=list(row.foreshadow_windows or []),
)
class SqlOutlineWriteRepo:
"""SQLAlchemy 实现:显式 read-modify-write upsert只 flush 不 commit"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def upsert_chapter(
self,
project_id: uuid.UUID,
*,
volume: int,
chapter_no: int,
beats: list[str],
foreshadow_windows: list[dict[str, Any]],
) -> OutlineWriteView:
row = (
await self._s.execute(
select(Outline).where(
Outline.project_id == project_id,
Outline.chapter_no == chapter_no,
)
)
).scalar_one_or_none()
# JSONB 写入建新对象重赋值(避免原地 mutate 不被 ORM 侦测,见 gotcha
beats_payload = {"beats": list(beats)}
windows_payload = [dict(w) for w in foreshadow_windows]
if row is None:
row = Outline(
project_id=project_id,
volume=volume,
chapter_no=chapter_no,
beats=beats_payload,
foreshadow_windows=windows_payload,
)
self._s.add(row)
else:
row.volume = volume
row.beats = beats_payload
row.foreshadow_windows = windows_payload
await self._s.flush()
await self._s.refresh(row)
return _to_view(row)

View File

@@ -10,9 +10,13 @@ from __future__ import annotations
from .collect import (
CONTINUITY,
FORESHADOW,
PACE,
ReviewRecorder,
collect_reviews,
extract_conflicts,
extract_foreshadow_sug,
extract_pace,
)
from .graph import (
COLLECT_NODE,
@@ -22,6 +26,7 @@ from .graph import (
build_write_graph,
setup_checkpointer,
)
from .outline_node import build_outline_request, run_outline
from .review_node import (
REVIEW_INCOMPLETE,
REVIEW_OK,
@@ -36,6 +41,8 @@ from .sse import (
EVENT_CONFLICT,
EVENT_DONE,
EVENT_ERROR,
EVENT_FORESHADOW,
EVENT_PACE,
EVENT_SECTION,
EVENT_TOKEN,
SECTION_DONE,
@@ -45,8 +52,10 @@ from .sse import (
conflict_event,
done_event,
error_event,
foreshadow_event,
normalize_deltas,
normalize_review,
pace_event,
section_event,
token_event,
)
@@ -59,8 +68,12 @@ __all__ = [
"EVENT_CONFLICT",
"EVENT_DONE",
"EVENT_ERROR",
"EVENT_FORESHADOW",
"EVENT_PACE",
"EVENT_SECTION",
"EVENT_TOKEN",
"FORESHADOW",
"PACE",
"REVIEW_INCOMPLETE",
"REVIEW_OK",
"REVIEW_SPECS",
@@ -74,6 +87,7 @@ __all__ = [
"GatewayStream",
"ReviewRecorder",
"SseEvent",
"build_outline_request",
"build_review_context",
"build_review_graph",
"build_review_request",
@@ -84,10 +98,15 @@ __all__ = [
"done_event",
"error_event",
"extract_conflicts",
"extract_foreshadow_sug",
"extract_pace",
"foreshadow_event",
"make_review_node",
"merge_reviews",
"normalize_deltas",
"normalize_review",
"pace_event",
"run_outline",
"run_review",
"section_event",
"setup_checkpointer",

View File

@@ -1,17 +1,21 @@
"""collect 节点C4 扩 / ARCH §5.2 collect 行)——汇总并行审 → 落 `chapter_reviews` 留痕。
并行审各把 `{spec.name: {status, result}}` 合并进 `state["reviews"]`reducer见 state.py
collect 把它们汇总, `review_repo.record(...)` append 一行 `chapter_reviews`(真相源留痕)
并行审各把 `{spec.name: {status, result}}` 合并进 `state["reviews"]`reducer见 state.py
collect 把它们汇总,**一次** `review_repo.record(...)` append 一行 `chapter_reviews`(真相源留痕)
按 spec.name 把各审结果映射到对应列。
不变量 #3审稿节点只读不写库**唯一写入点是这里经 `review_repo`**(仍是「留痕」非「生效」,
AI 产出真正入库经 T2.4 验收事务的裁决)。
AI 产出真正入库经验收事务的裁决)。
记账边界gotcha「网关 ledger 只 flush」审稿经网关产 usage网关内 `flush` 进同一 session
collect 与 review_repo 同样**只 flush 不 commit**——`commit` 归 HTTP 端点T2.5/验收事务T2.4
与 draft 端点一致(端点在流耗尽后 `await session.commit()`)。本节点绝不 commit。
collect 与 review_repo 同样**只 flush 不 commit**——`commit` 归 HTTP 端点 / 验收事务。
本节点绝不 commit。
M2 只接 continuity从 `reviews["continuity"].result.conflicts` 抽冲突落 `conflicts` 列;
foreshadow/style/pace 列留空M3/M4 接入后填)。设计成按 spec 名取分项,便于扩展。
M3 三审齐 → 列映射(一次 record 落齐本章三审):
- continuity → `conflicts`(冲突清单 list
- foreshadow → `foreshadow_sug`planted/resolved 扁平为建议 list每条带 `kind` 标记);
- pace → `pace`(节奏诊断 dictwater/hook/beat_map
style 第四审留 M4。任一审 `incomplete`网关失败隔离§5.2)→ 该列留空/None不阻塞其余。
"""
from __future__ import annotations
@@ -27,6 +31,12 @@ from .state import ChapterState
log = structlog.get_logger(__name__)
CONTINUITY = "continuity"
FORESHADOW = "foreshadow"
PACE = "pace"
# foreshadow 建议扁平化时的来源标记planted=新埋 / resolved=回收)。
FORESHADOW_KIND_PLANTED = "planted"
FORESHADOW_KIND_RESOLVED = "resolved"
class ReviewRecorder(Protocol):
@@ -49,17 +59,53 @@ class ReviewRecorder(Protocol):
) -> Any: ...
def _ok_result(reviews: dict[str, Any], name: str) -> dict[str, Any] | None:
"""取某审的结果 dict仅当 `ok` 且有结果;否则 Noneincomplete/缺席)。"""
entry = reviews.get(name)
if not entry or entry.get("status") != REVIEW_OK:
return None
result = entry.get("result")
return result if isinstance(result, dict) else None
def extract_conflicts(reviews: dict[str, Any]) -> list[dict[str, Any]]:
"""从 continuity 分项抽冲突清单(纯函数)。
仅当该审 `ok` 且有结果时取其 `conflicts`;未完成/缺席 → 空列表(不臆造)。
"""
entry = reviews.get(CONTINUITY)
if not entry or entry.get("status") != REVIEW_OK:
result = _ok_result(reviews, CONTINUITY)
if result is None:
return []
result = entry.get("result") or {}
conflicts = result.get("conflicts") or []
return [dict(c) for c in conflicts]
return [dict(c) for c in result.get("conflicts") or []]
def extract_foreshadow_sug(reviews: dict[str, Any]) -> list[dict[str, Any]]:
"""从 foreshadow 分项扁平化建议清单(纯函数)。
`ForeshadowReview{planted,resolved}` → 单个 list每条加 `kind` 标记来源
`foreshadow_sug` 列是 JSONB list。未完成/缺席 → 空列表。
"""
result = _ok_result(reviews, FORESHADOW)
if result is None:
return []
out: list[dict[str, Any]] = []
for sug in result.get("planted") or []:
out.append({**dict(sug), "kind": FORESHADOW_KIND_PLANTED})
for sug in result.get("resolved") or []:
out.append({**dict(sug), "kind": FORESHADOW_KIND_RESOLVED})
return out
def extract_pace(reviews: dict[str, Any]) -> dict[str, Any] | None:
"""从 pace 分项取节奏诊断 dict纯函数
`PaceReview{water,hook,beat_map}` 整体入 `pace` 列JSONB dict
未完成/缺席 → None列留空§5.2 不阻塞其余)。
"""
result = _ok_result(reviews, PACE)
if result is None:
return None
return dict(result)
async def collect_reviews(
@@ -67,25 +113,32 @@ async def collect_reviews(
*,
review_repo: ReviewRecorder,
) -> dict[str, Any]:
"""collect 节点:汇总并行审 → 抽冲突 → `review_repo.record` 留痕(只 flush 不 commit
"""collect 节点:汇总并行审 → 各列映射 → 一次 `review_repo.record` 留痕(只 flush 不 commit
直接调用本函数(注入内存 fake repo即可单测无需图运行时。
`chapter_version=None`:留痕行不绑具体 versionT2.4 验收时再绑/裁决R3/R4
`chapter_version=None`:留痕行不绑具体 version验收时再绑/裁决R3/R4
某审 incomplete → 该列空/None不阻塞其余审落库§5.2)。
返回空增量字典——审稿真相已在表state 不作真相源(不变量 #5
"""
reviews = state.get("reviews") or {}
conflicts = extract_conflicts(reviews)
foreshadow_sug = extract_foreshadow_sug(reviews)
pace = extract_pace(reviews)
await review_repo.record(
state["project_id"],
state["chapter_no"],
chapter_version=None,
conflicts=conflicts,
foreshadow_sug=foreshadow_sug,
pace=pace,
)
log.info(
"collect_reviews_recorded",
project_id=str(state["project_id"]),
chapter_no=state["chapter_no"],
conflict_count=len(conflicts),
foreshadow_sug_count=len(foreshadow_sug),
has_pace=pace is not None,
reviews=sorted(reviews.keys()),
)
# 留痕已在表(真相源);不在节点 commit端点/事务层负责,见模块 docstring

View File

@@ -15,7 +15,12 @@ from collections.abc import Sequence
from typing import TYPE_CHECKING, Any
from langgraph.graph import END, START, StateGraph
from ww_agents import AgentSpec, continuity_spec
from ww_agents import (
AgentSpec,
continuity_spec,
foreshadow_spec,
pace_spec,
)
from .collect import ReviewRecorder, collect_reviews
from .review_node import GatewayRun, run_review
@@ -51,7 +56,8 @@ def build_write_graph(
return builder.compile(checkpointer=checkpointer)
REVIEW_SPECS: tuple[AgentSpec, ...] = (continuity_spec,)
# M3三审齐continuity + foreshadow + pace。文风第四审属 M4/T4.2。
REVIEW_SPECS: tuple[AgentSpec, ...] = (continuity_spec, foreshadow_spec, pace_spec)
def build_review_graph(
@@ -63,9 +69,10 @@ def build_review_graph(
) -> CompiledStateGraph[ChapterState, None, ChapterState, ChapterState]:
"""构建并编译审稿子图:`START →`(各 review spec 并行节点)`→ collect → END`。
M2 默认continuity`review_specs` 可扩(M3/M4 加 foreshadow/style/pace 三审),
图按这组 spec 循环加并行分支,全部汇入 collect任一审失败不阻塞§5.2——失败隔离
在 `run_review` 内、标 incomplete 占位)。
M3 默认三审齐(continuity + foreshadow + pace`review_specs` 可扩M4 加 style
第四审),图按这组 spec 循环加并行分支,全部汇入 collect任一审失败不阻塞§5.2——
失败隔离在 `run_review` 内、标 incomplete 占位)。`run_review`/`make_review_node` 对 spec
泛型,三审天然支持(按 `spec.output_schema` 产 parsed无 spec-specific 假设)。
`gateway` / `review_repo` 经闭包绑进节点langgraph 不收 functools.partial见 gotcha
**accept 不在本图**:本任务只交付到 collect审稿留痕落 `chapter_reviews`+ review SSE

View File

@@ -0,0 +1,81 @@
"""大纲节点C6 扩 / ARCH §5.4 outliner 行 / §6.2 伏笔窗口)。
大纲是**独立生成**——不在写章/审稿流水线(不接进 review 图。端点T3.5)直接调
`run_outline` 拿结构化 `OutlineResult`,再持久化到 `outline` 表(含 foreshadow_windows
确定性边界CLAUDE.md「LangGraph」纪律
- 节点逻辑里**无 LLM 非确定性**——不确定性藏在网关后;节点只做
`AgentSpec + context → LlmRequest` 的纯构造 + 转发 `Gateway.run` 的 `parsed`。
- 因此注入 mock 网关(产 `parsed`)即可单测,无需图运行时、无需真 Postgres。
不变量 #2agent 只声明 `tier`,绝不传具体 modelspec.tier 透传)。
不变量 #3节点**只读、不写库**——结构化大纲返回调用方,落库经端点/验收T3.5)。
不变量 #9`spec.system_prompt` 进缓存断点前块cache=True注入材料进 `input`(断点后)。
"""
from __future__ import annotations
import uuid
from typing import Protocol
import structlog
from ww_agents import AgentSpec, OutlineResult
from ww_llm_gateway.types import Block, LlmRequest, LlmResponse, Scope
log = structlog.get_logger(__name__)
class GatewayRun(Protocol):
"""大纲节点对网关的最小依赖——只需 `run`(注入真网关或 mock"""
async def run(self, req: LlmRequest) -> LlmResponse: ...
def build_outline_request(
spec: AgentSpec,
*,
context: str,
user_id: uuid.UUID,
project_id: uuid.UUID,
) -> LlmRequest:
"""据 spec + 注入材料构造大纲请求(纯函数)。
`spec.system_prompt` → `system` 缓存断点前块cache=True不变量 #9
`context`(设定/伏笔/人物/世界观的序列化文本)→ `input`(断点后)。
排大纲用 `run()` 非 `stream()`(要结构化产物,非流式正文)。
"""
return LlmRequest(
tier=spec.tier, # 不变量 #2只传档位不传 model
system=[Block(text=spec.system_prompt, cache=True)],
input=context,
output_schema=spec.output_schema,
scope=Scope(user_id=user_id, project_id=project_id),
)
async def run_outline(
spec: AgentSpec,
*,
context: str,
gateway: GatewayRun,
user_id: uuid.UUID,
project_id: uuid.UUID,
) -> OutlineResult:
"""跑大纲生成:构请求 → `gateway.run` → 返回结构化 `OutlineResult`。
裸函数(显式 gateway 关键字),注入 mock 网关即可单测,无需图运行时。
大纲是独立生成(非并行审):网关失败直接上抛(端点/T3.5 处理),不静默吞。
**只产结构化大纲、不写库**(持久化属 T3.5,不变量 #3
"""
req = build_outline_request(spec, context=context, user_id=user_id, project_id=project_id)
resp = await gateway.run(req)
parsed = resp.parsed
if not isinstance(parsed, OutlineResult):
# 带 output_schema 时 parsed 必非 NoneC1违约则明确报错而非返回空大纲。
log.error(
"outline_node_missing_parsed",
project_id=str(project_id),
parsed_type=type(parsed).__name__,
)
raise ValueError("gateway returned no parsed OutlineResult for outline request")
return parsed

View File

@@ -18,6 +18,7 @@ from pydantic import BaseModel
from ww_llm_gateway.types import Delta
from ww_shared import AppError, ErrorCode
from .collect import CONTINUITY, FORESHADOW, PACE
from .review_node import REVIEW_OK
log = structlog.get_logger(__name__)
@@ -25,7 +26,9 @@ log = structlog.get_logger(__name__)
# §7.3 事件名(全集)。完整清单以 ARCHITECTURE §7.3 为准。
EVENT_TOKEN = "token" # 正文增量(写章 draft
EVENT_SECTION = "section" # 四审分项开始/完成(审稿 review
EVENT_CONFLICT = "conflict" # 冲突命中(审稿 review
EVENT_CONFLICT = "conflict" # continuity 冲突命中(审稿 review
EVENT_FORESHADOW = "foreshadow" # foreshadow 建议命中(新埋/回收,审稿 review
EVENT_PACE = "pace" # pace 节奏诊断(注水/钩子/节拍图,审稿 review
EVENT_DONE = "done"
EVENT_ERROR = "error"
@@ -72,6 +75,35 @@ def conflict_event(
)
def foreshadow_event(
*,
kind: str,
code: str | None,
title: str,
where: str | None,
note: str | None,
) -> SseEvent:
"""伏笔建议事件(审稿 review——前端伏笔看板联动。
`kind` ∈ planted新埋/ resolved回收作者验收裁决后才登记/改状态(只读建议)。
"""
return SseEvent(
event=EVENT_FORESHADOW,
data={"kind": kind, "code": code, "title": title, "where": where, "note": note},
)
def pace_event(*, water: list[dict[str, object]], hook: bool, beat_map: list[int]) -> SseEvent:
"""节奏诊断事件(审稿 review——前端节奏报告/节拍图 ▁▃▅ 可视化。
一次审产一条:注水段清单 + 章末钩子有无 + 逐段节拍强度序列。
"""
return SseEvent(
event=EVENT_PACE,
data={"water": water, "hook": hook, "beat_map": beat_map},
)
def error_event(*, code: str, message: str, request_id: str | None = None) -> SseEvent:
"""错误事件,形对齐错误信封 §7.1`code`/`message`),附 `request_id` 便于贯通排查。"""
return SseEvent(
@@ -122,40 +154,79 @@ async def normalize_deltas(
yield done_event(length=total)
def _section_result_events(name: str, result: dict[str, Any]) -> list[SseEvent]:
"""把某审 `result` dict 按审种 surface 为结构化事件(纯函数,便于单测)。
畸形 result非 dict 等)由调用方 try/except 兜成 `error` 事件;这里只按已知形读。
"""
events: list[SseEvent] = []
if name == CONTINUITY:
for conflict in result.get("conflicts") or []:
events.append(
conflict_event(
type=conflict.get("type", ""),
where=conflict.get("where", ""),
refs=list(conflict.get("refs") or []),
suggestion=conflict.get("suggestion", ""),
)
)
elif name == FORESHADOW:
for kind in ("planted", "resolved"):
for sug in result.get(kind) or []:
events.append(
foreshadow_event(
kind=kind,
code=sug.get("code"),
title=sug.get("title", ""),
where=sug.get("where"),
note=sug.get("note"),
)
)
elif name == PACE:
events.append(
pace_event(
water=[dict(w) for w in result.get("water") or []],
hook=bool(result.get("hook", False)),
beat_map=[int(b) for b in result.get("beat_map") or []],
)
)
return events
async def normalize_review(
reviews: Mapping[str, Any],
*,
request_id: str | None = None,
) -> AsyncIterator[SseEvent]:
"""把审稿子图的结构化产出(`state["reviews"]`)归一为 SSE 事件序列。
"""把审稿子图的结构化产出(`state["reviews"]`)归一为 SSE 事件序列M3 三审齐)
**T2.5** 的 `POST .../review` 端点消费:跑 `build_review_graph` 后拿 `final["reviews"]`
(形 `{name: {status, result}}`),本函数 `section`每审 done/incomplete+ `conflict`
(命中冲突,形对齐 C6 `Conflict`+ `done`。HTTP event-stream 编码归 T2.5,不在此处。
供 `POST .../review` 端点消费:跑 `build_review_graph` 后拿 `final["reviews"]`
(形 `{name: {status, result}}`),本函数每审先发 `section`done/incomplete,再按审种
surface 结构化结果:
- continuity → 每冲突一条 `conflict`(形对齐 C6 `Conflict`
- foreshadow → 每条建议一条 `foreshadow`planted/resolved前端看板联动
- pace → 一条 `pace`(注水/钩子/节拍图,前端节奏报告/▁▃▅)。
末尾 `done{length=审项数}`。HTTP event-stream 编码归端点,不在此处。
错误处理纪律(同 `normalize_deltas`):任何意外 → 一条 `error` 事件后正常收尾,不上抛。
某审 `incomplete`网关失败被隔离§5.2)→ 发 `section{status:incomplete}`,不发其冲突
`reviews` 键按字典序遍历 → 确定性事件顺序(便于单测/前端稳定渲染)。
某审 `incomplete`网关失败被隔离§5.2)→ 发 `section{status:incomplete}`,不发其结果
`reviews` 键按字典序遍历 → 确定性事件顺序(便于单测/前端稳定渲染)。向后兼容:
既有 `section`/`conflict` 形不变,仅新增 `foreshadow`/`pace`。
"""
try:
section_count = 0
for name in sorted(reviews.keys()):
entry = reviews.get(name) or {}
status = entry.get("status")
if status == REVIEW_OK:
if status != REVIEW_OK:
yield section_event(name=name, status=SECTION_INCOMPLETE)
section_count += 1
continue
yield section_event(name=name, status=SECTION_DONE)
section_count += 1
result = entry.get("result") or {}
for conflict in result.get("conflicts") or []:
yield conflict_event(
type=conflict.get("type", ""),
where=conflict.get("where", ""),
refs=list(conflict.get("refs") or []),
suggestion=conflict.get("suggestion", ""),
)
else:
yield section_event(name=name, status=SECTION_INCOMPLETE)
section_count += 1
for event in _section_result_events(name, result):
yield event
except Exception as exc: # noqa: BLE001 — 边界兜底:任何意外都归一为 error 事件,不泄异常
log.error("sse_review_unexpected_error", error=str(exc), request_id=request_id)
yield error_event(

View File

@@ -0,0 +1,112 @@
"""T3.8 回归并行审记账并发安全add-only无 flush 重入)。
旧 bugT3.7 真 pg E2E 暴露):三审作为同一 LangGraph superstep 的并行分支,各自
`gateway.run()` → 共用**同一请求 session** 的 `SqlAlchemyLedgerSink.record`
(`session.add` + `await session.flush()`)。`AsyncSession` 非并发安全:`await flush()`
是并行路径里唯一让出控制权的 DB-IO第二/三审的 flush 撞上「Session is already
flushing」flush 重入)→ 被 `run_review` 失败隔离吞成 `incomplete` → foreshadow/pace
结果静默丢失。
本测试用一个会**侦测 flush 重入**的假 AsyncSession`flush` 异步(`await asyncio.sleep`
让出控制权),若两协程同时在 flush 中即抛 `RuntimeError("Session is already flushing")`
——精确复刻 SQLAlchemy 的报错。在旧 add-then-flush 实现下,`asyncio.gather` 三并发
`record` 会触发该重入;修复后 `record` 只 `add`(同步、不让出),不再调 flush故三行
全部入 session 且零重入。
"""
from __future__ import annotations
import asyncio
import uuid
import pytest
from ww_llm_gateway.ledger import SqlAlchemyLedgerSink
from ww_llm_gateway.types import Scope, Usage
class _FlushReentryDetectingSession:
"""假 AsyncSession记录 `add` 的行;`flush` 异步且侦测并发重入。
`add` 同步追加(镜像真 session。`flush` 进入时若已有协程在 flush 中 → 抛
`RuntimeError("Session is already flushing")`(复刻 SQLAlchemy 行为),否则
`await asyncio.sleep(0)` 让出控制权模拟真实 DB-IO 让步点。
"""
def __init__(self) -> None:
self.added: list[object] = []
self.flush_calls = 0
self._flushing = False
def add(self, row: object) -> None:
self.added.append(row)
async def flush(self) -> None:
self.flush_calls += 1
if self._flushing:
raise RuntimeError("Session is already flushing")
self._flushing = True
try:
await asyncio.sleep(0) # 让出控制权——并行协程在此交错
finally:
self._flushing = False
def _usage(input_tokens: int) -> Usage:
return Usage(
provider="deepseek",
model="deepseek-chat",
input_tokens=input_tokens,
output_tokens=1,
cost_minor=0,
currency="USD",
)
def _scope() -> Scope:
return Scope(user_id=uuid.UUID(int=1), project_id=uuid.UUID(int=2))
async def test_concurrent_record_adds_all_rows_without_flush_reentry() -> None:
"""三并发 `record`(模拟三审并行)→ 三行全部入 session绝不抛 flush 重入。
旧 add-then-flush 实现在此会因 `await flush()` 让出控制权而 flush 重入抛错;
add-only 实现下 `record` 不让出控制权,三行确定性入 session。
"""
session = _FlushReentryDetectingSession()
sink = SqlAlchemyLedgerSink(session) # type: ignore[arg-type]
await asyncio.gather(
sink.record(_scope(), _usage(100)),
sink.record(_scope(), _usage(200)),
sink.record(_scope(), _usage(300)),
)
# 三审用量全部入 session一次调用一行记账不丢
assert len(session.added) == 3
input_tokens = sorted(row.input_tokens for row in session.added) # type: ignore[attr-defined]
assert input_tokens == [100, 200, 300]
# add-onlysink 不在并行路径 flush持久化靠端点/事务 commit
assert session.flush_calls == 0
async def test_record_does_not_flush_so_caller_commit_persists() -> None:
"""单次 `record` 只 add 不 flush——契约「网关 ledger 只 add、调用方负责 commit」。"""
session = _FlushReentryDetectingSession()
sink = SqlAlchemyLedgerSink(session) # type: ignore[arg-type]
await sink.record(_scope(), _usage(42))
assert len(session.added) == 1
assert session.flush_calls == 0
@pytest.mark.parametrize("n_concurrent", [2, 5, 10])
async def test_concurrent_record_scales_without_loss(n_concurrent: int) -> None:
"""N 并发 `record` 全部落 session无丢失、无 flush 重入(不论并发度)。"""
session = _FlushReentryDetectingSession()
sink = SqlAlchemyLedgerSink(session) # type: ignore[arg-type]
await asyncio.gather(*(sink.record(_scope(), _usage(i + 1)) for i in range(n_concurrent)))
assert len(session.added) == n_concurrent
assert session.flush_calls == 0

View File

@@ -24,6 +24,19 @@ class SqlAlchemyLedgerSink:
self._session = session
async def record(self, scope: Scope, usage: Usage) -> None:
"""把一次调用追加进 session**只 add不 flush/commit**)。
并发安全T3.8 bugfix三审作为同一 LangGraph superstep 的并行分支
共用一个请求 session 的本 sink。`AsyncSession` 非并发安全——若在此
`await self._session.flush()`,第二/三审的 flush 会撞上「Session is already
flushing」flush 重入),被 `run_review` 失败隔离吞成 `incomplete`
foreshadow/pace 结果静默丢失。`session.add()` 是同步、无 await、不让出
控制权,故并行协程不会在 add 中途交错。
持久化靠调用方:每个端点/验收事务在流耗尽/事务末尾 `commit()`commit
自动 flush 全部待决行),与「网关 ledger 只 add/flush、端点或事务负责
commit」的既有边界一致见 gotcha「网关 ledger 只 flush、调用方必须 commit」
"""
row = UsageLedger(
owner_id=scope.user_id,
project_id=scope.project_id,
@@ -36,4 +49,3 @@ class SqlAlchemyLedgerSink:
currency=usage.currency,
)
self._session.add(row)
await self._session.flush()

584
tests/test_m3_e2e.py Normal file
View File

@@ -0,0 +1,584 @@
"""M3 端到端:伏笔账本/到期提醒 + 大纲(含回收窗口) + 三审(真实 DB零 token
证明 M3 闭环(不变量 #2/#3/#4/#5
用例 1 · 伏笔 埋设→进展→逾期提醒(验收后扫描)
`POST /projects` → `POST .../foreshadow`(埋 F1, expected_close_to=2)
→ `PATCH .../foreshadow/F1`(追加一条进展) → `PUT .../chapters/5/draft`
→ `POST .../chapters/5/accept`(零冲突直通) → **验收后 BackgroundTask 到期扫描**置
`current_ch(5) > expected_close_to(2) AND status≠CLOSED → OVERDUE` →
`GET .../foreshadow?status=OVERDUE` 含 F1 + DB 真源断言F1.status==OVERDUE、progress 含那条)。
对照:未越界伏笔 F2(expected_close_to=9) 不应 OVERDUE。
用例 2 · 排大纲含伏笔回收窗口
`POST .../outline` → analyst 假适配器产 `OutlineResult` → DB `outline` 表逐章行存在
`beats` 形 `{"beats":[...]}`、`foreshadow_windows` JSONB list 含窗口);**再次 POST 幂等
覆盖**(唯一 `(project_id,chapter_no)` 行数不变);`usage_ledger` 含 analyst 那次记账。
用例 3 · 三审 SSEcontinuity/foreshadow/pace 三审齐 + 留痕列)
`POST .../chapters/N/review` → 解析帧含三审 section + ≥1 `foreshadow`/`pace` 事件;
`GET .../reviews` 留痕行 `foreshadow_sug`(list)/`pace`(dict) 列已填。
确定性 & 零成本(同 M1/M2真实 `Gateway` + 多档位假适配器(据 `req.output_schema`
分支返回固定 `parsed`,绝不联网)+ 真实 `SqlAlchemyLedgerSink`(用量记账闭环走通)。
无 pg → skip。
坑(见 memory/gotchas
- `LifespanManager` 触发 lifespan → `seed_stub_user`owner_id FK 依赖它)。
- `get_sessionmaker` engine 绑定首个 loop → 每 DB 测试清缓存重建、结束 dispose。
- 网关 ledger / 验收-side repos / outline upsert 均只 flush端点/验收事务 commit 才落库。
- **BackgroundTask 自建独立 session**FastAPI BackgroundTasks 在 response 发回、请求
session 关闭后才跑——`get_session_factory` 必被 override 成 `e2e_sm`(真 sessionmaker
否则 dep 解析会另建真 engine跨 loop 风险。httpx `ASGITransport` 下,`await client.post`
调用会等 ASGI app 协程(含 background tasks跑完才返回——故 client 上下文退出后断言稳定。
- 多档位假适配器据 `req.output_schema` 路由 parsedOutlineResult / 三审 schema /
ChapterDigestFacts写章走 stream。
"""
from __future__ import annotations
import json
import uuid
from collections.abc import AsyncIterator, Callable
from typing import Annotated
import httpx
import pytest
from asgi_lifespan import LifespanManager
from fastapi import Depends
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ww_agents import (
ContinuityReview,
ForeshadowReview,
ForeshadowSuggestion,
ForeshadowWindow,
OutlineChapter,
OutlineResult,
PaceIssue,
PaceReview,
)
from ww_api.services.digest_extraction import ChapterDigestFacts
from ww_db import get_session, get_sessionmaker
from ww_db.models import (
Chapter,
ChapterDigest,
ChapterReview,
Foreshadow,
Outline,
Project,
UsageLedger,
)
from ww_llm_gateway import (
Gateway,
SqlAlchemyLedgerSink,
resolve_route,
)
from ww_llm_gateway.adapters.base import (
Capabilities,
ProviderResult,
ProviderUsage,
StreamChunk,
)
from ww_llm_gateway.types import LlmRequest
# 三档位writer/analyst/light默认都路由到 deepseekconfig.tier_defaults
_PROVIDER = "deepseek"
# 确定性流式写章 token。
_TOKENS = ["", "", "", "", "回收之时。"]
# 终稿里可被 digest 追溯的标记串(验证 digest 从终稿提炼,不变量 #4
_FINAL_MARKER = "终稿独有事件:旧伏笔在第五章浮现"
# outliner 假产物:两章,章 1 关联伏笔窗口(回收窗口 code=F1
_OUTLINE_RESULT = OutlineResult(
chapters=[
OutlineChapter(
no=1,
beats=["开端:少年入门", "埋线:神秘玉佩"],
foreshadow_windows=[
ForeshadowWindow(
code="F1",
plant_chapter=1,
expected_close_from=2,
expected_close_to=2,
)
],
),
OutlineChapter(
no=2,
beats=["试炼:初次交锋"],
foreshadow_windows=[],
),
]
)
# 三审固定产物。
_FORESHADOW_REVIEW = ForeshadowReview(
planted=[ForeshadowSuggestion(code="F9", title="新埋:山门禁制", where="第2段")],
resolved=[ForeshadowSuggestion(code="F1", title="疑似回收:玉佩", where="第5段")],
)
_PACE_REVIEW = PaceReview(
water=[PaceIssue(where="第3段", reason="信息密度低")],
hook=True,
beat_map=[1, 3, 5, 2],
)
# 各档位假用量(喂记账;证明各档位调用各自落库)。
_USAGE = {
"writer": ProviderUsage(input_tokens=11, output_tokens=5),
"analyst": ProviderUsage(input_tokens=23, output_tokens=7),
"light": ProviderUsage(input_tokens=17, output_tokens=9),
}
class _FakeMultiTierAdapter:
"""实现 `ProviderAdapter` Protocol按 `req.output_schema` 分支返回 parsed绝不联网。
- `stream()`:吐固定写章 tokenwriter/draft末尾喂 writer 用量。
- `complete()`:据 `req.output_schema` 路由:
`OutlineResult`→大纲analyst`ContinuityReview`/`ForeshadowReview`/`PaceReview`
→三审analyst/light`ChapterDigestFacts`→验收 digestlight追溯终稿
每路带 `ProviderUsage` → 网关据此各落一条 usage_ledger。
"""
provider = _PROVIDER
def capabilities(self) -> Capabilities:
return Capabilities(structured_output=True)
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
schema = req.output_schema
if schema is OutlineResult:
return ProviderResult(
text=_OUTLINE_RESULT.model_dump_json(),
parsed=_OUTLINE_RESULT,
usage=_USAGE["analyst"],
)
if schema is ContinuityReview:
review = ContinuityReview(conflicts=[]) # 零冲突 → 验收 gate 直通
return ProviderResult(
text=review.model_dump_json(),
parsed=review,
usage=_USAGE["analyst"],
)
if schema is ForeshadowReview:
return ProviderResult(
text=_FORESHADOW_REVIEW.model_dump_json(),
parsed=_FORESHADOW_REVIEW,
usage=_USAGE["analyst"],
)
if schema is PaceReview:
return ProviderResult(
text=_PACE_REVIEW.model_dump_json(),
parsed=_PACE_REVIEW,
usage=_USAGE["light"],
)
# 否则视为 digest 提炼ChapterDigestFacts把终稿标记塞进 summary#4
facts = ChapterDigestFacts(
summary=_FINAL_MARKER,
events=["玉佩浮现"],
locations=["山门"],
)
return ProviderResult(
text=facts.model_dump_json(),
parsed=facts,
usage=_USAGE["light"],
)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
for token in _TOKENS:
yield StreamChunk(text=token)
yield StreamChunk(usage=_USAGE["writer"])
@pytest.fixture
async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
"""真实 DB session 工厂;无 pg 时跳过(每测试清缓存重建 engine、结束 dispose"""
get_sessionmaker.cache_clear()
maker = get_sessionmaker()
try:
async with maker() as probe:
await probe.execute(select(1))
except Exception:
pytest.skip("postgres not reachable")
yield maker
await maker.kw["bind"].dispose()
get_sessionmaker.cache_clear()
def _parse_sse(raw: str) -> list[tuple[str, str]]:
"""把 text/event-stream 原文解析为 `(event, data)` 帧列表。"""
frames: list[tuple[str, str]] = []
event: str | None = None
data: str | None = None
for line in raw.splitlines():
if line.startswith("event:"):
event = line[len("event:") :].strip()
elif line.startswith("data:"):
data = line[len("data:") :].strip()
elif line == "":
if event is not None and data is not None:
frames.append((event, data))
event, data = None, None
if event is not None and data is not None:
frames.append((event, data))
return frames
def _gateway_override_factory(
adapter: _FakeMultiTierAdapter,
) -> Callable[[AsyncSession], Gateway]:
"""构造依赖覆盖:真实 Gateway + 给定假适配器 + 真实 ledger请求 session
ledger 用**请求 session**`Depends(get_session)`FastAPI 按请求缓存,与端点/验收
事务同一实例)→ 由端点流末 / 验收事务的 `commit()` 落库(同 M1/M2 回归校验)。
"""
def _override(session: Annotated[AsyncSession, Depends(get_session)]) -> Gateway:
return Gateway(
adapters={_PROVIDER: adapter},
ledger=SqlAlchemyLedgerSink(session),
resolver=resolve_route,
)
return _override
async def _cleanup(e2e_sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None:
"""按 FK 顺序清理(无级联的先删,再删 project多数子表经 FK CASCADE"""
async with e2e_sm() as cleanup:
await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid))
await cleanup.execute(delete(ChapterDigest).where(ChapterDigest.project_id == project_uuid))
await cleanup.execute(delete(ChapterReview).where(ChapterReview.project_id == project_uuid))
await cleanup.execute(delete(Outline).where(Outline.project_id == project_uuid))
await cleanup.execute(delete(Foreshadow).where(Foreshadow.project_id == project_uuid))
await cleanup.execute(delete(Chapter).where(Chapter.project_id == project_uuid))
await cleanup.execute(delete(Project).where(Project.id == project_uuid))
await cleanup.commit()
async def test_m3_foreshadow_plant_progress_overdue_after_accept(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""用例 1伏笔 埋设→进展→验收后到期扫描置 OVERDUEBackgroundTask"""
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_digest_gateway,
get_review_gateway,
get_session_factory,
get_writer_gateway,
)
adapter = _FakeMultiTierAdapter()
override = _gateway_override_factory(adapter)
app = create_app()
app.dependency_overrides[get_writer_gateway] = override
app.dependency_overrides[get_review_gateway] = override
app.dependency_overrides[get_digest_gateway] = override
# 到期扫描 BackgroundTask 的独立 session 工厂 → 注入真实 sessionmaker同测试 engine/loop
# 否则默认 `get_sessionmaker()` 会另建 engine 绑别的 loop跨 loop 坑)。
app.dependency_overrides[get_session_factory] = lambda: e2e_sm
transport = httpx.ASGITransport(app=app)
project_uuid: uuid.UUID | None = None
try:
async with LifespanManager(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
# 1) 立项 → 201。
create_resp = await client.post(
"/projects",
json={"title": "M3 伏笔逾期验证作品", "genre": "玄幻"},
)
assert create_resp.status_code == 201
project_id = create_resp.json()["id"]
project_uuid = uuid.UUID(project_id)
# 2) 埋伏笔 F1expected_close_to=2 → 验收第5章后应逾期→ 201, status=OPEN。
f1_resp = await client.post(
f"/projects/{project_id}/foreshadow",
json={
"code": "F1",
"title": "神秘玉佩",
"planted_at": 1,
"expected_close_to": 2,
},
)
assert f1_resp.status_code == 201
assert f1_resp.json()["status"] == "OPEN"
# 对照F2expected_close_to=9 → 不越界,不应逾期)。
f2_resp = await client.post(
f"/projects/{project_id}/foreshadow",
json={"code": "F2", "title": "远期线索", "expected_close_to": 9},
)
assert f2_resp.status_code == 201
# 3) PATCH F1 追加一条进展 → 200, progress 追加。
progress_entry = {"chapter": 3, "note": "玉佩发热"}
patch_resp = await client.patch(
f"/projects/{project_id}/foreshadow/F1",
json={"progress_entry": progress_entry},
)
assert patch_resp.status_code == 200
assert patch_resp.json()["progress"][-1] == progress_entry
# 4) 写第5章草稿SSE+ 自动保存。
draft_resp = await client.post(f"/projects/{project_id}/chapters/5/draft")
assert draft_resp.status_code == 200
draft_text = "".join(
json.loads(d)["text"]
for (ev, d) in _parse_sse(draft_resp.text)
if ev == "token"
)
assert draft_text == "".join(_TOKENS)
save_resp = await client.put(
f"/projects/{project_id}/chapters/5/draft",
json={"text": draft_text},
)
assert save_resp.status_code == 200
# 5) 验收第5章无审稿留痕 → 零冲突直通final_text 含终稿标记 #4
final_text = draft_text + "\n\n" + _FINAL_MARKER
accept_resp = await client.post(
f"/projects/{project_id}/chapters/5/accept",
json={"final_text": final_text, "decisions": []},
)
assert accept_resp.status_code == 200
acc = accept_resp.json()
assert acc["digest_added"] is True
# BackgroundTask 到期扫描在此请求 ASGI 协程内response 后)跑完才返回。
# 6a) 端点视角GET ?status=OVERDUE 含 F1status==OVERDUE不含 F2。
board_resp = await client.get(
f"/projects/{project_id}/foreshadow", params={"status": "OVERDUE"}
)
assert board_resp.status_code == 200
overdue = board_resp.json()["foreshadow"]
overdue_codes = {f["code"] for f in overdue}
assert "F1" in overdue_codes
assert "F2" not in overdue_codes
f1_board = next(f for f in overdue if f["code"] == "F1")
assert f1_board["status"] == "OVERDUE"
# 6b) DB 真源断言(经 e2e session
assert project_uuid is not None
async with e2e_sm() as verify:
fore_rows = (
(
await verify.execute(
select(Foreshadow)
.where(Foreshadow.project_id == project_uuid)
.order_by(Foreshadow.code)
)
)
.scalars()
.all()
)
by_code = {r.code: r for r in fore_rows}
# F1验收第5章 > expected_close_to(2) → OVERDUEprogress 含那条进展。
assert by_code["F1"].status == "OVERDUE"
assert progress_entry in by_code["F1"].progress
# F2expected_close_to(9) 未越界 → 仍 OPEN对照扫描不误伤
assert by_code["F2"].status == "OPEN"
# 终稿 digest 落库且 summary 含终稿标记(#4从终稿非草稿提炼
digest = (
await verify.execute(
select(ChapterDigest).where(ChapterDigest.project_id == project_uuid)
)
).scalar_one()
assert digest.facts["summary"] == _FINAL_MARKER
assert _FINAL_MARKER not in draft_text
finally:
if project_uuid is not None:
await _cleanup(e2e_sm, project_uuid)
async def test_m3_outline_with_foreshadow_windows_upsert_idempotent(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""用例 2排大纲含伏笔回收窗口逐章落 `outline`;再次 POST 幂等覆盖;记账落库。"""
from ww_api.main import create_app
from ww_api.services.project_deps import get_outline_gateway
adapter = _FakeMultiTierAdapter()
override = _gateway_override_factory(adapter)
app = create_app()
app.dependency_overrides[get_outline_gateway] = override
transport = httpx.ASGITransport(app=app)
project_uuid: uuid.UUID | None = None
try:
async with LifespanManager(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
create_resp = await client.post("/projects", json={"title": "M3 大纲窗口验证作品"})
assert create_resp.status_code == 201
project_id = create_resp.json()["id"]
project_uuid = uuid.UUID(project_id)
# 1) 生成大纲analyst 假适配器产 OutlineResult→ 200 OutlineResponse。
outline_resp = await client.post(
f"/projects/{project_id}/outline", json={"volume": 1}
)
assert outline_resp.status_code == 200
chapters = outline_resp.json()["chapters"]
assert len(chapters) == 2
ch1 = next(c for c in chapters if c["no"] == 1)
assert ch1["volume"] == 1
assert ch1["beats"] == ["开端:少年入门", "埋线:神秘玉佩"]
assert ch1["foreshadow_windows"][0]["code"] == "F1"
assert ch1["foreshadow_windows"][0]["expected_close_to"] == 2
# 2) 再次 POST → 唯一 (project_id, chapter_no) 覆盖,行数不堆叠。
outline_resp2 = await client.post(
f"/projects/{project_id}/outline", json={"volume": 1}
)
assert outline_resp2.status_code == 200
# DB 真源断言。
assert project_uuid is not None
async with e2e_sm() as verify:
outline_rows = (
(
await verify.execute(
select(Outline)
.where(Outline.project_id == project_uuid)
.order_by(Outline.chapter_no)
)
)
.scalars()
.all()
)
# 幂等:两次 POST 仍是 2 行(每章一行,覆盖非堆叠)。
assert len(outline_rows) == 2
row1 = outline_rows[0]
assert row1.chapter_no == 1
assert row1.volume == 1
# beats 列形 `{"beats":[...]}`(写侧包裹,见 contracts C3 扩 T3.5)。
assert row1.beats == {"beats": ["开端:少年入门", "埋线:神秘玉佩"]}
# foreshadow_windows JSONB list 含窗口code=F1
assert isinstance(row1.foreshadow_windows, list)
assert row1.foreshadow_windows[0]["code"] == "F1"
assert row1.foreshadow_windows[0]["expected_close_to"] == 2
# 章 2 无窗口。
assert outline_rows[1].chapter_no == 2
assert outline_rows[1].foreshadow_windows == []
# usage_ledger两次大纲生成各 1 条 analyst 调用记账(端点 commit 后落库)。
ledger_rows = (
(
await verify.execute(
select(UsageLedger).where(UsageLedger.project_id == project_uuid)
)
)
.scalars()
.all()
)
assert len(ledger_rows) == 2
assert all(r.input_tokens == _USAGE["analyst"].input_tokens for r in ledger_rows)
assert all(r.provider == _PROVIDER for r in ledger_rows)
finally:
if project_uuid is not None:
await _cleanup(e2e_sm, project_uuid)
async def test_m3_three_reviewers_sse_and_review_trace(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""用例 3三审 SSEcontinuity/foreshadow/pace 三审齐)+ 留痕列foreshadow_sug/pace真落库。
三审在同一 LangGraph superstep 并行跑、共用请求 session 记账T3.8 把
`SqlAlchemyLedgerSink.record` 改 add-only`session.add` 同步不让步 → 并行协程不交错,
持久化靠端点 commit。故三审齐现真持久化断言严格正向
- SSE `section` 帧含 continuity/foreshadow/pace 三审,且无 `error` 帧;
- SSE 至少各一条 `foreshadow` / `pace` 事件foreshadow/pace 真发事件);
- `GET .../reviews` 留痕行 `foreshadow_sug`(list 非空) / `pace`(dict 非空) 列已填;
- DB 真源 `chapter_reviews.foreshadow_sug`/`pace` 列真落库(三审都 done无静默丢失
"""
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_review_gateway,
get_writer_gateway,
)
adapter = _FakeMultiTierAdapter()
override = _gateway_override_factory(adapter)
app = create_app()
app.dependency_overrides[get_writer_gateway] = override
app.dependency_overrides[get_review_gateway] = override
transport = httpx.ASGITransport(app=app)
project_uuid: uuid.UUID | None = None
try:
async with LifespanManager(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
create_resp = await client.post("/projects", json={"title": "M3 三审验证作品"})
assert create_resp.status_code == 201
project_id = create_resp.json()["id"]
project_uuid = uuid.UUID(project_id)
# 写第1章草稿 + 自动保存(供续审回退已存草稿)。
draft_resp = await client.post(f"/projects/{project_id}/chapters/1/draft")
draft_text = "".join(
json.loads(d)["text"]
for (ev, d) in _parse_sse(draft_resp.text)
if ev == "token"
)
await client.put(
f"/projects/{project_id}/chapters/1/draft",
json={"text": draft_text},
)
# 续审SSE三审 section + ≥1 foreshadow/pace 事件、无 error。
review_resp = await client.post(
f"/projects/{project_id}/chapters/1/review",
json={"draft": draft_text},
)
assert review_resp.status_code == 200
assert review_resp.headers["content-type"].startswith("text/event-stream")
rframes = _parse_sse(review_resp.text)
assert [d for (ev, d) in rframes if ev == "error"] == []
section_names = {
json.loads(d).get("name") for (ev, d) in rframes if ev == "section"
}
assert {"continuity", "foreshadow", "pace"} <= section_names
foreshadow_events = [d for (ev, d) in rframes if ev == "foreshadow"]
pace_events = [d for (ev, d) in rframes if ev == "pace"]
assert len(foreshadow_events) >= 1
assert len(pace_events) >= 1
# GET 历史:留痕行 foreshadow_sug(list) / pace(dict) 列已填。
hist_resp = await client.get(f"/projects/{project_id}/chapters/1/reviews")
assert hist_resp.status_code == 200
latest = hist_resp.json()["reviews"][0]
assert isinstance(latest["foreshadow_sug"], list)
assert len(latest["foreshadow_sug"]) >= 1
assert isinstance(latest["pace"], dict)
assert latest["pace"]["hook"] is True
# DB 真源断言chapter_reviews 留痕列已填。
assert project_uuid is not None
async with e2e_sm() as verify:
review_rows = (
(
await verify.execute(
select(ChapterReview).where(ChapterReview.project_id == project_uuid)
)
)
.scalars()
.all()
)
assert len(review_rows) == 1
row = review_rows[0]
assert isinstance(row.foreshadow_sug, list)
assert len(row.foreshadow_sug) >= 1
# 扁平后每条带 kind:"planted"|"resolved"(见 collect 列映射)。
assert {s.get("kind") for s in row.foreshadow_sug} <= {"planted", "resolved"}
assert isinstance(row.pace, dict)
assert row.pace["hook"] is True
assert row.pace["beat_map"] == [1, 3, 5, 2]
finally:
if project_uuid is not None:
await _cleanup(e2e_sm, project_uuid)