"""多章链长任务 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 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]] # 「按 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 # 冲突 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, 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", ]