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

@@ -33,6 +33,8 @@ class ReviewView(BaseModel):
foreshadow_sug: list[dict[str, Any]] = Field(default_factory=list)
style: dict[str, Any] | None = None
pace: dict[str, Any] | None = None
# 灵感③ advisory 人物塑造审留痕(末位默认 None优雅降级不进 conflicts/验收 gate
characterization: dict[str, Any] | None = None
health_score: int | None = None
decisions: dict[str, Any] | None = None
@@ -50,6 +52,7 @@ class ReviewRepo(Protocol):
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:
"""append 一行审稿报告T2.2 collect 写入)。"""
@@ -74,6 +77,7 @@ def _to_view(row: ChapterReview) -> ReviewView:
foreshadow_sug=list(row.foreshadow_sug or []),
style=row.style,
pace=row.pace,
characterization=row.characterization,
health_score=row.health_score,
decisions=row.decisions,
)
@@ -95,6 +99,7 @@ class SqlReviewRepo:
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:
row = ChapterReview(
@@ -105,6 +110,7 @@ class SqlReviewRepo:
foreshadow_sug=list(foreshadow_sug or []),
style=style,
pace=pace,
characterization=characterization,
health_score=health_score,
decisions=None,
)

View File

@@ -10,12 +10,14 @@ from __future__ import annotations
from ._protocols import GatewayRun
from .collect import (
CHARACTERIZATION,
CONTINUITY,
FORESHADOW,
PACE,
STYLE,
ReviewRecorder,
collect_reviews,
extract_characterization,
extract_conflicts,
extract_foreshadow_sug,
extract_pace,
@@ -52,6 +54,7 @@ from .review_node import (
run_review,
)
from .sse import (
EVENT_CHARACTERIZATION,
EVENT_CONFLICT,
EVENT_DONE,
EVENT_ERROR,
@@ -64,6 +67,7 @@ from .sse import (
SECTION_INCOMPLETE,
SECTION_STARTED,
SseEvent,
characterization_event,
conflict_event,
done_event,
error_event,
@@ -80,8 +84,10 @@ from .style_extract_node import build_style_extract_request, run_style_extractio
from .write_node import GatewayStream, build_write_request, stream_chapter_draft, write_node
__all__ = [
"CHARACTERIZATION",
"COLLECT_NODE",
"CONTINUITY",
"EVENT_CHARACTERIZATION",
"EVENT_CONFLICT",
"EVENT_DONE",
"EVENT_ERROR",
@@ -119,10 +125,12 @@ __all__ = [
"build_text_input_context",
"build_worldbuilder_context",
"build_write_request",
"characterization_event",
"collect_reviews",
"conflict_event",
"done_event",
"error_event",
"extract_characterization",
"extract_conflicts",
"extract_foreshadow_sug",
"extract_pace",

View File

@@ -93,6 +93,7 @@ class ReviewRecordRepo(Protocol):
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: ...

View File

@@ -11,11 +11,12 @@ AI 产出真正入库经验收事务的裁决)。
collect 与 review_repo 同样**只 flush 不 commit**——`commit` 归 HTTP 端点 / 验收事务。
本节点绝不 commit。
M4 四审齐 → 列映射(一次 record 落齐本章四审):
- continuity → `conflicts`(冲突清单 list
- foreshadow → `foreshadow_sug`planted/resolved 扁平为建议 list每条带 `kind` 标记);
- pace → `pace`(节奏诊断 dictwater/hook/beat_map
- style → `style`(文风漂移诊断 dictscore/segments
M4 四审齐 → 列映射(一次 record 落齐本章四审)+ 灵感③ advisory 人物塑造第五审
- continuity → `conflicts`(冲突清单 list
- foreshadow → `foreshadow_sug`planted/resolved 扁平为建议 list每条带 `kind` 标记);
- pace → `pace`(节奏诊断 dictwater/hook/beat_map
- style → `style`(文风漂移诊断 dictscore/segments
- characterization→ `characterization`(人物塑造诊断 dictissuesadvisory不进验收 gate
任一审 `incomplete`网关失败隔离§5.2)→ 该列留空/None不阻塞其余。
"""
@@ -35,6 +36,7 @@ CONTINUITY = "continuity"
FORESHADOW = "foreshadow"
PACE = "pace"
STYLE = "style"
CHARACTERIZATION = "characterization" # 灵感③ advisory 人物塑造审(仅建议、不阻断验收)
# foreshadow 建议扁平化时的来源标记planted=新埋 / resolved=回收)。
FORESHADOW_KIND_PLANTED = "planted"
@@ -57,6 +59,7 @@ class ReviewRecorder(Protocol):
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: ...
@@ -123,6 +126,19 @@ def extract_style(reviews: dict[str, Any]) -> dict[str, Any] | None:
return dict(result)
def extract_characterization(reviews: dict[str, Any]) -> dict[str, Any] | None:
"""从 characterization灵感③ advisory 审)分项取人物塑造诊断 dict纯函数
`CharacterizationReview{issues}` 整体入 `characterization` 列JSONB dict
未完成/缺席 → None列留空§5.2 不阻塞其余)。空 issues 降级仍是 `ok` 结果、照常落库。
advisory只留痕、不进 conflicts、不碰验收 gate灵感 D2
"""
result = _ok_result(reviews, CHARACTERIZATION)
if result is None:
return None
return dict(result)
async def collect_reviews(
state: ChapterState,
*,
@@ -140,6 +156,7 @@ async def collect_reviews(
foreshadow_sug = extract_foreshadow_sug(reviews)
pace = extract_pace(reviews)
style = extract_style(reviews)
characterization = extract_characterization(reviews)
await review_repo.record(
state["project_id"],
state["chapter_no"],
@@ -148,6 +165,7 @@ async def collect_reviews(
foreshadow_sug=foreshadow_sug,
style=style,
pace=pace,
characterization=characterization,
)
log.info(
"collect_reviews_recorded",
@@ -157,6 +175,7 @@ async def collect_reviews(
foreshadow_sug_count=len(foreshadow_sug),
has_pace=pace is not None,
has_style=style is not None,
has_characterization=characterization is not None,
reviews=sorted(reviews.keys()),
)
# 留痕已在表(真相源);不在节点 commit端点/事务层负责,见模块 docstring

View File

@@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any
from langgraph.graph import END, START, StateGraph
from ww_agents import (
AgentSpec,
characterization_spec,
continuity_spec,
foreshadow_spec,
pace_spec,
@@ -38,12 +39,15 @@ WRITE_NODE = "write"
COLLECT_NODE = "collect"
# M4四审齐continuity + foreshadow + pace + style。第四审 = 文风漂移打分轨
# M4 四审齐continuity + foreshadow + pace + style+ 灵感③ advisory 人物塑造第五审
# characterization 是 advisory仅建议、不阻断验收并行扇出自动汇入 collect
# 不进 conflicts、不碰验收 gate、不入 REVIEW_RESERVED_NAMES灵感 D2
REVIEW_SPECS: tuple[AgentSpec, ...] = (
continuity_spec,
foreshadow_spec,
pace_spec,
style_drift_spec,
characterization_spec,
)

View File

@@ -18,7 +18,7 @@ from pydantic import BaseModel
from ww_llm_gateway.types import Delta
from ww_shared import AppError, ErrorCode
from .collect import CONTINUITY, FORESHADOW, PACE, STYLE
from .collect import CHARACTERIZATION, CONTINUITY, FORESHADOW, PACE, STYLE
from .review_node import REVIEW_OK
log = structlog.get_logger(__name__)
@@ -30,6 +30,7 @@ EVENT_CONFLICT = "conflict" # continuity 冲突命中(审稿 review
EVENT_FORESHADOW = "foreshadow" # foreshadow 建议命中(新埋/回收,审稿 review
EVENT_PACE = "pace" # pace 节奏诊断(注水/钩子/节拍图,审稿 review
EVENT_STYLE = "style" # style 文风漂移诊断(整体相似度 + 漂移段,审稿 review
EVENT_CHARACTERIZATION = "characterization" # 人物塑造诊断advisory issues审稿 review
EVENT_DONE = "done"
EVENT_ERROR = "error"
@@ -129,6 +130,18 @@ def style_event(*, score: int, segments: list[dict[str, object]]) -> SseEvent:
)
def characterization_event(*, issues: list[dict[str, object]]) -> SseEvent:
"""人物塑造诊断事件advisory审稿 review——前端人物塑造面板只展示、无裁决 UI。
一次审产一条:人物塑造问题清单(每条 character/aspect/where/quote/diagnosis/suggestion/
confidence。仅建议、不阻断验收灵感 D2
"""
return SseEvent(
event=EVENT_CHARACTERIZATION,
data={"issues": issues},
)
def error_event(*, code: str, message: str, request_id: str | None = None) -> SseEvent:
"""错误事件,形对齐错误信封 §7.1`code`/`message`),附 `request_id` 便于贯通排查。"""
return SseEvent(
@@ -231,6 +244,23 @@ def _section_result_events(name: str, result: dict[str, Any]) -> list[SseEvent]:
],
)
)
elif name == CHARACTERIZATION:
events.append(
characterization_event(
issues=[
{
"character": issue.get("character", ""),
"aspect": issue.get("aspect", ""),
"where": issue.get("where", ""),
"quote": issue.get("quote", ""),
"diagnosis": issue.get("diagnosis", ""),
"suggestion": issue.get("suggestion", ""),
"confidence": float(issue.get("confidence", 0.0)),
}
for issue in result.get("issues") or []
]
)
)
return events