Files
writer-work-flow/tests/test_competitor_generators_e2e.py
Yaojia Wang 416caed56b test(qa): Scope B C — 4 竞品生成器 E2E(真 pg + mock 网关零 token)
新增 tests/test_competitor_generators_e2e.py(镜像 t6_toolbox E2E 范式):
- 续写:种入该章 accepted 前文 → 端点读取注入续写上下文(断 fake 适配器 req.input 含前文,跨层数据流 #1)
- 扩写/降AI率:作者原文经 body.text 路由进上下文(断 req.input 含原文)
- 拆书:text_input → 断结构化输出 themes/archetypes/structure/hooks 齐全
- 负向(#3):4 者均仅记 usage_ledger、业务表(chapters/world_entities/outline)零新增;响应体无 token/secret 泄漏

零 token:override get_tier_gateway_builder 返单一真 Gateway + 假适配器据 output_schema 分支。
2026-06-23 19:17:08 +02:00

402 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Scope B 端到端4 个竞品生成器(续写/扩写/降AI率/拆书)通用生成端点。
真实 DBpg无则 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 默认都路由到 deepseekconfig.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
# 注入材料进 inputstr断点后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)