merge: 多章工作流链(LangGraph cyclic 链图 + interrupt-on-conflict + checkpointer)

逐学科多 agent 实施 + 3 路对抗交叉评审 + 修复 1 CRITICAL/5 HIGH。
后端门禁绿:mypy 194 · alembic 无漂移 · pytest 608 passed。
This commit is contained in:
Yaojia Wang
2026-06-23 18:27:16 +02:00
27 changed files with 3330 additions and 60 deletions

View File

@@ -111,6 +111,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` > 格式:`- [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/#4token 不入 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/无网络/无真 LLMrun→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。**唯一新增 DDLlanggraph 检查点表)= 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 specbook-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/#9system_prompt 进缓存前块)。**"加生成器=加一份声明" 经实证8 个新生成器复用同一通用执行器 + 同一前端组件)。** **实景 browse 复验(工具箱卡片栅格→脑洞预览→词条入库)待做。** 后续可选 P5封面图像轨道 / 自定义 skill 运行期 schema / 收敛已有 3 竖井。 - [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 specbook-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/#9system_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计划 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``<lg` 触达目录/助手。**实景 390px 复验browse**:汉堡显示 + 静态导航 `display:none`、抽屉开合(聚焦 + Esc 关闭、工具条两行无横向溢出、助手抽屉真注入面板。前端门禁绿lint/typecheck 干净 · vitest **177**(171→+6) · build OK。无后端/契约变更。文件:`lib/nav/{items,items.test}.ts`(新) · `components/{Drawer,NavDrawer,NavItems}.tsx`(新) · `components/{AppShell,LeftNav}.tsx` · `components/workbench/{Workbench,ChapterList,ChapterAssistant}.tsx`。**UX 计划余项**B0 可控版(PUT override) + F1 控件 + 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``<lg` 触达目录/助手。**实景 390px 复验browse**:汉堡显示 + 静态导航 `display:none`、抽屉开合(聚焦 + Esc 关闭、工具条两行无横向溢出、助手抽屉真注入面板。前端门禁绿lint/typecheck 干净 · vitest **177**(171→+6) · build OK。无后端/契约变更。文件:`lib/nav/{items,items.test}.ts`(新) · `components/{Drawer,NavDrawer,NavItems}.tsx`(新) · `components/{AppShell,LeftNav}.tsx` · `components/workbench/{Workbench,ChapterList,ChapterAssistant}.tsx`。**UX 计划余项**B0 可控版(PUT override) + F1 控件 + R1/R2/R3 审稿人体学 + Tier4。

View File

@@ -23,6 +23,7 @@ from ww_core.domain.foreshadow_state import (
) )
from ww_core.domain.job_repo import ( from ww_core.domain.job_repo import (
PROGRESS_COMPLETE, PROGRESS_COMPLETE,
STATUS_AWAITING,
STATUS_DONE, STATUS_DONE,
STATUS_FAILED, STATUS_FAILED,
STATUS_QUEUED, STATUS_QUEUED,
@@ -413,7 +414,13 @@ class FakeJobRepo:
self.rows: dict[uuid.UUID, JobView] = {} self.rows: dict[uuid.UUID, JobView] = {}
async def create(self, project_id: uuid.UUID | None, kind: str) -> 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 self.rows[view.id] = view
return view return view
@@ -434,6 +441,22 @@ class FakeJobRepo:
self.rows[job_id] = view self.rows[job_id] = view
return view return view
async def set_awaiting(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
view = self.rows[job_id].model_copy(
update={"status": STATUS_AWAITING, "result": dict(result)}
)
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: async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
view = self.rows[job_id].model_copy(update={"status": STATUS_FAILED, "error": error}) view = self.rows[job_id].model_copy(update={"status": STATUS_FAILED, "error": error})
self.rows[job_id] = view self.rows[job_id] = view

View File

@@ -0,0 +1,653 @@
"""C2 多章链端点 + 服务层单测chain-workflow §6/§10——内存替身无 DB/无网络/无真 LLM。
覆盖:
- 端点契约HTTP 层,`run_chain_job` 被 no-op 替换以隔离调度逻辑):
run → 202 `{job_id,...}`resume → 202未知 chain_key → 404count 越界(>50 / 0→ 422
resume 非 awaiting 态 → 409项目不存在 → 404resume job 不存在 → 404。
- 服务层 `run_chain_job`mock 网关 + MemorySaver + fake session_factory + fake accept_op
无冲突全跑 → job done + result.written 满;有冲突 → interrupt → job awaiting_input
resume 续跑 → donetoken 绝不入 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:
"""链网关 mockwrite无 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
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 章"
# ---- 端点契约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_chain_gateway_builder,
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=[])
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
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
async def test_resume_chain_wrong_project_returns_404(
_noop_run_chain_job: list[dict[str, Any]],
) -> None:
"""所有权校验(审评 #4job 属项目 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 检查处 409CONFLICT
assert resp.status_code == 409
assert resp.json()["error"]["code"] == "CONFLICT"
assert len(_noop_run_chain_job) == 0
# ---- 服务层 run_chain_job直接 await注入全 fakejob 状态由 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,
chain_gateway_builder=_async_returning(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,
chain_gateway_builder=_async_returning(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,
chain_gateway_builder=_async_returning(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,
chain_gateway_builder=_async_returning(_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

View File

@@ -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.logging_config import configure_logging, get_logger
from ww_api.middleware import REQUEST_ID_HEADER, request_id_middleware from ww_api.middleware import REQUEST_ID_HEADER, request_id_middleware
from ww_api.routers import ( from ww_api.routers import (
chain,
foreshadow, foreshadow,
generation, generation,
health, health,
@@ -117,6 +118,7 @@ def create_app() -> FastAPI:
app.include_router(generation.router) app.include_router(generation.router)
app.include_router(generation.skills_router) app.include_router(generation.skills_router)
app.include_router(toolbox.router) app.include_router(toolbox.router)
app.include_router(chain.router)
app.include_router(settings_providers.router) app.include_router(settings_providers.router)
app.include_router(kimi_oauth.router) app.include_router(kimi_oauth.router)
return app return app

View File

@@ -0,0 +1,264 @@
"""多章工作流链端点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 越界 → 422schema 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 (
ChainResumeAccepted,
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 (
GatewayChainBuilder,
GatewayDigestBuilder,
get_chain_gateway,
get_chain_gateway_builder,
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)]
ChainBuilderDep = Annotated[GatewayChainBuilder, Depends(get_chain_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不用于实际 LLM 调用
session: SessionDep,
session_factory: SessionFactoryDep,
checkpointer_factory: CheckpointerFactoryDep,
digest_gateway_builder: DigestBuilderDep,
chain_gateway_builder: ChainBuilderDep,
) -> ChainRunAccepted:
"""发起多章链:写一行 job 返 202链经 BackgroundTask 异步跑。
项目不存在 → 404未知 chain_key → 404无凭据 → 503dep 解析阶段拦下);
`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,
chain_gateway_builder=chain_gateway_builder,
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, # 凭据探测(无凭据 → dep 解析阶段 503不用于实际 LLM 调用
session: SessionDep,
session_factory: SessionFactoryDep,
checkpointer_factory: CheckpointerFactoryDep,
digest_gateway_builder: DigestBuilderDep,
chain_gateway_builder: ChainBuilderDep,
) -> ChainResumeAccepted:
"""裁决续跑:仅当 job=awaiting_input带裁决经 BackgroundTask resume。
项目不存在 → 404job 不存在 / 不属于该项目 → 404同案不枚举非 awaiting 态 →
409CONFLICT。`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)
# 所有权校验(审评 #4job 不存在 **或** 不属于该项目 → 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(
ErrorCode.CONFLICT,
f"job {job_id} 非 awaiting_input 态(当前 {job.status}),不可续跑",
{"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,
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=1, # resume 不重置区间:图从检查点续,初值不再使用。
count=1,
chain_gateway_builder=chain_gateway_builder,
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 ChainResumeAccepted(job_id=job_id, chain_key=chain_key)
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)

View File

@@ -0,0 +1,64 @@
"""多章工作流链端点的请求/响应 schemachain-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):
"""链受理回执202job_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 章冲突的裁决清单(采纳/忽略/手改)",
)
class ChainResumeAccepted(BaseModel):
"""resume 受理回执202job_id + chain_key前端走 `GET /jobs/{id}` 轮询续跑进度)。
续跑无独立区间(图从检查点续),故不回显 start/count会是无意义哨兵值——只回 job 标识。
"""
job_id: uuid.UUID
chain_key: str

View File

@@ -0,0 +1,44 @@
"""多章链的 checkpointer 注入缝chain-workflow 设计 §4——激活休眠资产。
`get_checkpointer()` 是可注入缝:
- **运行时**:返 `AsyncPostgresSaver``langgraph.checkpoint.postgres.aio`),连 `DATABASE_URL`
持久控制流位置 + pending handle跨小时可恢复。检查点表由迁移/CI 调 `setup_checkpointer`
C3本缝**绝不**触碰 DDLCLAUDE.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→resumerun 结束关闭。测试经 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

View File

@@ -0,0 +1,312 @@
"""多章链长任务 runnerchain-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
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 的 kindjobs.kind 自由 Text 列零迁移复用§7
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(
*,
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
# 冲突 gateR5事务前拦截不写库缺判 → 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 checkpointerPostgres 运行时 / 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,
chain_gateway_builder: GatewayChainBuilder,
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。
`chain_gateway_builder`非具体网关实例write/review 节点在自建短事务的**新鲜** session
上现建网关,使 usage_ledger 绑节点 session不变量 #1。**绝不**把请求阶段建的网关传进来——
BackgroundTask 跑时请求 session 已关闭,其 ledger.record() 会落到死 session 上被静默丢弃。
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(
chain_gateway_builder,
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不冒泡。
# 日志只记脱敏文案 + 异常类型;绝不记 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:
"""独立短事务把 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 — 置失败态本身再炸只记日志,不冒泡。
# 同 §5不记 str(exc),只记异常类型(避免泄露内部细节)。
log.error(
"chain_job_fail_mark_failed",
job_id=str(job_id),
request_id=request_id,
exc_type=type(exc).__name__,
)
__all__ = [
"JOB_KIND_CHAIN",
"AcceptOp",
"CheckpointerCtx",
"DigestGatewayBuilder",
"GatewayChainBuilder",
"build_accept_op",
"run_chain_job",
]

View File

@@ -10,7 +10,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Annotated from typing import Annotated, Any
import httpx import httpx
from fastapi import Depends 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") 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 路由行构链;无行则退回全局默认(单 providerM1 兼容)。"""
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:
"""装配多章链网关:按请求 tierwriter/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( async def get_writer_gateway(
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway: ) -> Gateway:
@@ -451,3 +498,59 @@ async def get_refine_gateway(
"""回炉writer 档位)的可注入网关缝。测试经 override 注 mock产纯文本重写段""" """回炉writer 档位)的可注入网关缝。测试经 override 注 mock产纯文本重写段"""
store = SqlCredentialStore(session) store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "writer") 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
# 「按 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

View File

@@ -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`
TypedDictlanggraph 状态 = 检查点内容,**只放控制流,不放正文/上下文**
| 字段 | 类型 | 说明 |
|---|---|---|
| `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。冲突明文**不进** stateresume 时从 `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_inputBackgroundTask resume |
- 校验系统边界fail fast`count` 1..50>50 → 422 VALIDATION`start_chapter_no >= 1``chain_key` 未知 → 404resume 非 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 端点入 OpenAPIrun→202、resume→202、未知 key 404、count>50 422、resume 非 awaiting 409token 不入 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**C4FastAPI 端点 + 真 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"阶段台账。

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 ## 契约变更日志append-only
> 格式:`- [date] @skill 改 Cx<改了什么> → 影响 <依赖方/任务>` > 格式:`- [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 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 步进器)。 - [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」导入名。 - 选择:把实现落在已是 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` 而不动调用方(只改门面方向)。 - 理由 / 取舍非阻塞、门禁可干净覆盖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 执行。 - 影响:消费方 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] @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-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-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` 全仓再补

View File

@@ -0,0 +1,380 @@
"""多章链图单测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:
"""链网关 mockwrite无 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:
"""内存章草稿 reposave_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:
"""内存审稿 reporecord 追加留痕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()
)
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
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,
"gateway_builder": gateway_builder,
"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_builder"],
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_builder=h["gateway_builder"],
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

View File

@@ -15,6 +15,7 @@ import pytest
from pydantic import ValidationError from pydantic import ValidationError
from ww_core.domain.job_repo import ( from ww_core.domain.job_repo import (
PROGRESS_COMPLETE, PROGRESS_COMPLETE,
STATUS_AWAITING,
STATUS_DONE, STATUS_DONE,
STATUS_FAILED, STATUS_FAILED,
STATUS_QUEUED, STATUS_QUEUED,
@@ -93,6 +94,20 @@ class FakeJobRepo:
row.result = dict(result) row.result = dict(result)
return _view(row) 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 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: async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
row = self._require(job_id) row = self._require(job_id)
row.status = STATUS_FAILED row.status = STATUS_FAILED
@@ -166,6 +181,39 @@ async def test_fail_sets_failed_and_error() -> None:
assert failed.error == "boom" 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) ---- # ---- progress (clamped) ----

View File

@@ -8,15 +8,11 @@ from __future__ import annotations
import uuid import uuid
from fakes_orchestrator import FakeStreamGateway, RaisingGateway from fakes_orchestrator import FakeStreamGateway, RaisingGateway
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver
from ww_core.orchestrator import ( from ww_core.orchestrator import (
EVENT_DONE, EVENT_DONE,
EVENT_ERROR, EVENT_ERROR,
EVENT_TOKEN, EVENT_TOKEN,
WRITE_NODE,
ChapterState, ChapterState,
build_write_graph,
build_write_request, build_write_request,
normalize_deltas, normalize_deltas,
stream_chapter_draft, 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 assert events[0].data["code"] == ErrorCode.INTERNAL
# ---- 图编译:装节点 + checkpointerMemorySaver不连真 Postgres ---- # 单 `write` 节点图 `build_write_graph` 已删(链图取代,见 chain-workflow 设计 §3.3
# write 节点本体 `write_node` 仍由上面的 stream/run 用例直接覆盖(注入 mock 网关,无需图运行时)。
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"] == "云深"

View File

@@ -28,6 +28,8 @@ STATUS_QUEUED = "queued"
STATUS_RUNNING = "running" STATUS_RUNNING = "running"
STATUS_DONE = "done" STATUS_DONE = "done"
STATUS_FAILED = "failed" STATUS_FAILED = "failed"
# 多章链 interrupt 命中暂停等作者裁决chain-workflow §7零迁移复用 jobs.status 自由 Text 列)。
STATUS_AWAITING = "awaiting_input"
PROGRESS_COMPLETE = 100 PROGRESS_COMPLETE = 100
@@ -37,6 +39,7 @@ class JobView(BaseModel):
model_config = {"frozen": True} model_config = {"frozen": True}
id: uuid.UUID id: uuid.UUID
project_id: uuid.UUID | None = None
kind: str kind: str
status: str status: str
progress: int = 0 progress: int = 0
@@ -63,6 +66,15 @@ class JobRepo(Protocol):
"""置 status=done, progress=100, result=<dict>。""" """置 status=done, progress=100, result=<dict>。"""
... ...
async def set_awaiting(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
"""置 status=awaiting_input, result=<dict>(多章链 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: async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
"""置 status=failed, error=<str>。""" """置 status=failed, error=<str>。"""
... ...
@@ -79,6 +91,7 @@ class JobRepo(Protocol):
def _to_view(row: Job) -> JobView: def _to_view(row: Job) -> JobView:
return JobView( return JobView(
id=row.id, id=row.id,
project_id=row.project_id,
kind=row.kind, kind=row.kind,
status=row.status, status=row.status,
progress=row.progress, progress=row.progress,
@@ -144,6 +157,42 @@ class SqlJobRepo:
await self._s.refresh(row) await self._s.refresh(row)
return _to_view(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 claim_awaiting_to_running(self, job_id: uuid.UUID) -> JobView | None:
"""原子 `awaiting_input → running`(条件 UPDATEreturning 行)。
防并发 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: async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
row = await self._require(job_id) row = await self._require(job_id)
row.status = STATUS_FAILED row.status = STATUS_FAILED

View File

@@ -1,6 +1,6 @@
"""编排器C4 / ARCH §5.2)——写章图 + 审稿子图 + SSE 归一 + Postgres checkpointer。 """编排器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`+ - M2并行审子图`build_review_graph`START→各审并行→collect 落 `chapter_reviews`+
审稿 SSE`section`/`conflict` 事件 + `normalize_review`)。 审稿 SSE`section`/`conflict` 事件 + `normalize_review`)。
- accept验收事务/`interrupt_before`)不在本图——属 T2.4确定性代码读领域表R3 - accept验收事务/`interrupt_before`)不在本图——属 T2.4确定性代码读领域表R3
@@ -37,7 +37,6 @@ from .graph import (
REVIEW_SPECS, REVIEW_SPECS,
WRITE_NODE, WRITE_NODE,
build_review_graph, build_review_graph,
build_write_graph,
setup_checkpointer, setup_checkpointer,
) )
from .outline_node import build_outline_request, run_outline from .outline_node import build_outline_request, run_outline
@@ -115,7 +114,6 @@ __all__ = [
"build_review_request", "build_review_request",
"build_style_extract_request", "build_style_extract_request",
"build_worldbuilder_context", "build_worldbuilder_context",
"build_write_graph",
"build_write_request", "build_write_request",
"collect_reviews", "collect_reviews",
"conflict_event", "conflict_event",

View File

@@ -0,0 +1,56 @@
"""多章工作流链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,
GatewayBuilder,
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",
"GatewayBuilder",
"MemoryReposFactory",
"ReviewRecordRepo",
"SessionFactory",
"accept_chapter",
"build_chain_graph",
"decide",
"has_conflicts",
"initial_chain_state",
"review_chapter",
"write_chapter",
]

View File

@@ -0,0 +1,172 @@
"""多章链图工厂 `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 ..graph import REVIEW_SPECS
from . import nodes
from .nodes import (
AcceptChapterOp,
AssembleFn,
ChapterDraftRepo,
GatewayBuilder,
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_builder: GatewayBuilder,
*,
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_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。
`checkpointer`:运行时传 Postgres saver单测传 `MemorySaver`(同进程跨 invoke 可恢复)。
"""
async def _write(state: ChainState) -> dict[str, Any]:
return await nodes.write_chapter(
state,
gateway_builder=gateway_builder,
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_builder=gateway_builder,
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 后无论有无冲突都进 acceptinterrupt若有冲突在 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=startwritten=[])。
纯函数:服务层 / 测试构初值用集中区间换算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",
]

View File

@@ -0,0 +1,324 @@
"""多章链节点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
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]]
#: `session -> Gateway`:按**节点当前 session** 建网关(不变量 #1
#: 关键:网关的 ledger sink 绑节点 session节点末尾 `commit()` 一并持久 usage_ledger 行——
#: 绝不能复用请求阶段建好的网关(其 session 在 BackgroundTask 跑时已关闭,记账行会被静默丢弃)。
GatewayBuilder = Callable[[Any], Awaitable[GatewayRun]]
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_builder: GatewayBuilder,
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`
守不变量 #9stable_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(
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_builder: GatewayBuilder,
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
网关经 `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)
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",
"GatewayBuilder",
"accept_chapter",
"decide",
"has_conflicts",
"review_chapter",
"write_chapter",
]

View File

@@ -0,0 +1,36 @@
"""多章工作流链状态 `ChainState`chain-workflow 设计 §3.1)。
不变量 #5langgraph 检查点 = state 内容,**只放控制流**——章号/计数/已验收清单/
冲突标志。草稿正文/上下文/冲突明文**不进** state落 `chapters`/`chapter_reviews`
resume 时从领域表重读为真相)。
`ChainState` 用 TypedDictLangGraph 原生状态形态),字段全 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

View File

@@ -27,37 +27,17 @@ from ._protocols import GatewayRun
from .collect import ReviewRecorder, collect_reviews from .collect import ReviewRecorder, collect_reviews
from .review_node import run_review from .review_node import run_review
from .state import ChapterState from .state import ChapterState
from .write_node import GatewayStream, write_node
if TYPE_CHECKING: if TYPE_CHECKING:
from langgraph.checkpoint.base import BaseCheckpointSaver from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.graph.state import CompiledStateGraph 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" WRITE_NODE = "write"
COLLECT_NODE = "collect" 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。第四审 = 文风漂移打分轨。 # M4四审齐continuity + foreshadow + pace + style。第四审 = 文风漂移打分轨。
REVIEW_SPECS: tuple[AgentSpec, ...] = ( REVIEW_SPECS: tuple[AgentSpec, ...] = (
continuity_spec, continuity_spec,

View File

@@ -20,9 +20,27 @@ if config.config_file_name is not None:
config.set_main_option("sqlalchemy.url", get_settings().database_url) config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata 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: 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(): with context.begin_transaction():
context.run_migrations() context.run_migrations()

View File

@@ -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唯一新增 DDLlanggraph
检查点表(`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()

View File

@@ -8,6 +8,7 @@ from enum import StrEnum
class ErrorCode(StrEnum): class ErrorCode(StrEnum):
NOT_FOUND = "NOT_FOUND" NOT_FOUND = "NOT_FOUND"
VALIDATION = "VALIDATION" VALIDATION = "VALIDATION"
CONFLICT = "CONFLICT" # 资源状态冲突(如对非 awaiting 链 job 续跑)
CONFLICT_UNRESOLVED = "CONFLICT_UNRESOLVED" # 未决冲突禁验收 CONFLICT_UNRESOLVED = "CONFLICT_UNRESOLVED" # 未决冲突禁验收
LLM_UNAVAILABLE = "LLM_UNAVAILABLE" # 回退耗尽 LLM_UNAVAILABLE = "LLM_UNAVAILABLE" # 回退耗尽
RATE_LIMITED = "RATE_LIMITED" RATE_LIMITED = "RATE_LIMITED"
@@ -18,6 +19,7 @@ class ErrorCode(StrEnum):
HTTP_STATUS: dict[ErrorCode, int] = { HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.NOT_FOUND: 404, ErrorCode.NOT_FOUND: 404,
ErrorCode.VALIDATION: 422, ErrorCode.VALIDATION: 422,
ErrorCode.CONFLICT: 409,
ErrorCode.CONFLICT_UNRESOLVED: 409, ErrorCode.CONFLICT_UNRESOLVED: 409,
ErrorCode.LLM_UNAVAILABLE: 503, ErrorCode.LLM_UNAVAILABLE: 503,
ErrorCode.RATE_LIMITED: 429, ErrorCode.RATE_LIMITED: 429,

View File

@@ -0,0 +1,461 @@
"""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=doneresult.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默认都路由 deepseekconfig.tier_defaults
_PROVIDER = "deepseek"
# 链写章收集版正文(非 SSE经 gateway.run → adapter.completeoutput_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 → 各审 parsedcontinuity 注入冲突,其余无冲突)。
- `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_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)
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]
# 回归守卫(审评 #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()
.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)