"""F2 续写式链 continue_volume 端到端(真 pg + mock 网关零 token)。 证明续写式链闭环(不变量 #1/#5):`continue_volume` 每章写作以**上一章 accepted 正文末尾** 作前文引子(复用 `build_continuation_context`)。两章无冲突全自动: - 第二章写作请求的上下文须含第一章 accepted 正文(断前文注入); - DB 真源:两章 accepted + 两 digest 行。 回归守卫:`draft_volume`(非续写)第二章写作请求**不应**含第一章正文(区分两模式)。 镜像 `tests/test_chain_workflow_e2e.py`:真 `Gateway` + 假适配器(据 `req.output_schema` 分支 返 parsed/text,绝不联网)+ 真 `SqlAlchemyLedgerSink`;MemorySaver 检查点。无 pg → skip。 关键差异:写章假适配器**逐章返不同正文**(含章号标记),并**记录每次写章请求的 input**—— 故可断言第二章写章请求的 input 含第一章 accepted 正文标记(前文注入),而 draft_volume 不含。 """ 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 ( 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 _PROVIDER = "deepseek" _USAGE = ProviderUsage(input_tokens=13, output_tokens=7) # 每章正文含唯一章号标记,供「前文注入」断言(第二章请求须含第一章标记)。 _CHAPTER_MARK = "【第{n}章正文END】" _FORESHADOW_REVIEW = ForeshadowReview(planted=[], resolved=[]) _PACE_REVIEW = PaceReview(water=[], hook=True, beat_map=[1, 2, 3]) _STYLE_DRIFT = StyleDriftReview(score=88, segments=[]) class _RecordingChainAdapter: """实现 `ProviderAdapter`:写章逐章返带章号标记的正文 + 记录每次写章请求 input。 - `output_schema is None`(写章 `gateway.run`):自增计数返第 N 章正文,记录 req.input。 - 四审 schema → 无冲突 parsed(让图全自动跑完)。 - 其余(ChapterDigestFacts)→ 终稿 digest 提炼。 """ provider = _PROVIDER def __init__(self) -> None: self._write_count = 0 # (request_input, returned_text) 每次写章一条,按写章顺序。 self.write_calls: list[tuple[str, str]] = [] 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: self._write_count += 1 text = f"灵气如潮{_CHAPTER_MARK.format(n=self._write_count)}" self.write_calls.append((str(req.input), text)) return ProviderResult(text=text, parsed=None, usage=_USAGE) if schema is ContinuityReview: review = ContinuityReview(conflicts=[]) return ProviderResult(text=review.model_dump_json(), parsed=review, 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 ) 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(usage=_USAGE) @pytest.fixture async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]: """真 DB session 工厂;无 pg 时跳过。""" 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: _RecordingChainAdapter, session: AsyncSession) -> Gateway: return Gateway( adapters={_PROVIDER: adapter}, ledger=SqlAlchemyLedgerSink(session), resolver=resolve_route, ) def _chain_gateway_override( adapter: _RecordingChainAdapter, ) -> Callable[[AsyncSession], Gateway]: def _override(session: Annotated[AsyncSession, Depends(get_session)]) -> Gateway: return _gateway_for(adapter, session) return _override def _builder_override( adapter: _RecordingChainAdapter, ) -> Callable[[], Callable[[AsyncSession], Awaitable[Gateway]]]: 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]]]: @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: 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: _RecordingChainAdapter, saver: MemorySaver, e2e_sm: Any) -> Any: 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) app.dependency_overrides[get_chain_gateway_builder] = _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] = _builder_override(adapter) return app async def _run_two_chapter_chain( adapter: _RecordingChainAdapter, e2e_sm: async_sessionmaker[AsyncSession], chain_key: str, ) -> tuple[uuid.UUID, dict[str, Any]]: """跑两章链(无冲突全自动)→ 返 (project_uuid, job result)。调用方负责 cleanup。""" saver = MemorySaver() app = _build_app(adapter, saver, e2e_sm) transport = httpx.ASGITransport(app=app) async with LifespanManager(app): async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: create_resp = await client.post("/projects", json={"title": f"F2 {chain_key} 作品"}) 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/{chain_key}/run", json={"start_chapter_no": 1, "count": 2}, ) assert run_resp.status_code == 202, run_resp.text assert run_resp.json()["chain_key"] == chain_key job_id = run_resp.json()["job_id"] job = (await client.get(f"/jobs/{job_id}")).json() assert job["status"] == "done", job return project_uuid, job["result"] async def test_continue_volume_injects_prior_chapter_into_second_write( e2e_sm: async_sessionmaker[AsyncSession], ) -> None: """用例 1:continue_volume 两章 → 第二章写章请求含第一章 accepted 正文(前文注入)。""" adapter = _RecordingChainAdapter() project_uuid: uuid.UUID | None = None try: project_uuid, result = await _run_two_chapter_chain(adapter, e2e_sm, "continue_volume") assert result["written"] == [1, 2] assert result["completed"] is True # 两次写章请求被记录(按章序)。 assert len(adapter.write_calls) == 2 first_input, first_text = adapter.write_calls[0] second_input, _second_text = adapter.write_calls[1] # 第一章正文标记(accepted 后即此文本)。 chapter1_mark = _CHAPTER_MARK.format(n=1) # 核心断言:第二章写章请求上下文含第一章 accepted 正文(前文引子注入,不变量 #1/#5)。 assert chapter1_mark in second_input, second_input # 续写上下文结构标记(build_continuation_context)也应在第二章请求中。 assert "前文正文" in second_input # 第一章无前文 → 其请求不含「第1章标记」(首章占位降级,证明非凭空注入)。 assert chapter1_mark not in first_input # DB 真源:两章 accepted + 两 digest 行。 async with e2e_sm() as verify: accepted = ( ( await verify.execute( select(Chapter).where( Chapter.project_id == project_uuid, Chapter.status == "accepted" ) ) ) .scalars() .all() ) assert {c.chapter_no for c in accepted} == {1, 2} digests = ( ( await verify.execute( select(ChapterDigest).where(ChapterDigest.project_id == project_uuid) ) ) .scalars() .all() ) assert {d.chapter_no for d in digests} == {1, 2} finally: if project_uuid is not None: await _cleanup(e2e_sm, project_uuid) async def test_draft_volume_does_not_inject_prior_chapter_into_second_write( e2e_sm: async_sessionmaker[AsyncSession], ) -> None: """用例 2(回归守卫):draft_volume 第二章写章请求**不含**第一章正文(区分两模式)。""" adapter = _RecordingChainAdapter() project_uuid: uuid.UUID | None = None try: project_uuid, result = await _run_two_chapter_chain(adapter, e2e_sm, "draft_volume") assert result["written"] == [1, 2] assert len(adapter.write_calls) == 2 _first_input, _first_text = adapter.write_calls[0] second_input, _ = adapter.write_calls[1] chapter1_mark = _CHAPTER_MARK.format(n=1) # draft_volume 非续写:第二章写章请求不应含第一章正文(仅按记忆/digest 量产)。 assert chapter1_mark not in second_input # 亦无续写上下文结构标记。 assert "前文正文(续写须无缝承接)" not in second_input finally: if project_uuid is not None: await _cleanup(e2e_sm, project_uuid)