From 72b3c81146a66d34865753832610668083ce1611 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 6 Jul 2026 17:08:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(review):=20=E4=BA=BA=E7=89=A9=E5=A1=91?= =?UTF-8?q?=E9=80=A0=20advisory=20=E5=AE=A1=EF=BC=88=E7=AC=AC=E4=BA=94?= =?UTF-8?q?=E5=AE=A1=EF=BC=8C=E4=BB=85=E5=BB=BA=E8=AE=AE=E4=B8=8D=E9=98=BB?= =?UTF-8?q?=E6=96=AD=E9=AA=8C=E6=94=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 写→审链新增第五审「人物塑造」(灵感③/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 作客观锚点)。 --- PROGRESS.md | 2 + apps/api/tests/fakes_projects.py | 2 + apps/api/tests/test_chain.py | 1 + apps/api/tests/test_review_accept.py | 64 ++++++ apps/api/ww_api/routers/projects.py | 1 + apps/api/ww_api/schemas/projects.py | 2 + .../review/CharacterizationPanel.tsx | 102 +++++++++ apps/web/components/review/ReviewReport.tsx | 54 ++++- apps/web/lib/api/schema.d.ts | 4 + .../lib/review/characterization-sse.test.ts | 108 +++++++++ apps/web/lib/review/history.test.ts | 57 +++++ apps/web/lib/review/history.ts | 13 ++ apps/web/lib/review/sse.ts | 63 ++++++ apps/web/lib/review/useReviewStream.test.ts | 16 ++ apps/web/lib/review/useReviewStream.ts | 3 + memory/contracts.md | 2 + .../agents/tests/fixtures/prompt_hashes.json | 1 + .../tests/test_characterization_spec.py | 115 ++++++++++ packages/agents/tests/test_prompt_loader.py | 4 +- packages/agents/ww_agents/__init__.py | 6 + .../ww_agents/prompts/characterization.md | 47 ++++ packages/agents/ww_agents/schema_catalog.py | 4 +- packages/agents/ww_agents/schemas.py | 44 ++++ packages/agents/ww_agents/specs.py | 18 +- packages/core/tests/fakes_orchestrator.py | 2 + packages/core/tests/test_chain_graph.py | 1 + .../tests/test_characterization_review.py | 207 ++++++++++++++++++ packages/core/tests/test_review_repo.py | 2 + packages/core/tests/test_style_review.py | 28 ++- packages/core/ww_core/domain/review_repo.py | 6 + .../core/ww_core/orchestrator/__init__.py | 8 + .../core/ww_core/orchestrator/chain/nodes.py | 1 + packages/core/ww_core/orchestrator/collect.py | 29 ++- packages/core/ww_core/orchestrator/graph.py | 6 +- packages/core/ww_core/orchestrator/sse.py | 32 ++- ...c9d0e1_chapter_reviews_characterization.py | 33 +++ packages/db/ww_db/models.py | 2 + 37 files changed, 1071 insertions(+), 19 deletions(-) create mode 100644 apps/web/components/review/CharacterizationPanel.tsx create mode 100644 apps/web/lib/review/characterization-sse.test.ts create mode 100644 packages/agents/tests/test_characterization_spec.py create mode 100644 packages/agents/ww_agents/prompts/characterization.md create mode 100644 packages/core/tests/test_characterization_review.py create mode 100644 packages/db/migrations/versions/f6a7b8c9d0e1_chapter_reviews_characterization.py diff --git a/PROGRESS.md b/PROGRESS.md index be5e47a..8fb1ed2 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -161,6 +161,8 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` +- [2026-07-06] @llm/@db/@backend/@frontend — **✅ 灵感③ 人物塑造 advisory 审(develop 直提)**。写→审链新增第五审「人物塑造」:**advisory 单维——仅建议、不阻断验收**(不进 conflicts、不碰 `assert_conflicts_resolved`、**不入 `REVIEW_RESERVED_NAMES`**,D2);只做人物塑造、不做氛围(氛围噪声大折进 style/pace)。**新 SPEC** `characterization_spec`(`tier="analyst"` 不写 model,`reads=("characters",)` 以人物卡 motive/appearance 为客观锚点,`writes=()` 只读,`prompts/characterization.md` **显式忽略文本长度与辞藻华丽度**、引用逐字片段+置信度对抗 LLM-judge 冗长/辞藻偏见)+ schema `CharacterizationReview{issues:[…character/aspect/where/quote/diagnosis/suggestion/confidence]}`(全字段默认值守解析韧性)+ 注册 `SCHEMA_CATALOG["characterization"]`;**计数三处 22→23**(`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring)+ **regen `prompt_hashes.json`**(仅加一条,无其余漂移)。**DB** `chapter_reviews` 加 1 列 `characterization`(**JSONB nullable**,迁移 `f6a7b8c9d0e1`,nullable 免 backfill)。**契约变更** `ReviewHistoryItem`+`ReviewView` 加 `characterization`;接线链 `graph.py REVIEW_SPECS` 末位追加 + `chain/nodes.py ReviewRecordRepo` + `collect.py {CHARACTERIZATION/extract_characterization/ReviewRecorder/collect_reviews}` + `review_repo.py {Protocol/SqlReviewRepo/_to_view}` + `sse.py {EVENT_CHARACTERIZATION/characterization_event/_section_result_events}`(normalize 按 sorted 键自动纳入,字典序排最前不打乱既有四审断言)。**前端** `lib/review/{sse.ts,history.ts,useReviewStream.ts}` + 新 `CharacterizationPanel.tsx`(advisory 只展示、**无裁决 UI**、按置信度降序 + 低置信度折叠控注意力预算)+ `ReviewReport.tsx` 挂面板 + 双 seed。**门禁全绿**:后端 ruff/format 干净 · mypy **221** · pytest **848**(+核心 char 审 12 + agents spec 11 + api accept 2 等)· alembic 无漂移;前端 gen:api/lint/typecheck 干净 · vitest **536**(+10 含 `test_characterization_does_not_block_accept` 对应前端 reducer/normalize/parse)· coverage 95% · build OK。记 contracts C3+C4。守不变量 #2/#3/#9。依赖 ⑧(motive/appearance 已入 CharacterCard 契约,作客观锚点)。 + - [2026-07-06] @backend/@llm/@frontend — **✅ 灵感⑤ AI 立项方案生成+预填(develop 直提)**。向导阶段(project 未建)一键推演结构化书级蓝图预填立项向导。**新 SPEC** `project_plan_spec`(`tier="analyst"` 不写 model,`reads=()`/`writes=()` 纯预览,`prompts/project-plan.md`)+ schema `ProjectPlanResult`(书名候选 list + 时空背景/叙事结构/故事核心/结局设计/基调,**全字段默认值守解析韧性**)+ 注册 `SCHEMA_CATALOG["project-plan"]`;**计数三处 21→22**(`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring)+ **regen `prompt_hashes.json`**(仅加一条)。**新端点** `POST /skills/project-plan/generate`(**不带 project 前缀**绕开 toolbox 404)← `ProjectPlanGenerateRequest`(全字段 `max_length` CR-H9,`brief` 用 `str|None` 令 codegen 可选)→ `ProjectPlanView`;**种子门控**缺 genre/logline→422,无凭据→503。复用 `build_brief_context`+`run_generator`(`run_generator`/`_build_request` 的 `project_id` 放宽 `uuid.UUID|None`——向导无 project,ledger.project_id 本 nullable);新缝 `get_project_plan_gateway`(analyst)。**零迁移**。**前端** `wizard.ts` 加 `mapPlanResultToWizardForm`(书名/结构/基调→对应字段;时空背景/结局设计→折进 premise **不静默丢**;结局取向/视角预设枚举不臆造)+ `applyPlanPatch`(**仅回填空字段、保护已编辑**)+ `canGeneratePlan`/`toPlanSeedRequest` + `useProjectPlan` hook(renderHook 测试)+ `ProjectWizard.tsx` 第 2 步 `PlanAssistant`(生成→预览→按字段回填)。**门禁全绿**:后端 ruff/format 干净 · mypy **218** · pytest **814**(+新端点 6 + agents spec 8 测)· alembic 无漂移(零 ORM 变更);前端 gen:api/lint/typecheck 干净 · vitest **526**(+28)· coverage 95%(wizard/generation 100%)· build OK。记 contracts C3 新端点。守不变量 #2/#3/#9。依赖 ④(已交付)。 - [2026-06-26] @orchestrator — **全项目 Code Review(七维度)完成 → `QA-CR` 整改批立项**。6 个专科 reviewer 并行(后端 FastAPI 架构 / Python 规范 / LangGraph 最佳实践 / 前端 React 架构 / 前端 TS 类型安全 / 应用安全)+ 依赖时效自查;212 py + 168 tsx 全覆盖。绿门禁下仍揪出 **2 CRITICAL + 13 HIGH + 一批 MEDIUM/LOW**:**C1** Next.js 15.1.3 RCE(CVE-2025-66478「React2Shell」,App Router+RSC 受影响,升 15.1.9/15.5.7/16.x + 轮换密钥);**C2** 多章链缺 `recursion_limit`(默认 25 supersteps、每章 4 节点→超 ~6 章即崩,批量产卷不可用)。HIGH 含 Kimi OAuth 占 DB 会话 / httpx 泄漏 / Anthropic 计费记 0 / request_id 未贯通 / `.env.example` 提交可用 Fernet key / 文本输入无 `max_length` / 前端 errorCode 重复 / Drawer 焦点 / RefineView 未取消。**确认无虞**:SSRF(base_url 仅硬编码)/SQL 注入(全 ORM)/XSS(无 dangerouslySetInnerHTML)/错误信封不泄密/Kimi device-flow 无 CSRF。已登记 `QA-CR` 任务表(CR-C1/C2 + CR-H1..H12 + CR-M1..M5 + CR-L1 + CR-D1)按 owner 分派 @frontend/@backend/@llm/@devops,落地顺序见当前阶段 background。**本批为评审立项,尚无代码改动。** - [2026-06-23] @orchestrator — **🎉 多章工作流链(Chain Workflow)交付 + 多 agent 交叉评审 + CRITICAL 修复 + 合并 develop(merge `513bf71`)**。对标星月「一键多章」+ 差异化「每章过四审、遇冲突才停人」。设计契约 `docs/design/chain-workflow.md`(commit `5ee9799`)。**多 agent 逐学科建造**(契约先行、目录单写者):**C1 @llm**(`7f3eaab`) LangGraph **cyclic 链图** `orchestrator/chain/{state,nodes,graph}.py`(`build_chain_graph`:write→review→decide→accept 循环 + **interrupt-on-conflict** + resume;删死代码 `build_write_graph`,`write_node` 本体留用;7 单测 mock 网关+fake session+MemorySaver)·**C2 @backend**(`29349dc`) 服务/3 端点/schema/checkpointer 接线(见下条)·**C3 @db**(`548b7ab`) langgraph 检查点 4 表迁移 `d3e4f5a6b7c8`(`autocommit_block` 避开 `CREATE INDEX CONCURRENTLY` 与 alembic 事务死锁;`env.py` `include_object` 豁免这 4 表防漂移误报)·**C4 @qa**(`061792d`) E2E `tests/test_chain_workflow_e2e.py` 真 pg + mock 网关零 token(无冲突两章全自动 + 冲突→interrupt→awaiting→resume→accept)。**3 路对抗交叉评审**(python/fastapi/不变量)在**绿门禁下仍揪出 1 CRITICAL + 7 HIGH**——证伪"测试过=没问题":CRITICAL=链把**绑请求 session 的 gateway** 传进 BackgroundTask,后台跑时 session 已关→write/review 的 `usage_ledger.record()` 写进死 session **静默丢失**(链计费全丢,违 #1;E2E 清了 ledger 却没断言其存在故漏掉)。**修复**(`d1ea83b`):网关改 `chain_gateway_builder` 按节点 session 重建(仿 `digest_gateway_builder`/`style.py`);日志脱敏(`_classify_job_error`+`exc_type`,去 `str(exc)`,§5);resume 原子 claim `JobRepo.claim_awaiting_to_running`(防并发双跑同 thread_id,非 awaiting→409)+ 所有权校验(`JobView.project_id`,跨项目→404);`ChainResumeAccepted`(去 start/count=0 哨兵);删死导入 `extract_conflicts`;**补回归断言**(链 `usage_ledger` 必有行 + `chapter_reviews` 每章一行)。**出口(DoD)✅**:后端门禁绿 ruff/format 干净 · mypy **194 Success** · alembic **无漂移**(业务表零迁移,复用 `jobs.status=awaiting_input` + `result.awaiting_chapter`;唯一新 DDL=langgraph 检查点表)· pytest **608 passed**(+6 回归测)。首次真正启用 LangGraph checkpointer/interrupt。守不变量 #1/#3/#4/#5/#9。**剩余下一步(未做)**:① 前端链 UI(`pnpm gen:api` 纳入 run/resume + 发起页/进度轮询/awaiting 裁决续跑面板,复用 `ConflictAdjudication`);② §12 文档回写 `ARCHITECTURE.md §5.2`(补 cyclic 链图 + 更正现状:写章直连流式/单章 accept 为事务/链图首启 checkpointer);③ 未 push 远端。可选:续写/扩写节点入链;UGC 链市场需多租户(deferred)。 diff --git a/apps/api/tests/fakes_projects.py b/apps/api/tests/fakes_projects.py index cfe6c49..7806a45 100644 --- a/apps/api/tests/fakes_projects.py +++ b/apps/api/tests/fakes_projects.py @@ -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, ) # 新→旧:插到列表头部。 diff --git a/apps/api/tests/test_chain.py b/apps/api/tests/test_chain.py index d4a82b6..109c0e8 100644 --- a/apps/api/tests/test_chain.py +++ b/apps/api/tests/test_chain.py @@ -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)) diff --git a/apps/api/tests/test_review_accept.py b/apps/api/tests/test_review_accept.py index 96de313..37826ee 100644 --- a/apps/api/tests/test_review_accept.py +++ b/apps/api/tests/test_review_accept.py @@ -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 留痕不产 decisions(advisory);晋升照常。 + 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。""" diff --git a/apps/api/ww_api/routers/projects.py b/apps/api/ww_api/routers/projects.py index bca5581..eeeaa60 100644 --- a/apps/api/ww_api/routers/projects.py +++ b/apps/api/ww_api/routers/projects.py @@ -488,6 +488,7 @@ async def list_reviews( foreshadow_sug=v.foreshadow_sug, style=v.style, pace=v.pace, + characterization=v.characterization, health_score=v.health_score, decisions=v.decisions, ) diff --git a/apps/api/ww_api/schemas/projects.py b/apps/api/ww_api/schemas/projects.py index 5744573..3ec27d6 100644 --- a/apps/api/ww_api/schemas/projects.py +++ b/apps/api/ww_api/schemas/projects.py @@ -191,6 +191,8 @@ class ReviewHistoryItem(BaseModel): foreshadow_sug: list[dict[str, Any]] = Field(default_factory=list) style: dict[str, Any] | None = None pace: dict[str, Any] | None = None + # 灵感③ advisory 人物塑造审留痕(issues 列表 dict);仅建议、不阻断验收。 + characterization: dict[str, Any] | None = None health_score: int | None = None decisions: dict[str, Any] | None = None diff --git a/apps/web/components/review/CharacterizationPanel.tsx b/apps/web/components/review/CharacterizationPanel.tsx new file mode 100644 index 0000000..3ad982f --- /dev/null +++ b/apps/web/components/review/CharacterizationPanel.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { CheckCircle2, CircleDashed, UserRound } from "lucide-react"; + +import { Badge } from "@/components/ui/Badge"; +import type { CharacterizationIssue, CharacterizationReport } from "@/lib/review/sse"; + +interface CharacterizationPanelProps { + characterization: CharacterizationReport | null; + incomplete: boolean; +} + +// 高置信度阈值:≥0.7 为主区常显,<0.7 折进「更多低置信度建议」抽屉(控作者注意力预算,灵感 §10)。 +const HIGH_CONFIDENCE = 0.7; + +// 人物塑造报告区(灵感③ advisory 单维):只读建议、**无裁决 UI**(不进 conflicts/不阻断验收)。 +// 以人物卡 motive/appearance 为客观锚点;每条带逐字引用 + 置信度。按置信度降序, +// 低置信度折叠,避免维度噪声淹没作者。 +export function CharacterizationPanel({ + characterization, + incomplete, +}: CharacterizationPanelProps) { + if (incomplete) { + return ( +
+ + +
+ ); + } + const issues = characterization?.issues ?? []; + if (characterization === null || issues.length === 0) { + return ( +
+ + +
+ ); + } + + // 置信度降序(优先级合并);高/低分区。 + const sorted = [...issues].sort((a, b) => b.confidence - a.confidence); + const high = sorted.filter((i) => i.confidence >= HIGH_CONFIDENCE); + const low = sorted.filter((i) => i.confidence < HIGH_CONFIDENCE); + + return ( +
+

+ 以下为人物塑造建议(仅供参考,不影响验收)。忽略文本长度与辞藻,只看人物是否写得立体。 +

+ + {high.length > 0 && low.length > 0 ? ( +
+ + 更多低置信度建议({low.length}) + +
    + {low.map((issue, i) => ( + + ))} +
+
+ ) : null} +
+ ); +} + +function CharacterizationIssueCard({ issue }: { issue: CharacterizationIssue }) { + const pct = Math.round(Math.max(0, Math.min(1, issue.confidence)) * 100); + return ( +
  • +
    + + + + {pct}% + +
    + {issue.where ?

    定位:{issue.where}

    : null} + {issue.quote ? ( +
    + 「{issue.quote}」 +
    + ) : null} + {issue.diagnosis ?

    {issue.diagnosis}

    : null} + {issue.suggestion ? ( +

    建议:{issue.suggestion}

    + ) : null} +
  • + ); +} diff --git a/apps/web/components/review/ReviewReport.tsx b/apps/web/components/review/ReviewReport.tsx index 002377f..79d2630 100644 --- a/apps/web/components/review/ReviewReport.tsx +++ b/apps/web/components/review/ReviewReport.tsx @@ -32,6 +32,7 @@ import { } from "@/lib/review/decisions"; import { latestReview, + normalizeCharacterization, normalizeConflicts, normalizeForeshadowSug, normalizePace, @@ -56,6 +57,7 @@ import { useToast } from "@/components/Toast"; import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { AcceptPanel } from "./AcceptPanel"; import { AnnotatedText } from "./AnnotatedText"; +import { CharacterizationPanel } from "./CharacterizationPanel"; import { ConflictCard } from "./ConflictCard"; import { ForeshadowSuggestions } from "./ForeshadowSuggestions"; import { PacePanel } from "./PacePanel"; @@ -128,6 +130,10 @@ export function ReviewReport({ () => normalizeStyleDrift(initialReview), [initialReview], ); + const seededCharacterization = useMemo( + () => normalizeCharacterization(initialReview), + [initialReview], + ); useEffect(() => { if (seededRef.current) return; seededRef.current = true; @@ -135,22 +141,32 @@ export function ReviewReport({ seededConflicts.length > 0 || seededForeshadow.length > 0 || seededPace !== null || - seededStyle !== null; + seededStyle !== null || + seededCharacterization !== null; if (hasSeed) { review.seed({ conflicts: seededConflicts, foreshadow: seededForeshadow, pace: seededPace, style: seededStyle, + characterization: seededCharacterization, }); } - }, [review, seededConflicts, seededForeshadow, seededPace, seededStyle]); + }, [ + review, + seededConflicts, + seededForeshadow, + seededPace, + seededStyle, + seededCharacterization, + ]); // 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。 const conflicts: ReviewConflict[] = review.state.conflicts; const foreshadow = review.state.foreshadow; const pace = review.state.pace; const style = review.state.style; + const characterization = review.state.characterization; // 伏笔徽标/验收预览统一用「仍待处理数」(随登记/回收递减);未上报前回退原始建议数。 const foreshadowOpen = foreshadowRemaining ?? foreshadow.length; const sectionStatus = (name: string): string | undefined => @@ -359,7 +375,7 @@ export function ReviewReport({ const fresh = normalizeConflicts(latestReview(data?.reviews)); setMissing(new Set(outcome.missingIndices)); if (fresh.length > 0) { - review.seed({ conflicts: fresh, foreshadow, pace, style }); + review.seed({ conflicts: fresh, foreshadow, pace, style, characterization }); setDrafts(emptyDecisions(fresh.length)); toast("审稿报告已刷新:检测到未裁决冲突,请裁决后再验收", "error"); } else { @@ -405,7 +421,8 @@ export function ReviewReport({ seededConflicts.length > 0 || foreshadow.length > 0 || pace !== null || - style !== null; + style !== null || + characterization !== null; return ( + 0 + ? `${characterization.issues.length} 建议` + : characterization + ? "无明显问题" + : "无报告" + } + statusVariant={ + sectionStatus("characterization") === "incomplete" + ? "warning" + : characterization && characterization.issues.length > 0 + ? "accent" + : characterization + ? "success" + : "neutral" + } + defaultOpen={ + characterization !== null && characterization.issues.length > 0 + } + > + + {refineText !== null ? ( { + it("parses a characterization frame with a full issue", () => { + const evt = parseReviewBlock( + `event:characterization\ndata:{"issues":[${JSON.stringify(FULL_ISSUE)}]}`, + ); + expect(evt).toEqual({ + event: "characterization", + data: { issues: [FULL_ISSUE] }, + }); + }); + + it("defaults missing issue fields (advisory 韧性)", () => { + const evt = parseReviewBlock( + 'event:characterization\ndata:{"issues":[{"character":"阿黎"}]}', + ); + expect(evt).toEqual({ + event: "characterization", + data: { + issues: [ + { + character: "阿黎", + aspect: "", + where: "", + quote: "", + diagnosis: "", + suggestion: "", + confidence: 0, + }, + ], + }, + }); + }); + + it("parses an empty issues frame (无明显问题降级)", () => { + expect( + parseReviewBlock('event:characterization\ndata:{"issues":[]}'), + ).toEqual({ event: "characterization", data: { issues: [] } }); + }); + + it("drops non-record issues without dropping the whole frame", () => { + const evt = parseReviewBlock( + 'event:characterization\ndata:{"issues":["junk",{"character":"沈砚"}]}', + ); + expect(evt?.event).toBe("characterization"); + if (evt?.event === "characterization") { + expect(evt.data.issues).toHaveLength(1); + expect(evt.data.issues[0]?.character).toBe("沈砚"); + } + }); +}); + +describe("reduceReview — characterization (replace-style like pace)", () => { + it("sets characterization report and marks reviewing", () => { + const evt: ReviewSseEvent = { + event: "characterization", + data: { issues: [FULL_ISSUE] }, + }; + const out = reduceReview(initialReviewState, evt); + expect(out.phase).toBe("reviewing"); + expect(out.characterization).toEqual({ issues: [FULL_ISSUE] }); + }); + + it("replaces (not accumulates) the characterization report", () => { + const first = reduceReview(initialReviewState, { + event: "characterization", + data: { issues: [FULL_ISSUE] }, + }); + const second = reduceReview(first, { + event: "characterization", + data: { issues: [] }, + }); + expect(second.characterization).toEqual({ issues: [] }); + }); + + it("leaves characterization untouched on a section event", () => { + const seeded = reduceReview(initialReviewState, { + event: "characterization", + data: { issues: [FULL_ISSUE] }, + }); + const out = reduceReview(seeded, { + event: "section", + data: { name: "characterization", status: "done" }, + }); + expect(out.characterization).toEqual({ issues: [FULL_ISSUE] }); + expect(out.sections).toEqual([ + { name: "characterization", status: "done" }, + ]); + }); +}); diff --git a/apps/web/lib/review/history.test.ts b/apps/web/lib/review/history.test.ts index 6e36760..4c4f745 100644 --- a/apps/web/lib/review/history.test.ts +++ b/apps/web/lib/review/history.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import type { ReviewConflictView, ReviewHistoryItem } from "@/lib/api/types"; import { latestReview, + normalizeCharacterization, normalizeConflicts, normalizeForeshadowSug, normalizePace, @@ -121,6 +122,62 @@ describe("normalizePace", () => { }); }); +describe("normalizeCharacterization", () => { + it("tightens dict into CharacterizationReport with field defaults", () => { + const out = normalizeCharacterization({ + ...item([]), + characterization: { + issues: [ + { + character: "沈砚", + aspect: "动机一致性", + where: "第3段", + quote: "他忽然转身。", + diagnosis: "动机断裂", + suggestion: "扣回护妹动机", + confidence: 0.82, + }, + // 缺字段的畸形条目:安全默认,不丢整条 + { character: "阿黎" }, + ], + }, + }); + expect(out).toEqual({ + issues: [ + { + character: "沈砚", + aspect: "动机一致性", + where: "第3段", + quote: "他忽然转身。", + diagnosis: "动机断裂", + suggestion: "扣回护妹动机", + confidence: 0.82, + }, + { + character: "阿黎", + aspect: "", + where: "", + quote: "", + diagnosis: "", + suggestion: "", + confidence: 0, + }, + ], + }); + }); + + it("returns empty issues when characterization is present but issues missing", () => { + expect( + normalizeCharacterization({ ...item([]), characterization: {} }), + ).toEqual({ issues: [] }); + }); + + it("returns null when characterization is missing or not a dict", () => { + expect(normalizeCharacterization(item([]))).toBeNull(); + expect(normalizeCharacterization(undefined)).toBeNull(); + }); +}); + describe("latestReview", () => { it("returns first item (history is newest-first)", () => { const a = item([]); diff --git a/apps/web/lib/review/history.ts b/apps/web/lib/review/history.ts index dbe86c5..1c9121e 100644 --- a/apps/web/lib/review/history.ts +++ b/apps/web/lib/review/history.ts @@ -2,7 +2,9 @@ // 纯逻辑(可单测);schema 把 conflicts 标成 {[k]:unknown}[],这里安全收窄。 import type { ReviewHistoryItem } from "@/lib/api/types"; +import { asCharacterizationIssues } from "./sse"; import type { + CharacterizationReport, ForeshadowKind, ForeshadowSuggestion, PaceIssue, @@ -80,6 +82,17 @@ export function normalizePace( }; } +// 把一条留痕的 characterization(dict)收紧成人物塑造报告;缺失/非 dict → null(不渲染)。 +// advisory:只展示、无裁决 UI(灵感 D2)。 +export function normalizeCharacterization( + item: ReviewHistoryItem | undefined, +): CharacterizationReport | null { + const raw = item?.characterization; + if (typeof raw !== "object" || raw === null) return null; + const dict = raw as Record; + return { issues: asCharacterizationIssues(dict["issues"]) }; +} + // 最近一条留痕(GET .../reviews 已按新→旧排序)。 export function latestReview( items: readonly ReviewHistoryItem[] | undefined, diff --git a/apps/web/lib/review/sse.ts b/apps/web/lib/review/sse.ts index 87da3e6..9704ca4 100644 --- a/apps/web/lib/review/sse.ts +++ b/apps/web/lib/review/sse.ts @@ -60,6 +60,22 @@ export interface StyleDriftReport { segments: StyleDriftSegment[]; } +// 人物塑造(灵感③ advisory 单维):只读建议,不阻断验收(无裁决 UI)。 +// 以人物卡 motive/appearance 为客观锚点;每条带逐字引用 quote + 置信度 confidence。 +export interface CharacterizationIssue { + character: string; + aspect: string; + where: string; + quote: string; + diagnosis: string; + suggestion: string; + confidence: number; +} + +export interface CharacterizationReport { + issues: CharacterizationIssue[]; +} + export interface SectionEvent { event: "section"; data: { name: string; status: SectionStatus }; @@ -98,6 +114,10 @@ export interface StyleEvent { }[]; }; } +export interface CharacterizationEvent { + event: "characterization"; + data: { issues: CharacterizationIssue[] }; +} export interface DoneEvent { event: "done"; data: { length: number }; @@ -112,6 +132,7 @@ export type ReviewSseEvent = | ForeshadowEvent | PaceEvent | StyleEvent + | CharacterizationEvent | DoneEvent | ErrorEvent; @@ -121,6 +142,7 @@ const KNOWN_EVENTS = new Set([ "foreshadow", "pace", "style", + "characterization", "done", "error", ]); @@ -176,6 +198,34 @@ function asPaceIssues(v: unknown): PaceIssue[] { ); } +function asNumber(v: unknown, fallback = 0): number { + return typeof v === "number" && Number.isFinite(v) ? v : fallback; +} + +function asStringField(v: unknown): string { + return typeof v === "string" ? v : ""; +} + +// 收窄人物塑造 issues(每字段安全默认,advisory 韧性;缺字段不丢整条)。 +export function asCharacterizationIssues(v: unknown): CharacterizationIssue[] { + if (!Array.isArray(v)) return []; + return v.flatMap((x) => + isRecord(x) + ? [ + { + character: asStringField(x.character), + aspect: asStringField(x.aspect), + where: asStringField(x.where), + quote: asStringField(x.quote), + diagnosis: asStringField(x.diagnosis), + suggestion: asStringField(x.suggestion), + confidence: asNumber(x.confidence), + }, + ] + : [], + ); +} + function asStyleSegments(v: unknown): StyleEvent["data"]["segments"] { if (!Array.isArray(v)) return []; return v.flatMap((x) => @@ -248,6 +298,11 @@ function narrowReviewEvent(event: string, data: unknown): ReviewSseEvent | null }, } : null; + case "characterization": + return { + event: "characterization", + data: { issues: asCharacterizationIssues(data.issues) }, + }; case "done": return typeof data.length === "number" ? { event: "done", data: { length: data.length } } @@ -320,6 +375,7 @@ export interface ReviewStreamState { foreshadow: ForeshadowSuggestion[]; pace: PaceReport | null; style: StyleDriftReport | null; + characterization: CharacterizationReport | null; error: { code: string; message: string; request_id?: string | null } | null; } @@ -330,6 +386,7 @@ export const initialReviewState: ReviewStreamState = { foreshadow: [], pace: null, style: null, + characterization: null, error: null, }; @@ -377,6 +434,12 @@ export function reduceReview( })), }, }; + case "characterization": + return { + ...state, + phase: "reviewing", + characterization: { issues: event.data.issues.map((i) => ({ ...i })) }, + }; case "done": return { ...state, phase: "done" }; case "error": diff --git a/apps/web/lib/review/useReviewStream.test.ts b/apps/web/lib/review/useReviewStream.test.ts index 850bc05..bf11d76 100644 --- a/apps/web/lib/review/useReviewStream.test.ts +++ b/apps/web/lib/review/useReviewStream.test.ts @@ -222,6 +222,19 @@ describe("useReviewStream", () => { foreshadow: [{ kind: "resolved" as const, title: "旧伏笔" }], pace: { water: [], hook: false, beat_map: [1] }, style: { score: 0.9, segments: [] }, + characterization: { + issues: [ + { + character: "沈砚", + aspect: "动机一致性", + where: "第3段", + quote: "他忽然转身。", + diagnosis: "动机断裂", + suggestion: "扣回护妹动机", + confidence: 0.8, + }, + ], + }, }; act(() => result.current.seed(seedData)); @@ -230,6 +243,9 @@ describe("useReviewStream", () => { expect(result.current.state.foreshadow).toEqual(seedData.foreshadow); expect(result.current.state.pace).toEqual(seedData.pace); expect(result.current.state.style).toEqual(seedData.style); + expect(result.current.state.characterization).toEqual( + seedData.characterization, + ); expect(result.current.state.phase).toBe("idle"); }); }); diff --git a/apps/web/lib/review/useReviewStream.ts b/apps/web/lib/review/useReviewStream.ts index 42c311e..5a2a597 100644 --- a/apps/web/lib/review/useReviewStream.ts +++ b/apps/web/lib/review/useReviewStream.ts @@ -7,6 +7,7 @@ import { ReviewFrameBuffer, initialReviewState, reduceReview, + type CharacterizationReport, type ForeshadowSuggestion, type PaceReport, type ReviewConflict, @@ -19,6 +20,7 @@ export interface ReviewSeed { foreshadow: ForeshadowSuggestion[]; pace: PaceReport | null; style: StyleDriftReport | null; + characterization: CharacterizationReport | null; } type Action = @@ -49,6 +51,7 @@ function reducer(state: ReviewStreamState, action: Action): ReviewStreamState { foreshadow: action.seed.foreshadow, pace: action.seed.pace, style: action.seed.style, + characterization: action.seed.characterization, }; default: return state; diff --git a/memory/contracts.md b/memory/contracts.md index 87848d8..5e231eb 100644 --- a/memory/contracts.md +++ b/memory/contracts.md @@ -346,6 +346,8 @@ ## 契约变更日志(append-only) > 格式:`- [date] @skill 改 Cx:<改了什么> → 影响 <依赖方/任务>` +- [2026-07-06] @llm/@db/@backend/@frontend 改 C3+C4(③ 人物塑造 advisory 审):新增第五审 **人物塑造**(advisory 单维——仅建议、**不阻断验收**:不进 conflicts、不碰 `assert_conflicts_resolved`、**不入 `REVIEW_RESERVED_NAMES`**,D2)。**DB**:`chapter_reviews` 加 1 列 `characterization`(**JSONB nullable**,迁移 `f6a7b8c9d0e1`,独立于恒 None 的 health_score,nullable 免 backfill)。**契约变更**:`ReviewHistoryItem`(`schemas/projects.py`)+ `ReviewView`(`domain/review_repo.py`,末位默认 None 优雅降级)各加 `characterization: dict|None`;`list_reviews` 路由透传。新 SPEC `characterization_spec`(`tier="analyst"` 不写 model,`reads=("characters",)` 以人物卡 motive/appearance 为客观锚点,`writes=()` 只读,`prompts/characterization.md` **显式忽略文本长度与辞藻华丽度**、引用逐字片段+置信度)+ schema `CharacterizationReview{issues:[CharacterizationIssue{character,aspect,where,quote,diagnosis,suggestion,confidence}]}`(全字段默认值守解析韧性)+ 注册 `SCHEMA_CATALOG["characterization"]`;**计数三处 +1(22→23)**:`specs.py` assert、`test_prompt_loader.py` len 断言、`schema_catalog.py` docstring;**regen `prompt_hashes.json`**(仅加 characterization 一条)。接线链:`graph.py REVIEW_SPECS` 末位追加(并行扇出自动汇入 collect);`chain/nodes.py ReviewRecordRepo.record` + `collect.py {CHARACTERIZATION 常量, extract_characterization, ReviewRecorder Protocol, collect_reviews}` + `review_repo.py {Protocol.record, SqlReviewRepo.record, _to_view}` 均加 `characterization` kwarg/字段;`sse.py {EVENT_CHARACTERIZATION, characterization_event, _section_result_events elif}`(normalize 按 sorted 键自动纳入——字典序 characterization 排最前,不打乱既有四审断言)。前端 `lib/review/sse.ts`(`CharacterizationIssue`/`CharacterizationReport`/`CharacterizationEvent` + KNOWN_EVENTS + `asCharacterizationIssues` 守卫 + reducer + state)+ `lib/review/history.ts normalizeCharacterization` + `useReviewStream ReviewSeed` + 新 `CharacterizationPanel.tsx`(advisory 只展示、**无裁决 UI**、按置信度降序 + 低置信度折叠控注意力预算)+ `ReviewReport.tsx` 挂面板 + 双 seed 点。**codegen**:`characterization` `= None` → openapi-typescript 渲染为可选。**测试**:`test_characterization_does_not_block_accept`(零 continuity 冲突 + 有 advisory 问题 → 验收直通)+ 计数/golden + collect 映射 + normalize 事件 + 前端 reducer/normalize/parse 纯函数。→ 影响 @frontend(已 `pnpm gen:api`)。依赖 ⑧(motive/appearance 已入 CharacterCard 契约,作客观锚点)。 + - [2026-07-06] @llm/@backend/@frontend 立 C3 新端点(⑤ AI 立项方案生成):**`POST /skills/project-plan/generate`**(**不带 project 前缀**——立项前 project 未建,绕开 toolbox 的 `project_repo.get→404`)← `ProjectPlanGenerateRequest{genre?,logline?,title?,premise?,theme?,structure?,tone?,ending_type?,narrative_pov?,selling_points[],brief?}`(全字段 `max_length` 上界 CR-H9;`brief` 用 `str|None` 令 codegen 渲染为可选)→ `ProjectPlanView{title_candidates[str], setting, narrative_structure, story_core, ending_design, tone}`(全字段默认值守解析韧性)。**种子门控**:缺 genre 或 logline(strip 后空)→ **422 VALIDATION**(空向导不出方案,防 slop);无凭据→503。新 SPEC `project_plan_spec`(`tier="analyst"` 不写 model,`reads=()`/`writes=()` 纯预览,`prompts/project-plan.md`)+ schema `ProjectPlanResult`(`ww_agents.schemas`)+ 注册 `SCHEMA_CATALOG["project-plan"]`;**计数三处 +1(21→22)**:`specs.py` assert、`test_prompt_loader.py` len 断言、`schema_catalog.py` docstring;**regen `prompt_hashes.json`**(仅加 project-plan 一条)。端点复用 `build_brief_context`+`run_generator`(**`run_generator`/`_build_request` 的 `project_id` 放宽为 `uuid.UUID|None`**——向导阶段无 project,`usage_ledger.project_id` 本就 nullable);新网关缝 `get_project_plan_gateway`(analyst 档,`project_deps.py`)。**零迁移**(纯预览不写业务表,仅落 usage ledger)。前端 `wizard.ts` 加 `mapPlanResultToWizardForm`(书名候选→title、叙事结构→structure、基调→tone;时空背景/结局设计无独立向导字段→折进 premise 带标签**不静默丢**;结局取向/叙事视角预设枚举不臆造)+ `applyPlanPatch`(**仅回填空字段、保护已编辑**)+ `canGeneratePlan`/`toPlanSeedRequest` + `useProjectPlan` hook(renderHook 测试)+ `ProjectWizard.tsx` 第 2 步 `PlanAssistant`(生成→预览→按字段回填)。→ 影响 @frontend(已 `pnpm gen:api`)。依赖 ④(tone/ending_type/narrative_pov 已入 project 契约与向导)。 - [2026-07-06] @db/@backend/@frontend 改 C2+C3(④ 立项基调/结局/视角):`projects` 表加 3 列 `tone`/`ending_type`/`narrative_pov`(**Text nullable、无 CHECK**,同 genre/structure;迁移 `e5f6a7b8c9d0`,nullable 免 backfill)。`ProjectCreateRequest`+`ProjectResponse`(`schemas/projects.py`)各加 3 字段 `str|None`(**无 Literal**,Literal 只留 Verdict);domain `ProjectCreate`/`ProjectView`/`_to_view`/`SqlProjectRepo.create` + `FakeProjectRepo` 同步透传。完整 stable_core 链路:`ProjectSpecView`(`repositories.py`,frozen)加 3 字段 + `SqlProjectSpecRepo.spec` 查询构造带上 + `_build_spec_section`(`assemble.py`)渲染进「作品蓝本」块(**书级常量→缓存前缀,字节稳定守 #9**,绝不入 volatile)。前端 `wizard.ts` `WizardForm`/`emptyWizardForm`/`toCreateRequest` + 预设数组 `TONES`/`ENDING_TYPES`/`NARRATIVE_POVS`(仿 GENRES/STRUCTURES)+ `ProjectWizard.tsx` 第 3 步控件(tone→Select,pov/ending→SegmentedControl)+ 确认页回显。**codegen**:3 字段皆 `= None` 无默认工厂 → openapi-typescript 渲染为 `?: string | null` 可选。→ 影响 @frontend(已 `pnpm gen:api`);未来 ⑤ AI 立项方案回填这 3 字段。 diff --git a/packages/agents/tests/fixtures/prompt_hashes.json b/packages/agents/tests/fixtures/prompt_hashes.json index 1ad4635..e7dba0e 100644 --- a/packages/agents/tests/fixtures/prompt_hashes.json +++ b/packages/agents/tests/fixtures/prompt_hashes.json @@ -3,6 +3,7 @@ "book-title": "aa53ccf22da1d4e555c8168fc23f2f3547cc6f59e56e5df57540b7a946755e39", "brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada", "character-gen": "f65e2ec3158be378a6cd40c23035b326dc9f6b297b6135007a93fceedf575cfd", + "characterization": "643988cb899a66472acb3e18709f3299d2025f9bda132c774ee26c589266b51f", "continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763", "continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1", "de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249", diff --git a/packages/agents/tests/test_characterization_spec.py b/packages/agents/tests/test_characterization_spec.py new file mode 100644 index 0000000..ba2946c --- /dev/null +++ b/packages/agents/tests/test_characterization_spec.py @@ -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" diff --git a/packages/agents/tests/test_prompt_loader.py b/packages/agents/tests/test_prompt_loader.py index 7907c2a..e39cf83 100644 --- a/packages/agents/tests/test_prompt_loader.py +++ b/packages/agents/tests/test_prompt_loader.py @@ -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 即 key,dict 已去重;逐项确认 key == spec.name(无错位) assert all(key == spec.name for key, spec in SPECS.items()) diff --git a/packages/agents/ww_agents/__init__.py b/packages/agents/ww_agents/__init__.py index 7d6dbea..c6a80aa 100644 --- a/packages/agents/ww_agents/__init__.py +++ b/packages/agents/ww_agents/__init__.py @@ -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", diff --git a/packages/agents/ww_agents/prompts/characterization.md b/packages/agents/ww_agents/prompts/characterization.md new file mode 100644 index 0000000..fc74114 --- /dev/null +++ b/packages/agents/ww_agents/prompts/characterization.md @@ -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.0–1.0` 的置信度——你越确信是真问题给越高;主观/边缘/见仁见智的给低值,供作者过滤(对抗 LLM 评审主观偏见)。 +5. **建议而非改稿**:给出改进方向(如「让其行为回扣既定动机 X」),但**绝不输出改写后的正文**。 + +### 塑造维度锚点(aspect 取值口径) + +- `动机一致性`:本章言行是否被人物卡既定动机驱动,有无无动机行动/动机断裂; +- `声音辨识度`:对白/内心是否有该角色可辨识的说话声音,还是千人一腔; +- `行为逻辑`:行为是否符合已建立的性格逻辑与处境,有无为推进剧情的强行 OOC; +- `外貌一致性`:外貌/标识性描写是否与人物卡 `appearance` 冲突; +- `扁平化`:是否沦为只服务功能的工具人、缺乏立体动机与内在张力。 + +## 输出格式(严格 JSON,仅含以下顶层字段,不要多余字段或解释文字) + +- **`issues`**:人物塑造问题列表(数组)。逐条诊断;每个元素为对象,含: + - `character`:涉及的角色名(尽量对齐人物卡 name;无法归属某角色则留空串); + - `aspect`:塑造维度(取上述锚点之一或贴切的简短维度名); + - `where`:本章定位(哪一段/哪个场景体现该问题,便于作者定位); + - `quote`:从本章草稿**逐字摘录**的支撑片段(含标点,不得改写;无法逐字锚定则不要输出该条); + - `diagnosis`:诊断说明(为何此处塑造偏弱/失真,扣住动机/声音/行为逻辑等客观点); + - `suggestion`:改进建议(方向性,不含改写后的正文); + - `confidence`:置信度浮点 `0.0–1.0`(越确信越高,主观/边缘给低值)。 + - 若全章人物塑造无明显问题,`issues` 为空数组 `[]`。 + +## 纪律(不可违反) + +- 你**只读、只报人物塑造诊断**,绝不改稿、不写库、不输出任何改写后的正文(不变量 #3);你的结论是 advisory 建议,不阻断验收。 +- **显式忽略文本长度与辞藻华丽度**——只判塑造,不判文笔与篇幅。 +- 每条诊断必须带逐字摘录的 `quote` 与 `confidence`,不臆造、不空泛;无逐字证据则不报。 +- 无明显问题则 `issues` 为空数组;宁缺毋滥、少而准,避免维度噪声淹没作者注意力。 +- 只输出上述字段的 JSON,不附加旁白、不解释推理过程。 diff --git a/packages/agents/ww_agents/schema_catalog.py b/packages/agents/ww_agents/schema_catalog.py index abd305c..48b5867 100644 --- a/packages/agents/ww_agents/schema_catalog.py +++ b/packages/agents/ww_agents/schema_catalog.py @@ -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, # 唯一纯文本 writer,None 是合法值 diff --git a/packages/agents/ww_agents/schemas.py b/packages/agents/ww_agents/schemas.py index 35c321b..2fd8aca 100644 --- a/packages/agents/ww_agents/schemas.py +++ b/packages/agents/ww_agents/schemas.py @@ -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.0–1.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)---- diff --git a/packages/agents/ww_agents/specs.py b/packages/agents/ww_agents/specs.py index a830221..f18426b 100644 --- a/packages/agents/ww_agents/specs.py +++ b/packages/agents/ww_agents/specs.py @@ -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=(), # 只读(不变量 #3);advisory 唯一落库=collect 留痕,不进 conflicts/验收 gate + scope="builtin", +) + # ---- 回炉(refiner;writer 档;纯文本重写;非 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) # 四审受信保留名 —— 独立显式白名单(安全边界锚在此,不依附派生集合) diff --git a/packages/core/tests/fakes_orchestrator.py b/packages/core/tests/fakes_orchestrator.py index 648c197..f82ae21 100644 --- a/packages/core/tests/fakes_orchestrator.py +++ b/packages/core/tests/fakes_orchestrator.py @@ -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) diff --git a/packages/core/tests/test_chain_graph.py b/packages/core/tests/test_chain_graph.py index b4f6166..a89934c 100644 --- a/packages/core/tests/test_chain_graph.py +++ b/packages/core/tests/test_chain_graph.py @@ -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)) diff --git a/packages/core/tests/test_characterization_review.py b/packages/core/tests/test_characterization_review.py new file mode 100644 index 0000000..e9360ca --- /dev/null +++ b/packages/core/tests/test_characterization_review.py @@ -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": []} diff --git a/packages/core/tests/test_review_repo.py b/packages/core/tests/test_review_repo.py index eec4f95..a1f6780 100644 --- a/packages/core/tests/test_review_repo.py +++ b/packages/core/tests/test_review_repo.py @@ -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, ) diff --git a/packages/core/tests/test_style_review.py b/packages/core/tests/test_style_review.py index 30a0865..9bfad86 100644 --- a/packages/core/tests/test_style_review.py +++ b/packages/core/tests/test_style_review.py @@ -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: diff --git a/packages/core/ww_core/domain/review_repo.py b/packages/core/ww_core/domain/review_repo.py index a55c632..0d0df5b 100644 --- a/packages/core/ww_core/domain/review_repo.py +++ b/packages/core/ww_core/domain/review_repo.py @@ -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, ) diff --git a/packages/core/ww_core/orchestrator/__init__.py b/packages/core/ww_core/orchestrator/__init__.py index fdda49e..cd88f45 100644 --- a/packages/core/ww_core/orchestrator/__init__.py +++ b/packages/core/ww_core/orchestrator/__init__.py @@ -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", diff --git a/packages/core/ww_core/orchestrator/chain/nodes.py b/packages/core/ww_core/orchestrator/chain/nodes.py index 15bccf0..6efd4d2 100644 --- a/packages/core/ww_core/orchestrator/chain/nodes.py +++ b/packages/core/ww_core/orchestrator/chain/nodes.py @@ -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: ... diff --git a/packages/core/ww_core/orchestrator/collect.py b/packages/core/ww_core/orchestrator/collect.py index 34dfc26..f63d7f3 100644 --- a/packages/core/ww_core/orchestrator/collect.py +++ b/packages/core/ww_core/orchestrator/collect.py @@ -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)。 diff --git a/packages/core/ww_core/orchestrator/graph.py b/packages/core/ww_core/orchestrator/graph.py index b7447a0..1ce64a5 100644 --- a/packages/core/ww_core/orchestrator/graph.py +++ b/packages/core/ww_core/orchestrator/graph.py @@ -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, ) diff --git a/packages/core/ww_core/orchestrator/sse.py b/packages/core/ww_core/orchestrator/sse.py index 32a2c1b..15f732f 100644 --- a/packages/core/ww_core/orchestrator/sse.py +++ b/packages/core/ww_core/orchestrator/sse.py @@ -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 diff --git a/packages/db/migrations/versions/f6a7b8c9d0e1_chapter_reviews_characterization.py b/packages/db/migrations/versions/f6a7b8c9d0e1_chapter_reviews_characterization.py new file mode 100644 index 0000000..e689469 --- /dev/null +++ b/packages/db/migrations/versions/f6a7b8c9d0e1_chapter_reviews_characterization.py @@ -0,0 +1,33 @@ +"""Add characterization JSONB column to chapter_reviews (灵感③ advisory 人物塑造审). + +单列 JSONB nullable(advisory 留痕,独立于恒 None 的 health_score);nullable 免 backfill +(旧留痕行留 NULL,collect/前端优雅降级)。仅建议、不进 conflicts、不碰验收 gate(灵感 D2)。 + +Revision ID: f6a7b8c9d0e1 +Revises: e5f6a7b8c9d0 +Create Date: 2026-07-06 00:00:00.000000 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "f6a7b8c9d0e1" +down_revision: str | None = "e5f6a7b8c9d0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "chapter_reviews", + sa.Column("characterization", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("chapter_reviews", "characterization") diff --git a/packages/db/ww_db/models.py b/packages/db/ww_db/models.py index aacfb3a..71ffc0c 100644 --- a/packages/db/ww_db/models.py +++ b/packages/db/ww_db/models.py @@ -225,6 +225,8 @@ class ChapterReview(UuidPk, CreatedAt, Base): foreshadow_sug: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'")) style: Mapped[dict[str, Any] | None] = mapped_column(JSONB) pace: Mapped[dict[str, Any] | None] = mapped_column(JSONB) + # 灵感③ advisory 人物塑造审留痕(JSONB nullable,独立列不复用恒 None 的 health_score)。 + characterization: Mapped[dict[str, Any] | None] = mapped_column(JSONB) health_score: Mapped[int | None] = mapped_column(Integer) decisions: Mapped[dict[str, Any] | None] = mapped_column(JSONB)