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

@@ -151,6 +151,7 @@ class FakeReviewRepo:
foreshadow_sug: list[dict[str, Any]] | None = None,
style: dict[str, Any] | None = None,
pace: dict[str, Any] | None = None,
characterization: dict[str, Any] | None = None,
health_score: int | None = None,
) -> ReviewView:
view = ReviewView(
@@ -162,6 +163,7 @@ class FakeReviewRepo:
foreshadow_sug=[dict(s) for s in (foreshadow_sug or [])],
style=style,
pace=pace,
characterization=characterization,
health_score=health_score,
)
# 新→旧:插到列表头部。

View File

@@ -115,6 +115,7 @@ class _FakeReviewRepo:
foreshadow_sug: list[dict[str, Any]] | None = None,
style: dict[str, Any] | None = None,
pace: dict[str, Any] | None = None,
characterization: dict[str, Any] | None = None,
health_score: int | None = None,
) -> Any:
rec = _FakeReviewRecord(chapter_no, list(conflicts))

View File

@@ -209,6 +209,70 @@ async def test_accept_promotes_appends_digest_records_decisions() -> None:
assert session.commits == 1
@pytest.mark.asyncio
async def test_characterization_does_not_block_accept() -> None:
"""灵感③ advisory人物塑造问题零 continuity 冲突不阻断验收D2
留痕带 characterization issues 但 conflicts=[] → 验收 gate 只读 continuity conflicts
直通晋升characterization 从不进 conflicts、从不产 decisions。
"""
from ww_api.services.digest_extraction import ChapterDigestFacts
review_repo = FakeReviewRepo()
pid = uuid.uuid4()
# 零 continuity 冲突,但有 advisory 人物塑造问题。
await review_repo.record(
pid,
1,
conflicts=[],
characterization={
"issues": [
{
"character": "沈砚",
"aspect": "动机一致性",
"where": "第3段",
"quote": "他忽然转身就走。",
"diagnosis": "与既定护妹动机断裂",
"suggestion": "退缩扣回护妹动机",
"confidence": 0.82,
}
]
},
)
digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="s"))
client, chapter_repo, _, digest_repo, session = _make_client(
review_repo=review_repo, digest_gateway=digest_gw
)
async with client:
resp = await client.post(
f"/projects/{pid}/chapters/1/accept",
# 无需对人物塑造做任何裁决advisory 无 conflict 通道)。
json={"final_text": "作者终稿正文。", "decisions": []},
)
assert resp.status_code == 200
assert resp.json()["accepted_version"] == 1
assert session.commits == 1
# characterization 留痕不产 decisionsadvisory晋升照常。
accepted = await chapter_repo.latest_accepted(pid, 1)
assert accepted is not None
@pytest.mark.asyncio
async def test_characterization_surfaces_in_review_history() -> None:
"""列 characterization 经 GET .../reviews 透传到历史项(前端面板 seed 用)。"""
review_repo = FakeReviewRepo()
pid = uuid.uuid4()
await review_repo.record(
pid, 1, conflicts=[], characterization={"issues": [{"character": "沈砚"}]}
)
client, _, _, _, _ = _make_client(review_repo=review_repo)
async with client:
resp = await client.get(f"/projects/{pid}/chapters/1/reviews")
assert resp.status_code == 200
row = resp.json()["reviews"][0]
assert row["characterization"]["issues"][0]["character"] == "沈砚"
@pytest.mark.asyncio
async def test_accept_with_no_review_succeeds() -> None:
"""无审稿留痕(零冲突)→ gate 直通,仍晋升 + 提炼 digest。"""