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:
48
PROGRESS.md
48
PROGRESS.md
@@ -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.4;15 新测;只 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 API:outline + foreshadow | ✅ @backend 2026-06-18 | @backend | T3.1✅, T3.4✅ | C3✅扩 | `GET .../foreshadow?status=`(看板,续 foreshadow.router) + `POST .../outline`(独立 outline.py,run_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** / build);E2E 真实 DB + mock 网关零 token 走通 写→审→裁决→验收→摘要入库,DB 真源逐项断言。**M2 完成。** 下一阶段 M3(伏笔 + 节奏)见 DEV_PLAN,进入时拉入本表。
|
||||
**M3 出口(DoD)✅ 全部达成**:伏笔账本/到期提醒/看板;大纲含回收窗口;节奏报告(三审齐真持久化)。后端门禁绿(ruff / mypy **111 文件** / pytest **228 passed** / alembic **无漂移**);前端门禁绿(gen:api / lint / tsc / vitest **69** / build);E2E 真实 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 SSE(C4✅扩)@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 / build);E2E 写→审→裁决→验收→摘要入库闭环、DB 真源逐项断言。设计决策 R1–R6 全兑现(见 `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+SSE(C4✅稳定)@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 E:T3.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 E:T3.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 D:T3.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 C:T3.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` 三审齐,文风第四审留 M4);collect 一次 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.4:outliner 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 B:T3.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 A:T3.1(@backend·core/domain) ‖ T3.4(@llm·packages/agents+orchestrator)**(不同目录,无写冲突)。
|
||||
- [2026-06-18] @orchestrator — **🎉 M2 完成**(Wave E:T2.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 E:T2.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 提炼在事务外(R2,light 档)→ 单原子事务 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 D:T2.6**(@frontend 审稿页+裁决+验收 gate,先 `pnpm gen:api`)。
|
||||
|
||||
@@ -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`,绝不联网。"""
|
||||
|
||||
203
apps/api/tests/test_foreshadow.py
Normal file
203
apps/api/tests/test_foreshadow.py
Normal 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
|
||||
216
apps/api/tests/test_outline.py
Normal file
216
apps/api/tests/test_outline.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""T3.5 端点测试:大纲生成 + 持久化 + 伏笔看板(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- POST /outline:mock 网关产 `OutlineResult` → 逐章 upsert(含 foreshadow_windows)→ 端点 commit;
|
||||
- 幂等:同 (project_id, chapter_no) 再生成覆盖;
|
||||
- 项目不存在 → 404;无凭据 → LLM_UNAVAILABLE(503);
|
||||
- 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
|
||||
# 逐章 upsert(2 章)+ 端点末尾一次 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_UNAVAILABLE(503),在网关 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"
|
||||
63
apps/api/tests/test_outline_context.py
Normal file
63
apps/api/tests/test_outline_context.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
159
apps/api/ww_api/routers/foreshadow.py
Normal file
159
apps/api/ww_api/routers/foreshadow.py
Normal 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)
|
||||
135
apps/api/ww_api/routers/outline.py
Normal file
135
apps/api/ww_api/routers/outline.py
Normal 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 只 flush(run_outline 产 1 条 usage)、`OutlineWriteRepo.upsert_chapter`
|
||||
只 flush → 端点末尾 `await session.commit()`(否则 usage_ledger + outline 行静默丢失,见 gotcha)。
|
||||
|
||||
不变量 #2:经 `build_gateway_for_tier(..., "analyst")` 注入;outliner_spec 只声明档位不传 model。
|
||||
不变量 #3:outliner `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)
|
||||
@@ -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.5):gate(事务前)→ 终稿提炼 digest(事务外,R2)→
|
||||
单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit。
|
||||
单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit → 登记**验收后伏笔到期扫描**
|
||||
BackgroundTask(M3-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,
|
||||
)
|
||||
|
||||
# 验收**提交成功后**登记伏笔到期扫描 BackgroundTask(M3-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,
|
||||
|
||||
66
apps/api/ww_api/schemas/foreshadow.py
Normal file
66
apps/api/ww_api/schemas/foreshadow.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""伏笔登记 / 状态变更端点的请求/响应 schema(C3 扩 / 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)
|
||||
43
apps/api/ww_api/schemas/outline.py
Normal file
43
apps/api/ww_api/schemas/outline.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""大纲生成端点的请求/响应 schema(C3 扩 / ARCH §5.4, §7.2)。
|
||||
|
||||
snake_case;前端经 OpenAPI 生成 TS 类型消费(改字段 → 前端 `pnpm gen:api`)。
|
||||
大纲生成是**作者显式动作**(不变量 #3:outliner `writes=["outline"]` 声明经此端点落地,
|
||||
非 AI 静默写其它表)。窗口字段供前端 T3.6 渲染伏笔窗口徽标。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OutlineGenerateRequest(BaseModel):
|
||||
"""POST /projects/:id/outline:触发大纲生成 + 持久化。
|
||||
|
||||
M3 简单分卷:`volume` 缺省=1(schema 无逐章卷号;本端点把整批章节落到同一卷)。
|
||||
"""
|
||||
|
||||
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)
|
||||
@@ -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 同形)
|
||||
|
||||
|
||||
90
apps/api/ww_api/services/foreshadow_scan.py
Normal file
90
apps/api/ww_api/services/foreshadow_scan.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""验收后伏笔到期扫描(M3-b/d;ARCH §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
|
||||
87
apps/api/ww_api/services/outline_context.py
Normal file
87
apps/api/ww_api/services/outline_context.py
Normal 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),
|
||||
]
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
23
apps/web/app/projects/[id]/foreshadow/page.tsx
Normal file
23
apps/web/app/projects/[id]/foreshadow/page.tsx
Normal 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);
|
||||
// ForeshadowBoard(Client)按 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();
|
||||
}
|
||||
}
|
||||
20
apps/web/app/projects/[id]/outline/page.tsx
Normal file
20
apps/web/app/projects/[id]/outline/page.tsx
Normal 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 取项目;OutlineEditor(Client)按需
|
||||
// 触发 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -3,64 +3,126 @@
|
||||
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;
|
||||
if (!item.enabled) {
|
||||
return (
|
||||
<li key={item.label}>
|
||||
<span
|
||||
className="block cursor-not-allowed rounded px-3 py-2 text-sm text-ink-soft/50"
|
||||
aria-disabled="true"
|
||||
title="后续里程碑开放"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={item.label}>
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`flex items-center gap-2 rounded px-3 py-2 text-sm ${
|
||||
active
|
||||
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-[var(--color-cinnabar-wash)]"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
<span
|
||||
className="block cursor-not-allowed rounded px-3 py-2 text-sm text-ink-soft/50"
|
||||
aria-disabled="true"
|
||||
title="后续里程碑开放"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`flex items-center gap-2 rounded px-3 py-2 text-sm ${
|
||||
active
|
||||
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-[var(--color-cinnabar-wash)]"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
72
apps/web/components/foreshadow/ForeshadowBoard.tsx
Normal file
72
apps/web/components/foreshadow/ForeshadowBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
apps/web/components/foreshadow/ForeshadowCard.tsx
Normal file
133
apps/web/components/foreshadow/ForeshadowCard.tsx
Normal 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.8):code/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;
|
||||
}
|
||||
61
apps/web/components/foreshadow/KanbanColumn.tsx
Normal file
61
apps/web/components/foreshadow/KanbanColumn.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
apps/web/components/foreshadow/RegisterForm.tsx
Normal file
185
apps/web/components/foreshadow/RegisterForm.tsx
Normal 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;
|
||||
}
|
||||
51
apps/web/components/outline/OutlineChapterRow.tsx
Normal file
51
apps/web/components/outline/OutlineChapterRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
apps/web/components/outline/OutlineEditor.tsx
Normal file
97
apps/web/components/outline/OutlineEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/web/components/review/BeatMap.tsx
Normal file
34
apps/web/components/review/BeatMap.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { toBeatCells, toSparkline } from "@/lib/pace/beatmap";
|
||||
|
||||
interface BeatMapProps {
|
||||
beatMap: number[];
|
||||
}
|
||||
|
||||
// 爽点节拍图(UX §6.4):beat_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>
|
||||
);
|
||||
}
|
||||
58
apps/web/components/review/ForeshadowSuggestions.tsx
Normal file
58
apps/web/components/review/ForeshadowSuggestions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
apps/web/components/review/PacePanel.tsx
Normal file
56
apps/web/components/review/PacePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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)]"
|
||||
|
||||
370
apps/web/lib/api/schema.d.ts
vendored
370
apps/web/lib/api/schema.d.ts
vendored
@@ -169,7 +169,14 @@ export interface paths {
|
||||
/**
|
||||
* Accept Chapter
|
||||
* @description 验收事务 + 冲突 gate(§5.5):gate(事务前)→ 终稿提炼 digest(事务外,R2)→
|
||||
* 单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit。
|
||||
* 单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit → 登记**验收后伏笔到期扫描**
|
||||
* BackgroundTask(M3-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` 缺省=1(schema 无逐章卷号;本端点把整批章节落到同一卷)。
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
|
||||
134
apps/web/lib/foreshadow/board.test.ts
Normal file
134
apps/web/lib/foreshadow/board.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
141
apps/web/lib/foreshadow/board.ts
Normal file
141
apps/web/lib/foreshadow/board.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
// 伏笔看板纯逻辑:四泳道分组、登记/转移请求体组装、422 reason 映射。
|
||||
// 对齐 C3 扩(GET/POST/PATCH .../foreshadow)。纯逻辑,便于 node 环境单测。
|
||||
|
||||
import type {
|
||||
ForeshadowRegisterRequest,
|
||||
ForeshadowTransitionRequest,
|
||||
ForeshadowView,
|
||||
} from "@/lib/api/types";
|
||||
|
||||
// 四状态泳道(UX §6.8);OVERDUE 用琥珀强调(渲染层处理)。
|
||||
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.reason(C3 扩)→ 用户文案。
|
||||
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;
|
||||
}
|
||||
96
apps/web/lib/foreshadow/useForeshadow.ts
Normal file
96
apps/web/lib/foreshadow/useForeshadow.ts
Normal 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:成功后把返回行追加进 items;422 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 };
|
||||
}
|
||||
63
apps/web/lib/outline/outline.test.ts
Normal file
63
apps/web/lib/outline/outline.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
apps/web/lib/outline/outline.ts
Normal file
49
apps/web/lib/outline/outline.ts
Normal 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;
|
||||
}
|
||||
63
apps/web/lib/outline/useOutline.ts
Normal file
63
apps/web/lib/outline/useOutline.ts
Normal 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 .../outline(body {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 };
|
||||
}
|
||||
37
apps/web/lib/pace/beatmap.test.ts
Normal file
37
apps/web/lib/pace/beatmap.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
53
apps/web/lib/pace/beatmap.ts
Normal file
53
apps/web/lib/pace/beatmap.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// 爽点节拍图:把 pace.beat_map(int 序列)映射成 ▁▃▅ 风格的 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("");
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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.3):planted/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";
|
||||
}
|
||||
|
||||
// 把一条留痕的 pace(dict)收紧成节奏报告;缺失/非 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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,6 +19,29 @@ export interface ReviewConflict {
|
||||
|
||||
export type SectionStatus = "started" | "done" | "incomplete";
|
||||
|
||||
// 伏笔建议(C4 扩 T3.3):planted=新埋待确认 / 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":
|
||||
|
||||
@@ -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+ReadableStream(EventSource 不支持 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
@@ -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 末尾一次 commit(R3)。
|
||||
- 注入缝:`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-18,snake_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-18,snake_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-18;M2 扩四审/验收)
|
||||
- 来源:`ARCHITECTURE.md §5.2`(图)/ §7.3(SSE)。**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 不含 volume);analyst 网关复用 `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**。
|
||||
- 三审只读,留痕经 collect→review_repo(写库 commit 归端点)。详细图/SSE 见上「C4 扩展(T3.3)」。
|
||||
|
||||
## C7 · 前端 ↔ 后端类型契约 owner @backend(产出 OpenAPI) / @frontend(生成) 状态: 待定义
|
||||
- 机制:FastAPI OpenAPI → `apps/web/lib/api` TS 类型(openapi-typescript/orval)。
|
||||
|
||||
@@ -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 查询触发 autoflush(review_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 续接本 router;T3.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 不 commit(commit 归 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`=它。
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
|
||||
---
|
||||
|
||||
- [2026-06-18] @llm **`SqlAlchemyLedgerSink.record` 改 add-only(T3.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-only,commit 归调用方」(commit 仍会 flush,故记账语义不变)。
|
||||
- [2026-06-18] @qa/@llm **并行审记账撞 session(M3 真 bug,T3.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)。修向:审稿期记账避免并发 flush(add-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` 本地先改 status,PATCH 失败 `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` 身份,不可重排,否则裁决错位)。
|
||||
|
||||
117
packages/agents/tests/test_outliner_spec.py
Normal file
117
packages/agents/tests/test_outliner_spec.py
Normal 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)
|
||||
167
packages/agents/tests/test_review_specs.py
Normal file
167
packages/agents/tests/test_review_specs.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""T3.3 三审契约测试:foreshadow / pace spec + 输出 schema(C6 扩)。
|
||||
|
||||
契约测试——构造符合 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": "第 4–6 段反复描写天气", "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 经 rules(genre 级)注入
|
||||
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"
|
||||
@@ -1,17 +1,44 @@
|
||||
"""ww-agents:内置 Agent 声明(AgentSpec)+ 结构化输出 schema(C6)。
|
||||
|
||||
对齐 ARCH §5.1(AgentSpec)/ §5.4(I/O 契约)/ §6.1(冲突分类)。
|
||||
对齐 ARCH §5.1(AgentSpec)/ §5.4(I/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",
|
||||
]
|
||||
|
||||
@@ -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="逐段爽点节拍强度序列(整数,供前端节拍图可视化)",
|
||||
)
|
||||
|
||||
@@ -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(节拍图):把本章按段切分,给每段一个爽点强度整数(如 0–5),\
|
||||
形成逐段强度序列,供前端 ▁▃▅ 可视化节奏起伏。
|
||||
|
||||
纪律:
|
||||
- 你**只读、只报节奏诊断**,不改稿、不写库(不变量 #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 经 rules(genre 级)注入,复用 review_context 规则合并
|
||||
writes=[], # 只读(不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
@@ -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` 抛指定异常——验「某审失败不阻塞其余」+ 未完成占位归一。"""
|
||||
|
||||
|
||||
293
packages/core/tests/test_foreshadow_repo.py
Normal file
293
packages/core/tests/test_foreshadow_repo.py
Normal 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
|
||||
143
packages/core/tests/test_foreshadow_state.py
Normal file
143
packages/core/tests/test_foreshadow_state.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""T3.1 伏笔状态机纯函数单测(ARCH §6.2)。
|
||||
|
||||
确定性纯函数,无 IO:覆盖全转移(登记/首次照应/完成回收)+ OVERDUE 到期判据边界
|
||||
(current ==/</> expected_close_to;CLOSED 不被覆盖;无 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"}
|
||||
137
packages/core/tests/test_outline_node.py
Normal file
137
packages/core/tests/test_outline_node.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""T3.4 大纲节点单测——build_outline_request + run_outline(C6 扩 / 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_request:tier 不变量、缓存断点、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 必非 None(C1);若网关违约返回 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,
|
||||
)
|
||||
125
packages/core/tests/test_outline_write_repo.py
Normal file
125
packages/core/tests/test_outline_write_repo.py
Normal 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
|
||||
@@ -13,16 +13,31 @@ from fakes_orchestrator import (
|
||||
FailingRunGateway,
|
||||
FakeReviewRepo,
|
||||
FakeRunGateway,
|
||||
SchemaRoutingRunGateway,
|
||||
)
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from ww_agents import Conflict, ContinuityReview, continuity_spec
|
||||
from ww_agents import (
|
||||
Conflict,
|
||||
ContinuityReview,
|
||||
ForeshadowReview,
|
||||
ForeshadowSuggestion,
|
||||
PaceIssue,
|
||||
PaceReview,
|
||||
continuity_spec,
|
||||
foreshadow_spec,
|
||||
pace_spec,
|
||||
)
|
||||
from ww_core.orchestrator import (
|
||||
CONTINUITY,
|
||||
EVENT_CONFLICT,
|
||||
EVENT_DONE,
|
||||
EVENT_ERROR,
|
||||
EVENT_FORESHADOW,
|
||||
EVENT_PACE,
|
||||
EVENT_SECTION,
|
||||
FORESHADOW,
|
||||
PACE,
|
||||
REVIEW_INCOMPLETE,
|
||||
REVIEW_OK,
|
||||
SECTION_DONE,
|
||||
@@ -33,6 +48,8 @@ from ww_core.orchestrator import (
|
||||
build_review_request,
|
||||
collect_reviews,
|
||||
extract_conflicts,
|
||||
extract_foreshadow_sug,
|
||||
extract_pace,
|
||||
make_review_node,
|
||||
normalize_review,
|
||||
run_review,
|
||||
@@ -242,3 +259,177 @@ async def test_normalize_review_maps_unexpected_error_to_error_event() -> None:
|
||||
assert events[-1].event == EVENT_ERROR
|
||||
assert events[-1].data["code"] == ErrorCode.INTERNAL
|
||||
assert events[-1].data["request_id"] == "req-9"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# T3.3 三审齐:foreshadow + pace 并入并行审 / collect 列映射 / SSE surface
|
||||
# ============================================================================
|
||||
|
||||
_FORESHADOW = ForeshadowReview(
|
||||
planted=[
|
||||
ForeshadowSuggestion(code="FS-MARK", title="胸口胎记", where="第2段", note="皇族暗示")
|
||||
],
|
||||
resolved=[ForeshadowSuggestion(code="FS-BIRTH", title="身世", where="第9段", note="回收")],
|
||||
)
|
||||
_PACE = PaceReview(
|
||||
water=[PaceIssue(where="第4-6段", reason="信息密度低")],
|
||||
hook=True,
|
||||
beat_map=[1, 3, 5, 0, 4],
|
||||
)
|
||||
|
||||
|
||||
def _three_reviews() -> dict[str, Any]:
|
||||
"""三审皆 ok 的 state['reviews'](continuity/foreshadow/pace)。"""
|
||||
return {
|
||||
CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}},
|
||||
FORESHADOW: {"status": REVIEW_OK, "result": _FORESHADOW.model_dump()},
|
||||
PACE: {"status": REVIEW_OK, "result": _PACE.model_dump()},
|
||||
}
|
||||
|
||||
|
||||
# ---- collect 三审列映射 ----
|
||||
|
||||
|
||||
def test_extract_foreshadow_sug_flattens_planted_and_resolved() -> None:
|
||||
sug = extract_foreshadow_sug(_three_reviews())
|
||||
|
||||
assert len(sug) == 2 # 1 planted + 1 resolved 扁平
|
||||
kinds = {s["kind"] for s in sug}
|
||||
assert kinds == {"planted", "resolved"}
|
||||
planted = next(s for s in sug if s["kind"] == "planted")
|
||||
assert planted["code"] == "FS-MARK"
|
||||
|
||||
|
||||
def test_extract_pace_returns_dict() -> None:
|
||||
pace = extract_pace(_three_reviews())
|
||||
|
||||
assert pace is not None
|
||||
assert pace["hook"] is True
|
||||
assert pace["beat_map"] == [1, 3, 5, 0, 4]
|
||||
|
||||
|
||||
def test_extract_foreshadow_and_pace_empty_when_incomplete() -> None:
|
||||
reviews = {
|
||||
FORESHADOW: {"status": REVIEW_INCOMPLETE, "result": None},
|
||||
PACE: {"status": REVIEW_INCOMPLETE, "result": None},
|
||||
}
|
||||
assert extract_foreshadow_sug(reviews) == []
|
||||
assert extract_pace(reviews) is None
|
||||
|
||||
|
||||
async def test_collect_records_three_audits_to_their_columns() -> None:
|
||||
repo = FakeReviewRepo()
|
||||
|
||||
await collect_reviews(_state(_three_reviews()), review_repo=repo)
|
||||
|
||||
assert len(repo.records) == 1 # 一次 record 落齐三审
|
||||
rec = repo.records[0]
|
||||
assert rec["conflicts"][0]["type"] == "能力不符" # continuity→conflicts
|
||||
assert len(rec["foreshadow_sug"]) == 2 # foreshadow→foreshadow_sug(扁平)
|
||||
assert rec["pace"]["hook"] is True # pace→pace
|
||||
|
||||
|
||||
# ---- 三审并行图跑通(mock 网关按 schema 路由产三 parsed)----
|
||||
|
||||
|
||||
async def test_three_review_graph_runs_all_audits_end_to_end() -> None:
|
||||
gateway = SchemaRoutingRunGateway(
|
||||
{
|
||||
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
|
||||
ForeshadowReview: _FORESHADOW,
|
||||
PaceReview: _PACE,
|
||||
}
|
||||
)
|
||||
repo = FakeReviewRepo()
|
||||
graph = build_review_graph(
|
||||
gateway,
|
||||
repo,
|
||||
review_specs=(continuity_spec, foreshadow_spec, pace_spec),
|
||||
checkpointer=MemorySaver(),
|
||||
)
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "rev-3"}}
|
||||
|
||||
final = await graph.ainvoke(_state(), config=config)
|
||||
|
||||
assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
|
||||
assert final["reviews"][FORESHADOW]["status"] == REVIEW_OK
|
||||
assert final["reviews"][PACE]["status"] == REVIEW_OK
|
||||
assert len(gateway.calls) == 3 # 三审各调一次网关
|
||||
# collect 一次 record 落齐三列
|
||||
rec = repo.records[0]
|
||||
assert rec["conflicts"] and rec["foreshadow_sug"] and rec["pace"]
|
||||
|
||||
|
||||
async def test_three_review_graph_isolates_one_failing_audit() -> None:
|
||||
# foreshadow schema 缺失 → SchemaRoutingRunGateway 抛 KeyError → run_review 隔离为 incomplete
|
||||
gateway = SchemaRoutingRunGateway(
|
||||
{
|
||||
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
|
||||
PaceReview: _PACE,
|
||||
}
|
||||
)
|
||||
repo = FakeReviewRepo()
|
||||
graph = build_review_graph(
|
||||
gateway,
|
||||
repo,
|
||||
review_specs=(continuity_spec, foreshadow_spec, pace_spec),
|
||||
checkpointer=MemorySaver(),
|
||||
)
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "rev-4"}}
|
||||
|
||||
final = await graph.ainvoke(_state(), config=config)
|
||||
|
||||
# foreshadow 失败被隔离,其余两审照常 + collect 照常留痕(foreshadow_sug 空)
|
||||
assert final["reviews"][FORESHADOW]["status"] == REVIEW_INCOMPLETE
|
||||
assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
|
||||
assert final["reviews"][PACE]["status"] == REVIEW_OK
|
||||
rec = repo.records[0]
|
||||
assert rec["conflicts"] # continuity 落库
|
||||
assert rec["foreshadow_sug"] == [] # 失败列留空,不阻塞其余
|
||||
assert rec["pace"]["hook"] is True # pace 落库
|
||||
|
||||
|
||||
# ---- SSE:normalize_review surface 三审(section + conflict + foreshadow + pace + done)----
|
||||
|
||||
|
||||
async def test_normalize_review_surfaces_three_audits() -> None:
|
||||
events = [e async for e in normalize_review(_three_reviews())]
|
||||
kinds = [e.event for e in events]
|
||||
|
||||
# 字典序遍历:continuity < foreshadow < pace
|
||||
# continuity: section + 1 conflict
|
||||
# foreshadow: section + 2(planted+resolved);pace: section + 1
|
||||
assert kinds == [
|
||||
EVENT_SECTION,
|
||||
EVENT_CONFLICT,
|
||||
EVENT_SECTION,
|
||||
EVENT_FORESHADOW,
|
||||
EVENT_FORESHADOW,
|
||||
EVENT_SECTION,
|
||||
EVENT_PACE,
|
||||
EVENT_DONE,
|
||||
]
|
||||
# foreshadow 事件带 kind + code
|
||||
fs_events = [e for e in events if e.event == EVENT_FORESHADOW]
|
||||
assert {e.data["kind"] for e in fs_events} == {"planted", "resolved"}
|
||||
# pace 事件带节拍图
|
||||
pace_evt = next(e for e in events if e.event == EVENT_PACE)
|
||||
assert pace_evt.data["beat_map"] == [1, 3, 5, 0, 4]
|
||||
assert pace_evt.data["hook"] is True
|
||||
assert events[-1].data == {"length": 3} # 三审项
|
||||
|
||||
|
||||
async def test_normalize_review_skips_results_for_incomplete_audit() -> None:
|
||||
reviews = {
|
||||
CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": []}},
|
||||
FORESHADOW: {"status": REVIEW_INCOMPLETE, "result": None},
|
||||
PACE: {"status": REVIEW_OK, "result": _PACE.model_dump()},
|
||||
}
|
||||
|
||||
events = [e async for e in normalize_review(reviews)]
|
||||
kinds = [e.event for e in events]
|
||||
|
||||
# foreshadow incomplete → 仅 section,无 foreshadow 事件
|
||||
assert kinds == [EVENT_SECTION, EVENT_SECTION, EVENT_SECTION, EVENT_PACE, EVENT_DONE]
|
||||
fs_section = next(e for e in events if e.data.get("name") == FORESHADOW)
|
||||
assert fs_section.data["status"] == SECTION_INCOMPLETE
|
||||
|
||||
@@ -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",
|
||||
|
||||
219
packages/core/ww_core/domain/foreshadow_repo.py
Normal file
219
packages/core/ww_core/domain/foreshadow_repo.py
Normal 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` 提供(最小 View,C5 稳定,不动)。
|
||||
本模块加**写**能力 + 看板查询,命名加 `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_case,frozen)——看板/账本用,含进展与重要度。"""
|
||||
|
||||
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` JSONB(append-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
|
||||
105
packages/core/ww_core/domain/foreshadow_state.py
Normal file
105
packages/core/ww_core/domain/foreshadow_state.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""伏笔账本状态机——确定性纯函数(ARCH §6.2 / PRODUCT_SPEC §4.2)。
|
||||
|
||||
状态机(§6.2)::
|
||||
|
||||
登记 首次照应 完成回收
|
||||
─────▶ OPEN ──────▶ PARTIAL ─────────▶ CLOSED
|
||||
│ │
|
||||
└─────────────┴──▶ OVERDUE (章号 > expected_close_to 且未 CLOSED)
|
||||
(到期扫描置位; 回收后可转 CLOSED)
|
||||
|
||||
- 登记 → OPEN(DB 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 且仍逾期 → 保持 OVERDUE;CLOSED 永不被改写(is_overdue 已排除)。
|
||||
"""
|
||||
if is_overdue(
|
||||
current_chapter=current_chapter,
|
||||
expected_close_to=expected_close_to,
|
||||
status=status,
|
||||
):
|
||||
return OVERDUE
|
||||
return status
|
||||
109
packages/core/ww_core/domain/outline_write_repo.py
Normal file
109
packages/core/ww_core/domain/outline_write_repo.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""大纲**写侧** Repository(逐章 upsert;ARCH §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` 取到同形 dict(round-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_case,frozen)。"""
|
||||
|
||||
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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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`(节奏诊断 dict:water/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` 且有结果;否则 None(incomplete/缺席)。"""
|
||||
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`:留痕行不绑具体 version(T2.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)。
|
||||
|
||||
@@ -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;
|
||||
|
||||
81
packages/core/ww_core/orchestrator/outline_node.py
Normal file
81
packages/core/ww_core/orchestrator/outline_node.py
Normal 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。
|
||||
|
||||
不变量 #2:agent 只声明 `tier`,绝不传具体 model(spec.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 必非 None(C1);违约则明确报错而非返回空大纲。
|
||||
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
|
||||
@@ -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:
|
||||
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:
|
||||
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 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(
|
||||
|
||||
112
packages/llm_gateway/tests/test_ledger_concurrency.py
Normal file
112
packages/llm_gateway/tests/test_ledger_concurrency.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""T3.8 回归:并行审记账并发安全(add-only,无 flush 重入)。
|
||||
|
||||
旧 bug(T3.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-only:sink 不在并行路径 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
|
||||
@@ -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
584
tests/test_m3_e2e.py
Normal 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 · 三审 SSE(continuity/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` 路由 parsed(OutlineResult / 三审 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)默认都路由到 deepseek(config.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()`:吐固定写章 token(writer/draft),末尾喂 writer 用量。
|
||||
- `complete()`:据 `req.output_schema` 路由:
|
||||
`OutlineResult`→大纲(analyst);`ContinuityReview`/`ForeshadowReview`/`PaceReview`
|
||||
→三审(analyst/light);`ChapterDigestFacts`→验收 digest(light,追溯终稿)。
|
||||
每路带 `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:伏笔 埋设→进展→验收后到期扫描置 OVERDUE(BackgroundTask)。"""
|
||||
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) 埋伏笔 F1(expected_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"
|
||||
|
||||
# 对照:F2(expected_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 含 F1(status==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) → OVERDUE;progress 含那条进展。
|
||||
assert by_code["F1"].status == "OVERDUE"
|
||||
assert progress_entry in by_code["F1"].progress
|
||||
# F2:expected_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:三审 SSE(continuity/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)
|
||||
Reference in New Issue
Block a user