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>
This commit is contained in:
Yaojia Wang
2026-06-20 10:39:58 +02:00
parent 5fb7bfb1de
commit 765dbdfbd4
161 changed files with 17330 additions and 208 deletions

View File

@@ -0,0 +1,375 @@
"""T5.1 生成节点单测worldbuilder + character-gen + 入库前 continuity 校验缝。
注入 mock 网关(产 WorldGenResult / CharacterGenResult / ContinuityReview parsed
无真 LLM、无真 Postgres。生成节点是独立生成不在写章/审稿流水线),裸函数;
只产结构化产物,**不写库**(持久化属 T5.2 入库端点,不变量 #3。asyncio_mode=auto。
"""
from __future__ import annotations
import uuid
import pytest
from fakes_orchestrator import FailingRunGateway, FakeRunGateway, SchemaRoutingRunGateway
from ww_agents import (
CharacterCard,
CharacterGenResult,
CharacterRelation,
Conflict,
ContinuityReview,
WorldEntityCard,
WorldGenResult,
character_gen_spec,
continuity_spec,
worldbuilder_spec,
)
from ww_core.orchestrator import (
build_character_gen_context,
build_precheck_context,
precheck_generated_cards,
run_character_gen,
run_worldbuilder,
)
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
_WORLD = WorldGenResult(
entities=[
WorldEntityCard(type="力量体系", name="九转炼气", rules=["逐境突破不可越级"]),
]
)
_CARDS = CharacterGenResult(
cards=[
CharacterCard(
name="苏黎",
role="对手",
traits=["表面冷漠"],
backstory="出身敌对势力",
arc="复仇 → 救赎",
speech_tics=["惯用反问"],
tags=["亦正亦邪"],
relations=[CharacterRelation(name="主角", kind="宿敌")],
)
]
)
# ---- run_worldbuilder返回结构化世界观只读不写库 ----
async def test_run_worldbuilder_returns_structured_entities() -> None:
gateway = FakeRunGateway(_WORLD)
result = await run_worldbuilder(
worldbuilder_spec,
brief="设计一个修真世界的力量体系",
project_context="题材:修真;立意:逆袭",
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
assert isinstance(result, WorldGenResult)
assert result.entities[0].name == "九转炼气"
assert result.entities[0].rules == ["逐境突破不可越级"]
assert gateway.call_count == 1
async def test_run_worldbuilder_forwards_writer_tier_request() -> None:
gateway = FakeRunGateway(_WORLD)
await run_worldbuilder(
worldbuilder_spec,
brief="设计力量体系",
project_context="题材:修真",
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
req = gateway.last_request
assert req is not None
assert req.tier == "writer" # 不变量②spec 档位透传
assert req.stream is False # 生成用 run() 非 stream()
assert req.output_schema is WorldGenResult
assert req.system[0].cache is True # 不变量#9
assert "设计力量体系" in req.input
assert "题材:修真" in req.input
async def test_run_worldbuilder_raises_when_gateway_fails() -> None:
# 独立生成:网关失败直接上抛(端点/T5.2 处理),不静默吞。
gateway = FailingRunGateway(RuntimeError("provider down"))
with pytest.raises(RuntimeError):
await run_worldbuilder(
worldbuilder_spec,
brief="x",
project_context="y",
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
async def test_run_worldbuilder_raises_when_parsed_missing() -> None:
class _NoParseGateway:
async def run(self, req: object) -> object:
from ww_llm_gateway.types import LlmResponse, ServedBy, Usage
return LlmResponse(
text="{}",
parsed=None,
usage=Usage(
provider="fake",
model="fake-writer",
input_tokens=1,
output_tokens=1,
cost_minor=0,
currency="USD",
),
served_by=ServedBy(provider="fake", model="fake-writer"),
)
with pytest.raises(ValueError, match="parsed"):
await run_worldbuilder(
worldbuilder_spec,
brief="x",
project_context="y",
gateway=_NoParseGateway(), # type: ignore[arg-type]
user_id=USER,
project_id=PROJECT,
)
# ---- build_character_gen_context群像防雷同注入已有 + 已生成)----
def test_build_character_gen_context_injects_brief_count_role() -> None:
ctx = build_character_gen_context(
brief="给我一个亦正亦邪的女二",
count=3,
role="CP",
world_context="力量体系:九转炼气(逐境突破)",
existing_chars=[],
generated_so_far=[],
)
assert "给我一个亦正亦邪的女二" in ctx
assert "3" in ctx
assert "CP" in ctx
assert "九转炼气" in ctx
def test_build_character_gen_context_injects_existing_and_generated() -> None:
# M5-a 防雷同:已有角色 + 本批已生成卡都进上下文,要求差异化
existing = [CharacterCard(name="李白", role="主角", backstory="b", arc="a")]
generated = [CharacterCard(name="苏黎", role="对手", backstory="b2", arc="a2")]
ctx = build_character_gen_context(
brief="再来一个配角",
count=1,
role=None,
world_context="",
existing_chars=existing,
generated_so_far=generated,
)
assert "李白" in ctx # 已有角色
assert "苏黎" in ctx # 本批已生成卡
def test_build_character_gen_context_handles_empty_anti_dup() -> None:
# 首次单生成:无已有/已生成时仍产出可用上下文(不崩)
ctx = build_character_gen_context(
brief="主角",
count=1,
role="主角",
world_context="",
existing_chars=[],
generated_so_far=[],
)
assert "主角" in ctx
# ---- run_character_gen返回结构化群像注入防雷同上下文 ----
async def test_run_character_gen_returns_structured_cards() -> None:
gateway = FakeRunGateway(_CARDS)
result = await run_character_gen(
character_gen_spec,
brief="亦正亦邪的女二",
count=1,
role="对手",
world_context="力量体系:九转炼气",
existing_chars=[],
generated_so_far=[],
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
assert isinstance(result, CharacterGenResult)
assert result.cards[0].name == "苏黎"
assert gateway.call_count == 1
async def test_run_character_gen_injects_anti_dup_into_request() -> None:
existing = [CharacterCard(name="李白", role="主角", backstory="b", arc="a")]
gateway = FakeRunGateway(_CARDS)
await run_character_gen(
character_gen_spec,
brief="配角",
count=2,
role=None,
world_context="九转炼气",
existing_chars=existing,
generated_so_far=[],
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
req = gateway.last_request
assert req is not None
assert req.tier == "writer"
assert req.output_schema is CharacterGenResult
assert "李白" in req.input # 已有角色注入(防雷同)
assert "九转炼气" in req.input # 世界观约束注入
async def test_run_character_gen_raises_when_gateway_fails() -> None:
gateway = FailingRunGateway(RuntimeError("provider down"))
with pytest.raises(RuntimeError):
await run_character_gen(
character_gen_spec,
brief="x",
count=1,
role=None,
world_context="",
existing_chars=[],
generated_so_far=[],
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
# ---- precheck_generated_cards入库前 continuity 校验缝ARCH §6.5----
def test_build_precheck_context_includes_cards_and_truth() -> None:
ctx = build_precheck_context(
cards=_CARDS.cards,
world_context="力量体系:九转炼气(逐境突破不可越级)",
characters_context="主角:李白",
)
assert "苏黎" in ctx # 待校验角色卡
assert "九转炼气" in ctx # 世界观真相源
assert "李白" in ctx # 已有角色真相源
async def test_precheck_returns_conflicts_when_card_violates_world() -> None:
# 编排器入库前追加 continuity 校验:角色能力越级 → 检出设定违例冲突
review = ContinuityReview(
conflicts=[
Conflict(
type="设定违例",
where="苏黎背景:天生跨境界",
refs=["九转炼气:逐境突破不可越级"],
suggestion="改为逐境修炼",
)
]
)
gateway = FakeRunGateway(review)
conflicts = await precheck_generated_cards(
continuity_spec,
cards=_CARDS.cards,
world_context="力量体系:九转炼气(逐境突破不可越级)",
characters_context="",
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
assert len(conflicts) == 1
assert conflicts[0].type == "设定违例"
assert gateway.call_count == 1
# 校验用 continuity 档位analyst且只读
req = gateway.last_request
assert req is not None
assert req.tier == "analyst"
assert req.output_schema is ContinuityReview
async def test_precheck_returns_empty_when_no_conflict() -> None:
gateway = FakeRunGateway(ContinuityReview(conflicts=[]))
conflicts = await precheck_generated_cards(
continuity_spec,
cards=_CARDS.cards,
world_context="力量体系:九转炼气",
characters_context="",
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
assert conflicts == []
async def test_precheck_uses_continuity_schema_routing() -> None:
# 校验缝复用 continuity_spec → 网关按 output_schema 路由产 ContinuityReview
gateway = SchemaRoutingRunGateway({ContinuityReview: ContinuityReview(conflicts=[])})
conflicts = await precheck_generated_cards(
continuity_spec,
cards=_CARDS.cards,
world_context="w",
characters_context="c",
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
assert conflicts == []
assert gateway.calls == [ContinuityReview]
async def test_precheck_raises_when_parsed_missing() -> None:
class _NoParseGateway:
async def run(self, req: object) -> object:
from ww_llm_gateway.types import LlmResponse, ServedBy, Usage
return LlmResponse(
text="{}",
parsed=None,
usage=Usage(
provider="fake",
model="fake-analyst",
input_tokens=1,
output_tokens=1,
cost_minor=0,
currency="USD",
),
served_by=ServedBy(provider="fake", model="fake-analyst"),
)
with pytest.raises(ValueError, match="parsed"):
await precheck_generated_cards(
continuity_spec,
cards=_CARDS.cards,
world_context="w",
characters_context="c",
gateway=_NoParseGateway(), # type: ignore[arg-type]
user_id=USER,
project_id=PROJECT,
)

View File

@@ -0,0 +1,31 @@
"""T5.2 写侧 repo 的 schema→DB 形变(纯函数 helper无 DB
DB 行的实际落库由 apps/api 端点测试fake repo+ T5.7 E2E真 pg覆盖
这里只钉住 list→dict / str→dict 的转换契约T5.1 gotcha防回归。
"""
from __future__ import annotations
from ww_core.domain.character_repo import _arc_to_jsonb, _traits_to_jsonb
from ww_core.domain.world_entity_repo import _rules_to_jsonb
def test_traits_list_wraps_into_jsonb_dict() -> None:
# Arrange
traits = ["腹黑", "护短"]
# Act
out = _traits_to_jsonb(traits)
# AssertDB 列是 JSONB dictlist 包成 {"items":[...]}。
assert out == {"items": ["腹黑", "护短"]}
def test_traits_empty_list_still_dict_shape() -> None:
assert _traits_to_jsonb([]) == {"items": []}
def test_arc_str_wraps_into_jsonb_dict() -> None:
assert _arc_to_jsonb("从孤儿到帝王") == {"text": "从孤儿到帝王"}
def test_rules_list_wraps_into_jsonb_dict() -> None:
assert _rules_to_jsonb(["禁止跨界传送", "灵力守恒"]) == {"rules": ["禁止跨界传送", "灵力守恒"]}

View File

@@ -0,0 +1,243 @@
"""T4.1 长任务 `jobs` 写侧 repo 单测ARCH §7.4)。
纯内存 fake镜像 `SqlJobRepo` 语义),无 DB断言状态流转 queued→running→done/failed、
progress 夹取、result/error 落位、`reap_zombies` 把 running→failed、frozen View 不可变。
真实 DB 路径(含 `reap_zombies` 自 commit由 T4.5 E2E 覆盖。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import Any
import pytest
from pydantic import ValidationError
from ww_core.domain.job_repo import (
PROGRESS_COMPLETE,
STATUS_DONE,
STATUS_FAILED,
STATUS_QUEUED,
STATUS_RUNNING,
JobRepo,
JobView,
)
KIND = "style_learn"
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
@dataclass
class _Row:
id: uuid.UUID
kind: str
status: str
progress: int
result: dict[str, Any] | None
error: str | None
def _view(row: _Row) -> JobView:
return JobView(
id=row.id,
kind=row.kind,
status=row.status,
progress=row.progress,
result=row.result,
error=row.error,
)
def _clamp(pct: int) -> int:
return max(0, min(PROGRESS_COMPLETE, pct))
@dataclass
class FakeJobRepo:
"""内存替身——镜像 SqlJobRepo 语义(状态流转 + 进度夹取 + 僵尸回收)。"""
rows: list[_Row] = field(default_factory=list)
def _require(self, job_id: uuid.UUID) -> _Row:
row = next((r for r in self.rows if r.id == job_id), None)
if row is None:
raise LookupError(f"job not found: {job_id}")
return row
async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
row = _Row(
id=uuid.uuid4(),
kind=kind,
status=STATUS_QUEUED,
progress=0,
result=None,
error=None,
)
self.rows.append(row)
return _view(row)
async def set_running(self, job_id: uuid.UUID) -> JobView:
row = self._require(job_id)
row.status = STATUS_RUNNING
return _view(row)
async def set_progress(self, job_id: uuid.UUID, pct: int) -> JobView:
row = self._require(job_id)
row.progress = _clamp(pct)
return _view(row)
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
row = self._require(job_id)
row.status = STATUS_DONE
row.progress = PROGRESS_COMPLETE
row.result = dict(result)
return _view(row)
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
row = self._require(job_id)
row.status = STATUS_FAILED
row.error = error
return _view(row)
async def get(self, job_id: uuid.UUID) -> JobView | None:
row = next((r for r in self.rows if r.id == job_id), None)
return _view(row) if row is not None else None
async def reap_zombies(self) -> int:
changed = 0
for r in self.rows:
if r.status == STATUS_RUNNING:
r.status = STATUS_FAILED
r.error = "job interrupted (process restart)"
changed += 1
return changed
def _repo() -> FakeJobRepo:
return FakeJobRepo()
# ---- create ----
async def test_create_starts_queued() -> None:
repo: JobRepo = _repo()
view = await repo.create(PROJECT, KIND)
assert isinstance(view, JobView)
assert view.kind == KIND
assert view.status == STATUS_QUEUED
assert view.progress == 0
assert view.result is None
assert view.error is None
async def test_create_allows_null_project() -> None:
repo: JobRepo = _repo()
view = await repo.create(None, KIND)
assert view.status == STATUS_QUEUED
# ---- state transitions ----
async def test_set_running_marks_running() -> None:
repo: JobRepo = _repo()
job = await repo.create(PROJECT, KIND)
running = await repo.set_running(job.id)
assert running.status == STATUS_RUNNING
async def test_complete_sets_done_full_progress_and_result() -> None:
repo: JobRepo = _repo()
job = await repo.create(PROJECT, KIND)
await repo.set_running(job.id)
done = await repo.complete(job.id, {"version": 2, "dims_count": 16})
assert done.status == STATUS_DONE
assert done.progress == PROGRESS_COMPLETE
assert done.result == {"version": 2, "dims_count": 16}
async def test_fail_sets_failed_and_error() -> None:
repo: JobRepo = _repo()
job = await repo.create(PROJECT, KIND)
await repo.set_running(job.id)
failed = await repo.fail(job.id, "boom")
assert failed.status == STATUS_FAILED
assert failed.error == "boom"
# ---- progress (clamped) ----
async def test_set_progress_updates_pct() -> None:
repo: JobRepo = _repo()
job = await repo.create(PROJECT, KIND)
v = await repo.set_progress(job.id, 42)
assert v.progress == 42
async def test_set_progress_clamps_out_of_range() -> None:
repo: JobRepo = _repo()
job = await repo.create(PROJECT, KIND)
assert (await repo.set_progress(job.id, 250)).progress == PROGRESS_COMPLETE
assert (await repo.set_progress(job.id, -5)).progress == 0
# ---- get ----
async def test_get_returns_none_when_absent() -> None:
repo: JobRepo = _repo()
assert await repo.get(uuid.uuid4()) is None
async def test_get_returns_current_state() -> None:
repo: JobRepo = _repo()
job = await repo.create(PROJECT, KIND)
await repo.complete(job.id, {"ok": True})
fetched = await repo.get(job.id)
assert fetched is not None
assert fetched.status == STATUS_DONE
async def test_missing_job_raises_on_write() -> None:
repo: JobRepo = _repo()
with pytest.raises(LookupError):
await repo.set_running(uuid.uuid4())
# ---- reap_zombies ----
async def test_reap_zombies_marks_running_failed() -> None:
repo: JobRepo = _repo()
queued = await repo.create(PROJECT, KIND)
running = await repo.create(PROJECT, KIND)
await repo.set_running(running.id)
done = await repo.create(PROJECT, KIND)
await repo.complete(done.id, {})
count = await repo.reap_zombies()
assert count == 1
reaped = await repo.get(running.id)
assert reaped is not None and reaped.status == STATUS_FAILED
# queued / done 不受影响
assert (await repo.get(queued.id)).status == STATUS_QUEUED # type: ignore[union-attr]
assert (await repo.get(done.id)).status == STATUS_DONE # type: ignore[union-attr]
async def test_reap_zombies_noop_when_no_running() -> None:
repo: JobRepo = _repo()
await repo.create(PROJECT, KIND)
assert await repo.reap_zombies() == 0
# ---- frozen View ----
async def test_view_is_frozen() -> None:
repo: JobRepo = _repo()
v = await repo.create(PROJECT, KIND)
with pytest.raises(ValidationError):
v.status = STATUS_DONE

View File

@@ -14,6 +14,7 @@ from ww_core.domain.repositories import (
ForeshadowView,
MemoryRepos,
OutlineView,
ProjectSpecView,
RuleView,
StyleView,
WorldEntityView,
@@ -41,6 +42,9 @@ class FakeOutlineRepo:
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return self.rows.get(chapter_no)
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
return [self.rows[k] for k in sorted(self.rows)]
@dataclass
class FakeCharacterRepo:
@@ -91,6 +95,14 @@ class FakeRulesRepo:
return list(self.rows)
@dataclass
class FakeProjectSpecRepo:
row: ProjectSpecView | None = None
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
return self.row
def build_repos(
*,
outline: dict[int, OutlineView] | None = None,
@@ -100,6 +112,7 @@ def build_repos(
foreshadows: list[ForeshadowView] | None = None,
style: StyleView | None = None,
rules: list[RuleView] | None = None,
spec: ProjectSpecView | None = None,
) -> MemoryRepos:
return MemoryRepos(
outline=FakeOutlineRepo(outline or {}),
@@ -109,6 +122,7 @@ def build_repos(
foreshadow=FakeForeshadowRepo(foreshadows or []),
style=FakeStyleRepo(style),
rules=FakeRulesRepo(rules or []),
project=FakeProjectSpecRepo(spec),
)
@@ -244,6 +258,26 @@ async def test_rule_merge_precedence() -> None:
assert g < ge < s < p
async def test_bare_project_premise_only_yields_nonempty_prompt() -> None:
# 全新项目只有书级蓝本premise/logline/title无世界观/角色/大纲/伏笔。
spec = ProjectSpecView(title="无名之书", logline="一个开始", premise="末世孤舟漂流")
repos = build_repos(spec=spec) # 其余全空
ctx = await assemble(repos, PROJECT, 1)
# stable_core 非空且含书级蓝本
assert ctx.stable_core != ""
assert "末世孤舟漂流" in ctx.stable_core
assert "无名之书" in ctx.stable_core
# volatile 非空且含本章写作指令 → writer 的 user message 永不为空
assert ctx.volatile != ""
assert "请创作第 1 章的正文。" in ctx.volatile
async def test_chapter_directive_uses_chapter_no() -> None:
repos = build_repos(spec=ProjectSpecView(title=""))
ctx = await assemble(repos, PROJECT, 7)
assert "请创作第 7 章的正文。" in ctx.volatile
# ---- helpers ----
@@ -267,4 +301,5 @@ def _full_repos() -> MemoryRepos:
foreshadows=[ForeshadowView(code="F1", title="神秘石符", status="OPEN", planted_at=1)],
style=StyleView(dimensions={"句长": "短句为主"}),
rules=[RuleView(level="global", content="全局规则")],
spec=ProjectSpecView(title="大梦主", logline="少年逆天改命", premise="灵气复苏"),
)

View File

@@ -0,0 +1,46 @@
"""T5.5 规则写侧 repo 单测C3 扩 / PRODUCT_SPEC §7 POST /rules
`RuleWriteRepo.create` 插一行 `rules`level + content绑 project_id只 flush 不 commit
(提交交端点事务,与项目其它写侧 repo 一致)。`level` 合法性global/genre/style/project
由端点/schema 校验repo 只负责写。纯内存 fake无 DB。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
import pytest
from pydantic import ValidationError
from ww_core.domain.rule_repo import RuleWriteRepo, RuleWriteView
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
@dataclass
class _FakeRuleWriteRepo:
rows: list[RuleWriteView] = field(default_factory=list)
flushed: int = 0
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
view = RuleWriteView(project_id=project_id, level=level, content=content)
self.rows.append(view)
self.flushed += 1
return view
@pytest.mark.asyncio
async def test_create_returns_view() -> None:
repo: RuleWriteRepo = _FakeRuleWriteRepo()
view = await repo.create(PROJECT, level="project", content="主角不许复活")
assert view.level == "project"
assert view.content == "主角不许复活"
assert view.project_id == PROJECT
def test_rule_view_is_frozen() -> None:
view = RuleWriteView(project_id=PROJECT, level="global", content="x")
with pytest.raises(ValidationError):
view.content = "y"

View File

@@ -0,0 +1,137 @@
"""T4.2 文风提取节点单测——build_style_extract_request + run_style_extractionC6 扩 / ARCH §5.4)。
注入 mock 网关(产 `StyleFingerprintResult` parsed无真 LLM、无真 Postgres。
提取节点是裸函数(独立生成、仿 outliner不在写章/审稿流水线);只产结构化指纹,**不写库**
(持久化属 T4.3 端点,不变量 #3。asyncio_mode=auto。
"""
from __future__ import annotations
import uuid
import pytest
from fakes_orchestrator import FailingRunGateway, FakeRunGateway
from ww_agents import (
StyleDimension,
StyleFingerprintResult,
style_extract_spec,
)
from ww_core.orchestrator import build_style_extract_request, run_style_extraction
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
SAMPLES = "样本一:他一刀劈下,血溅三尺。\n样本二:风停了,只剩呼吸声。"
_FINGERPRINT = StyleFingerprintResult(
dimensions=[
StyleDimension(
name="句长节奏",
value="短句为主、节奏明快",
evidence=["他一刀劈下,血溅三尺。"],
),
StyleDimension(name="叙事人称", value="第三人称限知", evidence=["风停了"]),
]
)
# ---- build_style_extract_requesttier 不变量、缓存断点、output_schema、run 非 stream ----
def test_build_style_extract_request_uses_spec_tier_and_cached_prompt() -> None:
req = build_style_extract_request(
style_extract_spec, samples_text=SAMPLES, user_id=USER, project_id=PROJECT
)
assert req.tier == "analyst" # 不变量②spec 档位透传,不传 model
assert req.stream is False # 提取用 run() 非 stream()
assert len(req.system) == 1
assert req.system[0].text == style_extract_spec.system_prompt
assert req.system[0].cache is True # 不变量#9
assert req.input == SAMPLES
assert req.output_schema is StyleFingerprintResult
assert req.scope.user_id == USER
assert req.scope.project_id == PROJECT
# ---- run_style_extraction返回结构化指纹含证据只读不写库 ----
async def test_run_style_extraction_returns_fingerprint() -> None:
gateway = FakeRunGateway(_FINGERPRINT)
result = await run_style_extraction(
style_extract_spec,
samples_text=SAMPLES,
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
assert isinstance(result, StyleFingerprintResult)
assert len(result.dimensions) == 2
assert result.dimensions[0].name == "句长节奏"
assert result.dimensions[0].evidence == ["他一刀劈下,血溅三尺。"]
assert gateway.call_count == 1
async def test_run_style_extraction_forwards_constructed_request() -> None:
gateway = FakeRunGateway(_FINGERPRINT)
await run_style_extraction(
style_extract_spec,
samples_text=SAMPLES,
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
req = gateway.last_request
assert req is not None
assert req.tier == "analyst"
assert req.output_schema is StyleFingerprintResult
assert req.input == SAMPLES
async def test_run_style_extraction_raises_when_gateway_fails() -> None:
# 提取是独立生成(非流水线并行审);网关失败直接上抛,端点/T4.3 处理。
gateway = FailingRunGateway(RuntimeError("provider down"))
with pytest.raises(RuntimeError):
await run_style_extraction(
style_extract_spec,
samples_text=SAMPLES,
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
async def test_run_style_extraction_raises_when_parsed_missing() -> None:
# 带 output_schema 时 parsed 必非 NoneC1若网关违约返回 None明确报错而非静默。
class _NoParseGateway:
async def run(self, req: object) -> object:
from ww_llm_gateway.types import LlmResponse, ServedBy, Usage
return LlmResponse(
text="{}",
parsed=None,
usage=Usage(
provider="fake",
model="fake-analyst",
input_tokens=1,
output_tokens=1,
cost_minor=0,
currency="USD",
),
served_by=ServedBy(provider="fake", model="fake-analyst"),
)
with pytest.raises(ValueError, match="parsed"):
await run_style_extraction(
style_extract_spec,
samples_text=SAMPLES,
gateway=_NoParseGateway(), # type: ignore[arg-type]
user_id=USER,
project_id=PROJECT,
)

View File

@@ -0,0 +1,249 @@
"""T4.2 第四审style/drift单测——并入 review 图 + collect style 列 + SSE style 事件C4 扩)。
全部注入 mock 网关 / 内存 fake repo无真 LLM、无真 Postgres图用 MemorySaver
asyncio_mode=auto。验四审齐continuity/foreshadow/pace/style、collect 落 `style` 列、
SSE `section{name:"style"}` + `style{score,segments}`、无指纹优雅降级。
"""
from __future__ import annotations
import uuid
from typing import Any
from fakes_orchestrator import FakeReviewRepo, SchemaRoutingRunGateway
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver
from ww_agents import (
Conflict,
ContinuityReview,
ForeshadowReview,
ForeshadowSuggestion,
PaceIssue,
PaceReview,
StyleDriftReview,
StyleDriftSegment,
)
from ww_core.orchestrator import (
CONTINUITY,
EVENT_CONFLICT,
EVENT_DONE,
EVENT_FORESHADOW,
EVENT_PACE,
EVENT_SECTION,
EVENT_STYLE,
FORESHADOW,
PACE,
REVIEW_INCOMPLETE,
REVIEW_OK,
REVIEW_SPECS,
SECTION_DONE,
STYLE,
ChapterState,
build_review_context,
build_review_graph,
collect_reviews,
extract_style,
normalize_review,
)
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
STABLE = "## 世界观硬规则\n灵气可凝丹\n## 文风指纹\n句长节奏:短句明快"
VOLATILE = "## 近况\n主角已破境"
DRAFT = "他一拳轰碎山岳。"
_CONFLICT = Conflict(
type="能力不符", where="第3段", refs=["人物卡:主角"], suggestion="改为震裂巨石"
)
_FORESHADOW = ForeshadowReview(
planted=[ForeshadowSuggestion(code="FS-MARK", title="胸口胎记", where="第2段", note="皇族")]
)
_PACE = PaceReview(water=[PaceIssue(where="第4段", reason="注水")], hook=True, beat_map=[1, 3, 5])
_STYLE = StyleDriftReview(
score=72,
segments=[
StyleDriftSegment(idx=3, score=40, label="机翻腔"),
StyleDriftSegment(idx=7, score=55),
],
)
def _state(reviews: dict[str, Any] | None = None) -> ChapterState:
state: ChapterState = {
"project_id": PROJECT,
"chapter_no": 7,
"user_id": USER,
"stable_core": STABLE,
"volatile": VOLATILE,
"draft": DRAFT,
"review_context": build_review_context(draft=DRAFT, stable_core=STABLE, volatile=VOLATILE),
}
if reviews is not None:
state["reviews"] = reviews
return state
def _four_reviews() -> dict[str, Any]:
return {
CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}},
FORESHADOW: {"status": REVIEW_OK, "result": _FORESHADOW.model_dump()},
PACE: {"status": REVIEW_OK, "result": _PACE.model_dump()},
STYLE: {"status": REVIEW_OK, "result": _STYLE.model_dump()},
}
# ---- REVIEW_SPECS 含第四审 ----
def test_review_specs_include_four_audits() -> None:
names = [s.name for s in REVIEW_SPECS]
assert names == ["continuity", "foreshadow", "pace", "style"]
# ---- collectstyle 列映射 ----
def test_extract_style_returns_whole_dict() -> None:
style = extract_style(_four_reviews())
assert style is not None
assert style["score"] == 72
assert len(style["segments"]) == 2
assert style["segments"][0]["label"] == "机翻腔"
def test_extract_style_none_when_incomplete() -> None:
reviews = {STYLE: {"status": REVIEW_INCOMPLETE, "result": None}}
assert extract_style(reviews) is None
def test_extract_style_none_when_absent() -> None:
assert extract_style({}) is None
async def test_collect_records_style_to_style_column() -> None:
repo = FakeReviewRepo()
await collect_reviews(_state(_four_reviews()), review_repo=repo)
assert len(repo.records) == 1 # 一次 record 落齐四审
rec = repo.records[0]
assert rec["conflicts"][0]["type"] == "能力不符"
assert len(rec["foreshadow_sug"]) == 1
assert rec["pace"]["hook"] is True
assert rec["style"]["score"] == 72 # style→style 列
assert rec["style"]["segments"][0]["label"] == "机翻腔"
# ---- 四审并行图跑通mock 网关按 schema 路由产四 parsed----
async def test_four_review_graph_runs_all_audits_end_to_end() -> None:
gateway = SchemaRoutingRunGateway(
{
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
ForeshadowReview: _FORESHADOW,
PaceReview: _PACE,
StyleDriftReview: _STYLE,
}
)
repo = FakeReviewRepo()
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
config: RunnableConfig = {"configurable": {"thread_id": "rev-style-1"}}
final = await graph.ainvoke(_state(), config=config)
assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
assert final["reviews"][FORESHADOW]["status"] == REVIEW_OK
assert final["reviews"][PACE]["status"] == REVIEW_OK
assert final["reviews"][STYLE]["status"] == REVIEW_OK
assert len(gateway.calls) == 4 # 四审各调一次网关
rec = repo.records[0]
assert rec["conflicts"] and rec["foreshadow_sug"] and rec["pace"]
assert rec["style"]["score"] == 72
async def test_four_review_graph_isolates_failing_style_audit() -> None:
# style schema 缺失 → SchemaRoutingRunGateway 抛 KeyError → run_review 隔离为 incomplete
gateway = SchemaRoutingRunGateway(
{
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
ForeshadowReview: _FORESHADOW,
PaceReview: _PACE,
}
)
repo = FakeReviewRepo()
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
config: RunnableConfig = {"configurable": {"thread_id": "rev-style-2"}}
final = await graph.ainvoke(_state(), config=config)
assert final["reviews"][STYLE]["status"] == REVIEW_INCOMPLETE
assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
rec = repo.records[0]
assert rec["conflicts"] # 其余三审照常落库
assert rec["style"] is None # style 失败列留空,不阻塞其余
# ---- SSEnormalize_review surface 四审(含 style 事件)----
async def test_normalize_review_surfaces_style_event() -> None:
events = [e async for e in normalize_review(_four_reviews())]
kinds = [e.event for e in events]
# 字典序遍历continuity < foreshadow < pace < style
assert kinds == [
EVENT_SECTION,
EVENT_CONFLICT,
EVENT_SECTION,
EVENT_FORESHADOW,
EVENT_SECTION,
EVENT_PACE,
EVENT_SECTION,
EVENT_STYLE,
EVENT_DONE,
]
style_evt = next(e for e in events if e.event == EVENT_STYLE)
assert style_evt.data["score"] == 72
segments = style_evt.data["segments"]
assert segments == [
{"idx": 3, "score": 40, "label": "机翻腔"},
{"idx": 7, "score": 55, "label": None},
]
style_section = next(
e for e in events if e.event == EVENT_SECTION and e.data.get("name") == STYLE
)
assert style_section.data["status"] == SECTION_DONE
assert events[-1].data == {"length": 4} # 四审项
# ---- 无指纹优雅降级style 审返回 score=100/空段(默认 schema落库且 SSE 不报错 ----
async def test_style_graceful_degrade_when_no_fingerprint() -> None:
# 材料无指纹 → 第四审产默认 StyleDriftReview(score=100, segments=[])system_prompt 指示)
degraded = StyleDriftReview() # score=100, segments=[]
gateway = SchemaRoutingRunGateway(
{
ContinuityReview: ContinuityReview(conflicts=[]),
ForeshadowReview: ForeshadowReview(),
PaceReview: PaceReview(),
StyleDriftReview: degraded,
}
)
repo = FakeReviewRepo()
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
config: RunnableConfig = {"configurable": {"thread_id": "rev-style-degrade"}}
final = await graph.ainvoke(_state(), config=config)
assert final["reviews"][STYLE]["status"] == REVIEW_OK
rec = repo.records[0]
assert rec["style"] == {"score": 100, "segments": []} # 降级也落库(非 None
events = [e async for e in normalize_review(final["reviews"])]
style_evt = next(e for e in events if e.event == EVENT_STYLE)
assert style_evt.data["score"] == 100
assert style_evt.data["segments"] == []