From 5ee9799885ca4823953d84862274a692fa6fd19c Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 16:43:38 +0200 Subject: [PATCH 1/6] =?UTF-8?q?docs(chain):=20=E5=A4=9A=E7=AB=A0=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E9=93=BE=E6=AD=A3=E5=BC=8F=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=88=E5=AE=9E=E6=96=BD=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/chain-workflow.md | 211 ++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/design/chain-workflow.md diff --git a/docs/design/chain-workflow.md b/docs/design/chain-workflow.md new file mode 100644 index 0000000..cee8e01 --- /dev/null +++ b/docs/design/chain-workflow.md @@ -0,0 +1,211 @@ +# 设计文档 · 多章工作流链(Chain Workflow) + +> 状态:设计稿(实施前契约)· 分支 `feat/chain-workflow` · 锚点 ARCHITECTURE §5.2/§5.5、CLAUDE.md 架构不变量 #1/#3/#5/#9、DEV_PLAN §4 +> +> 本文是实施的**唯一契约源**。所有实施 agent 先读本文,按其中签名/事件/DoD 落地;与现有 spec 冲突时以更具体者为准并回写。 + +--- + +## 1. 背景与目标 + +### 1.1 为什么做 +对标竞品「星月写作」的核心能力是**一键多章工作流**(导语→大纲→细纲→一键十章正文循环),但其纯走量、无一致性闸,靠提示词补"关联前文/不跑偏"。本产品的差异化:**同样的链式量产,但每一章都过 `assemble → write → 四审 → digest` 闭环 → 批量生成而长篇不崩**。这是星月架构上做不到的杀手锏。 + +### 1.2 为什么用 LangGraph(而非端点+DB 循环) +多章循环是本项目**第一个真正需要 LangGraph 的场景**:单章流程是线性的(写一发、审一次 `ainvoke`、验收一个事务),无需图;而"写→审→判定→循环/中断、跨小时可恢复、遇冲突暂停交人"是**有环、可持久、可中断**的编排 = LangGraph 本职。本设计借此**激活当前休眠的资产**:`checkpointer`(现恒 `None`)、`interrupt()`(现未实现)、`build_write_graph`(现死代码——将被本链图取代/吸收)。 + +### 1.3 目标(DoD) +1. 一条内置链 `draft_volume`:从指定起始章,循环写 K 章,每章 `write → 四审 → 判定 → 验收`,digest 入库。 +2. **杀手锏 interrupt-on-conflict**:链自动跑;某章四审报出冲突 → `interrupt()` 暂停 → 前端取该章冲突交人裁决 → resume 续跑。无冲突章自动 accept。 +3. 跑在 `run_job` 长任务壳里:`POST .../chains/{key}/run` → 202 `{job_id}`;`GET /jobs/{id}` 看进度("3/10 章已过审");`POST .../chains/runs/{job_id}/resume` 带裁决续跑。 +4. **后端门禁绿**(ruff/format/mypy/alembic 无漂移/pytest)+ **E2E**(mock 网关零 token)走通:无冲突全自动两章 + 冲突→interrupt→resume 两路径。 + +### 1.4 非目标(本期不做) +- **前端 UI**(链发起页/进度/裁决续跑面板)→ 契约稳定后单独一刀(@frontend follow-up)。 +- **可编辑/可分享的 UGC 链市场**(星月式)→ 需多租户/Auth,已 deferred,单独立项。 +- **续写/扩写 节点** → 可作为链的可选步后补,本期链步固定为 write。 +- 多种 HITL 档位 → 本期只做 `auto`(无冲突自动过、有冲突才停)。 + +--- + +## 2. 架构总览 + +``` +POST .../chains/draft_volume/run {start_chapter_no, count} + │ (写 jobs 行 status=queued, kind="chain") → 202 {job_id} + ▼ BackgroundTasks → run_chain_job(job_id, resume=None) +┌──────────────────────── LangGraph ChainGraph (thread_id = job_id) ─────────────────────┐ +│ START → write_chapter → review_chapter → decide ──(无冲突)──→ accept_chapter ─┐ │ +│ ▲ │ │ │ +│ │ (有冲突) │ │ +│ │ ▼ │ │ +│ │ interrupt() ⏸ (job=awaiting_input) │ │ +│ │ │ │ +│ └────────────────(还有章: current<=last)──── advance ◄──────────────┘ │ +│ │ │ +│ (写满 K 章) → END │ +└────────────────────────────────────────────────────────────────────────────────────────┘ + │ checkpointer (Postgres runtime / MemorySaver tests) 持久控制流位置 + pending handle + ▼ +POST .../chains/runs/{job_id}/resume {decisions:[...]} + │ (Command(resume=decisions)) → 202 → run_chain_job(job_id, resume=decisions) + ▼ 从 interrupt 续跑:accept_chapter 用 decisions → advance → 下一章… +``` + +**关键纪律**:节点做 DB IO 时各自用**短事务、独立 session**(每章 accept 章原子提交,仿 `run_overdue_scan` 自建 session)。checkpointer **只存控制流**(`current_chapter_no` / `written` / `pending`),章正文落 `chapters`、审稿落 `chapter_reviews`——**resume 时重读领域表**为真相(不变量 #5)。 + +--- + +## 3. 状态与节点 + +### 3.1 State 类型 `ChainState`(新增 `orchestrator/chain/state.py`) +TypedDict(langgraph 状态 = 检查点内容,**只放控制流,不放正文/上下文**): + +| 字段 | 类型 | 说明 | +|---|---|---| +| `project_id` | `uuid.UUID` | | +| `user_id` | `uuid.UUID` | STUB_OWNER_ID | +| `chain_key` | `str` | 固定 `"draft_volume"` | +| `start_chapter_no` | `int` | 起始章 | +| `last_chapter_no` | `int` | = start + count - 1(含) | +| `current_chapter_no` | `int` | 当前处理章;初值 = start | +| `written` | `list[int]` | 本 run 已验收章号(进度来源) | +| `has_conflicts` | `bool` | `decide` 节点写入;控制流标志,不存冲突明文 | + +> 草稿正文/上下文**不进** state(落 DB)。冲突明文**不进** state(resume 时从 `chapter_reviews` 重读,不变量 #5)。 + +### 3.2 节点(`orchestrator/chain/nodes.py`,gateway/session_factory 经闭包绑入工厂) + +1. **`write_chapter`**:新建读写 session → `assemble(project_id, current_chapter_no)` 组装记忆(确定性,不变量 #6/#9:硬规则+指纹进缓存前缀)→ `gateway.run`(收集版写章,复用 `write_node` 同一 prompt 组装路径,非 SSE)→ `chapter_repo.save_draft` 落 `chapters` draft → commit。State 不变(正文在 DB)。 +2. **`review_chapter`**:复用四审。两种实现取其一(实施者选更省的):(a) 内联调用 `build_review_graph(gateway, review_repo).ainvoke(review_state)`;(b) 直接 `run_review × REVIEW_SPECS` + `collect_reviews`。落 `chapter_reviews` 留痕(不变量 #3 只读,不写正文)→ commit。 +3. **`decide`**(确定性纯逻辑,无 LLM):读该章最近审稿留痕 → `has_conflicts = len(conflicts) > 0`。写入 state。 +4. **条件边 `decide → ?`**: + - `has_conflicts == True` → `interrupt({chapter_no})`:`interrupt()` 暂停,恢复值 = 该章 `decisions` 列表。恢复后进 `accept_chapter`。 + - `has_conflicts == False` → 直接进 `accept_chapter`(`decisions = []`)。 +5. **`accept_chapter`**:新建 session → 从 `chapter_reviews` **重读**该章最近审稿(不信 state)→ `assert_conflicts_resolved(latest_review, decisions)`(缺判 → `CONFLICT_UNRESOLVED`,链失败)→ 事务外 `extract_digest_facts`(从终稿)→ `run_accept_transaction(...)` 章原子提交 → 提交后 `run_overdue_scan`(自建 session,伏笔到期)。`written.append(current_chapter_no)`。 +6. **条件边 `accept_chapter → ?`**:`current_chapter_no < last_chapter_no` → `advance`(`current_chapter_no += 1`)→ 回 `write_chapter`;否则 → `END`。 + +> `advance` 可并入 `accept_chapter` 的返回(`current_chapter_no+1`)+ 一个 `should_continue` 条件边判 START/END,避免独立节点。实施者择简。 + +### 3.3 图工厂 `build_chain_graph`(`orchestrator/chain/graph.py`)— 契约 + +```python +def build_chain_graph( + gateway: GatewayRun, # 复用 ._protocols.GatewayRun + *, + session_factory: SessionFactory, # 节点自建短事务用(services 注入) + review_repo_factory, # session -> ReviewRecorder + checkpointer: BaseCheckpointSaver | None = None, # 运行时 Postgres;测试 MemorySaver +) -> CompiledStateGraph[ChainState, None, ChainState, ChainState]: ... +``` + +- 节点 DB 依赖经 `session_factory` + `*_repo_factory` 闭包注入(与现有"gateway 闭包绑定"同模式;langgraph 不收 `functools.partial`,用默认参绑定,见 gotcha)。 +- **删除/吸收 `build_write_graph`**:本链图取代它;`write_node` 函数本体保留(被 `write_chapter` 节点复用),删掉无人调用的 `build_write_graph` 工厂(YAGNI,解决审计发现的死代码)。 + +--- + +## 4. checkpointer 接线(激活休眠资产) + +- **运行时**:`AsyncPostgresSaver`(`langgraph.checkpoint.postgres.aio`)。新增注入缝 `get_checkpointer()`(`services/chain_deps.py`)→ 运行时返 Postgres saver(连 `DATABASE_URL`),测试注 `MemorySaver`。 +- **建表**:`setup_checkpointer(conn_string)`(已存在 `orchestrator/graph.py:106`,迁移本链图后移到 `orchestrator/chain/graph.py` 或保留)。**在 alembic 迁移/CI 跑一次**建 langgraph 检查点表(不变量:DDL 不在 app-runtime)。新增迁移 `xxxx_langgraph_checkpoint_setup`:`op` 内 `await setup_checkpointer(sync_or_async_conn)`(实施者按本仓 alembic async 模式处理;若 alembic 环境不便跑 async saver.setup,则在 CI bootstrap 步骤调用并在迁移注释说明)。 +- `thread_id = str(job_id)`:`config={"configurable": {"thread_id": str(job_id)}}`。初始 run `graph.ainvoke(initial, config)`;resume `graph.ainvoke(Command(resume=decisions), config)`。 +- **测试**:E2E 用**单个进程内 MemorySaver 实例**横跨"初始 run"与"resume run"两次模拟调用(同进程同 saver,故 interrupt 可恢复);绝不连真 Postgres、绝不真 LLM。 + +--- + +## 5. 服务层(`apps/api/ww_api/services/chain_runner.py`) + +```python +async def run_chain_job( + session_factory, job_id, *, project_id, chain_key, start_chapter_no, count, + gateway, checkpointer, resume_decisions=None, request_id=None, +) -> None: + # 复用 run_job 的生命周期壳:set_running → work → complete/fail。 + # work: 构 build_chain_graph(...) → config{thread_id=job_id} + # - resume_decisions is None → graph.ainvoke(initial_state, config) + # - else → graph.ainvoke(Command(resume=resume_decisions), config) + # 返回值/进度: 从图最终 state 取 {written, last_chapter_no} → job.result + # {"chain_key","written":[...],"completed":bool,"awaiting_chapter":int|None} + # interrupt 命中 → graph 返回带 __interrupt__ → job 置 status="awaiting_input" + # (jobs 复用现有状态机 + 新增/复用一个 awaiting 态,见 §7 数据) +``` + +- **进度**:每验收一章可经 `job_repo.set_progress`(若有)更新 `progress`;否则 result 里 `written` 长度即进度。 +- **错误脱敏**:复用 `run_job` 的 `_classify_job_error`(AppError 透传安全文案,其余通用文案,不泄 `str(exc)`,P0-3)。 +- **token 不泄漏**:job result/status/日志绝不含 prompt/正文/token(只记章号、长度、计数)。 + +--- + +## 6. API 契约(@backend;改 OpenAPI → 记 contracts + 前端 follow-up `pnpm gen:api`) + +| 端点 | 方法 | 请求 | 响应 | 说明 | +|---|---|---|---|---| +| `/projects/{pid}/chains/{chain_key}/run` | POST | `ChainRunRequest{start_chapter_no:int>=1, count:int 1..50}` | 202 `ChainRunAccepted{job_id, chain_key, start_chapter_no, count}` | 写 jobs(queued,kind=chain) + BackgroundTask `run_chain_job` | +| `/jobs/{job_id}` | GET | — | 现有 `JobView` + `result{chain_key,written[],completed,awaiting_chapter}` | 复用现有 jobs 端点;轮询进度/awaiting | +| `/projects/{pid}/chains/runs/{job_id}/resume` | POST | `ChainResumeRequest{decisions: list[ConflictDecision]}` | 202 `ChainRunAccepted` | 仅当 job=awaiting_input;BackgroundTask resume | + +- 校验(系统边界,fail fast):`count` 1..50(>50 → 422 VALIDATION);`start_chapter_no >= 1`;`chain_key` 未知 → 404;resume 非 awaiting 态 → 409。 +- `ConflictDecision` 复用 `schemas/projects.py` 既有(`conflict_index/verdict/note`)。 +- schemas 新增 `schemas/chain.py`:`ChainRunRequest/ChainRunAccepted/ChainResumeRequest`。 + +--- + +## 7. 数据模型 + +- **无新业务表**。章正文 → `chapters`;审稿 → `chapter_reviews`;digest → `chapter_digests`;运行 → `jobs`。 +- `jobs`:复用。新增 `kind` 值 `"chain"`(若 `kind` 列已存在则直接用;若无则本设计**允许**一个最小迁移加 `jobs.kind TEXT` + `jobs.status` 增态 `awaiting_input`——实施者先查现表,能复用就零迁移)。 +- **langgraph 检查点表**:由 `setup_checkpointer` 在迁移/CI 建(§4)。这是唯一新增 DDL。 + +> 实施者 MUST 先查 `packages/db/ww_db/models.py` 的 `jobs` 现有列(是否已有 `kind`/`progress`/`result`/能否表达 awaiting)再决定是否需要迁移;优先零迁移方案(如用 `result.awaiting_chapter` 表达待裁决,`status` 复用现有枚举)。 + +--- + +## 8. 不变量符合性(逐条核对) + +- **#1 DB 即单一真源**:链节点只经领域表通信;节点间不互调。✅ +- **#3 四审只读 + 验收 HITL 闸**:`review_chapter` 只写 `chapter_reviews`;唯一正文写经 `accept_chapter`→`run_accept_transaction`;有冲突必 `interrupt()` 交人,`assert_conflicts_resolved` 缺判即拦。✅ +- **#4 digest 从终稿提炼**:`accept_chapter` 事务外 `extract_digest_facts(final_text)`。✅ +- **#5 checkpoint 只存控制流**:`ChainState` 只含章号/计数/标志;冲突明文 + 正文重读领域表。✅ +- **#9 缓存前缀稳定**:`write_chapter` 复用 `assemble` 的 stable_core/volatile 切分,硬规则+指纹进前缀、无时间戳/UUID。✅ +- **节点确定性可测**:LLM 非确定性锁在 gateway 后;`decide` 是纯逻辑。✅ + +--- + +## 9. 任务拆分(多 agent,契约先行) + +| 任务 | skill | 依赖 | 产出 | DoD | +|---|---|---|---|---| +| **C1 链图 + 节点 + 单测** | @llm | — | `orchestrator/chain/{state,nodes,graph}.py` + 删 `build_write_graph` + 单测(mock gateway + fake session_factory + MemorySaver;测无冲突全跑 + 有冲突 interrupt + resume→accept;`decide` 纯逻辑测) | `packages/core`+`packages/agents` ruff/format/mypy 干净 + pytest 绿;图可 ainvoke、可 interrupt、可 resume | +| **C2 服务 + 端点 + schema + checkpointer 接线 + 单测** | @backend | C1 契约 | `services/{chain_runner,chain_deps}.py` + `routers/chain.py` + `schemas/chain.py` + main 注册 + jobs 复用/最小迁移 | `apps/api` 门禁绿;3 端点入 OpenAPI;run→202、resume→202、未知 key 404、count>50 422、resume 非 awaiting 409;token 不入 result/日志 | +| **C3 检查点建表迁移** | @db/@devops | C1 | alembic 迁移调 `setup_checkpointer`(或 CI bootstrap)+ jobs 最小迁移(如需) | `alembic upgrade head` 应用、`alembic check` 无漂移 | +| **C4 E2E** | @qa | C2 | `tests/test_chain_workflow_e2e.py`:真 pg + mock 网关零 token:① 两章无冲突全自动→`chapters` 两 accepted 版次 + `chapter_digests` 两行 + job done `written=[n,n+1]`;② 注入冲突→第一章 interrupt→job awaiting→resume 带裁决→续跑→accept;负向:token 不入 job result/状态 | E2E 绿;DB 真源逐项断言 | + +**并行性**:C1 是契约源,先行(§3.3 签名 + §6 端点已在本文锁定,故 C2 可对照契约并行起,但建议 C1 先落地图签名)。C3 依赖 C1(用其 `setup_checkpointer`)。C4 依赖 C2。 + +--- + +## 10. 测试计划(守 TDD + mock LLM) + +- **单元**(C1):节点函数直接调用(不起图运行时):`write_chapter`(fake gateway 返固定文 + fake session 断言 save_draft)、`decide`(构造有/无冲突的 review → 断 `has_conflicts`)、`accept_chapter`(fake repos 断 promote/digest/decisions + 冲突缺判抛 CONFLICT_UNRESOLVED)。**流程**:MemorySaver 跑全图,断无冲突→END 且 written 满、有冲突→`__interrupt__`、resume→END。 +- **集成/E2E**(C4):FastAPI 端点 + 真 pg + mock 网关;interrupt+resume 在**单进程单 MemorySaver**(经依赖覆盖注入)内横跨两次端点调用。 +- **绝不**:真 LLM、真 Postgres 检查点联网、把正文/token 写进断言外的日志。 + +--- + +## 11. 风险与回退 + +| 风险 | 缓解 | +|---|---| +| alembic 跑 async `saver.setup()` 不便 | 退路:CI/bootstrap 脚本调 `setup_checkpointer`,迁移注释指明;E2E 用 MemorySaver 不依赖该表 | +| 一条 run 内多章长事务/连接占用 | 节点**各自短事务**,不跨章共享 session;图运行本身无 DB 事务,只 checkpointer 写 | +| MemorySaver 跨请求不持久 | 仅测试用(同进程);运行时强制 Postgres saver,文档标注 | +| 链中途崩溃 | checkpointer 持久 → 可经 resume/重跑同 thread_id 续;job 置 failed 带脱敏文案 | +| 与既有单章 `draft/review/accept` 端点重复逻辑 | 复用同一 `run_accept_transaction`/`assemble`/审稿 spec,不复制;链是**编排**新增、非业务重写 | + +--- + +## 12. 文档回写(实施完成后) +- `ARCHITECTURE.md §5.2`:补"多章链图(cyclic + interrupt + Postgres checkpointer)",并更正现状描述(写章直连流式、单章 accept 为事务、链图首次启用 checkpointer)。 +- `memory/contracts.md`:登记 C-Chain 契约(图签名 + 3 端点)。 +- `memory/decisions.md`:记"多章编排选 LangGraph 链图而非端点循环"的理由。 +- `PROGRESS.md`:拉入"Chain Workflow"阶段台账。 From 7f3eaaba3d79c31babb959fcbd92737da060c798 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 16:54:47 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(orchestrator):=20C1=20=E5=A4=9A?= =?UTF-8?q?=E7=AB=A0=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=93=BE=E5=9B=BE=20?= =?UTF-8?q?=E2=80=94=20write=E2=86=92review=E2=86=92decide=E2=86=92accept?= =?UTF-8?q?=20=E6=9C=89=E7=8E=AF=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 chain-workflow 设计 §3 的 LangGraph 链图,激活休眠资产 (checkpointer + interrupt-on-conflict)。 - packages/core/ww_core/orchestrator/chain/{state,nodes,graph}.py: - ChainState(§3.1):只放控制流(章号/计数/written/has_conflicts), 正文/审稿/冲突明文落领域表,resume 重读(不变量 #1/#3/#5)。 - 四节点 write_chapter/review_chapter/decide/accept_chapter(§3.2): 各自短事务、独立 session;decide 纯逻辑读最近审稿留痕定 has_conflicts; accept 节点起始处遇冲突 interrupt() 暂停交人,恢复值=裁决清单。 - build_chain_graph(§3.3 签名):节点 DB 依赖经 session_factory + repo 工厂 + assemble/accept_op 闭包默认参绑定(langgraph 不收 partial); 条件边实现循环(current<=last 回 write)+ interrupt。 - 验收事务(apps/api)经 accept_op 闭包注入,守目录所有权 + 不变量 #3/#4。 - 收集版写章:复用 build_write_request 同一组装路径(stream=False → run)。 - 删除无人调用的 build_write_graph 工厂(write_node 本体保留,被链复用); 清理其 __init__ 导出 + 旧单节点图单测(YAGNI,解决审计死代码)。 - 单测(mock gateway + fake session_factory + fake repos + MemorySaver): 无冲突全跑到 END 且 written 满 / 有冲突→__interrupt__ / resume(Command)→ accept→END / decide 纯逻辑 / accept 推进章号;零 token 零联网。 门禁:packages ruff/format/mypy 干净;pytest 314 passed。 --- packages/core/tests/test_chain_graph.py | 375 ++++++++++++++++++ packages/core/tests/test_orchestrator.py | 34 +- .../core/ww_core/orchestrator/__init__.py | 4 +- .../ww_core/orchestrator/chain/__init__.py | 54 +++ .../core/ww_core/orchestrator/chain/graph.py | 170 ++++++++ .../core/ww_core/orchestrator/chain/nodes.py | 311 +++++++++++++++ .../core/ww_core/orchestrator/chain/state.py | 36 ++ packages/core/ww_core/orchestrator/graph.py | 24 +- 8 files changed, 951 insertions(+), 57 deletions(-) create mode 100644 packages/core/tests/test_chain_graph.py create mode 100644 packages/core/ww_core/orchestrator/chain/__init__.py create mode 100644 packages/core/ww_core/orchestrator/chain/graph.py create mode 100644 packages/core/ww_core/orchestrator/chain/nodes.py create mode 100644 packages/core/ww_core/orchestrator/chain/state.py diff --git a/packages/core/tests/test_chain_graph.py b/packages/core/tests/test_chain_graph.py new file mode 100644 index 0000000..e52d035 --- /dev/null +++ b/packages/core/tests/test_chain_graph.py @@ -0,0 +1,375 @@ +"""多章链图单测(chain-workflow 设计 §3 / §10)——节点纯逻辑 + 全图 MemorySaver 流程。 + +全部注入 mock 网关 + fake session_factory + fake repos + MemorySaver,无真 LLM、无真 +Postgres。验:① 无冲突全跑到 END 且 written 满;② 有冲突 → `__interrupt__`;③ resume +(`Command(resume=decisions)`)→ accept → END;④ `decide` 纯逻辑;⑤ accept 推进章号。 +asyncio_mode=auto。 +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.memory import MemorySaver +from langgraph.types import Command +from pydantic import BaseModel +from ww_agents import Conflict, ContinuityReview +from ww_core.orchestrator.chain import ( + accept_chapter, + build_chain_graph, + decide, + has_conflicts, + initial_chain_state, + write_chapter, +) +from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage + +PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001") +USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa") + + +# ---- 测试替身 ---- + + +def _usage() -> Usage: + return Usage( + provider="fake", + model="fake", + input_tokens=1, + output_tokens=1, + cost_minor=0, + currency="USD", + ) + + +class FakeChainGateway: + """链网关 mock:write(无 output_schema)回固定正文;审稿按 `output_schema` 回 parsed。 + + `conflicts`:注给 continuity 审的冲突清单(空 = 无冲突章)。其余审 schema 回各自空实例。 + """ + + def __init__( + self, + *, + draft_text: str, + conflicts: list[Conflict], + by_schema: dict[type, BaseModel], + ) -> None: + self._draft = draft_text + self._continuity = ContinuityReview(conflicts=conflicts) + self._by_schema = by_schema + self.write_calls = 0 + self.review_calls = 0 + + async def run(self, req: LlmRequest) -> LlmResponse: + schema: type[BaseModel] | None = req.output_schema + if schema is None: + self.write_calls += 1 + return LlmResponse( + text=self._draft, + parsed=None, + usage=_usage(), + served_by=ServedBy(provider="fake", model="fake"), + ) + self.review_calls += 1 + parsed: BaseModel = ( + self._continuity if schema is ContinuityReview else self._by_schema[schema] + ) + return LlmResponse( + text=parsed.model_dump_json(), + parsed=parsed, + usage=_usage(), + served_by=ServedBy(provider="fake", model="fake"), + ) + + +class FakeDraftView: + def __init__(self, content: str) -> None: + self.content = content + + +class FakeChapterRepo: + """内存章草稿 repo:save_draft 存正文,get_draft 回读。""" + + def __init__(self, store: dict[int, str]) -> None: + self._store = store + + async def save_draft( + self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1 + ) -> Any: + self._store[chapter_no] = text + return FakeDraftView(text) + + async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> Any: + content = self._store.get(chapter_no) + return FakeDraftView(content) if content is not None else None + + +class FakeReviewRecord: + def __init__(self, chapter_no: int, conflicts: list[dict[str, Any]]) -> None: + self.id = uuid.uuid4() + self.chapter_no = chapter_no + self.conflicts = conflicts + + +class FakeReviewRepo: + """内存审稿 repo:record 追加留痕,list_for_chapter 回新→旧。""" + + def __init__(self, records: dict[int, list[FakeReviewRecord]]) -> None: + self._records = records + + async def record( + self, + project_id: uuid.UUID, + chapter_no: int, + *, + chapter_version: int | None = None, + conflicts: list[dict[str, Any]], + foreshadow_sug: list[dict[str, Any]] | None = None, + style: dict[str, Any] | None = None, + pace: dict[str, Any] | None = None, + health_score: int | None = None, + ) -> Any: + rec = FakeReviewRecord(chapter_no, list(conflicts)) + self._records.setdefault(chapter_no, []).insert(0, rec) + return rec + + async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[Any]: + return list(self._records.get(chapter_no, [])) + + +class FakeSession: + def __init__(self) -> None: + self.commits = 0 + + async def commit(self) -> None: + self.commits += 1 + + +class FakeContext: + def __init__(self) -> None: + self.stable_core = "## 世界观硬规则\n灵气可凝丹" + self.volatile = "## 写作指令\n写第 N 章" + + +@asynccontextmanager +async def _session_factory() -> AsyncIterator[FakeSession]: + """fake 短事务 session(每次新建;满足 SessionFactory)。""" + yield FakeSession() + + +def _empty_by_schema() -> dict[type, BaseModel]: + """非 continuity 审(pace/style/foreshadow)的空 parsed——按各 spec 的 output_schema。""" + from ww_agents import foreshadow_spec, pace_spec, style_drift_spec + + out: dict[type, BaseModel] = {} + for spec in (foreshadow_spec, pace_spec, style_drift_spec): + schema = spec.output_schema + assert schema is not None + out[schema] = schema() + return out + + +def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]: + """造一套共享内存态 + 工厂闭包 + accept 间谍,供图/节点测试复用。""" + draft_store: dict[int, str] = {} + review_store: dict[int, list[FakeReviewRecord]] = {} + accepted: list[dict[str, Any]] = [] + + gateway = FakeChainGateway( + draft_text="第 N 章正文。", conflicts=conflicts, by_schema=_empty_by_schema() + ) + + def memory_repos_factory(session: Any) -> Any: + return object() # assemble 被 fake 替换,不实际用 repos + + def chapter_repo_factory(session: Any) -> Any: + return FakeChapterRepo(draft_store) + + def review_repo_factory(session: Any) -> Any: + return FakeReviewRepo(review_store) + + async def assemble(repos: Any, project_id: uuid.UUID, chapter_no: int) -> Any: + return FakeContext() + + async def accept_op( + *, + project_id: uuid.UUID, + chapter_no: int, + user_id: uuid.UUID, + decisions: list[Any], + ) -> None: + accepted.append({"chapter_no": chapter_no, "decisions": list(decisions)}) + + return { + "gateway": gateway, + "session_factory": _session_factory, + "memory_repos_factory": memory_repos_factory, + "chapter_repo_factory": chapter_repo_factory, + "review_repo_factory": review_repo_factory, + "assemble": assemble, + "accept_op": accept_op, + "accepted": accepted, + "draft_store": draft_store, + "review_store": review_store, + } + + +def _build(h: dict[str, Any], checkpointer: Any) -> Any: + return build_chain_graph( + h["gateway"], + session_factory=h["session_factory"], + memory_repos_factory=h["memory_repos_factory"], + chapter_repo_factory=h["chapter_repo_factory"], + review_repo_factory=h["review_repo_factory"], + assemble=h["assemble"], + accept_op=h["accept_op"], + checkpointer=checkpointer, + ) + + +# ---- decide 纯逻辑 ---- + + +async def test_decide_no_conflicts_sets_false() -> None: + review_store: dict[int, list[FakeReviewRecord]] = {1: [FakeReviewRecord(1, [])]} + + def review_repo_factory(session: Any) -> Any: + return FakeReviewRepo(review_store) + + out = await decide( + {"project_id": PROJECT, "user_id": USER, "current_chapter_no": 1}, + session_factory=_session_factory, + review_repo_factory=review_repo_factory, + ) + + assert out == {"has_conflicts": False} + + +async def test_decide_with_conflicts_sets_true() -> None: + review_store: dict[int, list[FakeReviewRecord]] = { + 1: [FakeReviewRecord(1, [{"kind": "x", "detail": "矛盾"}])] + } + + def review_repo_factory(session: Any) -> Any: + return FakeReviewRepo(review_store) + + out = await decide( + {"project_id": PROJECT, "user_id": USER, "current_chapter_no": 1}, + session_factory=_session_factory, + review_repo_factory=review_repo_factory, + ) + + assert out == {"has_conflicts": True} + + +def test_has_conflicts_reads_flag() -> None: + assert has_conflicts({"has_conflicts": True}) is True + assert has_conflicts({"has_conflicts": False}) is False + assert has_conflicts({}) is False + + +# ---- accept 节点:推进章号 + 追加 written + 透传裁决 ---- + + +async def test_accept_chapter_advances_and_records_decisions() -> None: + accepted: list[dict[str, Any]] = [] + + async def accept_op( + *, + project_id: uuid.UUID, + chapter_no: int, + user_id: uuid.UUID, + decisions: list[Any], + ) -> None: + accepted.append({"chapter_no": chapter_no, "decisions": list(decisions)}) + + out = await accept_chapter( + { + "project_id": PROJECT, + "user_id": USER, + "current_chapter_no": 3, + "written": [1, 2], + "last_chapter_no": 5, + }, + decisions=[{"conflict_index": 0, "verdict": "ignore"}], + accept_op=accept_op, + ) + + assert out["current_chapter_no"] == 4 + assert out["written"] == [1, 2, 3] + assert out["has_conflicts"] is False + assert accepted == [ + {"chapter_no": 3, "decisions": [{"conflict_index": 0, "verdict": "ignore"}]} + ] + + +# ---- write 节点:收集版(非流式)落稿 ---- + + +async def test_write_chapter_saves_collected_draft() -> None: + h = _make_harness(conflicts=[]) + state = initial_chain_state(project_id=PROJECT, user_id=USER, start_chapter_no=1, count=1) + + out = await write_chapter( + state, + gateway=h["gateway"], + session_factory=h["session_factory"], + memory_repos_factory=h["memory_repos_factory"], + chapter_repo_factory=h["chapter_repo_factory"], + assemble=h["assemble"], + ) + + assert out == {} # state 不变(正文在 DB) + assert h["draft_store"][1] == "第 N 章正文。" + # 收集版:write 走非流式 run + assert h["gateway"].write_calls == 1 + + +# ---- 全图(MemorySaver):无冲突全自动跑满 ---- + + +async def test_chain_runs_all_chapters_when_no_conflicts() -> None: + h = _make_harness(conflicts=[]) + graph = _build(h, MemorySaver()) + config: RunnableConfig = {"configurable": {"thread_id": "chain-noconflict"}} + initial = initial_chain_state(project_id=PROJECT, user_id=USER, start_chapter_no=1, count=2) + + final = await graph.ainvoke(initial, config=config) + + assert "__interrupt__" not in final + assert final["written"] == [1, 2] + assert final["current_chapter_no"] == 3 # last(2) + 1,越界 → END + assert [a["chapter_no"] for a in h["accepted"]] == [1, 2] + assert h["accepted"][0]["decisions"] == [] # 无冲突章 decisions=[] + + +# ---- 全图:有冲突 → interrupt → resume → accept → END ---- + + +async def test_chain_interrupts_on_conflict_then_resumes() -> None: + conflict = Conflict(type="性格漂移", where="第3段", suggestion="统一主角姓名为张三") + h = _make_harness(conflicts=[conflict]) + saver = MemorySaver() # 同进程同 saver 跨两次 invoke → interrupt 可恢复 + graph = _build(h, saver) + config: RunnableConfig = {"configurable": {"thread_id": "chain-conflict"}} + initial = initial_chain_state(project_id=PROJECT, user_id=USER, start_chapter_no=1, count=1) + + # 初始 run:第 1 章四审报冲突 → interrupt 暂停。 + interrupted = await graph.ainvoke(initial, config=config) + + assert "__interrupt__" in interrupted + assert h["accepted"] == [] # 尚未验收 + + # resume:作者裁决该冲突 → 续跑 accept → END。 + decisions = [{"conflict_index": 0, "verdict": "ignore", "note": "笔误,后续统一"}] + final = await graph.ainvoke(Command(resume=decisions), config=config) + + assert "__interrupt__" not in final + assert final["written"] == [1] + assert [a["chapter_no"] for a in h["accepted"]] == [1] + assert h["accepted"][0]["decisions"] == decisions # 裁决透传给 accept_op diff --git a/packages/core/tests/test_orchestrator.py b/packages/core/tests/test_orchestrator.py index fe42035..3224e90 100644 --- a/packages/core/tests/test_orchestrator.py +++ b/packages/core/tests/test_orchestrator.py @@ -8,15 +8,11 @@ from __future__ import annotations import uuid from fakes_orchestrator import FakeStreamGateway, RaisingGateway -from langchain_core.runnables import RunnableConfig -from langgraph.checkpoint.memory import MemorySaver from ww_core.orchestrator import ( EVENT_DONE, EVENT_ERROR, EVENT_TOKEN, - WRITE_NODE, ChapterState, - build_write_graph, build_write_request, normalize_deltas, stream_chapter_draft, @@ -135,31 +131,5 @@ async def test_normalize_deltas_maps_unexpected_error_to_internal() -> None: assert events[0].data["code"] == ErrorCode.INTERNAL -# ---- 图编译:装节点 + checkpointer(MemorySaver,不连真 Postgres) ---- - - -def test_build_write_graph_compiles_with_checkpointer() -> None: - gateway = FakeStreamGateway(["x"]) - - graph = build_write_graph(gateway, checkpointer=MemorySaver()) - - assert WRITE_NODE in graph.get_graph().nodes - assert graph.checkpointer is not None - - -async def test_compiled_graph_runs_write_node_end_to_end() -> None: - gateway = FakeStreamGateway(["云", "深"]) - graph = build_write_graph(gateway, checkpointer=MemorySaver()) - config: RunnableConfig = {"configurable": {"thread_id": "t-1"}} - initial: ChapterState = { - "project_id": PROJECT, - "chapter_no": 1, - "user_id": USER, - "stable_core": STABLE, - "volatile": VOLATILE, - "draft": "", - } - - final = await graph.ainvoke(initial, config=config) - - assert final["draft"] == "云深" +# 单 `write` 节点图 `build_write_graph` 已删(链图取代,见 chain-workflow 设计 §3.3); +# write 节点本体 `write_node` 仍由上面的 stream/run 用例直接覆盖(注入 mock 网关,无需图运行时)。 diff --git a/packages/core/ww_core/orchestrator/__init__.py b/packages/core/ww_core/orchestrator/__init__.py index 5bd7d6a..5436229 100644 --- a/packages/core/ww_core/orchestrator/__init__.py +++ b/packages/core/ww_core/orchestrator/__init__.py @@ -1,6 +1,6 @@ """编排器(C4 / ARCH §5.2)——写章图 + 审稿子图 + SSE 归一 + Postgres checkpointer。 -- M1:单 `write` 节点(`build_write_graph`)。 +- write 节点本体 `write_node`(被多章链 `chain` 复用;M1 单节点图 `build_write_graph` 已删)。 - M2:并行审子图(`build_review_graph`:START→各审并行→collect 落 `chapter_reviews`)+ 审稿 SSE(`section`/`conflict` 事件 + `normalize_review`)。 - accept(验收事务/`interrupt_before`)不在本图——属 T2.4(确定性代码读领域表,R3)。 @@ -37,7 +37,6 @@ from .graph import ( REVIEW_SPECS, WRITE_NODE, build_review_graph, - build_write_graph, setup_checkpointer, ) from .outline_node import build_outline_request, run_outline @@ -115,7 +114,6 @@ __all__ = [ "build_review_request", "build_style_extract_request", "build_worldbuilder_context", - "build_write_graph", "build_write_request", "collect_reviews", "conflict_event", diff --git a/packages/core/ww_core/orchestrator/chain/__init__.py b/packages/core/ww_core/orchestrator/chain/__init__.py new file mode 100644 index 0000000..1b44738 --- /dev/null +++ b/packages/core/ww_core/orchestrator/chain/__init__.py @@ -0,0 +1,54 @@ +"""多章工作流链(chain-workflow 设计)——`write → review → decide → accept` 有环图。 + +第一个真正需要 LangGraph 的场景(有环 + 可持久 + 可中断):批量量产 K 章,每章过 +`assemble → write → 四审 → digest` 闭环,遇冲突 `interrupt()` 暂停交人裁决,resume 续跑。 +激活休眠资产(checkpointer + interrupt)。详见 docs/design/chain-workflow.md。 +""" + +from __future__ import annotations + +from .graph import ( + ACCEPT_CHAPTER, + DECIDE, + REVIEW_CHAPTER, + WRITE_CHAPTER, + build_chain_graph, + initial_chain_state, +) +from .nodes import ( + AcceptChapterOp, + AssembleContext, + AssembleFn, + ChapterDraftRepo, + MemoryReposFactory, + ReviewRecordRepo, + SessionFactory, + accept_chapter, + decide, + has_conflicts, + review_chapter, + write_chapter, +) +from .state import ChainState + +__all__ = [ + "ACCEPT_CHAPTER", + "DECIDE", + "REVIEW_CHAPTER", + "WRITE_CHAPTER", + "AcceptChapterOp", + "AssembleContext", + "AssembleFn", + "ChainState", + "ChapterDraftRepo", + "MemoryReposFactory", + "ReviewRecordRepo", + "SessionFactory", + "accept_chapter", + "build_chain_graph", + "decide", + "has_conflicts", + "initial_chain_state", + "review_chapter", + "write_chapter", +] diff --git a/packages/core/ww_core/orchestrator/chain/graph.py b/packages/core/ww_core/orchestrator/chain/graph.py new file mode 100644 index 0000000..40b0383 --- /dev/null +++ b/packages/core/ww_core/orchestrator/chain/graph.py @@ -0,0 +1,170 @@ +"""多章链图工厂 `build_chain_graph`(chain-workflow 设计 §3.3)。 + +链 = LangGraph 有环图:`write → review → decide →(无冲突)→ accept →(还有章)→ write`, +`decide →(有冲突)→ interrupt ⏸ → accept`,写满 K 章 → END。本图**激活休眠资产**: +checkpointer(持久控制流 + pending handle,跨小时可恢复)+ interrupt(遇冲突暂停交人)。 + +依赖注入纪律(与现有「gateway 闭包绑定」同模式):节点 DB 依赖经 `session_factory` + +`*_repo_factory` + `accept_op` 闭包绑入图节点。langgraph 不收 `functools.partial`,故用 +**默认参绑定**(见 memory/gotchas.md)。 + +checkpointer 纪律(CLAUDE.md「LangGraph」):`setup()` 只在 migrations/CI 跑(`setup_checkpointer` +在 `orchestrator/graph.py`,迁移/CI 调一次),build 函数绝不触碰 DDL;单测注 `MemorySaver`。 +""" + +from __future__ import annotations + +import uuid +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Any + +from langgraph.graph import END, START, StateGraph +from langgraph.types import interrupt +from ww_agents import AgentSpec + +from .._protocols import GatewayRun +from ..graph import REVIEW_SPECS +from . import nodes +from .nodes import ( + AcceptChapterOp, + AssembleFn, + ChapterDraftRepo, + MemoryReposFactory, + ReviewRecordRepo, + SessionFactory, +) +from .state import ChainState + +if TYPE_CHECKING: + from langgraph.checkpoint.base import BaseCheckpointSaver + from langgraph.graph.state import CompiledStateGraph + +WRITE_CHAPTER = "write_chapter" +REVIEW_CHAPTER = "review_chapter" +DECIDE = "decide" +ACCEPT_CHAPTER = "accept_chapter" + + +def build_chain_graph( + gateway: GatewayRun, + *, + session_factory: SessionFactory, + memory_repos_factory: MemoryReposFactory, + chapter_repo_factory: Callable[[Any], ChapterDraftRepo], + review_repo_factory: Callable[[Any], ReviewRecordRepo], + assemble: AssembleFn, + accept_op: AcceptChapterOp, + review_specs: Sequence[AgentSpec] = REVIEW_SPECS, + checkpointer: BaseCheckpointSaver[Any] | None = None, +) -> CompiledStateGraph[ChainState, None, ChainState, ChainState]: + """构建并编译 `draft_volume` 多章链图。 + + 节点(`write_chapter`/`review_chapter`/`decide`/`accept_chapter`)经默认参绑定把 + `gateway`/`session_factory`/各 repo 工厂/`assemble`/`accept_op` 闭包绑入。 + 条件边:`decide` 命中冲突 → `accept_chapter` 内 `interrupt()` 暂停交人; + `accept_chapter` 后判 `current_chapter_no <= last_chapter_no` 回 `write_chapter` 或 END。 + + `checkpointer`:运行时传 Postgres saver;单测传 `MemorySaver`(同进程跨 invoke 可恢复)。 + """ + + async def _write(state: ChainState) -> dict[str, Any]: + return await nodes.write_chapter( + state, + gateway=gateway, + session_factory=session_factory, + memory_repos_factory=memory_repos_factory, + chapter_repo_factory=chapter_repo_factory, + assemble=assemble, + ) + + async def _review(state: ChainState) -> dict[str, Any]: + return await nodes.review_chapter( + state, + gateway=gateway, + session_factory=session_factory, + memory_repos_factory=memory_repos_factory, + chapter_repo_factory=chapter_repo_factory, + review_repo_factory=review_repo_factory, + assemble=assemble, + review_specs=review_specs, + ) + + async def _decide(state: ChainState) -> dict[str, Any]: + return await nodes.decide( + state, + session_factory=session_factory, + review_repo_factory=review_repo_factory, + ) + + async def _accept(state: ChainState) -> dict[str, Any]: + # interrupt 放节点**起始**(CLAUDE.md「LangGraph」):有冲突 → 暂停交人,恢复值 = + # 该章裁决清单;无冲突 → decisions=[],直接验收。恢复后 accept_op 落库(不变量 #3)。 + decisions: list[Any] = [] + if nodes.has_conflicts(state): + resumed = interrupt({"chapter_no": state["current_chapter_no"]}) + decisions = list(resumed or []) + return await nodes.accept_chapter(state, decisions=decisions, accept_op=accept_op) + + builder: StateGraph[ChainState, None, ChainState, ChainState] = StateGraph(ChainState) + builder.add_node(WRITE_CHAPTER, _write) + builder.add_node(REVIEW_CHAPTER, _review) + builder.add_node(DECIDE, _decide) + builder.add_node(ACCEPT_CHAPTER, _accept) + + builder.add_edge(START, WRITE_CHAPTER) + builder.add_edge(WRITE_CHAPTER, REVIEW_CHAPTER) + builder.add_edge(REVIEW_CHAPTER, DECIDE) + # decide 后无论有无冲突都进 accept;interrupt(若有冲突)在 accept 节点起始处暂停。 + builder.add_edge(DECIDE, ACCEPT_CHAPTER) + # accept 后条件边:还有章 → 回 write 续跑;写满 K 章 → END。 + builder.add_conditional_edges( + ACCEPT_CHAPTER, + _should_continue, + {WRITE_CHAPTER: WRITE_CHAPTER, END: END}, + ) + return builder.compile(checkpointer=checkpointer) + + +def _should_continue(state: ChainState) -> str: + """accept 后路由:`current_chapter_no <= last_chapter_no` → 续写下一章;否则 END。 + + `accept_chapter` 已把 `current_chapter_no += 1`,故此处直接比较即判「是否还有章」。 + 纯函数,便于单测。 + """ + if state["current_chapter_no"] <= state["last_chapter_no"]: + return WRITE_CHAPTER + return END + + +def initial_chain_state( + *, + project_id: uuid.UUID, + user_id: uuid.UUID, + start_chapter_no: int, + count: int, + chain_key: str = "draft_volume", +) -> ChainState: + """构造初始 `ChainState`(区间 [start, start+count-1],current=start,written=[])。 + + 纯函数:服务层 / 测试构初值用,集中区间换算(last = start + count - 1,含)。 + """ + return { + "project_id": project_id, + "user_id": user_id, + "chain_key": chain_key, + "start_chapter_no": start_chapter_no, + "last_chapter_no": start_chapter_no + count - 1, + "current_chapter_no": start_chapter_no, + "written": [], + "has_conflicts": False, + } + + +__all__ = [ + "ACCEPT_CHAPTER", + "DECIDE", + "REVIEW_CHAPTER", + "WRITE_CHAPTER", + "build_chain_graph", + "initial_chain_state", +] diff --git a/packages/core/ww_core/orchestrator/chain/nodes.py b/packages/core/ww_core/orchestrator/chain/nodes.py new file mode 100644 index 0000000..2ac69ba --- /dev/null +++ b/packages/core/ww_core/orchestrator/chain/nodes.py @@ -0,0 +1,311 @@ +"""多章链节点(chain-workflow 设计 §3.2)——write → review → decide → accept 循环。 + +节点是**小确定性单元**:LLM 非确定性锁在网关后;`decide` 是纯逻辑。每个做 DB IO 的 +节点用**短事务、独立 session**(每章 accept 原子提交,仿 `run_overdue_scan` 自建 session)。 +checkpoint 只存控制流,正文/审稿落领域表——resume 时重读领域表为真相(不变量 #1/#3/#5)。 + +依赖注入:本模块的节点是裸异步函数(显式关键字依赖),供直接单测注入 fake;图工厂 +(`graph.py`)用默认参绑定把依赖闭包绑进图节点(langgraph 不收 functools.partial,见 gotcha)。 +""" + +from __future__ import annotations + +import uuid +from collections.abc import Awaitable, Callable, Sequence +from contextlib import AbstractAsyncContextManager +from typing import Any, Protocol + +import structlog +from ww_agents import AgentSpec + +from .._protocols import GatewayRun +from ..collect import collect_reviews, extract_conflicts +from ..review_node import build_review_context, run_review +from ..state import ChapterState +from ..write_node import build_write_request +from .state import ChainState + +log = structlog.get_logger(__name__) + +# ---- 注入缝(图工厂经默认参绑定,单测直接注 fake)---- + +#: `session -> Repo`:节点自建短事务里从 session 造 repo(仿端点依赖工厂)。 +SessionFactory = Callable[[], AbstractAsyncContextManager[Any]] + + +class AssembleContext(Protocol): + """`assemble` 产出的最小读形——只需 stable_core/volatile(构造写章/审稿请求)。""" + + stable_core: str + volatile: str + + +#: `(repos, project_id, chapter_no) -> AssembledContext`:组装记忆(确定性,不变量 #6/#9)。 +AssembleFn = Callable[[Any, uuid.UUID, int], Awaitable[AssembleContext]] + + +class MemoryReposFactory(Protocol): + """`session -> MemoryRepos`(assemble 所需 8 repo 捆绑)。""" + + def __call__(self, session: Any) -> Any: ... + + +class DraftView(Protocol): + """章草稿读形最小依赖——只需 `content`(重读正文构审稿上下文)。""" + + content: str + + +class ChapterDraftRepo(Protocol): + """章草稿 repo 最小依赖:write 落稿 `save_draft` + review 重读 `get_draft`(只 flush)。""" + + async def save_draft( + self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1 + ) -> Any: ... + + async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> DraftView | None: ... + + +class ReviewRecordRepo(Protocol): + """review/accept 节点对审稿 repo 的最小依赖:collect 留痕 + accept 重读最近审稿。""" + + async def record( + self, + project_id: uuid.UUID, + chapter_no: int, + *, + chapter_version: int | None = None, + conflicts: list[dict[str, Any]], + foreshadow_sug: list[dict[str, Any]] | None = None, + style: dict[str, Any] | None = None, + pace: dict[str, Any] | None = None, + health_score: int | None = None, + ) -> Any: ... + + async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[Any]: ... + + +class CommitSession(Protocol): + """节点短事务对 session 的最小依赖——只需 `commit()`(write/review 节点末尾提交)。""" + + async def commit(self) -> None: ... + + +#: accept 步:把作者裁决落库(晋升终稿 + digest + 裁决留痕 + 伏笔到期),各自独立 session。 +#: 编排(链)在 core;具体验收事务(apps/api)经此闭包注入,守目录所有权 + 不变量 #3/#4。 +#: 入参 `decisions` 为裁决清单(`accept_chapter` 节点经 interrupt 恢复值拿到,无冲突=[])。 +AcceptChapterOp = Callable[..., Awaitable[None]] + + +# ---- 节点本体(裸函数,显式依赖;图工厂绑定闭包)---- + + +async def write_chapter( + state: ChainState, + *, + gateway: GatewayRun, + session_factory: SessionFactory, + memory_repos_factory: MemoryReposFactory, + chapter_repo_factory: Callable[[Any], ChapterDraftRepo], + assemble: AssembleFn, +) -> dict[str, Any]: + """write 节点:组装记忆 → 收集版写章(非 SSE)→ 落 `chapters` draft → commit。 + + 短事务、独立 session(不跨章共享)。state 不变(正文在 DB)。返回空增量。 + 收集版写章复用 `build_write_request` 同一 prompt 组装路径(stream=False → `gateway.run`), + 守不变量 #9(stable_core 进缓存前缀);不确定性锁在网关后,注 mock 即可单测。 + """ + project_id = state["project_id"] + chapter_no = state["current_chapter_no"] + user_id = state["user_id"] + async with session_factory() as session: + repos = memory_repos_factory(session) + context = await assemble(repos, project_id, chapter_no) + req = build_write_request( + stable_core=context.stable_core, + volatile=context.volatile, + user_id=user_id, + project_id=project_id, + ) + # 收集版:非流式,一次 run 拿全文(链批量量产不走 SSE)。 + collect_req = req.model_copy(update={"stream": False}) + resp = await gateway.run(collect_req) + chapter_repo = chapter_repo_factory(session) + await chapter_repo.save_draft(project_id, chapter_no, text=resp.text) + await _commit(session) + log.info( + "chain_write_chapter", + project_id=str(project_id), + chapter_no=chapter_no, + draft_len=len(resp.text), + ) + return {} + + +async def review_chapter( + state: ChainState, + *, + gateway: GatewayRun, + session_factory: SessionFactory, + memory_repos_factory: MemoryReposFactory, + chapter_repo_factory: Callable[[Any], ChapterDraftRepo], + review_repo_factory: Callable[[Any], ReviewRecordRepo], + assemble: AssembleFn, + review_specs: Sequence[AgentSpec], +) -> dict[str, Any]: + """review 节点:复用四审 → 落 `chapter_reviews` 留痕(不变量 #3 只读、不写正文)→ commit。 + + 复用现有 `run_review × specs` + `collect_reviews`(直接顺序跑各审,非起子图——链节点本身 + 已是图节点,无需嵌套图)。从 DB 重读本章草稿构审稿上下文(真相在表,不信 state)。 + """ + project_id = state["project_id"] + chapter_no = state["current_chapter_no"] + user_id = state["user_id"] + async with session_factory() as session: + repos = memory_repos_factory(session) + context = await assemble(repos, project_id, chapter_no) + chapter_repo = chapter_repo_factory(session) + draft_view = await _read_draft(chapter_repo, project_id, chapter_no) + review_context = build_review_context( + draft=draft_view, + stable_core=context.stable_core, + volatile=context.volatile, + ) + review_state: ChapterState = { + "project_id": project_id, + "chapter_no": chapter_no, + "user_id": user_id, + "review_context": review_context, + } + reviews: dict[str, Any] = {} + for spec in review_specs: + part = await run_review(spec, review_state, gateway=gateway) + reviews.update(part.get("reviews", {})) + review_state["reviews"] = reviews + review_repo = review_repo_factory(session) + await collect_reviews(review_state, review_repo=review_repo) + await _commit(session) + log.info( + "chain_review_chapter", + project_id=str(project_id), + chapter_no=chapter_no, + reviews=sorted(reviews.keys()), + ) + return {} + + +async def decide( + state: ChainState, + *, + session_factory: SessionFactory, + review_repo_factory: Callable[[Any], ReviewRecordRepo], +) -> dict[str, Any]: + """decide 节点(确定性纯逻辑,无 LLM):读最近审稿留痕 → `has_conflicts`。 + + 冲突明文**不入** state(不变量 #5);只写控制流标志,accept 时再从领域表重读冲突。 + """ + project_id = state["project_id"] + chapter_no = state["current_chapter_no"] + async with session_factory() as session: + review_repo = review_repo_factory(session) + history = await review_repo.list_for_chapter(project_id, chapter_no) + latest = history[0] if history else None + conflicts = _conflicts_of(latest) + has_conflicts = len(conflicts) > 0 + log.info( + "chain_decide", + project_id=str(project_id), + chapter_no=chapter_no, + has_conflicts=has_conflicts, + conflict_count=len(conflicts), + ) + return {"has_conflicts": has_conflicts} + + +async def accept_chapter( + state: ChainState, + *, + decisions: list[Any], + accept_op: AcceptChapterOp, +) -> dict[str, Any]: + """accept 节点:用裁决落库(晋升终稿 + digest + 裁决留痕 + 伏笔到期),推进章号。 + + 具体验收事务(重读 `chapter_reviews`、`assert_conflicts_resolved` gate、终稿提炼 digest、 + `run_accept_transaction`、`run_overdue_scan`)属 apps/api,经 `accept_op` 闭包注入——守目录 + 所有权 + 不变量 #3/#4(缺判 → `CONFLICT_UNRESOLVED` 由 op 抛,链失败)。 + + `decisions`:无冲突章 = [];有冲突章 = interrupt 恢复值(作者裁决清单)。 + 返回 `current_chapter_no += 1` + `written` 追加本章——推进控制流(不可变增量)。 + """ + project_id = state["project_id"] + chapter_no = state["current_chapter_no"] + user_id = state["user_id"] + await accept_op( + project_id=project_id, + chapter_no=chapter_no, + user_id=user_id, + decisions=decisions, + ) + written = list(state.get("written") or []) + written.append(chapter_no) + log.info( + "chain_accept_chapter", + project_id=str(project_id), + chapter_no=chapter_no, + written_count=len(written), + ) + return { + "current_chapter_no": chapter_no + 1, + "written": written, + "has_conflicts": False, + } + + +# ---- 辅助(纯函数 / 小工具)---- + + +def _conflicts_of(latest_review: Any) -> list[dict[str, Any]]: + """从最近审稿留痕取冲突清单(兼容 ReviewView Pydantic 与内存 fake dict)。""" + if latest_review is None: + return [] + conflicts = getattr(latest_review, "conflicts", None) + if conflicts is None and isinstance(latest_review, dict): + conflicts = latest_review.get("conflicts") + return list(conflicts or []) + + +async def _read_draft( + chapter_repo: ChapterDraftRepo, project_id: uuid.UUID, chapter_no: int +) -> str: + """从 repo 重读本章草稿正文(真相在表,不信 state)。""" + view = await chapter_repo.get_draft(project_id, chapter_no) + return view.content if view is not None else "" + + +async def _commit(session: CommitSession) -> None: + """提交短事务(session 满足 `CommitSession`:只需 `commit()`)。""" + await session.commit() + + +# decide 暴露给条件边的辅助(纯判定,便于单测)。 +def has_conflicts(state: ChainState) -> bool: + """条件边读 state 的冲突标志(缺省 False)。""" + return bool(state.get("has_conflicts")) + + +__all__ = [ + "AcceptChapterOp", + "AssembleContext", + "AssembleFn", + "ChainState", + "ChapterDraftRepo", + "MemoryReposFactory", + "ReviewRecordRepo", + "SessionFactory", + "accept_chapter", + "decide", + "extract_conflicts", + "has_conflicts", + "review_chapter", + "write_chapter", +] diff --git a/packages/core/ww_core/orchestrator/chain/state.py b/packages/core/ww_core/orchestrator/chain/state.py new file mode 100644 index 0000000..a89f4a3 --- /dev/null +++ b/packages/core/ww_core/orchestrator/chain/state.py @@ -0,0 +1,36 @@ +"""多章工作流链状态 `ChainState`(chain-workflow 设计 §3.1)。 + +不变量 #5:langgraph 检查点 = state 内容,**只放控制流**——章号/计数/已验收清单/ +冲突标志。草稿正文/上下文/冲突明文**不进** state(落 `chapters`/`chapter_reviews`, +resume 时从领域表重读为真相)。 + +`ChainState` 用 TypedDict(LangGraph 原生状态形态),字段全 snake_case。 +""" + +from __future__ import annotations + +import uuid +from typing import TypedDict + + +class ChainState(TypedDict, total=False): + """`draft_volume` 链的控制流状态(检查点内容)。 + + - `project_id` / `user_id`:定位 + 调用作用域(原型单用户 stub)。 + - `chain_key`:固定 `"draft_volume"`(本期唯一内置链)。 + - `start_chapter_no` / `last_chapter_no`:本 run 区间 [start, last](含)。 + - `current_chapter_no`:当前处理章;初值 = start,每验收一章 +1。 + - `written`:本 run 已验收章号(进度来源;resume 后累积)。 + - `has_conflicts`:`decide` 节点写入的控制流标志(不存冲突明文,不变量 #5)。 + + `total=False`:初始 invoke 只需填区间字段,`written`/`has_conflicts` 由节点逐步填。 + """ + + project_id: uuid.UUID + user_id: uuid.UUID + chain_key: str + start_chapter_no: int + last_chapter_no: int + current_chapter_no: int + written: list[int] + has_conflicts: bool diff --git a/packages/core/ww_core/orchestrator/graph.py b/packages/core/ww_core/orchestrator/graph.py index 3f1535c..b7447a0 100644 --- a/packages/core/ww_core/orchestrator/graph.py +++ b/packages/core/ww_core/orchestrator/graph.py @@ -27,37 +27,17 @@ from ._protocols import GatewayRun from .collect import ReviewRecorder, collect_reviews from .review_node import run_review from .state import ChapterState -from .write_node import GatewayStream, write_node if TYPE_CHECKING: from langgraph.checkpoint.base import BaseCheckpointSaver from langgraph.graph.state import CompiledStateGraph +# write 节点名(write_node 本体保留,被多章链 `chain.nodes.write_chapter` 复用;M1 的 +# `build_write_graph` 单节点图已删——链图取代之,YAGNI,见 chain-workflow 设计 §3.3)。 WRITE_NODE = "write" COLLECT_NODE = "collect" -def build_write_graph( - gateway: GatewayStream, - *, - checkpointer: BaseCheckpointSaver[Any] | None = None, -) -> CompiledStateGraph[ChapterState, None, ChapterState, ChapterState]: - """构建并编译单 `write` 节点图。 - - `gateway` 经闭包绑进节点(节点对图只暴露 `(state)->dict`,确定性可单测)。 - `checkpointer`:运行时传 Postgres saver;单测传 `MemorySaver` 或不传。 - """ - - async def _write(state: ChapterState) -> dict[str, str]: - return await write_node(state, gateway=gateway) - - builder: StateGraph[ChapterState, None, ChapterState, ChapterState] = StateGraph(ChapterState) - builder.add_node(WRITE_NODE, _write) - builder.add_edge(START, WRITE_NODE) - builder.add_edge(WRITE_NODE, END) - return builder.compile(checkpointer=checkpointer) - - # M4:四审齐(continuity + foreshadow + pace + style)。第四审 = 文风漂移打分轨。 REVIEW_SPECS: tuple[AgentSpec, ...] = ( continuity_spec, From 29349dc7ee0ac3fa152f5898b995a54b26be11f5 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 17:12:53 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat(api):=20C2=20=E5=A4=9A=E7=AB=A0?= =?UTF-8?q?=E9=93=BE=20=E6=9C=8D=E5=8A=A1+=E7=AB=AF=E7=82=B9+schema+checkp?= =?UTF-8?q?ointer=20=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 承 C1 链图(build_chain_graph),落地多章工作流链的 apps/api 壳: - 3 端点 routers/chain.py:POST .../chains/{key}/run→202 ChainRunAccepted; POST .../chains/runs/{job_id}/resume→202;GET /jobs/{id} 复用。校验: count 1..50→422、未知 chain_key→404、resume 非 awaiting→409、无凭据→503。 - schemas/chain.py:ChainRunRequest/ChainRunAccepted/ChainResumeRequest (ConflictDecision 复用 schemas/projects)。 - services/chain_runner.py:run_chain_job 仿 run_job 壳自建独立 session 驱动链图 (set_running→ainvoke→据 __interrupt__ 置 awaiting_input/done/failed); build_accept_op 在 apps/api 装配验收事务闭包注入图节点(守 #3/#4); token 不入 result/日志。 - services/chain_deps.py:get_checkpointer_factory(运行时 AsyncPostgresSaver 上下文 / 测试 MemorySaver)。 - 零迁移(设计 §7):复用 jobs,新增 status="awaiting_input" + JobRepo.set_awaiting, awaiting 章经 result.awaiting_chapter;新错误码 ErrorCode.CONFLICT(409)。 - project_deps:build_chain_gateway/get_chain_gateway(按请求 tier writer/analyst/light 分派——单档网关恒返该档会错路由 review/digest)+ get_digest_gateway_builder。 单测 apps/api/tests/test_chain.py 12 用例(mock 网关 + MemorySaver + fake session/ accept_op,无 DB/无网络/无真 LLM):run/resume→202、未知 key 404、count 越界 422、 resume 非 awaiting 409、run_chain_job 无冲突→done、冲突→awaiting→resume→done、 错误脱敏、accept_op 冲突缺判→CONFLICT_UNRESOLVED。 门禁绿:ruff/format 干净 · mypy 193 Success · alembic 无漂移 · pytest 600 passed。 守不变量 #1/#3/#4/#5/#9。唯一新增 DDL(langgraph 检查点表)= C3 迁移。 Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 1 + apps/api/tests/fakes_projects.py | 8 + apps/api/tests/test_chain.py | 586 +++++++++++++++++++++++ apps/api/ww_api/main.py | 2 + apps/api/ww_api/routers/chain.py | 249 ++++++++++ apps/api/ww_api/schemas/chain.py | 54 +++ apps/api/ww_api/services/chain_deps.py | 44 ++ apps/api/ww_api/services/chain_runner.py | 294 ++++++++++++ apps/api/ww_api/services/project_deps.py | 81 +++- memory/contracts.md | 15 + memory/decisions.md | 6 + memory/gotchas.md | 3 + packages/core/tests/test_job_repo.py | 7 + packages/core/ww_core/domain/job_repo.py | 14 + packages/shared/ww_shared/errors.py | 2 + 15 files changed, 1365 insertions(+), 1 deletion(-) create mode 100644 apps/api/tests/test_chain.py create mode 100644 apps/api/ww_api/routers/chain.py create mode 100644 apps/api/ww_api/schemas/chain.py create mode 100644 apps/api/ww_api/services/chain_deps.py create mode 100644 apps/api/ww_api/services/chain_runner.py diff --git a/PROGRESS.md b/PROGRESS.md index d0c7f86..e39702b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -111,6 +111,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` +- [2026-06-23] @backend — **✅ Chain Workflow C2(多章链 服务+端点+schema+checkpointer 接线,分支 `feat/chain-workflow`)**。承 C1(@llm)链图(`build_chain_graph` §3.3),落地服务/端点壳:① **3 端点**`routers/chain.py`(`POST .../chains/{key}/run`→202 `ChainRunAccepted`;`POST .../chains/runs/{job_id}/resume`→202;`GET /jobs/{id}` 复用)。校验:count 1..50→422、未知 chain_key→404、项目不存在→404、resume job 不存在→404、resume 非 awaiting→**409 新码 `CONFLICT`**、无凭据→503。② **schema**`schemas/chain.py`(`ChainRunRequest/ChainRunAccepted/ChainResumeRequest`,`ConflictDecision` 复用)。③ **服务**`services/chain_runner.py`(`run_chain_job` 仿 `run_job` 壳自建独立 session 驱动链图:set_running→ainvoke→据 `__interrupt__` 置 `awaiting_input` 或 `done`/`failed`;`build_accept_op` 在 apps/api 装配验收事务闭包注入图节点,守 #3/#4;token 不入 result/日志)+ `services/chain_deps.py`(`get_checkpointer_factory`:运行时 `AsyncPostgresSaver` 上下文 / 测试 MemorySaver)。④ **零迁移**(§7):复用 `jobs`,新增 `status="awaiting_input"` + `JobRepo.set_awaiting`,awaiting 章经 `result.awaiting_chapter`;新错误码 `ErrorCode.CONFLICT`(409)。⑤ **新网关缝**`project_deps.build_chain_gateway`/`get_chain_gateway`(按请求 tier writer/analyst/light 分派——单档网关恒返该档会错路由 review/digest,见 gotcha)+ `get_digest_gateway_builder`。**单测**`apps/api/tests/test_chain.py` 12 用例(mock 网关 + MemorySaver + fake session/accept_op,无 DB/无网络/无真 LLM):run→202、resume→202、未知 key 404、count 越界(>50/0) 422、resume 非 awaiting 409、项目/ job 不存在 404、run_chain_job 无冲突→done+written 满、冲突→awaiting→resume→done、错误脱敏、accept_op 冲突缺判→CONFLICT_UNRESOLVED。**全仓门禁绿**:ruff/format 干净、mypy **193 Success**、alembic **无漂移**(无 ORM 变更)、pytest **600 passed**。守不变量 #1/#3/#4/#5/#9。记 contracts C-Chain 稳定 + 2 decision + 3 gotcha。**唯一新增 DDL(langgraph 检查点表)= C3 迁移;前端链 UI = 契约稳定后 @frontend follow-up(本期非目标)。** - [2026-06-22] @orchestrator — **🎉 T6 创作工具箱(通用生成器框架)全工具箱交付 + 独立复核(分支 `feat/t6-creation-toolbox`)**。多 agent 并行波次:Wave 0(@llm T6.5 ‖ @backend 描述符类型 ‖ P2 收尾)→ Wave A(@backend T6.2+T6.3 TOOLBOX+端点合龙,契约稳定点)→ Wave B(@frontend T6.6 ‖ @qa T6.4)→ Wave C 收口,目录单写者零写冲突、契约先行、零返工。**@llm T6.5**:`ww_agents` +7 输出 schema + 7 spec(book-title/blurb/name/golden-finger/glossary/opening/fine-outline,只声明 tier #2)+ `build_outline_chapter_context`(with_outline_chapter 策略)。**@frontend T6.6**:`gen:api` 纳入 3 端点;`app/projects/[id]/toolbox/page.tsx`(RSC) + `components/toolbox/{ToolboxPage,ToolCard,GeneratorRunner}` + `lib/toolbox/*`(声明驱动纯函数,按 `input_fields` 渲染表单、按 `output_kind` 渲染预览、可入库者复用 `ConflictAdjudication` 走 409→ack)+ LeftNav「工具箱」+ ⌘K `nav-toolbox`/`action-gen-*`;legacy 3 跳现页不回归。**@qa T6.4**:`tests/test_t6_toolbox_e2e.py` 5 用例真 pg + mock 网关零 token,**未发现端点 bug**。**P2 收尾**:限流→`decisions.md` 记延后(单用户原型);noopener/Committable 经查本分支早已修;`provider_deps.py` 残留 `type:ignore` 为 SDK 私有属性探活、load-bearing 保留。**最终独立复跑全仓门禁绿**:后端 ruff/format 干净、mypy **195 Success**、alembic **无漂移**(descriptor 在代码、复用既有表,零建表迁移)、pytest **583 passed**;前端 gen:api/lint/tsc/vitest **279**/build OK。守不变量 #2(只声明 tier)/#3(预览不写库、入库经验收 gate)/#9(system_prompt 进缓存前块)。**"加生成器=加一份声明" 经实证(8 个新生成器复用同一通用执行器 + 同一前端组件)。** **实景 browse 复验(工具箱卡片栅格→脑洞预览→词条入库)待做。** 后续可选 P5:封面图像轨道 / 自定义 skill 运行期 schema / 收敛已有 3 竖井。 - [2026-06-20] UX计划 B0(可控版)+F1(控件) — **✅ 本章注入透明 可控版(分支 `feat/ux-b0-controllable-injection`,提交 `0d473e7` 后端 + `6449c31` 前端,未合 develop)**。在 B0 读端点之上加作者微调,守不变量 #6「确定性选择」的作者兜底。**后端**(@backend/@db):`PUT /projects/{id}/chapters/{no}/injection` ← `InjectionOverrideRequest{pinned,excluded,recent_n(1..20→422)}` → `InjectionResponse`(新增回显 `pinned`/`excluded`,`selected[].reasons` 可含新值 **`author_pin`**);`select_relevant_entities` 加 `pinned/excluded` frozenset 入参(pin 强制纳入加 author_pin / excluded 强制剔除且**优先于 pin**);`assemble` 加 `override` 关键字参(recent_n 覆盖回看章数);**GET injection + PUT + draft 流式端点同读同一覆盖**(看到的=写章用的)。持久化:**新表 `chapter_injection`**(迁移 `ad2c4c663daf`,唯一 `(project_id,chapter_no)`;复用 outline 行污染 beats 已否决)+ 新 `domain/injection_repo.py`(`InjectionOverride`/`EntityRef`/`SqlInjectionOverrideRepo`,upsert 只 flush 端点 commit)。**前端**(@frontend):`gen:api` 纳入 PUT + 新 schema;`lib/workbench/injection.ts` override 纯函数(`currentOverride`/`withPinToggled`/`withExcluded`/`withRestored`/`withRecentN`/`isPinned`,钳 1..20);`useInjection` 加 `saving` 态 + `togglePin/exclude/restore/setRecentN`(PUT 后以服务端确定结果为准、写失败可读文案不改本地态);`ChapterAssistant` 每实体 📌置顶/✕排除 + 「近 N 章」步进器 + 「已排除」恢复区。**全仓门禁绿**:后端 ruff/format/mypy **163**/alembic 无漂移/pytest **476**;前端 lint/tsc/vitest **183**/build。记 contracts C3 再扩。**实景 browse 复验待做。** UX 计划余项:R1/R2/R3 审稿人体学 + Tier4。 - [2026-06-20] UX计划 M1+M2 — **✅ 移动端可用(纯前端,分支 `feat/ux-m1-m2-mobile` 提交 9b893a6)**。救活手机端(390px)写→审主路径。**M1 导航响应式抽屉**:抽出纯逻辑 `lib/nav/items.ts`(`GLOBAL_NAV_ITEMS`/`projectNavItems`/`isNavItemActive` + `ActiveNav` 类型,6 vitest),桌面静态侧栏与移动抽屉共用避免漂移;新增通用 `Drawer`(遮罩点击/Esc 关闭/打开聚焦面板,复用 `CommandPalette` a11y 模式,`lg:hidden`)+ `NavDrawer`(汉堡 `aria-label=打开导航` + 左抽屉)+ `NavItems`;`LeftNav` 改 `hidden lg:block`,`AppShell` 顶栏加汉堡。**M2 工具条不溢出 + 移动可达**:`Workbench` 工具条改 `flex-wrap`(390px 不再横切「尚未保存」);`ChapterList`/`ChapterAssistant` 抽出 `ChapterListContent`/`AssistantContent`,`Workbench` 经左/右 `Drawer` 在 ` JobView: + view = self.rows[job_id].model_copy( + update={"status": STATUS_AWAITING, "result": dict(result)} + ) + self.rows[job_id] = view + return view + async def fail(self, job_id: uuid.UUID, error: str) -> JobView: view = self.rows[job_id].model_copy(update={"status": STATUS_FAILED, "error": error}) self.rows[job_id] = view diff --git a/apps/api/tests/test_chain.py b/apps/api/tests/test_chain.py new file mode 100644 index 0000000..9d334c0 --- /dev/null +++ b/apps/api/tests/test_chain.py @@ -0,0 +1,586 @@ +"""C2 多章链端点 + 服务层单测(chain-workflow §6/§10)——内存替身,无 DB/无网络/无真 LLM。 + +覆盖: +- 端点契约(HTTP 层,`run_chain_job` 被 no-op 替换以隔离调度逻辑): + run → 202 `{job_id,...}`;resume → 202;未知 chain_key → 404;count 越界(>50 / 0)→ 422; + resume 非 awaiting 态 → 409;项目不存在 → 404;resume job 不存在 → 404。 +- 服务层 `run_chain_job`(mock 网关 + MemorySaver + fake session_factory + fake accept_op): + 无冲突全跑 → job done + result.written 满;有冲突 → interrupt → job awaiting_input; + resume 续跑 → done;token 绝不入 job result。 +- `build_accept_op`:冲突缺判 → CONFLICT_UNRESOLVED;无冲突章正常落库(经 fake op 桩验证)。 +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +import httpx +import pytest +from fakes_projects import FakeJobRepo, FakeProjectRepo +from fastapi import FastAPI +from langgraph.checkpoint.memory import MemorySaver +from pydantic import BaseModel +from ww_agents import Conflict, ContinuityReview +from ww_api.schemas.projects import ConflictDecision +from ww_api.services.chain_runner import build_accept_op, run_chain_job +from ww_core.domain.job_repo import STATUS_AWAITING, STATUS_DONE +from ww_core.domain.project_repo import ProjectCreate +from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage + +PROJECT_OWNER = uuid.UUID(int=1) +USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa") + + +# ---- 共享替身 ---- + + +def _usage() -> Usage: + return Usage( + provider="fake", model="fake", input_tokens=1, output_tokens=1, cost_minor=0, currency="USD" + ) + + +class _FakeChainGateway: + """链网关 mock:write(无 output_schema)回固定正文;审稿按 schema 回 parsed。""" + + def __init__(self, *, conflicts: list[Conflict]) -> None: + self._continuity = ContinuityReview(conflicts=conflicts) + from ww_agents import foreshadow_spec, pace_spec, style_drift_spec + + self._by_schema: dict[type, BaseModel] = {} + for spec in (foreshadow_spec, pace_spec, style_drift_spec): + schema = spec.output_schema + assert schema is not None + self._by_schema[schema] = schema() + + async def run(self, req: LlmRequest) -> LlmResponse: + schema = req.output_schema + if schema is None: + return LlmResponse( + text="第 N 章正文。", + parsed=None, + usage=_usage(), + served_by=ServedBy(provider="fake", model="fake"), + ) + parsed = self._continuity if schema is ContinuityReview else self._by_schema[schema] + return LlmResponse( + text=parsed.model_dump_json(), + parsed=parsed, + usage=_usage(), + served_by=ServedBy(provider="fake", model="fake"), + ) + + +class _FakeDraftView: + def __init__(self, content: str) -> None: + self.content = content + + +class _FakeChapterRepo: + def __init__(self, store: dict[int, str]) -> None: + self._store = store + + async def save_draft( + self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1 + ) -> Any: + self._store[chapter_no] = text + return _FakeDraftView(text) + + async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> Any: + content = self._store.get(chapter_no) + return _FakeDraftView(content) if content is not None else None + + +class _FakeReviewRecord: + def __init__(self, chapter_no: int, conflicts: list[dict[str, Any]]) -> None: + self.id = uuid.uuid4() + self.chapter_no = chapter_no + self.conflicts = conflicts + + +class _FakeReviewRepo: + def __init__(self, records: dict[int, list[_FakeReviewRecord]]) -> None: + self._records = records + + async def record( + self, + project_id: uuid.UUID, + chapter_no: int, + *, + chapter_version: int | None = None, + conflicts: list[dict[str, Any]], + foreshadow_sug: list[dict[str, Any]] | None = None, + style: dict[str, Any] | None = None, + pace: dict[str, Any] | None = None, + health_score: int | None = None, + ) -> Any: + rec = _FakeReviewRecord(chapter_no, list(conflicts)) + self._records.setdefault(chapter_no, []).insert(0, rec) + return rec + + async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[Any]: + return list(self._records.get(chapter_no, [])) + + +class _FakeSession: + def __init__(self) -> None: + self.commits = 0 + + async def commit(self) -> None: + self.commits += 1 + + +@asynccontextmanager +async def _session_cm() -> AsyncIterator[Any]: + yield _FakeSession() + + +def _session_factory() -> Any: + """fake 独立 session 工厂(满足 `SessionFactory` 形:`()` → async-CM)。 + + 返回 Any 避开 AsyncSession 不变型校验(同 test_job_runner 的 `__call__ -> Any` 纪律)。 + """ + return _session_cm() + + +@asynccontextmanager +async def _memsaver_ctx(saver: MemorySaver) -> AsyncIterator[Any]: + yield saver + + +class _FakeContext: + stable_core = "## 世界观硬规则\n灵气可凝丹" + volatile = "## 写作指令\n写第 N 章" + + +# ---- 端点契约(HTTP 层,run_chain_job no-op)---- + + +def _app(project_repo: FakeProjectRepo, job_repo: FakeJobRepo) -> FastAPI: + import os + + from cryptography.fernet import Fernet + + os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode()) + from ww_api.main import create_app + from ww_api.services.chain_deps import get_checkpointer_factory + from ww_api.services.project_deps import ( + get_chain_gateway, + get_digest_gateway_builder, + get_job_repo, + get_project_repo, + get_session_factory, + ) + from ww_db import get_session + + app = create_app() + app.dependency_overrides[get_project_repo] = lambda: project_repo + app.dependency_overrides[get_job_repo] = lambda: job_repo + app.dependency_overrides[get_session] = lambda: _FakeSession() + app.dependency_overrides[get_session_factory] = lambda: _session_factory + app.dependency_overrides[get_chain_gateway] = lambda: _FakeChainGateway(conflicts=[]) + saver = MemorySaver() + app.dependency_overrides[get_checkpointer_factory] = lambda: lambda: _memsaver_ctx(saver) + app.dependency_overrides[get_digest_gateway_builder] = lambda: lambda _s: None + return app + + +def _client(app: FastAPI) -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID: + view = await repo.create(PROJECT_OWNER, ProjectCreate(title="测试作品", genre="玄幻")) + return uuid.UUID(str(view.id)) + + +@pytest.fixture +def _noop_run_chain_job(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + """把端点登记的 `run_chain_job` 换成记录调用的 no-op(隔离调度逻辑,不真跑图)。""" + calls: list[dict[str, Any]] = [] + + async def _fake(*args: Any, **kwargs: Any) -> None: + calls.append({"args": args, "kwargs": kwargs}) + + monkeypatch.setattr("ww_api.routers.chain.run_chain_job", _fake) + return calls + + +async def test_run_chain_returns_202_and_schedules_job( + _noop_run_chain_job: list[dict[str, Any]], +) -> None: + project_repo = FakeProjectRepo() + job_repo = FakeJobRepo() + pid = await _seed_project(project_repo) + app = _app(project_repo, job_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/chains/draft_volume/run", + json={"start_chapter_no": 1, "count": 3}, + ) + + assert resp.status_code == 202 + body = resp.json() + assert body["chain_key"] == "draft_volume" + assert body["start_chapter_no"] == 1 + assert body["count"] == 3 + assert uuid.UUID(body["job_id"]) + # job 行已落(queued)+ 调度了一次后台任务 + assert len(job_repo.rows) == 1 + assert len(_noop_run_chain_job) == 1 + + +async def test_run_chain_unknown_key_returns_404( + _noop_run_chain_job: list[dict[str, Any]], +) -> None: + project_repo = FakeProjectRepo() + job_repo = FakeJobRepo() + pid = await _seed_project(project_repo) + app = _app(project_repo, job_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/chains/unknown_chain/run", + json={"start_chapter_no": 1, "count": 2}, + ) + + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == "NOT_FOUND" + assert len(_noop_run_chain_job) == 0 # 未调度 + + +async def test_run_chain_count_over_50_returns_422( + _noop_run_chain_job: list[dict[str, Any]], +) -> None: + project_repo = FakeProjectRepo() + job_repo = FakeJobRepo() + pid = await _seed_project(project_repo) + app = _app(project_repo, job_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/chains/draft_volume/run", + json={"start_chapter_no": 1, "count": 51}, + ) + + assert resp.status_code == 422 + + +async def test_run_chain_count_zero_returns_422( + _noop_run_chain_job: list[dict[str, Any]], +) -> None: + project_repo = FakeProjectRepo() + job_repo = FakeJobRepo() + pid = await _seed_project(project_repo) + app = _app(project_repo, job_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/chains/draft_volume/run", + json={"start_chapter_no": 1, "count": 0}, + ) + + assert resp.status_code == 422 + + +async def test_run_chain_project_not_found_returns_404( + _noop_run_chain_job: list[dict[str, Any]], +) -> None: + project_repo = FakeProjectRepo() + job_repo = FakeJobRepo() + app = _app(project_repo, job_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{uuid.uuid4()}/chains/draft_volume/run", + json={"start_chapter_no": 1, "count": 2}, + ) + + assert resp.status_code == 404 + + +async def test_resume_chain_awaiting_returns_202( + _noop_run_chain_job: list[dict[str, Any]], +) -> None: + project_repo = FakeProjectRepo() + job_repo = FakeJobRepo() + pid = await _seed_project(project_repo) + # 预置一个 awaiting_input 的链 job + job = await job_repo.create(pid, "chain") + await job_repo.set_awaiting(job.id, {"awaiting_chapter": 1}) + app = _app(project_repo, job_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/chains/runs/{job.id}/resume", + json={"decisions": [{"conflict_index": 0, "verdict": "ignore"}]}, + ) + + assert resp.status_code == 202 + assert resp.json()["job_id"] == str(job.id) + assert len(_noop_run_chain_job) == 1 + + +async def test_resume_chain_non_awaiting_returns_409( + _noop_run_chain_job: list[dict[str, Any]], +) -> None: + project_repo = FakeProjectRepo() + job_repo = FakeJobRepo() + pid = await _seed_project(project_repo) + job = await job_repo.create(pid, "chain") # queued(非 awaiting) + app = _app(project_repo, job_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/chains/runs/{job.id}/resume", + json={"decisions": []}, + ) + + assert resp.status_code == 409 + assert resp.json()["error"]["code"] == "CONFLICT" + assert len(_noop_run_chain_job) == 0 + + +async def test_resume_chain_job_not_found_returns_404( + _noop_run_chain_job: list[dict[str, Any]], +) -> None: + project_repo = FakeProjectRepo() + job_repo = FakeJobRepo() + pid = await _seed_project(project_repo) + app = _app(project_repo, job_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/chains/runs/{uuid.uuid4()}/resume", + json={"decisions": []}, + ) + + assert resp.status_code == 404 + + +# ---- 服务层 run_chain_job(直接 await,注入全 fake;job 状态由 fake SqlJobRepo 替身记录)---- + + +class _RecordingJobRepo: + """run_chain_job 内部 `SqlJobRepo(session)` 的替身(monkeypatch):记录状态流转。""" + + state: dict[uuid.UUID, dict[str, Any]] = {} + + def __init__(self, session: Any) -> None: + pass + + async def set_running(self, job_id: uuid.UUID) -> Any: + _RecordingJobRepo.state.setdefault(job_id, {})["status"] = "running" + return None + + async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> Any: + _RecordingJobRepo.state[job_id] = {"status": STATUS_DONE, "result": result} + return None + + async def set_awaiting(self, job_id: uuid.UUID, result: dict[str, Any]) -> Any: + _RecordingJobRepo.state[job_id] = {"status": STATUS_AWAITING, "result": result} + return None + + async def fail(self, job_id: uuid.UUID, error: str) -> Any: + _RecordingJobRepo.state[job_id] = {"status": "failed", "error": error} + return None + + +def _chain_runner_fakes() -> dict[str, Any]: + draft_store: dict[int, str] = {} + review_store: dict[int, list[_FakeReviewRecord]] = {} + accepted: list[dict[str, Any]] = [] + + async def accept_op( + *, project_id: uuid.UUID, chapter_no: int, user_id: uuid.UUID, decisions: list[Any] + ) -> None: + accepted.append({"chapter_no": chapter_no, "decisions": list(decisions)}) + + return { + "draft_store": draft_store, + "review_store": review_store, + "accepted": accepted, + "accept_op": accept_op, + "chapter_repo_factory": lambda _s: _FakeChapterRepo(draft_store), + "review_repo_factory": lambda _s: _FakeReviewRepo(review_store), + } + + +@pytest.fixture +def _patched_runner(monkeypatch: pytest.MonkeyPatch) -> None: + """把 run_chain_job 里的 SqlJobRepo + assemble + sql_memory_repos 换成 fake(不触 DB)。""" + _RecordingJobRepo.state = {} + monkeypatch.setattr("ww_api.services.chain_runner.SqlJobRepo", _RecordingJobRepo) + + async def _fake_assemble( + repos: Any, project_id: uuid.UUID, chapter_no: int, *a: Any, **k: Any + ) -> _FakeContext: + return _FakeContext() + + monkeypatch.setattr("ww_api.services.chain_runner.assemble", _fake_assemble) + monkeypatch.setattr("ww_api.services.chain_runner.sql_memory_repos", lambda _s: object()) + + +async def test_run_chain_job_no_conflict_completes_done(_patched_runner: None) -> None: + fakes = _chain_runner_fakes() + gateway = _FakeChainGateway(conflicts=[]) + job_id = uuid.uuid4() + saver = MemorySaver() + + await run_chain_job( + _session_factory, + job_id, + project_id=uuid.uuid4(), + user_id=USER, + chain_key="draft_volume", + start_chapter_no=1, + count=2, + gateway=gateway, + checkpointer_ctx=lambda: _memsaver_ctx(saver), + accept_op=fakes["accept_op"], + chapter_repo_factory=fakes["chapter_repo_factory"], + review_repo_factory=fakes["review_repo_factory"], + ) + + rec = _RecordingJobRepo.state[job_id] + assert rec["status"] == STATUS_DONE + assert rec["result"]["written"] == [1, 2] + assert rec["result"]["completed"] is True + assert rec["result"]["awaiting_chapter"] is None + # token 不入 result:只含章号/标志,无正文/prompt + assert "第 N 章正文" not in str(rec["result"]) + assert [a["chapter_no"] for a in fakes["accepted"]] == [1, 2] + + +async def test_run_chain_job_conflict_sets_awaiting_then_resume_done( + _patched_runner: None, +) -> None: + fakes = _chain_runner_fakes() + conflict = Conflict(type="性格漂移", where="第3段", suggestion="统一主角姓名") + gateway = _FakeChainGateway(conflicts=[conflict]) + job_id = uuid.uuid4() + saver = MemorySaver() # 同 saver 跨 run/resume 两次调用 + project_id = uuid.uuid4() + + # 初始 run:第 1 章四审报冲突 → interrupt → job awaiting_input。 + await run_chain_job( + _session_factory, + job_id, + project_id=project_id, + user_id=USER, + chain_key="draft_volume", + start_chapter_no=1, + count=1, + gateway=gateway, + checkpointer_ctx=lambda: _memsaver_ctx(saver), + accept_op=fakes["accept_op"], + chapter_repo_factory=fakes["chapter_repo_factory"], + review_repo_factory=fakes["review_repo_factory"], + ) + + rec = _RecordingJobRepo.state[job_id] + assert rec["status"] == STATUS_AWAITING + assert rec["result"]["awaiting_chapter"] == 1 + assert fakes["accepted"] == [] # 尚未验收 + + # resume:作者裁决 → 续跑 accept → done。 + decisions = [ConflictDecision(conflict_index=0, verdict="ignore", note="笔误")] + await run_chain_job( + _session_factory, + job_id, + project_id=project_id, + user_id=USER, + chain_key="draft_volume", + start_chapter_no=1, + count=1, + gateway=gateway, + checkpointer_ctx=lambda: _memsaver_ctx(saver), + accept_op=fakes["accept_op"], + chapter_repo_factory=fakes["chapter_repo_factory"], + review_repo_factory=fakes["review_repo_factory"], + resume_decisions=decisions, + ) + + rec = _RecordingJobRepo.state[job_id] + assert rec["status"] == STATUS_DONE + assert rec["result"]["written"] == [1] + assert [a["chapter_no"] for a in fakes["accepted"]] == [1] + assert fakes["accepted"][0]["decisions"] == [ + {"conflict_index": 0, "verdict": "ignore", "note": "笔误"} + ] + + +async def test_run_chain_job_error_marks_failed_safely(_patched_runner: None) -> None: + """work 抛 → job failed + 脱敏文案(不泄 str(exc)),异常不冒泡。""" + fakes = _chain_runner_fakes() + job_id = uuid.uuid4() + + class _BoomGateway: + async def run(self, req: LlmRequest) -> LlmResponse: + raise RuntimeError("boom secret=/internal/path") + + await run_chain_job( + _session_factory, + job_id, + project_id=uuid.uuid4(), + user_id=USER, + chain_key="draft_volume", + start_chapter_no=1, + count=1, + gateway=_BoomGateway(), + checkpointer_ctx=lambda: _memsaver_ctx(MemorySaver()), + accept_op=fakes["accept_op"], + chapter_repo_factory=fakes["chapter_repo_factory"], + review_repo_factory=fakes["review_repo_factory"], + ) + + rec = _RecordingJobRepo.state[job_id] + assert rec["status"] == "failed" + assert rec["error"] == "任务执行失败" + assert "secret" not in rec["error"] + + +# ---- build_accept_op:冲突缺判 gate ---- + + +async def test_build_accept_op_missing_decision_raises_conflict_unresolved( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from ww_shared import AppError, ErrorCode + + review_store: dict[int, list[_FakeReviewRecord]] = { + 1: [_FakeReviewRecord(1, [{"type": "矛盾", "where": "第1段"}])] + } + + class _Session: + async def commit(self) -> None: ... + + @asynccontextmanager + async def _sf_cm() -> AsyncIterator[Any]: + yield _Session() + + def _sf() -> Any: + return _sf_cm() + + # 桩出 accept_op 依赖:链 accept 节点重读审稿、跑 gate(缺判应在 gate 拦下,不触 digest/事务)。 + monkeypatch.setattr( + "ww_api.services.chain_runner.SqlChapterRepo", lambda _s: _FakeChapterRepo({1: "正文"}) + ) + monkeypatch.setattr( + "ww_api.services.chain_runner.SqlReviewRepo", lambda _s: _FakeReviewRepo(review_store) + ) + monkeypatch.setattr("ww_api.services.chain_runner.SqlDigestAppendRepo", lambda _s: object()) + + async def _digest_builder(_s: Any) -> Any: + return object() + + accept_op = build_accept_op(session_factory=_sf, digest_gateway_builder=_digest_builder) + + with pytest.raises(AppError) as exc: + await accept_op(project_id=uuid.uuid4(), chapter_no=1, user_id=USER, decisions=[]) + assert exc.value.code == ErrorCode.CONFLICT_UNRESOLVED diff --git a/apps/api/ww_api/main.py b/apps/api/ww_api/main.py index 56f34c4..e64b5f6 100644 --- a/apps/api/ww_api/main.py +++ b/apps/api/ww_api/main.py @@ -16,6 +16,7 @@ from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope from ww_api.logging_config import configure_logging, get_logger from ww_api.middleware import REQUEST_ID_HEADER, request_id_middleware from ww_api.routers import ( + chain, foreshadow, generation, health, @@ -117,6 +118,7 @@ def create_app() -> FastAPI: app.include_router(generation.router) app.include_router(generation.skills_router) app.include_router(toolbox.router) + app.include_router(chain.router) app.include_router(settings_providers.router) app.include_router(kimi_oauth.router) return app diff --git a/apps/api/ww_api/routers/chain.py b/apps/api/ww_api/routers/chain.py new file mode 100644 index 0000000..738cdf2 --- /dev/null +++ b/apps/api/ww_api/routers/chain.py @@ -0,0 +1,249 @@ +"""多章工作流链端点(chain-workflow 设计 §6)——发起 / 续跑,进度走 `GET /jobs/{id}`。 + +三端点: +- `POST /projects/{pid}/chains/{chain_key}/run` → 202 `ChainRunAccepted{job_id,...}`: + 写一行 jobs(queued, kind=chain) 立即返 202,链经 BackgroundTask `run_chain_job` 跑 + (**自建独立 session**,请求 session 已关闭)。无凭据 → 流前 `LLM_UNAVAILABLE`(503, + 调度 job 前拦下)。未知 chain_key → 404;`count` 1..50 越界 → 422(schema Field 约束)。 +- `GET /jobs/{job_id}`:复用现有 jobs 端点轮询进度/awaiting(不在本 router)。 +- `POST /projects/{pid}/chains/runs/{job_id}/resume` → 202:仅当 job=awaiting_input; + 带裁决经 BackgroundTask resume 续跑。非 awaiting 态 → 409。 + +不变量:链是**编排**新增、非业务重写——复用同一 `assemble`/审稿 spec/`run_accept_transaction` +(经 `accept_op` 闭包注入),守 #1/#3/#4/#5/#9。token 绝不入 job result/状态/日志(§5)。 +""" + +from __future__ import annotations + +import uuid +from typing import Annotated, Any + +from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response +from sqlalchemy.ext.asyncio import AsyncSession +from ww_core.domain import JobRepo, ProjectRepo +from ww_core.domain.job_repo import STATUS_AWAITING +from ww_db import get_session +from ww_llm_gateway import Gateway +from ww_shared import AppError, ErrorCode, ErrorEnvelope + +from ww_api.logging_config import get_logger +from ww_api.schemas.chain import ( + ChainResumeRequest, + ChainRunAccepted, + ChainRunRequest, +) +from ww_api.services.chain_deps import ( + CheckpointerFactory, + get_checkpointer_factory, +) +from ww_api.services.chain_runner import ( + JOB_KIND_CHAIN, + build_accept_op, + run_chain_job, +) +from ww_api.services.credentials import STUB_OWNER_ID +from ww_api.services.foreshadow_scan import SessionFactory +from ww_api.services.project_deps import ( + GatewayDigestBuilder, + get_chain_gateway, + get_digest_gateway_builder, + get_job_repo, + get_project_repo, + get_session_factory, +) + +log = get_logger("ww.api.chain") + +router = APIRouter(prefix="/projects", tags=["chain"]) + +# 本期唯一内置链(§1.3/§3)。未知 key → 404(系统边界,fail fast)。 +SUPPORTED_CHAINS = frozenset({"draft_volume"}) + +ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] +JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)] +ChainGatewayDep = Annotated[Gateway, Depends(get_chain_gateway)] +SessionDep = Annotated[AsyncSession, Depends(get_session)] +SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)] +CheckpointerFactoryDep = Annotated[CheckpointerFactory, Depends(get_checkpointer_factory)] +DigestBuilderDep = Annotated[GatewayDigestBuilder, Depends(get_digest_gateway_builder)] + +_RUN_ERRORS: dict[int | str, dict[str, Any]] = { + 404: {"model": ErrorEnvelope, "description": "项目或链 key 不存在"}, + 422: {"model": ErrorEnvelope, "description": "请求参数非法(count 越界等)"}, + 503: {"model": ErrorEnvelope, "description": "无可用 LLM 凭据"}, +} +_RESUME_ERRORS: dict[int | str, dict[str, Any]] = { + 404: {"model": ErrorEnvelope, "description": "项目或 job 不存在"}, + 409: {"model": ErrorEnvelope, "description": "job 非 awaiting_input 态,不可续跑"}, +} + + +async def _require_project(project_repo: ProjectRepo, project_id: uuid.UUID) -> None: + if await project_repo.get(STUB_OWNER_ID, project_id) is None: + raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") + + +@router.post( + "/{project_id}/chains/{chain_key}/run", + status_code=202, + responses=_RUN_ERRORS, +) +async def run_chain( + project_id: uuid.UUID, + chain_key: str, + body: ChainRunRequest, + request: Request, + response: Response, + background_tasks: BackgroundTasks, + project_repo: ProjectRepoDep, + job_repo: JobRepoDep, + gateway: ChainGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503) + session: SessionDep, + session_factory: SessionFactoryDep, + checkpointer_factory: CheckpointerFactoryDep, + digest_gateway_builder: DigestBuilderDep, +) -> ChainRunAccepted: + """发起多章链:写一行 job 返 202,链经 BackgroundTask 异步跑。 + + 项目不存在 → 404;未知 chain_key → 404;无凭据 → 503(dep 解析阶段拦下); + `count` 越界由 schema Field 在解析阶段 → 422。`gateway` 仅做请求阶段凭据探测—— + 链本体在 BackgroundTask 里用独立 session 重建网关跑(请求 session 即将关闭)。 + """ + request_id = getattr(request.state, "request_id", None) + + await _require_project(project_repo, project_id) + if chain_key not in SUPPORTED_CHAINS: + raise AppError(ErrorCode.NOT_FOUND, f"unknown chain key: {chain_key}") + + job = await job_repo.create(project_id, JOB_KIND_CHAIN) + await session.commit() # job 行须在 202 返回前持久化(供前端立即轮询)。 + + accept_op = build_accept_op( + session_factory=session_factory, + digest_gateway_builder=digest_gateway_builder, + request_id=request_id, + ) + background_tasks.add_task( + run_chain_job, + session_factory, + job.id, + project_id=project_id, + user_id=STUB_OWNER_ID, + chain_key=chain_key, + start_chapter_no=body.start_chapter_no, + count=body.count, + gateway=gateway, + checkpointer_ctx=checkpointer_factory, + accept_op=accept_op, + chapter_repo_factory=_chapter_repo_factory, + review_repo_factory=_review_repo_factory, + request_id=request_id, + ) + + log.info( + "chain_run_scheduled", + project_id=str(project_id), + request_id=request_id, + job_id=str(job.id), + chain_key=chain_key, + start_chapter_no=body.start_chapter_no, + count=body.count, + ) + response.status_code = 202 + return ChainRunAccepted( + job_id=job.id, + chain_key=chain_key, + start_chapter_no=body.start_chapter_no, + count=body.count, + ) + + +@router.post( + "/{project_id}/chains/runs/{job_id}/resume", + status_code=202, + responses=_RESUME_ERRORS, +) +async def resume_chain( + project_id: uuid.UUID, + job_id: uuid.UUID, + body: ChainResumeRequest, + request: Request, + response: Response, + background_tasks: BackgroundTasks, + project_repo: ProjectRepoDep, + job_repo: JobRepoDep, + gateway: ChainGatewayDep, + session: SessionDep, + session_factory: SessionFactoryDep, + checkpointer_factory: CheckpointerFactoryDep, + digest_gateway_builder: DigestBuilderDep, +) -> ChainRunAccepted: + """裁决续跑:仅当 job=awaiting_input,带裁决经 BackgroundTask resume。 + + 项目不存在 → 404;job 不存在 → 404;非 awaiting 态 → 409(CONFLICT)。 + resume 经 `Command(resume=decisions)` 从 interrupt 续跑(同 thread_id=job_id)。 + """ + request_id = getattr(request.state, "request_id", None) + + await _require_project(project_repo, project_id) + job = await job_repo.get(job_id) + if job is None: + raise AppError(ErrorCode.NOT_FOUND, f"job not found: {job_id}") + if job.status != STATUS_AWAITING: + raise AppError( + ErrorCode.CONFLICT, + f"job {job_id} 非 awaiting_input 态(当前 {job.status}),不可续跑", + {"job_status": job.status}, + ) + + accept_op = build_accept_op( + session_factory=session_factory, + digest_gateway_builder=digest_gateway_builder, + request_id=request_id, + ) + background_tasks.add_task( + run_chain_job, + session_factory, + job_id, + project_id=project_id, + user_id=STUB_OWNER_ID, + chain_key=job.kind if job.kind in SUPPORTED_CHAINS else "draft_volume", + start_chapter_no=1, # resume 不重置区间:图从检查点续,初值不再使用。 + count=1, + gateway=gateway, + checkpointer_ctx=checkpointer_factory, + accept_op=accept_op, + chapter_repo_factory=_chapter_repo_factory, + review_repo_factory=_review_repo_factory, + resume_decisions=body.decisions, + request_id=request_id, + ) + + log.info( + "chain_resume_scheduled", + project_id=str(project_id), + request_id=request_id, + job_id=str(job_id), + decision_count=len(body.decisions), + ) + response.status_code = 202 + return ChainRunAccepted( + job_id=job_id, + chain_key="draft_volume", + start_chapter_no=0, + count=0, + ) + + +def _chapter_repo_factory(session: AsyncSession) -> object: + """链节点(review 重读草稿)按 session 建 chapter draft repo。""" + from ww_core.domain.chapter_repo import SqlChapterRepo + + return SqlChapterRepo(session) + + +def _review_repo_factory(session: AsyncSession) -> object: + """链节点(review 落留痕 / decide 重读)按 session 建审稿 repo。""" + from ww_core.domain.review_repo import SqlReviewRepo + + return SqlReviewRepo(session) diff --git a/apps/api/ww_api/schemas/chain.py b/apps/api/ww_api/schemas/chain.py new file mode 100644 index 0000000..f3d047c --- /dev/null +++ b/apps/api/ww_api/schemas/chain.py @@ -0,0 +1,54 @@ +"""多章工作流链端点的请求/响应 schema(chain-workflow 设计 §6)。 + +snake_case(命名契约,见 memory/gotchas)。前端经 OpenAPI→TS 客户端消费——改字段 +须 `pnpm gen:api` 重生成。 + +- `ChainRunRequest`:发起一条链(起始章 + 章数)。`count` 1..50(系统边界校验,fail fast)。 +- `ChainRunAccepted`:202 受理回执(job_id + 回显请求参数,供前端轮询 `GET /jobs/{id}`)。 +- `ChainResumeRequest`:裁决续跑(复用 `ConflictDecision`,对 awaiting 章的冲突逐条裁决)。 +""" + +from __future__ import annotations + +import uuid + +from pydantic import BaseModel, Field + +from ww_api.schemas.projects import ConflictDecision + +# 链一次最多写 K 章(系统边界,避免一条 run 无界占用;§6 校验)。 +MAX_CHAIN_COUNT = 50 +MIN_CHAIN_COUNT = 1 + + +class ChainRunRequest(BaseModel): + """发起多章链:从 `start_chapter_no` 起循环写 `count` 章(区间含两端)。 + + `count` 1..50(>50 → 422 VALIDATION,由 Field 约束在边界拦下,不进 BackgroundTask); + `start_chapter_no >= 1`。 + """ + + start_chapter_no: int = Field(ge=1, description="起始章号(含),>=1") + count: int = Field( + ge=MIN_CHAIN_COUNT, + le=MAX_CHAIN_COUNT, + description="本 run 连续写的章数(1..50,含起始章)", + ) + + +class ChainRunAccepted(BaseModel): + """链受理回执(202):job_id + 回显请求参数(前端走 `GET /jobs/{id}` 轮询进度/awaiting)。""" + + job_id: uuid.UUID + chain_key: str + start_chapter_no: int + count: int + + +class ChainResumeRequest(BaseModel): + """裁决续跑:对 awaiting 章最近审稿的每个冲突逐条裁决(复用 `ConflictDecision`)。""" + + decisions: list[ConflictDecision] = Field( + default_factory=list, + description="对 awaiting 章冲突的裁决清单(采纳/忽略/手改)", + ) diff --git a/apps/api/ww_api/services/chain_deps.py b/apps/api/ww_api/services/chain_deps.py new file mode 100644 index 0000000..e354466 --- /dev/null +++ b/apps/api/ww_api/services/chain_deps.py @@ -0,0 +1,44 @@ +"""多章链的 checkpointer 注入缝(chain-workflow 设计 §4)——激活休眠资产。 + +`get_checkpointer()` 是可注入缝: +- **运行时**:返 `AsyncPostgresSaver`(`langgraph.checkpoint.postgres.aio`),连 `DATABASE_URL`, + 持久控制流位置 + pending handle(跨小时可恢复)。检查点表由迁移/CI 调 `setup_checkpointer` + 建(C3),本缝**绝不**触碰 DDL(CLAUDE.md「LangGraph」:`setup()` 只在 migrations/CI)。 +- **测试**:经 `app.dependency_overrides[get_checkpointer]` 注 `MemorySaver`(同进程跨两次 + invoke 可恢复 interrupt),绝不连真 Postgres、绝不真 LLM。 + +注:`AsyncPostgresSaver.from_conn_string` 是 async 上下文管理器——其连接生命周期须横跨整条 +链 run(含 interrupt 暂停后的 resume)。故 `run_chain_job` 在自身 task 内打开/关闭 saver +上下文(见 `chain_runner`),本缝只提供「按需建一个 saver 上下文管理器」的工厂。 +""" + +from __future__ import annotations + +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager +from typing import TYPE_CHECKING, Any + +from ww_config import get_settings + +if TYPE_CHECKING: + from langgraph.checkpoint.base import BaseCheckpointSaver + +# checkpointer 工厂:`()` → async 上下文管理器,进入得一个 `BaseCheckpointSaver`。 +# 运行时 = Postgres saver 上下文(连接随上下文开/关);测试注一个产 MemorySaver 的工厂。 +CheckpointerFactory = Callable[[], AbstractAsyncContextManager[Any]] + + +def get_checkpointer_factory() -> CheckpointerFactory: + """运行时 checkpointer 工厂:建连 `DATABASE_URL` 的 `AsyncPostgresSaver` 上下文。 + + `from_conn_string` 返回的 async-CM 在 `run_chain_job` 内 `async with` 打开——其连接 + 存活整条链(含 interrupt→resume);run 结束关闭。测试经 dep 覆盖注 MemorySaver 工厂。 + """ + + def _factory() -> AbstractAsyncContextManager[BaseCheckpointSaver[Any]]: + from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver + + # langgraph 的 Postgres saver 走 psycopg(同步驱动串);用 sync 串建连接。 + return AsyncPostgresSaver.from_conn_string(get_settings().database_url_sync) + + return _factory diff --git a/apps/api/ww_api/services/chain_runner.py b/apps/api/ww_api/services/chain_runner.py new file mode 100644 index 0000000..c04cadc --- /dev/null +++ b/apps/api/ww_api/services/chain_runner.py @@ -0,0 +1,294 @@ +"""多章链长任务 runner(chain-workflow 设计 §5)——`run_chain_job` 在 jobs 壳里跑链图。 + +复用 `run_job` 的生命周期心智(set_running → work → complete/fail),但链多一态: +**interrupt 命中 → `awaiting_input`**(等作者裁决续跑)。故不直接复用 `run_job`,而是 +仿其壳自建独立 session 写 job 状态,work 本体 = 驱动 LangGraph 链图。 + +纪律(守不变量): +- **#1/#5**:图节点各自短事务落领域表(`chapters`/`chapter_reviews`/`chapter_digests`); + checkpointer 只存控制流;resume 重读领域表。本 runner 不持业务事务。 +- **#3/#4**:`accept_op`(apps/api 注入)跑冲突 gate → 终稿提炼 digest → 单原子验收事务 → + 伏笔到期扫描,唯一正文写经此。 +- **token 不泄漏**:job result/status/日志只记章号/计数/标志,绝不含 prompt/正文/token。 +- **错误脱敏**:复用 `_classify_job_error`(AppError 透传安全文案,其余通用文案,P0-3)。 + +可测性:`session_factory`/`gateway`/`checkpointer`/`accept_op` 全可注入——单测直接 `await` +它,注 fake session 工厂 + mock 网关 + MemorySaver + fake accept_op(绝不联网/真 DB/真 LLM)。 +""" + +from __future__ import annotations + +import uuid +from collections.abc import Awaitable, Callable, Sequence +from contextlib import AbstractAsyncContextManager +from typing import Any + +import structlog +from langchain_core.runnables import RunnableConfig +from langgraph.types import Command +from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo +from ww_core.domain.digest_repo import SqlDigestAppendRepo +from ww_core.domain.job_repo import SqlJobRepo +from ww_core.domain.review_repo import SqlReviewRepo +from ww_core.memory import assemble +from ww_core.memory.sql_repositories import sql_memory_repos +from ww_core.orchestrator import REVIEW_SPECS, GatewayRun +from ww_core.orchestrator.chain import build_chain_graph, initial_chain_state +from ww_llm_gateway import Gateway + +from ww_api.schemas.projects import ConflictDecision +from ww_api.services.accept_service import ( + assert_conflicts_resolved, + run_accept_transaction, +) +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.job_runner import _classify_job_error + +log = structlog.get_logger(__name__) + +# 链 job 的 kind(jobs.kind 自由 Text 列,零迁移复用;§7)。 +JOB_KIND_CHAIN = "chain" + + +# 「按 session 建 digest 网关」缝:accept 节点在自建短事务里需 light 档网关跑终稿提炼。 +DigestGatewayBuilder = Callable[[Any], Awaitable[Gateway]] + + +def build_accept_op( + *, + session_factory: SessionFactory, + digest_gateway_builder: DigestGatewayBuilder, + request_id: str | None = None, +) -> AcceptOp: + """组装单章验收落库闭包(apps/api,守不变量 #3/#4)。 + + 链 `accept_chapter` 节点经此闭包把作者裁决落库——每章**独立短事务**(自建 session): + 重读最近审稿 → 冲突 gate(缺判 → `CONFLICT_UNRESOLVED`,链失败)→ 终稿(=已存草稿)提炼 + digest(事务外)→ `run_accept_transaction` 单原子提交 → 提交后伏笔到期扫描(自建 session)。 + + 自动链无作者改稿:`final_text` = `chapters` 里该章草稿正文(write 节点所落)。 + `decisions` 为 dict 清单(interrupt 恢复值 / 无冲突 []),此处校验为 `ConflictDecision`。 + """ + + async def accept_op( + *, + project_id: uuid.UUID, + chapter_no: int, + user_id: uuid.UUID, + decisions: list[Any], + ) -> None: + parsed_decisions = [ + d if isinstance(d, ConflictDecision) else ConflictDecision.model_validate(d) + for d in (decisions or []) + ] + async with session_factory() as session: + chapter_repo: ChapterRepo = SqlChapterRepo(session) + review_repo = SqlReviewRepo(session) + digest_repo = SqlDigestAppendRepo(session) + + history = await review_repo.list_for_chapter(project_id, chapter_no) + latest_review = history[0] if history else None + # 冲突 gate(R5,事务前拦截,不写库;缺判 → CONFLICT_UNRESOLVED 抛,链失败)。 + assert_conflicts_resolved(latest_review, parsed_decisions) + + draft = await chapter_repo.get_draft(project_id, chapter_no) + final_text = draft.content if draft is not None else "" + + gateway = await digest_gateway_builder(session) + # R2:终稿 digest 提炼在开原子事务之前(别在持开事务里跨网络调 LLM)。 + digest_facts = await extract_digest_facts( + gateway, + final_text=final_text, + user_id=user_id, + project_id=project_id, + chapter_no=chapter_no, + ) + await run_accept_transaction( + session=session, + chapter_repo=chapter_repo, + digest_repo=digest_repo, + review_repo=review_repo, + project_id=project_id, + chapter_no=chapter_no, + final_text=final_text, + digest_facts=digest_facts, + latest_review=latest_review, + decisions=parsed_decisions, + ) + # 验收提交后伏笔到期扫描(自建独立 session,§6.2;与单章端点同纪律)。 + await run_overdue_scan( + session_factory, + project_id=project_id, + chapter_no=chapter_no, + request_id=request_id, + ) + + return accept_op + + +# `()` → async-CM,进入得一个 langgraph checkpointer(Postgres 运行时 / MemorySaver 测试)。 +CheckpointerCtx = Callable[[], AbstractAsyncContextManager[Any]] + +#: apps/api 注入的「按 session 建 chapter draft repo」工厂(review 节点重读草稿用)。 +ChapterRepoFactory = Callable[[Any], Any] +#: apps/api 注入的「按 session 建审稿留痕 repo」工厂(review/decide 节点用)。 +ReviewRepoFactory = Callable[[Any], Any] +#: 单章验收落库闭包(apps/api 组装:冲突 gate + digest + 验收事务 + 到期扫描)。 +AcceptOp = Callable[..., Awaitable[None]] + + +def _chain_result(state: dict[str, Any], *, awaiting_chapter: int | None) -> dict[str, Any]: + """从图 state 取非密进度摘要(绝不含正文/token;§5)。 + + `awaiting_chapter` 非 None = interrupt 命中该章待裁决;None 且 completed = 全跑完。 + """ + written = list(state.get("written") or []) + return { + "chain_key": state.get("chain_key"), + "written": written, + "completed": awaiting_chapter is None, + "awaiting_chapter": awaiting_chapter, + } + + +def _extract_awaiting_chapter(final: dict[str, Any]) -> int | None: + """从图返回值判 interrupt:有 `__interrupt__` → 取暂停章号;否则 None(跑完)。 + + interrupt 载荷 = `{"chapter_no": n}`(见链图 `_accept` 节点)。LangGraph 把它放在 + `final["__interrupt__"]`(Interrupt 对象列表)。容错读 `.value`/dict 两种形。 + """ + interrupts = final.get("__interrupt__") + if not interrupts: + return None + first = interrupts[0] + payload = getattr(first, "value", first) + if isinstance(payload, dict): + chapter_no = payload.get("chapter_no") + return int(chapter_no) if chapter_no is not None else None + return None + + +async def run_chain_job( + session_factory: SessionFactory, + job_id: uuid.UUID, + *, + project_id: uuid.UUID, + user_id: uuid.UUID, + chain_key: str, + start_chapter_no: int, + count: int, + gateway: GatewayRun, + checkpointer_ctx: CheckpointerCtx, + accept_op: AcceptOp, + chapter_repo_factory: ChapterRepoFactory, + review_repo_factory: ReviewRepoFactory, + resume_decisions: list[ConflictDecision] | None = None, + review_specs: Sequence[Any] = REVIEW_SPECS, + request_id: str | None = None, +) -> None: + """跑/续一条多章链:set_running → 驱动链图 → 据返回置 done/awaiting_input/failed。 + + `resume_decisions is None` → 初始 run(`graph.ainvoke(initial_state, config)`); + 否则 resume(`graph.ainvoke(Command(resume=decisions), config)`),从 interrupt 续跑。 + `thread_id = str(job_id)`:同 job 的初始/续跑落同一检查点 thread。 + + checkpointer 上下文(Postgres 连接 / MemorySaver)横跨整次 invoke;本 runner 在 task + 内打开/关闭它。异常一律被吞(后台任务边界),脱敏后置 job failed,不冒泡崩进程。 + """ + try: + async with checkpointer_ctx() as checkpointer: + await _set_running(session_factory, job_id) + config: RunnableConfig = {"configurable": {"thread_id": str(job_id)}} + graph = build_chain_graph( + gateway, + session_factory=session_factory, + memory_repos_factory=sql_memory_repos, + chapter_repo_factory=chapter_repo_factory, + review_repo_factory=review_repo_factory, + assemble=assemble, + accept_op=accept_op, + review_specs=review_specs, + checkpointer=checkpointer, + ) + if resume_decisions is None: + initial = initial_chain_state( + project_id=project_id, + user_id=user_id, + start_chapter_no=start_chapter_no, + count=count, + chain_key=chain_key, + ) + raw_final = await graph.ainvoke(initial, config=config) + else: + resume_value = [d.model_dump() for d in resume_decisions] + raw_final = await graph.ainvoke(Command(resume=resume_value), config=config) + + final: dict[str, Any] = dict(raw_final) + awaiting_chapter = _extract_awaiting_chapter(final) + result = _chain_result(final, awaiting_chapter=awaiting_chapter) + await _finish(session_factory, job_id, result, awaiting=awaiting_chapter is not None) + log.info( + "chain_job_settled", + job_id=str(job_id), + request_id=request_id, + awaiting_chapter=awaiting_chapter, + written_count=len(result["written"]), + ) + except Exception as exc: # noqa: BLE001 — 后台任务边界:记错误 + 置 job failed,不冒泡。 + log.error("chain_job_failed", job_id=str(job_id), request_id=request_id, error=str(exc)) + await _mark_failed(session_factory, job_id, _classify_job_error(exc), request_id) + + +async def _set_running(session_factory: SessionFactory, job_id: uuid.UUID) -> None: + """独立短事务把 job 置 running(图驱动开始前)。""" + async with session_factory() as session: + await SqlJobRepo(session).set_running(job_id) + await session.commit() + + +async def _finish( + session_factory: SessionFactory, + job_id: uuid.UUID, + result: dict[str, Any], + *, + awaiting: bool, +) -> None: + """据是否 interrupt 把 job 置 awaiting_input(待裁决)或 done(跑完),独立短事务。""" + async with session_factory() as session: + repo = SqlJobRepo(session) + if awaiting: + await repo.set_awaiting(job_id, result) + else: + await repo.complete(job_id, result) + await session.commit() + + +async def _mark_failed( + session_factory: SessionFactory, + job_id: uuid.UUID, + error: str, + request_id: str | None, +) -> None: + """在**全新** session 里把 job 置 failed(前一事务已因异常作废)。""" + try: + async with session_factory() as session: + await SqlJobRepo(session).fail(job_id, error) + await session.commit() + except Exception as exc: # noqa: BLE001 — 置失败态本身再炸只记日志,不冒泡。 + log.error( + "chain_job_fail_mark_failed", + job_id=str(job_id), + request_id=request_id, + error=str(exc), + ) + + +__all__ = [ + "JOB_KIND_CHAIN", + "AcceptOp", + "CheckpointerCtx", + "DigestGatewayBuilder", + "build_accept_op", + "run_chain_job", +] diff --git a/apps/api/ww_api/services/project_deps.py b/apps/api/ww_api/services/project_deps.py index 8637f10..befc0eb 100644 --- a/apps/api/ww_api/services/project_deps.py +++ b/apps/api/ww_api/services/project_deps.py @@ -10,7 +10,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import Annotated +from typing import Annotated, Any import httpx from fastapi import Depends @@ -399,6 +399,53 @@ async def build_writer_gateway(session: AsyncSession, store: CredentialStore) -> return await build_gateway_for_tier(session, store, "writer") +# 多章链一条 run 内跨 writer(write)/analyst(review)/light(digest) 三档——单档网关的 +# chain_resolver 恒返该档链、忽略请求 tier,会把 review/digest 错路由到 writer。故链需一个 +# **按请求 tier 分派**的网关:并 union 三档所有 provider 的适配器 + 逐 tier 解析其链。 +_CHAIN_TIERS: tuple[Tier, ...] = ("writer", "analyst", "light") + + +def _chain_for_tier(stored_by_tier: dict[Tier, Any], tier: Tier) -> list[Route]: + """据该 tier 的 DB 路由行构链;无行则退回全局默认(单 provider,M1 兼容)。""" + stored = stored_by_tier.get(tier) + if stored is None: + return [resolve_route(tier)] + primary_spec = f"{stored.provider}:{stored.model}" + return chain_from_routing(tier, primary_spec, list(stored.fallback)) + + +async def build_chain_gateway(session: AsyncSession, store: CredentialStore) -> Gateway: + """装配多章链网关:按请求 tier(writer/analyst/light)分派,union 三档适配器 + 回退链。 + + 与 `build_gateway_for_tier` 同凭据/适配器装配,但 `chain_resolver` 据 `req.tier` 返回 + 对应档的链(不锁死单档)。至少一个可用适配器,否则 `LLM_UNAVAILABLE`(无任何凭据)。 + """ + ledger = SqlAlchemyLedgerSink(session) + routing_rows = await store.list_routing() + stored_by_tier: dict[Tier, Any] = {r.tier: r for r in routing_rows if r.tier in _CHAIN_TIERS} + + # union 三档链里所有 provider,逐个建可用适配器(未配凭据/未知 base_url 的跳过)。 + adapters: dict[str, ProviderAdapter] = {} + for tier in _CHAIN_TIERS: + for route in _chain_for_tier(stored_by_tier, tier): + if route.provider in adapters: + continue + built = await _build_provider_adapter(store, route.provider) + if built is not None: + adapters[route.provider] = built + if not adapters: + raise AppError( + ErrorCode.LLM_UNAVAILABLE, + "多章链无任何已配置凭据的 provider,请先在设置中配置", + {"tiers": list(_CHAIN_TIERS)}, + ) + + def _resolver(tier: Tier) -> list[Route]: + return _chain_for_tier(stored_by_tier, tier) + + return Gateway(adapters=adapters, ledger=ledger, chain_resolver=_resolver) + + async def get_writer_gateway( session: Annotated[AsyncSession, Depends(get_session)], ) -> Gateway: @@ -451,3 +498,35 @@ async def get_refine_gateway( """回炉(writer 档位)的可注入网关缝。测试经 override 注 mock(产纯文本重写段)。""" store = SqlCredentialStore(session) return await build_gateway_for_tier(session, store, "writer") + + +async def get_chain_gateway( + session: Annotated[AsyncSession, Depends(get_session)], +) -> Gateway: + """多章链(write writer + review analyst + digest light,按请求 tier 分派)的可注入网关缝。 + + `POST .../chains/{key}/run` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE` + (503,调度 job 之前拦下,避免凭空写注定失败的 job)。链本体在 BackgroundTask 里用 + `run_chain_job` 自建独立 session 重新构网关跑(请求 session 已关闭);本依赖只做请求阶段 + 凭据探测。测试经 `app.dependency_overrides[get_chain_gateway]` 注 mock(绝不联网)。 + """ + store = SqlCredentialStore(session) + return await build_chain_gateway(session, store) + + +# 「按 session 建 digest 网关」缝类型(chain accept_op 终稿提炼按节点自建短事务的 session 建)。 +GatewayDigestBuilder = Callable[[AsyncSession], Awaitable[Gateway]] + + +def _digest_gateway_builder(session: AsyncSession) -> Awaitable[Gateway]: + """链 accept 节点在自建短事务里建 light 档 digest 网关(终稿提炼)。""" + return build_gateway_for_tier(session, SqlCredentialStore(session), "light") + + +def get_digest_gateway_builder() -> GatewayDigestBuilder: + """返回「按 session 建 digest 网关」的缝(chain accept_op 终稿提炼用)。 + + BackgroundTask 自建短事务 → 节点内据该 session 现建 light 档网关。测试经 + `app.dependency_overrides[get_digest_gateway_builder]` 注返回 mock 的 builder(绝不联网)。 + """ + return _digest_gateway_builder diff --git a/memory/contracts.md b/memory/contracts.md index df1198e..b552439 100644 --- a/memory/contracts.md +++ b/memory/contracts.md @@ -313,9 +313,24 @@ --- +## C-Chain · 多章工作流链端点 + 服务层 owner @backend / @llm(图) 状态: 稳定(C2, 2026-06-23) +> 设计真源:`docs/design/chain-workflow.md`。C1(@llm) 图签名 §3.3;C2(@backend) 端点/schema/服务接线。 +- **3 端点**(`routers/chain.py`,挂 `/projects` 前缀): + - `POST /projects/{pid}/chains/{chain_key}/run` ← `ChainRunRequest{start_chapter_no:int>=1, count:int 1..50}` → 202 `ChainRunAccepted{job_id, chain_key, start_chapter_no, count}`。写一行 `jobs(queued, kind="chain")` 返 202,链经 BackgroundTask `run_chain_job` 跑(自建独立 session)。未知 chain_key→404(仅 `draft_volume`);count 越界→422(schema Field);项目不存在→404;无凭据→503。 + - `GET /jobs/{job_id}`(**复用**现有):轮询进度/awaiting。链 job `result = {chain_key, written:[int], completed:bool, awaiting_chapter:int|null}`;interrupt 命中时 `status="awaiting_input"` + `result.awaiting_chapter`。 + - `POST /projects/{pid}/chains/runs/{job_id}/resume` ← `ChainResumeRequest{decisions:[ConflictDecision]}` → 202 `ChainRunAccepted`。仅当 job=`awaiting_input`;非该态→**409 `CONFLICT`**;job 不存在→404。带裁决 `Command(resume=...)` 续跑。 +- **schema**(`schemas/chain.py`):`ChainRunRequest`/`ChainRunAccepted`/`ChainResumeRequest`;`ConflictDecision` 复用 `schemas/projects.py`(conflict_index/verdict/note)。 +- **零迁移**(§7):复用 `jobs`(kind/status/progress/result 全已存);新增 `status` 值 `"awaiting_input"`(jobs.status 自由 Text 列,无 DDL);awaiting 章经 `result.awaiting_chapter` 表达。`JobRepo` 加 `set_awaiting(job_id, result)`。**唯一新增 DDL = langgraph 检查点表(C3 迁移建,本任务未引入)。** +- **新增错误码**(`ww_shared/errors.py`):`ErrorCode.CONFLICT`→409(资源状态冲突,如对非 awaiting 链 job 续跑;与 `CONFLICT_UNRESOLVED` 区分)。 +- **新网关缝**(`project_deps.py`):`get_chain_gateway`(`build_chain_gateway`:按请求 tier writer/analyst/light 分派、union 三档适配器+回退链——单档网关 chain_resolver 恒返该档会错路由 review/digest,故链需多档分派);`get_digest_gateway_builder`(accept 节点自建短事务建 light 档 digest 网关)。`get_checkpointer_factory`(`services/chain_deps.py`:运行时 `AsyncPostgresSaver.from_conn_string(database_url_sync)` 上下文;测试注 MemorySaver 工厂)。 +- **token 纪律**:job result/status/日志只记章号/计数/标志,绝不含 prompt/正文/token(§5,已断言)。 +- → 影响 @frontend(链发起页/进度/裁决续跑面板,**非目标本期不做**,契约稳定后 follow-up `pnpm gen:api`);@db/@devops C3(检查点建表迁移 + 可选 jobs 注释)。 + ## 契约变更日志(append-only) > 格式:`- [date] @skill 改 Cx:<改了什么> → 影响 <依赖方/任务>` +- [2026-06-23] @backend 立 C-Chain(C2):3 端点 + `schemas/chain.py` + `services/{chain_runner,chain_deps}.py` + `jobs` 零迁移复用(加 `status="awaiting_input"` + `JobRepo.set_awaiting`)+ 新 `ErrorCode.CONFLICT`(409) + `project_deps` 加 `build_chain_gateway`/`get_chain_gateway`/`get_digest_gateway_builder`/`get_checkpointer_factory`。OpenAPI 含 2 新 POST(GET /jobs 复用)。门禁绿:ruff/format/mypy(193) 干净 + pytest 600 passed + alembic 无漂移。→ 影响 @frontend(follow-up `pnpm gen:api`)、@db/@devops C3(检查点迁移)。 + - [2026-06-20] @backend 扩 C3:新增 **`GET /projects/{project_id}/chapters/{chapter_no}/injection`**(本章注入透明,B0 读端点)→ `InjectionResponse{project_id, chapter_no, selected:[InjectionEntity{kind,name,reasons[]}], recent_n}`(`schemas/injection.py`,snake_case)。实现仅调既有 `assemble()` 回放确定性 `SelectionTrace`——**无 LLM、无 commit、无 DDL**;项目不存在→404,无大纲→`selected:[]`。reasons 取值同 `SelectionReason`(explicit_beat/main_character/recent_digest/foreshadow_window)。→ 影响 @frontend(已 `pnpm gen:api` + `ChapterAssistant` 消费)。**B0 可控版(PUT override + `select_relevant_entities` 加 pinned/excluded/recent_n + draft 端点同读 override + 持久化)尚未实现,届时再扩本契约。** - [2026-06-20] @backend 再扩 C3(B0 **可控版**,接上条):新增 **`PUT /projects/{project_id}/chapters/{chapter_no}/injection`** ← `InjectionOverrideRequest{pinned:[{kind,name}], excluded:[{kind,name}], recent_n:int|null(1..20)}` → 返回 `InjectionResponse`(同上,**新增回显字段 `pinned`/`excluded`**,`selected[].reasons` 可含新值 **`author_pin`**)。语义:pin 强制纳入并加 `author_pin` 理由 / excluded 强制剔除(**优先于 pin**)/ recent_n 覆盖近况回看章数。GET injection 与 **draft 流式端点**均先读同一覆盖再 `assemble(override=...)`,故「看到的=写章用的」(不变量 #6 作者兜底)。持久化:**新表 `chapter_injection`**(迁移 `ad2c4c663daf`,唯一 `(project_id,chapter_no)`,复用 outline 行已否决);`select_relevant_entities` 加 `pinned/excluded` frozenset 入参、`assemble` 加 `override` 关键字参;新 `domain/injection_repo.py`(`InjectionOverride`/`EntityRef`/`SqlInjectionOverrideRepo`,upsert 只 flush 端点 commit)。→ 影响 @frontend(已 `pnpm gen:api`;F1 加 pin/排除控件 + recent_n 步进器)。 diff --git a/memory/decisions.md b/memory/decisions.md index 4f72207..54d6c13 100644 --- a/memory/decisions.md +++ b/memory/decisions.md @@ -149,3 +149,9 @@ - 选择:把实现落在已是 workspace member、且承载「编排/写库 apply 层」的 `ww_core.domain`(`skill_registry.py` + `skill_permissions.py`,经 `ww_core.domain` 导出)。`ARCHITECTURE §5.6` 明确「强制点在编排/写库层」=ww_core,语义上也吻合。`packages/skills/ww_skills/__init__.py` 留**再导出门面**(import 自 ww_core),保留稳定「skills」导入名。 - 理由 / 取舍:非阻塞、门禁可干净覆盖(ruff/mypy/pytest 全跑)、不越权改根配置;代价是物理位置与任务文档措辞略偏,但功能/契约(C8)完整。待 @devops 把 `packages/skills` 纳入 workspace + mypy_path 后,可平滑迁移实现到 `ww_skills` 而不动调用方(只改门面方向)。 - 影响:消费方 import `ww_core.domain` 的 `SkillRegistry`/`filter_reads`/`partition_writes`/`validate_declaration`(或经 `ww_skills` 门面)。若 @devops 后续纳入 workspace,迁移由届时的 @backend/@llm 执行。 + +## [2026-06-23] @backend C2(多章链)服务/端点接线:零迁移表达 awaiting + 多档分派网关 +- 决策 1(**零迁移表达 awaiting**):链 interrupt 暂停态不新建 jobs 列/枚举——`jobs.status` 是自由 Text 列(无 DB CHECK),直接新增值 `"awaiting_input"` + `JobRepo.set_awaiting`;待裁决章号经 `result.awaiting_chapter` 表达。`jobs.kind="chain"` 同理零迁移。唯一新增 DDL(langgraph 检查点表)归 C3 迁移。理由:设计 §7 优先零迁移;result 已是 JSONB 足以承载非密进度。 +- 决策 2(**多档分派网关 `build_chain_gateway`**):一条链 run 跨 writer(write)/analyst(review)/light(digest) 三档,但 `build_gateway_for_tier(tier)` 装出的网关 `chain_resolver` 恒返该单档链、忽略 `req.tier`——会把 review/digest 错路由到 writer。故链需一个**按请求 tier 分派**的网关:union 三档所有 provider 适配器 + `chain_resolver` 据 `req.tier` 返回对应档链。digest 在 accept 节点自建短事务内按 session 现建 light 档网关(`get_digest_gateway_builder`)。 +- 决策 3(**accept_op 在 apps/api 装配**):链编排(图/节点)在 `ww_core`,但具体验收事务(`run_accept_transaction`/冲突 gate/digest 提炼/伏笔到期扫描)属 apps/api——经 `build_accept_op` 闭包注入图节点,守目录所有权 + 不变量 #3/#4。自动链无作者改稿,`final_text` = `chapters` 该章草稿正文(write 节点所落)。 +- 决策 4(**新错误码 `CONFLICT`**):resume 非 awaiting 态须 409,但既有 409 码 `CONFLICT_UNRESOLVED` 语义专指「未决冲突禁验收」,不宜复用。新增通用 `ErrorCode.CONFLICT`→409(资源状态冲突)。 diff --git a/memory/gotchas.md b/memory/gotchas.md index 16932f4..c691a03 100644 --- a/memory/gotchas.md +++ b/memory/gotchas.md @@ -84,3 +84,6 @@ - [2026-06-19] @llm **Kimi Code coding API 伪造头真源 = `ooojustin/opencode-kimi`(`src/headers.ts`+`src/constants.ts`,1:1 镜像 kimi-cli v1.37.0),发完整 7 头、UA `KimiCLI/1.37.0`**(K1.2 校正,推翻上方 pi-kimi-coder UA-only 那条):`picassio/pi-kimi-coder` 是分歧/错误参考(只发 UA),照它裁成 UA-only 是误修,已回退。`kimi_code_headers()` 发 `User-Agent=KimiCLI/1.37.0` + `X-Msh-Platform=kimi_cli`(字面常量,非 OS 名)+ `X-Msh-Version=1.37.0`(==UA 版本)+ `X-Msh-Device-Name`(`socket.gethostname()` ASCII 化)+ `X-Msh-Device-Model`(macOS=`f"macOS {platform.mac_ver()[0]} {platform.machine()}"`,`machine()` 原样 `arm64`/`x86_64` 不归一化)+ `X-Msh-Os-Version`(`platform.version()`)+ `X-Msh-Device-Id`(稳定 32 位无连字符 `uuid4().hex`:env `KIMI_DEVICE_ID` 优先→否则读/建 `~/.kimi/device_id` 复用,**绝不每次随机**,否则 Kimi 侧每次当新设备)。源码若与此有出入以源码为准(本次核对 master 一致)。⚠️ HTTP 头值含非 ASCII 会被底层 fetch/httpx 拒,host 派生值(Device-Name/Model/Os-Version)须 ASCII 化(`asciiHeaderValue`:裁 `\x20-\x7e` 之外 + trim,空回退 `unknown`)。单测用 env `KIMI_DEVICE_ID` 注入固定 id 保 CI 确定(不触真 ~/.kimi)。ruff E501 按显示宽度算(中文占 2 列),含中文 docstring/注释的行别贴边 100。 - [2026-06-19] @backend `assemble`(C5)此前只从 world_entities/characters/style/rules + cards/foreshadow/digests/outline.beats 组装,**从不含项目 premise/logline/theme/title,也无「写章」指令** → 全新项目(只有 premise、无世界观/角色/大纲)产出 `stable_core=""`+`volatile=""`,writer 的 user message 为空,LLM 回 `400 "the message at position 0 with role 'user' must not be empty"`。修复:`MemoryRepos` 加第 8 个 repo `project:ProjectSpecRepo`(`spec(project_id)->ProjectSpecView{title,logline?,premise?,theme?}`,按 project_id 读 `projects`,无 owner 过滤——owner 隔离归路由层);`assemble` 把「作品蓝本」放进 `stable_core` 首段(书级 spec=定型→缓存前缀,不违 #9),并让 `volatile` 始终含 `请创作第 {chapter_no} 章的正文。`(易变指令)。⚠️ 任何手搓 `MemoryRepos(...)` 的地方(含测试 fake)现在**必须**补 `project=` 字段,否则 dataclass 缺参报错。无 DDL 变更(只读既有 projects 列),无迁移。 - [2026-06-22] @frontend/@orchestrator T6 创作工具箱通用端点的两处契约边界(加新生成器时注意):① `ToolGenerateRequest` 是**固定字段并集** `{brief, chapter_no?, count?, kind?}`——`GeneratorRunner` 只映射落在此集合内的 `input_field` 名;新生成器若声明了集合外的 `input_field`,前端会**静默忽略**该字段。新工具的 `input_fields` 须落在 `brief/chapter_no/count/kind` 内,否则需先扩 `ToolGenerateRequest` 契约(+`pnpm gen:api`)。② `ToolInputFieldView.type` 是自由字符串,后端可声明 `"select"` 但 descriptor **无 `options` 字段** → 前端把 select 当 text 输入渲染。若某工具需要真下拉选项,须先给 `InputField`/`ToolInputFieldView` 加 `options` 再改前端 `GeneratorRunner`。 +- [2026-06-23] @backend 多章链网关(C2):**单档 `build_gateway_for_tier(tier)` 网关不能驱动跨档链**。其 `chain_resolver`/`_resolver` 闭包恒返该 tier 的链、**忽略入参 `req.tier`**,故拿它跑 review(analyst)/digest(light) 会全部错路由到 writer 档的 provider/model。链/任何跨档编排须用 `build_chain_gateway`(union 三档适配器 + `chain_resolver` 据 `req.tier` 分派)。测试用 mock 网关按 `req.output_schema` 路由(无 schema=write,有=review),不受此影响。 +- [2026-06-23] @backend langgraph `CompiledStateGraph.ainvoke` 的 mypy 重载:`config` 必须是 `RunnableConfig`(`from langchain_core.runnables import RunnableConfig`),裸 `dict[str,dict[str,str]]` 不匹配任何重载 → call-overload 错。返回值是 `dict[str,Any] | Any`,传给取 `__interrupt__`/`written` 的 helper 前先 `dict(raw_final)` 收敛为 `dict[str,Any]`。interrupt 命中时返回值含 `__interrupt__`(Interrupt 对象列表),载荷读 `getattr(first,"value",first)`(兼容 dict)。 +- [2026-06-23] @backend 改 `JobRepo` Protocol 加方法(如 `set_awaiting`)= 契约变更:**所有 fake 实现都要同步补**,否则 mypy 报 Protocol 缺成员(本次漏了 `packages/core/tests/test_job_repo.py::FakeJobRepo` + `apps/api/tests/fakes_projects.py::FakeJobRepo`)。grep `class Fake.*JobRepo` 全仓再补。 diff --git a/packages/core/tests/test_job_repo.py b/packages/core/tests/test_job_repo.py index 3cc41e6..1c32866 100644 --- a/packages/core/tests/test_job_repo.py +++ b/packages/core/tests/test_job_repo.py @@ -15,6 +15,7 @@ import pytest from pydantic import ValidationError from ww_core.domain.job_repo import ( PROGRESS_COMPLETE, + STATUS_AWAITING, STATUS_DONE, STATUS_FAILED, STATUS_QUEUED, @@ -93,6 +94,12 @@ class FakeJobRepo: row.result = dict(result) return _view(row) + async def set_awaiting(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView: + row = self._require(job_id) + row.status = STATUS_AWAITING + row.result = dict(result) + return _view(row) + async def fail(self, job_id: uuid.UUID, error: str) -> JobView: row = self._require(job_id) row.status = STATUS_FAILED diff --git a/packages/core/ww_core/domain/job_repo.py b/packages/core/ww_core/domain/job_repo.py index 820c165..bdfcfb3 100644 --- a/packages/core/ww_core/domain/job_repo.py +++ b/packages/core/ww_core/domain/job_repo.py @@ -28,6 +28,8 @@ STATUS_QUEUED = "queued" STATUS_RUNNING = "running" STATUS_DONE = "done" STATUS_FAILED = "failed" +# 多章链 interrupt 命中:暂停等作者裁决(chain-workflow §7,零迁移复用 jobs.status 自由 Text 列)。 +STATUS_AWAITING = "awaiting_input" PROGRESS_COMPLETE = 100 @@ -63,6 +65,10 @@ class JobRepo(Protocol): """置 status=done, progress=100, result=。""" ... + async def set_awaiting(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView: + """置 status=awaiting_input, result=(多章链 interrupt 暂停等裁决;chain §5/§7)。""" + ... + async def fail(self, job_id: uuid.UUID, error: str) -> JobView: """置 status=failed, error=。""" ... @@ -144,6 +150,14 @@ class SqlJobRepo: await self._s.refresh(row) return _to_view(row) + async def set_awaiting(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView: + row = await self._require(job_id) + row.status = STATUS_AWAITING + row.result = dict(result) + await self._s.flush() + await self._s.refresh(row) + return _to_view(row) + async def fail(self, job_id: uuid.UUID, error: str) -> JobView: row = await self._require(job_id) row.status = STATUS_FAILED diff --git a/packages/shared/ww_shared/errors.py b/packages/shared/ww_shared/errors.py index d243b8e..1d7eb8f 100644 --- a/packages/shared/ww_shared/errors.py +++ b/packages/shared/ww_shared/errors.py @@ -8,6 +8,7 @@ from enum import StrEnum class ErrorCode(StrEnum): NOT_FOUND = "NOT_FOUND" VALIDATION = "VALIDATION" + CONFLICT = "CONFLICT" # 资源状态冲突(如对非 awaiting 链 job 续跑) CONFLICT_UNRESOLVED = "CONFLICT_UNRESOLVED" # 未决冲突禁验收 LLM_UNAVAILABLE = "LLM_UNAVAILABLE" # 回退耗尽 RATE_LIMITED = "RATE_LIMITED" @@ -18,6 +19,7 @@ class ErrorCode(StrEnum): HTTP_STATUS: dict[ErrorCode, int] = { ErrorCode.NOT_FOUND: 404, ErrorCode.VALIDATION: 422, + ErrorCode.CONFLICT: 409, ErrorCode.CONFLICT_UNRESOLVED: 409, ErrorCode.LLM_UNAVAILABLE: 503, ErrorCode.RATE_LIMITED: 429, From 548b7abd6fb5263bf836c0393ed964e21a29e511 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 17:23:06 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat(db):=20C3=20langgraph=20=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E7=82=B9=E5=BB=BA=E8=A1=A8=E8=BF=81=E7=A7=BB=20+=20al?= =?UTF-8?q?embic=20=E6=BC=82=E7=A7=BB=E8=B1=81=E5=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多章工作流链(docs/design/chain-workflow.md §4/§7)唯一新增 DDL:langgraph 检查点四表(checkpoints/checkpoint_blobs/checkpoint_writes/checkpoint_migrations)。 - 新增迁移 d3e4f5a6b7c8:在迁移内调 langgraph 同步 PostgresSaver.setup() 建表 (DDL 只在 migrations/CI,绝不 app-runtime)。从 database_url 派生 psycopg conn string(去 +asyncpg),与 alembic 的 asyncpg 连接解耦。 - §11 风险落地:setup() 跑 CREATE INDEX CONCURRENTLY,不能在事务块内且会与 alembic 开着的事务死锁 → 用 op.get_context().autocommit_block() 先提交退出 事务块再建表。downgrade 幂等 DROP ... CASCADE。 - env.py 加 include_object:langgraph 四表不在 Base.metadata,过滤掉以免 alembic check 误报漂移。 - jobs 表零迁移(C2 判定:result.awaiting_chapter 表达待裁决,复用现有状态枚举)。 验证:alembic upgrade head 应用、downgrade/re-upgrade 往返、alembic check 无漂移;ruff/format/mypy(packages/db) 全绿。 --- packages/db/migrations/env.py | 20 ++++- ...d3e4f5a6b7c8_langgraph_checkpoint_setup.py | 75 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 packages/db/migrations/versions/d3e4f5a6b7c8_langgraph_checkpoint_setup.py diff --git a/packages/db/migrations/env.py b/packages/db/migrations/env.py index 620d5aa..092b35e 100644 --- a/packages/db/migrations/env.py +++ b/packages/db/migrations/env.py @@ -20,9 +20,27 @@ if config.config_file_name is not None: config.set_main_option("sqlalchemy.url", get_settings().database_url) target_metadata = Base.metadata +# langgraph 检查点表由 langgraph 自管 schema(迁移 d3e4f5a6b7c8 调其 setup() 建表), +# 不在 Base.metadata。autogenerate/check 须忽略它们,否则会误报"待 drop"漂移。 +_LANGGRAPH_TABLES = frozenset( + {"checkpoints", "checkpoint_blobs", "checkpoint_writes", "checkpoint_migrations"} +) + + +def include_object( + obj: object, name: str | None, type_: str, reflected: bool, compare_to: object +) -> bool: + if type_ == "table" and name in _LANGGRAPH_TABLES: + return False + return True + def do_run_migrations(connection: Connection) -> None: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure( + connection=connection, + target_metadata=target_metadata, + include_object=include_object, + ) with context.begin_transaction(): context.run_migrations() diff --git a/packages/db/migrations/versions/d3e4f5a6b7c8_langgraph_checkpoint_setup.py b/packages/db/migrations/versions/d3e4f5a6b7c8_langgraph_checkpoint_setup.py new file mode 100644 index 0000000..daf0f37 --- /dev/null +++ b/packages/db/migrations/versions/d3e4f5a6b7c8_langgraph_checkpoint_setup.py @@ -0,0 +1,75 @@ +"""langgraph checkpoint setup (multi-chapter chain workflow) + +Revision ID: d3e4f5a6b7c8 +Revises: c2b3d4e5f6a7 +Create Date: 2026-06-23 10:00:00.000000 + +多章工作流链(docs/design/chain-workflow.md §4/§7)唯一新增 DDL:langgraph +检查点表(`checkpoints` / `checkpoint_blobs` / `checkpoint_writes` / +`checkpoint_migrations`)。这些表**不在** ``Base.metadata``(由 langgraph 自己 +管理 schema),故迁移直接调用 langgraph 的一次性 ``setup()`` 建表,而非 ORM +``op.create_table``。env.py 的 ``include_object`` 过滤这些表,避免 ``alembic +check`` 误报漂移(要 drop 这些"非 metadata"表)。 + +实现说明(CLAUDE.md「LangGraph」+ 设计 §11 回退): +- DDL 只在 **migrations/CI** 跑(绝不在 app-runtime)——本迁移即该入口。 +- ``upgrade()`` 跑在 alembic 的**同步**迁移上下文里(``connection.run_sync`` + 下已有 event loop 在跑 async 迁移),故**不能**调 async ``setup_checkpointer`` + /``asyncio.run``(会嵌套 loop 报错)。改用 langgraph 同步 ``PostgresSaver`` + (psycopg),用一条从 ``database_url`` 派生的 psycopg conn string 自建连接, + 与 alembic 的 asyncpg 连接解耦。``setup()`` 幂等(langgraph 内部 migration 版本表)。 +- **关键**:langgraph ``setup()`` 跑 ``CREATE INDEX CONCURRENTLY``,它不能在 + 事务块内执行,且会等 alembic 那条仍开着事务(持 ``alembic_version`` 锁)的连接 + → 死锁(§11 风险落地)。故用 ``op.get_context().autocommit_block()`` 把 alembic + 的事务先提交、退出事务块,再让 langgraph 在独立 autocommit 连接里建表/建索引。 + +jobs 表:C2 判定零迁移复用(用 ``result.awaiting_chapter`` 表达待裁决, +``status`` 复用现有枚举),故本迁移不动 jobs。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +revision: str = "d3e4f5a6b7c8" +down_revision: str | None = "c2b3d4e5f6a7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# langgraph 检查点表(langgraph.checkpoint.postgres.base 的 MIGRATIONS 建的全部表) +_CHECKPOINT_TABLES: tuple[str, ...] = ( + "checkpoint_writes", + "checkpoint_blobs", + "checkpoints", + "checkpoint_migrations", +) + + +def _psycopg_conn_string() -> str: + """从 settings.database_url 派生 psycopg(同步)conn string。 + + settings 用 asyncpg URL(``postgresql+asyncpg://…``);langgraph 的同步 + ``PostgresSaver`` 走 psycopg,需去掉 SQLAlchemy 的 ``+asyncpg`` 驱动后缀。 + """ + from ww_config import get_settings + + return get_settings().database_url.replace("+asyncpg", "") + + +def upgrade() -> None: + from alembic import op + from langgraph.checkpoint.postgres import PostgresSaver + + # 退出 alembic 事务块(提交 alembic_version 更新,释放其锁),langgraph 的 + # CREATE INDEX CONCURRENTLY 才能在独立连接里跑而不死锁。 + with op.get_context().autocommit_block(): + with PostgresSaver.from_conn_string(_psycopg_conn_string()) as saver: + saver.setup() + + +def downgrade() -> None: + import psycopg + + with psycopg.connect(_psycopg_conn_string()) as conn: + conn.execute("DROP TABLE IF EXISTS " + ", ".join(_CHECKPOINT_TABLES) + " CASCADE") + conn.commit() From 061792db1c4025978901231ca93f049f7fed4e61 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 17:27:46 +0200 Subject: [PATCH 5/6] =?UTF-8?q?test(chain):=20C4=20=E5=A4=9A=E7=AB=A0?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=93=BE=20E2E=20=E2=80=94=20?= =?UTF-8?q?=E7=9C=9F=20pg=20+=20mock=20=E7=BD=91=E5=85=B3=E9=9B=B6=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用例①两章无冲突全自动:两 accepted 章 + 两 digest 行 + job done written=[1,2] - 用例②注入冲突→interrupt→job awaiting→resume 带裁决→续跑→accept; interrupt+resume 横跨两次端点调用,单进程单 MemorySaver(覆盖 get_checkpointer_factory) - 负向断言:正文/token 不入 job result/状态(HTTP + DB 真源双查) - 守不变量 #1/#3/#4/#5/#9 --- tests/test_chain_workflow_e2e.py | 429 +++++++++++++++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 tests/test_chain_workflow_e2e.py diff --git a/tests/test_chain_workflow_e2e.py b/tests/test_chain_workflow_e2e.py new file mode 100644 index 0000000..410f570 --- /dev/null +++ b/tests/test_chain_workflow_e2e.py @@ -0,0 +1,429 @@ +"""C4 多章工作流链 端到端(chain-workflow §9 C4 / §10)——真 pg + mock 网关零 token。 + +证明杀手锏闭环(不变量 #1/#3/#4/#5/#9):批量量产,但每章过 `write→四审→decide→accept`, +遇冲突 interrupt 交人裁决再 resume 续跑——星月架构做不到的一致性闸。 + + 用例 1 · 两章无冲突全自动 + `POST /projects` → `POST .../chains/draft_volume/run {start=1,count=2}` → 202 {job_id}; + BackgroundTask `run_chain_job` 自建独立 session 驱动链图(mock 网关零冲突)→ 两章 + `write→四审(无冲突)→accept` 自动跑完。DB 真源逐项断言: + - `chapters` 第 1/2 章各一行 `accepted`(write 落 draft → accept 晋升终稿); + - `chapter_digests` 两行(digest 从终稿提炼,不变量 #4); + - `GET /jobs/{id}` status=done,result.written=[1,2]、completed=True、awaiting_chapter=None。 + 负向:job result/状态绝不含正文/prompt/token(§5)。 + + 用例 2 · 注入冲突 → interrupt → awaiting → resume → accept + 续审第 1 章报冲突 → `decide` 命中 → `accept` 节点 `interrupt()` 暂停 → job=awaiting_input、 + result.awaiting_chapter=1、`chapters` 第 1 章仍 `draft`(未晋升)、`chapter_digests` 空。 + `POST .../chains/runs/{job_id}/resume {decisions:[...]}` → 202 → resume 续跑 → accept → + job done、第 1 章 `accepted`、digest 一行。 + interrupt+resume 横跨**两次端点调用**,靠单进程单 `MemorySaver`(经依赖覆盖注入 + `get_checkpointer_factory` 的同一 saver 实例)持久控制流位置 + pending handle。 + +确定性 & 零成本(同 M3/M4):真 `Gateway` + 假适配器(据 `req.output_schema` 分支返固定 +parsed/text,绝不联网)+ 真 `SqlAlchemyLedgerSink`。检查点用 MemorySaver(绝不连真 Postgres +检查点)。无 pg → skip。 + +坑(见 memory/gotchas): +- `get_checkpointer_factory` 必 override 成产**同一** MemorySaver 的工厂——run 与 resume 两次 + 端点调用须落同一 thread_id 的同一检查点存储,否则 resume 找不到 interrupt 现场。 +- `get_session_factory` 必 override → `e2e_sm`(真 sessionmaker,同测试 engine/loop); + BackgroundTask 在请求 session 关闭后才跑。 +- `get_digest_gateway_builder` 必 override → 返「按 session 建真 Gateway 包假适配器」的 builder + (链 accept 节点终稿提炼用 light 档),否则会从空凭据建真 OpenAI 适配器。 +- ASGITransport 下 `await client.post` 等 ASGI app(含 background task)跑完才返回。 +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from typing import Annotated, Any + +import httpx +import pytest +from asgi_lifespan import LifespanManager +from fastapi import Depends +from langgraph.checkpoint.memory import MemorySaver +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from ww_agents import ( + Conflict, + ContinuityReview, + ForeshadowReview, + PaceReview, + StyleDriftReview, +) +from ww_api.services.digest_extraction import ChapterDigestFacts +from ww_db import get_session, get_sessionmaker +from ww_db.models import ( + Chapter, + ChapterDigest, + ChapterReview, + Job, + 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" + +# 链写章收集版正文(非 SSE,经 gateway.run → adapter.complete,output_schema is None)。 +_CHAPTER_TEXT = "灵气如潮,少年握剑而立,山门之上风雷大作。" + +# 各档假用量(喂记账;证明各档调用各落一条 usage_ledger)。 +_USAGE = ProviderUsage(input_tokens=13, output_tokens=7) + +# 三审固定无冲突产物(让四审图跑通;continuity 的冲突由 fixture 参数注入)。 +_FORESHADOW_REVIEW = ForeshadowReview(planted=[], resolved=[]) +_PACE_REVIEW = PaceReview(water=[], hook=True, beat_map=[1, 2, 3]) +_STYLE_DRIFT = StyleDriftReview(score=88, segments=[]) + + +class _FakeChainAdapter: + """实现 `ProviderAdapter`:据 `req.output_schema` 分支返 parsed/text,绝不联网。 + + - `output_schema is None` → 链写章收集版纯文本(writer 档,`gateway.run`)。 + - 四审 schema → 各审 parsed(continuity 注入冲突,其余无冲突)。 + - `ChapterDigestFacts` → 终稿 digest 提炼(light 档)。 + `stream()` 实现但链不走(收集版量产非 SSE)。 + """ + + provider = _PROVIDER + + def __init__(self, *, continuity_conflicts: list[Conflict]) -> None: + self._continuity = ContinuityReview(conflicts=continuity_conflicts) + + 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 None: + return ProviderResult(text=_CHAPTER_TEXT, parsed=None, usage=_USAGE) + if schema is ContinuityReview: + return ProviderResult( + text=self._continuity.model_dump_json(), parsed=self._continuity, usage=_USAGE + ) + if schema is ForeshadowReview: + return ProviderResult( + text=_FORESHADOW_REVIEW.model_dump_json(), parsed=_FORESHADOW_REVIEW, usage=_USAGE + ) + if schema is PaceReview: + return ProviderResult( + text=_PACE_REVIEW.model_dump_json(), parsed=_PACE_REVIEW, usage=_USAGE + ) + if schema is StyleDriftReview: + return ProviderResult( + text=_STYLE_DRIFT.model_dump_json(), parsed=_STYLE_DRIFT, usage=_USAGE + ) + # 否则视为终稿 digest 提炼(ChapterDigestFacts)。 + facts = ChapterDigestFacts(summary="终稿摘要", events=["少年出山"], locations=["山门"]) + return ProviderResult(text=facts.model_dump_json(), parsed=facts, usage=_USAGE) + + async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]: + yield StreamChunk(text=_CHAPTER_TEXT) + yield StreamChunk(usage=_USAGE) + + +@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 _gateway_for(adapter: _FakeChainAdapter, session: AsyncSession) -> Gateway: + """真 Gateway + 给定假适配器 + 真 ledger(绑给定 session)。""" + return Gateway( + adapters={_PROVIDER: adapter}, + ledger=SqlAlchemyLedgerSink(session), + resolver=resolve_route, + ) + + +def _chain_gateway_override( + adapter: _FakeChainAdapter, +) -> Callable[[AsyncSession], Gateway]: + """`get_chain_gateway` 覆盖:请求阶段凭据探测 → 真 Gateway 包假适配器(绕过 503)。""" + + def _override(session: Annotated[AsyncSession, Depends(get_session)]) -> Gateway: + return _gateway_for(adapter, session) + + return _override + + +def _digest_builder_override( + adapter: _FakeChainAdapter, +) -> Callable[[], Callable[[AsyncSession], Awaitable[Gateway]]]: + """`get_digest_gateway_builder` 覆盖:返「按 session 建真 Gateway 包假适配器」的 builder。 + + 链 accept 节点在自建短事务里据该 session 现建 light 档 digest 网关(终稿提炼)。 + """ + + def _get_builder() -> Callable[[AsyncSession], Awaitable[Gateway]]: + async def _build(session: AsyncSession) -> Gateway: + return _gateway_for(adapter, session) + + return _build + + return _get_builder + + +def _memsaver_override( + saver: MemorySaver, +) -> Callable[[], Callable[[], AbstractAsyncContextManager[MemorySaver]]]: + """`get_checkpointer_factory` 覆盖:返产**同一** saver 的工厂(run/resume 跨调用共用)。""" + + @asynccontextmanager + async def _ctx() -> AsyncIterator[MemorySaver]: + yield saver + + return lambda: lambda: _ctx() + + +async def _cleanup(e2e_sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None: + """按 FK 顺序清理(多数子表 FK CASCADE;显式删保独立)。""" + async with e2e_sm() as cleanup: + await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid)) + await cleanup.execute(delete(Job).where(Job.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(Chapter).where(Chapter.project_id == project_uuid)) + await cleanup.execute(delete(Project).where(Project.id == project_uuid)) + await cleanup.commit() + + +def _build_app(adapter: _FakeChainAdapter, saver: MemorySaver, e2e_sm: Any) -> Any: + """组装注入了全 fake 缝的 app(凭据探测/session 工厂/checkpointer/digest builder)。""" + from ww_api.main import create_app + from ww_api.services.chain_deps import get_checkpointer_factory + from ww_api.services.project_deps import ( + get_chain_gateway, + get_digest_gateway_builder, + get_session_factory, + ) + + app = create_app() + app.dependency_overrides[get_chain_gateway] = _chain_gateway_override(adapter) + app.dependency_overrides[get_session_factory] = lambda: e2e_sm + app.dependency_overrides[get_checkpointer_factory] = _memsaver_override(saver) + app.dependency_overrides[get_digest_gateway_builder] = _digest_builder_override(adapter) + return app + + +async def test_chain_two_chapters_no_conflict_full_auto( + e2e_sm: async_sessionmaker[AsyncSession], +) -> None: + """用例 1:两章无冲突全自动 → 两 accepted 章 + 两 digest 行 + job done written=[1,2]。""" + adapter = _FakeChainAdapter(continuity_conflicts=[]) # 零冲突 → 全自动 + saver = MemorySaver() + app = _build_app(adapter, saver, 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: + create_resp = await client.post("/projects", json={"title": "链 E2E 无冲突作品"}) + assert create_resp.status_code == 201 + project_id = create_resp.json()["id"] + project_uuid = uuid.UUID(project_id) + + run_resp = await client.post( + f"/projects/{project_id}/chains/draft_volume/run", + json={"start_chapter_no": 1, "count": 2}, + ) + assert run_resp.status_code == 202 + run_body = run_resp.json() + assert run_body["chain_key"] == "draft_volume" + job_id = run_body["job_id"] + + # 链经 BackgroundTask 已跑完(ASGITransport 等其完成)→ job done。 + job_resp = await client.get(f"/jobs/{job_id}") + assert job_resp.status_code == 200 + job = job_resp.json() + assert job["status"] == "done", job + assert job["kind"] == "chain" + result = job["result"] + assert result["written"] == [1, 2] + assert result["completed"] is True + assert result["awaiting_chapter"] is None + # 负向:job result 绝不含正文/token(只章号/计数/标志)。 + assert _CHAPTER_TEXT not in str(result) + assert "终稿摘要" not in str(result) + + # DB 真源逐项断言。 + assert project_uuid is not None + async with e2e_sm() as verify: + chapters = ( + ( + await verify.execute( + select(Chapter) + .where(Chapter.project_id == project_uuid) + .order_by(Chapter.chapter_no, Chapter.version) + ) + ) + .scalars() + .all() + ) + # 第 1/2 章各晋升一个 accepted 版次。 + accepted = [c for c in chapters if c.status == "accepted"] + assert {c.chapter_no for c in accepted} == {1, 2} + assert all(c.content == _CHAPTER_TEXT for c in accepted) + + digests = ( + ( + await verify.execute( + select(ChapterDigest) + .where(ChapterDigest.project_id == project_uuid) + .order_by(ChapterDigest.chapter_no) + ) + ) + .scalars() + .all() + ) + assert [d.chapter_no for d in digests] == [1, 2] + + job_rows = ( + (await verify.execute(select(Job).where(Job.project_id == project_uuid))) + .scalars() + .all() + ) + assert len(job_rows) == 1 + assert job_rows[0].status == "done" + assert job_rows[0].kind == "chain" + # 负向(DB 真源):job result 列不含正文/token。 + assert _CHAPTER_TEXT not in str(job_rows[0].result) + finally: + if project_uuid is not None: + await _cleanup(e2e_sm, project_uuid) + + +async def test_chain_conflict_interrupts_then_resume_accepts( + e2e_sm: async_sessionmaker[AsyncSession], +) -> None: + """用例 2:注入冲突 → interrupt → job awaiting → resume 带裁决 → 续跑 → accept。 + + interrupt+resume 横跨两次端点调用,靠单进程单 MemorySaver(同一 thread_id 检查点)。 + """ + conflict = Conflict(type="性格漂移", where="第2段", suggestion="统一主角姓名为「萧寒」") + adapter = _FakeChainAdapter(continuity_conflicts=[conflict]) + saver = MemorySaver() # 单实例横跨 run + resume 两次端点调用 + app = _build_app(adapter, saver, 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: + create_resp = await client.post("/projects", json={"title": "链 E2E 冲突作品"}) + assert create_resp.status_code == 201 + project_id = create_resp.json()["id"] + project_uuid = uuid.UUID(project_id) + + # 1) 发起:第 1 章四审报冲突 → interrupt → job awaiting_input。 + run_resp = await client.post( + f"/projects/{project_id}/chains/draft_volume/run", + json={"start_chapter_no": 1, "count": 1}, + ) + assert run_resp.status_code == 202 + job_id = run_resp.json()["job_id"] + + job = (await client.get(f"/jobs/{job_id}")).json() + assert job["status"] == "awaiting_input", job + assert job["result"]["awaiting_chapter"] == 1 + assert job["result"]["completed"] is False + assert job["result"]["written"] == [] + + # awaiting 时第 1 章仍 draft(未经 accept 晋升),无 digest。 + async with e2e_sm() as mid: + mid_chapters = ( + ( + await mid.execute( + select(Chapter).where(Chapter.project_id == project_uuid) + ) + ) + .scalars() + .all() + ) + assert all(c.status == "draft" for c in mid_chapters) + assert mid_chapters != [] + mid_digests = ( + ( + await mid.execute( + select(ChapterDigest).where( + ChapterDigest.project_id == project_uuid + ) + ) + ) + .scalars() + .all() + ) + assert mid_digests == [] + + # 2) resume:作者裁决冲突 → 续跑 accept → done。 + resume_resp = await client.post( + f"/projects/{project_id}/chains/runs/{job_id}/resume", + json={ + "decisions": [ + {"conflict_index": 0, "verdict": "ignore", "note": "笔误,忽略"} + ] + }, + ) + assert resume_resp.status_code == 202 + + job = (await client.get(f"/jobs/{job_id}")).json() + assert job["status"] == "done", job + assert job["result"]["written"] == [1] + assert job["result"]["completed"] is True + assert job["result"]["awaiting_chapter"] is None + + # DB 真源断言:resume 后第 1 章 accepted + digest 一行。 + assert project_uuid is not None + async with e2e_sm() as verify: + chapters = ( + (await verify.execute(select(Chapter).where(Chapter.project_id == project_uuid))) + .scalars() + .all() + ) + accepted = [c for c in chapters if c.status == "accepted"] + assert {c.chapter_no for c in accepted} == {1} + + digests = ( + ( + await verify.execute( + select(ChapterDigest).where(ChapterDigest.project_id == project_uuid) + ) + ) + .scalars() + .all() + ) + assert [d.chapter_no for d in digests] == [1] + + # 负向:job 状态/result 不含正文/裁决正文外的 token/prompt。 + job_row = ( + await verify.execute(select(Job).where(Job.project_id == project_uuid)) + ).scalar_one() + assert _CHAPTER_TEXT not in str(job_row.result) + assert job_row.status == "done" + finally: + if project_uuid is not None: + await _cleanup(e2e_sm, project_uuid) From d1ea83b191a8a6517cc003e920251fdef91a6a58 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 18:04:10 +0200 Subject: [PATCH 6/6] =?UTF-8?q?fix(chain):=20=E4=BF=AE=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=20CRITICAL+HIGH=20=E2=80=94=20=E9=93=BE=E7=BD=91=E5=85=B3?= =?UTF-8?q?=E6=8C=89=20session=20=E9=87=8D=E5=BB=BA/=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=84=B1=E6=95=8F/resume=20=E5=8E=9F=E5=AD=90=E5=8C=96+?= =?UTF-8?q?=E6=89=80=E6=9C=89=E6=9D=83/=E6=AD=BB=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CRITICAL #1:链 write/review 节点经 gateway_builder 按节点自建 session 现建网关, usage_ledger sink 绑活 session,随节点 commit 持久化;run_chain_job 不再转发请求网关 (其 session 在 BackgroundTask 跑时已关闭,记账行被静默丢弃)。新增 get_chain_gateway_builder 缝(仿 digest builder),get_chain_gateway 退化为纯 503 凭据预检。守不变量 #1。 - HIGH #2:chain_runner 失败日志不再记 str(exc)(可能含 key/连接串/LLM 输出),改记 _classify_job_error 脱敏文案 + exc_type(设计 §5)。 - HIGH #3:resume 端点原子抢占 awaiting→running(JobRepo.claim_awaiting_to_running 条件 UPDATE),抢不到 → 409,防并发 resume 双 Command(resume) 损坏图。 - HIGH #4:resume 校验 job.project_id == project_id(JobView 新增 project_id),不匹配 → 404。 - HIGH #5:resume 返回新 ChainResumeAccepted{job_id,chain_key},去掉无意义哨兵 start/count=0。 - HIGH #6:删 nodes.py 死导入 extract_conflicts(import + __all__)。 - 测试 #7:e2e 断言链跑后 usage_ledger 有行(#1 回归守卫)+ chapter_reviews 每章一行; 新增 claim 原子抢占单测 + resume 跨项目 404 / 并发 409 端点测。 --- apps/api/tests/fakes_projects.py | 17 ++++- apps/api/tests/test_chain.py | 75 ++++++++++++++++++- apps/api/ww_api/routers/chain.py | 45 +++++++---- apps/api/ww_api/schemas/chain.py | 10 +++ apps/api/ww_api/services/chain_runner.py | 30 ++++++-- apps/api/ww_api/services/project_deps.py | 24 ++++++ packages/core/tests/test_chain_graph.py | 9 ++- packages/core/tests/test_job_repo.py | 41 ++++++++++ packages/core/ww_core/domain/job_repo.py | 35 +++++++++ .../ww_core/orchestrator/chain/__init__.py | 2 + .../core/ww_core/orchestrator/chain/graph.py | 12 +-- .../core/ww_core/orchestrator/chain/nodes.py | 21 +++++- tests/test_chain_workflow_e2e.py | 32 ++++++++ 13 files changed, 316 insertions(+), 37 deletions(-) diff --git a/apps/api/tests/fakes_projects.py b/apps/api/tests/fakes_projects.py index ac61c17..77e1637 100644 --- a/apps/api/tests/fakes_projects.py +++ b/apps/api/tests/fakes_projects.py @@ -414,7 +414,13 @@ class FakeJobRepo: self.rows: dict[uuid.UUID, JobView] = {} async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView: - view = JobView(id=uuid.uuid4(), kind=kind, status=STATUS_QUEUED, progress=0) + view = JobView( + id=uuid.uuid4(), + project_id=project_id, + kind=kind, + status=STATUS_QUEUED, + progress=0, + ) self.rows[view.id] = view return view @@ -442,6 +448,15 @@ class FakeJobRepo: self.rows[job_id] = view return view + async def claim_awaiting_to_running(self, job_id: uuid.UUID) -> JobView | None: + """原子抢占 awaiting→running(内存替身:仅当现态 awaiting 才抢到,否则 None)。""" + current = self.rows.get(job_id) + if current is None or current.status != STATUS_AWAITING: + return None + view = current.model_copy(update={"status": STATUS_RUNNING}) + self.rows[job_id] = view + return view + async def fail(self, job_id: uuid.UUID, error: str) -> JobView: view = self.rows[job_id].model_copy(update={"status": STATUS_FAILED, "error": error}) self.rows[job_id] = view diff --git a/apps/api/tests/test_chain.py b/apps/api/tests/test_chain.py index 9d334c0..9cb2948 100644 --- a/apps/api/tests/test_chain.py +++ b/apps/api/tests/test_chain.py @@ -151,6 +151,19 @@ async def _memsaver_ctx(saver: MemorySaver) -> AsyncIterator[Any]: yield saver +def _async_returning(gateway: Any) -> Any: + """构造「按 session 建网关」的 builder 替身:忽略 session,恒返给定 mock 网关(绝不联网)。 + + 链节点经 `gateway_builder(session)` 现建网关(不变量 #1);测试里 ledger 不落库(fake + session),故 builder 只需把 mock 网关交回即可。 + """ + + async def _build(_session: Any) -> Any: + return gateway + + return _build + + class _FakeContext: stable_core = "## 世界观硬规则\n灵气可凝丹" volatile = "## 写作指令\n写第 N 章" @@ -169,6 +182,7 @@ def _app(project_repo: FakeProjectRepo, job_repo: FakeJobRepo) -> FastAPI: from ww_api.services.chain_deps import get_checkpointer_factory from ww_api.services.project_deps import ( get_chain_gateway, + get_chain_gateway_builder, get_digest_gateway_builder, get_job_repo, get_project_repo, @@ -182,6 +196,9 @@ def _app(project_repo: FakeProjectRepo, job_repo: FakeJobRepo) -> FastAPI: app.dependency_overrides[get_session] = lambda: _FakeSession() app.dependency_overrides[get_session_factory] = lambda: _session_factory app.dependency_overrides[get_chain_gateway] = lambda: _FakeChainGateway(conflicts=[]) + app.dependency_overrides[get_chain_gateway_builder] = lambda: _async_returning( + _FakeChainGateway(conflicts=[]) + ) saver = MemorySaver() app.dependency_overrides[get_checkpointer_factory] = lambda: lambda: _memsaver_ctx(saver) app.dependency_overrides[get_digest_gateway_builder] = lambda: lambda _s: None @@ -363,6 +380,56 @@ async def test_resume_chain_job_not_found_returns_404( assert resp.status_code == 404 +async def test_resume_chain_wrong_project_returns_404( + _noop_run_chain_job: list[dict[str, Any]], +) -> None: + """所有权校验(审评 #4):job 属项目 A,从项目 B 路径 resume → 404(同案,不枚举)。""" + project_repo = FakeProjectRepo() + job_repo = FakeJobRepo() + pid_a = await _seed_project(project_repo) + pid_b = uuid.UUID( + str((await project_repo.create(PROJECT_OWNER, ProjectCreate(title="作品B"))).id) + ) + job = await job_repo.create(pid_a, "chain") # job 属 A + await job_repo.set_awaiting(job.id, {"awaiting_chapter": 1}) + app = _app(project_repo, job_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid_b}/chains/runs/{job.id}/resume", # 从 B 续 A 的 job + json={"decisions": []}, + ) + + assert resp.status_code == 404 + assert len(_noop_run_chain_job) == 0 # 未调度 + + +async def test_resume_chain_concurrent_claim_returns_409( + _noop_run_chain_job: list[dict[str, Any]], +) -> None: + """原子抢占(审评 #3):第一个 resume 已把 awaiting→running,第二个抢不到 → 409。""" + project_repo = FakeProjectRepo() + job_repo = FakeJobRepo() + pid = await _seed_project(project_repo) + job = await job_repo.create(pid, "chain") + await job_repo.set_awaiting(job.id, {"awaiting_chapter": 1}) + # 模拟并发:第一个 resume 已抢占 awaiting→running。 + claimed = await job_repo.claim_awaiting_to_running(job.id) + assert claimed is not None + app = _app(project_repo, job_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/chains/runs/{job.id}/resume", + json={"decisions": []}, + ) + + # job 现态 running(非 awaiting)→ 守卫先在 status 检查处 409(CONFLICT)。 + assert resp.status_code == 409 + assert resp.json()["error"]["code"] == "CONFLICT" + assert len(_noop_run_chain_job) == 0 + + # ---- 服务层 run_chain_job(直接 await,注入全 fake;job 状态由 fake SqlJobRepo 替身记录)---- @@ -440,7 +507,7 @@ async def test_run_chain_job_no_conflict_completes_done(_patched_runner: None) - chain_key="draft_volume", start_chapter_no=1, count=2, - gateway=gateway, + chain_gateway_builder=_async_returning(gateway), checkpointer_ctx=lambda: _memsaver_ctx(saver), accept_op=fakes["accept_op"], chapter_repo_factory=fakes["chapter_repo_factory"], @@ -476,7 +543,7 @@ async def test_run_chain_job_conflict_sets_awaiting_then_resume_done( chain_key="draft_volume", start_chapter_no=1, count=1, - gateway=gateway, + chain_gateway_builder=_async_returning(gateway), checkpointer_ctx=lambda: _memsaver_ctx(saver), accept_op=fakes["accept_op"], chapter_repo_factory=fakes["chapter_repo_factory"], @@ -498,7 +565,7 @@ async def test_run_chain_job_conflict_sets_awaiting_then_resume_done( chain_key="draft_volume", start_chapter_no=1, count=1, - gateway=gateway, + chain_gateway_builder=_async_returning(gateway), checkpointer_ctx=lambda: _memsaver_ctx(saver), accept_op=fakes["accept_op"], chapter_repo_factory=fakes["chapter_repo_factory"], @@ -532,7 +599,7 @@ async def test_run_chain_job_error_marks_failed_safely(_patched_runner: None) -> chain_key="draft_volume", start_chapter_no=1, count=1, - gateway=_BoomGateway(), + chain_gateway_builder=_async_returning(_BoomGateway()), checkpointer_ctx=lambda: _memsaver_ctx(MemorySaver()), accept_op=fakes["accept_op"], chapter_repo_factory=fakes["chapter_repo_factory"], diff --git a/apps/api/ww_api/routers/chain.py b/apps/api/ww_api/routers/chain.py index 738cdf2..73ceea3 100644 --- a/apps/api/ww_api/routers/chain.py +++ b/apps/api/ww_api/routers/chain.py @@ -28,6 +28,7 @@ from ww_shared import AppError, ErrorCode, ErrorEnvelope from ww_api.logging_config import get_logger from ww_api.schemas.chain import ( + ChainResumeAccepted, ChainResumeRequest, ChainRunAccepted, ChainRunRequest, @@ -44,8 +45,10 @@ from ww_api.services.chain_runner import ( from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.foreshadow_scan import SessionFactory from ww_api.services.project_deps import ( + GatewayChainBuilder, GatewayDigestBuilder, get_chain_gateway, + get_chain_gateway_builder, get_digest_gateway_builder, get_job_repo, get_project_repo, @@ -66,6 +69,7 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)] SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)] CheckpointerFactoryDep = Annotated[CheckpointerFactory, Depends(get_checkpointer_factory)] DigestBuilderDep = Annotated[GatewayDigestBuilder, Depends(get_digest_gateway_builder)] +ChainBuilderDep = Annotated[GatewayChainBuilder, Depends(get_chain_gateway_builder)] _RUN_ERRORS: dict[int | str, dict[str, Any]] = { 404: {"model": ErrorEnvelope, "description": "项目或链 key 不存在"}, @@ -97,11 +101,12 @@ async def run_chain( background_tasks: BackgroundTasks, project_repo: ProjectRepoDep, job_repo: JobRepoDep, - gateway: ChainGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503) + gateway: ChainGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503);不用于实际 LLM 调用 session: SessionDep, session_factory: SessionFactoryDep, checkpointer_factory: CheckpointerFactoryDep, digest_gateway_builder: DigestBuilderDep, + chain_gateway_builder: ChainBuilderDep, ) -> ChainRunAccepted: """发起多章链:写一行 job 返 202,链经 BackgroundTask 异步跑。 @@ -132,7 +137,7 @@ async def run_chain( chain_key=chain_key, start_chapter_no=body.start_chapter_no, count=body.count, - gateway=gateway, + chain_gateway_builder=chain_gateway_builder, checkpointer_ctx=checkpointer_factory, accept_op=accept_op, chapter_repo_factory=_chapter_repo_factory, @@ -172,22 +177,26 @@ async def resume_chain( background_tasks: BackgroundTasks, project_repo: ProjectRepoDep, job_repo: JobRepoDep, - gateway: ChainGatewayDep, + gateway: ChainGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503);不用于实际 LLM 调用 session: SessionDep, session_factory: SessionFactoryDep, checkpointer_factory: CheckpointerFactoryDep, digest_gateway_builder: DigestBuilderDep, -) -> ChainRunAccepted: + chain_gateway_builder: ChainBuilderDep, +) -> ChainResumeAccepted: """裁决续跑:仅当 job=awaiting_input,带裁决经 BackgroundTask resume。 - 项目不存在 → 404;job 不存在 → 404;非 awaiting 态 → 409(CONFLICT)。 - resume 经 `Command(resume=decisions)` 从 interrupt 续跑(同 thread_id=job_id)。 + 项目不存在 → 404;job 不存在 / 不属于该项目 → 404(同案,不枚举);非 awaiting 态 → + 409(CONFLICT)。`awaiting→running` 在 HTTP 处理器内**原子抢占**(条件 UPDATE),避免两个 + 并发 resume 都过守卫后双 `Command(resume=...)` 损坏图(审评 #3);抢不到(已被并发抢走/非 + awaiting)→ 409。resume 经 `Command(resume=decisions)` 从 interrupt 续跑(同 thread_id)。 """ request_id = getattr(request.state, "request_id", None) await _require_project(project_repo, project_id) job = await job_repo.get(job_id) - if job is None: + # 所有权校验(审评 #4):job 不存在 **或** 不属于该项目 → 404(同案,避免跨项目枚举 job)。 + if job is None or job.project_id != project_id: raise AppError(ErrorCode.NOT_FOUND, f"job not found: {job_id}") if job.status != STATUS_AWAITING: raise AppError( @@ -196,6 +205,17 @@ async def resume_chain( {"job_status": job.status}, ) + # 原子抢占 awaiting→running(审评 #3):抢不到(并发竞态/已非 awaiting)→ 409,绝不调度。 + claimed = await job_repo.claim_awaiting_to_running(job_id) + await session.commit() # 抢占须在 202 返回前持久化(防并发 resume 双调度)。 + if claimed is None: + raise AppError( + ErrorCode.CONFLICT, + f"job {job_id} 已被并发续跑抢占或非 awaiting_input 态,不可重复续跑", + ) + + chain_key = claimed.kind if claimed.kind in SUPPORTED_CHAINS else "draft_volume" + accept_op = build_accept_op( session_factory=session_factory, digest_gateway_builder=digest_gateway_builder, @@ -207,10 +227,10 @@ async def resume_chain( job_id, project_id=project_id, user_id=STUB_OWNER_ID, - chain_key=job.kind if job.kind in SUPPORTED_CHAINS else "draft_volume", + chain_key=chain_key, start_chapter_no=1, # resume 不重置区间:图从检查点续,初值不再使用。 count=1, - gateway=gateway, + chain_gateway_builder=chain_gateway_builder, checkpointer_ctx=checkpointer_factory, accept_op=accept_op, chapter_repo_factory=_chapter_repo_factory, @@ -227,12 +247,7 @@ async def resume_chain( decision_count=len(body.decisions), ) response.status_code = 202 - return ChainRunAccepted( - job_id=job_id, - chain_key="draft_volume", - start_chapter_no=0, - count=0, - ) + return ChainResumeAccepted(job_id=job_id, chain_key=chain_key) def _chapter_repo_factory(session: AsyncSession) -> object: diff --git a/apps/api/ww_api/schemas/chain.py b/apps/api/ww_api/schemas/chain.py index f3d047c..f382fda 100644 --- a/apps/api/ww_api/schemas/chain.py +++ b/apps/api/ww_api/schemas/chain.py @@ -52,3 +52,13 @@ class ChainResumeRequest(BaseModel): default_factory=list, description="对 awaiting 章冲突的裁决清单(采纳/忽略/手改)", ) + + +class ChainResumeAccepted(BaseModel): + """resume 受理回执(202):job_id + chain_key(前端走 `GET /jobs/{id}` 轮询续跑进度)。 + + 续跑无独立区间(图从检查点续),故不回显 start/count(会是无意义哨兵值)——只回 job 标识。 + """ + + job_id: uuid.UUID + chain_key: str diff --git a/apps/api/ww_api/services/chain_runner.py b/apps/api/ww_api/services/chain_runner.py index c04cadc..eda1dce 100644 --- a/apps/api/ww_api/services/chain_runner.py +++ b/apps/api/ww_api/services/chain_runner.py @@ -32,7 +32,7 @@ from ww_core.domain.job_repo import SqlJobRepo from ww_core.domain.review_repo import SqlReviewRepo from ww_core.memory import assemble from ww_core.memory.sql_repositories import sql_memory_repos -from ww_core.orchestrator import REVIEW_SPECS, GatewayRun +from ww_core.orchestrator import REVIEW_SPECS from ww_core.orchestrator.chain import build_chain_graph, initial_chain_state from ww_llm_gateway import Gateway @@ -54,6 +54,10 @@ JOB_KIND_CHAIN = "chain" # 「按 session 建 digest 网关」缝:accept 节点在自建短事务里需 light 档网关跑终稿提炼。 DigestGatewayBuilder = Callable[[Any], Awaitable[Gateway]] +# 「按 session 建链网关」缝:write/review 节点在自建短事务里现建网关(ledger 绑节点 session, +# 不变量 #1)。BackgroundTask 跑时请求 session 已关闭,故必须用节点 session 重建网关。 +GatewayChainBuilder = Callable[[Any], Awaitable[Gateway]] + def build_accept_op( *, @@ -178,7 +182,7 @@ async def run_chain_job( chain_key: str, start_chapter_no: int, count: int, - gateway: GatewayRun, + chain_gateway_builder: GatewayChainBuilder, checkpointer_ctx: CheckpointerCtx, accept_op: AcceptOp, chapter_repo_factory: ChapterRepoFactory, @@ -193,6 +197,10 @@ async def run_chain_job( 否则 resume(`graph.ainvoke(Command(resume=decisions), config)`),从 interrupt 续跑。 `thread_id = str(job_id)`:同 job 的初始/续跑落同一检查点 thread。 + `chain_gateway_builder`(非具体网关实例):write/review 节点在自建短事务的**新鲜** session + 上现建网关,使 usage_ledger 绑节点 session(不变量 #1)。**绝不**把请求阶段建的网关传进来—— + BackgroundTask 跑时请求 session 已关闭,其 ledger.record() 会落到死 session 上被静默丢弃。 + checkpointer 上下文(Postgres 连接 / MemorySaver)横跨整次 invoke;本 runner 在 task 内打开/关闭它。异常一律被吞(后台任务边界),脱敏后置 job failed,不冒泡崩进程。 """ @@ -201,7 +209,7 @@ async def run_chain_job( await _set_running(session_factory, job_id) config: RunnableConfig = {"configurable": {"thread_id": str(job_id)}} graph = build_chain_graph( - gateway, + chain_gateway_builder, session_factory=session_factory, memory_repos_factory=sql_memory_repos, chapter_repo_factory=chapter_repo_factory, @@ -236,8 +244,16 @@ async def run_chain_job( written_count=len(result["written"]), ) except Exception as exc: # noqa: BLE001 — 后台任务边界:记错误 + 置 job failed,不冒泡。 - log.error("chain_job_failed", job_id=str(job_id), request_id=request_id, error=str(exc)) - await _mark_failed(session_factory, job_id, _classify_job_error(exc), request_id) + # 日志只记脱敏文案 + 异常类型;绝不记 str(exc)(可能含 API key/连接串/LLM 输出,设计 §5)。 + stored_error = _classify_job_error(exc) + log.error( + "chain_job_failed", + job_id=str(job_id), + request_id=request_id, + exc_type=type(exc).__name__, + error=stored_error, + ) + await _mark_failed(session_factory, job_id, stored_error, request_id) async def _set_running(session_factory: SessionFactory, job_id: uuid.UUID) -> None: @@ -276,11 +292,12 @@ async def _mark_failed( await SqlJobRepo(session).fail(job_id, error) await session.commit() except Exception as exc: # noqa: BLE001 — 置失败态本身再炸只记日志,不冒泡。 + # 同 §5:不记 str(exc),只记异常类型(避免泄露内部细节)。 log.error( "chain_job_fail_mark_failed", job_id=str(job_id), request_id=request_id, - error=str(exc), + exc_type=type(exc).__name__, ) @@ -289,6 +306,7 @@ __all__ = [ "AcceptOp", "CheckpointerCtx", "DigestGatewayBuilder", + "GatewayChainBuilder", "build_accept_op", "run_chain_job", ] diff --git a/apps/api/ww_api/services/project_deps.py b/apps/api/ww_api/services/project_deps.py index befc0eb..3c8af1e 100644 --- a/apps/api/ww_api/services/project_deps.py +++ b/apps/api/ww_api/services/project_deps.py @@ -530,3 +530,27 @@ def get_digest_gateway_builder() -> GatewayDigestBuilder: `app.dependency_overrides[get_digest_gateway_builder]` 注返回 mock 的 builder(绝不联网)。 """ return _digest_gateway_builder + + +# 「按 session 建链网关」缝类型(链 write/review 节点在 BackgroundTask 自建短事务的 session 建)。 +GatewayChainBuilder = Callable[[AsyncSession], Awaitable[Gateway]] + + +def _chain_gateway_builder(session: AsyncSession) -> Awaitable[Gateway]: + """链 write/review 节点在自建短事务里建按请求 tier 分派的链网关。 + + 关键(守不变量 #1):网关的 `SqlAlchemyLedgerSink` 必须绑**节点当前的** session—— + BackgroundTask 跑时请求 session 已关闭,故不能复用请求阶段的网关,必须按节点新 session 现建, + 否则 `gateway.run()` 的 `usage_ledger` 写会落到已死 session 上被静默丢弃(成本记账断裂)。 + """ + return build_chain_gateway(session, SqlCredentialStore(session)) + + +def get_chain_gateway_builder() -> GatewayChainBuilder: + """返回「按 session 建链网关」的缝(链 write/review 节点在 BackgroundTask 内现建网关用)。 + + 与 `get_chain_gateway`(请求阶段凭据探测,返单实例,仅做 503 拦截)不同:本缝供 + `run_chain_job` 在节点自建的**新鲜** session 上重建网关,使 usage_ledger 落到活 session。 + 测试经 `app.dependency_overrides[get_chain_gateway_builder]` 注返 mock 的 builder(绝不联网)。 + """ + return _chain_gateway_builder diff --git a/packages/core/tests/test_chain_graph.py b/packages/core/tests/test_chain_graph.py index e52d035..f004e31 100644 --- a/packages/core/tests/test_chain_graph.py +++ b/packages/core/tests/test_chain_graph.py @@ -184,6 +184,10 @@ def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]: draft_text="第 N 章正文。", conflicts=conflicts, by_schema=_empty_by_schema() ) + async def gateway_builder(session: Any) -> Any: + """按节点 session 建网关的替身(不变量 #1):忽略 session,恒返同一 mock 网关。""" + return gateway + def memory_repos_factory(session: Any) -> Any: return object() # assemble 被 fake 替换,不实际用 repos @@ -207,6 +211,7 @@ def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]: return { "gateway": gateway, + "gateway_builder": gateway_builder, "session_factory": _session_factory, "memory_repos_factory": memory_repos_factory, "chapter_repo_factory": chapter_repo_factory, @@ -221,7 +226,7 @@ def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]: def _build(h: dict[str, Any], checkpointer: Any) -> Any: return build_chain_graph( - h["gateway"], + h["gateway_builder"], session_factory=h["session_factory"], memory_repos_factory=h["memory_repos_factory"], chapter_repo_factory=h["chapter_repo_factory"], @@ -317,7 +322,7 @@ async def test_write_chapter_saves_collected_draft() -> None: out = await write_chapter( state, - gateway=h["gateway"], + gateway_builder=h["gateway_builder"], session_factory=h["session_factory"], memory_repos_factory=h["memory_repos_factory"], chapter_repo_factory=h["chapter_repo_factory"], diff --git a/packages/core/tests/test_job_repo.py b/packages/core/tests/test_job_repo.py index 1c32866..14a4543 100644 --- a/packages/core/tests/test_job_repo.py +++ b/packages/core/tests/test_job_repo.py @@ -100,6 +100,14 @@ class FakeJobRepo: row.result = dict(result) return _view(row) + async def claim_awaiting_to_running(self, job_id: uuid.UUID) -> JobView | None: + """原子抢占 awaiting→running(内存替身:仅当现态 awaiting 才抢到,否则 None)。""" + row = next((r for r in self.rows if r.id == job_id), None) + if row is None or row.status != STATUS_AWAITING: + return None + row.status = STATUS_RUNNING + return _view(row) + async def fail(self, job_id: uuid.UUID, error: str) -> JobView: row = self._require(job_id) row.status = STATUS_FAILED @@ -173,6 +181,39 @@ async def test_fail_sets_failed_and_error() -> None: assert failed.error == "boom" +# ---- 原子抢占 awaiting → running(防并发 resume 竞态,审评 #3)---- + + +async def test_claim_awaiting_to_running_succeeds_once() -> None: + repo: JobRepo = _repo() + job = await repo.create(PROJECT, KIND) + await repo.set_awaiting(job.id, {"awaiting_chapter": 1}) + claimed = await repo.claim_awaiting_to_running(job.id) + assert claimed is not None + assert claimed.status == STATUS_RUNNING + + +async def test_claim_awaiting_second_call_returns_none() -> None: + repo: JobRepo = _repo() + job = await repo.create(PROJECT, KIND) + await repo.set_awaiting(job.id, {"awaiting_chapter": 1}) + first = await repo.claim_awaiting_to_running(job.id) + second = await repo.claim_awaiting_to_running(job.id) # 已 running → 抢不到 + assert first is not None + assert second is None + + +async def test_claim_non_awaiting_returns_none() -> None: + repo: JobRepo = _repo() + job = await repo.create(PROJECT, KIND) # queued(非 awaiting) + assert await repo.claim_awaiting_to_running(job.id) is None + + +async def test_claim_absent_job_returns_none() -> None: + repo: JobRepo = _repo() + assert await repo.claim_awaiting_to_running(uuid.uuid4()) is None + + # ---- progress (clamped) ---- diff --git a/packages/core/ww_core/domain/job_repo.py b/packages/core/ww_core/domain/job_repo.py index bdfcfb3..34c64c6 100644 --- a/packages/core/ww_core/domain/job_repo.py +++ b/packages/core/ww_core/domain/job_repo.py @@ -39,6 +39,7 @@ class JobView(BaseModel): model_config = {"frozen": True} id: uuid.UUID + project_id: uuid.UUID | None = None kind: str status: str progress: int = 0 @@ -69,6 +70,11 @@ class JobRepo(Protocol): """置 status=awaiting_input, result=(多章链 interrupt 暂停等裁决;chain §5/§7)。""" ... + async def claim_awaiting_to_running(self, job_id: uuid.UUID) -> JobView | None: + """**原子**抢占 awaiting_input → running,返回抢到的 JobView;未抢到(不存在/非 + awaiting/已被并发抢走)返 None。供 resume 端点防并发 resume 竞态(chain §3/审评 #3)。""" + ... + async def fail(self, job_id: uuid.UUID, error: str) -> JobView: """置 status=failed, error=。""" ... @@ -85,6 +91,7 @@ class JobRepo(Protocol): def _to_view(row: Job) -> JobView: return JobView( id=row.id, + project_id=row.project_id, kind=row.kind, status=row.status, progress=row.progress, @@ -158,6 +165,34 @@ class SqlJobRepo: await self._s.refresh(row) return _to_view(row) + async def claim_awaiting_to_running(self, job_id: uuid.UUID) -> JobView | None: + """原子 `awaiting_input → running`(条件 UPDATE,returning 行)。 + + 防并发 resume 竞态(审评 #3):两个并发 resume 仅一个能把 awaiting→running,另一个 + UPDATE 命中 0 行 → 返 None(端点据此返 409)。避免「两个 resume 都过 awaiting 守卫 → + 同 thread_id 双 `Command(resume=...)` → 图损坏」。提交交调用方(端点事务)。 + """ + result = await self._s.execute( + update(Job) + .where(Job.id == job_id, Job.status == STATUS_AWAITING) + .values(status=STATUS_RUNNING) + .returning( + Job.id, Job.project_id, Job.kind, Job.status, Job.progress, Job.result, Job.error + ) + ) + row = result.one_or_none() + if row is None: + return None + return JobView( + id=row.id, + project_id=row.project_id, + kind=row.kind, + status=row.status, + progress=row.progress, + result=row.result, + error=row.error, + ) + async def fail(self, job_id: uuid.UUID, error: str) -> JobView: row = await self._require(job_id) row.status = STATUS_FAILED diff --git a/packages/core/ww_core/orchestrator/chain/__init__.py b/packages/core/ww_core/orchestrator/chain/__init__.py index 1b44738..ca448c0 100644 --- a/packages/core/ww_core/orchestrator/chain/__init__.py +++ b/packages/core/ww_core/orchestrator/chain/__init__.py @@ -20,6 +20,7 @@ from .nodes import ( AssembleContext, AssembleFn, ChapterDraftRepo, + GatewayBuilder, MemoryReposFactory, ReviewRecordRepo, SessionFactory, @@ -41,6 +42,7 @@ __all__ = [ "AssembleFn", "ChainState", "ChapterDraftRepo", + "GatewayBuilder", "MemoryReposFactory", "ReviewRecordRepo", "SessionFactory", diff --git a/packages/core/ww_core/orchestrator/chain/graph.py b/packages/core/ww_core/orchestrator/chain/graph.py index 40b0383..3f911cf 100644 --- a/packages/core/ww_core/orchestrator/chain/graph.py +++ b/packages/core/ww_core/orchestrator/chain/graph.py @@ -22,13 +22,13 @@ from langgraph.graph import END, START, StateGraph from langgraph.types import interrupt from ww_agents import AgentSpec -from .._protocols import GatewayRun from ..graph import REVIEW_SPECS from . import nodes from .nodes import ( AcceptChapterOp, AssembleFn, ChapterDraftRepo, + GatewayBuilder, MemoryReposFactory, ReviewRecordRepo, SessionFactory, @@ -46,7 +46,7 @@ ACCEPT_CHAPTER = "accept_chapter" def build_chain_graph( - gateway: GatewayRun, + gateway_builder: GatewayBuilder, *, session_factory: SessionFactory, memory_repos_factory: MemoryReposFactory, @@ -60,7 +60,9 @@ def build_chain_graph( """构建并编译 `draft_volume` 多章链图。 节点(`write_chapter`/`review_chapter`/`decide`/`accept_chapter`)经默认参绑定把 - `gateway`/`session_factory`/各 repo 工厂/`assemble`/`accept_op` 闭包绑入。 + `gateway_builder`/`session_factory`/各 repo 工厂/`assemble`/`accept_op` 闭包绑入。 + `gateway_builder` 而非具体网关实例:write/review 节点在自建 session 上现建网关,使 + usage_ledger 绑节点 session(不变量 #1,修复请求网关 session 已关闭致记账丢失的缺陷)。 条件边:`decide` 命中冲突 → `accept_chapter` 内 `interrupt()` 暂停交人; `accept_chapter` 后判 `current_chapter_no <= last_chapter_no` 回 `write_chapter` 或 END。 @@ -70,7 +72,7 @@ def build_chain_graph( async def _write(state: ChainState) -> dict[str, Any]: return await nodes.write_chapter( state, - gateway=gateway, + gateway_builder=gateway_builder, session_factory=session_factory, memory_repos_factory=memory_repos_factory, chapter_repo_factory=chapter_repo_factory, @@ -80,7 +82,7 @@ def build_chain_graph( async def _review(state: ChainState) -> dict[str, Any]: return await nodes.review_chapter( state, - gateway=gateway, + gateway_builder=gateway_builder, session_factory=session_factory, memory_repos_factory=memory_repos_factory, chapter_repo_factory=chapter_repo_factory, diff --git a/packages/core/ww_core/orchestrator/chain/nodes.py b/packages/core/ww_core/orchestrator/chain/nodes.py index 2ac69ba..2926c94 100644 --- a/packages/core/ww_core/orchestrator/chain/nodes.py +++ b/packages/core/ww_core/orchestrator/chain/nodes.py @@ -19,7 +19,7 @@ import structlog from ww_agents import AgentSpec from .._protocols import GatewayRun -from ..collect import collect_reviews, extract_conflicts +from ..collect import collect_reviews from ..review_node import build_review_context, run_review from ..state import ChapterState from ..write_node import build_write_request @@ -32,6 +32,11 @@ log = structlog.get_logger(__name__) #: `session -> Repo`:节点自建短事务里从 session 造 repo(仿端点依赖工厂)。 SessionFactory = Callable[[], AbstractAsyncContextManager[Any]] +#: `session -> Gateway`:按**节点当前 session** 建网关(不变量 #1)。 +#: 关键:网关的 ledger sink 绑节点 session,节点末尾 `commit()` 一并持久 usage_ledger 行—— +#: 绝不能复用请求阶段建好的网关(其 session 在 BackgroundTask 跑时已关闭,记账行会被静默丢弃)。 +GatewayBuilder = Callable[[Any], Awaitable[GatewayRun]] + class AssembleContext(Protocol): """`assemble` 产出的最小读形——只需 stable_core/volatile(构造写章/审稿请求)。""" @@ -103,7 +108,7 @@ AcceptChapterOp = Callable[..., Awaitable[None]] async def write_chapter( state: ChainState, *, - gateway: GatewayRun, + gateway_builder: GatewayBuilder, session_factory: SessionFactory, memory_repos_factory: MemoryReposFactory, chapter_repo_factory: Callable[[Any], ChapterDraftRepo], @@ -114,11 +119,15 @@ async def write_chapter( 短事务、独立 session(不跨章共享)。state 不变(正文在 DB)。返回空增量。 收集版写章复用 `build_write_request` 同一 prompt 组装路径(stream=False → `gateway.run`), 守不变量 #9(stable_core 进缓存前缀);不确定性锁在网关后,注 mock 即可单测。 + + 网关经 `gateway_builder(session)` 按**本节点 session** 现建(不变量 #1):其 ledger sink + 绑此 session,末尾 `commit()` 一并持久 usage_ledger——绝不复用请求阶段已关闭 session 的网关。 """ project_id = state["project_id"] chapter_no = state["current_chapter_no"] user_id = state["user_id"] async with session_factory() as session: + gateway = await gateway_builder(session) repos = memory_repos_factory(session) context = await assemble(repos, project_id, chapter_no) req = build_write_request( @@ -145,7 +154,7 @@ async def write_chapter( async def review_chapter( state: ChainState, *, - gateway: GatewayRun, + gateway_builder: GatewayBuilder, session_factory: SessionFactory, memory_repos_factory: MemoryReposFactory, chapter_repo_factory: Callable[[Any], ChapterDraftRepo], @@ -157,11 +166,15 @@ async def review_chapter( 复用现有 `run_review × specs` + `collect_reviews`(直接顺序跑各审,非起子图——链节点本身 已是图节点,无需嵌套图)。从 DB 重读本章草稿构审稿上下文(真相在表,不信 state)。 + + 网关经 `gateway_builder(session)` 按**本节点 session** 现建(不变量 #1):四审各调的 + usage_ledger 行随节点末尾 `commit()` 一并持久——绝不复用请求阶段已关闭 session 的网关。 """ project_id = state["project_id"] chapter_no = state["current_chapter_no"] user_id = state["user_id"] async with session_factory() as session: + gateway = await gateway_builder(session) repos = memory_repos_factory(session) context = await assemble(repos, project_id, chapter_no) chapter_repo = chapter_repo_factory(session) @@ -302,9 +315,9 @@ __all__ = [ "MemoryReposFactory", "ReviewRecordRepo", "SessionFactory", + "GatewayBuilder", "accept_chapter", "decide", - "extract_conflicts", "has_conflicts", "review_chapter", "write_chapter", diff --git a/tests/test_chain_workflow_e2e.py b/tests/test_chain_workflow_e2e.py index 410f570..21ee9c7 100644 --- a/tests/test_chain_workflow_e2e.py +++ b/tests/test_chain_workflow_e2e.py @@ -217,12 +217,15 @@ def _build_app(adapter: _FakeChainAdapter, saver: MemorySaver, e2e_sm: Any) -> A from ww_api.services.chain_deps import get_checkpointer_factory from ww_api.services.project_deps import ( get_chain_gateway, + get_chain_gateway_builder, get_digest_gateway_builder, get_session_factory, ) app = create_app() app.dependency_overrides[get_chain_gateway] = _chain_gateway_override(adapter) + # 链 write/review 节点经 builder 在节点 session 上现建网关(ledger 绑节点 session,不变量 #1)。 + app.dependency_overrides[get_chain_gateway_builder] = _digest_builder_override(adapter) app.dependency_overrides[get_session_factory] = lambda: e2e_sm app.dependency_overrides[get_checkpointer_factory] = _memsaver_override(saver) app.dependency_overrides[get_digest_gateway_builder] = _digest_builder_override(adapter) @@ -302,6 +305,35 @@ async def test_chain_two_chapters_no_conflict_full_auto( ) assert [d.chapter_no for d in digests] == [1, 2] + # 回归守卫(审评 #1):链每次 gateway.run 都应落 usage_ledger 行——证明网关 ledger + # 绑节点活 session、节点 commit 持久化。 + # 修复前:请求网关 session 已关闭,行被静默丢弃。 + ledger_rows = ( + ( + await verify.execute( + select(UsageLedger).where(UsageLedger.project_id == project_uuid) + ) + ) + .scalars() + .all() + ) + # 两章 × (1 写 + 四审 + 1 digest);至少应有若干条,绝不为空。 + assert len(ledger_rows) > 0, "chain usage_ledger 行不应为空(成本记账断裂回归守卫)" + + # 每审过一章应在 chapter_reviews 留痕(不变量 #3 只读留痕;审评 #7b)。 + review_rows = ( + ( + await verify.execute( + select(ChapterReview) + .where(ChapterReview.project_id == project_uuid) + .order_by(ChapterReview.chapter_no) + ) + ) + .scalars() + .all() + ) + assert {r.chapter_no for r in review_rows} == {1, 2} + job_rows = ( (await verify.execute(select(Job).where(Job.project_id == project_uuid))) .scalars()