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())

View File

@@ -13,6 +13,8 @@ from .schemas import (
BookTeardownResult,
CharacterCard,
CharacterGenResult,
CharacterizationIssue,
CharacterizationReview,
CharacterRelation,
Conflict,
ConflictType,
@@ -57,6 +59,7 @@ from .specs import (
book_title_spec,
brainstorm_spec,
character_gen_spec,
characterization_spec,
continue_spec,
continuity_spec,
de_ai_spec,
@@ -87,6 +90,8 @@ __all__ = [
"BookTeardownResult",
"CharacterCard",
"CharacterGenResult",
"CharacterizationIssue",
"CharacterizationReview",
"CharacterRelation",
"Conflict",
"ConflictType",
@@ -127,6 +132,7 @@ __all__ = [
"book_title_spec",
"brainstorm_spec",
"character_gen_spec",
"characterization_spec",
"continue_spec",
"continuity_spec",
"de_ai_spec",

View File

@@ -0,0 +1,47 @@
你是长篇连载网络小说的「人物塑造续审」characterization-checker——专审人物塑造的资深网文编辑。你的唯一职责对照作品真相源里的人物卡尤其**动机 motive** 与**外貌 appearance** 两个客观锚点),逐条诊断本章草稿里人物塑造的**失真/扁平/失声/动机断裂**,引用具体片段并给出置信度。你**只读、只诊断、只报告**,绝不改稿、不续写、不写库(不变量 #3)。你的产出是**建议性的advisory**——供作者参考,不阻断验收。
## 审稿依据:人物卡(客观锚点优先)
注入材料里带本作的人物卡(含 `motive` 动机/欲望/恐惧、`appearance` 外貌、性格三层 `traits`、成长弧 `arc`)。以人物卡为客观基准,重点核对本章人物的言行是否与其**既定动机**一致、外貌/标识描写是否与**既定 appearance** 冲突。人物卡缺失或未注入该角色时,退回网文通用基线:主角与主要配角应有**清晰动机驱动**、**可辨识的说话声音**、**行为符合已建立的性格逻辑**,避免工具人式的扁平与前后失声。
## 只审塑造、不审辞藻(关键纪律)
- **显式忽略文本长度与辞藻华丽度**:段落长短、用词是否华丽、句子是否优美,**一律不在本审范围**。只判「这个人物写得像不像这个人、动机通不通、有没有辨识度」,不判文笔好坏、不判篇幅多寡。
- 不因描写细腻/篇幅长就抬高评价,也不因文字朴素/篇幅短就判为塑造不足——**塑造质量与文本体量、辞藻华丽度无关**(对抗 LLM 评审的冗长偏见与辞藻偏见)。
- 节奏/注水、文风漂移、一致性冲突、伏笔——**均不在本审范围**(各有专审)。本审只看人物塑造这一维。
## 审稿方法(逐步执行)
1. **定位人物**:列出本章有实质戏份的角色,优先主角与主要配角。
2. **对锚诊断**:逐个角色,对照人物卡的 `motive`/`appearance`/`traits`/`arc`,找出本章中与之**失真、断裂、扁平化、失声**的具体表现。
3. **证据优先**:每条诊断都必须从本章草稿**逐字摘录**一小段支撑片段(内容锚,不得改写),锚定「问题出在哪句」;无法逐字锚定则不报(不臆造)。
4. **给置信度**:每条诊断给一个 `0.01.0` 的置信度——你越确信是真问题给越高;主观/边缘/见仁见智的给低值,供作者过滤(对抗 LLM 评审主观偏见)。
5. **建议而非改稿**:给出改进方向(如「让其行为回扣既定动机 X」但**绝不输出改写后的正文**。
### 塑造维度锚点aspect 取值口径)
- `动机一致性`:本章言行是否被人物卡既定动机驱动,有无无动机行动/动机断裂;
- `声音辨识度`:对白/内心是否有该角色可辨识的说话声音,还是千人一腔;
- `行为逻辑`:行为是否符合已建立的性格逻辑与处境,有无为推进剧情的强行 OOC
- `外貌一致性`:外貌/标识性描写是否与人物卡 `appearance` 冲突;
- `扁平化`:是否沦为只服务功能的工具人、缺乏立体动机与内在张力。
## 输出格式(严格 JSON仅含以下顶层字段不要多余字段或解释文字
- **`issues`**:人物塑造问题列表(数组)。逐条诊断;每个元素为对象,含:
- `character`:涉及的角色名(尽量对齐人物卡 name无法归属某角色则留空串
- `aspect`:塑造维度(取上述锚点之一或贴切的简短维度名);
- `where`:本章定位(哪一段/哪个场景体现该问题,便于作者定位);
- `quote`:从本章草稿**逐字摘录**的支撑片段(含标点,不得改写;无法逐字锚定则不要输出该条);
- `diagnosis`:诊断说明(为何此处塑造偏弱/失真,扣住动机/声音/行为逻辑等客观点);
- `suggestion`:改进建议(方向性,不含改写后的正文);
- `confidence`:置信度浮点 `0.01.0`(越确信越高,主观/边缘给低值)。
- 若全章人物塑造无明显问题,`issues` 为空数组 `[]`
## 纪律(不可违反)
- 你**只读、只报人物塑造诊断**,绝不改稿、不写库、不输出任何改写后的正文(不变量 #3);你的结论是 advisory 建议,不阻断验收。
- **显式忽略文本长度与辞藻华丽度**——只判塑造,不判文笔与篇幅。
- 每条诊断必须带逐字摘录的 `quote``confidence`,不臆造、不空泛;无逐字证据则不报。
- 无明显问题则 `issues` 为空数组;宁缺毋滥、少而准,避免维度噪声淹没作者注意力。
- 只输出上述字段的 JSON不附加旁白、不解释推理过程。

View File

@@ -2,7 +2,7 @@
Pydantic 类型承载结构化解析与 `isinstance` 校验,无法从文本推导、无法运行时安全构造,
必须留 Python。`SPECS[name].output_schema` 一律从这里派生(单向派生,避免双真相)。
`input_schema` 全 22 个恒为 None入参为序列化文本本波不建入参槽YAGNI
`input_schema` 全 23 个恒为 None入参为序列化文本本波不建入参槽YAGNI
`refiner` 是唯一纯文本 writer`None` 是合法值(无结构化 schema
"""
@@ -17,6 +17,7 @@ from .schemas import (
BlurbResult,
BookTeardownResult,
CharacterGenResult,
CharacterizationReview,
ContinuationResult,
ContinuityReview,
DeAiResult,
@@ -43,6 +44,7 @@ SCHEMA_CATALOG: Final[dict[str, type[BaseModel] | None]] = {
"outliner": OutlineResult,
"foreshadow": ForeshadowReview,
"pace": PaceReview,
"characterization": CharacterizationReview,
"style_extract": StyleFingerprintResult,
"style": StyleDriftReview,
"refiner": None, # 唯一纯文本 writerNone 是合法值

View File

@@ -226,6 +226,50 @@ class StyleDriftReview(BaseModel):
)
# ---- 人物塑造续审结构化输出(灵感③ · advisory 单维;仅建议、不阻断验收)----
class CharacterizationIssue(BaseModel):
"""单条人物塑造问题:角色 + 维度 + 定位 + 逐字引用 + 诊断 + 建议 + 置信度。
以人物卡 `motive`/`appearance` 为客观锚点。`quote` 是从本章草稿**逐字摘录**的支撑
片段(内容锚,不得改写);`confidence` 供作者过滤主观/边缘诊断(对抗 LLM-judge 偏见)。
全字段给默认值守解析韧性(畸形/缺字段不炸)。
"""
character: str = Field(
default="", description="涉及的角色名(尽量对齐人物卡 name无法归属留空"
)
aspect: str = Field(
default="",
description="塑造维度(动机一致性 / 声音辨识度 / 行为逻辑 / 外貌一致性 / 扁平化 等)",
)
where: str = Field(default="", description="本章定位:哪一段/哪个场景体现该问题")
quote: str = Field(
default="",
description="从本章草稿**逐字摘录**的支撑片段(含标点,不得改写);供作者内容锚定位",
)
diagnosis: str = Field(default="", description="诊断说明:为何此处人物塑造偏弱/失真")
suggestion: str = Field(default="", description="改进建议(方向性,不含改写后的正文)")
confidence: float = Field(
default=0.0, description="置信度 0.01.0(越确信越高,主观/边缘给低值,供作者过滤)"
)
class CharacterizationReview(BaseModel):
"""人物塑造续审结构化产出只读建议清单advisory不阻断验收不变量 #3
**带默认值降级**:材料无人物卡 / 无明显问题时返回 `issues=[]`(默认值),不报错、
不阻塞其余审§5.2 失败隔离之外的内容性兜底,仿 `StyleDriftReview`)。仅建议——
不进 conflicts、不碰验收 gate灵感 D2 advisory
"""
issues: list[CharacterizationIssue] = Field(
default_factory=list,
description="人物塑造问题清单(每条带角色/维度/定位/逐字引用/置信度);无则空列表",
)
# ---- worldbuilder 结构化输出ARCH §5.4 worldbuilder 行 / §6.5 / §4.5----

View File

@@ -30,6 +30,7 @@ __all__ = [
"book_title_spec",
"brainstorm_spec",
"character_gen_spec",
"characterization_spec",
"continue_spec",
"continuity_spec",
"de_ai_spec",
@@ -119,6 +120,20 @@ style_drift_spec = AgentSpec(
scope="builtin",
)
# ---- 人物塑造续审灵感③analyst 档advisory 单维——仅建议、不阻断验收D2----
characterization_spec = AgentSpec(
name="characterization",
tier="analyst", # 不变量 #2只声明档位不写 model人物塑造诊断用分析档
system_prompt=load_prompt("characterization"),
input_schema=None, # 注入材料为序列化文本(人物卡 + 草稿,经 assemble非结构化入参
output_schema=SCHEMA_CATALOG["characterization"],
reads=(
"characters",
), # 以人物卡 motive/appearance 为客观锚点(经 assemble 的 stable_core 注入)
writes=(), # 只读(不变量 #3advisory 唯一落库=collect 留痕,不进 conflicts/验收 gate
scope="builtin",
)
# ---- 回炉refinerwriter 档;纯文本重写;非 Agent 流水线、非持久M4-e----
refiner_spec = AgentSpec(
name="refiner",
@@ -322,6 +337,7 @@ _SPECS_BY_NAME: dict[str, AgentSpec] = {
outliner_spec,
foreshadow_spec,
pace_spec,
characterization_spec,
style_extract_spec,
style_drift_spec,
refiner_spec,
@@ -342,7 +358,7 @@ _SPECS_BY_NAME: dict[str, AgentSpec] = {
project_plan_spec,
)
}
assert len(_SPECS_BY_NAME) == 22, "SPECS name 冲突或缺失" # 唯一性 + 数量自检import 期)
assert len(_SPECS_BY_NAME) == 23, "SPECS name 冲突或缺失" # 唯一性 + 数量自检import 期)
SPECS: Final[Mapping[str, AgentSpec]] = MappingProxyType(_SPECS_BY_NAME)
# 四审受信保留名 —— 独立显式白名单(安全边界锚在此,不依附派生集合)