diff --git a/tests/test_competitor_generators_e2e.py b/tests/test_competitor_generators_e2e.py new file mode 100644 index 0000000..d9047fe --- /dev/null +++ b/tests/test_competitor_generators_e2e.py @@ -0,0 +1,401 @@ +"""Scope B 端到端:4 个竞品生成器(续写/扩写/降AI率/拆书)通用生成端点。 + +真实 DB(pg,无则 skip)+ 零 token MOCK 网关。证明 Scope B 竞品快赢闭环: +- 4 生成器均经一条通用执行路径 `POST /projects/{id}/skills/{tool_key}/generate`; +- 续写(with_prior_chapter):端点先读该章最新 accepted/draft 正文,注入续写上下文 + (断 fake 适配器收到的 `req.input` 含前文正文 — 验证跨层数据流,不变量 #1 DB 为真相源); +- 扩写/降AI率(text_input):作者原文经 `body.text` 路由进上下文(断 `req.input` 含原文); +- 拆书(text_input):结构化输出 themes/archetypes/structure/hooks(断 preview 字段齐全); +- **不变量 #3(预览不写库)**:4 者均仅记 usage_ledger、业务表(chapters/world_entities/ + outline)零新增;token/正文不入响应外日志(断响应体无敏感字样)。 + +镜像 `tests/test_t6_toolbox_e2e.py` 的范式:override `get_tier_gateway_builder` 返回单个真 +`Gateway`(假适配器据 `req.output_schema` 分支返回 parsed + 真实 `SqlAlchemyLedgerSink`)。 +fake 适配器额外按 output_schema 记下收到的 `req.input`,供路由断言。 +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Annotated + +import httpx +import pytest +from asgi_lifespan import LifespanManager +from fastapi import Depends +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from sqlalchemy.orm import InstrumentedAttribute +from ww_agents.schemas import ( + BookTeardownResult, + ContinuationResult, + DeAiResult, + PolishResult, +) +from ww_db import get_session, get_sessionmaker +from ww_db.models import ( + Chapter, + Outline, + Project, + UsageLedger, + WorldEntity, +) +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, Tier + +# light/analyst/writer 默认都路由到 deepseek(config.tier_defaults)。 +_PROVIDER = "deepseek" + +# 各档位假用量(喂记账;证明记账落库)。 +_USAGE = { + "analyst": ProviderUsage(input_tokens=23, output_tokens=7), + "writer": ProviderUsage(input_tokens=31, output_tokens=17), +} + +# 前一章已写正文(accepted)——续写须无缝承接,端点读它注入上下文。 +_PRIOR_ACCEPTED_TEXT = "林动握紧拳头,灵气在丹田翻涌,他知道下一步将踏入炼气九重。" +# 续写假产物(writer)。 +_CONTINUATION = ContinuationResult(text="他深吸一口气,周身灵气如潮水般汇聚,瓶颈应声而破。") + +# 扩写原文(作者经 body.text 提供)+ 扩写假产物(writer)。 +_EXPAND_SOURCE = "他跑了。" +_POLISH = PolishResult(text="他撒腿狂奔,脚下碎石飞溅,心跳如擂鼓,只想逃离这片死地。") + +# 降AI率原文 + 假产物(analyst)。 +_DE_AI_SOURCE = "该角色在此刻进行了一次具有重要意义的决策行为。" +_DE_AI = DeAiResult(text="这一刻,他咬了咬牙,做出了那个改变一切的决定。") + +# 拆书样本 + 结构化拆解假产物(analyst)。 +_TEARDOWN_SOURCE = "第一章:少年得宝,立志复仇……(样本片段)" +_TEARDOWN = BookTeardownResult( + themes=["逆袭", "复仇"], + archetypes=["落魄天才", "腹黑反派"], + structure="开篇遭难→获得机缘→步步升级→反攻高潮", + hooks=["开局打脸", "扮猪吃虎"], +) + + +class _FakeGeneratorAdapter: + """实现 `ProviderAdapter` Protocol:按 `req.output_schema` 分支返回 parsed,绝不联网。 + + 额外把每路收到的 `req.input` 记进 `seen_input`(供路由断言:续写注入前文、 + 扩写/降AI 注入原文)。 + """ + + def __init__(self, provider: str = _PROVIDER) -> None: + self.provider = provider + self.seen_input: dict[type, str] = {} + + def capabilities(self) -> Capabilities: + return Capabilities(structured_output=True) + + async def complete(self, req: LlmRequest, model: str) -> ProviderResult: + schema = req.output_schema + assert schema is not None + # 注入材料进 input(str;断点后),system 才是 list[Block]——记下供路由断言。 + assert isinstance(req.input, str) + self.seen_input[schema] = req.input + if schema is ContinuationResult: + return ProviderResult( + text=_CONTINUATION.model_dump_json(), parsed=_CONTINUATION, usage=_USAGE["writer"] + ) + if schema is PolishResult: + return ProviderResult( + text=_POLISH.model_dump_json(), parsed=_POLISH, usage=_USAGE["writer"] + ) + if schema is DeAiResult: + return ProviderResult( + text=_DE_AI.model_dump_json(), parsed=_DE_AI, usage=_USAGE["analyst"] + ) + if schema is BookTeardownResult: + return ProviderResult( + text=_TEARDOWN.model_dump_json(), parsed=_TEARDOWN, usage=_USAGE["analyst"] + ) + raise AssertionError(f"unexpected output_schema in Scope B fake adapter: {schema!r}") + + async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]: + yield StreamChunk(usage=_USAGE["analyst"]) + raise AssertionError("competitor generators must not stream") + + +@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 _tier_builder_override( + adapter: _FakeGeneratorAdapter, +) -> Callable[[AsyncSession], Callable[[Tier], Awaitable[Gateway]]]: + """依赖覆盖:`get_tier_gateway_builder` → 返回「按 tier 建网关」的 builder。 + + builder 忽略 tier 返回同一个真 `Gateway`(单 provider 假适配器据 output_schema 分支 + + 真实 ledger,请求 session)——覆盖 writer(续写/扩写)与 analyst(降AI/拆书)两路。 + """ + + def _override( + session: Annotated[AsyncSession, Depends(get_session)], + ) -> Callable[[Tier], Awaitable[Gateway]]: + async def _build(_tier: Tier) -> Gateway: + return Gateway( + adapters={adapter.provider: adapter}, + ledger=SqlAlchemyLedgerSink(session), + resolver=resolve_route, + ) + + return _build + + return _override + + +async def _count( + sm: async_sessionmaker[AsyncSession], + project_id_col: InstrumentedAttribute[uuid.UUID | None], + project_uuid: uuid.UUID, +) -> int: + async with sm() as s: + return int( + ( + await s.execute(select(func.count()).where(project_id_col == project_uuid)) + ).scalar_one() + ) + + +async def _seed_prior_chapter( + sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID, chapter_no: int +) -> None: + """种入第 `chapter_no` 章的 accepted 正文(续写承接的前文,DB 为真相源 #1)。""" + async with sm() as s: + s.add( + Chapter( + project_id=project_uuid, + volume=1, + chapter_no=chapter_no, + content=_PRIOR_ACCEPTED_TEXT, + status="accepted", + version=2, + ) + ) + await s.commit() + + +async def _cleanup(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None: + """按 FK 顺序清理。""" + async with sm() as cleanup: + await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid)) + await cleanup.execute(delete(Chapter).where(Chapter.project_id == project_uuid)) + await cleanup.execute(delete(Outline).where(Outline.project_id == project_uuid)) + await cleanup.execute(delete(WorldEntity).where(WorldEntity.project_id == project_uuid)) + await cleanup.execute(delete(Project).where(Project.id == project_uuid)) + await cleanup.commit() + + +def _assert_no_secret_leak(payload: object) -> None: + """负向:响应体序列化后绝不含 token/secret/api_key 等敏感字样。""" + import json + + blob = json.dumps(payload, ensure_ascii=False).lower() + for needle in ("api_key", "secret", "token", "bearer", "sk-", "credential"): + assert needle not in blob, f"sensitive marker leaked into response: {needle!r}" + + +async def _create_project(client: httpx.AsyncClient, title: str) -> uuid.UUID: + resp = await client.post("/projects", json={"title": title, "genre": "玄幻"}) + assert resp.status_code == 201 + return uuid.UUID(resp.json()["id"]) + + +async def _assert_business_tables_untouched( + sm: async_sessionmaker[AsyncSession], + project_uuid: uuid.UUID, + *, + expected_chapters: int, +) -> None: + """不变量 #3:预览路径不写任何业务表(chapters 仅有种入的前文、world_entities/outline 空)。""" + assert await _count(sm, Chapter.project_id, project_uuid) == expected_chapters + assert await _count(sm, WorldEntity.project_id, project_uuid) == 0 + assert await _count(sm, Outline.project_id, project_uuid) == 0 + + +async def test_continue_generate_injects_prior_chapter_text( + e2e_sm: async_sessionmaker[AsyncSession], +) -> None: + """续写:端点读该章 accepted 正文注入续写上下文 → 200 预览;ledger 1 行、业务表零新增。""" + from ww_api.main import create_app + from ww_api.services.project_deps import get_tier_gateway_builder + + adapter = _FakeGeneratorAdapter() + app = create_app() + app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(adapter) + + 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: + project_uuid = await _create_project(client, "Scope B 续写作品") + await _seed_prior_chapter(e2e_sm, project_uuid, chapter_no=1) + + resp = await client.post( + f"/projects/{project_uuid}/skills/continue/generate", + json={"chapter_no": 1, "brief": "推进突破桥段"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["tool_key"] == "continue" + assert body["output_kind"] == "ContinuationResult" + assert body["preview"]["text"] == _CONTINUATION.text + _assert_no_secret_leak(body) + + # 路由断言:端点把前一章 accepted 正文注入了续写上下文(跨层数据流 #1)。 + injected = adapter.seen_input[ContinuationResult] + assert _PRIOR_ACCEPTED_TEXT in injected + + # DB 真源:ledger 落 1 行(writer);业务表零新增(除种入的 1 章前文,不变量 #3)。 + assert project_uuid is not None + assert await _count(e2e_sm, UsageLedger.project_id, project_uuid) == 1 + await _assert_business_tables_untouched(e2e_sm, project_uuid, expected_chapters=1) + finally: + if project_uuid is not None: + await _cleanup(e2e_sm, project_uuid) + + +async def test_expand_generate_routes_source_text_into_context( + e2e_sm: async_sessionmaker[AsyncSession], +) -> None: + """扩写:作者原文经 body.text 路由进上下文 → 200 预览;ledger 1 行、业务表零新增。""" + from ww_api.main import create_app + from ww_api.services.project_deps import get_tier_gateway_builder + + adapter = _FakeGeneratorAdapter() + app = create_app() + app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(adapter) + + 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: + project_uuid = await _create_project(client, "Scope B 扩写作品") + + resp = await client.post( + f"/projects/{project_uuid}/skills/expand/generate", + json={"text": _EXPAND_SOURCE, "brief": "丰富细节"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["tool_key"] == "expand" + assert body["output_kind"] == "PolishResult" + assert body["preview"]["text"] == _POLISH.text + _assert_no_secret_leak(body) + + # 路由断言:作者原文进入了扩写上下文(text_input 策略)。 + assert _EXPAND_SOURCE in adapter.seen_input[PolishResult] + + assert project_uuid is not None + assert await _count(e2e_sm, UsageLedger.project_id, project_uuid) == 1 + await _assert_business_tables_untouched(e2e_sm, project_uuid, expected_chapters=0) + finally: + if project_uuid is not None: + await _cleanup(e2e_sm, project_uuid) + + +async def test_de_ai_generate_routes_source_text_into_context( + e2e_sm: async_sessionmaker[AsyncSession], +) -> None: + """降AI率:作者原文经 body.text 路由进上下文 → 200 预览;ledger 1 行、业务表零新增。""" + from ww_api.main import create_app + from ww_api.services.project_deps import get_tier_gateway_builder + + adapter = _FakeGeneratorAdapter() + app = create_app() + app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(adapter) + + 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: + project_uuid = await _create_project(client, "Scope B 降AI作品") + + resp = await client.post( + f"/projects/{project_uuid}/skills/de-ai/generate", + json={"text": _DE_AI_SOURCE, "brief": "去机翻腔"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["tool_key"] == "de-ai" + assert body["output_kind"] == "DeAiResult" + assert body["preview"]["text"] == _DE_AI.text + _assert_no_secret_leak(body) + + # 路由断言:作者原文进入了降AI上下文(text_input 策略)。 + assert _DE_AI_SOURCE in adapter.seen_input[DeAiResult] + + assert project_uuid is not None + assert await _count(e2e_sm, UsageLedger.project_id, project_uuid) == 1 + await _assert_business_tables_untouched(e2e_sm, project_uuid, expected_chapters=0) + finally: + if project_uuid is not None: + await _cleanup(e2e_sm, project_uuid) + + +async def test_teardown_generate_returns_structured_output( + e2e_sm: async_sessionmaker[AsyncSession], +) -> None: + """拆书:text_input → 200 结构化预览(themes/archetypes/structure/hooks);业务表零新增。""" + from ww_api.main import create_app + from ww_api.services.project_deps import get_tier_gateway_builder + + adapter = _FakeGeneratorAdapter() + app = create_app() + app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(adapter) + + 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: + project_uuid = await _create_project(client, "Scope B 拆书作品") + + resp = await client.post( + f"/projects/{project_uuid}/skills/teardown/generate", + json={"text": _TEARDOWN_SOURCE, "kind": "某爽文", "brief": "拆解套路"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["tool_key"] == "teardown" + assert body["output_kind"] == "BookTeardownResult" + preview = body["preview"] + # 结构化输出四字段齐全(断结构而非仅 text)。 + assert preview["themes"] == ["逆袭", "复仇"] + assert preview["archetypes"] == ["落魄天才", "腹黑反派"] + assert preview["structure"] == _TEARDOWN.structure + assert preview["hooks"] == ["开局打脸", "扮猪吃虎"] + _assert_no_secret_leak(body) + + # 路由断言:作者样本原文进入了拆书上下文(text_input 策略)。 + assert _TEARDOWN_SOURCE in adapter.seen_input[BookTeardownResult] + + assert project_uuid is not None + assert await _count(e2e_sm, UsageLedger.project_id, project_uuid) == 1 + await _assert_business_tables_untouched(e2e_sm, project_uuid, expected_chapters=0) + finally: + if project_uuid is not None: + await _cleanup(e2e_sm, project_uuid)