"""M2 端到端:写 → 审(一致性) → 裁决 → 验收(事务) → 摘要入库(真实 DB,零 token)。 证明 M2 闭环(不变量 #3/#4/#5): `POST /projects` → `POST .../draft`(SSE) → `PUT .../draft`(自动保存) → `POST .../review`(SSE,产 ≥1 冲突) → `GET .../reviews`(留痕) → `POST .../accept`(**残缺裁决 → 409 CONFLICT_UNRESOLVED,未写库**) → `POST .../accept`(**全量裁决 + 终稿 → 200**) 且 DB 为唯一真源:`chapters`(草稿+accepted 多版本) / `chapter_digests`(从**终稿**提炼) / `chapter_reviews`(裁决留痕) / `usage_ledger`(写/审/digest 三档位记账各落库)。 确定性 & 零成本(同 M1):真实 `Gateway` + 假适配器(吐固定 `Delta`/`parsed`,绝不联网) + 真实 `SqlAlchemyLedgerSink`(用量记账闭环也被走通)。无 pg → skip。 M2 多档位 + 结构化输出:假适配器据 `req.output_schema` 分支—— - writer/stream → 固定 token; - analyst/run(`ContinuityReview`)→ 故意产 ≥1 `Conflict`(测裁决 + gate); - light/run(`ChapterDigestFacts`)→ facts 内容可追溯到**终稿**(验证 digest 从终稿非草稿,#4)。 坑(见 memory/gotchas): - `LifespanManager` 触发 lifespan → `seed_stub_user`(owner_id FK 依赖它)。 - `get_sessionmaker` engine 绑定首个 loop → 每 DB 测试清缓存重建、结束 dispose。 - 网关 ledger / 验收-side repos 只 flush;端点/验收事务 commit → usage_ledger/留痕方落库。 - ASGITransport `raise_app_exceptions=True`:gate 409 是 `AppError` → 正常返回信封(不上抛)。 """ from __future__ import annotations import json import uuid from collections.abc import AsyncIterator, Callable from typing import Annotated import httpx import pytest from asgi_lifespan import LifespanManager from fastapi import Depends from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from ww_agents import Conflict, ContinuityReview from ww_api.services.digest_extraction import ChapterDigestFacts from ww_db import get_session, get_sessionmaker from ww_db.models import ( Chapter, ChapterDigest, ChapterReview, 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)默认都路由到 deepseek(config.tier_defaults)。 _PROVIDER = "deepseek" # 确定性流式写章 token。 _TOKENS = ["第", "一", "章", ":", "试炼之始。"] # 续审故意产的固定冲突(≥1 → 触发裁决 + gate)。 _CONFLICT = Conflict( type="设定违例", where="第3段:主角施展禁术", refs=["世界观·禁术条目", "第1章"], suggestion="改为消耗灵力的普通法术,符合前文设定", ) # 终稿里可被 digest 追溯的标记串(验证 digest 从终稿提炼,不变量 #4)。 _FINAL_MARKER = "终稿独有事件:主角在第十章觉醒血脉" # 各档位假用量(喂记账;证明三档位用量闭环各自落库)。 _USAGE = { "writer": ProviderUsage(input_tokens=11, output_tokens=5), "analyst": ProviderUsage(input_tokens=23, output_tokens=7), "light": ProviderUsage(input_tokens=17, output_tokens=9), } class _FakeMultiTierAdapter: """实现 `ProviderAdapter` Protocol:按调用形态/`output_schema` 分支,绝不联网。 - `stream()`:吐固定写章 token(writer/draft)。 - `complete()`:据 `req.output_schema` 返回对应 `parsed`: `ContinuityReview`(续审,产固定冲突)/ `ChapterDigestFacts`(digest,追溯终稿)。 每路均带 `ProviderUsage` → 网关据此各落一条 usage_ledger。 """ provider = _PROVIDER def __init__(self, *, conflicts: list[Conflict] | None = None) -> None: # 可配冲突数(主用例 ≥1;零冲突直通用例传 [])。 self._conflicts = [_CONFLICT] if conflicts is None else conflicts def capabilities(self) -> Capabilities: return Capabilities(structured_output=True) async def complete(self, req: LlmRequest, model: str) -> ProviderResult: if req.output_schema is ContinuityReview: review = ContinuityReview(conflicts=list(self._conflicts)) return ProviderResult( text=review.model_dump_json(), parsed=review, usage=_USAGE["analyst"], ) # 否则视为 digest 提炼(ChapterDigestFacts):把终稿标记塞进 summary → # 断言 digest 来自终稿(非审稿草稿,不变量 #4)。 facts = ChapterDigestFacts( summary=_FINAL_MARKER, events=["主角觉醒"], locations=["试炼之地"], ) return ProviderResult( text=facts.model_dump_json(), parsed=facts, usage=_USAGE["light"], ) async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]: for token in _TOKENS: yield StreamChunk(text=token) yield StreamChunk(usage=_USAGE["writer"]) @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 _parse_sse(raw: str) -> list[tuple[str, str]]: """把 text/event-stream 原文解析为 `(event, data)` 帧列表。""" frames: list[tuple[str, str]] = [] event: str | None = None data: str | None = None for line in raw.splitlines(): if line.startswith("event:"): event = line[len("event:") :].strip() elif line.startswith("data:"): data = line[len("data:") :].strip() elif line == "": if event is not None and data is not None: frames.append((event, data)) event, data = None, None if event is not None and data is not None: frames.append((event, data)) return frames def _gateway_override_factory( adapter: _FakeMultiTierAdapter, ) -> Callable[[AsyncSession], Gateway]: """构造一个依赖覆盖:真实 Gateway + 给定假适配器 + 真实 ledger(请求 session)。 ledger 用**请求 session**(`Depends(get_session)`,FastAPI 按请求缓存,与端点/验收 事务同一实例)→ 由端点流末 / 验收事务的 `commit()` 落库(同 M1 回归校验)。 """ def _override(session: Annotated[AsyncSession, Depends(get_session)]) -> Gateway: return Gateway( adapters={_PROVIDER: adapter}, ledger=SqlAlchemyLedgerSink(session), resolver=resolve_route, ) return _override async def _cleanup(e2e_sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None: """按 FK 顺序清理(无级联的先删,再删 project;chapters/digests/reviews 经 FK 级联)。""" async with e2e_sm() as cleanup: await cleanup.execute(delete(UsageLedger).where(UsageLedger.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() async def test_m2_closed_loop_write_review_adjudicate_accept_digest( e2e_sm: async_sessionmaker[AsyncSession], ) -> None: from ww_api.main import create_app from ww_api.services.project_deps import ( get_digest_gateway, get_review_gateway, get_writer_gateway, ) adapter = _FakeMultiTierAdapter() # 默认产 1 个冲突 override = _gateway_override_factory(adapter) app = create_app() # 三档位网关均经 override 注入(真实 Gateway + 同一假适配器 + 真实 ledger)。 app.dependency_overrides[get_writer_gateway] = override app.dependency_overrides[get_review_gateway] = override app.dependency_overrides[get_digest_gateway] = override 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: # 1) 立项 → 201。 title = "M2 E2E 闭环验证作品" create_resp = await client.post( "/projects", json={"title": title, "genre": "玄幻", "logline": "少年试炼"}, ) assert create_resp.status_code == 201 project_id = create_resp.json()["id"] project_uuid = uuid.UUID(project_id) # 2) 流式写章草稿(SSE)→ 重组文本。 draft_resp = await client.post(f"/projects/{project_id}/chapters/1/draft") assert draft_resp.status_code == 200 frames = _parse_sse(draft_resp.text) token_frames = [d for (ev, d) in frames if ev == "token"] assert [d for (ev, d) in frames if ev == "error"] == [] assert len([d for (ev, d) in frames if ev == "done"]) == 1 draft_text = "".join(json.loads(d)["text"] for d in token_frames) assert draft_text == "".join(_TOKENS) # 自动保存草稿(version=1, status='draft')。 save_resp = await client.put( f"/projects/{project_id}/chapters/1/draft", json={"text": draft_text}, ) assert save_resp.status_code == 200 assert save_resp.json()["version"] == 1 assert save_resp.json()["status"] == "draft" # 3) 续审(SSE,body draft=当前草稿)→ ≥1 section(continuity,done) + # ≥1 conflict + 1 done、无 error。 review_resp = await client.post( f"/projects/{project_id}/chapters/1/review", json={"draft": draft_text}, ) assert review_resp.status_code == 200 assert review_resp.headers["content-type"].startswith("text/event-stream") rframes = _parse_sse(review_resp.text) section_frames = [json.loads(d) for (ev, d) in rframes if ev == "section"] conflict_frames = [json.loads(d) for (ev, d) in rframes if ev == "conflict"] rdone = [d for (ev, d) in rframes if ev == "done"] rerror = [d for (ev, d) in rframes if ev == "error"] assert rerror == [] assert len(rdone) == 1 continuity_sections = [ s for s in section_frames if s.get("name") == "continuity" and s.get("status") == "done" ] assert len(continuity_sections) >= 1 assert len(conflict_frames) >= 1 assert conflict_frames[0]["type"] == _CONFLICT.type assert conflict_frames[0]["suggestion"] == _CONFLICT.suggestion # 4) GET 历史含刚落的留痕(conflicts 非空、decisions 为空)。 hist_resp = await client.get(f"/projects/{project_id}/chapters/1/reviews") assert hist_resp.status_code == 200 reviews = hist_resp.json()["reviews"] assert len(reviews) >= 1 latest = reviews[0] assert len(latest["conflicts"]) >= 1 assert latest["decisions"] is None # 5) 冲突 gate(未决禁验收):残缺裁决(空 decisions,缺 conflict_index=0) # → 409 CONFLICT_UNRESOLVED,details 命中缺项。 final_text = draft_text + "\n\n" + _FINAL_MARKER gate_resp = await client.post( f"/projects/{project_id}/chapters/1/accept", json={"final_text": final_text, "decisions": []}, ) assert gate_resp.status_code == 409 gate_body = gate_resp.json() assert gate_body["error"]["code"] == "CONFLICT_UNRESOLVED" details = gate_body["error"]["details"] assert 0 in details["missing_conflict_indices"] assert details["conflict_count"] >= 1 # 6) 完整验收:全量裁决 + 终稿(与草稿不同,验证 #4)→ 200。 accept_resp = await client.post( f"/projects/{project_id}/chapters/1/accept", json={ "final_text": final_text, "decisions": [ {"conflict_index": 0, "verdict": "accept", "note": "采纳改法"} ], }, ) assert accept_resp.status_code == 200 acc = accept_resp.json() assert acc["accepted_version"] == 2 # 草稿 v1 + accepted v2 assert acc["digest_added"] is True assert acc["decisions_recorded"] == 1 assert acc["review_id"] is not None # 7) DB 真源断言(经 e2e session)。 assert project_uuid is not None async with e2e_sm() as verify: # chapters:草稿 v1 仍在 + 新增 accepted v2(content==final_text)。 chapter_rows = ( ( await verify.execute( select(Chapter) .where(Chapter.project_id == project_uuid) .order_by(Chapter.version) ) ) .scalars() .all() ) assert len(chapter_rows) == 2 draft_row, accepted_row = chapter_rows[0], chapter_rows[1] assert draft_row.version == 1 assert draft_row.status == "draft" assert draft_row.content == draft_text assert accepted_row.version == 2 assert accepted_row.status == "accepted" assert accepted_row.content == final_text # chapter_digests:append 一行,facts 可追溯到**终稿**(#4:summary 含终稿标记)。 digest_rows = ( ( await verify.execute( select(ChapterDigest).where(ChapterDigest.project_id == project_uuid) ) ) .scalars() .all() ) assert len(digest_rows) == 1 digest_facts = digest_rows[0].facts assert digest_facts["summary"] == _FINAL_MARKER # 终稿标记不在草稿里 → 证明 digest 从终稿而非审稿草稿提炼。 assert _FINAL_MARKER not in draft_text # chapter_reviews:留痕行 decisions 已写(裁决留痕)。 review_rows = ( ( await verify.execute( select(ChapterReview).where(ChapterReview.project_id == project_uuid) ) ) .scalars() .all() ) assert len(review_rows) == 1 assert len(review_rows[0].conflicts) >= 1 decisions = review_rows[0].decisions assert decisions is not None assert decisions["items"][0]["conflict_index"] == 0 assert decisions["items"][0]["verdict"] == "accept" # usage_ledger:写章 + 续审 + digest 提炼三档位各记一条(≥3)。 ledger_rows = ( ( await verify.execute( select(UsageLedger).where(UsageLedger.project_id == project_uuid) ) ) .scalars() .all() ) assert len(ledger_rows) >= 3 # 三档位 input_tokens 各异 → 验证各档位调用都真落库(事务已 commit)。 input_token_set = {r.input_tokens for r in ledger_rows} assert _USAGE["writer"].input_tokens in input_token_set assert _USAGE["analyst"].input_tokens in input_token_set assert _USAGE["light"].input_tokens in input_token_set finally: if project_uuid is not None: await _cleanup(e2e_sm, project_uuid) async def test_m2_zero_conflict_accept_passes_gate_directly( e2e_sm: async_sessionmaker[AsyncSession], ) -> None: """零冲突直通:续审产 conflicts=[] → 空裁决 accept 直接 200(gate 直通)。""" from ww_api.main import create_app from ww_api.services.project_deps import ( get_digest_gateway, get_review_gateway, get_writer_gateway, ) adapter = _FakeMultiTierAdapter(conflicts=[]) # 零冲突路径 override = _gateway_override_factory(adapter) app = create_app() app.dependency_overrides[get_writer_gateway] = override app.dependency_overrides[get_review_gateway] = override app.dependency_overrides[get_digest_gateway] = override 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": "M2 零冲突直通作品"}) assert create_resp.status_code == 201 project_id = create_resp.json()["id"] project_uuid = uuid.UUID(project_id) draft_resp = await client.post(f"/projects/{project_id}/chapters/1/draft") draft_text = "".join( json.loads(d)["text"] for (ev, d) in _parse_sse(draft_resp.text) if ev == "token" ) await client.put( f"/projects/{project_id}/chapters/1/draft", json={"text": draft_text}, ) # 续审产零冲突 → 留痕 conflicts=[]。 review_resp = await client.post( f"/projects/{project_id}/chapters/1/review", json={"draft": draft_text}, ) assert review_resp.status_code == 200 rframes = _parse_sse(review_resp.text) assert [d for (ev, d) in rframes if ev == "conflict"] == [] # 空裁决 accept → gate 直通 200。 final_text = draft_text + "\n" + _FINAL_MARKER accept_resp = await client.post( f"/projects/{project_id}/chapters/1/accept", json={"final_text": final_text, "decisions": []}, ) assert accept_resp.status_code == 200 acc = accept_resp.json() assert acc["accepted_version"] == 2 assert acc["digest_added"] is True assert acc["decisions_recorded"] == 0 # DB 断言:accepted 行落库、digest 从终稿。 assert project_uuid is not None async with e2e_sm() as verify: accepted = ( await verify.execute( select(Chapter).where( Chapter.project_id == project_uuid, Chapter.status == "accepted", ) ) ).scalar_one() assert accepted.content == final_text digest = ( await verify.execute( select(ChapterDigest).where(ChapterDigest.project_id == project_uuid) ) ).scalar_one() assert digest.facts["summary"] == _FINAL_MARKER finally: if project_uuid is not None: await _cleanup(e2e_sm, project_uuid)