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 — 一句话结果 + 影响的契约/文件`
- [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-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。

View File

@@ -23,6 +23,7 @@ from ww_core.domain.foreshadow_state import (
)
from ww_core.domain.job_repo import (
PROGRESS_COMPLETE,
STATUS_AWAITING,
STATUS_DONE,
STATUS_FAILED,
STATUS_QUEUED,
@@ -413,7 +414,13 @@ class FakeJobRepo:
self.rows: dict[uuid.UUID, JobView] = {}
async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
view = JobView(id=uuid.uuid4(), kind=kind, status=STATUS_QUEUED, progress=0)
view = JobView(
id=uuid.uuid4(),
project_id=project_id,
kind=kind,
status=STATUS_QUEUED,
progress=0,
)
self.rows[view.id] = view
return view
@@ -434,6 +441,22 @@ class FakeJobRepo:
self.rows[job_id] = 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:
view = self.rows[job_id].model_copy(update={"status": STATUS_FAILED, "error": error})
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.middleware import REQUEST_ID_HEADER, request_id_middleware
from ww_api.routers import (
chain,
foreshadow,
generation,
health,
@@ -117,6 +118,7 @@ def create_app() -> FastAPI:
app.include_router(generation.router)
app.include_router(generation.skills_router)
app.include_router(toolbox.router)
app.include_router(chain.router)
app.include_router(settings_providers.router)
app.include_router(kimi_oauth.router)
return app

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 collections.abc import Awaitable, Callable
from typing import Annotated
from typing import Annotated, Any
import httpx
from fastapi import Depends
@@ -399,6 +399,53 @@ async def build_writer_gateway(session: AsyncSession, store: CredentialStore) ->
return await build_gateway_for_tier(session, store, "writer")
# 多章链一条 run 内跨 writer(write)/analyst(review)/light(digest) 三档——单档网关的
# chain_resolver 恒返该档链、忽略请求 tier会把 review/digest 错路由到 writer。故链需一个
# **按请求 tier 分派**的网关:并 union 三档所有 provider 的适配器 + 逐 tier 解析其链。
_CHAIN_TIERS: tuple[Tier, ...] = ("writer", "analyst", "light")
def _chain_for_tier(stored_by_tier: dict[Tier, Any], tier: Tier) -> list[Route]:
"""据该 tier 的 DB 路由行构链;无行则退回全局默认(单 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(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
@@ -451,3 +498,59 @@ async def get_refine_gateway(
"""回炉writer 档位)的可注入网关缝。测试经 override 注 mock产纯文本重写段"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "writer")
async def get_chain_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""多章链write writer + review analyst + digest light按请求 tier 分派)的可注入网关缝。
`POST .../chains/{key}/run` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE`
503调度 job 之前拦下,避免凭空写注定失败的 job。链本体在 BackgroundTask 里用
`run_chain_job` 自建独立 session 重新构网关跑(请求 session 已关闭);本依赖只做请求阶段
凭据探测。测试经 `app.dependency_overrides[get_chain_gateway]` 注 mock绝不联网
"""
store = SqlCredentialStore(session)
return await build_chain_gateway(session, store)
# 「按 session 建 digest 网关」缝类型chain accept_op 终稿提炼按节点自建短事务的 session 建)。
GatewayDigestBuilder = Callable[[AsyncSession], Awaitable[Gateway]]
def _digest_gateway_builder(session: AsyncSession) -> Awaitable[Gateway]:
"""链 accept 节点在自建短事务里建 light 档 digest 网关(终稿提炼)。"""
return build_gateway_for_tier(session, SqlCredentialStore(session), "light")
def get_digest_gateway_builder() -> GatewayDigestBuilder:
"""返回「按 session 建 digest 网关」的缝chain accept_op 终稿提炼用)。
BackgroundTask 自建短事务 → 节点内据该 session 现建 light 档网关。测试经
`app.dependency_overrides[get_digest_gateway_builder]` 注返回 mock 的 builder绝不联网
"""
return _digest_gateway_builder
# 「按 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
> 格式:`- [date] @skill 改 Cx<改了什么> → 影响 <依赖方/任务>`
- [2026-06-23] @backend C-ChainC23 端点 + `schemas/chain.py` + `services/{chain_runner,chain_deps}.py` + `jobs` 零迁移复用 `status="awaiting_input"` + `JobRepo.set_awaiting`+ `ErrorCode.CONFLICT`(409) + `project_deps` `build_chain_gateway`/`get_chain_gateway`/`get_digest_gateway_builder`/`get_checkpointer_factory`OpenAPI 2 POSTGET /jobs 复用)。门禁绿ruff/format/mypy(193) 干净 + pytest 600 passed + alembic 无漂移。→ 影响 @frontendfollow-up `pnpm gen:api`)、@db/@devops C3检查点迁移)。
- [2026-06-20] @backend C3新增 **`GET /projects/{project_id}/chapters/{chapter_no}/injection`**本章注入透明B0 读端点)→ `InjectionResponse{project_id, chapter_no, selected:[InjectionEntity{kind,name,reasons[]}], recent_n}``schemas/injection.py`snake_case)。实现仅调既有 `assemble()` 回放确定性 `SelectionTrace`——** LLM commit DDL**项目不存在404无大纲`selected:[]`reasons 取值同 `SelectionReason`explicit_beat/main_character/recent_digest/foreshadow_window)。→ 影响 @frontend `pnpm gen:api` + `ChapterAssistant` 消费)。**B0 可控版PUT override + `select_relevant_entities` pinned/excluded/recent_n + draft 端点同读 override + 持久化尚未实现届时再扩本契约。**
- [2026-06-20] @backend 再扩 C3B0 **可控版**接上条新增 **`PUT /projects/{project_id}/chapters/{chapter_no}/injection`** `InjectionOverrideRequest{pinned:[{kind,name}], excluded:[{kind,name}], recent_n:int|null(1..20)}` 返回 `InjectionResponse`同上**新增回显字段 `pinned`/`excluded`**`selected[].reasons` 可含新值 **`author_pin`**)。语义pin 强制纳入并加 `author_pin` 理由 / excluded 强制剔除**优先于 pin**/ recent_n 覆盖近况回看章数GET injection **draft 流式端点**均先读同一覆盖再 `assemble(override=...)`,故「看到的=写章用的」(不变量 #6 作者兜底)。持久化:**新表 `chapter_injection`**迁移 `ad2c4c663daf`唯一 `(project_id,chapter_no)`复用 outline 行已否决`select_relevant_entities` `pinned/excluded` frozenset 入参`assemble` `override` 关键字参 `domain/injection_repo.py``InjectionOverride`/`EntityRef`/`SqlInjectionOverrideRepo`upsert flush 端点 commit)。→ 影响 @frontend `pnpm gen:api`F1 pin/排除控件 + recent_n 步进器)。

View File

@@ -149,3 +149,9 @@
- 选择:把实现落在已是 workspace member、且承载「编排/写库 apply 层」的 `ww_core.domain``skill_registry.py` + `skill_permissions.py`,经 `ww_core.domain` 导出)。`ARCHITECTURE §5.6` 明确「强制点在编排/写库层」=ww_core语义上也吻合。`packages/skills/ww_skills/__init__.py` 留**再导出门面**import 自 ww_core保留稳定「skills」导入名。
- 理由 / 取舍非阻塞、门禁可干净覆盖ruff/mypy/pytest 全跑)、不越权改根配置;代价是物理位置与任务文档措辞略偏,但功能/契约C8完整。待 @devops`packages/skills` 纳入 workspace + mypy_path 后,可平滑迁移实现到 `ww_skills` 而不动调用方(只改门面方向)。
- 影响:消费方 import `ww_core.domain``SkillRegistry`/`filter_reads`/`partition_writes`/`validate_declaration`(或经 `ww_skills` 门面)。若 @devops 后续纳入 workspace迁移由届时的 @backend/@llm 执行。
## [2026-06-23] @backend C2多章链服务/端点接线:零迁移表达 awaiting + 多档分派网关
- 决策 1**零迁移表达 awaiting**):链 interrupt 暂停态不新建 jobs 列/枚举——`jobs.status` 是自由 Text 列(无 DB CHECK直接新增值 `"awaiting_input"` + `JobRepo.set_awaiting`;待裁决章号经 `result.awaiting_chapter` 表达。`jobs.kind="chain"` 同理零迁移。唯一新增 DDLlanggraph 检查点表)归 C3 迁移。理由:设计 §7 优先零迁移result 已是 JSONB 足以承载非密进度。
- 决策 2**多档分派网关 `build_chain_gateway`**):一条链 run 跨 writer(write)/analyst(review)/light(digest) 三档,但 `build_gateway_for_tier(tier)` 装出的网关 `chain_resolver` 恒返该单档链、忽略 `req.tier`——会把 review/digest 错路由到 writer。故链需一个**按请求 tier 分派**的网关union 三档所有 provider 适配器 + `chain_resolver``req.tier` 返回对应档链。digest 在 accept 节点自建短事务内按 session 现建 light 档网关(`get_digest_gateway_builder`)。
- 决策 3**accept_op 在 apps/api 装配**):链编排(图/节点)在 `ww_core`,但具体验收事务(`run_accept_transaction`/冲突 gate/digest 提炼/伏笔到期扫描)属 apps/api——经 `build_accept_op` 闭包注入图节点,守目录所有权 + 不变量 #3/#4。自动链无作者改稿,`final_text` = `chapters` 该章草稿正文write 节点所落)。
- 决策 4**新错误码 `CONFLICT`**resume 非 awaiting 态须 409但既有 409 码 `CONFLICT_UNRESOLVED` 语义专指「未决冲突禁验收」,不宜复用。新增通用 `ErrorCode.CONFLICT`→409资源状态冲突

View File

@@ -84,3 +84,6 @@
- [2026-06-19] @llm **Kimi Code coding API 伪造头真源 = `ooojustin/opencode-kimi``src/headers.ts`+`src/constants.ts`1:1 镜像 kimi-cli v1.37.0),发完整 7 头、UA `KimiCLI/1.37.0`**K1.2 校正推翻上方 pi-kimi-coder UA-only 那条`picassio/pi-kimi-coder` 是分歧/错误参考只发 UA照它裁成 UA-only 是误修已回退`kimi_code_headers()` `User-Agent=KimiCLI/1.37.0` + `X-Msh-Platform=kimi_cli`字面常量 OS + `X-Msh-Version=1.37.0`==UA 版本+ `X-Msh-Device-Name``socket.gethostname()` ASCII + `X-Msh-Device-Model`macOS=`f"macOS {platform.mac_ver()[0]} {platform.machine()}"``machine()` 原样 `arm64`/`x86_64` 不归一化+ `X-Msh-Os-Version``platform.version()`+ `X-Msh-Device-Id`稳定 32 位无连字符 `uuid4().hex`env `KIMI_DEVICE_ID` 优先否则读/ `~/.kimi/device_id` 复用**绝不每次随机**否则 Kimi 侧每次当新设备)。源码若与此有出入以源码为准本次核对 master 一致)。⚠ HTTP 头值含非 ASCII 会被底层 fetch/httpx host 派生值Device-Name/Model/Os-Version ASCII `asciiHeaderValue` `\x20-\x7e` 之外 + trim空回退 `unknown`)。单测用 env `KIMI_DEVICE_ID` 注入固定 id CI 确定不触真 ~/.kimi)。ruff E501 按显示宽度算中文占 2 含中文 docstring/注释的行别贴边 100
- [2026-06-19] @backend `assemble`C5此前只从 world_entities/characters/style/rules + cards/foreshadow/digests/outline.beats 组装**从不含项目 premise/logline/theme/title也无写章指令** 全新项目只有 premise无世界观/角色/大纲产出 `stable_core=""`+`volatile=""`writer user message 为空LLM `400 "the message at position 0 with role 'user' must not be empty"`修复`MemoryRepos` 加第 8 repo `project:ProjectSpecRepo``spec(project_id)->ProjectSpecView{title,logline?,premise?,theme?}` project_id `projects` owner 过滤——owner 隔离归路由层`assemble` 作品蓝本放进 `stable_core` 首段书级 spec=定型→缓存前缀,不违 #9并让 `volatile` 始终含 `请创作第 {chapter_no} 章的正文。`易变指令)。⚠ 任何手搓 `MemoryRepos(...)` 的地方含测试 fake现在**必须** `project=` 字段否则 dataclass 缺参报错 DDL 变更只读既有 projects 无迁移
- [2026-06-22] @frontend/@orchestrator T6 创作工具箱通用端点的两处契约边界加新生成器时注意):① `ToolGenerateRequest` **固定字段并集** `{brief, chapter_no?, count?, kind?}`——`GeneratorRunner` 只映射落在此集合内的 `input_field` 新生成器若声明了集合外的 `input_field`前端会**静默忽略**该字段新工具的 `input_fields` 须落在 `brief/chapter_no/count/kind` 否则需先扩 `ToolGenerateRequest` 契约+`pnpm gen:api`)。② `ToolInputFieldView.type` 是自由字符串后端可声明 `"select"` descriptor **无 `options` 字段** 前端把 select text 输入渲染若某工具需要真下拉选项须先给 `InputField`/`ToolInputFieldView` `options` 再改前端 `GeneratorRunner`
- [2026-06-23] @backend 多章链网关C2**单档 `build_gateway_for_tier(tier)` 网关不能驱动跨档链**。 `chain_resolver`/`_resolver` 闭包恒返该 tier 的链、**忽略入参 `req.tier`**故拿它跑 review(analyst)/digest(light) 会全部错路由到 writer 档的 provider/model/任何跨档编排须用 `build_chain_gateway`union 三档适配器 + `chain_resolver` `req.tier` 分派)。测试用 mock 网关按 `req.output_schema` 路由 schema=write有=review不受此影响。
- [2026-06-23] @backend langgraph `CompiledStateGraph.ainvoke` mypy 重载`config` 必须是 `RunnableConfig``from langchain_core.runnables import RunnableConfig` `dict[str,dict[str,str]]` 不匹配任何重载 call-overload 返回值是 `dict[str,Any] | Any`传给取 `__interrupt__`/`written` helper 前先 `dict(raw_final)` 收敛为 `dict[str,Any]`interrupt 命中时返回值含 `__interrupt__`Interrupt 对象列表载荷读 `getattr(first,"value",first)`兼容 dict)。
- [2026-06-23] @backend `JobRepo` Protocol 加方法 `set_awaiting`= 契约变更**所有 fake 实现都要同步补**否则 mypy Protocol 缺成员本次漏了 `packages/core/tests/test_job_repo.py::FakeJobRepo` + `apps/api/tests/fakes_projects.py::FakeJobRepo`)。grep `class Fake.*JobRepo` 全仓再补

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 ww_core.domain.job_repo import (
PROGRESS_COMPLETE,
STATUS_AWAITING,
STATUS_DONE,
STATUS_FAILED,
STATUS_QUEUED,
@@ -93,6 +94,20 @@ class FakeJobRepo:
row.result = dict(result)
return _view(row)
async def set_awaiting(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
row = self._require(job_id)
row.status = STATUS_AWAITING
row.result = dict(result)
return _view(row)
async def claim_awaiting_to_running(self, job_id: uuid.UUID) -> JobView | None:
"""原子抢占 awaiting→running内存替身仅当现态 awaiting 才抢到,否则 None"""
row = next((r for r in self.rows if r.id == job_id), None)
if row is None or row.status != STATUS_AWAITING:
return None
row.status = STATUS_RUNNING
return _view(row)
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
row = self._require(job_id)
row.status = STATUS_FAILED
@@ -166,6 +181,39 @@ async def test_fail_sets_failed_and_error() -> None:
assert failed.error == "boom"
# ---- 原子抢占 awaiting → running防并发 resume 竞态,审评 #3----
async def test_claim_awaiting_to_running_succeeds_once() -> None:
repo: JobRepo = _repo()
job = await repo.create(PROJECT, KIND)
await repo.set_awaiting(job.id, {"awaiting_chapter": 1})
claimed = await repo.claim_awaiting_to_running(job.id)
assert claimed is not None
assert claimed.status == STATUS_RUNNING
async def test_claim_awaiting_second_call_returns_none() -> None:
repo: JobRepo = _repo()
job = await repo.create(PROJECT, KIND)
await repo.set_awaiting(job.id, {"awaiting_chapter": 1})
first = await repo.claim_awaiting_to_running(job.id)
second = await repo.claim_awaiting_to_running(job.id) # 已 running → 抢不到
assert first is not None
assert second is None
async def test_claim_non_awaiting_returns_none() -> None:
repo: JobRepo = _repo()
job = await repo.create(PROJECT, KIND) # queued非 awaiting
assert await repo.claim_awaiting_to_running(job.id) is None
async def test_claim_absent_job_returns_none() -> None:
repo: JobRepo = _repo()
assert await repo.claim_awaiting_to_running(uuid.uuid4()) is None
# ---- progress (clamped) ----

View File

@@ -8,15 +8,11 @@ from __future__ import annotations
import uuid
from fakes_orchestrator import FakeStreamGateway, RaisingGateway
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver
from ww_core.orchestrator import (
EVENT_DONE,
EVENT_ERROR,
EVENT_TOKEN,
WRITE_NODE,
ChapterState,
build_write_graph,
build_write_request,
normalize_deltas,
stream_chapter_draft,
@@ -135,31 +131,5 @@ async def test_normalize_deltas_maps_unexpected_error_to_internal() -> None:
assert events[0].data["code"] == ErrorCode.INTERNAL
# ---- 图编译:装节点 + checkpointerMemorySaver不连真 Postgres ----
def test_build_write_graph_compiles_with_checkpointer() -> None:
gateway = FakeStreamGateway(["x"])
graph = build_write_graph(gateway, checkpointer=MemorySaver())
assert WRITE_NODE in graph.get_graph().nodes
assert graph.checkpointer is not None
async def test_compiled_graph_runs_write_node_end_to_end() -> None:
gateway = FakeStreamGateway(["", ""])
graph = build_write_graph(gateway, checkpointer=MemorySaver())
config: RunnableConfig = {"configurable": {"thread_id": "t-1"}}
initial: ChapterState = {
"project_id": PROJECT,
"chapter_no": 1,
"user_id": USER,
"stable_core": STABLE,
"volatile": VOLATILE,
"draft": "",
}
final = await graph.ainvoke(initial, config=config)
assert final["draft"] == "云深"
# 单 `write` 节点图 `build_write_graph` 已删(链图取代,见 chain-workflow 设计 §3.3
# write 节点本体 `write_node` 仍由上面的 stream/run 用例直接覆盖(注入 mock 网关,无需图运行时)。

View File

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

View File

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

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 .review_node import run_review
from .state import ChapterState
from .write_node import GatewayStream, write_node
if TYPE_CHECKING:
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.graph.state import CompiledStateGraph
# write 节点名write_node 本体保留,被多章链 `chain.nodes.write_chapter` 复用M1 的
# `build_write_graph` 单节点图已删——链图取代之YAGNI见 chain-workflow 设计 §3.3)。
WRITE_NODE = "write"
COLLECT_NODE = "collect"
def build_write_graph(
gateway: GatewayStream,
*,
checkpointer: BaseCheckpointSaver[Any] | None = None,
) -> CompiledStateGraph[ChapterState, None, ChapterState, ChapterState]:
"""构建并编译单 `write` 节点图。
`gateway` 经闭包绑进节点(节点对图只暴露 `(state)->dict`,确定性可单测)。
`checkpointer`:运行时传 Postgres saver单测传 `MemorySaver` 或不传。
"""
async def _write(state: ChapterState) -> dict[str, str]:
return await write_node(state, gateway=gateway)
builder: StateGraph[ChapterState, None, ChapterState, ChapterState] = StateGraph(ChapterState)
builder.add_node(WRITE_NODE, _write)
builder.add_edge(START, WRITE_NODE)
builder.add_edge(WRITE_NODE, END)
return builder.compile(checkpointer=checkpointer)
# M4四审齐continuity + foreshadow + pace + style。第四审 = 文风漂移打分轨。
REVIEW_SPECS: tuple[AgentSpec, ...] = (
continuity_spec,

View File

@@ -20,9 +20,27 @@ if config.config_file_name is not None:
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
# langgraph 检查点表由 langgraph 自管 schema迁移 d3e4f5a6b7c8 调其 setup() 建表),
# 不在 Base.metadata。autogenerate/check 须忽略它们,否则会误报"待 drop"漂移。
_LANGGRAPH_TABLES = frozenset(
{"checkpoints", "checkpoint_blobs", "checkpoint_writes", "checkpoint_migrations"}
)
def include_object(
obj: object, name: str | None, type_: str, reflected: bool, compare_to: object
) -> bool:
if type_ == "table" and name in _LANGGRAPH_TABLES:
return False
return True
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
context.configure(
connection=connection,
target_metadata=target_metadata,
include_object=include_object,
)
with context.begin_transaction():
context.run_migrations()

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

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)