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:
@@ -161,6 +161,8 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
|||||||
|
|
||||||
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
|
> 格式:`- [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-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-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)。
|
- [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)。
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ class FakeReviewRepo:
|
|||||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||||
style: dict[str, Any] | None = None,
|
style: dict[str, Any] | None = None,
|
||||||
pace: dict[str, Any] | None = None,
|
pace: dict[str, Any] | None = None,
|
||||||
|
characterization: dict[str, Any] | None = None,
|
||||||
health_score: int | None = None,
|
health_score: int | None = None,
|
||||||
) -> ReviewView:
|
) -> ReviewView:
|
||||||
view = ReviewView(
|
view = ReviewView(
|
||||||
@@ -162,6 +163,7 @@ class FakeReviewRepo:
|
|||||||
foreshadow_sug=[dict(s) for s in (foreshadow_sug or [])],
|
foreshadow_sug=[dict(s) for s in (foreshadow_sug or [])],
|
||||||
style=style,
|
style=style,
|
||||||
pace=pace,
|
pace=pace,
|
||||||
|
characterization=characterization,
|
||||||
health_score=health_score,
|
health_score=health_score,
|
||||||
)
|
)
|
||||||
# 新→旧:插到列表头部。
|
# 新→旧:插到列表头部。
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ class _FakeReviewRepo:
|
|||||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||||
style: dict[str, Any] | None = None,
|
style: dict[str, Any] | None = None,
|
||||||
pace: dict[str, Any] | None = None,
|
pace: dict[str, Any] | None = None,
|
||||||
|
characterization: dict[str, Any] | None = None,
|
||||||
health_score: int | None = None,
|
health_score: int | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
rec = _FakeReviewRecord(chapter_no, list(conflicts))
|
rec = _FakeReviewRecord(chapter_no, list(conflicts))
|
||||||
|
|||||||
@@ -209,6 +209,70 @@ async def test_accept_promotes_appends_digest_records_decisions() -> None:
|
|||||||
assert session.commits == 1
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_accept_with_no_review_succeeds() -> None:
|
async def test_accept_with_no_review_succeeds() -> None:
|
||||||
"""无审稿留痕(零冲突)→ gate 直通,仍晋升 + 提炼 digest。"""
|
"""无审稿留痕(零冲突)→ gate 直通,仍晋升 + 提炼 digest。"""
|
||||||
|
|||||||
@@ -488,6 +488,7 @@ async def list_reviews(
|
|||||||
foreshadow_sug=v.foreshadow_sug,
|
foreshadow_sug=v.foreshadow_sug,
|
||||||
style=v.style,
|
style=v.style,
|
||||||
pace=v.pace,
|
pace=v.pace,
|
||||||
|
characterization=v.characterization,
|
||||||
health_score=v.health_score,
|
health_score=v.health_score,
|
||||||
decisions=v.decisions,
|
decisions=v.decisions,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -191,6 +191,8 @@ class ReviewHistoryItem(BaseModel):
|
|||||||
foreshadow_sug: list[dict[str, Any]] = Field(default_factory=list)
|
foreshadow_sug: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
style: dict[str, Any] | None = None
|
style: dict[str, Any] | None = None
|
||||||
pace: 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
|
health_score: int | None = None
|
||||||
decisions: dict[str, Any] | None = None
|
decisions: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|||||||
102
apps/web/components/review/CharacterizationPanel.tsx
Normal file
102
apps/web/components/review/CharacterizationPanel.tsx
Normal file
@@ -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 (
|
||||||
|
<section>
|
||||||
|
<Badge variant="warning">
|
||||||
|
<CircleDashed className="h-3 w-3" aria-hidden="true" />
|
||||||
|
未完成
|
||||||
|
</Badge>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const issues = characterization?.issues ?? [];
|
||||||
|
if (characterization === null || issues.length === 0) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<Badge variant="success">
|
||||||
|
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||||
|
人物塑造无明显问题
|
||||||
|
</Badge>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 置信度降序(优先级合并);高/低分区。
|
||||||
|
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 (
|
||||||
|
<section className="space-y-2 text-xs">
|
||||||
|
<p className="rounded border border-dashed border-line/70 bg-bg/40 px-3 py-1.5 text-[11px] text-ink-soft">
|
||||||
|
以下为人物塑造建议(仅供参考,不影响验收)。忽略文本长度与辞藻,只看人物是否写得立体。
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{(high.length > 0 ? high : sorted).map((issue, i) => (
|
||||||
|
<CharacterizationIssueCard key={i} issue={issue} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{high.length > 0 && low.length > 0 ? (
|
||||||
|
<details className="group rounded border border-line/70 bg-bg/35 p-2">
|
||||||
|
<summary className="cursor-pointer list-none text-2xs font-semibold text-ink-soft">
|
||||||
|
更多低置信度建议({low.length})
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-2 space-y-2">
|
||||||
|
{low.map((issue, i) => (
|
||||||
|
<CharacterizationIssueCard key={i} issue={issue} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CharacterizationIssueCard({ issue }: { issue: CharacterizationIssue }) {
|
||||||
|
const pct = Math.round(Math.max(0, Math.min(1, issue.confidence)) * 100);
|
||||||
|
return (
|
||||||
|
<li className="rounded border border-line/70 bg-bg/35 p-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="flex items-center gap-1.5 font-semibold text-ink">
|
||||||
|
<UserRound className="h-3.5 w-3.5 text-ink-soft" aria-hidden="true" />
|
||||||
|
{issue.character || "(未归属角色)"}
|
||||||
|
{issue.aspect ? <Badge variant="accent">{issue.aspect}</Badge> : null}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono text-2xs text-ink-soft" title="置信度">
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{issue.where ? <p className="mt-1 text-ink-soft">定位:{issue.where}</p> : null}
|
||||||
|
{issue.quote ? (
|
||||||
|
<blockquote className="mt-1 border-l-2 border-line pl-2 text-ink-soft italic">
|
||||||
|
「{issue.quote}」
|
||||||
|
</blockquote>
|
||||||
|
) : null}
|
||||||
|
{issue.diagnosis ? <p className="mt-1 text-ink">{issue.diagnosis}</p> : null}
|
||||||
|
{issue.suggestion ? (
|
||||||
|
<p className="mt-1 text-ink-soft">建议:{issue.suggestion}</p>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from "@/lib/review/decisions";
|
} from "@/lib/review/decisions";
|
||||||
import {
|
import {
|
||||||
latestReview,
|
latestReview,
|
||||||
|
normalizeCharacterization,
|
||||||
normalizeConflicts,
|
normalizeConflicts,
|
||||||
normalizeForeshadowSug,
|
normalizeForeshadowSug,
|
||||||
normalizePace,
|
normalizePace,
|
||||||
@@ -56,6 +57,7 @@ import { useToast } from "@/components/Toast";
|
|||||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
import { AcceptPanel } from "./AcceptPanel";
|
import { AcceptPanel } from "./AcceptPanel";
|
||||||
import { AnnotatedText } from "./AnnotatedText";
|
import { AnnotatedText } from "./AnnotatedText";
|
||||||
|
import { CharacterizationPanel } from "./CharacterizationPanel";
|
||||||
import { ConflictCard } from "./ConflictCard";
|
import { ConflictCard } from "./ConflictCard";
|
||||||
import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
|
import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
|
||||||
import { PacePanel } from "./PacePanel";
|
import { PacePanel } from "./PacePanel";
|
||||||
@@ -128,6 +130,10 @@ export function ReviewReport({
|
|||||||
() => normalizeStyleDrift(initialReview),
|
() => normalizeStyleDrift(initialReview),
|
||||||
[initialReview],
|
[initialReview],
|
||||||
);
|
);
|
||||||
|
const seededCharacterization = useMemo(
|
||||||
|
() => normalizeCharacterization(initialReview),
|
||||||
|
[initialReview],
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (seededRef.current) return;
|
if (seededRef.current) return;
|
||||||
seededRef.current = true;
|
seededRef.current = true;
|
||||||
@@ -135,22 +141,32 @@ export function ReviewReport({
|
|||||||
seededConflicts.length > 0 ||
|
seededConflicts.length > 0 ||
|
||||||
seededForeshadow.length > 0 ||
|
seededForeshadow.length > 0 ||
|
||||||
seededPace !== null ||
|
seededPace !== null ||
|
||||||
seededStyle !== null;
|
seededStyle !== null ||
|
||||||
|
seededCharacterization !== null;
|
||||||
if (hasSeed) {
|
if (hasSeed) {
|
||||||
review.seed({
|
review.seed({
|
||||||
conflicts: seededConflicts,
|
conflicts: seededConflicts,
|
||||||
foreshadow: seededForeshadow,
|
foreshadow: seededForeshadow,
|
||||||
pace: seededPace,
|
pace: seededPace,
|
||||||
style: seededStyle,
|
style: seededStyle,
|
||||||
|
characterization: seededCharacterization,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [review, seededConflicts, seededForeshadow, seededPace, seededStyle]);
|
}, [
|
||||||
|
review,
|
||||||
|
seededConflicts,
|
||||||
|
seededForeshadow,
|
||||||
|
seededPace,
|
||||||
|
seededStyle,
|
||||||
|
seededCharacterization,
|
||||||
|
]);
|
||||||
|
|
||||||
// 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。
|
// 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。
|
||||||
const conflicts: ReviewConflict[] = review.state.conflicts;
|
const conflicts: ReviewConflict[] = review.state.conflicts;
|
||||||
const foreshadow = review.state.foreshadow;
|
const foreshadow = review.state.foreshadow;
|
||||||
const pace = review.state.pace;
|
const pace = review.state.pace;
|
||||||
const style = review.state.style;
|
const style = review.state.style;
|
||||||
|
const characterization = review.state.characterization;
|
||||||
// 伏笔徽标/验收预览统一用「仍待处理数」(随登记/回收递减);未上报前回退原始建议数。
|
// 伏笔徽标/验收预览统一用「仍待处理数」(随登记/回收递减);未上报前回退原始建议数。
|
||||||
const foreshadowOpen = foreshadowRemaining ?? foreshadow.length;
|
const foreshadowOpen = foreshadowRemaining ?? foreshadow.length;
|
||||||
const sectionStatus = (name: string): string | undefined =>
|
const sectionStatus = (name: string): string | undefined =>
|
||||||
@@ -359,7 +375,7 @@ export function ReviewReport({
|
|||||||
const fresh = normalizeConflicts(latestReview(data?.reviews));
|
const fresh = normalizeConflicts(latestReview(data?.reviews));
|
||||||
setMissing(new Set(outcome.missingIndices));
|
setMissing(new Set(outcome.missingIndices));
|
||||||
if (fresh.length > 0) {
|
if (fresh.length > 0) {
|
||||||
review.seed({ conflicts: fresh, foreshadow, pace, style });
|
review.seed({ conflicts: fresh, foreshadow, pace, style, characterization });
|
||||||
setDrafts(emptyDecisions(fresh.length));
|
setDrafts(emptyDecisions(fresh.length));
|
||||||
toast("审稿报告已刷新:检测到未裁决冲突,请裁决后再验收", "error");
|
toast("审稿报告已刷新:检测到未裁决冲突,请裁决后再验收", "error");
|
||||||
} else {
|
} else {
|
||||||
@@ -405,7 +421,8 @@ export function ReviewReport({
|
|||||||
seededConflicts.length > 0 ||
|
seededConflicts.length > 0 ||
|
||||||
foreshadow.length > 0 ||
|
foreshadow.length > 0 ||
|
||||||
pace !== null ||
|
pace !== null ||
|
||||||
style !== null;
|
style !== null ||
|
||||||
|
characterization !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@@ -671,6 +688,35 @@ export function ReviewReport({
|
|||||||
onRefine={onRefineSegment}
|
onRefine={onRefineSegment}
|
||||||
/>
|
/>
|
||||||
</ReviewSectionPanel>
|
</ReviewSectionPanel>
|
||||||
|
<ReviewSectionPanel
|
||||||
|
title="人物塑造"
|
||||||
|
statusLabel={
|
||||||
|
sectionStatus("characterization") === "incomplete"
|
||||||
|
? "未完成"
|
||||||
|
: characterization && characterization.issues.length > 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
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CharacterizationPanel
|
||||||
|
characterization={characterization}
|
||||||
|
incomplete={sectionStatus("characterization") === "incomplete"}
|
||||||
|
/>
|
||||||
|
</ReviewSectionPanel>
|
||||||
{refineText !== null ? (
|
{refineText !== null ? (
|
||||||
<RefineView
|
<RefineView
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
|
|||||||
4
apps/web/lib/api/schema.d.ts
vendored
4
apps/web/lib/api/schema.d.ts
vendored
@@ -1828,6 +1828,10 @@ export interface components {
|
|||||||
pace?: {
|
pace?: {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
|
/** Characterization */
|
||||||
|
characterization?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
/** Health Score */
|
/** Health Score */
|
||||||
health_score?: number | null;
|
health_score?: number | null;
|
||||||
/** Decisions */
|
/** Decisions */
|
||||||
|
|||||||
108
apps/web/lib/review/characterization-sse.test.ts
Normal file
108
apps/web/lib/review/characterization-sse.test.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
initialReviewState,
|
||||||
|
parseReviewBlock,
|
||||||
|
reduceReview,
|
||||||
|
type ReviewSseEvent,
|
||||||
|
} from "./sse";
|
||||||
|
|
||||||
|
const FULL_ISSUE = {
|
||||||
|
character: "沈砚",
|
||||||
|
aspect: "动机一致性",
|
||||||
|
where: "第3段",
|
||||||
|
quote: "他忽然转身。",
|
||||||
|
diagnosis: "动机断裂",
|
||||||
|
suggestion: "扣回护妹动机",
|
||||||
|
confidence: 0.82,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("parseReviewBlock — characterization (灵感③ advisory)", () => {
|
||||||
|
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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { ReviewConflictView, ReviewHistoryItem } from "@/lib/api/types";
|
import type { ReviewConflictView, ReviewHistoryItem } from "@/lib/api/types";
|
||||||
import {
|
import {
|
||||||
latestReview,
|
latestReview,
|
||||||
|
normalizeCharacterization,
|
||||||
normalizeConflicts,
|
normalizeConflicts,
|
||||||
normalizeForeshadowSug,
|
normalizeForeshadowSug,
|
||||||
normalizePace,
|
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", () => {
|
describe("latestReview", () => {
|
||||||
it("returns first item (history is newest-first)", () => {
|
it("returns first item (history is newest-first)", () => {
|
||||||
const a = item([]);
|
const a = item([]);
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
// 纯逻辑(可单测);schema 把 conflicts 标成 {[k]:unknown}[],这里安全收窄。
|
// 纯逻辑(可单测);schema 把 conflicts 标成 {[k]:unknown}[],这里安全收窄。
|
||||||
|
|
||||||
import type { ReviewHistoryItem } from "@/lib/api/types";
|
import type { ReviewHistoryItem } from "@/lib/api/types";
|
||||||
|
import { asCharacterizationIssues } from "./sse";
|
||||||
import type {
|
import type {
|
||||||
|
CharacterizationReport,
|
||||||
ForeshadowKind,
|
ForeshadowKind,
|
||||||
ForeshadowSuggestion,
|
ForeshadowSuggestion,
|
||||||
PaceIssue,
|
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<string, unknown>;
|
||||||
|
return { issues: asCharacterizationIssues(dict["issues"]) };
|
||||||
|
}
|
||||||
|
|
||||||
// 最近一条留痕(GET .../reviews 已按新→旧排序)。
|
// 最近一条留痕(GET .../reviews 已按新→旧排序)。
|
||||||
export function latestReview(
|
export function latestReview(
|
||||||
items: readonly ReviewHistoryItem[] | undefined,
|
items: readonly ReviewHistoryItem[] | undefined,
|
||||||
|
|||||||
@@ -60,6 +60,22 @@ export interface StyleDriftReport {
|
|||||||
segments: StyleDriftSegment[];
|
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 {
|
export interface SectionEvent {
|
||||||
event: "section";
|
event: "section";
|
||||||
data: { name: string; status: SectionStatus };
|
data: { name: string; status: SectionStatus };
|
||||||
@@ -98,6 +114,10 @@ export interface StyleEvent {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
export interface CharacterizationEvent {
|
||||||
|
event: "characterization";
|
||||||
|
data: { issues: CharacterizationIssue[] };
|
||||||
|
}
|
||||||
export interface DoneEvent {
|
export interface DoneEvent {
|
||||||
event: "done";
|
event: "done";
|
||||||
data: { length: number };
|
data: { length: number };
|
||||||
@@ -112,6 +132,7 @@ export type ReviewSseEvent =
|
|||||||
| ForeshadowEvent
|
| ForeshadowEvent
|
||||||
| PaceEvent
|
| PaceEvent
|
||||||
| StyleEvent
|
| StyleEvent
|
||||||
|
| CharacterizationEvent
|
||||||
| DoneEvent
|
| DoneEvent
|
||||||
| ErrorEvent;
|
| ErrorEvent;
|
||||||
|
|
||||||
@@ -121,6 +142,7 @@ const KNOWN_EVENTS = new Set([
|
|||||||
"foreshadow",
|
"foreshadow",
|
||||||
"pace",
|
"pace",
|
||||||
"style",
|
"style",
|
||||||
|
"characterization",
|
||||||
"done",
|
"done",
|
||||||
"error",
|
"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"] {
|
function asStyleSegments(v: unknown): StyleEvent["data"]["segments"] {
|
||||||
if (!Array.isArray(v)) return [];
|
if (!Array.isArray(v)) return [];
|
||||||
return v.flatMap((x) =>
|
return v.flatMap((x) =>
|
||||||
@@ -248,6 +298,11 @@ function narrowReviewEvent(event: string, data: unknown): ReviewSseEvent | null
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
case "characterization":
|
||||||
|
return {
|
||||||
|
event: "characterization",
|
||||||
|
data: { issues: asCharacterizationIssues(data.issues) },
|
||||||
|
};
|
||||||
case "done":
|
case "done":
|
||||||
return typeof data.length === "number"
|
return typeof data.length === "number"
|
||||||
? { event: "done", data: { length: data.length } }
|
? { event: "done", data: { length: data.length } }
|
||||||
@@ -320,6 +375,7 @@ export interface ReviewStreamState {
|
|||||||
foreshadow: ForeshadowSuggestion[];
|
foreshadow: ForeshadowSuggestion[];
|
||||||
pace: PaceReport | null;
|
pace: PaceReport | null;
|
||||||
style: StyleDriftReport | null;
|
style: StyleDriftReport | null;
|
||||||
|
characterization: CharacterizationReport | null;
|
||||||
error: { code: string; message: string; request_id?: string | null } | null;
|
error: { code: string; message: string; request_id?: string | null } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +386,7 @@ export const initialReviewState: ReviewStreamState = {
|
|||||||
foreshadow: [],
|
foreshadow: [],
|
||||||
pace: null,
|
pace: null,
|
||||||
style: null,
|
style: null,
|
||||||
|
characterization: null,
|
||||||
error: 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":
|
case "done":
|
||||||
return { ...state, phase: "done" };
|
return { ...state, phase: "done" };
|
||||||
case "error":
|
case "error":
|
||||||
|
|||||||
@@ -222,6 +222,19 @@ describe("useReviewStream", () => {
|
|||||||
foreshadow: [{ kind: "resolved" as const, title: "旧伏笔" }],
|
foreshadow: [{ kind: "resolved" as const, title: "旧伏笔" }],
|
||||||
pace: { water: [], hook: false, beat_map: [1] },
|
pace: { water: [], hook: false, beat_map: [1] },
|
||||||
style: { score: 0.9, segments: [] },
|
style: { score: 0.9, segments: [] },
|
||||||
|
characterization: {
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
character: "沈砚",
|
||||||
|
aspect: "动机一致性",
|
||||||
|
where: "第3段",
|
||||||
|
quote: "他忽然转身。",
|
||||||
|
diagnosis: "动机断裂",
|
||||||
|
suggestion: "扣回护妹动机",
|
||||||
|
confidence: 0.8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
act(() => result.current.seed(seedData));
|
act(() => result.current.seed(seedData));
|
||||||
@@ -230,6 +243,9 @@ describe("useReviewStream", () => {
|
|||||||
expect(result.current.state.foreshadow).toEqual(seedData.foreshadow);
|
expect(result.current.state.foreshadow).toEqual(seedData.foreshadow);
|
||||||
expect(result.current.state.pace).toEqual(seedData.pace);
|
expect(result.current.state.pace).toEqual(seedData.pace);
|
||||||
expect(result.current.state.style).toEqual(seedData.style);
|
expect(result.current.state.style).toEqual(seedData.style);
|
||||||
|
expect(result.current.state.characterization).toEqual(
|
||||||
|
seedData.characterization,
|
||||||
|
);
|
||||||
expect(result.current.state.phase).toBe("idle");
|
expect(result.current.state.phase).toBe("idle");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ReviewFrameBuffer,
|
ReviewFrameBuffer,
|
||||||
initialReviewState,
|
initialReviewState,
|
||||||
reduceReview,
|
reduceReview,
|
||||||
|
type CharacterizationReport,
|
||||||
type ForeshadowSuggestion,
|
type ForeshadowSuggestion,
|
||||||
type PaceReport,
|
type PaceReport,
|
||||||
type ReviewConflict,
|
type ReviewConflict,
|
||||||
@@ -19,6 +20,7 @@ export interface ReviewSeed {
|
|||||||
foreshadow: ForeshadowSuggestion[];
|
foreshadow: ForeshadowSuggestion[];
|
||||||
pace: PaceReport | null;
|
pace: PaceReport | null;
|
||||||
style: StyleDriftReport | null;
|
style: StyleDriftReport | null;
|
||||||
|
characterization: CharacterizationReport | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
@@ -49,6 +51,7 @@ function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
|
|||||||
foreshadow: action.seed.foreshadow,
|
foreshadow: action.seed.foreshadow,
|
||||||
pace: action.seed.pace,
|
pace: action.seed.pace,
|
||||||
style: action.seed.style,
|
style: action.seed.style,
|
||||||
|
characterization: action.seed.characterization,
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
|
|||||||
@@ -346,6 +346,8 @@
|
|||||||
## 契约变更日志(append-only)
|
## 契约变更日志(append-only)
|
||||||
> 格式:`- [date] @skill 改 Cx:<改了什么> → 影响 <依赖方/任务>`
|
> 格式:`- [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] @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 字段。
|
- [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 字段。
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"book-title": "aa53ccf22da1d4e555c8168fc23f2f3547cc6f59e56e5df57540b7a946755e39",
|
"book-title": "aa53ccf22da1d4e555c8168fc23f2f3547cc6f59e56e5df57540b7a946755e39",
|
||||||
"brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada",
|
"brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada",
|
||||||
"character-gen": "f65e2ec3158be378a6cd40c23035b326dc9f6b297b6135007a93fceedf575cfd",
|
"character-gen": "f65e2ec3158be378a6cd40c23035b326dc9f6b297b6135007a93fceedf575cfd",
|
||||||
|
"characterization": "643988cb899a66472acb3e18709f3299d2025f9bda132c774ee26c589266b51f",
|
||||||
"continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763",
|
"continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763",
|
||||||
"continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1",
|
"continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1",
|
||||||
"de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249",
|
"de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249",
|
||||||
|
|||||||
115
packages/agents/tests/test_characterization_spec.py
Normal file
115
packages/agents/tests/test_characterization_spec.py
Normal file
@@ -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"
|
||||||
@@ -52,8 +52,8 @@ def test_load_prompt_matches_golden(name: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
# ---- #1 注册表唯一性 ----
|
# ---- #1 注册表唯一性 ----
|
||||||
def test_specs_registry_len_is_22() -> None:
|
def test_specs_registry_len_is_23() -> None:
|
||||||
assert len(SPECS) == 22
|
assert len(SPECS) == 23
|
||||||
# name 即 key,dict 已去重;逐项确认 key == spec.name(无错位)
|
# name 即 key,dict 已去重;逐项确认 key == spec.name(无错位)
|
||||||
assert all(key == spec.name for key, spec in SPECS.items())
|
assert all(key == spec.name for key, spec in SPECS.items())
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from .schemas import (
|
|||||||
BookTeardownResult,
|
BookTeardownResult,
|
||||||
CharacterCard,
|
CharacterCard,
|
||||||
CharacterGenResult,
|
CharacterGenResult,
|
||||||
|
CharacterizationIssue,
|
||||||
|
CharacterizationReview,
|
||||||
CharacterRelation,
|
CharacterRelation,
|
||||||
Conflict,
|
Conflict,
|
||||||
ConflictType,
|
ConflictType,
|
||||||
@@ -57,6 +59,7 @@ from .specs import (
|
|||||||
book_title_spec,
|
book_title_spec,
|
||||||
brainstorm_spec,
|
brainstorm_spec,
|
||||||
character_gen_spec,
|
character_gen_spec,
|
||||||
|
characterization_spec,
|
||||||
continue_spec,
|
continue_spec,
|
||||||
continuity_spec,
|
continuity_spec,
|
||||||
de_ai_spec,
|
de_ai_spec,
|
||||||
@@ -87,6 +90,8 @@ __all__ = [
|
|||||||
"BookTeardownResult",
|
"BookTeardownResult",
|
||||||
"CharacterCard",
|
"CharacterCard",
|
||||||
"CharacterGenResult",
|
"CharacterGenResult",
|
||||||
|
"CharacterizationIssue",
|
||||||
|
"CharacterizationReview",
|
||||||
"CharacterRelation",
|
"CharacterRelation",
|
||||||
"Conflict",
|
"Conflict",
|
||||||
"ConflictType",
|
"ConflictType",
|
||||||
@@ -127,6 +132,7 @@ __all__ = [
|
|||||||
"book_title_spec",
|
"book_title_spec",
|
||||||
"brainstorm_spec",
|
"brainstorm_spec",
|
||||||
"character_gen_spec",
|
"character_gen_spec",
|
||||||
|
"characterization_spec",
|
||||||
"continue_spec",
|
"continue_spec",
|
||||||
"continuity_spec",
|
"continuity_spec",
|
||||||
"de_ai_spec",
|
"de_ai_spec",
|
||||||
|
|||||||
47
packages/agents/ww_agents/prompts/characterization.md
Normal file
47
packages/agents/ww_agents/prompts/characterization.md
Normal file
@@ -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,不附加旁白、不解释推理过程。
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Pydantic 类型承载结构化解析与 `isinstance` 校验,无法从文本推导、无法运行时安全构造,
|
Pydantic 类型承载结构化解析与 `isinstance` 校验,无法从文本推导、无法运行时安全构造,
|
||||||
必须留 Python。`SPECS[name].output_schema` 一律从这里派生(单向派生,避免双真相)。
|
必须留 Python。`SPECS[name].output_schema` 一律从这里派生(单向派生,避免双真相)。
|
||||||
`input_schema` 全 22 个恒为 None(入参为序列化文本),本波不建入参槽(YAGNI)。
|
`input_schema` 全 23 个恒为 None(入参为序列化文本),本波不建入参槽(YAGNI)。
|
||||||
|
|
||||||
`refiner` 是唯一纯文本 writer:`None` 是合法值(无结构化 schema)。
|
`refiner` 是唯一纯文本 writer:`None` 是合法值(无结构化 schema)。
|
||||||
"""
|
"""
|
||||||
@@ -17,6 +17,7 @@ from .schemas import (
|
|||||||
BlurbResult,
|
BlurbResult,
|
||||||
BookTeardownResult,
|
BookTeardownResult,
|
||||||
CharacterGenResult,
|
CharacterGenResult,
|
||||||
|
CharacterizationReview,
|
||||||
ContinuationResult,
|
ContinuationResult,
|
||||||
ContinuityReview,
|
ContinuityReview,
|
||||||
DeAiResult,
|
DeAiResult,
|
||||||
@@ -43,6 +44,7 @@ SCHEMA_CATALOG: Final[dict[str, type[BaseModel] | None]] = {
|
|||||||
"outliner": OutlineResult,
|
"outliner": OutlineResult,
|
||||||
"foreshadow": ForeshadowReview,
|
"foreshadow": ForeshadowReview,
|
||||||
"pace": PaceReview,
|
"pace": PaceReview,
|
||||||
|
"characterization": CharacterizationReview,
|
||||||
"style_extract": StyleFingerprintResult,
|
"style_extract": StyleFingerprintResult,
|
||||||
"style": StyleDriftReview,
|
"style": StyleDriftReview,
|
||||||
"refiner": None, # 唯一纯文本 writer,None 是合法值
|
"refiner": None, # 唯一纯文本 writer,None 是合法值
|
||||||
|
|||||||
@@ -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)----
|
# ---- worldbuilder 结构化输出(ARCH §5.4 worldbuilder 行 / §6.5 / §4.5)----
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ __all__ = [
|
|||||||
"book_title_spec",
|
"book_title_spec",
|
||||||
"brainstorm_spec",
|
"brainstorm_spec",
|
||||||
"character_gen_spec",
|
"character_gen_spec",
|
||||||
|
"characterization_spec",
|
||||||
"continue_spec",
|
"continue_spec",
|
||||||
"continuity_spec",
|
"continuity_spec",
|
||||||
"de_ai_spec",
|
"de_ai_spec",
|
||||||
@@ -119,6 +120,20 @@ style_drift_spec = AgentSpec(
|
|||||||
scope="builtin",
|
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;writer 档;纯文本重写;非 Agent 流水线、非持久,M4-e)----
|
||||||
refiner_spec = AgentSpec(
|
refiner_spec = AgentSpec(
|
||||||
name="refiner",
|
name="refiner",
|
||||||
@@ -322,6 +337,7 @@ _SPECS_BY_NAME: dict[str, AgentSpec] = {
|
|||||||
outliner_spec,
|
outliner_spec,
|
||||||
foreshadow_spec,
|
foreshadow_spec,
|
||||||
pace_spec,
|
pace_spec,
|
||||||
|
characterization_spec,
|
||||||
style_extract_spec,
|
style_extract_spec,
|
||||||
style_drift_spec,
|
style_drift_spec,
|
||||||
refiner_spec,
|
refiner_spec,
|
||||||
@@ -342,7 +358,7 @@ _SPECS_BY_NAME: dict[str, AgentSpec] = {
|
|||||||
project_plan_spec,
|
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)
|
SPECS: Final[Mapping[str, AgentSpec]] = MappingProxyType(_SPECS_BY_NAME)
|
||||||
|
|
||||||
# 四审受信保留名 —— 独立显式白名单(安全边界锚在此,不依附派生集合)
|
# 四审受信保留名 —— 独立显式白名单(安全边界锚在此,不依附派生集合)
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ class FakeReviewRepo:
|
|||||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||||
style: dict[str, Any] | None = None,
|
style: dict[str, Any] | None = None,
|
||||||
pace: dict[str, Any] | None = None,
|
pace: dict[str, Any] | None = None,
|
||||||
|
characterization: dict[str, Any] | None = None,
|
||||||
health_score: int | None = None,
|
health_score: int | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
entry = {
|
entry = {
|
||||||
@@ -139,6 +140,7 @@ class FakeReviewRepo:
|
|||||||
"foreshadow_sug": list(foreshadow_sug or []),
|
"foreshadow_sug": list(foreshadow_sug or []),
|
||||||
"style": style,
|
"style": style,
|
||||||
"pace": pace,
|
"pace": pace,
|
||||||
|
"characterization": characterization,
|
||||||
"health_score": health_score,
|
"health_score": health_score,
|
||||||
}
|
}
|
||||||
self.records.append(entry)
|
self.records.append(entry)
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ class FakeReviewRepo:
|
|||||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||||
style: dict[str, Any] | None = None,
|
style: dict[str, Any] | None = None,
|
||||||
pace: dict[str, Any] | None = None,
|
pace: dict[str, Any] | None = None,
|
||||||
|
characterization: dict[str, Any] | None = None,
|
||||||
health_score: int | None = None,
|
health_score: int | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
rec = FakeReviewRecord(chapter_no, list(conflicts))
|
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,
|
foreshadow_sug: list[dict[str, object]] | None = None,
|
||||||
style: dict[str, object] | None = None,
|
style: dict[str, object] | None = None,
|
||||||
pace: dict[str, object] | None = None,
|
pace: dict[str, object] | None = None,
|
||||||
|
characterization: dict[str, object] | None = None,
|
||||||
health_score: int | None = None,
|
health_score: int | None = None,
|
||||||
) -> ReviewView:
|
) -> ReviewView:
|
||||||
self._seq += 1
|
self._seq += 1
|
||||||
@@ -46,6 +47,7 @@ class FakeReviewRepo:
|
|||||||
foreshadow_sug=list(foreshadow_sug or []),
|
foreshadow_sug=list(foreshadow_sug or []),
|
||||||
style=style,
|
style=style,
|
||||||
pace=pace,
|
pace=pace,
|
||||||
|
characterization=characterization,
|
||||||
health_score=health_score,
|
health_score=health_score,
|
||||||
decisions=None,
|
decisions=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from fakes_orchestrator import FakeReviewRepo, SchemaRoutingRunGateway
|
|||||||
from langchain_core.runnables import RunnableConfig
|
from langchain_core.runnables import RunnableConfig
|
||||||
from langgraph.checkpoint.memory import MemorySaver
|
from langgraph.checkpoint.memory import MemorySaver
|
||||||
from ww_agents import (
|
from ww_agents import (
|
||||||
|
CharacterizationIssue,
|
||||||
|
CharacterizationReview,
|
||||||
Conflict,
|
Conflict,
|
||||||
ContinuityReview,
|
ContinuityReview,
|
||||||
ForeshadowReview,
|
ForeshadowReview,
|
||||||
@@ -24,6 +26,7 @@ from ww_agents import (
|
|||||||
StyleDriftSegment,
|
StyleDriftSegment,
|
||||||
)
|
)
|
||||||
from ww_core.orchestrator import (
|
from ww_core.orchestrator import (
|
||||||
|
CHARACTERIZATION,
|
||||||
CONTINUITY,
|
CONTINUITY,
|
||||||
EVENT_CONFLICT,
|
EVENT_CONFLICT,
|
||||||
EVENT_DONE,
|
EVENT_DONE,
|
||||||
@@ -67,6 +70,19 @@ _STYLE = StyleDriftReview(
|
|||||||
StyleDriftSegment(idx=7, score=55),
|
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:
|
def _state(reviews: dict[str, Any] | None = None) -> ChapterState:
|
||||||
@@ -96,9 +112,10 @@ def _four_reviews() -> dict[str, Any]:
|
|||||||
# ---- REVIEW_SPECS 含第四审 ----
|
# ---- 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]
|
names = [s.name for s in REVIEW_SPECS]
|
||||||
assert names == ["continuity", "foreshadow", "pace", "style"]
|
assert names == ["continuity", "foreshadow", "pace", "style", "characterization"]
|
||||||
|
|
||||||
|
|
||||||
# ---- collect:style 列映射 ----
|
# ---- collect:style 列映射 ----
|
||||||
@@ -139,13 +156,14 @@ async def test_collect_records_style_to_style_column() -> None:
|
|||||||
# ---- 四审并行图跑通(mock 网关按 schema 路由产四 parsed)----
|
# ---- 四审并行图跑通(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(
|
gateway = SchemaRoutingRunGateway(
|
||||||
{
|
{
|
||||||
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
|
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
|
||||||
ForeshadowReview: _FORESHADOW,
|
ForeshadowReview: _FORESHADOW,
|
||||||
PaceReview: _PACE,
|
PaceReview: _PACE,
|
||||||
StyleDriftReview: _STYLE,
|
StyleDriftReview: _STYLE,
|
||||||
|
CharacterizationReview: _CHARACTERIZATION,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
repo = FakeReviewRepo()
|
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"][FORESHADOW]["status"] == REVIEW_OK
|
||||||
assert final["reviews"][PACE]["status"] == REVIEW_OK
|
assert final["reviews"][PACE]["status"] == REVIEW_OK
|
||||||
assert final["reviews"][STYLE]["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]
|
rec = repo.records[0]
|
||||||
assert rec["conflicts"] and rec["foreshadow_sug"] and rec["pace"]
|
assert rec["conflicts"] and rec["foreshadow_sug"] and rec["pace"]
|
||||||
assert rec["style"]["score"] == 72
|
assert rec["style"]["score"] == 72
|
||||||
|
assert rec["characterization"]["issues"][0]["character"] == "沈砚"
|
||||||
|
|
||||||
|
|
||||||
async def test_four_review_graph_isolates_failing_style_audit() -> None:
|
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)
|
foreshadow_sug: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
style: dict[str, Any] | None = None
|
style: dict[str, Any] | None = None
|
||||||
pace: 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
|
health_score: int | None = None
|
||||||
decisions: dict[str, Any] | None = None
|
decisions: dict[str, Any] | None = None
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ class ReviewRepo(Protocol):
|
|||||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||||
style: dict[str, Any] | None = None,
|
style: dict[str, Any] | None = None,
|
||||||
pace: dict[str, Any] | None = None,
|
pace: dict[str, Any] | None = None,
|
||||||
|
characterization: dict[str, Any] | None = None,
|
||||||
health_score: int | None = None,
|
health_score: int | None = None,
|
||||||
) -> ReviewView:
|
) -> ReviewView:
|
||||||
"""append 一行审稿报告(T2.2 collect 写入)。"""
|
"""append 一行审稿报告(T2.2 collect 写入)。"""
|
||||||
@@ -74,6 +77,7 @@ def _to_view(row: ChapterReview) -> ReviewView:
|
|||||||
foreshadow_sug=list(row.foreshadow_sug or []),
|
foreshadow_sug=list(row.foreshadow_sug or []),
|
||||||
style=row.style,
|
style=row.style,
|
||||||
pace=row.pace,
|
pace=row.pace,
|
||||||
|
characterization=row.characterization,
|
||||||
health_score=row.health_score,
|
health_score=row.health_score,
|
||||||
decisions=row.decisions,
|
decisions=row.decisions,
|
||||||
)
|
)
|
||||||
@@ -95,6 +99,7 @@ class SqlReviewRepo:
|
|||||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||||
style: dict[str, Any] | None = None,
|
style: dict[str, Any] | None = None,
|
||||||
pace: dict[str, Any] | None = None,
|
pace: dict[str, Any] | None = None,
|
||||||
|
characterization: dict[str, Any] | None = None,
|
||||||
health_score: int | None = None,
|
health_score: int | None = None,
|
||||||
) -> ReviewView:
|
) -> ReviewView:
|
||||||
row = ChapterReview(
|
row = ChapterReview(
|
||||||
@@ -105,6 +110,7 @@ class SqlReviewRepo:
|
|||||||
foreshadow_sug=list(foreshadow_sug or []),
|
foreshadow_sug=list(foreshadow_sug or []),
|
||||||
style=style,
|
style=style,
|
||||||
pace=pace,
|
pace=pace,
|
||||||
|
characterization=characterization,
|
||||||
health_score=health_score,
|
health_score=health_score,
|
||||||
decisions=None,
|
decisions=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from ._protocols import GatewayRun
|
from ._protocols import GatewayRun
|
||||||
from .collect import (
|
from .collect import (
|
||||||
|
CHARACTERIZATION,
|
||||||
CONTINUITY,
|
CONTINUITY,
|
||||||
FORESHADOW,
|
FORESHADOW,
|
||||||
PACE,
|
PACE,
|
||||||
STYLE,
|
STYLE,
|
||||||
ReviewRecorder,
|
ReviewRecorder,
|
||||||
collect_reviews,
|
collect_reviews,
|
||||||
|
extract_characterization,
|
||||||
extract_conflicts,
|
extract_conflicts,
|
||||||
extract_foreshadow_sug,
|
extract_foreshadow_sug,
|
||||||
extract_pace,
|
extract_pace,
|
||||||
@@ -52,6 +54,7 @@ from .review_node import (
|
|||||||
run_review,
|
run_review,
|
||||||
)
|
)
|
||||||
from .sse import (
|
from .sse import (
|
||||||
|
EVENT_CHARACTERIZATION,
|
||||||
EVENT_CONFLICT,
|
EVENT_CONFLICT,
|
||||||
EVENT_DONE,
|
EVENT_DONE,
|
||||||
EVENT_ERROR,
|
EVENT_ERROR,
|
||||||
@@ -64,6 +67,7 @@ from .sse import (
|
|||||||
SECTION_INCOMPLETE,
|
SECTION_INCOMPLETE,
|
||||||
SECTION_STARTED,
|
SECTION_STARTED,
|
||||||
SseEvent,
|
SseEvent,
|
||||||
|
characterization_event,
|
||||||
conflict_event,
|
conflict_event,
|
||||||
done_event,
|
done_event,
|
||||||
error_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
|
from .write_node import GatewayStream, build_write_request, stream_chapter_draft, write_node
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"CHARACTERIZATION",
|
||||||
"COLLECT_NODE",
|
"COLLECT_NODE",
|
||||||
"CONTINUITY",
|
"CONTINUITY",
|
||||||
|
"EVENT_CHARACTERIZATION",
|
||||||
"EVENT_CONFLICT",
|
"EVENT_CONFLICT",
|
||||||
"EVENT_DONE",
|
"EVENT_DONE",
|
||||||
"EVENT_ERROR",
|
"EVENT_ERROR",
|
||||||
@@ -119,10 +125,12 @@ __all__ = [
|
|||||||
"build_text_input_context",
|
"build_text_input_context",
|
||||||
"build_worldbuilder_context",
|
"build_worldbuilder_context",
|
||||||
"build_write_request",
|
"build_write_request",
|
||||||
|
"characterization_event",
|
||||||
"collect_reviews",
|
"collect_reviews",
|
||||||
"conflict_event",
|
"conflict_event",
|
||||||
"done_event",
|
"done_event",
|
||||||
"error_event",
|
"error_event",
|
||||||
|
"extract_characterization",
|
||||||
"extract_conflicts",
|
"extract_conflicts",
|
||||||
"extract_foreshadow_sug",
|
"extract_foreshadow_sug",
|
||||||
"extract_pace",
|
"extract_pace",
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class ReviewRecordRepo(Protocol):
|
|||||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||||
style: dict[str, Any] | None = None,
|
style: dict[str, Any] | None = None,
|
||||||
pace: dict[str, Any] | None = None,
|
pace: dict[str, Any] | None = None,
|
||||||
|
characterization: dict[str, Any] | None = None,
|
||||||
health_score: int | None = None,
|
health_score: int | None = None,
|
||||||
) -> Any: ...
|
) -> Any: ...
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ AI 产出真正入库经验收事务的裁决)。
|
|||||||
collect 与 review_repo 同样**只 flush 不 commit**——`commit` 归 HTTP 端点 / 验收事务。
|
collect 与 review_repo 同样**只 flush 不 commit**——`commit` 归 HTTP 端点 / 验收事务。
|
||||||
本节点绝不 commit。
|
本节点绝不 commit。
|
||||||
|
|
||||||
M4 四审齐 → 列映射(一次 record 落齐本章四审):
|
M4 四审齐 → 列映射(一次 record 落齐本章四审)+ 灵感③ advisory 人物塑造第五审:
|
||||||
- continuity → `conflicts`(冲突清单 list);
|
- continuity → `conflicts`(冲突清单 list);
|
||||||
- foreshadow → `foreshadow_sug`(planted/resolved 扁平为建议 list,每条带 `kind` 标记);
|
- foreshadow → `foreshadow_sug`(planted/resolved 扁平为建议 list,每条带 `kind` 标记);
|
||||||
- pace → `pace`(节奏诊断 dict:water/hook/beat_map);
|
- pace → `pace`(节奏诊断 dict:water/hook/beat_map);
|
||||||
- style → `style`(文风漂移诊断 dict:score/segments)。
|
- style → `style`(文风漂移诊断 dict:score/segments);
|
||||||
|
- characterization→ `characterization`(人物塑造诊断 dict:issues;advisory,不进验收 gate)。
|
||||||
任一审 `incomplete`(网关失败隔离,§5.2)→ 该列留空/None,不阻塞其余。
|
任一审 `incomplete`(网关失败隔离,§5.2)→ 该列留空/None,不阻塞其余。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ CONTINUITY = "continuity"
|
|||||||
FORESHADOW = "foreshadow"
|
FORESHADOW = "foreshadow"
|
||||||
PACE = "pace"
|
PACE = "pace"
|
||||||
STYLE = "style"
|
STYLE = "style"
|
||||||
|
CHARACTERIZATION = "characterization" # 灵感③ advisory 人物塑造审(仅建议、不阻断验收)
|
||||||
|
|
||||||
# foreshadow 建议扁平化时的来源标记(planted=新埋 / resolved=回收)。
|
# foreshadow 建议扁平化时的来源标记(planted=新埋 / resolved=回收)。
|
||||||
FORESHADOW_KIND_PLANTED = "planted"
|
FORESHADOW_KIND_PLANTED = "planted"
|
||||||
@@ -57,6 +59,7 @@ class ReviewRecorder(Protocol):
|
|||||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||||
style: dict[str, Any] | None = None,
|
style: dict[str, Any] | None = None,
|
||||||
pace: dict[str, Any] | None = None,
|
pace: dict[str, Any] | None = None,
|
||||||
|
characterization: dict[str, Any] | None = None,
|
||||||
health_score: int | None = None,
|
health_score: int | None = None,
|
||||||
) -> Any: ...
|
) -> Any: ...
|
||||||
|
|
||||||
@@ -123,6 +126,19 @@ def extract_style(reviews: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
return dict(result)
|
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(
|
async def collect_reviews(
|
||||||
state: ChapterState,
|
state: ChapterState,
|
||||||
*,
|
*,
|
||||||
@@ -140,6 +156,7 @@ async def collect_reviews(
|
|||||||
foreshadow_sug = extract_foreshadow_sug(reviews)
|
foreshadow_sug = extract_foreshadow_sug(reviews)
|
||||||
pace = extract_pace(reviews)
|
pace = extract_pace(reviews)
|
||||||
style = extract_style(reviews)
|
style = extract_style(reviews)
|
||||||
|
characterization = extract_characterization(reviews)
|
||||||
await review_repo.record(
|
await review_repo.record(
|
||||||
state["project_id"],
|
state["project_id"],
|
||||||
state["chapter_no"],
|
state["chapter_no"],
|
||||||
@@ -148,6 +165,7 @@ async def collect_reviews(
|
|||||||
foreshadow_sug=foreshadow_sug,
|
foreshadow_sug=foreshadow_sug,
|
||||||
style=style,
|
style=style,
|
||||||
pace=pace,
|
pace=pace,
|
||||||
|
characterization=characterization,
|
||||||
)
|
)
|
||||||
log.info(
|
log.info(
|
||||||
"collect_reviews_recorded",
|
"collect_reviews_recorded",
|
||||||
@@ -157,6 +175,7 @@ async def collect_reviews(
|
|||||||
foreshadow_sug_count=len(foreshadow_sug),
|
foreshadow_sug_count=len(foreshadow_sug),
|
||||||
has_pace=pace is not None,
|
has_pace=pace is not None,
|
||||||
has_style=style is not None,
|
has_style=style is not None,
|
||||||
|
has_characterization=characterization is not None,
|
||||||
reviews=sorted(reviews.keys()),
|
reviews=sorted(reviews.keys()),
|
||||||
)
|
)
|
||||||
# 留痕已在表(真相源);不在节点 commit(端点/事务层负责,见模块 docstring)。
|
# 留痕已在表(真相源);不在节点 commit(端点/事务层负责,见模块 docstring)。
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from langgraph.graph import END, START, StateGraph
|
from langgraph.graph import END, START, StateGraph
|
||||||
from ww_agents import (
|
from ww_agents import (
|
||||||
AgentSpec,
|
AgentSpec,
|
||||||
|
characterization_spec,
|
||||||
continuity_spec,
|
continuity_spec,
|
||||||
foreshadow_spec,
|
foreshadow_spec,
|
||||||
pace_spec,
|
pace_spec,
|
||||||
@@ -38,12 +39,15 @@ WRITE_NODE = "write"
|
|||||||
COLLECT_NODE = "collect"
|
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, ...] = (
|
REVIEW_SPECS: tuple[AgentSpec, ...] = (
|
||||||
continuity_spec,
|
continuity_spec,
|
||||||
foreshadow_spec,
|
foreshadow_spec,
|
||||||
pace_spec,
|
pace_spec,
|
||||||
style_drift_spec,
|
style_drift_spec,
|
||||||
|
characterization_spec,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from pydantic import BaseModel
|
|||||||
from ww_llm_gateway.types import Delta
|
from ww_llm_gateway.types import Delta
|
||||||
from ww_shared import AppError, ErrorCode
|
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
|
from .review_node import REVIEW_OK
|
||||||
|
|
||||||
log = structlog.get_logger(__name__)
|
log = structlog.get_logger(__name__)
|
||||||
@@ -30,6 +30,7 @@ EVENT_CONFLICT = "conflict" # continuity 冲突命中(审稿 review)
|
|||||||
EVENT_FORESHADOW = "foreshadow" # foreshadow 建议命中(新埋/回收,审稿 review)
|
EVENT_FORESHADOW = "foreshadow" # foreshadow 建议命中(新埋/回收,审稿 review)
|
||||||
EVENT_PACE = "pace" # pace 节奏诊断(注水/钩子/节拍图,审稿 review)
|
EVENT_PACE = "pace" # pace 节奏诊断(注水/钩子/节拍图,审稿 review)
|
||||||
EVENT_STYLE = "style" # style 文风漂移诊断(整体相似度 + 漂移段,审稿 review)
|
EVENT_STYLE = "style" # style 文风漂移诊断(整体相似度 + 漂移段,审稿 review)
|
||||||
|
EVENT_CHARACTERIZATION = "characterization" # 人物塑造诊断(advisory issues,审稿 review)
|
||||||
EVENT_DONE = "done"
|
EVENT_DONE = "done"
|
||||||
EVENT_ERROR = "error"
|
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:
|
def error_event(*, code: str, message: str, request_id: str | None = None) -> SseEvent:
|
||||||
"""错误事件,形对齐错误信封 §7.1(`code`/`message`),附 `request_id` 便于贯通排查。"""
|
"""错误事件,形对齐错误信封 §7.1(`code`/`message`),附 `request_id` 便于贯通排查。"""
|
||||||
return SseEvent(
|
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
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -225,6 +225,8 @@ class ChapterReview(UuidPk, CreatedAt, Base):
|
|||||||
foreshadow_sug: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
|
foreshadow_sug: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
|
||||||
style: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
style: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
||||||
pace: 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)
|
health_score: Mapped[int | None] = mapped_column(Integer)
|
||||||
decisions: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
decisions: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user