feat(review): 人物塑造 advisory 审(第五审,仅建议不阻断验收)

写→审链新增第五审「人物塑造」(灵感③/D2):advisory 单维——仅建议、不阻断
验收(不进 conflicts、不碰 assert_conflicts_resolved、不入 REVIEW_RESERVED_NAMES);
只做人物塑造、不做氛围。

- SPEC characterization_spec(analyst,reads=("characters",),writes=())+
  prompts/characterization.md(只读、显式忽略文本长度与辞藻华丽度、引用逐字片段+置信度)
- schema CharacterizationReview{issues[...]}(全字段默认值守解析韧性)+ 注册 catalog
- 计数三处 22→23 + regen prompt_hashes.json(仅加一条)
- DB chapter_reviews 加 characterization JSONB nullable 列(迁移 f6a7b8c9d0e1)
- 接线:graph REVIEW_SPECS 追加 · chain ReviewRecordRepo · collect(extract/record) ·
  review_repo(Protocol/Sql/_to_view/ReviewView) · sse(event/factory/section) · normalize 自动纳入
- 契约 ReviewHistoryItem 加 characterization(gen:api 已重生成)
- 前端 sse/history/useReviewStream + CharacterizationPanel(只展示无裁决 UI、置信度降序+低置信折叠)+ ReviewReport 挂面板
- 测试 test_characterization_does_not_block_accept + 计数/golden + collect/normalize + 前端 reducer/normalize/parse

守不变量 #2/#3/#9;依赖 ⑧(motive/appearance 作客观锚点)。
This commit is contained in:
Yaojia Wang
2026-07-06 17:08:38 +02:00
parent 2a7a865e98
commit 72b3c81146
37 changed files with 1071 additions and 19 deletions

View File

@@ -3,6 +3,7 @@
"book-title": "aa53ccf22da1d4e555c8168fc23f2f3547cc6f59e56e5df57540b7a946755e39",
"brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada",
"character-gen": "f65e2ec3158be378a6cd40c23035b326dc9f6b297b6135007a93fceedf575cfd",
"characterization": "643988cb899a66472acb3e18709f3299d2025f9bda132c774ee26c589266b51f",
"continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763",
"continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1",
"de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249",

View File

@@ -0,0 +1,115 @@
"""灵感③ 人物塑造续审契约测试CharacterizationReview schema + characterization_spec。
advisory 单维——仅建议、不阻断验收D2
契约测试——构造符合 schema 的 mock 响应校验字段默认值降级、tier、只读writes==())、
输出 schema 派生。人物塑造是 advisory**不加入 REVIEW_RESERVED_NAMES**。不联网、无 DB。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from ww_agents import (
REVIEW_RESERVED_NAMES,
AgentSpec,
CharacterizationIssue,
CharacterizationReview,
characterization_spec,
)
# ---- CharacterizationReview schema ----
def test_characterization_parses_mock_response() -> None:
# Arrange模拟网关 instructor 校验后的结构化产出(每条带逐字引用 + 置信度)
mock = {
"issues": [
{
"character": "沈砚",
"aspect": "动机一致性",
"where": "第4段决斗前退缩",
"quote": "他忽然转身就走,什么也没说。",
"diagnosis": "与人物卡既定「护妹至死」动机断裂,无过渡",
"suggestion": "让退缩扣回护妹动机(如察觉陷阱护妹先撤)",
"confidence": 0.82,
}
]
}
# Act
review = CharacterizationReview.model_validate(mock)
# Assert
assert len(review.issues) == 1
issue = review.issues[0]
assert issue.character == "沈砚"
assert issue.aspect == "动机一致性"
assert issue.quote == "他忽然转身就走,什么也没说。"
assert issue.confidence == pytest.approx(0.82)
def test_characterization_defaults_to_empty_issues() -> None:
# 无人物卡 / 无问题 → 空 issues默认值降级仿 StyleDriftReview 韧性)
review = CharacterizationReview()
assert review.issues == []
def test_characterization_issue_all_fields_have_defaults() -> None:
# 畸形/缺字段守韧性:全字段给默认值,空 dict 也能解析
issue = CharacterizationIssue.model_validate({})
assert issue.character == ""
assert issue.aspect == ""
assert issue.where == ""
assert issue.quote == ""
assert issue.diagnosis == ""
assert issue.suggestion == ""
assert issue.confidence == 0.0
def test_characterization_issue_partial_fields_ok() -> None:
# 缺 confidence/quote 时仍解析(降级默认),不抛
issue = CharacterizationIssue.model_validate({"character": "阿黎", "diagnosis": "扁平"})
assert issue.character == "阿黎"
assert issue.diagnosis == "扁平"
assert issue.confidence == 0.0
# ---- characterization_spec 声明analyst 档advisory 单维)----
def test_characterization_spec_is_analyst_tier() -> None:
# 不变量 #2只声明档位不写 model
assert characterization_spec.tier == "analyst"
assert characterization_spec.name == "characterization"
def test_characterization_spec_is_read_only() -> None:
# 不变量 #3审查只读唯一落库=collect 留痕
assert characterization_spec.writes == ()
def test_characterization_spec_reads_characters() -> None:
# 以人物卡 motive/appearance 为客观锚点
assert characterization_spec.reads == ("characters",)
def test_characterization_spec_output_schema() -> None:
assert characterization_spec.output_schema is CharacterizationReview
def test_characterization_spec_prompt_ignores_length_and_diction() -> None:
# 对抗 LLM-judge 冗长/辞藻偏见prompt 必须显式声明忽略文本长度与辞藻华丽度
prompt = characterization_spec.system_prompt
assert "忽略文本长度与辞藻华丽度" in prompt
def test_characterization_not_in_review_reserved_names() -> None:
# advisory不进受信保留名不阻断验收、无 conflicts 通道)
assert "characterization" not in REVIEW_RESERVED_NAMES
def test_characterization_spec_is_agent_spec_and_immutable() -> None:
assert isinstance(characterization_spec, AgentSpec)
with pytest.raises(ValidationError):
characterization_spec.tier = "writer"

View File

@@ -52,8 +52,8 @@ def test_load_prompt_matches_golden(name: str) -> None:
# ---- #1 注册表唯一性 ----
def test_specs_registry_len_is_22() -> None:
assert len(SPECS) == 22
def test_specs_registry_len_is_23() -> None:
assert len(SPECS) == 23
# name 即 keydict 已去重;逐项确认 key == spec.name无错位
assert all(key == spec.name for key, spec in SPECS.items())