feat(api): C2 多章链 服务+端点+schema+checkpointer 接线
承 C1 链图(build_chain_graph),落地多章工作流链的 apps/api 壳:
- 3 端点 routers/chain.py:POST .../chains/{key}/run→202 ChainRunAccepted;
POST .../chains/runs/{job_id}/resume→202;GET /jobs/{id} 复用。校验:
count 1..50→422、未知 chain_key→404、resume 非 awaiting→409、无凭据→503。
- schemas/chain.py:ChainRunRequest/ChainRunAccepted/ChainResumeRequest
(ConflictDecision 复用 schemas/projects)。
- services/chain_runner.py:run_chain_job 仿 run_job 壳自建独立 session 驱动链图
(set_running→ainvoke→据 __interrupt__ 置 awaiting_input/done/failed);
build_accept_op 在 apps/api 装配验收事务闭包注入图节点(守 #3/#4);
token 不入 result/日志。
- services/chain_deps.py:get_checkpointer_factory(运行时 AsyncPostgresSaver
上下文 / 测试 MemorySaver)。
- 零迁移(设计 §7):复用 jobs,新增 status="awaiting_input" + JobRepo.set_awaiting,
awaiting 章经 result.awaiting_chapter;新错误码 ErrorCode.CONFLICT(409)。
- project_deps:build_chain_gateway/get_chain_gateway(按请求 tier writer/analyst/light
分派——单档网关恒返该档会错路由 review/digest)+ get_digest_gateway_builder。
单测 apps/api/tests/test_chain.py 12 用例(mock 网关 + MemorySaver + fake session/
accept_op,无 DB/无网络/无真 LLM):run/resume→202、未知 key 404、count 越界 422、
resume 非 awaiting 409、run_chain_job 无冲突→done、冲突→awaiting→resume→done、
错误脱敏、accept_op 冲突缺判→CONFLICT_UNRESOLVED。
门禁绿:ruff/format 干净 · mypy 193 Success · alembic 无漂移 · pytest 600 passed。
守不变量 #1/#3/#4/#5/#9。唯一新增 DDL(langgraph 检查点表)= C3 迁移。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
@@ -434,6 +435,13 @@ 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 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
|
||||
|
||||
586
apps/api/tests/test_chain.py
Normal file
586
apps/api/tests/test_chain.py
Normal file
@@ -0,0 +1,586 @@
|
||||
"""C2 多章链端点 + 服务层单测(chain-workflow §6/§10)——内存替身,无 DB/无网络/无真 LLM。
|
||||
|
||||
覆盖:
|
||||
- 端点契约(HTTP 层,`run_chain_job` 被 no-op 替换以隔离调度逻辑):
|
||||
run → 202 `{job_id,...}`;resume → 202;未知 chain_key → 404;count 越界(>50 / 0)→ 422;
|
||||
resume 非 awaiting 态 → 409;项目不存在 → 404;resume job 不存在 → 404。
|
||||
- 服务层 `run_chain_job`(mock 网关 + MemorySaver + fake session_factory + fake accept_op):
|
||||
无冲突全跑 → job done + result.written 满;有冲突 → interrupt → job awaiting_input;
|
||||
resume 续跑 → done;token 绝不入 job result。
|
||||
- `build_accept_op`:冲突缺判 → CONFLICT_UNRESOLVED;无冲突章正常落库(经 fake op 桩验证)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fakes_projects import FakeJobRepo, FakeProjectRepo
|
||||
from fastapi import FastAPI
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from pydantic import BaseModel
|
||||
from ww_agents import Conflict, ContinuityReview
|
||||
from ww_api.schemas.projects import ConflictDecision
|
||||
from ww_api.services.chain_runner import build_accept_op, run_chain_job
|
||||
from ww_core.domain.job_repo import STATUS_AWAITING, STATUS_DONE
|
||||
from ww_core.domain.project_repo import ProjectCreate
|
||||
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||||
|
||||
PROJECT_OWNER = uuid.UUID(int=1)
|
||||
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
|
||||
|
||||
|
||||
# ---- 共享替身 ----
|
||||
|
||||
|
||||
def _usage() -> Usage:
|
||||
return Usage(
|
||||
provider="fake", model="fake", input_tokens=1, output_tokens=1, cost_minor=0, currency="USD"
|
||||
)
|
||||
|
||||
|
||||
class _FakeChainGateway:
|
||||
"""链网关 mock:write(无 output_schema)回固定正文;审稿按 schema 回 parsed。"""
|
||||
|
||||
def __init__(self, *, conflicts: list[Conflict]) -> None:
|
||||
self._continuity = ContinuityReview(conflicts=conflicts)
|
||||
from ww_agents import foreshadow_spec, pace_spec, style_drift_spec
|
||||
|
||||
self._by_schema: dict[type, BaseModel] = {}
|
||||
for spec in (foreshadow_spec, pace_spec, style_drift_spec):
|
||||
schema = spec.output_schema
|
||||
assert schema is not None
|
||||
self._by_schema[schema] = schema()
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
schema = req.output_schema
|
||||
if schema is None:
|
||||
return LlmResponse(
|
||||
text="第 N 章正文。",
|
||||
parsed=None,
|
||||
usage=_usage(),
|
||||
served_by=ServedBy(provider="fake", model="fake"),
|
||||
)
|
||||
parsed = self._continuity if schema is ContinuityReview else self._by_schema[schema]
|
||||
return LlmResponse(
|
||||
text=parsed.model_dump_json(),
|
||||
parsed=parsed,
|
||||
usage=_usage(),
|
||||
served_by=ServedBy(provider="fake", model="fake"),
|
||||
)
|
||||
|
||||
|
||||
class _FakeDraftView:
|
||||
def __init__(self, content: str) -> None:
|
||||
self.content = content
|
||||
|
||||
|
||||
class _FakeChapterRepo:
|
||||
def __init__(self, store: dict[int, str]) -> None:
|
||||
self._store = store
|
||||
|
||||
async def save_draft(
|
||||
self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1
|
||||
) -> Any:
|
||||
self._store[chapter_no] = text
|
||||
return _FakeDraftView(text)
|
||||
|
||||
async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> Any:
|
||||
content = self._store.get(chapter_no)
|
||||
return _FakeDraftView(content) if content is not None else None
|
||||
|
||||
|
||||
class _FakeReviewRecord:
|
||||
def __init__(self, chapter_no: int, conflicts: list[dict[str, Any]]) -> None:
|
||||
self.id = uuid.uuid4()
|
||||
self.chapter_no = chapter_no
|
||||
self.conflicts = conflicts
|
||||
|
||||
|
||||
class _FakeReviewRepo:
|
||||
def __init__(self, records: dict[int, list[_FakeReviewRecord]]) -> None:
|
||||
self._records = records
|
||||
|
||||
async def record(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
*,
|
||||
chapter_version: int | None = None,
|
||||
conflicts: list[dict[str, Any]],
|
||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||
style: dict[str, Any] | None = None,
|
||||
pace: dict[str, Any] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> Any:
|
||||
rec = _FakeReviewRecord(chapter_no, list(conflicts))
|
||||
self._records.setdefault(chapter_no, []).insert(0, rec)
|
||||
return rec
|
||||
|
||||
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[Any]:
|
||||
return list(self._records.get(chapter_no, []))
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.commits = 0
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _session_cm() -> AsyncIterator[Any]:
|
||||
yield _FakeSession()
|
||||
|
||||
|
||||
def _session_factory() -> Any:
|
||||
"""fake 独立 session 工厂(满足 `SessionFactory` 形:`()` → async-CM)。
|
||||
|
||||
返回 Any 避开 AsyncSession 不变型校验(同 test_job_runner 的 `__call__ -> Any` 纪律)。
|
||||
"""
|
||||
return _session_cm()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _memsaver_ctx(saver: MemorySaver) -> AsyncIterator[Any]:
|
||||
yield saver
|
||||
|
||||
|
||||
class _FakeContext:
|
||||
stable_core = "## 世界观硬规则\n灵气可凝丹"
|
||||
volatile = "## 写作指令\n写第 N 章"
|
||||
|
||||
|
||||
# ---- 端点契约(HTTP 层,run_chain_job no-op)----
|
||||
|
||||
|
||||
def _app(project_repo: FakeProjectRepo, job_repo: FakeJobRepo) -> FastAPI:
|
||||
import os
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.chain_deps import get_checkpointer_factory
|
||||
from ww_api.services.project_deps import (
|
||||
get_chain_gateway,
|
||||
get_digest_gateway_builder,
|
||||
get_job_repo,
|
||||
get_project_repo,
|
||||
get_session_factory,
|
||||
)
|
||||
from ww_db import get_session
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_job_repo] = lambda: job_repo
|
||||
app.dependency_overrides[get_session] = lambda: _FakeSession()
|
||||
app.dependency_overrides[get_session_factory] = lambda: _session_factory
|
||||
app.dependency_overrides[get_chain_gateway] = lambda: _FakeChainGateway(conflicts=[])
|
||||
saver = MemorySaver()
|
||||
app.dependency_overrides[get_checkpointer_factory] = lambda: lambda: _memsaver_ctx(saver)
|
||||
app.dependency_overrides[get_digest_gateway_builder] = lambda: lambda _s: None
|
||||
return app
|
||||
|
||||
|
||||
def _client(app: FastAPI) -> httpx.AsyncClient:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
|
||||
async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
|
||||
view = await repo.create(PROJECT_OWNER, ProjectCreate(title="测试作品", genre="玄幻"))
|
||||
return uuid.UUID(str(view.id))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _noop_run_chain_job(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
|
||||
"""把端点登记的 `run_chain_job` 换成记录调用的 no-op(隔离调度逻辑,不真跑图)。"""
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def _fake(*args: Any, **kwargs: Any) -> None:
|
||||
calls.append({"args": args, "kwargs": kwargs})
|
||||
|
||||
monkeypatch.setattr("ww_api.routers.chain.run_chain_job", _fake)
|
||||
return calls
|
||||
|
||||
|
||||
async def test_run_chain_returns_202_and_schedules_job(
|
||||
_noop_run_chain_job: list[dict[str, Any]],
|
||||
) -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
job_repo = FakeJobRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
app = _app(project_repo, job_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chains/draft_volume/run",
|
||||
json={"start_chapter_no": 1, "count": 3},
|
||||
)
|
||||
|
||||
assert resp.status_code == 202
|
||||
body = resp.json()
|
||||
assert body["chain_key"] == "draft_volume"
|
||||
assert body["start_chapter_no"] == 1
|
||||
assert body["count"] == 3
|
||||
assert uuid.UUID(body["job_id"])
|
||||
# job 行已落(queued)+ 调度了一次后台任务
|
||||
assert len(job_repo.rows) == 1
|
||||
assert len(_noop_run_chain_job) == 1
|
||||
|
||||
|
||||
async def test_run_chain_unknown_key_returns_404(
|
||||
_noop_run_chain_job: list[dict[str, Any]],
|
||||
) -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
job_repo = FakeJobRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
app = _app(project_repo, job_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chains/unknown_chain/run",
|
||||
json={"start_chapter_no": 1, "count": 2},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||||
assert len(_noop_run_chain_job) == 0 # 未调度
|
||||
|
||||
|
||||
async def test_run_chain_count_over_50_returns_422(
|
||||
_noop_run_chain_job: list[dict[str, Any]],
|
||||
) -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
job_repo = FakeJobRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
app = _app(project_repo, job_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chains/draft_volume/run",
|
||||
json={"start_chapter_no": 1, "count": 51},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_run_chain_count_zero_returns_422(
|
||||
_noop_run_chain_job: list[dict[str, Any]],
|
||||
) -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
job_repo = FakeJobRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
app = _app(project_repo, job_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chains/draft_volume/run",
|
||||
json={"start_chapter_no": 1, "count": 0},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_run_chain_project_not_found_returns_404(
|
||||
_noop_run_chain_job: list[dict[str, Any]],
|
||||
) -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
job_repo = FakeJobRepo()
|
||||
app = _app(project_repo, job_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{uuid.uuid4()}/chains/draft_volume/run",
|
||||
json={"start_chapter_no": 1, "count": 2},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_resume_chain_awaiting_returns_202(
|
||||
_noop_run_chain_job: list[dict[str, Any]],
|
||||
) -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
job_repo = FakeJobRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
# 预置一个 awaiting_input 的链 job
|
||||
job = await job_repo.create(pid, "chain")
|
||||
await job_repo.set_awaiting(job.id, {"awaiting_chapter": 1})
|
||||
app = _app(project_repo, job_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chains/runs/{job.id}/resume",
|
||||
json={"decisions": [{"conflict_index": 0, "verdict": "ignore"}]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 202
|
||||
assert resp.json()["job_id"] == str(job.id)
|
||||
assert len(_noop_run_chain_job) == 1
|
||||
|
||||
|
||||
async def test_resume_chain_non_awaiting_returns_409(
|
||||
_noop_run_chain_job: list[dict[str, Any]],
|
||||
) -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
job_repo = FakeJobRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
job = await job_repo.create(pid, "chain") # queued(非 awaiting)
|
||||
app = _app(project_repo, job_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chains/runs/{job.id}/resume",
|
||||
json={"decisions": []},
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["error"]["code"] == "CONFLICT"
|
||||
assert len(_noop_run_chain_job) == 0
|
||||
|
||||
|
||||
async def test_resume_chain_job_not_found_returns_404(
|
||||
_noop_run_chain_job: list[dict[str, Any]],
|
||||
) -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
job_repo = FakeJobRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
app = _app(project_repo, job_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chains/runs/{uuid.uuid4()}/resume",
|
||||
json={"decisions": []},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---- 服务层 run_chain_job(直接 await,注入全 fake;job 状态由 fake SqlJobRepo 替身记录)----
|
||||
|
||||
|
||||
class _RecordingJobRepo:
|
||||
"""run_chain_job 内部 `SqlJobRepo(session)` 的替身(monkeypatch):记录状态流转。"""
|
||||
|
||||
state: dict[uuid.UUID, dict[str, Any]] = {}
|
||||
|
||||
def __init__(self, session: Any) -> None:
|
||||
pass
|
||||
|
||||
async def set_running(self, job_id: uuid.UUID) -> Any:
|
||||
_RecordingJobRepo.state.setdefault(job_id, {})["status"] = "running"
|
||||
return None
|
||||
|
||||
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> Any:
|
||||
_RecordingJobRepo.state[job_id] = {"status": STATUS_DONE, "result": result}
|
||||
return None
|
||||
|
||||
async def set_awaiting(self, job_id: uuid.UUID, result: dict[str, Any]) -> Any:
|
||||
_RecordingJobRepo.state[job_id] = {"status": STATUS_AWAITING, "result": result}
|
||||
return None
|
||||
|
||||
async def fail(self, job_id: uuid.UUID, error: str) -> Any:
|
||||
_RecordingJobRepo.state[job_id] = {"status": "failed", "error": error}
|
||||
return None
|
||||
|
||||
|
||||
def _chain_runner_fakes() -> dict[str, Any]:
|
||||
draft_store: dict[int, str] = {}
|
||||
review_store: dict[int, list[_FakeReviewRecord]] = {}
|
||||
accepted: list[dict[str, Any]] = []
|
||||
|
||||
async def accept_op(
|
||||
*, project_id: uuid.UUID, chapter_no: int, user_id: uuid.UUID, decisions: list[Any]
|
||||
) -> None:
|
||||
accepted.append({"chapter_no": chapter_no, "decisions": list(decisions)})
|
||||
|
||||
return {
|
||||
"draft_store": draft_store,
|
||||
"review_store": review_store,
|
||||
"accepted": accepted,
|
||||
"accept_op": accept_op,
|
||||
"chapter_repo_factory": lambda _s: _FakeChapterRepo(draft_store),
|
||||
"review_repo_factory": lambda _s: _FakeReviewRepo(review_store),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _patched_runner(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""把 run_chain_job 里的 SqlJobRepo + assemble + sql_memory_repos 换成 fake(不触 DB)。"""
|
||||
_RecordingJobRepo.state = {}
|
||||
monkeypatch.setattr("ww_api.services.chain_runner.SqlJobRepo", _RecordingJobRepo)
|
||||
|
||||
async def _fake_assemble(
|
||||
repos: Any, project_id: uuid.UUID, chapter_no: int, *a: Any, **k: Any
|
||||
) -> _FakeContext:
|
||||
return _FakeContext()
|
||||
|
||||
monkeypatch.setattr("ww_api.services.chain_runner.assemble", _fake_assemble)
|
||||
monkeypatch.setattr("ww_api.services.chain_runner.sql_memory_repos", lambda _s: object())
|
||||
|
||||
|
||||
async def test_run_chain_job_no_conflict_completes_done(_patched_runner: None) -> None:
|
||||
fakes = _chain_runner_fakes()
|
||||
gateway = _FakeChainGateway(conflicts=[])
|
||||
job_id = uuid.uuid4()
|
||||
saver = MemorySaver()
|
||||
|
||||
await run_chain_job(
|
||||
_session_factory,
|
||||
job_id,
|
||||
project_id=uuid.uuid4(),
|
||||
user_id=USER,
|
||||
chain_key="draft_volume",
|
||||
start_chapter_no=1,
|
||||
count=2,
|
||||
gateway=gateway,
|
||||
checkpointer_ctx=lambda: _memsaver_ctx(saver),
|
||||
accept_op=fakes["accept_op"],
|
||||
chapter_repo_factory=fakes["chapter_repo_factory"],
|
||||
review_repo_factory=fakes["review_repo_factory"],
|
||||
)
|
||||
|
||||
rec = _RecordingJobRepo.state[job_id]
|
||||
assert rec["status"] == STATUS_DONE
|
||||
assert rec["result"]["written"] == [1, 2]
|
||||
assert rec["result"]["completed"] is True
|
||||
assert rec["result"]["awaiting_chapter"] is None
|
||||
# token 不入 result:只含章号/标志,无正文/prompt
|
||||
assert "第 N 章正文" not in str(rec["result"])
|
||||
assert [a["chapter_no"] for a in fakes["accepted"]] == [1, 2]
|
||||
|
||||
|
||||
async def test_run_chain_job_conflict_sets_awaiting_then_resume_done(
|
||||
_patched_runner: None,
|
||||
) -> None:
|
||||
fakes = _chain_runner_fakes()
|
||||
conflict = Conflict(type="性格漂移", where="第3段", suggestion="统一主角姓名")
|
||||
gateway = _FakeChainGateway(conflicts=[conflict])
|
||||
job_id = uuid.uuid4()
|
||||
saver = MemorySaver() # 同 saver 跨 run/resume 两次调用
|
||||
project_id = uuid.uuid4()
|
||||
|
||||
# 初始 run:第 1 章四审报冲突 → interrupt → job awaiting_input。
|
||||
await run_chain_job(
|
||||
_session_factory,
|
||||
job_id,
|
||||
project_id=project_id,
|
||||
user_id=USER,
|
||||
chain_key="draft_volume",
|
||||
start_chapter_no=1,
|
||||
count=1,
|
||||
gateway=gateway,
|
||||
checkpointer_ctx=lambda: _memsaver_ctx(saver),
|
||||
accept_op=fakes["accept_op"],
|
||||
chapter_repo_factory=fakes["chapter_repo_factory"],
|
||||
review_repo_factory=fakes["review_repo_factory"],
|
||||
)
|
||||
|
||||
rec = _RecordingJobRepo.state[job_id]
|
||||
assert rec["status"] == STATUS_AWAITING
|
||||
assert rec["result"]["awaiting_chapter"] == 1
|
||||
assert fakes["accepted"] == [] # 尚未验收
|
||||
|
||||
# resume:作者裁决 → 续跑 accept → done。
|
||||
decisions = [ConflictDecision(conflict_index=0, verdict="ignore", note="笔误")]
|
||||
await run_chain_job(
|
||||
_session_factory,
|
||||
job_id,
|
||||
project_id=project_id,
|
||||
user_id=USER,
|
||||
chain_key="draft_volume",
|
||||
start_chapter_no=1,
|
||||
count=1,
|
||||
gateway=gateway,
|
||||
checkpointer_ctx=lambda: _memsaver_ctx(saver),
|
||||
accept_op=fakes["accept_op"],
|
||||
chapter_repo_factory=fakes["chapter_repo_factory"],
|
||||
review_repo_factory=fakes["review_repo_factory"],
|
||||
resume_decisions=decisions,
|
||||
)
|
||||
|
||||
rec = _RecordingJobRepo.state[job_id]
|
||||
assert rec["status"] == STATUS_DONE
|
||||
assert rec["result"]["written"] == [1]
|
||||
assert [a["chapter_no"] for a in fakes["accepted"]] == [1]
|
||||
assert fakes["accepted"][0]["decisions"] == [
|
||||
{"conflict_index": 0, "verdict": "ignore", "note": "笔误"}
|
||||
]
|
||||
|
||||
|
||||
async def test_run_chain_job_error_marks_failed_safely(_patched_runner: None) -> None:
|
||||
"""work 抛 → job failed + 脱敏文案(不泄 str(exc)),异常不冒泡。"""
|
||||
fakes = _chain_runner_fakes()
|
||||
job_id = uuid.uuid4()
|
||||
|
||||
class _BoomGateway:
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
raise RuntimeError("boom secret=/internal/path")
|
||||
|
||||
await run_chain_job(
|
||||
_session_factory,
|
||||
job_id,
|
||||
project_id=uuid.uuid4(),
|
||||
user_id=USER,
|
||||
chain_key="draft_volume",
|
||||
start_chapter_no=1,
|
||||
count=1,
|
||||
gateway=_BoomGateway(),
|
||||
checkpointer_ctx=lambda: _memsaver_ctx(MemorySaver()),
|
||||
accept_op=fakes["accept_op"],
|
||||
chapter_repo_factory=fakes["chapter_repo_factory"],
|
||||
review_repo_factory=fakes["review_repo_factory"],
|
||||
)
|
||||
|
||||
rec = _RecordingJobRepo.state[job_id]
|
||||
assert rec["status"] == "failed"
|
||||
assert rec["error"] == "任务执行失败"
|
||||
assert "secret" not in rec["error"]
|
||||
|
||||
|
||||
# ---- build_accept_op:冲突缺判 gate ----
|
||||
|
||||
|
||||
async def test_build_accept_op_missing_decision_raises_conflict_unresolved(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
review_store: dict[int, list[_FakeReviewRecord]] = {
|
||||
1: [_FakeReviewRecord(1, [{"type": "矛盾", "where": "第1段"}])]
|
||||
}
|
||||
|
||||
class _Session:
|
||||
async def commit(self) -> None: ...
|
||||
|
||||
@asynccontextmanager
|
||||
async def _sf_cm() -> AsyncIterator[Any]:
|
||||
yield _Session()
|
||||
|
||||
def _sf() -> Any:
|
||||
return _sf_cm()
|
||||
|
||||
# 桩出 accept_op 依赖:链 accept 节点重读审稿、跑 gate(缺判应在 gate 拦下,不触 digest/事务)。
|
||||
monkeypatch.setattr(
|
||||
"ww_api.services.chain_runner.SqlChapterRepo", lambda _s: _FakeChapterRepo({1: "正文"})
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"ww_api.services.chain_runner.SqlReviewRepo", lambda _s: _FakeReviewRepo(review_store)
|
||||
)
|
||||
monkeypatch.setattr("ww_api.services.chain_runner.SqlDigestAppendRepo", lambda _s: object())
|
||||
|
||||
async def _digest_builder(_s: Any) -> Any:
|
||||
return object()
|
||||
|
||||
accept_op = build_accept_op(session_factory=_sf, digest_gateway_builder=_digest_builder)
|
||||
|
||||
with pytest.raises(AppError) as exc:
|
||||
await accept_op(project_id=uuid.uuid4(), chapter_no=1, user_id=USER, decisions=[])
|
||||
assert exc.value.code == ErrorCode.CONFLICT_UNRESOLVED
|
||||
@@ -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
|
||||
|
||||
249
apps/api/ww_api/routers/chain.py
Normal file
249
apps/api/ww_api/routers/chain.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""多章工作流链端点(chain-workflow 设计 §6)——发起 / 续跑,进度走 `GET /jobs/{id}`。
|
||||
|
||||
三端点:
|
||||
- `POST /projects/{pid}/chains/{chain_key}/run` → 202 `ChainRunAccepted{job_id,...}`:
|
||||
写一行 jobs(queued, kind=chain) 立即返 202,链经 BackgroundTask `run_chain_job` 跑
|
||||
(**自建独立 session**,请求 session 已关闭)。无凭据 → 流前 `LLM_UNAVAILABLE`(503,
|
||||
调度 job 前拦下)。未知 chain_key → 404;`count` 1..50 越界 → 422(schema Field 约束)。
|
||||
- `GET /jobs/{job_id}`:复用现有 jobs 端点轮询进度/awaiting(不在本 router)。
|
||||
- `POST /projects/{pid}/chains/runs/{job_id}/resume` → 202:仅当 job=awaiting_input;
|
||||
带裁决经 BackgroundTask resume 续跑。非 awaiting 态 → 409。
|
||||
|
||||
不变量:链是**编排**新增、非业务重写——复用同一 `assemble`/审稿 spec/`run_accept_transaction`
|
||||
(经 `accept_op` 闭包注入),守 #1/#3/#4/#5/#9。token 绝不入 job result/状态/日志(§5)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain import JobRepo, ProjectRepo
|
||||
from ww_core.domain.job_repo import STATUS_AWAITING
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.schemas.chain import (
|
||||
ChainResumeRequest,
|
||||
ChainRunAccepted,
|
||||
ChainRunRequest,
|
||||
)
|
||||
from ww_api.services.chain_deps import (
|
||||
CheckpointerFactory,
|
||||
get_checkpointer_factory,
|
||||
)
|
||||
from ww_api.services.chain_runner import (
|
||||
JOB_KIND_CHAIN,
|
||||
build_accept_op,
|
||||
run_chain_job,
|
||||
)
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.foreshadow_scan import SessionFactory
|
||||
from ww_api.services.project_deps import (
|
||||
GatewayDigestBuilder,
|
||||
get_chain_gateway,
|
||||
get_digest_gateway_builder,
|
||||
get_job_repo,
|
||||
get_project_repo,
|
||||
get_session_factory,
|
||||
)
|
||||
|
||||
log = get_logger("ww.api.chain")
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["chain"])
|
||||
|
||||
# 本期唯一内置链(§1.3/§3)。未知 key → 404(系统边界,fail fast)。
|
||||
SUPPORTED_CHAINS = frozenset({"draft_volume"})
|
||||
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)]
|
||||
ChainGatewayDep = Annotated[Gateway, Depends(get_chain_gateway)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
|
||||
CheckpointerFactoryDep = Annotated[CheckpointerFactory, Depends(get_checkpointer_factory)]
|
||||
DigestBuilderDep = Annotated[GatewayDigestBuilder, Depends(get_digest_gateway_builder)]
|
||||
|
||||
_RUN_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||
404: {"model": ErrorEnvelope, "description": "项目或链 key 不存在"},
|
||||
422: {"model": ErrorEnvelope, "description": "请求参数非法(count 越界等)"},
|
||||
503: {"model": ErrorEnvelope, "description": "无可用 LLM 凭据"},
|
||||
}
|
||||
_RESUME_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||
404: {"model": ErrorEnvelope, "description": "项目或 job 不存在"},
|
||||
409: {"model": ErrorEnvelope, "description": "job 非 awaiting_input 态,不可续跑"},
|
||||
}
|
||||
|
||||
|
||||
async def _require_project(project_repo: ProjectRepo, project_id: uuid.UUID) -> None:
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/chains/{chain_key}/run",
|
||||
status_code=202,
|
||||
responses=_RUN_ERRORS,
|
||||
)
|
||||
async def run_chain(
|
||||
project_id: uuid.UUID,
|
||||
chain_key: str,
|
||||
body: ChainRunRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
background_tasks: BackgroundTasks,
|
||||
project_repo: ProjectRepoDep,
|
||||
job_repo: JobRepoDep,
|
||||
gateway: ChainGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503)
|
||||
session: SessionDep,
|
||||
session_factory: SessionFactoryDep,
|
||||
checkpointer_factory: CheckpointerFactoryDep,
|
||||
digest_gateway_builder: DigestBuilderDep,
|
||||
) -> ChainRunAccepted:
|
||||
"""发起多章链:写一行 job 返 202,链经 BackgroundTask 异步跑。
|
||||
|
||||
项目不存在 → 404;未知 chain_key → 404;无凭据 → 503(dep 解析阶段拦下);
|
||||
`count` 越界由 schema Field 在解析阶段 → 422。`gateway` 仅做请求阶段凭据探测——
|
||||
链本体在 BackgroundTask 里用独立 session 重建网关跑(请求 session 即将关闭)。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
await _require_project(project_repo, project_id)
|
||||
if chain_key not in SUPPORTED_CHAINS:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"unknown chain key: {chain_key}")
|
||||
|
||||
job = await job_repo.create(project_id, JOB_KIND_CHAIN)
|
||||
await session.commit() # job 行须在 202 返回前持久化(供前端立即轮询)。
|
||||
|
||||
accept_op = build_accept_op(
|
||||
session_factory=session_factory,
|
||||
digest_gateway_builder=digest_gateway_builder,
|
||||
request_id=request_id,
|
||||
)
|
||||
background_tasks.add_task(
|
||||
run_chain_job,
|
||||
session_factory,
|
||||
job.id,
|
||||
project_id=project_id,
|
||||
user_id=STUB_OWNER_ID,
|
||||
chain_key=chain_key,
|
||||
start_chapter_no=body.start_chapter_no,
|
||||
count=body.count,
|
||||
gateway=gateway,
|
||||
checkpointer_ctx=checkpointer_factory,
|
||||
accept_op=accept_op,
|
||||
chapter_repo_factory=_chapter_repo_factory,
|
||||
review_repo_factory=_review_repo_factory,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
log.info(
|
||||
"chain_run_scheduled",
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
job_id=str(job.id),
|
||||
chain_key=chain_key,
|
||||
start_chapter_no=body.start_chapter_no,
|
||||
count=body.count,
|
||||
)
|
||||
response.status_code = 202
|
||||
return ChainRunAccepted(
|
||||
job_id=job.id,
|
||||
chain_key=chain_key,
|
||||
start_chapter_no=body.start_chapter_no,
|
||||
count=body.count,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/chains/runs/{job_id}/resume",
|
||||
status_code=202,
|
||||
responses=_RESUME_ERRORS,
|
||||
)
|
||||
async def resume_chain(
|
||||
project_id: uuid.UUID,
|
||||
job_id: uuid.UUID,
|
||||
body: ChainResumeRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
background_tasks: BackgroundTasks,
|
||||
project_repo: ProjectRepoDep,
|
||||
job_repo: JobRepoDep,
|
||||
gateway: ChainGatewayDep,
|
||||
session: SessionDep,
|
||||
session_factory: SessionFactoryDep,
|
||||
checkpointer_factory: CheckpointerFactoryDep,
|
||||
digest_gateway_builder: DigestBuilderDep,
|
||||
) -> ChainRunAccepted:
|
||||
"""裁决续跑:仅当 job=awaiting_input,带裁决经 BackgroundTask resume。
|
||||
|
||||
项目不存在 → 404;job 不存在 → 404;非 awaiting 态 → 409(CONFLICT)。
|
||||
resume 经 `Command(resume=decisions)` 从 interrupt 续跑(同 thread_id=job_id)。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
await _require_project(project_repo, project_id)
|
||||
job = await job_repo.get(job_id)
|
||||
if job is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"job not found: {job_id}")
|
||||
if job.status != STATUS_AWAITING:
|
||||
raise AppError(
|
||||
ErrorCode.CONFLICT,
|
||||
f"job {job_id} 非 awaiting_input 态(当前 {job.status}),不可续跑",
|
||||
{"job_status": job.status},
|
||||
)
|
||||
|
||||
accept_op = build_accept_op(
|
||||
session_factory=session_factory,
|
||||
digest_gateway_builder=digest_gateway_builder,
|
||||
request_id=request_id,
|
||||
)
|
||||
background_tasks.add_task(
|
||||
run_chain_job,
|
||||
session_factory,
|
||||
job_id,
|
||||
project_id=project_id,
|
||||
user_id=STUB_OWNER_ID,
|
||||
chain_key=job.kind if job.kind in SUPPORTED_CHAINS else "draft_volume",
|
||||
start_chapter_no=1, # resume 不重置区间:图从检查点续,初值不再使用。
|
||||
count=1,
|
||||
gateway=gateway,
|
||||
checkpointer_ctx=checkpointer_factory,
|
||||
accept_op=accept_op,
|
||||
chapter_repo_factory=_chapter_repo_factory,
|
||||
review_repo_factory=_review_repo_factory,
|
||||
resume_decisions=body.decisions,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
log.info(
|
||||
"chain_resume_scheduled",
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
job_id=str(job_id),
|
||||
decision_count=len(body.decisions),
|
||||
)
|
||||
response.status_code = 202
|
||||
return ChainRunAccepted(
|
||||
job_id=job_id,
|
||||
chain_key="draft_volume",
|
||||
start_chapter_no=0,
|
||||
count=0,
|
||||
)
|
||||
|
||||
|
||||
def _chapter_repo_factory(session: AsyncSession) -> object:
|
||||
"""链节点(review 重读草稿)按 session 建 chapter draft repo。"""
|
||||
from ww_core.domain.chapter_repo import SqlChapterRepo
|
||||
|
||||
return SqlChapterRepo(session)
|
||||
|
||||
|
||||
def _review_repo_factory(session: AsyncSession) -> object:
|
||||
"""链节点(review 落留痕 / decide 重读)按 session 建审稿 repo。"""
|
||||
from ww_core.domain.review_repo import SqlReviewRepo
|
||||
|
||||
return SqlReviewRepo(session)
|
||||
54
apps/api/ww_api/schemas/chain.py
Normal file
54
apps/api/ww_api/schemas/chain.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""多章工作流链端点的请求/响应 schema(chain-workflow 设计 §6)。
|
||||
|
||||
snake_case(命名契约,见 memory/gotchas)。前端经 OpenAPI→TS 客户端消费——改字段
|
||||
须 `pnpm gen:api` 重生成。
|
||||
|
||||
- `ChainRunRequest`:发起一条链(起始章 + 章数)。`count` 1..50(系统边界校验,fail fast)。
|
||||
- `ChainRunAccepted`:202 受理回执(job_id + 回显请求参数,供前端轮询 `GET /jobs/{id}`)。
|
||||
- `ChainResumeRequest`:裁决续跑(复用 `ConflictDecision`,对 awaiting 章的冲突逐条裁决)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ww_api.schemas.projects import ConflictDecision
|
||||
|
||||
# 链一次最多写 K 章(系统边界,避免一条 run 无界占用;§6 校验)。
|
||||
MAX_CHAIN_COUNT = 50
|
||||
MIN_CHAIN_COUNT = 1
|
||||
|
||||
|
||||
class ChainRunRequest(BaseModel):
|
||||
"""发起多章链:从 `start_chapter_no` 起循环写 `count` 章(区间含两端)。
|
||||
|
||||
`count` 1..50(>50 → 422 VALIDATION,由 Field 约束在边界拦下,不进 BackgroundTask);
|
||||
`start_chapter_no >= 1`。
|
||||
"""
|
||||
|
||||
start_chapter_no: int = Field(ge=1, description="起始章号(含),>=1")
|
||||
count: int = Field(
|
||||
ge=MIN_CHAIN_COUNT,
|
||||
le=MAX_CHAIN_COUNT,
|
||||
description="本 run 连续写的章数(1..50,含起始章)",
|
||||
)
|
||||
|
||||
|
||||
class ChainRunAccepted(BaseModel):
|
||||
"""链受理回执(202):job_id + 回显请求参数(前端走 `GET /jobs/{id}` 轮询进度/awaiting)。"""
|
||||
|
||||
job_id: uuid.UUID
|
||||
chain_key: str
|
||||
start_chapter_no: int
|
||||
count: int
|
||||
|
||||
|
||||
class ChainResumeRequest(BaseModel):
|
||||
"""裁决续跑:对 awaiting 章最近审稿的每个冲突逐条裁决(复用 `ConflictDecision`)。"""
|
||||
|
||||
decisions: list[ConflictDecision] = Field(
|
||||
default_factory=list,
|
||||
description="对 awaiting 章冲突的裁决清单(采纳/忽略/手改)",
|
||||
)
|
||||
44
apps/api/ww_api/services/chain_deps.py
Normal file
44
apps/api/ww_api/services/chain_deps.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""多章链的 checkpointer 注入缝(chain-workflow 设计 §4)——激活休眠资产。
|
||||
|
||||
`get_checkpointer()` 是可注入缝:
|
||||
- **运行时**:返 `AsyncPostgresSaver`(`langgraph.checkpoint.postgres.aio`),连 `DATABASE_URL`,
|
||||
持久控制流位置 + pending handle(跨小时可恢复)。检查点表由迁移/CI 调 `setup_checkpointer`
|
||||
建(C3),本缝**绝不**触碰 DDL(CLAUDE.md「LangGraph」:`setup()` 只在 migrations/CI)。
|
||||
- **测试**:经 `app.dependency_overrides[get_checkpointer]` 注 `MemorySaver`(同进程跨两次
|
||||
invoke 可恢复 interrupt),绝不连真 Postgres、绝不真 LLM。
|
||||
|
||||
注:`AsyncPostgresSaver.from_conn_string` 是 async 上下文管理器——其连接生命周期须横跨整条
|
||||
链 run(含 interrupt 暂停后的 resume)。故 `run_chain_job` 在自身 task 内打开/关闭 saver
|
||||
上下文(见 `chain_runner`),本缝只提供「按需建一个 saver 上下文管理器」的工厂。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ww_config import get_settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
|
||||
# checkpointer 工厂:`()` → async 上下文管理器,进入得一个 `BaseCheckpointSaver`。
|
||||
# 运行时 = Postgres saver 上下文(连接随上下文开/关);测试注一个产 MemorySaver 的工厂。
|
||||
CheckpointerFactory = Callable[[], AbstractAsyncContextManager[Any]]
|
||||
|
||||
|
||||
def get_checkpointer_factory() -> CheckpointerFactory:
|
||||
"""运行时 checkpointer 工厂:建连 `DATABASE_URL` 的 `AsyncPostgresSaver` 上下文。
|
||||
|
||||
`from_conn_string` 返回的 async-CM 在 `run_chain_job` 内 `async with` 打开——其连接
|
||||
存活整条链(含 interrupt→resume);run 结束关闭。测试经 dep 覆盖注 MemorySaver 工厂。
|
||||
"""
|
||||
|
||||
def _factory() -> AbstractAsyncContextManager[BaseCheckpointSaver[Any]]:
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
|
||||
# langgraph 的 Postgres saver 走 psycopg(同步驱动串);用 sync 串建连接。
|
||||
return AsyncPostgresSaver.from_conn_string(get_settings().database_url_sync)
|
||||
|
||||
return _factory
|
||||
294
apps/api/ww_api/services/chain_runner.py
Normal file
294
apps/api/ww_api/services/chain_runner.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""多章链长任务 runner(chain-workflow 设计 §5)——`run_chain_job` 在 jobs 壳里跑链图。
|
||||
|
||||
复用 `run_job` 的生命周期心智(set_running → work → complete/fail),但链多一态:
|
||||
**interrupt 命中 → `awaiting_input`**(等作者裁决续跑)。故不直接复用 `run_job`,而是
|
||||
仿其壳自建独立 session 写 job 状态,work 本体 = 驱动 LangGraph 链图。
|
||||
|
||||
纪律(守不变量):
|
||||
- **#1/#5**:图节点各自短事务落领域表(`chapters`/`chapter_reviews`/`chapter_digests`);
|
||||
checkpointer 只存控制流;resume 重读领域表。本 runner 不持业务事务。
|
||||
- **#3/#4**:`accept_op`(apps/api 注入)跑冲突 gate → 终稿提炼 digest → 单原子验收事务 →
|
||||
伏笔到期扫描,唯一正文写经此。
|
||||
- **token 不泄漏**:job result/status/日志只记章号/计数/标志,绝不含 prompt/正文/token。
|
||||
- **错误脱敏**:复用 `_classify_job_error`(AppError 透传安全文案,其余通用文案,P0-3)。
|
||||
|
||||
可测性:`session_factory`/`gateway`/`checkpointer`/`accept_op` 全可注入——单测直接 `await`
|
||||
它,注 fake session 工厂 + mock 网关 + MemorySaver + fake accept_op(绝不联网/真 DB/真 LLM)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.types import Command
|
||||
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
|
||||
from ww_core.domain.digest_repo import SqlDigestAppendRepo
|
||||
from ww_core.domain.job_repo import SqlJobRepo
|
||||
from ww_core.domain.review_repo import SqlReviewRepo
|
||||
from ww_core.memory import assemble
|
||||
from ww_core.memory.sql_repositories import sql_memory_repos
|
||||
from ww_core.orchestrator import REVIEW_SPECS, GatewayRun
|
||||
from ww_core.orchestrator.chain import build_chain_graph, initial_chain_state
|
||||
from ww_llm_gateway import Gateway
|
||||
|
||||
from ww_api.schemas.projects import ConflictDecision
|
||||
from ww_api.services.accept_service import (
|
||||
assert_conflicts_resolved,
|
||||
run_accept_transaction,
|
||||
)
|
||||
from ww_api.services.digest_extraction import extract_digest_facts
|
||||
from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan
|
||||
from ww_api.services.job_runner import _classify_job_error
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
# 链 job 的 kind(jobs.kind 自由 Text 列,零迁移复用;§7)。
|
||||
JOB_KIND_CHAIN = "chain"
|
||||
|
||||
|
||||
# 「按 session 建 digest 网关」缝:accept 节点在自建短事务里需 light 档网关跑终稿提炼。
|
||||
DigestGatewayBuilder = Callable[[Any], Awaitable[Gateway]]
|
||||
|
||||
|
||||
def build_accept_op(
|
||||
*,
|
||||
session_factory: SessionFactory,
|
||||
digest_gateway_builder: DigestGatewayBuilder,
|
||||
request_id: str | None = None,
|
||||
) -> AcceptOp:
|
||||
"""组装单章验收落库闭包(apps/api,守不变量 #3/#4)。
|
||||
|
||||
链 `accept_chapter` 节点经此闭包把作者裁决落库——每章**独立短事务**(自建 session):
|
||||
重读最近审稿 → 冲突 gate(缺判 → `CONFLICT_UNRESOLVED`,链失败)→ 终稿(=已存草稿)提炼
|
||||
digest(事务外)→ `run_accept_transaction` 单原子提交 → 提交后伏笔到期扫描(自建 session)。
|
||||
|
||||
自动链无作者改稿:`final_text` = `chapters` 里该章草稿正文(write 节点所落)。
|
||||
`decisions` 为 dict 清单(interrupt 恢复值 / 无冲突 []),此处校验为 `ConflictDecision`。
|
||||
"""
|
||||
|
||||
async def accept_op(
|
||||
*,
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
user_id: uuid.UUID,
|
||||
decisions: list[Any],
|
||||
) -> None:
|
||||
parsed_decisions = [
|
||||
d if isinstance(d, ConflictDecision) else ConflictDecision.model_validate(d)
|
||||
for d in (decisions or [])
|
||||
]
|
||||
async with session_factory() as session:
|
||||
chapter_repo: ChapterRepo = SqlChapterRepo(session)
|
||||
review_repo = SqlReviewRepo(session)
|
||||
digest_repo = SqlDigestAppendRepo(session)
|
||||
|
||||
history = await review_repo.list_for_chapter(project_id, chapter_no)
|
||||
latest_review = history[0] if history else None
|
||||
# 冲突 gate(R5,事务前拦截,不写库;缺判 → CONFLICT_UNRESOLVED 抛,链失败)。
|
||||
assert_conflicts_resolved(latest_review, parsed_decisions)
|
||||
|
||||
draft = await chapter_repo.get_draft(project_id, chapter_no)
|
||||
final_text = draft.content if draft is not None else ""
|
||||
|
||||
gateway = await digest_gateway_builder(session)
|
||||
# R2:终稿 digest 提炼在开原子事务之前(别在持开事务里跨网络调 LLM)。
|
||||
digest_facts = await extract_digest_facts(
|
||||
gateway,
|
||||
final_text=final_text,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
)
|
||||
await run_accept_transaction(
|
||||
session=session,
|
||||
chapter_repo=chapter_repo,
|
||||
digest_repo=digest_repo,
|
||||
review_repo=review_repo,
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
final_text=final_text,
|
||||
digest_facts=digest_facts,
|
||||
latest_review=latest_review,
|
||||
decisions=parsed_decisions,
|
||||
)
|
||||
# 验收提交后伏笔到期扫描(自建独立 session,§6.2;与单章端点同纪律)。
|
||||
await run_overdue_scan(
|
||||
session_factory,
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
return accept_op
|
||||
|
||||
|
||||
# `()` → async-CM,进入得一个 langgraph checkpointer(Postgres 运行时 / MemorySaver 测试)。
|
||||
CheckpointerCtx = Callable[[], AbstractAsyncContextManager[Any]]
|
||||
|
||||
#: apps/api 注入的「按 session 建 chapter draft repo」工厂(review 节点重读草稿用)。
|
||||
ChapterRepoFactory = Callable[[Any], Any]
|
||||
#: apps/api 注入的「按 session 建审稿留痕 repo」工厂(review/decide 节点用)。
|
||||
ReviewRepoFactory = Callable[[Any], Any]
|
||||
#: 单章验收落库闭包(apps/api 组装:冲突 gate + digest + 验收事务 + 到期扫描)。
|
||||
AcceptOp = Callable[..., Awaitable[None]]
|
||||
|
||||
|
||||
def _chain_result(state: dict[str, Any], *, awaiting_chapter: int | None) -> dict[str, Any]:
|
||||
"""从图 state 取非密进度摘要(绝不含正文/token;§5)。
|
||||
|
||||
`awaiting_chapter` 非 None = interrupt 命中该章待裁决;None 且 completed = 全跑完。
|
||||
"""
|
||||
written = list(state.get("written") or [])
|
||||
return {
|
||||
"chain_key": state.get("chain_key"),
|
||||
"written": written,
|
||||
"completed": awaiting_chapter is None,
|
||||
"awaiting_chapter": awaiting_chapter,
|
||||
}
|
||||
|
||||
|
||||
def _extract_awaiting_chapter(final: dict[str, Any]) -> int | None:
|
||||
"""从图返回值判 interrupt:有 `__interrupt__` → 取暂停章号;否则 None(跑完)。
|
||||
|
||||
interrupt 载荷 = `{"chapter_no": n}`(见链图 `_accept` 节点)。LangGraph 把它放在
|
||||
`final["__interrupt__"]`(Interrupt 对象列表)。容错读 `.value`/dict 两种形。
|
||||
"""
|
||||
interrupts = final.get("__interrupt__")
|
||||
if not interrupts:
|
||||
return None
|
||||
first = interrupts[0]
|
||||
payload = getattr(first, "value", first)
|
||||
if isinstance(payload, dict):
|
||||
chapter_no = payload.get("chapter_no")
|
||||
return int(chapter_no) if chapter_no is not None else None
|
||||
return None
|
||||
|
||||
|
||||
async def run_chain_job(
|
||||
session_factory: SessionFactory,
|
||||
job_id: uuid.UUID,
|
||||
*,
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
chain_key: str,
|
||||
start_chapter_no: int,
|
||||
count: int,
|
||||
gateway: GatewayRun,
|
||||
checkpointer_ctx: CheckpointerCtx,
|
||||
accept_op: AcceptOp,
|
||||
chapter_repo_factory: ChapterRepoFactory,
|
||||
review_repo_factory: ReviewRepoFactory,
|
||||
resume_decisions: list[ConflictDecision] | None = None,
|
||||
review_specs: Sequence[Any] = REVIEW_SPECS,
|
||||
request_id: str | None = None,
|
||||
) -> None:
|
||||
"""跑/续一条多章链:set_running → 驱动链图 → 据返回置 done/awaiting_input/failed。
|
||||
|
||||
`resume_decisions is None` → 初始 run(`graph.ainvoke(initial_state, config)`);
|
||||
否则 resume(`graph.ainvoke(Command(resume=decisions), config)`),从 interrupt 续跑。
|
||||
`thread_id = str(job_id)`:同 job 的初始/续跑落同一检查点 thread。
|
||||
|
||||
checkpointer 上下文(Postgres 连接 / MemorySaver)横跨整次 invoke;本 runner 在 task
|
||||
内打开/关闭它。异常一律被吞(后台任务边界),脱敏后置 job failed,不冒泡崩进程。
|
||||
"""
|
||||
try:
|
||||
async with checkpointer_ctx() as checkpointer:
|
||||
await _set_running(session_factory, job_id)
|
||||
config: RunnableConfig = {"configurable": {"thread_id": str(job_id)}}
|
||||
graph = build_chain_graph(
|
||||
gateway,
|
||||
session_factory=session_factory,
|
||||
memory_repos_factory=sql_memory_repos,
|
||||
chapter_repo_factory=chapter_repo_factory,
|
||||
review_repo_factory=review_repo_factory,
|
||||
assemble=assemble,
|
||||
accept_op=accept_op,
|
||||
review_specs=review_specs,
|
||||
checkpointer=checkpointer,
|
||||
)
|
||||
if resume_decisions is None:
|
||||
initial = initial_chain_state(
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
start_chapter_no=start_chapter_no,
|
||||
count=count,
|
||||
chain_key=chain_key,
|
||||
)
|
||||
raw_final = await graph.ainvoke(initial, config=config)
|
||||
else:
|
||||
resume_value = [d.model_dump() for d in resume_decisions]
|
||||
raw_final = await graph.ainvoke(Command(resume=resume_value), config=config)
|
||||
|
||||
final: dict[str, Any] = dict(raw_final)
|
||||
awaiting_chapter = _extract_awaiting_chapter(final)
|
||||
result = _chain_result(final, awaiting_chapter=awaiting_chapter)
|
||||
await _finish(session_factory, job_id, result, awaiting=awaiting_chapter is not None)
|
||||
log.info(
|
||||
"chain_job_settled",
|
||||
job_id=str(job_id),
|
||||
request_id=request_id,
|
||||
awaiting_chapter=awaiting_chapter,
|
||||
written_count=len(result["written"]),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — 后台任务边界:记错误 + 置 job failed,不冒泡。
|
||||
log.error("chain_job_failed", job_id=str(job_id), request_id=request_id, error=str(exc))
|
||||
await _mark_failed(session_factory, job_id, _classify_job_error(exc), request_id)
|
||||
|
||||
|
||||
async def _set_running(session_factory: SessionFactory, job_id: uuid.UUID) -> None:
|
||||
"""独立短事务把 job 置 running(图驱动开始前)。"""
|
||||
async with session_factory() as session:
|
||||
await SqlJobRepo(session).set_running(job_id)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _finish(
|
||||
session_factory: SessionFactory,
|
||||
job_id: uuid.UUID,
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
awaiting: bool,
|
||||
) -> None:
|
||||
"""据是否 interrupt 把 job 置 awaiting_input(待裁决)或 done(跑完),独立短事务。"""
|
||||
async with session_factory() as session:
|
||||
repo = SqlJobRepo(session)
|
||||
if awaiting:
|
||||
await repo.set_awaiting(job_id, result)
|
||||
else:
|
||||
await repo.complete(job_id, result)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _mark_failed(
|
||||
session_factory: SessionFactory,
|
||||
job_id: uuid.UUID,
|
||||
error: str,
|
||||
request_id: str | None,
|
||||
) -> None:
|
||||
"""在**全新** session 里把 job 置 failed(前一事务已因异常作废)。"""
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
await SqlJobRepo(session).fail(job_id, error)
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001 — 置失败态本身再炸只记日志,不冒泡。
|
||||
log.error(
|
||||
"chain_job_fail_mark_failed",
|
||||
job_id=str(job_id),
|
||||
request_id=request_id,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"JOB_KIND_CHAIN",
|
||||
"AcceptOp",
|
||||
"CheckpointerCtx",
|
||||
"DigestGatewayBuilder",
|
||||
"build_accept_op",
|
||||
"run_chain_job",
|
||||
]
|
||||
@@ -10,7 +10,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends
|
||||
@@ -399,6 +399,53 @@ async def build_writer_gateway(session: AsyncSession, store: CredentialStore) ->
|
||||
return await build_gateway_for_tier(session, store, "writer")
|
||||
|
||||
|
||||
# 多章链一条 run 内跨 writer(write)/analyst(review)/light(digest) 三档——单档网关的
|
||||
# chain_resolver 恒返该档链、忽略请求 tier,会把 review/digest 错路由到 writer。故链需一个
|
||||
# **按请求 tier 分派**的网关:并 union 三档所有 provider 的适配器 + 逐 tier 解析其链。
|
||||
_CHAIN_TIERS: tuple[Tier, ...] = ("writer", "analyst", "light")
|
||||
|
||||
|
||||
def _chain_for_tier(stored_by_tier: dict[Tier, Any], tier: Tier) -> list[Route]:
|
||||
"""据该 tier 的 DB 路由行构链;无行则退回全局默认(单 provider,M1 兼容)。"""
|
||||
stored = stored_by_tier.get(tier)
|
||||
if stored is None:
|
||||
return [resolve_route(tier)]
|
||||
primary_spec = f"{stored.provider}:{stored.model}"
|
||||
return chain_from_routing(tier, primary_spec, list(stored.fallback))
|
||||
|
||||
|
||||
async def build_chain_gateway(session: AsyncSession, store: CredentialStore) -> Gateway:
|
||||
"""装配多章链网关:按请求 tier(writer/analyst/light)分派,union 三档适配器 + 回退链。
|
||||
|
||||
与 `build_gateway_for_tier` 同凭据/适配器装配,但 `chain_resolver` 据 `req.tier` 返回
|
||||
对应档的链(不锁死单档)。至少一个可用适配器,否则 `LLM_UNAVAILABLE`(无任何凭据)。
|
||||
"""
|
||||
ledger = SqlAlchemyLedgerSink(session)
|
||||
routing_rows = await store.list_routing()
|
||||
stored_by_tier: dict[Tier, Any] = {r.tier: r for r in routing_rows if r.tier in _CHAIN_TIERS}
|
||||
|
||||
# union 三档链里所有 provider,逐个建可用适配器(未配凭据/未知 base_url 的跳过)。
|
||||
adapters: dict[str, ProviderAdapter] = {}
|
||||
for tier in _CHAIN_TIERS:
|
||||
for route in _chain_for_tier(stored_by_tier, tier):
|
||||
if route.provider in adapters:
|
||||
continue
|
||||
built = await _build_provider_adapter(store, route.provider)
|
||||
if built is not None:
|
||||
adapters[route.provider] = built
|
||||
if not adapters:
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
"多章链无任何已配置凭据的 provider,请先在设置中配置",
|
||||
{"tiers": list(_CHAIN_TIERS)},
|
||||
)
|
||||
|
||||
def _resolver(tier: Tier) -> list[Route]:
|
||||
return _chain_for_tier(stored_by_tier, tier)
|
||||
|
||||
return Gateway(adapters=adapters, ledger=ledger, chain_resolver=_resolver)
|
||||
|
||||
|
||||
async def get_writer_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
@@ -451,3 +498,35 @@ async def get_refine_gateway(
|
||||
"""回炉(writer 档位)的可注入网关缝。测试经 override 注 mock(产纯文本重写段)。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "writer")
|
||||
|
||||
|
||||
async def get_chain_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""多章链(write writer + review analyst + digest light,按请求 tier 分派)的可注入网关缝。
|
||||
|
||||
`POST .../chains/{key}/run` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE`
|
||||
(503,调度 job 之前拦下,避免凭空写注定失败的 job)。链本体在 BackgroundTask 里用
|
||||
`run_chain_job` 自建独立 session 重新构网关跑(请求 session 已关闭);本依赖只做请求阶段
|
||||
凭据探测。测试经 `app.dependency_overrides[get_chain_gateway]` 注 mock(绝不联网)。
|
||||
"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_chain_gateway(session, store)
|
||||
|
||||
|
||||
# 「按 session 建 digest 网关」缝类型(chain accept_op 终稿提炼按节点自建短事务的 session 建)。
|
||||
GatewayDigestBuilder = Callable[[AsyncSession], Awaitable[Gateway]]
|
||||
|
||||
|
||||
def _digest_gateway_builder(session: AsyncSession) -> Awaitable[Gateway]:
|
||||
"""链 accept 节点在自建短事务里建 light 档 digest 网关(终稿提炼)。"""
|
||||
return build_gateway_for_tier(session, SqlCredentialStore(session), "light")
|
||||
|
||||
|
||||
def get_digest_gateway_builder() -> GatewayDigestBuilder:
|
||||
"""返回「按 session 建 digest 网关」的缝(chain accept_op 终稿提炼用)。
|
||||
|
||||
BackgroundTask 自建短事务 → 节点内据该 session 现建 light 档网关。测试经
|
||||
`app.dependency_overrides[get_digest_gateway_builder]` 注返回 mock 的 builder(绝不联网)。
|
||||
"""
|
||||
return _digest_gateway_builder
|
||||
|
||||
Reference in New Issue
Block a user