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:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
207
packages/core/tests/test_characterization_review.py
Normal file
207
packages/core/tests/test_characterization_review.py
Normal 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()}}
|
||||
|
||||
|
||||
# ---- collect:characterization 列映射(纯函数)----
|
||||
|
||||
|
||||
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 # 失败列留空,不阻塞其余
|
||||
|
||||
|
||||
# ---- SSE:normalize_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": []}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
# ---- collect:style 列映射 ----
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
|
||||
@@ -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`(节奏诊断 dict:water/hook/beat_map);
|
||||
- style → `style`(文风漂移诊断 dict:score/segments)。
|
||||
M4 四审齐 → 列映射(一次 record 落齐本章四审)+ 灵感③ advisory 人物塑造第五审:
|
||||
- continuity → `conflicts`(冲突清单 list);
|
||||
- foreshadow → `foreshadow_sug`(planted/resolved 扁平为建议 list,每条带 `kind` 标记);
|
||||
- pace → `pace`(节奏诊断 dict:water/hook/beat_map);
|
||||
- style → `style`(文风漂移诊断 dict:score/segments);
|
||||
- characterization→ `characterization`(人物塑造诊断 dict:issues;advisory,不进验收 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)。
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user