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

@@ -128,6 +128,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:
entry = {
@@ -139,6 +140,7 @@ class FakeReviewRepo:
"foreshadow_sug": list(foreshadow_sug or []),
"style": style,
"pace": pace,
"characterization": characterization,
"health_score": health_score,
}
self.records.append(entry)

View File

@@ -141,6 +141,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

@@ -0,0 +1,207 @@
"""灵感③ 人物塑造 advisory 审单测——并入 review 图 + collect characterization 列 + SSE 事件。
全部注入 mock 网关 / 内存 fake repo无真 LLM、无真 Postgres图用 MemorySaver
asyncio_mode=auto。验第五审advisory落 `characterization` 列、SSE `section` + `characterization`
事件、空 issues 优雅降级、失败隔离不阻塞其余审。
"""
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 (
CharacterizationIssue,
CharacterizationReview,
ContinuityReview,
ForeshadowReview,
PaceReview,
StyleDriftReview,
)
from ww_core.orchestrator import (
CHARACTERIZATION,
CONTINUITY,
EVENT_CHARACTERIZATION,
EVENT_DONE,
EVENT_SECTION,
REVIEW_INCOMPLETE,
REVIEW_OK,
SECTION_DONE,
SECTION_INCOMPLETE,
ChapterState,
build_review_context,
build_review_graph,
collect_reviews,
extract_characterization,
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 = "他忽然转身就走,什么也没说。"
_CHAR = CharacterizationReview(
issues=[
CharacterizationIssue(
character="沈砚",
aspect="动机一致性",
where="第1段",
quote="他忽然转身就走,什么也没说。",
diagnosis="与既定护妹动机断裂",
suggestion="退缩扣回护妹动机",
confidence=0.82,
)
]
)
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 _char_reviews() -> dict[str, Any]:
return {CHARACTERIZATION: {"status": REVIEW_OK, "result": _CHAR.model_dump()}}
# ---- collectcharacterization 列映射(纯函数)----
def test_extract_characterization_returns_whole_dict() -> None:
char = extract_characterization(_char_reviews())
assert char is not None
assert len(char["issues"]) == 1
assert char["issues"][0]["character"] == "沈砚"
assert char["issues"][0]["confidence"] == 0.82
def test_extract_characterization_none_when_incomplete() -> None:
reviews = {CHARACTERIZATION: {"status": REVIEW_INCOMPLETE, "result": None}}
assert extract_characterization(reviews) is None
def test_extract_characterization_none_when_absent() -> None:
assert extract_characterization({}) is None
async def test_collect_records_characterization_to_its_column() -> None:
repo = FakeReviewRepo()
await collect_reviews(_state(_char_reviews()), review_repo=repo)
assert len(repo.records) == 1
rec = repo.records[0]
assert rec["characterization"]["issues"][0]["aspect"] == "动机一致性"
# advisory不进 conflicts不阻断验收
assert rec["conflicts"] == []
# ---- 图characterization 作为默认第五审并行跑通 ----
async def test_characterization_runs_in_default_review_graph() -> None:
gateway = SchemaRoutingRunGateway(
{
ContinuityReview: ContinuityReview(conflicts=[]),
ForeshadowReview: ForeshadowReview(),
PaceReview: PaceReview(),
StyleDriftReview: StyleDriftReview(),
CharacterizationReview: _CHAR,
}
)
repo = FakeReviewRepo()
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
config: RunnableConfig = {"configurable": {"thread_id": "char-1"}}
final = await graph.ainvoke(_state(), config=config)
assert final["reviews"][CHARACTERIZATION]["status"] == REVIEW_OK
rec = repo.records[0]
assert rec["characterization"]["issues"][0]["character"] == "沈砚"
async def test_characterization_failure_isolated_does_not_block_others() -> None:
# characterization schema 缺失 → SchemaRoutingRunGateway 抛 KeyError → 隔离为 incomplete
gateway = SchemaRoutingRunGateway(
{
ContinuityReview: ContinuityReview(conflicts=[]),
ForeshadowReview: ForeshadowReview(),
PaceReview: PaceReview(),
StyleDriftReview: StyleDriftReview(),
}
)
repo = FakeReviewRepo()
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
config: RunnableConfig = {"configurable": {"thread_id": "char-2"}}
final = await graph.ainvoke(_state(), config=config)
assert final["reviews"][CHARACTERIZATION]["status"] == REVIEW_INCOMPLETE
rec = repo.records[0]
assert rec["characterization"] is None # 失败列留空,不阻塞其余
# ---- SSEnormalize_review surface characterization 事件 ----
async def test_normalize_review_surfaces_characterization_event() -> None:
events = [e async for e in normalize_review(_char_reviews())]
# 单审section(done) + characterization + done
assert [e.event for e in events] == [EVENT_SECTION, EVENT_CHARACTERIZATION, EVENT_DONE]
section = events[0]
assert section.data == {"name": CHARACTERIZATION, "status": SECTION_DONE}
char_evt = events[1]
issues = char_evt.data["issues"]
assert isinstance(issues, list)
assert issues[0]["character"] == "沈砚"
assert issues[0]["confidence"] == 0.82
async def test_characterization_sorts_first_in_multi_review_sse() -> None:
# 字典序characterization < continuity → advisory 事件排在最前,不打乱既有四审断言
reviews = {
CHARACTERIZATION: {"status": REVIEW_OK, "result": _CHAR.model_dump()},
CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": []}},
}
events = [e async for e in normalize_review(reviews)]
names = [e.data.get("name") for e in events if e.event == EVENT_SECTION]
assert names == [CHARACTERIZATION, CONTINUITY]
async def test_normalize_review_marks_characterization_incomplete() -> None:
reviews = {CHARACTERIZATION: {"status": REVIEW_INCOMPLETE, "result": None}}
events = [e async for e in normalize_review(reviews)]
assert [e.event for e in events] == [EVENT_SECTION, EVENT_DONE]
assert events[0].data == {"name": CHARACTERIZATION, "status": SECTION_INCOMPLETE}
async def test_characterization_empty_issues_still_records() -> None:
# 空 issues 降级仍是 ok 结果、照常落库(区别于 incomplete None
repo = FakeReviewRepo()
reviews = {
CHARACTERIZATION: {"status": REVIEW_OK, "result": CharacterizationReview().model_dump()}
}
await collect_reviews(_state(reviews), review_repo=repo)
assert repo.records[0]["characterization"] == {"issues": []}

View File

@@ -34,6 +34,7 @@ class FakeReviewRepo:
foreshadow_sug: list[dict[str, object]] | None = None,
style: dict[str, object] | None = None,
pace: dict[str, object] | None = None,
characterization: dict[str, object] | None = None,
health_score: int | None = None,
) -> ReviewView:
self._seq += 1
@@ -46,6 +47,7 @@ class FakeReviewRepo:
foreshadow_sug=list(foreshadow_sug or []),
style=style,
pace=pace,
characterization=characterization,
health_score=health_score,
decisions=None,
)

View File

@@ -14,6 +14,8 @@ from fakes_orchestrator import FakeReviewRepo, SchemaRoutingRunGateway
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver
from ww_agents import (
CharacterizationIssue,
CharacterizationReview,
Conflict,
ContinuityReview,
ForeshadowReview,
@@ -24,6 +26,7 @@ from ww_agents import (
StyleDriftSegment,
)
from ww_core.orchestrator import (
CHARACTERIZATION,
CONTINUITY,
EVENT_CONFLICT,
EVENT_DONE,
@@ -67,6 +70,19 @@ _STYLE = StyleDriftReview(
StyleDriftSegment(idx=7, score=55),
],
)
_CHARACTERIZATION = CharacterizationReview(
issues=[
CharacterizationIssue(
character="沈砚",
aspect="动机一致性",
where="第4段",
quote="他忽然转身就走。",
diagnosis="与既定护妹动机断裂",
suggestion="退缩扣回护妹动机",
confidence=0.82,
)
]
)
def _state(reviews: dict[str, Any] | None = None) -> ChapterState:
@@ -96,9 +112,10 @@ def _four_reviews() -> dict[str, Any]:
# ---- REVIEW_SPECS 含第四审 ----
def test_review_specs_include_four_audits() -> None:
def test_review_specs_include_five_audits() -> None:
# 四审 + 灵感③ advisory 人物塑造第五审characterization 末位追加)。
names = [s.name for s in REVIEW_SPECS]
assert names == ["continuity", "foreshadow", "pace", "style"]
assert names == ["continuity", "foreshadow", "pace", "style", "characterization"]
# ---- collectstyle 列映射 ----
@@ -139,13 +156,14 @@ async def test_collect_records_style_to_style_column() -> None:
# ---- 四审并行图跑通mock 网关按 schema 路由产四 parsed----
async def test_four_review_graph_runs_all_audits_end_to_end() -> None:
async def test_five_review_graph_runs_all_audits_end_to_end() -> None:
gateway = SchemaRoutingRunGateway(
{
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
ForeshadowReview: _FORESHADOW,
PaceReview: _PACE,
StyleDriftReview: _STYLE,
CharacterizationReview: _CHARACTERIZATION,
}
)
repo = FakeReviewRepo()
@@ -158,10 +176,12 @@ async def test_four_review_graph_runs_all_audits_end_to_end() -> None:
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 # 四审各调一次网关
assert final["reviews"][CHARACTERIZATION]["status"] == REVIEW_OK
assert len(gateway.calls) == 5 # 五审各调一次网关(含 advisory 人物塑造)
rec = repo.records[0]
assert rec["conflicts"] and rec["foreshadow_sug"] and rec["pace"]
assert rec["style"]["score"] == 72
assert rec["characterization"]["issues"][0]["character"] == "沈砚"
async def test_four_review_graph_isolates_failing_style_audit() -> None: