Files
writer-work-flow/apps/api/tests/test_style.py
Yaojia Wang 765dbdfbd4 feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。
M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);
  网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则;
  前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。
K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径;
  coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。
本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400);
  GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化;
  字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。
门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:39:58 +02:00

395 lines
15 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.

"""T4.3 端点测试学文风jobs 异步)+ 回炉 + 最新指纹(内存替身,无 DB/无网络)。
覆盖:
- POST /style写一行 job 返 202 `{job_id}`BackgroundTask 跑完写 style_fingerprint
+ 置 job doneASGITransport 下 background task 在请求返回前跑完,见 gotcha
- mode="update" → 第二次 append version+1
- 项目不存在 → 404无凭据 → 503dep 解析阶段拦下,未写 job
- GET /style返最新指纹dimensions/evidence/version无指纹 → 404
- POST /refine{original, refined}refiner 纯文本)+ 末尾 commit记账落库无凭据 → 503。
学文风 work 在 `run_job` 自建独立 session 上自造网关 + repo请求 session 已关闭)——
测试 monkeypatch `build_gateway_for_tier`(产 StyleFingerprintResult+ 写侧 repo
(指到共享 fake repo并 override `get_session_factory` 为 FakeSessionFactory断言后台落库。
"""
from __future__ import annotations
import os
import uuid
import httpx
import pytest
from fakes_projects import (
FakeJobRepo,
FakeProjectRepo,
FakeReviewGateway,
FakeSession,
FakeSessionFactory,
FakeStyleWriteRepo,
)
from fastapi import FastAPI
from ww_agents import StyleDimension, StyleFingerprintResult
from ww_core.domain.job_repo import STATUS_DONE
from ww_core.domain.project_repo import ProjectCreate
from ww_shared import AppError, ErrorCode
def _fingerprint() -> StyleFingerprintResult:
return StyleFingerprintResult(
dimensions=[
StyleDimension(name="句长节奏", value="短句为主", evidence=["他来了。", "她走了。"]),
StyleDimension(name="叙事人称", value="第三人称", evidence=["他看着远方。"]),
]
)
async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
view = await repo.create(uuid.UUID(int=1), ProjectCreate(title="测试作品", genre="玄幻"))
return uuid.UUID(str(view.id))
def _app_with_overrides(
*,
project_repo: FakeProjectRepo,
job_repo: FakeJobRepo | None = None,
style_repo: FakeStyleWriteRepo | None = None,
session: FakeSession | None = None,
session_factory: FakeSessionFactory | None = None,
extract_gateway: object | None = None,
refine_gateway: object | None = None,
) -> FastAPI:
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_job_repo,
get_project_repo,
get_refine_gateway,
get_session_factory,
get_style_extract_gateway,
get_style_write_repo,
)
from ww_db import get_session
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_session] = lambda: session or FakeSession()
if job_repo is not None:
app.dependency_overrides[get_job_repo] = lambda: job_repo
if style_repo is not None:
app.dependency_overrides[get_style_write_repo] = lambda: style_repo
if session_factory is not None:
app.dependency_overrides[get_session_factory] = lambda: session_factory
if extract_gateway is not None:
app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway
if refine_gateway is not None:
app.dependency_overrides[get_refine_gateway] = lambda: refine_gateway
return app
def _client(app: FastAPI) -> httpx.AsyncClient:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
return httpx.AsyncClient(transport=transport, base_url="http://test")
# ---- POST /style学文风 jobs 异步)----
@pytest.mark.asyncio
async def test_learn_style_returns_202_and_writes_job_row(
monkeypatch: pytest.MonkeyPatch,
) -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
job_repo = FakeJobRepo()
session = FakeSession()
app = _app_with_overrides(
project_repo=project_repo,
job_repo=job_repo,
session=session,
session_factory=FakeSessionFactory(),
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
# background task work 触网关/repo → monkeypatch 成 no-op fake本用例不验落库
# run_job 默认 repo_factory 用 SqlJobRepo(FakeSession) 会触 .execute → 改用 fake job repo。
import ww_api.routers.style as style_mod
import ww_api.services.job_runner as runner_mod
async def _noop_work(_session: object) -> dict[str, int]:
return {"version": 1, "dims_count": 2}
monkeypatch.setattr(style_mod, "_make_style_learn_work", lambda *_a, **_k: _noop_work)
monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/style", json={"samples": ["样本正文一"]})
assert resp.status_code == 202
body = resp.json()
job_id = uuid.UUID(body["job_id"])
# job 行已创建 + 请求阶段 commit 一次(供前端立即轮询)。
assert job_id in job_repo.rows
assert job_repo.rows[job_id].kind == "style_learn"
assert session.commits == 1
@pytest.mark.asyncio
async def test_learn_style_background_task_writes_fingerprint_and_completes_job(
monkeypatch: pytest.MonkeyPatch,
) -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
job_repo = FakeJobRepo()
style_repo = FakeStyleWriteRepo()
session_factory = FakeSessionFactory()
import ww_api.routers.style as style_mod
# work 内 build_gateway_for_tier → fake 网关产指纹;写侧 repo → 共享 fake repo。
async def _fake_build_gateway(_session, _store, _tier): # type: ignore[no-untyped-def]
return FakeReviewGateway(parsed=_fingerprint())
monkeypatch.setattr(style_mod, "build_gateway_for_tier", _fake_build_gateway)
monkeypatch.setattr(style_mod, "SqlStyleFingerprintWriteRepo", lambda _s: style_repo)
# `run_job` 用 SqlJobRepo默认 repo_factory会触 FakeSession.execute → 改用 fake job repo。
import ww_api.services.job_runner as runner_mod
monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)
app = _app_with_overrides(
project_repo=project_repo,
job_repo=job_repo,
session=FakeSession(),
session_factory=session_factory,
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/style", json={"samples": ["样本一", "样本二"], "mode": "create"}
)
assert resp.status_code == 202
job_id = uuid.UUID(resp.json()["job_id"])
# BackgroundTask 已跑完ASGITransport 等其完成style_fingerprint 落库 + job done。
assert style_repo.append_calls == 1
assert style_repo.rows[1][0] == {"句长节奏": "短句为主", "叙事人称": "第三人称"}
assert style_repo.rows[1][1]["句长节奏"] == ["他来了。", "她走了。"]
assert job_repo.rows[job_id].status == STATUS_DONE
assert job_repo.rows[job_id].result == {"version": 1, "dims_count": 2}
@pytest.mark.asyncio
async def test_learn_style_update_mode_bumps_version(monkeypatch: pytest.MonkeyPatch) -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
job_repo = FakeJobRepo()
style_repo = FakeStyleWriteRepo()
# 预置 version 1首学已存在
await style_repo.append(pid, dimensions_json={"x": "y"}, evidence_json={"x": []})
import ww_api.routers.style as style_mod
import ww_api.services.job_runner as runner_mod
async def _fake_build_gateway(_session, _store, _tier): # type: ignore[no-untyped-def]
return FakeReviewGateway(parsed=_fingerprint())
monkeypatch.setattr(style_mod, "build_gateway_for_tier", _fake_build_gateway)
monkeypatch.setattr(style_mod, "SqlStyleFingerprintWriteRepo", lambda _s: style_repo)
monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)
app = _app_with_overrides(
project_repo=project_repo,
job_repo=job_repo,
session=FakeSession(),
session_factory=FakeSessionFactory(),
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/style", json={"samples": ["新样本"], "mode": "update"}
)
assert resp.status_code == 202
job_id = uuid.UUID(resp.json()["job_id"])
# 第二次 append → version 2version+1
assert max(style_repo.rows) == 2
assert job_repo.rows[job_id].result == {"version": 2, "dims_count": 2}
@pytest.mark.asyncio
async def test_learn_style_unknown_project_404() -> None:
project_repo = FakeProjectRepo()
app = _app_with_overrides(
project_repo=project_repo,
job_repo=FakeJobRepo(),
session_factory=FakeSessionFactory(),
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
async with _client(app) as client:
resp = await client.post(f"/projects/{uuid.uuid4()}/style", json={"samples": ["x"]})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_learn_style_without_credentials_503_and_no_job() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
job_repo = FakeJobRepo()
session = FakeSession()
async def _no_creds() -> object:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
app = _app_with_overrides(
project_repo=project_repo,
job_repo=job_repo,
session=session,
session_factory=FakeSessionFactory(),
)
from ww_api.services.project_deps import get_style_extract_gateway
app.dependency_overrides[get_style_extract_gateway] = _no_creds
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/style", json={"samples": ["x"]})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
# 凭据探测在调度 job 之前 → 未写 job、未 commit。
assert job_repo.rows == {}
assert session.commits == 0
@pytest.mark.asyncio
async def test_learn_style_empty_samples_422() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
app = _app_with_overrides(
project_repo=project_repo,
job_repo=FakeJobRepo(),
session_factory=FakeSessionFactory(),
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/style", json={"samples": []})
assert resp.status_code == 422
# ---- GET /style最新指纹----
@pytest.mark.asyncio
async def test_get_style_returns_latest_fingerprint() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
style_repo = FakeStyleWriteRepo()
await style_repo.append(
pid, dimensions_json={"句长": ""}, evidence_json={"句长": ["他来了。"]}
)
app = _app_with_overrides(project_repo=project_repo, style_repo=style_repo)
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/style")
assert resp.status_code == 200
body = resp.json()
assert body["version"] == 1
assert body["dimensions"] == {"句长": ""}
assert body["evidence"] == {"句长": ["他来了。"]}
@pytest.mark.asyncio
async def test_get_style_no_fingerprint_404() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
app = _app_with_overrides(project_repo=project_repo, style_repo=FakeStyleWriteRepo())
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/style")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_get_style_unknown_project_404() -> None:
app = _app_with_overrides(project_repo=FakeProjectRepo(), style_repo=FakeStyleWriteRepo())
async with _client(app) as client:
resp = await client.get(f"/projects/{uuid.uuid4()}/style")
assert resp.status_code == 404
# ---- POST /refine同步回炉----
@pytest.mark.asyncio
async def test_refine_returns_original_and_refined_and_commits() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(text="重写后的段落。")
app = _app_with_overrides(project_repo=project_repo, session=session, refine_gateway=gateway)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/1/refine",
json={"segment": "原始段落。", "instruction": "去掉机翻腔"},
)
assert resp.status_code == 200
body = resp.json()
assert body["original"] == "原始段落。"
assert body["refined"] == "重写后的段落。"
# writer 档 + 末尾 commit记账落库
assert gateway.requests[0].tier == "writer"
assert session.commits == 1
# 指令进入了输入文本。
assert "去掉机翻腔" in str(gateway.requests[0].input)
@pytest.mark.asyncio
async def test_refine_unknown_project_404() -> None:
app = _app_with_overrides(
project_repo=FakeProjectRepo(), refine_gateway=FakeReviewGateway(text="x")
)
async with _client(app) as client:
resp = await client.post(
f"/projects/{uuid.uuid4()}/chapters/1/refine", json={"segment": "原段"}
)
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_refine_empty_segment_422() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
app = _app_with_overrides(project_repo=project_repo, refine_gateway=FakeReviewGateway(text="x"))
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/chapters/1/refine", json={"segment": ""})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_refine_without_credentials_503() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
async def _no_creds() -> object:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
app = _app_with_overrides(project_repo=project_repo, session=session)
from ww_api.services.project_deps import get_refine_gateway
app.dependency_overrides[get_refine_gateway] = _no_creds
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/chapters/1/refine", json={"segment": "原段"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0