feat(api): C2 多章链 服务+端点+schema+checkpointer 接线

承 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) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-06-23 17:12:53 +02:00
parent 7f3eaaba3d
commit 29349dc7ee
15 changed files with 1365 additions and 1 deletions

View File

@@ -313,9 +313,24 @@
---
## C-Chain · 多章工作流链端点 + 服务层 owner @backend / @llm(图) 状态: 稳定C2, 2026-06-23
> 设计真源:`docs/design/chain-workflow.md`。C1(@llm) 图签名 §3.3C2(@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_key404 `draft_volume`count 越界422schema 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 DDLawaiting 章经 `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-ChainC23 端点 + `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 POSTGET /jobs 复用)。门禁绿ruff/format/mypy(193) 干净 + pytest 600 passed + alembic 无漂移。→ 影响 @frontendfollow-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 再扩 C3B0 **可控版**接上条新增 **`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 步进器)。

View File

@@ -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"` 同理零迁移。唯一新增 DDLlanggraph 检查点表)归 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资源状态冲突

View File

@@ -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` 全仓再补