Compare commits

...

10 Commits

Author SHA1 Message Date
Yaojia Wang
51bfb6e320 test(qa): craft 成稿质量前后对照评估回路(灵感 F2)
固定 3 题材金标准输入(仙侠/都市/悬疑,各含最小 spec+world+outline 及开篇章),
经真实 assemble()+build_write_request() 组装 craft-on / craft-off 两版写章请求:

- dry 模式(默认,零成本、免真实 LLM,可进 CI):断言 craft 教条块、genre 片段、
  开篇黄金三章 marker 确实按 fixture 注入正确内容,且 craft-off 基线不含这三者;
  并把两版将发送给模型的 prompt 并排 dump 供人眼看。这些断言即 tests/test_craft_eval.py,
  是 craft/审查注入路径的回归门禁。
- --live 模式(真实付费 LLM,绝不进 CI):经 gateway 各生成一章 + analyst 五维裁判,
  凭据读 DB、路由走 tier_routing,与生产写章同一网关路径。

入口 `uv run python -m tests.craft_eval [--live]`;用法/成本/门禁说明见 README.md。
2026-07-06 17:57:31 +02:00
Yaojia Wang
3d2a8b8cee feat(web): 角色关系图谱(灵感⑥缩减版/D4)
设定库人物 tab 新增 React Flow 关系图谱,仅用现有角色读端点数据
(CharacterRelationView={name,kind,note}):边按 name-join、kind 作中文
标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造)。

- lib/characters/graphLayout.ts:纯逻辑(role→分组配色关键词映射 / 节点
  去空重名 / kind→标签+DEFAULT_RELATION_LABEL / 悬空边跳过并计数 / 自指
  跳过 / 互指无向去重 / @dagrejs/dagre 一次性确定性初始坐标、中心→左上、
  不可变更新)+22 单测。
- components/characters/RelationshipGraph.tsx:'use client',经 CodexPage
  dynamic(ssr:false) 挂载;显式容器高度 + import RF CSS + prefers-reduced-
  motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示。
- 依赖:@xyflow/react@^12.11.2 + @dagrejs/dagre@^3.0.0。

无后端契约变更/迁移/gen:api。门禁:lint/typecheck 干净、vitest 565、
coverage lib 95.4%(graphLayout 97.6%)、build OK。
2026-07-06 17:38:58 +02:00
Yaojia Wang
7babb4854b feat(web): 角色群像按定位配比生成(role_mix 缩减版)
- 契约: CharacterGenerateRequest 加 role_mix{定位:数量},model_validator 归一
  count=各值之和(每项≥1、总和≤12),缺省退回单一 role+count
- @llm: build_character_gen_context/run_character_gen 加 role_mix 参并注入配比块
  (确定性、无时间戳,守 assemble 纯函数);router 穿参
- character-gen.md 加"按配比严格分配 role"纪律 + regen prompt_hashes 金标准
- 前端: sanitizeRoleMix/roleMixTotal + buildCharacterGenerateRequest 第4参 +
  useCharacterGen 穿参 + RoleMixEditor + CharacterGenerator 单一/配比双模
2026-07-06 17:24:16 +02:00
Yaojia Wang
72b3c81146 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 作客观锚点)。
2026-07-06 17:08:38 +02:00
Yaojia Wang
2a7a865e98 feat(core): continuity 冲突裁决反哺——上一已验收章保留裁决入本章 volatile 前瞻约束
灵感②/D3 窄版 v1:assemble 读上一已验收章的 continuity 冲突作者裁决
(decisions.items 按 conflict_index join 回 conflicts 取正文),仅保留
verdict∈{accept,manual}、去重、按 conflict_index 排序、有界 top-3,
用确定性字符串模板改写成前瞻正向约束,注入本章 volatile「上一章冲突提醒」。

- 绝不调 LLM、绝不反哺 advisory(style/pace/characterization 无作者裁决 oracle)——守不变量 #3/#4。
- 只入 volatile、永不进 stable_core 缓存前缀(依赖上一章裁决、每章易变)——守 #9。
- assemble 仍是确定性纯函数、字节稳定、可缓存——守 #6/#7。
- decisions 非空=已验收(只在验收事务写入);未验收上一章不反哺——守风险#5。
- MemoryRepos 末位加 review: ReviewRepo | None = None(默认 None→优雅降级不反哺),
  docstring 8→9;sql_memory_repos 工厂补 review=SqlReviewRepo(既有构造点靠默认无需感知)——守风险#6。
2026-07-06 16:38:06 +02:00
Yaojia Wang
02d19019f6 feat(web): AI 立项方案生成+预填(种子门控 + 按字段接受)
向导阶段(project 未建)一键推演结构化书级蓝图预填立项向导。

- 新 SPEC project_plan_spec(analyst 档,纯预览 reads/writes=())+ schema
  ProjectPlanResult(书名候选 + 时空背景/叙事结构/故事核心/结局设计/基调,
  全字段默认值守解析韧性)+ 注册 SCHEMA_CATALOG;计数三处 21→22 + regen golden。
- 端点 POST /skills/project-plan/generate(不带 project 前缀,绕开 toolbox 404):
  请求体序列化向导草稿为 project_context,复用 build_brief_context + run_generator;
  种子门控(缺 genre/logline→422);请求 schema 加 max_length 上界。
- run_generator/_build_request 的 project_id 放宽 uuid.UUID|None(向导无 project)。
- 前端 wizard.ts 加 mapPlanResultToWizardForm(错配字段折进 premise 不静默丢)+
  applyPlanPatch(仅回填空字段、保护已编辑)+ useProjectPlan hook + PlanAssistant。
2026-07-06 16:26:08 +02:00
Yaojia Wang
821ace5989 feat(web): 立项新增 基调/结局/视角 三字段(书级常量入 stable_core 缓存前缀)
灵感计划 §3④ + §4 D 枚举决策:
- projects 表加 tone/ending_type/narrative_pov(Text nullable、无 CHECK,同 genre/structure;
  迁移 e5f6a7b8c9d0 nullable 免 backfill)。
- ProjectCreateRequest/ProjectResponse + domain ProjectCreate/ProjectView 全链路透传(snake_case)。
- 完整 stable_core 链路:ProjectSpecView + SqlProjectSpecRepo.spec + _build_spec_section 渲染进
  「作品蓝本」块——书级常量随书稳定,字节稳定守缓存前缀不变量 #9,绝不入 volatile。
- 前端 wizard.ts 三字段 + 预设数组 TONES/ENDING_TYPES/NARRATIVE_POVS + ProjectWizard 控件与确认页回显。
- 契约变更已 pnpm gen:api + memory/contracts.md 登记。
2026-07-06 15:59:49 +02:00
Yaojia Wang
491d9342ef merge: appearance/motive 全栈穿线 (P1 流B) 2026-07-06 14:38:17 +02:00
Yaojia Wang
acde715515 feat(character): appearance/motive 全栈穿线 + motive/traits 口径重切
打通 appearance/motive 从生成→schema→契约→落库→前端整条穿线
(DB 列早在初始迁移、此前一直 NULL,无新迁移),并原子重切人物
内驱口径。

- @llm: CharacterCard/character-gen.md 加 appearance/motive(给默认值守
  解析韧性),重切——动机唯一落 motive、traits 只留核心/表层/阴影三层、
  arc 引用 motive;regen prompt_hashes.json 金标准。
- @backend: CharacterCardView + CharacterWriteRepo/SqlCharacterWriteRepo
  (create + update backfill 分支)+ CharacterWriteFields + 路由双向形变
  与 _existing_characters 带上两字段。
- @frontend: pnpm gen:api 重生成 TS 客户端;CharacterCardItem 展示/编辑
  两字段。
- 测试: schema 默认值/解析、动机不双写重切回归、card_to_view 双向、
  ingest 透传、list 暴露、SqlCharacterWriteRepo create/backfill 真 pg 集成。
2026-07-06 14:36:11 +02:00
Yaojia Wang
452bd56d95 feat(agents,core,config): 写章 craft 教条块 + genre-aware 片段 + 开篇黄金三章特判
- 新增 write_craft.md 品类无关写章 craft 内核(网文语域/show-don't-tell/对话 voice/
  反注水/结构性文字质感,灵活原则非穷举清单)+ load_craft_doctrine()(import 期读盘,
  不入 SPECS/金标准)
- build_write_request 把 craft 教条作为最前的 cache=True system 块注入(craft→stable_core,
  守缓存前缀 #9,tier=writer 不写 model)
- 新增 packages/config GENRE_CRAFT 题材片段表,assemble 按 project.genre 注入 stable_core;
  ProjectSpecView + SqlProjectSpecRepo 透传 genre
- 开篇前 3 章在 volatile 注入黄金三章提示(含章号→每章易变,不进缓存前缀)
- 修正 toolbox_registry 注释:11 条 → 15 条(legacy 3 + 新 8 + Scope B 4)
2026-07-06 14:29:43 +02:00
95 changed files with 5748 additions and 102 deletions

View File

@@ -161,6 +161,11 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
- [2026-07-06] @backend/@llm/@frontend**✅ 灵感⑦ 群像按定位配比·缩减版 + §0 归属纠偏develop 直提)**。角色群像生成支持「按定位配比」下发。**契约变更** `CharacterGenerateRequest``role_mix:dict[str,int]|None`——`model_validator(mode="after")` 定义与既有 `count` 的关系在场时校验非空、每项≥1、总和≤`MAX_GENERATE_COUNT`=12并把 `count` 归一为各值之和(构造期归一化,忽略客户端 count缺省退回单一 `role`+`count`。**§0 归属纠偏**`build_character_gen_context`+`run_character_gen``ww_core/orchestrator/generation_node.py`)确为 **@llm 域**——加末位默认 `role_mix=None` 关键字参,在场时注入「角色定位配比」块(保插入序、确定性、无时间戳,守 #6/#7 assemble 纯函数;覆盖单一 role 行);`routers/generation.py` 穿 `role_mix=body.role_mix``character-gen.md` 加「按配比严格分配 role、各定位之和=总数」纪律 → **regen `prompt_hashes.json`**(仅 character-gen 哈希变,**不进 SPECS→不 bump 计数**)。**前端** `cards.ts``sanitizeRoleMix`(去空/去重/clamp≥1/按 12 截断)+`roleMixTotal`+`buildCharacterGenerateRequest` 第 4 参 roleMix`useCharacterGen` `CharacterGenInput.roleMix` 穿参;新 `RoleMixEditor.tsx` + `CharacterGenerator` SegmentedControl 单一/配比双模。**TDD**schema 校验 7 例总和≤12/空 dict/非正值拒绝)+ context 注入 2 例 + run 穿参 1 例 + 前端 cards 构造/clamp 5 例 + hook renderHook 1 例。**门禁全绿**:后端 ruff/format 干净 · mypy **222** · pytest **858**+11· 无迁移(未动模型,未跑 alembic前端 gen:api/lint/typecheck 干净 · vitest **543**+6· coverage 95%cards.ts 100%)· build OK。记 contracts C3。守不变量 #2/#6/#7/#9。**无 DB 迁移**。
- [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`——向导无 projectledger.project_id 本 nullable新缝 `get_project_plan_gateway`analyst。**零迁移**。**前端** `wizard.ts``mapPlanResultToWizardForm`(书名/结构/基调→对应字段;时空背景/结局设计→折进 premise **不静默丢**;结局取向/视角预设枚举不臆造)+ `applyPlanPatch`**仅回填空字段、保护已编辑**+ `canGeneratePlan`/`toPlanSeedRequest` + `useProjectPlan` hookrenderHook 测试)+ `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 RCECVE-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 未取消。**确认无虞**SSRFbase_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 RCECVE-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 未取消。**确认无虞**SSRFbase_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 修复 + 合并 developmerge `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 **静默丢失**(链计费全丢,违 #1E2E 清了 ledger 却没断言其存在故漏掉)。**修复**(`d1ea83b`):网关改 `chain_gateway_builder` 按节点 session 重建(仿 `digest_gateway_builder`/`style.py`);日志脱敏(`_classify_job_error`+`exc_type`,去 `str(exc)`§5resume 原子 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 修复 + 合并 developmerge `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 **静默丢失**(链计费全丢,违 #1E2E 清了 ledger 却没断言其存在故漏掉)。**修复**(`d1ea83b`):网关改 `chain_gateway_builder` 按节点 session 重建(仿 `digest_gateway_builder`/`style.py`);日志脱敏(`_classify_job_error`+`exc_type`,去 `str(exc)`§5resume 原子 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] @backend**✅ Chain Workflow C2多章链 服务+端点+schema+checkpointer 接线,分支 `feat/chain-workflow`**。承 C1(@llm)链图(`build_chain_graph` §3.3),落地服务/端点壳:① **3 端点**`routers/chain.py``POST .../chains/{key}/run`→202 `ChainRunAccepted``POST .../chains/runs/{job_id}/resume`→202`GET /jobs/{id}` 复用。校验count 1..50→422、未知 chain_key→404、项目不存在→404、resume job 不存在→404、resume 非 awaiting→**409 新码 `CONFLICT`**、无凭据→503。② **schema**`schemas/chain.py``ChainRunRequest/ChainRunAccepted/ChainResumeRequest``ConflictDecision` 复用)。③ **服务**`services/chain_runner.py``run_chain_job` 仿 `run_job` 壳自建独立 session 驱动链图set_running→ainvoke→据 `__interrupt__``awaiting_input``done`/`failed``build_accept_op` 在 apps/api 装配验收事务闭包注入图节点,守 #3/#4token 不入 result/日志)+ `services/chain_deps.py``get_checkpointer_factory`:运行时 `AsyncPostgresSaver` 上下文 / 测试 MemorySaver。④ **零迁移**§7复用 `jobs`,新增 `status="awaiting_input"` + `JobRepo.set_awaiting`awaiting 章经 `result.awaiting_chapter`;新错误码 `ErrorCode.CONFLICT`(409)。⑤ **新网关缝**`project_deps.build_chain_gateway`/`get_chain_gateway`(按请求 tier writer/analyst/light 分派——单档网关恒返该档会错路由 review/digest见 gotcha+ `get_digest_gateway_builder`。**单测**`apps/api/tests/test_chain.py` 12 用例mock 网关 + MemorySaver + fake session/accept_op无 DB/无网络/无真 LLMrun→202、resume→202、未知 key 404、count 越界(>50/0) 422、resume 非 awaiting 409、项目/ job 不存在 404、run_chain_job 无冲突→done+written 满、冲突→awaiting→resume→done、错误脱敏、accept_op 冲突缺判→CONFLICT_UNRESOLVED。**全仓门禁绿**ruff/format 干净、mypy **193 Success**、alembic **无漂移**(无 ORM 变更、pytest **600 passed**。守不变量 #1/#3/#4/#5/#9。记 contracts C-Chain 稳定 + 2 decision + 3 gotcha。**唯一新增 DDLlanggraph 检查点表)= C3 迁移;前端链 UI = 契约稳定后 @frontend follow-up本期非目标。** - [2026-06-23] @backend**✅ Chain Workflow C2多章链 服务+端点+schema+checkpointer 接线,分支 `feat/chain-workflow`**。承 C1(@llm)链图(`build_chain_graph` §3.3),落地服务/端点壳:① **3 端点**`routers/chain.py``POST .../chains/{key}/run`→202 `ChainRunAccepted``POST .../chains/runs/{job_id}/resume`→202`GET /jobs/{id}` 复用。校验count 1..50→422、未知 chain_key→404、项目不存在→404、resume job 不存在→404、resume 非 awaiting→**409 新码 `CONFLICT`**、无凭据→503。② **schema**`schemas/chain.py``ChainRunRequest/ChainRunAccepted/ChainResumeRequest``ConflictDecision` 复用)。③ **服务**`services/chain_runner.py``run_chain_job` 仿 `run_job` 壳自建独立 session 驱动链图set_running→ainvoke→据 `__interrupt__``awaiting_input``done`/`failed``build_accept_op` 在 apps/api 装配验收事务闭包注入图节点,守 #3/#4token 不入 result/日志)+ `services/chain_deps.py``get_checkpointer_factory`:运行时 `AsyncPostgresSaver` 上下文 / 测试 MemorySaver。④ **零迁移**§7复用 `jobs`,新增 `status="awaiting_input"` + `JobRepo.set_awaiting`awaiting 章经 `result.awaiting_chapter`;新错误码 `ErrorCode.CONFLICT`(409)。⑤ **新网关缝**`project_deps.build_chain_gateway`/`get_chain_gateway`(按请求 tier writer/analyst/light 分派——单档网关恒返该档会错路由 review/digest见 gotcha+ `get_digest_gateway_builder`。**单测**`apps/api/tests/test_chain.py` 12 用例mock 网关 + MemorySaver + fake session/accept_op无 DB/无网络/无真 LLMrun→202、resume→202、未知 key 404、count 越界(>50/0) 422、resume 非 awaiting 409、项目/ job 不存在 404、run_chain_job 无冲突→done+written 满、冲突→awaiting→resume→done、错误脱敏、accept_op 冲突缺判→CONFLICT_UNRESOLVED。**全仓门禁绿**ruff/format 干净、mypy **193 Success**、alembic **无漂移**(无 ORM 变更、pytest **600 passed**。守不变量 #1/#3/#4/#5/#9。记 contracts C-Chain 稳定 + 2 decision + 3 gotcha。**唯一新增 DDLlanggraph 检查点表)= C3 迁移;前端链 UI = 契约稳定后 @frontend follow-up本期非目标。**
@@ -239,3 +244,4 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
- [2026-06-20] @frontend**Tier4 完成**T4-a 全局 AI 工具条 + T4-b 本章指令直通生成,分支 `feat/ux-tier4-ai-toolbar`,未并 develop。T4-a`lib/nav/ai-tools.ts`(aiToolItems) + `components/AiToolbar.tsx` 服务端组件AppShell 顶栏下仅项目页渲染(写本章/审稿/大纲/设定库/工具箱常驻一排,写本章=朱砂主动作),用 `--chrome` CSS 变量统一 chrome 高度(项目页 7rem=header4+toolbar3余 4rem6 个固定高度页改用 `calc(100vh-var(--chrome,4rem))` 适配。T4-b全链后端 `assemble/_build_volatile``directive` 形参(「本章指令」段领衔 volatile**绝不入 stable_core**,守缓存前缀不变量 #9`stream_draft` 加可选 body `DraftStreamRequest{directive}` 透传、`directive_len` 入 log 不记原文、不持久化无迁移,重生成 TS 客户端;前端新 `lib/workbench/directive.ts`(STYLE_PRESETS + composeDirective 纯函数)、`useDraftStream.start(p,no,directive?)` 非空时发 JSON body、Workbench 加可折叠「本章指令」textarea + 风格预设 chips。TDDai-tools 4 测 + directive 4 测 + assemble directive 测 + draft 端点 directive/向后兼容 2 测。门禁全绿:后端 ruff/format/mypy(163 文件)/**pytest 479 passed**;前端 lint/tsc/**vitest 213**/build。契约记 contracts C3 扩T4-b - [2026-06-20] @frontend**Tier4 完成**T4-a 全局 AI 工具条 + T4-b 本章指令直通生成,分支 `feat/ux-tier4-ai-toolbar`,未并 develop。T4-a`lib/nav/ai-tools.ts`(aiToolItems) + `components/AiToolbar.tsx` 服务端组件AppShell 顶栏下仅项目页渲染(写本章/审稿/大纲/设定库/工具箱常驻一排,写本章=朱砂主动作),用 `--chrome` CSS 变量统一 chrome 高度(项目页 7rem=header4+toolbar3余 4rem6 个固定高度页改用 `calc(100vh-var(--chrome,4rem))` 适配。T4-b全链后端 `assemble/_build_volatile``directive` 形参(「本章指令」段领衔 volatile**绝不入 stable_core**,守缓存前缀不变量 #9`stream_draft` 加可选 body `DraftStreamRequest{directive}` 透传、`directive_len` 入 log 不记原文、不持久化无迁移,重生成 TS 客户端;前端新 `lib/workbench/directive.ts`(STYLE_PRESETS + composeDirective 纯函数)、`useDraftStream.start(p,no,directive?)` 非空时发 JSON body、Workbench 加可折叠「本章指令」textarea + 风格预设 chips。TDDai-tools 4 测 + directive 4 测 + assemble directive 测 + draft 端点 directive/向后兼容 2 测。门禁全绿:后端 ruff/format/mypy(163 文件)/**pytest 479 passed**;前端 lint/tsc/**vitest 213**/build。契约记 contracts C3 扩T4-b
- [2026-06-28] @backend/@frontend**UI Phase G 后端依赖补齐**`ProjectResponse` 增加 `updated_at` / `pending_review_count``chapters``updated_at` 迁移 `8c1d2e3f4a5b``SqlProjectRepo` 聚合项目自身、章节、审稿时间作为最近编辑,并按草稿是否晚于最近 accepted/review 统计待审稿章数。前端 `pnpm gen:api` 后项目库默认按最近编辑排序,新增“待审稿”筛选,作品卡显示最近编辑与待审稿徽标。契约记 `memory/contracts.md` C3 扩UI 计划记 `docs/design/ui-improvement-plan.md` 0.16。 - [2026-06-28] @backend/@frontend**UI Phase G 后端依赖补齐**`ProjectResponse` 增加 `updated_at` / `pending_review_count``chapters``updated_at` 迁移 `8c1d2e3f4a5b``SqlProjectRepo` 聚合项目自身、章节、审稿时间作为最近编辑,并按草稿是否晚于最近 accepted/review 统计待审稿章数。前端 `pnpm gen:api` 后项目库默认按最近编辑排序,新增“待审稿”筛选,作品卡显示最近编辑与待审稿徽标。契约记 `memory/contracts.md` C3 扩UI 计划记 `docs/design/ui-improvement-plan.md` 0.16。
- [2026-06-29] @frontend/@qa**UI review follow-up 修复完成**:补 `lib/review/chapterNav.ts` 使新增章导航测试可运行;`ReviewSectionPanel` 改为本地 open 状态,避免用户折叠后重渲染强制弹开;`ReviewReport.hasReport` 纳入 clean review新增 `tests/test_project_metadata_e2e.py` 用真实 DB 覆盖项目最近编辑/待审稿统计口径。验证:前端 lint/typecheck/vitest **490 passed**/build后端 ruff/format/mypy、pytest **763 passed**;浏览器抽样 review/settings 无 console error/横向溢出。 - [2026-06-29] @frontend/@qa**UI review follow-up 修复完成**:补 `lib/review/chapterNav.ts` 使新增章导航测试可运行;`ReviewSectionPanel` 改为本地 open 状态,避免用户折叠后重渲染强制弹开;`ReviewReport.hasReport` 纳入 clean review新增 `tests/test_project_metadata_e2e.py` 用真实 DB 覆盖项目最近编辑/待审稿统计口径。验证:前端 lint/typecheck/vitest **490 passed**/build后端 ruff/format/mypy、pytest **763 passed**;浏览器抽样 review/settings 无 console error/横向溢出。
- [2026-07-06] @frontend**✅ 灵感⑥ 关系图谱(缩减版)+ D4 完成**(纯前端,无后端契约/迁移/gen:api。新增 `lib/characters/graphLayout.ts`(纯逻辑:`roleStyle` role→分组配色关键词映射、`buildGraphNodes`/`buildGraphEdges`(kind→标签`DEFAULT_RELATION_LABEL` 命名常量)、`countDanglingRelations``layoutGraph`(@dagrejs/dagre 一次性确定性初始坐标、中心→左上、不可变)、`buildRelationshipGraph` 端到端)——**仅用现有 `CharacterCardView.relations={name,kind,note}`**:边按 name-join、kind 作中文标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造);悬空边(引用未入库角色)跳过并计数。新增 `components/characters/RelationshipGraph.tsx``'use client'`React Flow @xyflow/reactCodexPage 经 `dynamic(ssr:false)` 挂载于人物 tab显式容器高度 h-[28rem] + `import '@xyflow/react/dist/style.css'` + prefers-reduced-motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示)。依赖:`@xyflow/react@^12.11.2`+`@dagrejs/dagre@^3.0.0`(见 contracts.md 2026-07-06。TDDgraphLayout **+22 测**role 映射/节点去空重名/kind 标签+默认/悬空跳过/自指跳过/无向去重/边 id 唯一/悬空计数/布局确定性+不可变/端到端);组件走人工/E2E。前端门禁全绿lint 干净 · typecheck 干净 · **vitest 565 passed** · **coverage lib 95.4%graphLayout.ts 97.6%** · **build OK**codex 路由 7.58 kBReact Flow 懒加载独立 chunk。注`pnpm build` 门禁与运行中的 `next dev`(3001) 共享 `.next` → 触发 dev 服务临时 500stale chunk非代码缺陷、dev watcher 下次重编自愈,未重启服务。

View File

@@ -55,6 +55,9 @@ class FakeProjectRepo:
theme=data.theme, theme=data.theme,
selling_points=list(data.selling_points), selling_points=list(data.selling_points),
structure=data.structure, structure=data.structure,
tone=data.tone,
ending_type=data.ending_type,
narrative_pov=data.narrative_pov,
updated_at=self.now, updated_at=self.now,
) )
self.rows[pid] = (owner_id, view) self.rows[pid] = (owner_id, view)
@@ -148,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(
@@ -159,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,
) )
# 新→旧:插到列表头部。 # 新→旧:插到列表头部。

View File

@@ -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))

View File

@@ -75,6 +75,8 @@ class _FakeCharacterWriteRepo:
name: str, name: str,
role: str, role: str,
traits: list[str], traits: list[str],
appearance: str,
motive: str,
backstory: str, backstory: str,
arc: str, arc: str,
speech_tics: list[str], speech_tics: list[str],
@@ -91,6 +93,8 @@ class _FakeCharacterWriteRepo:
{ {
"role": role, "role": role,
"traits": list(traits), "traits": list(traits),
"appearance": appearance,
"motive": motive,
"arc": arc, "arc": arc,
"speech_tics": list(speech_tics), "speech_tics": list(speech_tics),
"tags": list(tags), "tags": list(tags),
@@ -106,6 +110,8 @@ class _FakeCharacterWriteRepo:
"name": name, "name": name,
"role": role, "role": role,
"traits": list(traits), "traits": list(traits),
"appearance": appearance,
"motive": motive,
"arc": arc, "arc": arc,
"speech_tics": list(speech_tics), "speech_tics": list(speech_tics),
"tags": list(tags), "tags": list(tags),
@@ -463,6 +469,62 @@ async def test_ingest_relations_persist_on_write() -> None:
assert char_repo.rows[0]["relations"] == relations assert char_repo.rows[0]["relations"] == relations
@pytest.mark.asyncio
async def test_ingest_persists_appearance_and_motive() -> None:
# ⑧ 穿线:入库端点须把 appearance/motive 透传给写侧 repo否则新列永远 NULL
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
char_repo = _FakeCharacterWriteRepo()
app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/characters",
json={
"cards": [
{
"name": "苏璃",
"role": "CP",
"traits": ["清冷毒舌"],
"appearance": "银发赤瞳,玄色劲装",
"motive": "替亡兄查清族灭真相",
"backstory": "玄霜阁遗孤",
"arc": "由敌转盟",
"speech_tics": [],
"tags": [],
"relations": [],
}
]
},
)
assert resp.status_code == 201
assert char_repo.rows[0]["appearance"] == "银发赤瞳,玄色劲装"
assert char_repo.rows[0]["motive"] == "替亡兄查清族灭真相"
def test_card_to_view_roundtrips_new_fields() -> None:
# ⑧ 穿线schema↔view 双向形变须带上 appearance/motive否则前后端静默丢字段
from ww_api.routers.generation import _card_to_view, _view_to_card
card = CharacterCard(
name="苏璃",
role="CP",
traits=["清冷毒舌"],
appearance="银发赤瞳,玄色劲装",
motive="替亡兄查清族灭真相",
backstory="玄霜阁遗孤",
arc="由敌转盟",
)
view = _card_to_view(card)
assert view.appearance == "银发赤瞳,玄色劲装"
assert view.motive == "替亡兄查清族灭真相"
back = _view_to_card(view)
assert back.appearance == card.appearance
assert back.motive == card.motive
def test_relations_from_jsonb_parses_and_skips_dirty() -> None: def test_relations_from_jsonb_parses_and_skips_dirty() -> None:
# Arrange混入脏条目非 dict / 缺 name / 缺 kind # Arrange混入脏条目非 dict / 缺 name / 缺 kind
from ww_api.routers.generation import _relations_from_jsonb from ww_api.routers.generation import _relations_from_jsonb
@@ -572,6 +634,8 @@ def _codex_memory() -> Any:
name="叶寒", name="叶寒",
role="主角", role="主角",
traits={"items": ["腹黑", "护短"]}, traits={"items": ["腹黑", "护短"]},
appearance="剑眉星目,一袭青衫",
motive="为母复仇、登临帝位",
backstory="孤儿出身", backstory="孤儿出身",
arc={"text": "从孤儿到帝王"}, arc={"text": "从孤儿到帝王"},
speech_tics={"items": [""]}, speech_tics={"items": [""]},
@@ -668,6 +732,9 @@ async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
assert card["speech_tics"] == [""] assert card["speech_tics"] == [""]
assert card["arc"] == "从孤儿到帝王" assert card["arc"] == "从孤儿到帝王"
assert card["tags"] == ["天才"] assert card["tags"] == ["天才"]
# ⑧ 穿线:读端点暴露 appearance/motiveText 列直落,不形变)。
assert card["appearance"] == "剑眉星目,一袭青衫"
assert card["motive"] == "为母复仇、登临帝位"
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -0,0 +1,51 @@
"""CharacterGenerateRequest.role_mix 校验单测(⑦ 群像按定位配比)。
纯 pydantic 校验,无 DB/网关。契约role_mix 在场时 count 取各值之和,
每项数量须 ≥ 1、总和须 ≤ MAX_GENERATE_COUNT。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from ww_api.schemas.generation import MAX_GENERATE_COUNT, CharacterGenerateRequest
def test_role_mix_absent_keeps_explicit_count() -> None:
req = CharacterGenerateRequest(brief="剑修", count=3)
assert req.role_mix is None
assert req.count == 3
def test_role_mix_sets_count_to_sum() -> None:
req = CharacterGenerateRequest(brief="群像", role_mix={"主角": 1, "对手": 2, "配角": 3})
assert req.count == 6
def test_role_mix_overrides_provided_count() -> None:
# role_mix 在场时 count 取各值之和,忽略客户端显式传入的 count
req = CharacterGenerateRequest(brief="群像", count=1, role_mix={"主角": 2, "对手": 2})
assert req.count == 4
def test_role_mix_total_at_max_allowed() -> None:
req = CharacterGenerateRequest(brief="群像", role_mix={"主角": MAX_GENERATE_COUNT})
assert req.count == MAX_GENERATE_COUNT
def test_role_mix_sum_over_max_rejected() -> None:
with pytest.raises(ValidationError):
# 6 + 7 = 13 > 12
CharacterGenerateRequest(brief="群像", role_mix={"主角": 6, "对手": 7})
def test_role_mix_empty_dict_rejected() -> None:
with pytest.raises(ValidationError):
CharacterGenerateRequest(brief="群像", role_mix={})
def test_role_mix_nonpositive_value_rejected() -> None:
with pytest.raises(ValidationError):
CharacterGenerateRequest(brief="群像", role_mix={"主角": 0})
with pytest.raises(ValidationError):
CharacterGenerateRequest(brief="群像", role_mix={"主角": -1})

View File

@@ -0,0 +1,197 @@
"""AI 立项方案生成端点测试(灵感⑤;内存替身,无 DB/无网络)。
覆盖 `POST /skills/project-plan/generate`(不带 project 前缀):
- 无既有 project 也能生成(向导阶段 project 未建)→ 200 结构化预览 + 落 ledger。
- 种子门控:缺 genre / logline → 422不触达网关、不 commit。
- 无凭据 → 503。
- 向导草稿序列化进网关 context种子 + 已填字段透传)。
LLM 一律 mock绝不联网。
"""
from __future__ import annotations
import os
from typing import Any
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeSession
from ww_agents import ProjectPlanResult
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode
class _CaptureGateway:
"""记录最近一次 req.input验证向导草稿进了 context返固定 ProjectPlanResult。"""
def __init__(self, parsed: ProjectPlanResult) -> None:
self._parsed = parsed
self.last_input: str | None = None
self.calls = 0
async def run(self, req: LlmRequest) -> LlmResponse:
self.calls += 1
self.last_input = req.input if isinstance(req.input, str) else str(req.input)
return LlmResponse(
text=self._parsed.model_dump_json(),
parsed=self._parsed,
usage=Usage(
provider="fake",
model="fake",
input_tokens=1,
output_tokens=1,
cost_minor=0,
currency="USD",
),
served_by=ServedBy(provider="fake", model="fake"),
)
def _plan() -> ProjectPlanResult:
return ProjectPlanResult(
title_candidates=["逐光而行", "剑试九霄"],
setting="上古仙门世界",
narrative_structure="黄金三章立钩 → 升级",
story_core="废柴逆袭封神的代价",
ending_design="归隐收束(正剧)",
tone="热血",
)
def _make_app(
*,
gateway: Any,
session: FakeSession | None = None,
no_creds: bool = False,
) -> tuple[Any, FakeSession]:
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_project_plan_gateway
from ww_db import get_session
session = session or FakeSession()
async def _ok() -> Any:
return gateway
async def _no_creds() -> Any:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
app = create_app()
app.dependency_overrides[get_project_plan_gateway] = _no_creds if no_creds else _ok
app.dependency_overrides[get_session] = lambda: session
return app, session
def _client(app: Any) -> httpx.AsyncClient:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
return httpx.AsyncClient(transport=transport, base_url="http://test")
@pytest.mark.asyncio
async def test_generate_without_existing_project() -> None:
# 向导阶段 project 未建:无 project 前缀端点直接生成,返结构化方案 + 落 ledger。
gateway = _CaptureGateway(_plan())
app, session = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post(
"/skills/project-plan/generate",
json={"genre": "仙侠", "logline": "废柴少年觉醒禁忌血脉,逆势封神。"},
)
assert resp.status_code == 200
body = resp.json()
assert body["title_candidates"] == ["逐光而行", "剑试九霄"]
assert body["story_core"] == "废柴逆袭封神的代价"
assert body["ending_design"].endswith("(正剧)")
assert body["tone"] == "热血"
assert session.commits == 1 # 预览不写业务表,但落 ledger
@pytest.mark.asyncio
async def test_generate_serializes_wizard_draft_into_context() -> None:
# 种子 + 已填字段序列化进网关 context有则贴合
gateway = _CaptureGateway(_plan())
app, _ = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post(
"/skills/project-plan/generate",
json={
"genre": "都市",
"logline": "外卖骑手觉醒读心术。",
"title": "听风者",
"tone": "轻松",
"selling_points": ["逆袭", "系统流"],
"brief": "偏悬疑向",
},
)
assert resp.status_code == 200
assert gateway.last_input is not None
assert "题材:都市" in gateway.last_input
assert "一句话故事:外卖骑手觉醒读心术。" in gateway.last_input
assert "暂定书名:听风者" in gateway.last_input
assert "核心卖点:逆袭、系统流" in gateway.last_input
assert "偏悬疑向" in gateway.last_input # brief 进 context
@pytest.mark.asyncio
async def test_requires_seed_missing_logline_422() -> None:
# 种子门控:缺 logline → 422不触达网关、不 commit。
gateway = _CaptureGateway(_plan())
app, session = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post("/skills/project-plan/generate", json={"genre": "仙侠"})
assert resp.status_code == 422
assert resp.json()["error"]["code"] == ErrorCode.VALIDATION
assert gateway.calls == 0
assert session.commits == 0
@pytest.mark.asyncio
async def test_requires_seed_missing_genre_422() -> None:
gateway = _CaptureGateway(_plan())
app, session = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post("/skills/project-plan/generate", json={"logline": "一句话故事。"})
assert resp.status_code == 422
assert gateway.calls == 0
assert session.commits == 0
@pytest.mark.asyncio
async def test_requires_seed_blank_strings_422() -> None:
# 纯空白种子也拒strip 后为空)。
gateway = _CaptureGateway(_plan())
app, session = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post(
"/skills/project-plan/generate", json={"genre": " ", "logline": " "}
)
assert resp.status_code == 422
assert gateway.calls == 0
assert session.commits == 0
@pytest.mark.asyncio
async def test_generate_no_credentials_503() -> None:
app, session = _make_app(gateway=object(), no_creds=True)
async with _client(app) as client:
resp = await client.post(
"/skills/project-plan/generate",
json={"genre": "仙侠", "logline": "一句话故事。"},
)
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0

View File

@@ -150,6 +150,54 @@ async def test_create_project_returns_201() -> None:
assert uuid.UUID(body["id"]) assert uuid.UUID(body["id"])
@pytest.mark.asyncio
async def test_create_project_persists_tone_ending_pov() -> None:
# ④ 立项新增基调/结局/视角:请求 → repo 落库 → 响应回显snake_case 契约)。
client, repo, _, _ = _make_client()
async with client:
resp = await client.post(
"/projects",
json={
"title": "基调作品",
"tone": "热血",
"ending_type": "HE",
"narrative_pov": "第一人称",
},
)
assert resp.status_code == 201
body = resp.json()
assert body["tone"] == "热血"
assert body["ending_type"] == "HE"
assert body["narrative_pov"] == "第一人称"
(_owner, view) = next(iter(repo.rows.values()))
assert view.tone == "热血"
assert view.ending_type == "HE"
assert view.narrative_pov == "第一人称"
@pytest.mark.asyncio
async def test_get_project_exposes_tone_ending_pov() -> None:
client, _, _, _ = _make_client()
async with client:
created = (
await client.post(
"/projects",
json={
"title": "",
"tone": "沉重",
"ending_type": "BE",
"narrative_pov": "多视角",
},
)
).json()
resp = await client.get(f"/projects/{created['id']}")
assert resp.status_code == 200
body = resp.json()
assert body["tone"] == "沉重"
assert body["ending_type"] == "BE"
assert body["narrative_pov"] == "多视角"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_projects() -> None: async def test_list_projects() -> None:
client, repo, _, _ = _make_client() client, repo, _, _ = _make_client()

View File

@@ -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 留痕不产 decisionsadvisory晋升照常。
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。"""

View File

@@ -26,8 +26,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import ( from ww_agents import (
CharacterCard, CharacterCard,
CharacterRelation, CharacterRelation,
ProjectPlanResult,
character_gen_spec, character_gen_spec,
continuity_spec, continuity_spec,
project_plan_spec,
worldbuilder_spec, worldbuilder_spec,
) )
from ww_core.domain import ( from ww_core.domain import (
@@ -37,8 +39,10 @@ from ww_core.domain import (
) )
from ww_core.domain.repositories import MemoryRepos from ww_core.domain.repositories import MemoryRepos
from ww_core.orchestrator import ( from ww_core.orchestrator import (
build_brief_context,
precheck_generated_cards, precheck_generated_cards,
run_character_gen, run_character_gen,
run_generator,
run_worldbuilder, run_worldbuilder,
) )
from ww_db import get_session from ww_db import get_session
@@ -64,6 +68,7 @@ from ww_api.schemas.generation import (
WorldGenerateRequest, WorldGenerateRequest,
WorldGenPreviewResponse, WorldGenPreviewResponse,
) )
from ww_api.schemas.projects import ProjectPlanGenerateRequest, ProjectPlanView
from ww_api.schemas.rules import RuleView from ww_api.schemas.rules import RuleView
from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import ( from ww_api.services.project_deps import (
@@ -71,6 +76,7 @@ from ww_api.services.project_deps import (
get_character_write_repo, get_character_write_repo,
get_memory_repos, get_memory_repos,
get_precheck_gateway, get_precheck_gateway,
get_project_plan_gateway,
get_project_repo, get_project_repo,
get_rule_write_repo, get_rule_write_repo,
get_skill_registry, get_skill_registry,
@@ -90,6 +96,7 @@ SkillRegistryDep = Annotated[SkillRegistry, Depends(get_skill_registry)]
WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)] WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)]
CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)] CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)]
PrecheckGatewayDep = Annotated[Gateway, Depends(get_precheck_gateway)] PrecheckGatewayDep = Annotated[Gateway, Depends(get_precheck_gateway)]
ProjectPlanGatewayDep = Annotated[Gateway, Depends(get_project_plan_gateway)]
SessionDep = Annotated[AsyncSession, Depends(get_session)] SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -105,6 +112,46 @@ def _project_context(title: str, genre: str | None, premise: str | None, theme:
return "\n".join(lines) return "\n".join(lines)
def _plan_seed_context(body: ProjectPlanGenerateRequest) -> str:
"""把立项向导草稿序列化为作品种子(喂 project-plan仿 `_project_context`)。
确定性、无时间戳/UUID只拼非空字段有则贴合、无则不臆造
genre + logline 由端点种子门控保证非空。
"""
lines: list[str] = []
label_fields: list[tuple[str, str | None]] = [
("暂定书名", body.title),
("题材", body.genre),
("一句话故事", body.logline),
("立意", body.premise),
("主题", body.theme),
("故事结构", body.structure),
("基调", body.tone),
("结局取向", body.ending_type),
("叙事视角", body.narrative_pov),
]
for label, value in label_fields:
text = (value or "").strip()
if text:
lines.append(f"{label}{text}")
points = [p.strip() for p in body.selling_points if p.strip()]
if points:
lines.append(f"核心卖点:{''.join(points)}")
return "\n".join(lines)
def _plan_to_view(plan: ProjectPlanResult) -> ProjectPlanView:
"""ww_agents.ProjectPlanResult → API ProjectPlanView字段同名直传"""
return ProjectPlanView(
title_candidates=list(plan.title_candidates),
setting=plan.setting,
narrative_structure=plan.narrative_structure,
story_core=plan.story_core,
ending_design=plan.ending_design,
tone=plan.tone,
)
def _render_characters_context(cards: list[CharacterCard]) -> str: def _render_characters_context(cards: list[CharacterCard]) -> str:
"""已有角色简表(喂 character-gen 防雷同 + precheck 真相源)。确定性、保序。""" """已有角色简表(喂 character-gen 防雷同 + precheck 真相源)。确定性、保序。"""
if not cards: if not cards:
@@ -148,6 +195,8 @@ async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> li
name=v.name, name=v.name,
role=v.role or "", role=v.role or "",
traits=traits, traits=traits,
appearance=v.appearance or "",
motive=v.motive or "",
backstory=v.backstory or "", backstory=v.backstory or "",
arc=arc or "", arc=arc or "",
speech_tics=tics, speech_tics=tics,
@@ -244,6 +293,7 @@ async def generate_characters(
gateway=gateway, gateway=gateway,
user_id=STUB_OWNER_ID, user_id=STUB_OWNER_ID,
project_id=project_id, project_id=project_id,
role_mix=body.role_mix,
) )
await session.commit() await session.commit()
log.info( log.info(
@@ -329,6 +379,8 @@ async def ingest_characters(
name=card.name, name=card.name,
role=card.role, role=card.role,
traits=list(card.traits), traits=list(card.traits),
appearance=card.appearance,
motive=card.motive,
backstory=card.backstory, backstory=card.backstory,
arc=card.arc, arc=card.arc,
speech_tics=list(card.speech_tics), speech_tics=list(card.speech_tics),
@@ -426,6 +478,53 @@ async def list_skills(registry: SkillRegistryDep) -> SkillListResponse:
return SkillListResponse(skills=skills) return SkillListResponse(skills=skills)
# ---- AI 立项方案生成(灵感⑤;不带 project 前缀,立项前 project 未建)----
@skills_router.post("/project-plan/generate")
async def generate_project_plan(
body: ProjectPlanGenerateRequest,
request: Request,
gateway: ProjectPlanGatewayDep,
session: SessionDep,
) -> ProjectPlanView:
"""生成结构化立项方案预览向导阶段project 未建,不入库)。
**不带 project 前缀**——绕开 toolbox 的 `project_repo.get→404`(立项前无 project
**种子门控**:缺 genre 或 logline → 422空向导不出方案防 slop。无凭据 → 503。
"""
request_id = getattr(request.state, "request_id", None)
genre = (body.genre or "").strip()
logline = (body.logline or "").strip()
if not genre or not logline:
raise AppError(
ErrorCode.VALIDATION,
"生成立项方案至少需要「题材」与「一句话故事logline」两个种子",
{"has_genre": bool(genre), "has_logline": bool(logline)},
)
project_context = _plan_seed_context(body)
context = build_brief_context(brief=body.brief or "", project_context=project_context)
parsed = await run_generator(
project_plan_spec,
context=context,
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=None, # 向导阶段 project 未建usage_ledger.project_id 可空
)
# 提交边界:预览不写业务表,但网关 ledger 只 flush → 末尾 commit 落 usage否则丢失
await session.commit()
if not isinstance(parsed, ProjectPlanResult):
# run_generator 已按 output_schema 校验;防御性收窄(不应触达)。
raise AppError(ErrorCode.INTERNAL, "立项方案生成返回类型异常")
log.info(
"project_plan_generate_done",
request_id=request_id,
title_count=len(parsed.title_candidates),
)
return _plan_to_view(parsed)
# ---- schema <-> ww_agents 卡转换 ---- # ---- schema <-> ww_agents 卡转换 ----
@@ -434,6 +533,8 @@ def _card_to_view(card: CharacterCard) -> CharacterCardView:
name=card.name, name=card.name,
role=card.role, role=card.role,
traits=list(card.traits), traits=list(card.traits),
appearance=card.appearance,
motive=card.motive,
backstory=card.backstory, backstory=card.backstory,
arc=card.arc, arc=card.arc,
speech_tics=list(card.speech_tics), speech_tics=list(card.speech_tics),
@@ -449,6 +550,8 @@ def _view_to_card(view: CharacterCardView) -> CharacterCard:
name=view.name, name=view.name,
role=view.role, role=view.role,
traits=list(view.traits), traits=list(view.traits),
appearance=view.appearance,
motive=view.motive,
backstory=view.backstory, backstory=view.backstory,
arc=view.arc, arc=view.arc,
speech_tics=list(view.speech_tics), speech_tics=list(view.speech_tics),

View File

@@ -127,6 +127,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
theme=body.theme, theme=body.theme,
selling_points=body.selling_points, selling_points=body.selling_points,
structure=body.structure, structure=body.structure,
tone=body.tone,
ending_type=body.ending_type,
narrative_pov=body.narrative_pov,
), ),
) )
log.info("project_created", project_id=str(view.id), title_len=len(view.title)) log.info("project_created", project_id=str(view.id), title_len=len(view.title))
@@ -485,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,
) )

View File

@@ -12,7 +12,7 @@ snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
from ww_api.schemas.rules import RuleView from ww_api.schemas.rules import RuleView
@@ -47,11 +47,40 @@ class WorldGenPreviewResponse(BaseModel):
class CharacterGenerateRequest(BaseModel): class CharacterGenerateRequest(BaseModel):
"""POST /projects/:id/characters/generate据需求生成角色卡预览群像防雷同""" """POST /projects/:id/characters/generate据需求生成角色卡预览群像防雷同
`role_mix`(⑦ 群像按定位配比)与 `count` 的关系:`role_mix` 在场时 `count` 取各值之和
(忽略客户端显式 count每项数量须 ≥ 1、总和须 ≤ `MAX_GENERATE_COUNT`;缺省则退回
单一 `role` + `count` 语义。
"""
brief: str = Field(min_length=1, description="角色生成需求") brief: str = Field(min_length=1, description="角色生成需求")
count: int = Field(default=1, ge=1, le=MAX_GENERATE_COUNT, description="生成数量") count: int = Field(
default=1,
ge=1,
le=MAX_GENERATE_COUNT,
description="生成数量role_mix 在场时取各值之和)",
)
role: str | None = Field(default=None, description="角色定位(主角/CP/对手/导师/工具人 等)") role: str | None = Field(default=None, description="角色定位(主角/CP/对手/导师/工具人 等)")
role_mix: dict[str, int] | None = Field(
default=None,
description="按定位配比生成 {定位: 数量};在场时 count=各值之和每项≥1、总和≤12",
)
@model_validator(mode="after")
def _reconcile_role_mix(self) -> CharacterGenerateRequest:
"""配比在场时校验并把 count 定为各值之和(构造期归一化,非外部对象变更)。"""
if self.role_mix is None:
return self
if not self.role_mix:
raise ValueError("role_mix 不能为空对象")
if any(n < 1 for n in self.role_mix.values()):
raise ValueError("role_mix 每个定位的数量须 ≥ 1")
total = sum(self.role_mix.values())
if total > MAX_GENERATE_COUNT:
raise ValueError(f"role_mix 数量总和须 ≤ {MAX_GENERATE_COUNT}")
self.count = total
return self
class CharacterRelationView(BaseModel): class CharacterRelationView(BaseModel):
@@ -67,7 +96,9 @@ class CharacterCardView(BaseModel):
name: str = Field(description="角色名") name: str = Field(description="角色名")
role: str = Field(description="角色定位") role: str = Field(description="角色定位")
traits: list[str] = Field(default_factory=list, description="性格特质清单") traits: list[str] = Field(default_factory=list, description="性格特质清单(性格三层)")
appearance: str = Field(default="", description="外貌形象(缺则空串)")
motive: str = Field(default="", description="核心动机/欲望/恐惧(缺则空串)")
backstory: str = Field(description="背景故事") backstory: str = Field(description="背景故事")
arc: str = Field(description="人物弧光(一句话)") arc: str = Field(description="人物弧光(一句话)")
speech_tics: list[str] = Field(default_factory=list, description="口癖/语言风格") speech_tics: list[str] = Field(default_factory=list, description="口癖/语言风格")

View File

@@ -22,6 +22,10 @@ class ProjectCreateRequest(BaseModel):
theme: str | None = None theme: str | None = None
selling_points: list[str] = Field(default_factory=list) selling_points: list[str] = Field(default_factory=list)
structure: str | None = None structure: str | None = None
# 立项书级常量灵感④——free text + 前端预设,无 LiteralLiteral 只留 Verdict
tone: str | None = None
ending_type: str | None = None
narrative_pov: str | None = None
class ProjectResponse(BaseModel): class ProjectResponse(BaseModel):
@@ -35,6 +39,9 @@ class ProjectResponse(BaseModel):
theme: str | None = None theme: str | None = None
selling_points: list[str] = Field(default_factory=list) selling_points: list[str] = Field(default_factory=list)
structure: str | None = None structure: str | None = None
tone: str | None = None
ending_type: str | None = None
narrative_pov: str | None = None
updated_at: datetime | None = Field(default=None, description="项目最近更新时间") updated_at: datetime | None = Field(default=None, description="项目最近更新时间")
pending_review_count: int = Field(default=0, ge=0, description="待审稿章节数") pending_review_count: int = Field(default=0, ge=0, description="待审稿章节数")
@@ -45,6 +52,64 @@ class ProjectListResponse(BaseModel):
projects: list[ProjectResponse] = Field(default_factory=list) projects: list[ProjectResponse] = Field(default_factory=list)
# ---- AI 立项方案生成(灵感⑤;向导阶段 project 未建,纯预览不写库)----
# 请求字段长度上界CR-H9防超长向导草稿撑爆上下文/成本)。短字段=标签级,长字段=段落级。
_PLAN_SHORT_MAX = 200
_PLAN_LONG_MAX = 4000
_PLAN_LIST_MAX = 20
class ProjectPlanGenerateRequest(BaseModel):
"""POST /skills/project-plan/generate向导草稿种子不带 project 前缀,立项前 project 未建)。
**种子门控**`genre` + `logline` 为必给种子(端点校验非空,否则 422——空向导不出方案
防 slop。其余字段有则贴合、无则不臆造序列化进 project_context仿 `_project_context`)。
全字段带 `max_length` 上界CR-H9`brief` = 作者一句话方向(可空)。
"""
genre: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="题材(种子)")
logline: str | None = Field(
default=None, max_length=_PLAN_LONG_MAX, description="一句话故事(种子)"
)
title: str | None = Field(
default=None, max_length=_PLAN_SHORT_MAX, description="暂定书名(可空)"
)
premise: str | None = Field(default=None, max_length=_PLAN_LONG_MAX, description="立意(可空)")
theme: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="主题(可空)")
structure: str | None = Field(
default=None, max_length=_PLAN_SHORT_MAX, description="故事结构(可空)"
)
tone: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="基调(可空)")
ending_type: str | None = Field(
default=None, max_length=_PLAN_SHORT_MAX, description="结局取向(可空)"
)
narrative_pov: str | None = Field(
default=None, max_length=_PLAN_SHORT_MAX, description="叙事视角(可空)"
)
selling_points: list[str] = Field(
default_factory=list, max_length=_PLAN_LIST_MAX, description="核心卖点(可空)"
)
# `str | None`(非 `str=""`codegen 渲染为可选字段前端种子请求可省略brief 本就可空)。
brief: str | None = Field(
default=None, max_length=_PLAN_LONG_MAX, description="作者一句话方向/需求(可空)"
)
class ProjectPlanView(BaseModel):
"""立项方案生成结果(贴 ww_agents.ProjectPlanResult结构化预览不入库
全字段有默认值(守解析韧性)——前端按字段回填立项向导,只填非空交集、不覆盖作者已编辑值。
"""
title_candidates: list[str] = Field(default_factory=list, description="书名候选清单")
setting: str = Field(default="", description="时空背景")
narrative_structure: str = Field(default="", description="叙事结构")
story_core: str = Field(default="", description="故事核心")
ending_design: str = Field(default="", description="结局设计")
tone: str = Field(default="", description="基调")
class DraftStreamRequest(BaseModel): class DraftStreamRequest(BaseModel):
"""POST /projects/:id/chapters/:no/draft本章生成的可选输入T4-b """POST /projects/:id/chapters/:no/draft本章生成的可选输入T4-b
@@ -126,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

View File

@@ -268,6 +268,14 @@ async def get_precheck_gateway(
return await build_gateway_for_tier(session, store, "analyst") return await build_gateway_for_tier(session, store, "analyst")
async def get_project_plan_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""AI 立项方案生成analyst 档位)的可注入网关缝。测试注 mock产 ProjectPlanResult"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
def get_session_factory() -> SessionFactory: def get_session_factory() -> SessionFactory:
"""验收后到期扫描的**独立 session 工厂**缝。 """验收后到期扫描的**独立 session 工厂**缝。

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { ArrowLeft, ArrowRight, Check, CheckCircle2 } from "lucide-react"; import { ArrowLeft, ArrowRight, Check, CheckCircle2, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
@@ -14,17 +15,26 @@ import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import { buttonClass } from "@/lib/ui/variants"; import { buttonClass } from "@/lib/ui/variants";
import type { ProjectPlanView } from "@/lib/api/types";
import { useProjectPlan } from "@/lib/wizard/useProjectPlan";
import { import {
ENDING_TYPES,
GENRES, GENRES,
NARRATIVE_POVS,
SELLING_POINT_PRESETS, SELLING_POINT_PRESETS,
STEP_TITLES, STEP_TITLES,
STRUCTURES, STRUCTURES,
TONES,
WIZARD_STEPS, WIZARD_STEPS,
applyPlanPatch,
canAdvance, canAdvance,
canGeneratePlan,
canSubmit, canSubmit,
clampStep, clampStep,
emptyWizardForm, emptyWizardForm,
mapPlanResultToWizardForm,
toCreateRequest, toCreateRequest,
toPlanSeedRequest,
type WizardForm, type WizardForm,
} from "@/lib/wizard/wizard"; } from "@/lib/wizard/wizard";
@@ -58,6 +68,21 @@ export function ProjectWizard() {
: [...prev.sellingPoints, point], : [...prev.sellingPoints, point],
})); }));
// AI 立项方案按字段回填:只填当前为空的字段(保护作者已编辑内容,不 bulk 覆盖)。
const applyPlan = (plan: ProjectPlanView): void => {
const patch = mapPlanResultToWizardForm(plan);
const filled = (Object.keys(patch) as Array<keyof WizardForm>).filter(
(k) => typeof form[k] === "string" && (form[k] as string).trim() === "",
);
setForm((prev) => applyPlanPatch(prev, patch));
toast(
filled.length > 0
? `已回填 ${filled.length} 个空字段(已编辑内容不覆盖)`
: "向导相关字段均已填写,未覆盖任何内容",
filled.length > 0 ? "success" : "info",
);
};
const goBack = (): void => setStep((s) => clampStep(s - 1)); const goBack = (): void => setStep((s) => clampStep(s - 1));
const goNext = (): void => setStep((s) => clampStep(s + 1)); const goNext = (): void => setStep((s) => clampStep(s + 1));
@@ -107,6 +132,7 @@ export function ProjectWizard() {
form={form} form={form}
update={update} update={update}
toggleSellingPoint={toggleSellingPoint} toggleSellingPoint={toggleSellingPoint}
onApplyPlan={applyPlan}
/> />
)} )}
{step === 3 && <StepPremise form={form} update={update} />} {step === 3 && <StepPremise form={form} update={update} />}
@@ -196,11 +222,91 @@ function StepBasics({ form, update }: StepProps) {
); );
} }
// AI 立项方案助手(灵感⑤):种子(题材+logline就绪后一键生成结构化方案 → 预览 → 按字段回填。
function PlanAssistant({
form,
onApplyPlan,
}: {
form: WizardForm;
onApplyPlan: (plan: ProjectPlanView) => void;
}) {
const { status, plan, generate } = useProjectPlan();
const ready = canGeneratePlan(form);
const generating = status === "generating";
return (
<div className="my-4 rounded border border-line bg-bg p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-sm text-ink">
<Sparkles className="h-4 w-4 text-cinnabar" aria-hidden="true" />
AI
</p>
<p className="mt-0.5 text-xs text-ink-soft">
{ready
? "根据题材 + 一句话故事推演书级蓝图,按字段回填(不覆盖已填内容)。"
: "填好「题材」(上一步)与「一句话故事」后即可生成。"}
</p>
</div>
<Button
onClick={() => void generate(toPlanSeedRequest(form))}
disabled={!ready || generating}
variant="secondary"
>
{generating ? (
<ThinkingIndicator label="生成中" />
) : (
<>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</>
)}
</Button>
</div>
{status === "done" && plan && (
<div className="mt-3 border-t border-line pt-3">
<dl className="space-y-1.5 text-sm">
{plan.title_candidates && plan.title_candidates.length > 0 && (
<PlanRow label="书名候选" value={plan.title_candidates.join("、")} />
)}
<PlanRow label="时空背景" value={plan.setting} />
<PlanRow label="叙事结构" value={plan.narrative_structure} />
<PlanRow label="故事核心" value={plan.story_core} />
<PlanRow label="结局设计" value={plan.ending_design} />
<PlanRow label="基调" value={plan.tone} />
</dl>
<div className="mt-3 flex justify-end">
<Button onClick={() => onApplyPlan(plan)} variant="primary">
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
)}
</div>
);
}
function PlanRow({ label, value }: { label: string; value: string }) {
if (!value.trim()) return null;
return (
<div className="flex gap-3">
<dt className="w-16 shrink-0 text-ink-soft">{label}</dt>
<dd className="min-w-0 flex-1 whitespace-pre-wrap text-ink">{value}</dd>
</div>
);
}
function StepStory({ function StepStory({
form, form,
update, update,
toggleSellingPoint, toggleSellingPoint,
}: StepProps & { toggleSellingPoint: (p: string) => void }) { onApplyPlan,
}: StepProps & {
toggleSellingPoint: (p: string) => void;
onApplyPlan: (plan: ProjectPlanView) => void;
}) {
return ( return (
<div> <div>
<Field label="一句话故事logline"> <Field label="一句话故事logline">
@@ -211,6 +317,7 @@ function StepStory({
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。" placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
/> />
</Field> </Field>
<PlanAssistant form={form} onApplyPlan={onApplyPlan} />
<Field label="故事结构"> <Field label="故事结构">
<SegmentedControl <SegmentedControl
ariaLabel="故事结构" ariaLabel="故事结构"
@@ -267,6 +374,37 @@ function StepPremise({ form, update }: StepProps) {
placeholder="例:抗争与代价" placeholder="例:抗争与代价"
/> />
</Field> </Field>
<Field label="基调">
<Select
value={form.tone}
onChange={(e) => update({ tone: e.target.value })}
>
<option value=""></option>
{TONES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</Select>
</Field>
<Field label="叙事视角">
<SegmentedControl
ariaLabel="叙事视角"
className="flex-wrap"
value={form.narrativePov}
onChange={(narrativePov) => update({ narrativePov })}
options={NARRATIVE_POVS.map((p) => ({ value: p, label: p }))}
/>
</Field>
<Field label="结局取向">
<SegmentedControl
ariaLabel="结局取向"
className="flex-wrap"
value={form.endingType}
onChange={(endingType) => update({ endingType })}
options={ENDING_TYPES.map((e) => ({ value: e, label: e }))}
/>
</Field>
</div> </div>
); );
} }
@@ -302,6 +440,9 @@ function StepConfirm({ form }: { form: WizardForm }) {
label: "核心卖点", label: "核心卖点",
value: form.sellingPoints.length > 0 ? form.sellingPoints.join("、") : "未选择", value: form.sellingPoints.length > 0 ? form.sellingPoints.join("、") : "未选择",
}, },
{ label: "基调", value: form.tone || "未选择" },
{ label: "叙事视角", value: form.narrativePov || "未选择" },
{ label: "结局取向", value: form.endingType || "未选择" },
]; ];
return ( return (
<div> <div>

View File

@@ -0,0 +1,149 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
Background,
BackgroundVariant,
Controls,
ReactFlow,
type Edge,
type Node,
} from "@xyflow/react";
import { Users } from "lucide-react";
// React Flow 样式必须显式引入(组件经 dynamic ssr:false 加载,样式随该 chunk 打包)。
import "@xyflow/react/dist/style.css";
import { EmptyState } from "@/components/ui/EmptyState";
import {
buildRelationshipGraph,
NODE_WIDTH,
type GraphNode,
} from "@/lib/characters/graphLayout";
import type { CharacterCardView } from "@/lib/api/types";
interface RelationshipGraphProps {
characters: CharacterCardView[];
}
// 纸感节点样式:面板底 + 细线框 + 左侧按分组配色的强调条(色值走主题 CSS 变量)。
function paperNodeStyle(colorVar: string): React.CSSProperties {
return {
width: NODE_WIDTH,
padding: "8px 10px",
borderRadius: 6,
background: "var(--color-panel)",
color: "var(--color-ink)",
border: "1px solid var(--color-line)",
borderLeft: `4px solid var(${colorVar})`,
boxShadow: "0 1px 3px var(--shadow-paper)",
fontSize: 12,
textAlign: "left",
};
}
// 节点内容:角色名(宋体)+ 定位副标题。
function NodeLabel({ name, role }: { name: string; role: string }) {
return (
<div className="min-w-0">
<p className="truncate font-serif text-sm text-ink">{name}</p>
{role ? <p className="truncate text-2xs text-ink-soft">{role}</p> : null}
</div>
);
}
function toReactFlowNodes(nodes: GraphNode[]): Node[] {
return nodes.map((node) => ({
id: node.id,
position: node.position,
data: { label: <NodeLabel name={node.data.label} role={node.data.role} /> },
style: paperNodeStyle(node.data.colorVar),
// 只读可视化:可拖动重排,不可新建连线。
connectable: false,
}));
}
function toReactFlowEdges(
edges: { id: string; source: string; target: string; label: string }[],
): Edge[] {
return edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
label: edge.label,
labelStyle: { fill: "var(--color-ink-soft)", fontSize: 11 },
labelBgStyle: { fill: "var(--color-bg)", fillOpacity: 0.9 },
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 4,
style: { stroke: "var(--color-line)", strokeWidth: 1.5 },
}));
}
// 尊重 prefers-reduced-motion减动偏好下关闭 fitView 平滑动画与边流动动画。
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduced(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
return reduced;
}
// 角色关系图谱(灵感⑥ 缩减版 / D4只用现有 relations 数据,节点按 role 上色、
// 边按 name-join、kind 作中文标签。悬空边(引用未入库角色)已在 graphLayout 略过并计数。
export function RelationshipGraph({ characters }: RelationshipGraphProps) {
const reducedMotion = usePrefersReducedMotion();
const graph = useMemo(
() => buildRelationshipGraph(characters),
[characters],
);
const nodes = useMemo(() => toReactFlowNodes(graph.nodes), [graph.nodes]);
const edges = useMemo(() => toReactFlowEdges(graph.edges), [graph.edges]);
if (graph.nodes.length === 0) {
return (
<EmptyState
icon={Users}
title="暂无可视化的人物"
description="先入库角色并登记彼此关系,这里会绘制出关系图谱。"
/>
);
}
return (
<div className="flex flex-col gap-2">
{/* React Flow 必须有显式尺寸的容器,否则画布高度为 0。 */}
<div className="h-[28rem] w-full overflow-hidden rounded border border-line bg-bg">
<ReactFlow
nodes={nodes}
edges={edges}
fitView
fitViewOptions={{ padding: 0.2, duration: reducedMotion ? 0 : 400 }}
nodesConnectable={false}
edgesFocusable={false}
proOptions={{ hideAttribution: false }}
minZoom={0.2}
maxZoom={1.75}
defaultEdgeOptions={{ animated: false }}
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="var(--color-line)"
/>
<Controls showInteractive={false} />
</ReactFlow>
</div>
{graph.danglingCount > 0 ? (
<p className="text-2xs text-ink-soft">
{graph.danglingCount}
</p>
) : null}
</div>
);
}

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Clock3, Globe2, Sparkles, UserRound } from "lucide-react"; import dynamic from "next/dynamic";
import { Clock3, Globe2, Share2, Sparkles, UserRound } from "lucide-react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { CharacterGenerator } from "@/components/generation/CharacterGenerator"; import { CharacterGenerator } from "@/components/generation/CharacterGenerator";
@@ -39,6 +40,23 @@ const TABS: { key: CodexTab; label: string }[] = [
{ key: "timeline", label: "时间线" }, { key: "timeline", label: "时间线" },
]; ];
// 关系图谱React Flow只在客户端渲染ssr:false 绕开 canvas/window 依赖,
// 并把 React Flow 及其样式拆到独立 chunk进人物 tab 才加载。
const RelationshipGraph = dynamic(
() =>
import("@/components/characters/RelationshipGraph").then(
(mod) => mod.RelationshipGraph,
),
{
ssr: false,
loading: () => (
<div className="flex h-[28rem] items-center justify-center rounded border border-line bg-bg text-sm text-ink-soft">
</div>
),
},
);
// 设定库 CodexUX §6.5):管理人物 / 世界观 / 时间线。 // 设定库 CodexUX §6.5):管理人物 / 世界观 / 时间线。
// 初始列表来自后端读端点(跨会话全量真源);本会话新入库的角色卡再合并补显, // 初始列表来自后端读端点(跨会话全量真源);本会话新入库的角色卡再合并补显,
// 故刷新后不再为空、且不与真源重复(按 name 去重,见 mergeCharacterCards // 故刷新后不再为空、且不与真源重复(按 name 去重,见 mergeCharacterCards
@@ -143,6 +161,15 @@ export function CodexPage({
/> />
)} )}
</Card> </Card>
{characters.length > 0 ? (
<Card as="section" className="p-4">
<h3 className="mb-3 flex items-center gap-2 font-serif text-sm text-ink">
<Share2 className="h-4 w-4 text-cinnabar" aria-hidden="true" />
</h3>
<RelationshipGraph characters={characters} />
</Card>
) : null}
<div id="character-generator"> <div id="character-generator">
<CharacterGenerator <CharacterGenerator
projectId={project.id} projectId={project.id}

View File

@@ -55,6 +55,40 @@ export function CharacterCardItem({
</p> </p>
) : null} ) : null}
{onEdit || card.appearance ? (
<div className="mb-1 flex items-start gap-1 text-ink-soft">
<span className="shrink-0 text-ink"></span>
{onEdit ? (
<TextInput
value={card.appearance ?? ""}
onChange={(e) => onEdit({ appearance: e.target.value })}
aria-label="外貌形象"
controlSize="sm"
className="min-w-0 flex-1"
/>
) : (
<span>{card.appearance}</span>
)}
</div>
) : null}
{onEdit || card.motive ? (
<div className="mb-1 flex items-start gap-1 text-ink-soft">
<span className="shrink-0 text-ink"></span>
{onEdit ? (
<TextInput
value={card.motive ?? ""}
onChange={(e) => onEdit({ motive: e.target.value })}
aria-label="核心动机"
controlSize="sm"
className="min-w-0 flex-1"
/>
) : (
<span>{card.motive}</span>
)}
</div>
) : null}
<p className="mb-1 text-ink-soft"> <p className="mb-1 text-ink-soft">
<span className="text-ink"></span> <span className="text-ink"></span>
{card.backstory} {card.backstory}

View File

@@ -7,6 +7,7 @@ import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field"; import { Field } from "@/components/ui/Field";
import { SectionHeader } from "@/components/ui/SectionHeader"; import { SectionHeader } from "@/components/ui/SectionHeader";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
import type { CharacterCardView } from "@/lib/api/types"; import type { CharacterCardView } from "@/lib/api/types";
@@ -14,11 +15,26 @@ import {
clampCharacterCount, clampCharacterCount,
MAX_CHARACTER_COUNT, MAX_CHARACTER_COUNT,
MIN_CHARACTER_COUNT, MIN_CHARACTER_COUNT,
roleMixTotal,
sanitizeRoleMix,
updateCard, updateCard,
} from "@/lib/generation/cards"; } from "@/lib/generation/cards";
import { useCharacterGen } from "@/lib/generation/useCharacterGen"; import { useCharacterGen } from "@/lib/generation/useCharacterGen";
import { CharacterCardItem } from "./CharacterCardItem"; import { CharacterCardItem } from "./CharacterCardItem";
import { ConflictAdjudication } from "./ConflictAdjudication"; import { ConflictAdjudication } from "./ConflictAdjudication";
import { RoleMixEditor, type RoleMixRow } from "./RoleMixEditor";
type GenMode = "single" | "mix";
const MODE_OPTIONS: Array<{ value: GenMode; label: string }> = [
{ value: "single", label: "单一定位" },
{ value: "mix", label: "按定位配比" },
];
const DEFAULT_MIX_ROWS: RoleMixRow[] = [
{ role: "主角", count: 1 },
{ role: "对手", count: 1 },
];
interface CharacterGeneratorProps { interface CharacterGeneratorProps {
projectId: string; projectId: string;
@@ -35,6 +51,10 @@ export function CharacterGenerator({
const [brief, setBrief] = useState(""); const [brief, setBrief] = useState("");
const [count, setCount] = useState(MIN_CHARACTER_COUNT); const [count, setCount] = useState(MIN_CHARACTER_COUNT);
const [role, setRole] = useState(""); const [role, setRole] = useState("");
const [mode, setMode] = useState<GenMode>("single");
const [mixRows, setMixRows] = useState<RoleMixRow[]>(DEFAULT_MIX_ROWS);
// 配比模式下的有效合计sanitize 后,已按 MAX 截断)——门控生成按钮。
const mixTotal = roleMixTotal(sanitizeRoleMix(mixRows));
// 编辑态卡片(预览返回后拷贝进本地,允许就地改)。 // 编辑态卡片(预览返回后拷贝进本地,允许就地改)。
const [drafts, setDrafts] = useState<CharacterCardView[]>([]); const [drafts, setDrafts] = useState<CharacterCardView[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set()); const [selected, setSelected] = useState<Set<number>>(new Set());
@@ -46,8 +66,17 @@ export function CharacterGenerator({
}, []); }, []);
const onGenerate = useCallback(async () => { const onGenerate = useCallback(async () => {
if (mode === "mix") {
await gen.generate({
projectId,
brief,
count,
roleMix: sanitizeRoleMix(mixRows),
});
return;
}
await gen.generate({ projectId, brief, count, role }); await gen.generate({ projectId, brief, count, role });
}, [gen, projectId, brief, count, role]); }, [gen, projectId, brief, count, role, mode, mixRows]);
// gen.cards 变化(新预览)→ 重新同步本地草稿。 // gen.cards 变化(新预览)→ 重新同步本地草稿。
const previewKey = gen.cards.map((c) => c.name).join("|"); const previewKey = gen.cards.map((c) => c.name).join("|");
@@ -96,43 +125,64 @@ export function CharacterGenerator({
placeholder="如:一个亦正亦邪的剑修,背负灭门血仇" placeholder="如:一个亦正亦邪的剑修,背负灭门血仇"
/> />
</Field> </Field>
<div className="flex flex-wrap items-end gap-3"> <div className="mb-3">
<Field <SegmentedControl
label="数量" options={MODE_OPTIONS}
className="w-28" value={mode}
help={`${MIN_CHARACTER_COUNT}${MAX_CHARACTER_COUNT}`} onChange={setMode}
> ariaLabel="生成模式"
<TextInput />
type="number"
min={MIN_CHARACTER_COUNT}
max={MAX_CHARACTER_COUNT}
value={count}
onChange={(e) =>
setCount(clampCharacterCount(Number(e.target.value)))
}
/>
</Field>
<Field label="定位(可选)" className="min-w-[12rem] flex-1">
<TextInput
value={role}
onChange={(e) => setRole(e.target.value)}
placeholder="主角 / CP / 对手 / 导师 / 工具人"
/>
</Field>
<Button
onClick={() => void onGenerate()}
disabled={
generating ||
brief.trim().length === 0 ||
count < MIN_CHARACTER_COUNT ||
count > MAX_CHARACTER_COUNT
}
variant="primary"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成"}
</Button>
</div> </div>
{mode === "mix" ? (
<Field
label="角色定位配比"
className="mb-3"
help="每个定位产出对应数量,合计即总张数"
>
<RoleMixEditor rows={mixRows} onChange={setMixRows} />
</Field>
) : (
<div className="mb-3 flex flex-wrap items-end gap-3">
<Field
label="数量"
className="w-28"
help={`${MIN_CHARACTER_COUNT}${MAX_CHARACTER_COUNT}`}
>
<TextInput
type="number"
min={MIN_CHARACTER_COUNT}
max={MAX_CHARACTER_COUNT}
value={count}
onChange={(e) =>
setCount(clampCharacterCount(Number(e.target.value)))
}
/>
</Field>
<Field label="定位(可选)" className="min-w-[12rem] flex-1">
<TextInput
value={role}
onChange={(e) => setRole(e.target.value)}
placeholder="主角 / CP / 对手 / 导师 / 工具人"
/>
</Field>
</div>
)}
<Button
onClick={() => void onGenerate()}
disabled={
generating ||
brief.trim().length === 0 ||
(mode === "mix"
? mixTotal < MIN_CHARACTER_COUNT
: count < MIN_CHARACTER_COUNT || count > MAX_CHARACTER_COUNT)
}
variant="primary"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成"}
</Button>
</div> </div>
{generating ? ( {generating ? (

View File

@@ -0,0 +1,82 @@
"use client";
import { Plus, X } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { TextInput } from "@/components/ui/TextInput";
import {
MAX_CHARACTER_COUNT,
MIN_CHARACTER_COUNT,
roleMixTotal,
sanitizeRoleMix,
} from "@/lib/generation/cards";
// 单行配比:某定位 + 该定位要生成的数量。
export interface RoleMixRow {
role: string;
count: number;
}
interface RoleMixEditorProps {
rows: RoleMixRow[];
onChange: (rows: RoleMixRow[]) => void;
}
// 群像按定位配比编辑器(⑦):一组「定位 + 数量」行;合计封顶 MAX_CHARACTER_COUNT。
// 不可变更新——每次编辑返回新数组,不改入参。
export function RoleMixEditor({ rows, onChange }: RoleMixEditorProps) {
// 有效合计 = sanitize 后的总数(已按 MAX 截断),即实际会生成的张数。
const effectiveTotal = roleMixTotal(sanitizeRoleMix(rows));
const updateRow = (index: number, patch: Partial<RoleMixRow>): void => {
onChange(rows.map((r, i) => (i === index ? { ...r, ...patch } : r)));
};
const addRow = (): void => onChange([...rows, { role: "", count: 1 }]);
const removeRow = (index: number): void =>
onChange(rows.filter((_, i) => i !== index));
return (
<div className="flex flex-col gap-2" aria-label="角色定位配比">
{rows.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<TextInput
value={row.role}
onChange={(e) => updateRow(i, { role: e.target.value })}
placeholder="定位:主角 / 对手 / 配角"
aria-label={`${i + 1} 行定位`}
className="min-w-[10rem] flex-1"
/>
<TextInput
type="number"
min={MIN_CHARACTER_COUNT}
max={MAX_CHARACTER_COUNT}
value={row.count}
onChange={(e) =>
updateRow(i, { count: Math.round(Number(e.target.value)) })
}
aria-label={`${i + 1} 行数量`}
className="w-20"
/>
<Button
variant="ghost"
size="icon"
onClick={() => removeRow(i)}
disabled={rows.length <= 1}
aria-label={`删除第 ${i + 1}`}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
))}
<div className="flex items-center justify-between">
<Button variant="outline" size="sm" onClick={addRow}>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
<span className="text-xs text-ink-soft">
{effectiveTotal} / {MAX_CHARACTER_COUNT}
</span>
</div>
</div>
);
}

View 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>
);
}

View File

@@ -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}

View File

@@ -560,6 +560,29 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/skills/project-plan/generate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Generate Project Plan
* @description 生成结构化立项方案预览向导阶段project 未建,不入库)。
*
* **不带 project 前缀**——绕开 toolbox 的 `project_repo.get→404`(立项前无 project
* **种子门控**:缺 genre 或 logline → 422空向导不出方案防 slop。无凭据 → 503。
*/
post: operations["generate_project_plan_skills_project_plan_generate_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/skills/toolbox": { "/skills/toolbox": {
parameters: { parameters: {
query?: never; query?: never;
@@ -922,9 +945,21 @@ export interface components {
role: string; role: string;
/** /**
* Traits * Traits
* @description 性格特质清单 * @description 性格特质清单(性格三层)
*/ */
traits?: string[]; traits?: string[];
/**
* Appearance
* @description 外貌形象(缺则空串)
* @default
*/
appearance: string;
/**
* Motive
* @description 核心动机/欲望/恐惧(缺则空串)
* @default
*/
motive: string;
/** /**
* Backstory * Backstory
* @description 背景故事 * @description 背景故事
@@ -962,6 +997,10 @@ export interface components {
/** /**
* CharacterGenerateRequest * CharacterGenerateRequest
* @description POST /projects/:id/characters/generate据需求生成角色卡预览群像防雷同 * @description POST /projects/:id/characters/generate据需求生成角色卡预览群像防雷同
*
* `role_mix`(⑦ 群像按定位配比)与 `count` 的关系:`role_mix` 在场时 `count` 取各值之和
* (忽略客户端显式 count每项数量须 ≥ 1、总和须 ≤ `MAX_GENERATE_COUNT`;缺省则退回
* 单一 `role` + `count` 语义。
*/ */
CharacterGenerateRequest: { CharacterGenerateRequest: {
/** /**
@@ -971,7 +1010,7 @@ export interface components {
brief: string; brief: string;
/** /**
* Count * Count
* @description 生成数量 * @description 生成数量role_mix 在场时取各值之和)
* @default 1 * @default 1
*/ */
count: number; count: number;
@@ -980,6 +1019,13 @@ export interface components {
* @description 角色定位(主角/CP/对手/导师/工具人 等) * @description 角色定位(主角/CP/对手/导师/工具人 等)
*/ */
role?: string | null; role?: string | null;
/**
* Role Mix
* @description 按定位配比生成 {定位: 数量};在场时 count=各值之和每项≥1、总和≤12
*/
role_mix?: {
[key: string]: number;
} | null;
}; };
/** /**
* CharacterIngestRequest * CharacterIngestRequest
@@ -1506,6 +1552,12 @@ export interface components {
selling_points?: string[]; selling_points?: string[];
/** Structure */ /** Structure */
structure?: string | null; structure?: string | null;
/** Tone */
tone?: string | null;
/** Ending Type */
ending_type?: string | null;
/** Narrative Pov */
narrative_pov?: string | null;
}; };
/** /**
* ProjectListResponse * ProjectListResponse
@@ -1515,6 +1567,114 @@ export interface components {
/** Projects */ /** Projects */
projects?: components["schemas"]["ProjectResponse"][]; projects?: components["schemas"]["ProjectResponse"][];
}; };
/**
* ProjectPlanGenerateRequest
* @description POST /skills/project-plan/generate向导草稿种子不带 project 前缀,立项前 project 未建)。
*
* **种子门控**`genre` + `logline` 为必给种子(端点校验非空,否则 422——空向导不出方案
* 防 slop。其余字段有则贴合、无则不臆造序列化进 project_context仿 `_project_context`)。
* 全字段带 `max_length` 上界CR-H9`brief` = 作者一句话方向(可空)。
*/
ProjectPlanGenerateRequest: {
/**
* Genre
* @description 题材(种子)
*/
genre?: string | null;
/**
* Logline
* @description 一句话故事(种子)
*/
logline?: string | null;
/**
* Title
* @description 暂定书名(可空)
*/
title?: string | null;
/**
* Premise
* @description 立意(可空)
*/
premise?: string | null;
/**
* Theme
* @description 主题(可空)
*/
theme?: string | null;
/**
* Structure
* @description 故事结构(可空)
*/
structure?: string | null;
/**
* Tone
* @description 基调(可空)
*/
tone?: string | null;
/**
* Ending Type
* @description 结局取向(可空)
*/
ending_type?: string | null;
/**
* Narrative Pov
* @description 叙事视角(可空)
*/
narrative_pov?: string | null;
/**
* Selling Points
* @description 核心卖点(可空)
*/
selling_points?: string[];
/**
* Brief
* @description 作者一句话方向/需求(可空)
*/
brief?: string | null;
};
/**
* ProjectPlanView
* @description 立项方案生成结果(贴 ww_agents.ProjectPlanResult结构化预览不入库
*
* 全字段有默认值(守解析韧性)——前端按字段回填立项向导,只填非空交集、不覆盖作者已编辑值。
*/
ProjectPlanView: {
/**
* Title Candidates
* @description 书名候选清单
*/
title_candidates?: string[];
/**
* Setting
* @description 时空背景
* @default
*/
setting: string;
/**
* Narrative Structure
* @description 叙事结构
* @default
*/
narrative_structure: string;
/**
* Story Core
* @description 故事核心
* @default
*/
story_core: string;
/**
* Ending Design
* @description 结局设计
* @default
*/
ending_design: string;
/**
* Tone
* @description 基调
* @default
*/
tone: string;
};
/** /**
* ProjectResponse * ProjectResponse
* @description 项目视图(创建/列表/详情共用)。 * @description 项目视图(创建/列表/详情共用)。
@@ -1539,6 +1699,12 @@ export interface components {
selling_points?: string[]; selling_points?: string[];
/** Structure */ /** Structure */
structure?: string | null; structure?: string | null;
/** Tone */
tone?: string | null;
/** Ending Type */
ending_type?: string | null;
/** Narrative Pov */
narrative_pov?: string | null;
/** /**
* Updated At * Updated At
* @description 项目最近更新时间 * @description 项目最近更新时间
@@ -1673,6 +1839,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 */
@@ -3336,6 +3506,39 @@ export interface operations {
}; };
}; };
}; };
generate_project_plan_skills_project_plan_generate_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ProjectPlanGenerateRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectPlanView"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_toolbox_skills_toolbox_get: { list_toolbox_skills_toolbox_get: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -4,6 +4,10 @@ import type { components } from "./schema";
export type ProjectResponse = components["schemas"]["ProjectResponse"]; export type ProjectResponse = components["schemas"]["ProjectResponse"];
export type ProjectCreateRequest = components["schemas"]["ProjectCreateRequest"]; export type ProjectCreateRequest = components["schemas"]["ProjectCreateRequest"];
export type ProjectListResponse = components["schemas"]["ProjectListResponse"]; export type ProjectListResponse = components["schemas"]["ProjectListResponse"];
// AI 立项方案生成(灵感⑤):向导阶段 project 未建,种子门控 + 结构化预览回填向导。
export type ProjectPlanGenerateRequest =
components["schemas"]["ProjectPlanGenerateRequest"];
export type ProjectPlanView = components["schemas"]["ProjectPlanView"];
export type DraftSaveRequest = components["schemas"]["DraftSaveRequest"]; export type DraftSaveRequest = components["schemas"]["DraftSaveRequest"];
export type DraftResponse = components["schemas"]["DraftResponse"]; export type DraftResponse = components["schemas"]["DraftResponse"];
// GET .../draft 读端点含正文供工作台重访时重载编辑器404→空编辑器 // GET .../draft 读端点含正文供工作台重访时重载编辑器404→空编辑器

View File

@@ -0,0 +1,258 @@
import { describe, expect, it } from "vitest";
import type { CharacterCardView } from "@/lib/api/types";
import {
buildGraphEdges,
buildGraphNodes,
buildRelationshipGraph,
countDanglingRelations,
DEFAULT_RELATION_LABEL,
layoutGraph,
roleStyle,
} from "./graphLayout";
// 最小角色卡工厂CharacterCardView 多字段必填,测试只关心 name/role/relations。
function ch(
name: string,
role: string,
relations: { name: string; kind?: string; note?: string | null }[] = [],
): CharacterCardView {
return {
name,
role,
traits: [],
appearance: "",
motive: "",
backstory: "",
arc: "",
speech_tics: [],
relations: relations.map((r) => ({
name: r.name,
kind: r.kind ?? "",
note: r.note ?? null,
})),
};
}
describe("roleStyle", () => {
it("主角关键词 → protagonist + 朱砂色", () => {
expect(roleStyle("主角")).toEqual({
group: "protagonist",
colorVar: "--color-cinnabar",
});
expect(roleStyle("男主角").group).toBe("protagonist");
});
it("反派/对手/敌 → antagonist + 冲突色", () => {
expect(roleStyle("反派").group).toBe("antagonist");
expect(roleStyle("对手").group).toBe("antagonist");
expect(roleStyle("宿敌").group).toBe("antagonist");
expect(roleStyle("反派").colorVar).toBe("--color-conflict");
});
it("导师/师父 → mentor + info 色", () => {
expect(roleStyle("导师").group).toBe("mentor");
expect(roleStyle("师父").group).toBe("mentor");
expect(roleStyle("导师").colorVar).toBe("--color-info");
});
it("CP/恋人 → ally大小写不敏感", () => {
expect(roleStyle("CP").group).toBe("ally");
expect(roleStyle("cp").group).toBe("ally");
expect(roleStyle("恋人").group).toBe("ally");
});
it("配角/工具人 → supporting", () => {
expect(roleStyle("配角").group).toBe("supporting");
expect(roleStyle("工具人").group).toBe("supporting");
});
it("未知/空定位 → other默认", () => {
expect(roleStyle("神秘存在").group).toBe("other");
expect(roleStyle("").group).toBe("other");
expect(roleStyle(" ").group).toBe("other");
});
});
describe("buildGraphNodes", () => {
it("每个唯一角色一个节点id=namerole 决定分组/配色", () => {
const nodes = buildGraphNodes([ch("林风", "主角"), ch("赵石", "对手")]);
expect(nodes).toHaveLength(2);
expect(nodes[0]).toMatchObject({
id: "林风",
position: { x: 0, y: 0 },
data: {
label: "林风",
role: "主角",
group: "protagonist",
colorVar: "--color-cinnabar",
},
});
expect(nodes[1]?.data.group).toBe("antagonist");
});
it("跳过空名与重名角色(先到先得)", () => {
const nodes = buildGraphNodes([
ch("林风", "主角"),
ch(" ", "配角"),
ch("林风", "对手"),
]);
expect(nodes).toHaveLength(1);
expect(nodes[0]?.data.role).toBe("主角");
});
it("名字两端空白被裁剪用作 id", () => {
const nodes = buildGraphNodes([ch(" 林风 ", "主角")]);
expect(nodes[0]?.id).toBe("林风");
});
});
describe("buildGraphEdges", () => {
const known = new Set(["林风", "赵石", "青禾"]);
it("kind → 边标签", () => {
const edges = buildGraphEdges(
[ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }])],
known,
);
expect(edges).toHaveLength(1);
expect(edges[0]).toMatchObject({
source: "林风",
target: "赵石",
label: "宿敌",
});
});
it("kind 为空 → 回退到命名常量默认标签", () => {
const edges = buildGraphEdges(
[ch("林风", "主角", [{ name: "青禾", kind: "" }])],
known,
);
expect(edges[0]?.label).toBe(DEFAULT_RELATION_LABEL);
});
it("悬空边(目标未入库)被跳过", () => {
const edges = buildGraphEdges(
[ch("林风", "主角", [{ name: "不存在的人", kind: "师父" }])],
known,
);
expect(edges).toHaveLength(0);
});
it("自指关系被跳过", () => {
const edges = buildGraphEdges(
[ch("林风", "主角", [{ name: "林风", kind: "内心" }])],
known,
);
expect(edges).toHaveLength(0);
});
it("互指关系无向去重为单边", () => {
const edges = buildGraphEdges(
[
ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }]),
ch("赵石", "对手", [{ name: "林风", kind: "宿敌" }]),
],
known,
);
expect(edges).toHaveLength(1);
});
it("边 id 唯一", () => {
const edges = buildGraphEdges(
[
ch("林风", "主角", [
{ name: "赵石", kind: "宿敌" },
{ name: "青禾", kind: "青梅竹马" },
]),
],
known,
);
const ids = edges.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe("countDanglingRelations", () => {
it("统计引用未入库角色的关系数", () => {
const known = new Set(["林风"]);
const count = countDanglingRelations(
[
ch("林风", "主角", [
{ name: "赵石", kind: "宿敌" }, // 悬空
{ name: "青禾", kind: "青梅竹马" }, // 悬空
]),
],
known,
);
expect(count).toBe(2);
});
it("全部已入库时为 0", () => {
const known = new Set(["林风", "赵石"]);
const count = countDanglingRelations(
[ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }])],
known,
);
expect(count).toBe(0);
});
});
describe("layoutGraph", () => {
const nodes = buildGraphNodes([ch("林风", "主角"), ch("赵石", "对手")]);
const edges = buildGraphEdges(
[ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }])],
new Set(["林风", "赵石"]),
);
it("同输入产出相同坐标(确定性)", () => {
const a = layoutGraph(nodes, edges);
const b = layoutGraph(nodes, edges);
expect(a).toEqual(b);
});
it("dagre 赋予非零坐标并保持节点数据", () => {
const laid = layoutGraph(nodes, edges);
expect(laid).toHaveLength(2);
for (const n of laid) {
expect(Number.isFinite(n.position.x)).toBe(true);
expect(Number.isFinite(n.position.y)).toBe(true);
expect(n.data.label).toBeTruthy();
}
// 两节点经布局后不再重叠于原点。
expect(laid[0]?.position).not.toEqual(laid[1]?.position);
});
it("不可变:不修改传入节点,返回新对象", () => {
const before = structuredClone(nodes);
const laid = layoutGraph(nodes, edges);
expect(nodes).toEqual(before); // 入参未被修改
expect(laid[0]).not.toBe(nodes[0]); // 新对象
});
});
describe("buildRelationshipGraph", () => {
it("端到端:布局后节点 + 有效边 + 悬空计数", () => {
const graph = buildRelationshipGraph([
ch("林风", "主角", [
{ name: "赵石", kind: "宿敌" },
{ name: "幽灵", kind: "谜团" }, // 悬空
]),
ch("赵石", "对手", [{ name: "林风", kind: "宿敌" }]),
]);
expect(graph.nodes).toHaveLength(2);
expect(graph.edges).toHaveLength(1);
expect(graph.danglingCount).toBe(1);
// 已布局(非全零)。
const positions = graph.nodes.map((n) => `${n.position.x},${n.position.y}`);
expect(new Set(positions).size).toBe(2);
});
it("空输入 → 空图", () => {
const graph = buildRelationshipGraph([]);
expect(graph.nodes).toEqual([]);
expect(graph.edges).toEqual([]);
expect(graph.danglingCount).toBe(0);
});
});

View File

@@ -0,0 +1,245 @@
import dagre from "@dagrejs/dagre";
import type { CharacterCardView } from "@/lib/api/types";
// 关系图布局纯逻辑(灵感⑥ 缩减版):把「角色 + relations」转成节点/边并跑一次性
// dagre 初始坐标。仅用现有读端点数据CharacterRelationView={name,kind,note})——
// 无 closeness/阵营/target-id节点按 role 上色,边按 name-join、kind 作中文标签。
// 无 DOM 依赖,可在 node 环境单测React Flow 组件只做渲染,不含此处逻辑。
// 节点尺寸dagre 布局与 React Flow 渲染共用;命名常量非魔法值)。
export const NODE_WIDTH = 176;
export const NODE_HEIGHT = 56;
// dagre 间距。
export const DAGRE_RANK_SEP = 88;
export const DAGRE_NODE_SEP = 48;
// kind 缺失时的关系标签回退(命名常量非魔法值)。
export const DEFAULT_RELATION_LABEL = "关系";
// 角色定位分组role 是自由中文文本,无固定枚举 → 关键词映射到有限分组)。
export type RoleGroup =
| "protagonist"
| "antagonist"
| "mentor"
| "ally"
| "supporting"
| "other";
export interface RoleStyle {
group: RoleGroup;
// 纸感主题 CSS 变量名(随亮/暗主题自适应,不写死色值)。
colorVar: string;
}
// role→分组关键词规则按序匹配首个命中生效否则回退 other。
const ROLE_RULES: ReadonlyArray<{
keywords: readonly string[];
style: RoleStyle;
}> = [
{
keywords: ["主角", "主人公", "男主", "女主"],
style: { group: "protagonist", colorVar: "--color-cinnabar" },
},
{
keywords: ["反派", "对手", "宿敌", "敌", "boss"],
style: { group: "antagonist", colorVar: "--color-conflict" },
},
{
keywords: ["导师", "师父", "师傅", "师尊", "老师"],
style: { group: "mentor", colorVar: "--color-info" },
},
{
keywords: ["cp", "恋", "爱人", "伴侣", "挚友", "知己"],
style: { group: "ally", colorVar: "--color-pass" },
},
{
keywords: ["配角", "工具人", "路人", "龙套"],
style: { group: "supporting", colorVar: "--color-ink-soft" },
},
];
const DEFAULT_ROLE_STYLE: RoleStyle = {
group: "other",
colorVar: "--color-ink-soft",
};
// 把角色定位映射到分组/配色(大小写不敏感,子串匹配)。
export function roleStyle(role: string): RoleStyle {
const normalized = role.trim().toLowerCase();
if (!normalized) {
return DEFAULT_ROLE_STYLE;
}
for (const rule of ROLE_RULES) {
if (rule.keywords.some((keyword) => normalized.includes(keyword))) {
return rule.style;
}
}
return DEFAULT_ROLE_STYLE;
}
export interface RelationNodeData {
label: string;
role: string;
group: RoleGroup;
colorVar: string;
}
export interface GraphNode {
id: string; // = 角色名name-join 主键)
position: { x: number; y: number };
data: RelationNodeData;
}
export interface GraphEdge {
id: string;
source: string;
target: string;
label: string;
}
export interface RelationshipGraphData {
nodes: GraphNode[];
edges: GraphEdge[];
// 引用未入库角色而被略过的关系条数(供 UI 提示)。
danglingCount: number;
}
// 每个唯一角色一个节点;空名/重名(先到先得)跳过。位置初始 {0,0},由 layoutGraph 赋值。
export function buildGraphNodes(
characters: readonly CharacterCardView[],
): GraphNode[] {
const seen = new Set<string>();
const nodes: GraphNode[] = [];
for (const character of characters) {
const name = character.name.trim();
if (!name || seen.has(name)) {
continue;
}
seen.add(name);
const style = roleStyle(character.role);
nodes.push({
id: name,
position: { x: 0, y: 0 },
data: {
label: name,
role: character.role,
group: style.group,
colorVar: style.colorVar,
},
});
}
return nodes;
}
// 无向对键:与方向无关,用于 A↔B 去重。
function pairKey(a: string, b: string): string {
return [a, b].sort().join("");
}
// 按 name-join 构边kind→标签悬空边目标不在 knownNames与自指跳过互指无向去重。
export function buildGraphEdges(
characters: readonly CharacterCardView[],
knownNames: ReadonlySet<string>,
): GraphEdge[] {
const edges: GraphEdge[] = [];
const seenPairs = new Set<string>();
let index = 0;
for (const character of characters) {
const source = character.name.trim();
if (!source || !knownNames.has(source)) {
continue;
}
for (const relation of character.relations ?? []) {
const target = relation.name.trim();
if (!target || !knownNames.has(target) || target === source) {
continue;
}
const key = pairKey(source, target);
if (seenPairs.has(key)) {
continue;
}
seenPairs.add(key);
const label = relation.kind.trim() || DEFAULT_RELATION_LABEL;
edges.push({
id: `e-${index}-${source}-${target}`,
source,
target,
label,
});
index += 1;
}
}
return edges;
}
// 统计引用未入库角色(悬空)的关系条数——供 UI「N 条关系已略过」提示。
export function countDanglingRelations(
characters: readonly CharacterCardView[],
knownNames: ReadonlySet<string>,
): number {
let count = 0;
for (const character of characters) {
const source = character.name.trim();
if (!source || !knownNames.has(source)) {
continue;
}
for (const relation of character.relations ?? []) {
const target = relation.name.trim();
if (target && target !== source && !knownNames.has(target)) {
count += 1;
}
}
}
return count;
}
interface LaidOutNode {
x: number;
y: number;
}
// 跑一次性 dagre 布局确定性同输入同坐标。dagre 给中心坐标 → 转 React Flow 左上角。
// 不可变:返回新节点数组,不修改入参。
export function layoutGraph(
nodes: readonly GraphNode[],
edges: readonly GraphEdge[],
): GraphNode[] {
const graph = new dagre.graphlib.Graph();
graph.setGraph({
rankdir: "LR",
nodesep: DAGRE_NODE_SEP,
ranksep: DAGRE_RANK_SEP,
});
graph.setDefaultEdgeLabel(() => ({}));
for (const node of nodes) {
graph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
}
for (const edge of edges) {
graph.setEdge(edge.source, edge.target);
}
dagre.layout(graph);
return nodes.map((node) => {
const laid = graph.node(node.id) as LaidOutNode | undefined;
const centerX = laid?.x ?? 0;
const centerY = laid?.y ?? 0;
return {
...node,
position: {
x: centerX - NODE_WIDTH / 2,
y: centerY - NODE_HEIGHT / 2,
},
};
});
}
// 端到端:角色列表 → 布局后的节点 + 有效边 + 悬空计数(组件里 useMemo 缓存)。
export function buildRelationshipGraph(
characters: readonly CharacterCardView[],
): RelationshipGraphData {
const nodes = buildGraphNodes(characters);
const knownNames = new Set(nodes.map((node) => node.id));
const edges = buildGraphEdges(characters, knownNames);
const danglingCount = countDanglingRelations(characters, knownNames);
const laidOut = layoutGraph(nodes, edges);
return { nodes: laidOut, edges, danglingCount };
}

View File

@@ -16,6 +16,8 @@ import {
mergeCharacterCards, mergeCharacterCards,
mergeWorldEntities, mergeWorldEntities,
MIN_CHARACTER_COUNT, MIN_CHARACTER_COUNT,
roleMixTotal,
sanitizeRoleMix,
updateCard, updateCard,
worldEntityRules, worldEntityRules,
} from "./cards"; } from "./cards";
@@ -23,6 +25,8 @@ import {
const card = (over: Partial<CharacterCardView> = {}): CharacterCardView => ({ const card = (over: Partial<CharacterCardView> = {}): CharacterCardView => ({
name: "甲", name: "甲",
role: "主角", role: "主角",
appearance: "白衣如雪",
motive: "为家族正名",
backstory: "出身寒门", backstory: "出身寒门",
arc: "由怯转勇", arc: "由怯转勇",
...over, ...over,
@@ -60,6 +64,62 @@ describe("buildCharacterGenerateRequest", () => {
role: "对手", role: "对手",
}); });
}); });
it("uses role_mix (count=sum, no single role) when a mix is supplied", () => {
expect(
buildCharacterGenerateRequest("群像", 1, "忽略", {
主角: 1,
对手: 2,
配角: 3,
}),
).toEqual({
brief: "群像",
count: 6,
role_mix: { 主角: 1, 对手: 2, 配角: 3 },
});
});
it("falls back to count/role when role_mix is empty", () => {
expect(buildCharacterGenerateRequest("群像", 4, "主角", {})).toEqual({
brief: "群像",
count: 4,
role: "主角",
});
});
});
describe("sanitizeRoleMix", () => {
it("trims roles, drops blanks, clamps counts to >=1, dedups", () => {
expect(
sanitizeRoleMix([
{ role: " 主角 ", count: 2 },
{ role: "", count: 3 }, // 空定位丢弃
{ role: "对手", count: 0 }, // clamp 到 1
{ role: "主角", count: 5 }, // 重复定位丢弃(先到先得)
]),
).toEqual({ 主角: 2, 对手: 1 });
});
it("caps the running total at MAX_CHARACTER_COUNT", () => {
const out = sanitizeRoleMix([
{ role: "主角", count: 10 },
{ role: "对手", count: 5 }, // 只剩 2 额度
{ role: "配角", count: 3 }, // 无额度,丢弃
]);
expect(out).toEqual({ 主角: 10, 对手: 2 });
expect(roleMixTotal(out)).toBe(MAX_CHARACTER_COUNT);
});
it("returns empty object when no valid rows", () => {
expect(sanitizeRoleMix([{ role: " ", count: 3 }])).toEqual({});
});
});
describe("roleMixTotal", () => {
it("sums counts", () => {
expect(roleMixTotal({ 主角: 1, 对手: 2 })).toBe(3);
expect(roleMixTotal({})).toBe(0);
});
}); });
describe("buildCharacterIngestRequest", () => { describe("buildCharacterIngestRequest", () => {
@@ -86,6 +146,18 @@ describe("updateCard", () => {
expect(original.name).toBe("甲"); expect(original.name).toBe("甲");
expect(next).not.toBe(original); expect(next).not.toBe(original);
}); });
it("patches appearance/motive without dropping the other fields", () => {
const original = card();
const next = updateCard(original, {
appearance: "玄色劲装",
motive: "复仇",
});
expect(next.appearance).toBe("玄色劲装");
expect(next.motive).toBe("复仇");
expect(next.backstory).toBe(original.backstory);
expect(original.appearance).toBe("白衣如雪"); // no mutation
});
}); });
describe("extractIngestConflicts", () => { describe("extractIngestConflicts", () => {

Binary file not shown.

View File

@@ -53,6 +53,24 @@ describe("useCharacterGen", () => {
expect(toast).not.toHaveBeenCalled(); expect(toast).not.toHaveBeenCalled();
}); });
it("按定位配比生成:请求体带 role_mix、count 取各值之和", async () => {
post.mockResolvedValue({ data: { cards: [] }, error: null });
const { result } = renderHook(() => useCharacterGen());
await act(async () => {
await result.current.generate({
projectId: "p1",
brief: "群像",
count: 1,
role: "忽略",
roleMix: { 主角: 1, 对手: 2 },
});
});
const body = post.mock.calls[0]?.[1]?.body;
expect(body.role_mix).toEqual({ 主角: 1, 对手: 2 });
expect(body.count).toBe(3);
expect(body.role).toBeUndefined();
});
it("生成成功但 cards 缺失:回退为空数组", async () => { it("生成成功但 cards 缺失:回退为空数组", async () => {
post.mockResolvedValue({ data: { cards: null }, error: null }); post.mockResolvedValue({ data: { cards: null }, error: null });
const { result } = renderHook(() => useCharacterGen()); const { result } = renderHook(() => useCharacterGen());

View File

@@ -21,6 +21,8 @@ export interface CharacterGenInput {
brief: string; brief: string;
count: number; count: number;
role?: string | null; role?: string | null;
// 群像按定位配比(⑦);在场时后端把 count 定为各值之和,忽略 count/role。
roleMix?: Readonly<Record<string, number>> | null;
} }
export interface UseCharacterGen { export interface UseCharacterGen {
@@ -50,7 +52,7 @@ export function useCharacterGen(): UseCharacterGen {
const toast = useToast(); const toast = useToast();
const generate = useCallback<UseCharacterGen["generate"]>( const generate = useCallback<UseCharacterGen["generate"]>(
async ({ projectId, brief, count, role }) => { async ({ projectId, brief, count, role, roleMix }) => {
setGenStatus("generating"); setGenStatus("generating");
setCards([]); setCards([]);
setConflicts(null); setConflicts(null);
@@ -60,7 +62,7 @@ export function useCharacterGen(): UseCharacterGen {
"/projects/{project_id}/characters/generate", "/projects/{project_id}/characters/generate",
{ {
params: { path: { project_id: projectId } }, params: { path: { project_id: projectId } },
body: buildCharacterGenerateRequest(brief, count, role), body: buildCharacterGenerateRequest(brief, count, role, roleMix),
}, },
); );
if (error || !data) { if (error || !data) {

View 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" },
]);
});
});

View File

@@ -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([]);

View File

@@ -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(
}; };
} }
// 把一条留痕的 characterizationdict收紧成人物塑造报告缺失/非 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,

View File

@@ -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":

View File

@@ -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");
}); });
}); });

View File

@@ -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;

View File

@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProjectPlanGenerateRequest } from "@/lib/api/types";
import { useProjectPlan } from "./useProjectPlan";
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
const post = vi.fn();
const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
const seed: ProjectPlanGenerateRequest = {
genre: "仙侠",
logline: "废柴逆袭封神。",
};
describe("useProjectPlan", () => {
beforeEach(() => {
post.mockReset();
toast.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("初始为 idle、无方案", () => {
const { result } = renderHook(() => useProjectPlan());
expect(result.current.status).toBe("idle");
expect(result.current.plan).toBeNull();
});
it("生成成功status 走到 done 并填充方案", async () => {
const plan = {
title_candidates: ["逐光而行"],
setting: "仙门世界",
narrative_structure: "立钩",
story_core: "逆袭",
ending_design: "归隐",
tone: "热血",
};
post.mockResolvedValue({ data: plan, error: null });
const { result } = renderHook(() => useProjectPlan());
await act(async () => {
await result.current.generate(seed);
});
expect(result.current.status).toBe("done");
expect(result.current.plan).toEqual(plan);
expect(toast).not.toHaveBeenCalled();
});
it("后端返回 errorstatus=error 且弹错误 toast", async () => {
post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
const { result } = renderHook(() => useProjectPlan());
await act(async () => {
await result.current.generate(seed);
});
expect(result.current.status).toBe("error");
expect(result.current.plan).toBeNull();
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
it("请求抛异常status=error 且弹网络异常 toast", async () => {
post.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() => useProjectPlan());
await act(async () => {
await result.current.generate(seed);
});
expect(result.current.status).toBe("error");
expect(toast).toHaveBeenCalledWith("生成请求异常,请检查网络。", "error");
});
it("reset 清回 idle 与空方案", async () => {
post.mockResolvedValue({
data: {
title_candidates: ["书"],
setting: "",
narrative_structure: "",
story_core: "",
ending_design: "",
tone: "",
},
error: null,
});
const { result } = renderHook(() => useProjectPlan());
await act(async () => {
await result.current.generate(seed);
});
act(() => result.current.reset());
expect(result.current.status).toBe("idle");
expect(result.current.plan).toBeNull();
});
});

View File

@@ -0,0 +1,59 @@
"use client";
import { useCallback, useState } from "react";
import { useToast } from "@/components/Toast";
import { api } from "@/lib/api/client";
import { generationErrorMessage } from "@/lib/generation/cards";
import type {
ProjectPlanGenerateRequest,
ProjectPlanView,
} from "@/lib/api/types";
export type ProjectPlanStatus = "idle" | "generating" | "done" | "error";
export interface UseProjectPlan {
status: ProjectPlanStatus;
plan: ProjectPlanView | null;
generate: (seed: ProjectPlanGenerateRequest) => Promise<void>;
reset: () => void;
}
// AI 立项方案生成器POST /skills/project-plan/generate → 结构化方案预览(不入库)。
// 向导阶段调用project 未建);种子门控在后端亦校验(缺 genre/logline → 422
export function useProjectPlan(): UseProjectPlan {
const [status, setStatus] = useState<ProjectPlanStatus>("idle");
const [plan, setPlan] = useState<ProjectPlanView | null>(null);
const toast = useToast();
const generate = useCallback<UseProjectPlan["generate"]>(
async (seed) => {
setStatus("generating");
setPlan(null);
try {
const { data, error } = await api.POST(
"/skills/project-plan/generate",
{ body: seed },
);
if (error || !data) {
setStatus("error");
toast(generationErrorMessage(error), "error");
return;
}
setPlan(data);
setStatus("done");
} catch {
setStatus("error");
toast("生成请求异常,请检查网络。", "error");
}
},
[toast],
);
const reset = useCallback((): void => {
setStatus("idle");
setPlan(null);
}, []);
return { status, plan, generate, reset };
}

View File

@@ -1,16 +1,31 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ProjectPlanView } from "@/lib/api/types";
import { import {
applyPlanPatch,
canAdvance, canAdvance,
canGeneratePlan,
canSubmit, canSubmit,
clampStep, clampStep,
emptyWizardForm, emptyWizardForm,
mapPlanResultToWizardForm,
STEP_TITLES, STEP_TITLES,
toCreateRequest, toCreateRequest,
toPlanSeedRequest,
WIZARD_STEPS, WIZARD_STEPS,
type WizardForm, type WizardForm,
} from "./wizard"; } from "./wizard";
const fullPlan: ProjectPlanView = {
title_candidates: ["逐光而行", "剑试九霄"],
setting: "上古仙门倾轧的洞天世界",
narrative_structure: "黄金三章立钩 → 拜师 → 宗门大比爆发",
story_core: "废柴少年觉醒禁忌血脉,逆势封神的代价",
ending_design: "登顶后归隐,自我救赎收束",
tone: "热血",
};
describe("wizard step metadata", () => { describe("wizard step metadata", () => {
it("provides one title per step", () => { it("provides one title per step", () => {
expect(STEP_TITLES).toHaveLength(WIZARD_STEPS); expect(STEP_TITLES).toHaveLength(WIZARD_STEPS);
@@ -57,6 +72,9 @@ describe("toCreateRequest", () => {
premise: " ", premise: " ",
protagonist: "", protagonist: "",
theme: "抗争", theme: "抗争",
tone: "",
endingType: "",
narrativePov: "",
}; };
expect(toCreateRequest(form)).toEqual({ expect(toCreateRequest(form)).toEqual({
title: "逐光而行", title: "逐光而行",
@@ -66,9 +84,34 @@ describe("toCreateRequest", () => {
theme: "抗争", theme: "抗争",
selling_points: ["逆袭", "系统流"], selling_points: ["逆袭", "系统流"],
structure: "三幕", structure: "三幕",
tone: null,
ending_type: null,
narrative_pov: null,
}); });
}); });
// ④ 立项新增基调/结局/视角camelCase 表单 → snake_case 请求,空值落 null。
it("maps tone, endingType, narrativePov to snake_case fields", () => {
const form: WizardForm = {
...emptyWizardForm,
title: "书",
tone: "热血",
endingType: "HE",
narrativePov: "第一人称",
};
const req = toCreateRequest(form);
expect(req.tone).toBe("热血");
expect(req.ending_type).toBe("HE");
expect(req.narrative_pov).toBe("第一人称");
});
it("nulls empty tone, endingType, narrativePov", () => {
const req = toCreateRequest({ ...emptyWizardForm, title: "书" });
expect(req.tone).toBeNull();
expect(req.ending_type).toBeNull();
expect(req.narrative_pov).toBeNull();
});
// QA H1 回归立意premise与主角/金手指protagonist是两个独立输入 // QA H1 回归立意premise与主角/金手指protagonist是两个独立输入
// 不能互相覆盖——提交时各占一段合并进 premise。 // 不能互相覆盖——提交时各占一段合并进 premise。
it("merges premise and protagonist into premise without overwriting", () => { it("merges premise and protagonist into premise without overwriting", () => {
@@ -94,6 +137,123 @@ describe("toCreateRequest", () => {
}); });
}); });
// ⑤ AI 立项方案 → 向导映射(含 ④ 字段 tone错配字段折进 premise不静默丢内容
describe("mapPlanResultToWizardForm", () => {
it("maps title candidate, structure and tone to their wizard homes", () => {
const patch = mapPlanResultToWizardForm(fullPlan);
// 书名候选 → title取首个
expect(patch.title).toBe("逐光而行");
// 叙事结构 → structure。
expect(patch.structure).toBe("黄金三章立钩 → 拜师 → 宗门大比爆发");
// ④ 字段:基调 → tone。
expect(patch.tone).toBe("热血");
});
it("folds setting and ending design into premise without dropping content", () => {
const patch = mapPlanResultToWizardForm(fullPlan);
// 故事核心 + 时空背景 + 结局设计 逐段拼进 premise无独立向导字段不静默丢
expect(patch.premise).toContain("废柴少年觉醒禁忌血脉,逆势封神的代价");
expect(patch.premise).toContain("时空背景:上古仙门倾轧的洞天世界");
expect(patch.premise).toContain("结局设计:登顶后归隐,自我救赎收束");
});
it("does not fabricate endingType or narrativePov (④ presets left for author)", () => {
// 结局取向/叙事视角是预设枚举,无法从自由文本可靠反推 → 不进 patch不臆造
const patch = mapPlanResultToWizardForm(fullPlan);
expect(patch.endingType).toBeUndefined();
expect(patch.narrativePov).toBeUndefined();
});
it("omits empty fields from the patch (parse resilience)", () => {
const empty: ProjectPlanView = {
title_candidates: [],
setting: "",
narrative_structure: "",
story_core: "",
ending_design: "",
tone: "",
};
expect(mapPlanResultToWizardForm(empty)).toEqual({});
});
});
// applyPlanPatch按字段并入、仅填空字段保护作者已编辑内容非 bulk 覆盖)。
describe("applyPlanPatch", () => {
it("fills only empty fields, preserving author-edited values", () => {
const form: WizardForm = {
...emptyWizardForm,
title: "作者已取的书名",
genre: "仙侠",
};
const patch = { title: "AI书名", structure: "三幕", tone: "热血" };
const next = applyPlanPatch(form, patch);
// 已编辑的 title 受保护、不被覆盖;空的 structure/tone 才回填。
expect(next.title).toBe("作者已取的书名");
expect(next.structure).toBe("三幕");
expect(next.tone).toBe("热血");
// 未在 patch 中的字段原样保留。
expect(next.genre).toBe("仙侠");
});
it("treats whitespace-only fields as empty (fillable)", () => {
const form: WizardForm = { ...emptyWizardForm, premise: " " };
const next = applyPlanPatch(form, { premise: "AI 立意" });
expect(next.premise).toBe("AI 立意");
});
it("returns a new object without mutating the input form", () => {
const form: WizardForm = { ...emptyWizardForm };
const next = applyPlanPatch(form, { tone: "热血" });
expect(next).not.toBe(form);
expect(form.tone).toBe("");
});
});
// canGeneratePlan种子门控题材 + logline 都非空)。
describe("canGeneratePlan", () => {
it("requires both genre and logline", () => {
expect(canGeneratePlan(emptyWizardForm)).toBe(false);
expect(
canGeneratePlan({ ...emptyWizardForm, genre: "仙侠" }),
).toBe(false);
expect(
canGeneratePlan({ ...emptyWizardForm, logline: "一句话" }),
).toBe(false);
expect(
canGeneratePlan({ ...emptyWizardForm, genre: "仙侠", logline: "一句话" }),
).toBe(true);
});
it("rejects whitespace-only seeds", () => {
expect(
canGeneratePlan({ ...emptyWizardForm, genre: " ", logline: " " }),
).toBe(false);
});
});
// toPlanSeedRequest向导草稿 → 生成请求snake_case空值省略
describe("toPlanSeedRequest", () => {
it("serializes seed and filled fields, omitting empties", () => {
const form: WizardForm = {
...emptyWizardForm,
genre: "都市",
logline: "外卖骑手觉醒读心术。",
title: "听风者",
sellingPoints: ["逆袭"],
endingType: "HE",
};
const req = toPlanSeedRequest(form);
expect(req.genre).toBe("都市");
expect(req.logline).toBe("外卖骑手觉醒读心术。");
expect(req.title).toBe("听风者");
expect(req.ending_type).toBe("HE");
expect(req.selling_points).toEqual(["逆袭"]);
// 空字段省略undefined不发空串。
expect(req.premise).toBeUndefined();
expect(req.tone).toBeUndefined();
});
});
// wizard→create 流程:归一后的请求体经 mock 客户端提交,返回新建项目 id。 // wizard→create 流程:归一后的请求体经 mock 客户端提交,返回新建项目 id。
describe("wizard create flow (mocked client)", () => { describe("wizard create flow (mocked client)", () => {
it("submits the normalized body and resolves a project id", async () => { it("submits the normalized body and resolves a project id", async () => {

View File

@@ -1,4 +1,8 @@
import type { ProjectCreateRequest } from "@/lib/api/types"; import type {
ProjectCreateRequest,
ProjectPlanGenerateRequest,
ProjectPlanView,
} from "@/lib/api/types";
// 立项向导 5 步UX §6.2)。表单状态用字符串/数组,提交时归一为 ProjectCreateRequest。 // 立项向导 5 步UX §6.2)。表单状态用字符串/数组,提交时归一为 ProjectCreateRequest。
export interface WizardForm { export interface WizardForm {
@@ -10,6 +14,10 @@ export interface WizardForm {
premise: string; premise: string;
protagonist: string; protagonist: string;
theme: string; theme: string;
// 立项书级常量(灵感④):基调 / 结局取向 / 叙事视角。
tone: string;
endingType: string;
narrativePov: string;
} }
export const WIZARD_STEPS = 5; export const WIZARD_STEPS = 5;
@@ -32,11 +40,18 @@ export const emptyWizardForm: WizardForm = {
premise: "", premise: "",
protagonist: "", protagonist: "",
theme: "", theme: "",
tone: "",
endingType: "",
narrativePov: "",
}; };
export const GENRES = ["玄幻", "仙侠", "都市", "科幻", "历史", "悬疑"]; export const GENRES = ["玄幻", "仙侠", "都市", "科幻", "历史", "悬疑"];
export const STRUCTURES = ["三幕", "故事圈", "雪花"]; export const STRUCTURES = ["三幕", "故事圈", "雪花"];
export const SELLING_POINT_PRESETS = ["逆袭", "打脸", "系统流", "双男主", "群像"]; export const SELLING_POINT_PRESETS = ["逆袭", "打脸", "系统流", "双男主", "群像"];
// 立项书级常量预设(灵感④ / D 枚举——free text 单选,命不中预设可留空。
export const TONES = ["热血", "轻松", "沉重", "治愈", "暗黑", "爽文"];
export const ENDING_TYPES = ["HE", "BE", "开放", "正剧"];
export const NARRATIVE_POVS = ["第一人称", "第三人称限知", "第三人称全知", "多视角"];
// 每一步可否前进:仅第 1 步(书名)必填,其余可空着继续(草稿式立项)。 // 每一步可否前进:仅第 1 步(书名)必填,其余可空着继续(草稿式立项)。
export function canAdvance(step: number, form: WizardForm): boolean { export function canAdvance(step: number, form: WizardForm): boolean {
@@ -55,6 +70,82 @@ export function clampStep(step: number): number {
return step; return step;
} }
// AI 立项方案 → 向导表单补丁(灵感⑤)。按字段回填:
// - 有对应向导输入的字段直接映射书名候选→title、叙事结构→structure、基调→tone
// - 无独立向导输入的字段(时空背景/结局设计)折进立意/总纲premise带标签**不静默丢内容**
// - 结局取向/叙事视角这类预设枚举无法从自由文本可靠反推 → 不臆造,留给作者手选(不进 patch
// 返回补丁而非整表覆盖,由 applyPlanPatch 按字段并入(保护已编辑字段)。
export function mapPlanResultToWizardForm(
plan: ProjectPlanView,
): Partial<WizardForm> {
const patch: Partial<WizardForm> = {};
const firstTitle = plan.title_candidates?.[0]?.trim();
if (firstTitle) patch.title = firstTitle;
const structure = plan.narrative_structure.trim();
if (structure) patch.structure = structure;
const tone = plan.tone.trim();
if (tone) patch.tone = tone;
// 立意/总纲 = 故事核心 + 无独立向导字段的时空背景/结局设计(带标签,逐段拼接不丢内容)。
const premiseParts: string[] = [];
const core = plan.story_core.trim();
if (core) premiseParts.push(core);
const setting = plan.setting.trim();
if (setting) premiseParts.push(`时空背景:${setting}`);
const ending = plan.ending_design.trim();
if (ending) premiseParts.push(`结局设计:${ending}`);
if (premiseParts.length > 0) patch.premise = premiseParts.join("\n\n");
return patch;
}
// WizardForm 中值为字符串的字段键(排除 sellingPoints: string[])——方案 patch 只回填这些。
type WizardStringKey = {
[K in keyof WizardForm]: WizardForm[K] extends string ? K : never;
}[keyof WizardForm];
// 把方案补丁按字段并入向导表单:**仅回填当前为空的字段**(保护作者已编辑内容,
// 非 bulk 覆盖)。只处理字符串字段(方案 patch 只产字符串值);空 = trim 后为空串。
export function applyPlanPatch(
form: WizardForm,
patch: Partial<WizardForm>,
): WizardForm {
const next: WizardForm = { ...form };
for (const key of Object.keys(patch) as WizardStringKey[]) {
const value = patch[key];
// 守卫短路:非字符串值(如误传数组键)直接跳过,绝不触达 .trim()。
if (typeof value === "string" && form[key].trim() === "") {
next[key] = value;
}
}
return next;
}
// AI 立项方案的种子门控:题材 + 一句话故事logline都非空才允许生成后端亦校验防 slop
export function canGeneratePlan(form: WizardForm): boolean {
return form.genre.trim().length > 0 && form.logline.trim().length > 0;
}
// 向导草稿 → 立项方案生成请求snake_case空值省略。genre+logline 为种子,其余有则贴合。
export function toPlanSeedRequest(
form: WizardForm,
): ProjectPlanGenerateRequest {
const trimOrUndef = (s: string): string | undefined => {
const v = s.trim();
return v.length > 0 ? v : undefined;
};
return {
genre: trimOrUndef(form.genre),
logline: trimOrUndef(form.logline),
title: trimOrUndef(form.title),
premise: trimOrUndef(form.premise),
theme: trimOrUndef(form.theme),
structure: trimOrUndef(form.structure),
tone: trimOrUndef(form.tone),
ending_type: trimOrUndef(form.endingType),
narrative_pov: trimOrUndef(form.narrativePov),
selling_points: form.sellingPoints,
};
}
// 归一为后端请求体snake_case空值落 null/省略)。 // 归一为后端请求体snake_case空值落 null/省略)。
export function toCreateRequest(form: WizardForm): ProjectCreateRequest { export function toCreateRequest(form: WizardForm): ProjectCreateRequest {
const trim = (s: string): string | null => { const trim = (s: string): string | null => {
@@ -75,5 +166,8 @@ export function toCreateRequest(form: WizardForm): ProjectCreateRequest {
theme: trim(form.theme), theme: trim(form.theme),
selling_points: form.sellingPoints, selling_points: form.sellingPoints,
structure: trim(form.structure), structure: trim(form.structure),
tone: trim(form.tone),
ending_type: trim(form.endingType),
narrative_pov: trim(form.narrativePov),
}; };
} }

View File

@@ -13,6 +13,8 @@
"gen:api": "node scripts/gen-api.mjs" "gen:api": "node scripts/gen-api.mjs"
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@xyflow/react": "^12.11.2",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
"next": "15.1.3", "next": "15.1.3",
"openapi-fetch": "^0.13.0", "openapi-fetch": "^0.13.0",

208
apps/web/pnpm-lock.yaml generated
View File

@@ -8,6 +8,12 @@ importers:
.: .:
dependencies: dependencies:
'@dagrejs/dagre':
specifier: ^3.0.0
version: 3.0.0
'@xyflow/react':
specifier: ^12.11.2
version: 12.11.2(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
lucide-react: lucide-react:
specifier: ^1.21.0 specifier: ^1.21.0
version: 1.21.0(react@19.0.0) version: 1.21.0(react@19.0.0)
@@ -166,6 +172,12 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
'@dagrejs/dagre@3.0.0':
resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==}
'@dagrejs/graphlib@4.0.1':
resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==}
'@emnapi/core@1.10.0': '@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -790,6 +802,24 @@ packages:
'@types/aria-query@5.0.4': '@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-drag@3.0.7':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-selection@3.0.11':
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
'@types/d3-transition@3.0.9':
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
'@types/d3-zoom@3.0.8':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -1025,6 +1055,22 @@ packages:
'@vitest/utils@2.1.9': '@vitest/utils@2.1.9':
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
'@xyflow/react@12.11.2':
resolution: {integrity: sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==}
peerDependencies:
'@types/react': '>=17'
'@types/react-dom': '>=17'
react: '>=17'
react-dom: '>=17'
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@xyflow/system@0.0.79':
resolution: {integrity: sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==}
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@@ -1236,6 +1282,9 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
classcat@5.0.5:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
client-only@0.0.1: client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@@ -1279,6 +1328,44 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
damerau-levenshtein@1.0.8: damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -2662,6 +2749,11 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -2798,6 +2890,21 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
snapshots: snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
@@ -2878,6 +2985,12 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {} '@csstools/css-tokenizer@4.0.0': {}
'@dagrejs/dagre@3.0.0':
dependencies:
'@dagrejs/graphlib': 4.0.1
'@dagrejs/graphlib@4.0.1': {}
'@emnapi/core@1.10.0': '@emnapi/core@1.10.0':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.2.1 '@emnapi/wasi-threads': 1.2.1
@@ -3322,6 +3435,27 @@ snapshots:
'@types/aria-query@5.0.4': {} '@types/aria-query@5.0.4': {}
'@types/d3-color@3.1.3': {}
'@types/d3-drag@3.0.7':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-selection@3.0.11': {}
'@types/d3-transition@3.0.9':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-zoom@3.0.8':
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@@ -3559,6 +3693,31 @@ snapshots:
loupe: 3.2.1 loupe: 3.2.1
tinyrainbow: 1.2.0 tinyrainbow: 1.2.0
'@xyflow/react@12.11.2(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@xyflow/system': 0.0.79
classcat: 5.0.5
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
zustand: 4.5.7(@types/react@19.0.0)(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.0
'@types/react-dom': 19.0.0
transitivePeerDependencies:
- immer
'@xyflow/system@0.0.79':
dependencies:
'@types/d3-drag': 3.0.7
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-zoom: 3.0.0
acorn-jsx@5.3.2(acorn@8.17.0): acorn-jsx@5.3.2(acorn@8.17.0):
dependencies: dependencies:
acorn: 8.17.0 acorn: 8.17.0
@@ -3790,6 +3949,8 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
classcat@5.0.5: {}
client-only@0.0.1: {} client-only@0.0.1: {}
color-convert@2.0.1: color-convert@2.0.1:
@@ -3831,6 +3992,42 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-selection@3.0.0: {}
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
damerau-levenshtein@1.0.8: {} damerau-levenshtein@1.0.8: {}
data-urls@7.0.0: data-urls@7.0.0:
@@ -5532,6 +5729,10 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
use-sync-external-store@1.6.0(react@19.0.0):
dependencies:
react: 19.0.0
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
vite-node@2.1.9(@types/node@22.19.21)(supports-color@10.2.2): vite-node@2.1.9(@types/node@22.19.21)(supports-color@10.2.2):
@@ -5686,3 +5887,10 @@ snapshots:
yargs-parser@21.1.1: {} yargs-parser@21.1.1: {}
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zustand@4.5.7(@types/react@19.0.0)(react@19.0.0):
dependencies:
use-sync-external-store: 1.6.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.0
react: 19.0.0

View File

@@ -4,6 +4,9 @@ allowBuilds:
esbuild: true esbuild: true
sharp: true sharp: true
unrs-resolver: true unrs-resolver: true
minimumReleaseAgeExclude:
- '@xyflow/react@12.11.2'
- '@xyflow/system@0.0.79'
onlyBuiltDependencies: onlyBuiltDependencies:
- esbuild - esbuild
- sharp - sharp

View File

@@ -137,7 +137,7 @@
### C3 扩展T5.2, 2026-06-19· 生成/入库 + 读端点(独立 `routers/generation.py`已注册 owner @backend 状态: 稳定 ### C3 扩展T5.2, 2026-06-19· 生成/入库 + 读端点(独立 `routers/generation.py`已注册 owner @backend 状态: 稳定
- snake_case改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`**T5.6 前置)。生成走**即时返回**预览→作者确认→入库M5-d非 jobs - snake_case改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`**T5.6 前置)。生成走**即时返回**预览→作者确认→入库M5-d非 jobs
- `POST /projects/{project_id}/world/generate``WorldGenerateRequest{brief:str(min1)}`**200** `WorldGenPreviewResponse{entities:[WorldEntityCardView{type:str,name:str,rules:list[str]}]}`。**预览不入库**worldbuilder writer 网关 `run_worldbuilder`);末尾 `commit()` 仅落网关 ledger。项目不存在→404无凭据→**503** `LLM_UNAVAILABLE``get_worldbuilder_gateway` dep 解析阶段拦下)。 - `POST /projects/{project_id}/world/generate``WorldGenerateRequest{brief:str(min1)}`**200** `WorldGenPreviewResponse{entities:[WorldEntityCardView{type:str,name:str,rules:list[str]}]}`。**预览不入库**worldbuilder writer 网关 `run_worldbuilder`);末尾 `commit()` 仅落网关 ledger。项目不存在→404无凭据→**503** `LLM_UNAVAILABLE``get_worldbuilder_gateway` dep 解析阶段拦下)。
- `POST /projects/{project_id}/characters/generate``CharacterGenerateRequest{brief:str(min1), count:int=1(ge1,le12), role:str|None}`**200** `CharacterGenPreviewResponse{cards:[CharacterCardView{name,role,traits:list[str],backstory,arc:str,speech_tics:list[str],tags:list[str],relations:[CharacterRelationView{name,kind,note?}]}]}`。**预览不入库**character-gen writer 网关注入已有角色防雷同generated_so_far=[]);末尾 commit 落 ledger。404/503 同上。 - `POST /projects/{project_id}/characters/generate``CharacterGenerateRequest{brief:str(min1), count:int=1(ge1,le12), role:str|None, role_mix:dict[str,int]|None}`**200** `CharacterGenPreviewResponse{cards:[CharacterCardView{name,role,traits:list[str],backstory,arc:str,speech_tics:list[str],tags:list[str],relations:[CharacterRelationView{name,kind,note?}]}]}`。**预览不入库**character-gen writer 网关注入已有角色防雷同generated_so_far=[]);末尾 commit 落 ledger。404/503 同上。**⑦ 群像按定位配比**`role_mix`{定位:数量})在场时 `count` 取各值之和(忽略客户端 count每项≥1、总和≤12`model_validator` 归一化);缺省退回单一 `role`+`count`
- `POST /projects/{project_id}/characters`(入库)← `CharacterIngestRequest{cards:[CharacterCardView](min1), acknowledge_conflicts:bool=false}`**201** `CharacterIngestResponse{created:list[str], rejected_tables:list[str]}`。**入库 gate**:① `precheck_generated_cards`(continuity 预检, analyst 网关) 比对卡 vs 世界观/已有角色真相源——有冲突且 `acknowledge_conflicts=false`**409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[{type,where,refs,suggestion}], conflict_count}`(仿 accept gate不静默入库不变量#3`acknowledge_conflicts=true` 即作者裁决放行)。② `partition_writes(character_gen_spec, {"characters":cards})` 白名单过滤(越权表→`rejected_tables` 审计 log+丢弃character-gen 只声明 writes=characters 故正常为空)。③ 写 `characters`schema list/str → DB JSONB **dict** 形变,见写侧 repo。404/503 同上。提交边界precheck 网关 ledger + 角色写侧均只 flush → 端点末尾一次 `commit()`(含冲突 409 路径也 commit 落 precheck usage - `POST /projects/{project_id}/characters`(入库)← `CharacterIngestRequest{cards:[CharacterCardView](min1), acknowledge_conflicts:bool=false}`**201** `CharacterIngestResponse{created:list[str], rejected_tables:list[str]}`。**入库 gate**:① `precheck_generated_cards`(continuity 预检, analyst 网关) 比对卡 vs 世界观/已有角色真相源——有冲突且 `acknowledge_conflicts=false`**409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[{type,where,refs,suggestion}], conflict_count}`(仿 accept gate不静默入库不变量#3`acknowledge_conflicts=true` 即作者裁决放行)。② `partition_writes(character_gen_spec, {"characters":cards})` 白名单过滤(越权表→`rejected_tables` 审计 log+丢弃character-gen 只声明 writes=characters 故正常为空)。③ 写 `characters`schema list/str → DB JSONB **dict** 形变,见写侧 repo。404/503 同上。提交边界precheck 网关 ledger + 角色写侧均只 flush → 端点末尾一次 `commit()`(含冲突 409 路径也 commit 落 precheck usage
- `GET /projects/{project_id}/rules`**200** `RuleListResponse{rules:[RuleView{level,content}]}`(规则页;复用 C5 读侧 `SqlRulesRepo.all_for_project`,不动 assemble - `GET /projects/{project_id}/rules`**200** `RuleListResponse{rules:[RuleView{level,content}]}`(规则页;复用 C5 读侧 `SqlRulesRepo.all_for_project`,不动 assemble
- `GET /skills`(独立 `skills_router`prefix `/skills`)→ **200** `SkillListResponse{skills:[SkillView{name,scope,tier,reads:list[str],writes:list[str],genre?}]}`(技能库 UI`get_skill_registry` 读 registry按 name 升序)。 - `GET /skills`(独立 `skills_router`prefix `/skills`)→ **200** `SkillListResponse{skills:[SkillView{name,scope,tier,reads:list[str],writes:list[str],genre?}]}`(技能库 UI`get_skill_registry` 读 registry按 name 升序)。
@@ -346,6 +346,16 @@
## 契约变更日志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_scorenullable 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"]`**计数三处 +12223**`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 loglinestrip 后空)→ **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"]`**计数三处 +12122**`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` hookrenderHook 测试+ `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 只留 Verdictdomain `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 步控件toneSelectpov/endingSegmentedControl+ 确认页回显。**codegen**3 字段皆 `= None` 无默认工厂 openapi-typescript 渲染为 `?: string | null` 可选。→ 影响 @frontend `pnpm gen:api`未来 AI 立项方案回填这 3 字段
- [2026-07-06] @llm/@backend C3(⑧ appearance/motive 穿线`CharacterCardView``schemas/generation.py`+ `ww_agents.CharacterCard` 各加 `appearance:str=""` + `motive:str=""`**均给默认值守解析韧性**DB `characters.appearance/motive` 早已在初始迁移此前一直 NULL——**无新迁移**)。写侧 `CharacterWriteRepo.create`/`SqlCharacterWriteRepo`create+**update backfill 分支**/`CharacterWriteFields` 同步加两列`routers/generation.py` `_card_to_view`/`_view_to_card`/`_existing_characters` 双向形变带上两字段同批**重切 motive/traits/arc 口径**动机唯一落 `motive``traits[]` 只留核心/表层/阴影三层`arc` 引用 motive `character-gen.md` regen `prompt_hashes.json` 金标准)。**codegen 注意**因带 `default`openapi-typescript 把两字段渲染为**必填**响应恒有值前端 `CharacterCardView` 字面量构造点须显式给两字段。→ 影响 @frontend `pnpm gen:api` + `CharacterCardItem` 展示/编辑两字段未来 人物塑造审查以 motive/appearance 为客观锚点
- [2026-07-06] @backend/@llm/@frontend C3(⑦ 群像按定位配比·缩减版`CharacterGenerateRequest``schemas/generation.py` `role_mix:dict[str,int]|None`——`model_validator(mode="after")` 在场时校验非空每项1总和`MAX_GENERATE_COUNT`=12并把 `count` 归一为各值之和构造期归一化忽略客户端 count缺省退回单一 `role`+`count`。**归属纠偏**`build_character_gen_context`+`run_character_gen``ww_core/orchestrator/generation_node.py`=**@llm **加末位默认 `role_mix=None` 关键字参在场时注入角色定位配比保插入序确定性无时间戳 #6/#7覆盖单一 role `routers/generation.py` 穿 `role_mix=body.role_mix``character-gen.md` 按配比分配 role纪律 regen `prompt_hashes.json` character-gen 哈希变**不进 SPECS/ bump 计数**)。前端 `cards.ts` `sanitizeRoleMix`/`roleMixTotal` + `buildCharacterGenerateRequest` 4 roleMix在场时 count=Σ、发 role_mix role`useCharacterGen` `CharacterGenInput.roleMix` 穿参 `RoleMixEditor.tsx` + `CharacterGenerator` SegmentedControl 单一/配比双模。**codegen**`role_mix` 渲染 `?: {[k:string]:number} | null` 可选。→ 影响 @frontend `pnpm gen:api`)。**无迁移**。
- [2026-06-24] @llm/@backend C6-extPrompt 外置方案A21 prompt 散文外置 `prompts/<name>.md` + `load_prompt`/`SPECS`/`SCHEMA_CATALOG`/`REVIEW_RESERVED_NAMES`@llm+ `SpecResolver` + SkillRegistry 入库守卫前移 + toolbox 桥接 `SPECS`@backend+ `.gitattributes`/wheel package-data/CI 冒烟@devops)。随后全量重写 21 prompt 内容任务对齐schema 契约/不变量保持金标准 fixture 重生成)。门禁绿ruff/format/mypy(209)/pytest(744)。→ 影响后续波次可将编排器 + apps/api 路由切 `SpecResolver` 并删 `*_spec` 导出前端零影响GeneratorTool 描述符字段不变)。 - [2026-06-24] @llm/@backend C6-extPrompt 外置方案A21 prompt 散文外置 `prompts/<name>.md` + `load_prompt`/`SPECS`/`SCHEMA_CATALOG`/`REVIEW_RESERVED_NAMES`@llm+ `SpecResolver` + SkillRegistry 入库守卫前移 + toolbox 桥接 `SPECS`@backend+ `.gitattributes`/wheel package-data/CI 冒烟@devops)。随后全量重写 21 prompt 内容任务对齐schema 契约/不变量保持金标准 fixture 重生成)。门禁绿ruff/format/mypy(209)/pytest(744)。→ 影响后续波次可将编排器 + apps/api 路由切 `SpecResolver` 并删 `*_spec` 导出前端零影响GeneratorTool 描述符字段不变)。
- [2026-06-23] @backend C-ChainC23 端点 + `schemas/chain.py` + `services/{chain_runner,chain_deps}.py` + `jobs` 零迁移复用 `status="awaiting_input"` + `JobRepo.set_awaiting`+ `ErrorCode.CONFLICT`(409) + `project_deps` `build_chain_gateway`/`get_chain_gateway`/`get_digest_gateway_builder`/`get_checkpointer_factory`OpenAPI 2 POSTGET /jobs 复用)。门禁绿ruff/format/mypy(193) 干净 + pytest 600 passed + alembic 无漂移。→ 影响 @frontendfollow-up `pnpm gen:api`)、@db/@devops C3检查点迁移)。 - [2026-06-23] @backend C-ChainC23 端点 + `schemas/chain.py` + `services/{chain_runner,chain_deps}.py` + `jobs` 零迁移复用 `status="awaiting_input"` + `JobRepo.set_awaiting`+ `ErrorCode.CONFLICT`(409) + `project_deps` `build_chain_gateway`/`get_chain_gateway`/`get_digest_gateway_builder`/`get_checkpointer_factory`OpenAPI 2 POSTGET /jobs 复用)。门禁绿ruff/format/mypy(193) 干净 + pytest 600 passed + alembic 无漂移。→ 影响 @frontendfollow-up `pnpm gen:api`)、@db/@devops C3检查点迁移)。
@@ -355,3 +365,5 @@
- [2026-06-20] @backend 再扩 C3B0 **可控版**接上条新增 **`PUT /projects/{project_id}/chapters/{chapter_no}/injection`** `InjectionOverrideRequest{pinned:[{kind,name}], excluded:[{kind,name}], recent_n:int|null(1..20)}` 返回 `InjectionResponse`同上**新增回显字段 `pinned`/`excluded`**`selected[].reasons` 可含新值 **`author_pin`**)。语义pin 强制纳入并加 `author_pin` 理由 / excluded 强制剔除**优先于 pin**/ recent_n 覆盖近况回看章数GET injection **draft 流式端点**均先读同一覆盖再 `assemble(override=...)`,故「看到的=写章用的」(不变量 #6 作者兜底)。持久化:**新表 `chapter_injection`**迁移 `ad2c4c663daf`唯一 `(project_id,chapter_no)`复用 outline 行已否决`select_relevant_entities` `pinned/excluded` frozenset 入参`assemble` `override` 关键字参 `domain/injection_repo.py``InjectionOverride`/`EntityRef`/`SqlInjectionOverrideRepo`upsert flush 端点 commit)。→ 影响 @frontend `pnpm gen:api`F1 pin/排除控件 + recent_n 步进器)。 - [2026-06-20] @backend 再扩 C3B0 **可控版**接上条新增 **`PUT /projects/{project_id}/chapters/{chapter_no}/injection`** `InjectionOverrideRequest{pinned:[{kind,name}], excluded:[{kind,name}], recent_n:int|null(1..20)}` 返回 `InjectionResponse`同上**新增回显字段 `pinned`/`excluded`**`selected[].reasons` 可含新值 **`author_pin`**)。语义pin 强制纳入并加 `author_pin` 理由 / excluded 强制剔除**优先于 pin**/ recent_n 覆盖近况回看章数GET injection **draft 流式端点**均先读同一覆盖再 `assemble(override=...)`,故「看到的=写章用的」(不变量 #6 作者兜底)。持久化:**新表 `chapter_injection`**迁移 `ad2c4c663daf`唯一 `(project_id,chapter_no)`复用 outline 行已否决`select_relevant_entities` `pinned/excluded` frozenset 入参`assemble` `override` 关键字参 `domain/injection_repo.py``InjectionOverride`/`EntityRef`/`SqlInjectionOverrideRepo`upsert flush 端点 commit)。→ 影响 @frontend `pnpm gen:api`F1 pin/排除控件 + recent_n 步进器)。
- [2026-06-18] @llm C1`Gateway.run()` 现消费 `LlmRequest.output_schema`——`OpenAICompatAdapter.complete` schema 非空时经 **instructor**(`create_with_completion(response_model=...)`) 取已校验 Pydantic 实例并填 `LlmResponse.parsed` schema `parsed is None`纯文本路径不变记账仍 **1 条 usage_ledger**/调用usage raw completion 提取)。`ProviderResult` 新增 `parsed` 字段结构化路径经可注入 `StructuredClient` Protocol fake测试不联网)。→ 影响 T2.2续审节点可直接 `gateway.run(req).parsed`)、未来所有结构化输出 Agent - [2026-06-18] @llm C1`Gateway.run()` 现消费 `LlmRequest.output_schema`——`OpenAICompatAdapter.complete` schema 非空时经 **instructor**(`create_with_completion(response_model=...)`) 取已校验 Pydantic 实例并填 `LlmResponse.parsed` schema `parsed is None`纯文本路径不变记账仍 **1 条 usage_ledger**/调用usage raw completion 提取)。`ProviderResult` 新增 `parsed` 字段结构化路径经可注入 `StructuredClient` Protocol fake测试不联网)。→ 影响 T2.2续审节点可直接 `gateway.run(req).parsed`)、未来所有结构化输出 Agent
- [2026-07-06] @frontend 前端依赖新增灵感 关系图谱缩减版 / D4**无后端契约变更 gen:api无迁移**`apps/web/package.json` `@xyflow/react@^12.11.2`React FlowMITpeer react>=17 兼容 R19+ `@dagrejs/dagre@^3.0.0`(一次性初始布局,自带 TS 类型)。`pnpm install``pnpm-workspace.yaml` 被 pnpm 自动追加 `minimumReleaseAgeExclude`@xyflow/react/@xyflow/system 供应链豁免)+ `pnpm-lock.yaml` 更新。新增 `lib/characters/graphLayout.ts`(纯逻辑:角色+relations→节点/边+dagre 布局,仅用现有 `CharacterCardView.relations={name,kind,note}`——**边按 name-join、kind 作中文标签、节点按 role 上色,无 closeness/阵营**,悬空边跳过并计数)+ `components/characters/RelationshipGraph.tsx``'use client'`,经 CodexPage `dynamic(ssr:false)` 加载,显式容器高度 + import RF CSS + prefers-reduced-motion 关 fitView 动画 + 纸感配色)。消费方:无(叶子特性,挂在设定库人物 tab

View File

@@ -2,7 +2,8 @@
"blurb": "2adba04a16707d7d9dfbb84d0a5a302ea8e946104dd8a617ff7801ed86f41605", "blurb": "2adba04a16707d7d9dfbb84d0a5a302ea8e946104dd8a617ff7801ed86f41605",
"book-title": "aa53ccf22da1d4e555c8168fc23f2f3547cc6f59e56e5df57540b7a946755e39", "book-title": "aa53ccf22da1d4e555c8168fc23f2f3547cc6f59e56e5df57540b7a946755e39",
"brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada", "brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada",
"character-gen": "61e443ab15aab8abfd4a785b32b079b304fbd2096e40541452f553c008156330", "character-gen": "e0f97a5d7bb622fdd7582cdeef85ba13b3f434b2633f4d22a8c6daeba48636bf",
"characterization": "643988cb899a66472acb3e18709f3299d2025f9bda132c774ee26c589266b51f",
"continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763", "continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763",
"continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1", "continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1",
"de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249", "de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249",
@@ -15,6 +16,7 @@
"opening": "c3b442a26dddbfac0a3c1b01ab5b726b34b656ebd640c19abd25b900a27093b5", "opening": "c3b442a26dddbfac0a3c1b01ab5b726b34b656ebd640c19abd25b900a27093b5",
"outliner": "3086ba81fe8028687bf079db2c2fb227ba5a162b7fc09210914e6f4ebd9c0d2e", "outliner": "3086ba81fe8028687bf079db2c2fb227ba5a162b7fc09210914e6f4ebd9c0d2e",
"pace": "c6a023cb93fde4879a0fb93cd28e0227694a4cf867e64e768319ee3135d82746", "pace": "c6a023cb93fde4879a0fb93cd28e0227694a4cf867e64e768319ee3135d82746",
"project-plan": "0b0afeee63c04a3dbfcf6ebc9bd4cf4f3e93a7ce5c7be15e013be23b38d97dfe",
"refiner": "65a4baa298bedce4592829c02d6a16c15a19ef6fdbb2098278f9decc8ffeb813", "refiner": "65a4baa298bedce4592829c02d6a16c15a19ef6fdbb2098278f9decc8ffeb813",
"style": "3a7b2c078b62e4e08f62af91ecfae8d9a27adc598ca60f59927c3bcd136393c5", "style": "3a7b2c078b62e4e08f62af91ecfae8d9a27adc598ca60f59927c3bcd136393c5",
"style_extract": "998c30ea0d0eab3936e1d6b319e832645eefaa7f7dd4a1c86d5c1ec9467e8b8c", "style_extract": "998c30ea0d0eab3936e1d6b319e832645eefaa7f7dd4a1c86d5c1ec9467e8b8c",

View 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"

View File

@@ -0,0 +1,43 @@
"""写章 craft 教条加载器单测(灵感①)。
`load_craft_doctrine()` import 期读盘 `prompts/write_craft.md`(仿 load_prompt
主写章不在 SPECS、不进 prompt_hashes 金标准。文件缺失应 fail-fast崩 import
"""
from __future__ import annotations
import hashlib
from ww_agents import load_craft_doctrine
def _sha256(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def test_load_craft_doctrine_returns_nonempty_text() -> None:
# Act
doctrine = load_craft_doctrine()
# Assert
assert isinstance(doctrine, str)
assert doctrine.strip()
def test_load_craft_doctrine_is_byte_stable() -> None:
# 稳定缓存前缀(不变量 #9两次读盘字节完全一致。
assert _sha256(load_craft_doctrine()) == _sha256(load_craft_doctrine())
def test_craft_doctrine_covers_core_principles() -> None:
# 品类无关内核应覆盖show-don't-tell / 对话个性化 / 反注水(按情节功能分配字数)。
doctrine = load_craft_doctrine()
assert "反注水" in doctrine or "注水" in doctrine
assert "对话" in doctrine
assert "情节功能" in doctrine
def test_craft_doctrine_is_genre_agnostic() -> None:
# 品类无关内核不应写死具体题材名(题材片段归 GENRE_CRAFT
doctrine = load_craft_doctrine()
for genre in ("玄幻", "仙侠", "都市"):
assert genre not in doctrine, f"品类无关内核不应含题材名 {genre!r}"

View File

@@ -108,6 +108,30 @@ def test_character_card_optional_collections_default_empty() -> None:
assert card.relations == [] assert card.relations == []
def test_character_card_has_appearance_motive_defaults() -> None:
# ⑧ 穿线appearance/motive 有默认值守解析韧性LLM 漏产字段不致整卡解析失败)。
card = CharacterCard.model_validate(
{"name": "", "role": "工具人", "backstory": "无名小卒", "arc": "始终如一"}
)
assert card.appearance == ""
assert card.motive == ""
def test_character_card_parses_appearance_and_motive() -> None:
card = CharacterCard.model_validate(
{
"name": "苏璃",
"role": "CP",
"backstory": "遗孤",
"arc": "由敌转盟",
"appearance": "银发赤瞳,惯着玄色劲装",
"motive": "替亡兄查清族灭真相",
}
)
assert card.appearance == "银发赤瞳,惯着玄色劲装"
assert card.motive == "替亡兄查清族灭真相"
def test_character_card_requires_core_fields() -> None: def test_character_card_requires_core_fields() -> None:
# name/role/backstory/arc 为必填 # name/role/backstory/arc 为必填
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
@@ -168,6 +192,31 @@ def test_character_gen_spec_prompt_documents_anti_duplication() -> None:
assert "防雷同" in prompt assert "防雷同" in prompt
def test_character_gen_prompt_declares_appearance_and_motive_fields() -> None:
# ⑧ 穿线:产出契约须显式声明 appearance/motive 两个新字段。
prompt = character_gen_spec.system_prompt
assert "appearance" in prompt
assert "motive" in prompt
def test_motive_not_double_written_in_traits() -> None:
# D6 重切回归:动机唯一落 `motive` 字段,`traits` 只留性格三层——
# traits 契约行须把动机/欲望/恐惧**改导向 motive**,绝不再指示折进 traits
# (否则双写→漂移→喂 ③ 人物塑造审查假信号)。
prompt = character_gen_spec.system_prompt
lines = prompt.splitlines()
traits_line = next(line for line in lines if line.lstrip().startswith("- **traits**"))
motive_line = next(line for line in lines if line.lstrip().startswith("- **motive**"))
# traits 契约行显式把动机重导向 motive 字段(而非折进 traits
assert "motive" in traits_line
# 旧的「写明核心动机」折入指示已被移除。
assert "写明核心动机" not in prompt
assert "并写明" not in traits_line
# motive 契约行承接内驱三要素。
assert "动机" in motive_line
assert "恐惧" in motive_line
# ---- 共性immutable + 是 AgentSpec ---- # ---- 共性immutable + 是 AgentSpec ----

View File

@@ -0,0 +1,97 @@
"""立项方案生成器project-plan契约测试schema + spec灵感⑤
契约测试——构造符合 schema 的 mock 响应校验字段默认值解析韧性、tier、只读权限。
project-plan`{title_candidates:[str], setting, narrative_structure, story_core,
ending_design, tone}`分析档reads=()writes=() 纯预览)。不联网、无 DB。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from ww_agents import (
SPECS,
AgentSpec,
ProjectPlanResult,
project_plan_spec,
)
# ---- ProjectPlanResult schema ----
def test_project_plan_parses_mock_response() -> None:
# Arrange模拟网关 instructor 校验后的结构化立项方案产出
mock = {
"title_candidates": ["逐光而行", "剑试九霄", "凡骨录"],
"setting": "上古仙门倾轧的洞天世界",
"narrative_structure": "黄金三章立钩 → 拜师入门 → 宗门大比爆发",
"story_core": "废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神,代价是逐渐失去人性",
"ending_design": "主角登顶却选择归隐,核心冲突以自我救赎收束(正剧)",
"tone": "热血 + 悲怆",
}
# Act
result = ProjectPlanResult.model_validate(mock)
# Assert
assert result.title_candidates == ["逐光而行", "剑试九霄", "凡骨录"]
assert result.setting == "上古仙门倾轧的洞天世界"
assert result.narrative_structure.startswith("黄金三章")
assert "逆势封神" in result.story_core
assert result.ending_design.endswith("(正剧)")
assert result.tone == "热血 + 悲怆"
def test_project_plan_all_fields_default_for_parse_resilience() -> None:
# 全字段给默认值守解析韧性LLM 漏产任一字段不致整卡解析失败(仿 StyleDriftReview 降级)。
result = ProjectPlanResult()
assert result.title_candidates == []
assert result.setting == ""
assert result.narrative_structure == ""
assert result.story_core == ""
assert result.ending_design == ""
assert result.tone == ""
def test_project_plan_partial_response_degrades_gracefully() -> None:
# 只给书名候选,其余缺失 → 缺字段降级为空串,不报错。
result = ProjectPlanResult.model_validate({"title_candidates": ["书名甲"]})
assert result.title_candidates == ["书名甲"]
assert result.story_core == ""
# ---- project_plan_spec 声明 ----
def test_project_plan_spec_is_analyst_tier() -> None:
# 不变量 #2立项方案构思用分析档只声明 tier、不写 model
assert project_plan_spec.tier == "analyst"
assert project_plan_spec.name == "project-plan"
def test_project_plan_spec_is_read_only_preview() -> None:
# 立项前 project 未建reads=()(无库可读);纯预览 writes=()(不变量 #3
assert project_plan_spec.reads == ()
assert project_plan_spec.writes == ()
def test_project_plan_spec_output_schema() -> None:
assert project_plan_spec.output_schema is ProjectPlanResult
def test_project_plan_spec_registered_in_specs() -> None:
# 注册表按 name 命中同一实例不变量SPECS[name] is *_spec
assert SPECS["project-plan"] is project_plan_spec
def test_project_plan_spec_is_immutable() -> None:
assert isinstance(project_plan_spec, AgentSpec)
with pytest.raises(ValidationError):
project_plan_spec.tier = "writer"
def test_project_plan_spec_prompt_documents_seed_discipline() -> None:
# prompt 必须强调忠于种子(题材 + logline不另起炉灶
prompt = project_plan_spec.system_prompt
assert "logline" in prompt
assert "种子" in prompt

View File

@@ -52,15 +52,21 @@ def test_load_prompt_matches_golden(name: str) -> None:
# ---- #1 注册表唯一性 ---- # ---- #1 注册表唯一性 ----
def test_specs_registry_len_is_21() -> None: def test_specs_registry_len_is_23() -> None:
assert len(SPECS) == 21 assert len(SPECS) == 23
# name 即 keydict 已去重;逐项确认 key == spec.name无错位 # name 即 keydict 已去重;逐项确认 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())
# 非 SPEC 教条 prompt主写章 craft 内核(灵感①)。它是散文教条、不是结构化产物 spec
# 故不入 SPECS/SCHEMA_CATALOG、不进 prompt_hashes 金标准——但仍是 prompts/*.md需在
# md↔spec↔catalog 一一对应校验里显式排除,否则集合等式误报。
_DOCTRINE_STEMS = {"write_craft"}
# ---- #2 md ↔ spec ↔ catalog 一一对应(覆盖 style.md / character-gen.md 连字符)---- # ---- #2 md ↔ spec ↔ catalog 一一对应(覆盖 style.md / character-gen.md 连字符)----
def test_md_spec_catalog_one_to_one() -> None: def test_md_spec_catalog_one_to_one() -> None:
md_stems = {p.stem for p in PROMPTS_DIR.glob("*.md")} md_stems = {p.stem for p in PROMPTS_DIR.glob("*.md")} - _DOCTRINE_STEMS
assert set(SPECS) == md_stems == set(SCHEMA_CATALOG) assert set(SPECS) == md_stems == set(SCHEMA_CATALOG)
# 易错连字符 / 非变量名命名显式覆盖 # 易错连字符 / 非变量名命名显式覆盖
assert "style" in md_stems and "style_drift" not in md_stems assert "style" in md_stems and "style_drift" not in md_stems

View File

@@ -5,7 +5,7 @@
from __future__ import annotations from __future__ import annotations
from .prompt_loader import PromptNotFoundError, load_prompt from .prompt_loader import PromptNotFoundError, load_craft_doctrine, load_prompt
from .schema_catalog import SCHEMA_CATALOG, output_schema_for from .schema_catalog import SCHEMA_CATALOG, output_schema_for
from .schemas import ( from .schemas import (
Blurb, Blurb,
@@ -13,6 +13,8 @@ from .schemas import (
BookTeardownResult, BookTeardownResult,
CharacterCard, CharacterCard,
CharacterGenResult, CharacterGenResult,
CharacterizationIssue,
CharacterizationReview,
CharacterRelation, CharacterRelation,
Conflict, Conflict,
ConflictType, ConflictType,
@@ -38,6 +40,7 @@ from .schemas import (
PaceIssue, PaceIssue,
PaceReview, PaceReview,
PolishResult, PolishResult,
ProjectPlanResult,
Scene, Scene,
StyleDimension, StyleDimension,
StyleDriftReview, StyleDriftReview,
@@ -56,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,
@@ -68,6 +72,7 @@ from .specs import (
opening_spec, opening_spec,
outliner_spec, outliner_spec,
pace_spec, pace_spec,
project_plan_spec,
refiner_spec, refiner_spec,
style_drift_spec, style_drift_spec,
style_extract_spec, style_extract_spec,
@@ -85,6 +90,8 @@ __all__ = [
"BookTeardownResult", "BookTeardownResult",
"CharacterCard", "CharacterCard",
"CharacterGenResult", "CharacterGenResult",
"CharacterizationIssue",
"CharacterizationReview",
"CharacterRelation", "CharacterRelation",
"Conflict", "Conflict",
"ConflictType", "ConflictType",
@@ -111,6 +118,7 @@ __all__ = [
"PaceIssue", "PaceIssue",
"PaceReview", "PaceReview",
"PolishResult", "PolishResult",
"ProjectPlanResult",
"Scene", "Scene",
"StyleDimension", "StyleDimension",
"StyleDriftReview", "StyleDriftReview",
@@ -124,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",
@@ -132,12 +141,14 @@ __all__ = [
"foreshadow_spec", "foreshadow_spec",
"glossary_spec", "glossary_spec",
"golden_finger_spec", "golden_finger_spec",
"load_craft_doctrine",
"load_prompt", "load_prompt",
"name_spec", "name_spec",
"opening_spec", "opening_spec",
"outliner_spec", "outliner_spec",
"output_schema_for", "output_schema_for",
"pace_spec", "pace_spec",
"project_plan_spec",
"refiner_spec", "refiner_spec",
"style_drift_spec", "style_drift_spec",
"style_extract_spec", "style_extract_spec",

View File

@@ -46,3 +46,18 @@ def load_prompt(name: str) -> str:
_CACHE[name] = text _CACHE[name] = text
return text return text
# 主写章 craft 教条文件名(灵感①)。它是散文教条、不是结构化产物 spec
# 不入 SPECS、不进 prompt_hashes 金标准、不参与计数断言——但复用同一读盘/规整/缓存路径,
# 保证字节稳定(不变量 #9
CRAFT_DOCTRINE_NAME: Final = "write_craft"
def load_craft_doctrine() -> str:
"""读 `prompts/write_craft.md`(品类无关写章 craft 内核)→ 规整 → 缓存 → 返回。
与 `load_prompt` 同一读盘/规整/缓存逻辑DRY文件缺失同样 fail-fast
`PromptNotFoundError`)。调用方在 import 期取常量即触发读盘。
"""
return load_prompt(CRAFT_DOCTRINE_NAME)

View File

@@ -2,14 +2,14 @@
## 你信奉的角色塑造法则 ## 你信奉的角色塑造法则
- **缺陷驱动弧光**:好角色起点必有缺点/盲区/创伤,故事让其被填平或代价显现,才有真正的「转变」。没有缺陷的完人弧光是平的——`arc` 必须由 `traits` 的阴影/恐惧推动。 - **缺陷驱动弧光**:好角色起点必有缺点/盲区/创伤,故事让其被填平或代价显现,才有真正的「转变」。没有缺陷的完人弧光是平的——`arc` 必须由 `traits` 的阴影层与 `motive` 里的恐惧推动。
- **三层性格 + 内驱动机**:性格分核心(灵魂底色)-表层(外人所见)-阴影(自己都未必承认的一面)三层,再叠加核心动机 / 欲望 / 恐惧 / 价值观。这套内驱让角色在任何情境下行为自洽。 - **三层性格内驱动机分列**:性格`traits`分核心(灵魂底色)-表层(外人所见)-阴影(自己都未必承认的一面)三层核心动机 / 欲望 / 恐惧这套**内驱单独落 `motive` 字段**,不折进 `traits`。两者分列,让角色在任何情境下行为自洽、且动机不双写
- **代入感来自具体**:用具体的经历、口癖、萌点把角色钉成「一个真实的人」,而非一串形容词。 - **代入感来自具体**:用具体的经历、口癖、萌点、外貌(`appearance`把角色钉成「一个真实的人」,而非一串形容词。
- **契合世界观硬规则**:取名风格、能力上限、出身势力都不能越过世界观的力量体系 / 势力 / 地理硬规则。 - **契合世界观硬规则**:取名风格、能力上限、出身势力都不能越过世界观的力量体系 / 势力 / 地理硬规则。
## 输入材料 ## 输入材料
- **角色需求**(一句话,如「亦正亦邪的女二、与主角有宿命纠葛、出身敌对势力」)+ 数量count+ 定位; - **角色需求**(一句话,如「亦正亦邪的女二、与主角有宿命纠葛、出身敌对势力」)+ 数量count+ 定位;若给出**角色定位配比**(各定位对应数量的清单),按配比给每个定位产出对应数量的卡、合计等于总数量;
- **世界观约束**world_entities力量体系、势力、地理硬规则——取名 / 能力须契合); - **世界观约束**world_entities力量体系、势力、地理硬规则——取名 / 能力须契合);
- **已有角色**characters现有角色卡新角色须与其区分、可与其建关系 - **已有角色**characters现有角色卡新角色须与其区分、可与其建关系
- **本批已生成卡**(同一次批量里先产出的卡——后续卡须与它们差异化,避免雷同)。 - **本批已生成卡**(同一次批量里先产出的卡——后续卡须与它们差异化,避免雷同)。
@@ -22,9 +22,11 @@
- **name**(字符串):角色名,取名风格契合世界观; - **name**(字符串):角色名,取名风格契合世界观;
- **role**(字符串):角色定位(主角 / CP / 对手 / 导师 / 工具人 等),按需求/定位分配; - **role**(字符串):角色定位(主角 / CP / 对手 / 导师 / 工具人 等),按需求/定位分配;
- **traits**(字符串列表):性格特质清单——按「核心-表层-阴影」三层展开,并写明核心动机 / 欲望 / 恐惧 / 价值观;每条独立成项 - **traits**(字符串列表):性格特质清单——**只按「核心-表层-阴影」三层展开**(核心=灵魂底色、表层=外人所见、阴影=自己都未必承认的一面);每条独立成项。**动机 / 欲望 / 恐惧不写这里,落 `motive` 字段**
- **appearance**(字符串):外貌形象——身形、五官、气质、标志性外观特征、常着装扮;喂给文风一致性让描写有据。无从设定则留白(空串),不硬编;
- **motive**(字符串):核心动机 / 欲望 / 恐惧——驱动角色行动的**唯一内驱**,是 `arc` 转变的推力;一段话写清「他到底要什么、怕什么」。无从设定则留白(空串);
- **backstory**(字符串):背景故事——出身、关键经历、创伤 / 转折点; - **backstory**(字符串):背景故事——出身、关键经历、创伤 / 转折点;
- **arc**(字符串):人物弧光——起点 → 转变 → 终点,须与剧情挂钩,且转变由 traits 里的缺陷/阴影推动; - **arc**(字符串):人物弧光——起点 → 转变 → 终点,须与剧情挂钩,且转变由 `motive` 的欲望/恐惧与 `traits`阴影推动;
- **speech_tics**(字符串列表):口癖 / 语言风格——用词偏好、口头禅;这是喂给文风一致性、让对话有辨识度的关键,每条独立成项; - **speech_tics**(字符串列表):口癖 / 语言风格——用词偏好、口头禅;这是喂给文风一致性、让对话有辨识度的关键,每条独立成项;
- **tags**(字符串列表):人设标签 / 萌点(网文专属,辅助检索与差异化),每条独立成项; - **tags**(字符串列表):人设标签 / 萌点(网文专属,辅助检索与差异化),每条独立成项;
- **relations**(对象列表):关系网——与已有 / 同批角色建边;每条边含 `name`(关系对象的角色名)+ `kind`(关系类型:宿敌 / 师徒 / CP / 同盟 / 工具人 等)+ 可选 `note`(关系说明,可缺省);无关系则为空列表。 - **relations**(对象列表):关系网——与已有 / 同批角色建边;每条边含 `name`(关系对象的角色名)+ `kind`(关系类型:宿敌 / 师徒 / CP / 同盟 / 工具人 等)+ 可选 `note`(关系说明,可缺省);无关系则为空列表。
@@ -44,15 +46,16 @@
## 落笔前自检 ## 落笔前自检
每张卡产出前,逐项确认: 每张卡产出前,逐项确认:
- 个字段是否齐全、类型正确(该列表的给列表)? - 个字段是否齐全、类型正确(该列表的给列表appearance / motive 给字符串
- `arc` 的转变是否真由 `traits` 里的阴影 / 恐惧推动,而非凭空逆转 - 动机 / 欲望 / 恐惧是否只写在 `motive`、没有回流进 `traits``traits` 只剩性格三层)
- 取名、能力、出身是否都没越过世界观硬规则 - `arc` 的转变是否真由 `motive` 的欲望 / 恐惧与 `traits` 的阴影推动,而非凭空逆转
- 取名、能力、出身、`appearance` 是否都没越过世界观硬规则?
- 与已有卡 / 本批前序卡在内核上确实**差异化**了? - 与已有卡 / 本批前序卡在内核上确实**差异化**了?
## 纪律 ## 纪律
- 你只产结构化角色卡,**不改稿、不写库、不直接互调其他 agent**——入库前由编排器追加一道 continuity 校验确认不与世界观 / 力量体系冲突§6.5 - 你只产结构化角色卡,**不改稿、不写库、不直接互调其他 agent**——入库前由编排器追加一道 continuity 校验确认不与世界观 / 力量体系冲突§6.5
- count 是几就产几张,不多不少;定位若给出则按定位分配 - count 是几就产几张,不多不少;给出**角色定位配比**时严格按配比分配——每个定位产出清单标注的数量、各定位之和等于总数量;只给单一定位则整批按该定位分配;两者皆无则按需求自行判定
- 不臆造与需求 / 世界观无关的设定,宁可留白也不硬填。 - 不臆造与需求 / 世界观无关的设定,宁可留白也不硬填。
## 示例(单卡,仅示范字段填法与质感,勿照抄设定) ## 示例(单卡,仅示范字段填法与质感,勿照抄设定)
@@ -61,7 +64,9 @@
- name苏璃敌对势力「玄霜阁」的姓氏体系冷感单字名 - name苏璃敌对势力「玄霜阁」的姓氏体系冷感单字名
- roleCP / 亦敌亦友女二 - roleCP / 亦敌亦友女二
- traits["核心:极度护短、认定的人会拼命表层:清冷毒舌、拒人千里阴影:童年被族中抛弃,恐惧再次被抛弃故先发制人地疏远他人", "核心动机:替亡兄查清族灭真相", "欲望:被真正接纳", "恐惧:信任后被背叛", "价值观:恩怨分明、私恩高于阵营"] - traits["核心:极度护短、认定的人会拼命", "表层:清冷毒舌、拒人千里", "阴影:童年被族中抛弃,先发制人地疏远他人"]
- appearance银发赤瞳身形清瘦惯着玄霜阁玄色劲装左颈一道旧疤情绪起伏时会无意识地按住它
- motive核心动机是替亡兄查清族灭真相深处渴望被真正接纳却最恐惧信任后再被背叛——恩怨分明、私恩高于阵营
- backstory玄霜阁旁支遗孤兄长死于阁主清洗被迫隐忍效忠仇敌暗中收集证据…… - backstory玄霜阁旁支遗孤兄长死于阁主清洗被迫隐忍效忠仇敌暗中收集证据……
- arc起点——以阵营之名敌视主角转变——查真相途中数次被主角所救护短的核心压过阵营立场终点——背叛玄霜阁与主角同盟却因「先发制人疏远他人」的旧伤酿成一次几乎致命的误会 - arc起点——以阵营之名敌视主角转变——查真相途中数次被主角所救护短的核心压过阵营立场终点——背叛玄霜阁与主角同盟却因「先发制人疏远他人」的旧伤酿成一次几乎致命的误会
- speech_tics["惯用反问句堵人", "说狠话前先轻笑一声「呵」", "极少称呼对方名字,用「你」保持距离"] - speech_tics["惯用反问句堵人", "说狠话前先轻笑一声「呵」", "极少称呼对方名字,用「你」保持距离"]

View 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.01.0` 的置信度——你越确信是真问题给越高;主观/边缘/见仁见智的给低值,供作者过滤(对抗 LLM 评审主观偏见)。
5. **建议而非改稿**:给出改进方向(如「让其行为回扣既定动机 X」但**绝不输出改写后的正文**。
### 塑造维度锚点aspect 取值口径)
- `动机一致性`:本章言行是否被人物卡既定动机驱动,有无无动机行动/动机断裂;
- `声音辨识度`:对白/内心是否有该角色可辨识的说话声音,还是千人一腔;
- `行为逻辑`:行为是否符合已建立的性格逻辑与处境,有无为推进剧情的强行 OOC
- `外貌一致性`:外貌/标识性描写是否与人物卡 `appearance` 冲突;
- `扁平化`:是否沦为只服务功能的工具人、缺乏立体动机与内在张力。
## 输出格式(严格 JSON仅含以下顶层字段不要多余字段或解释文字
- **`issues`**:人物塑造问题列表(数组)。逐条诊断;每个元素为对象,含:
- `character`:涉及的角色名(尽量对齐人物卡 name无法归属某角色则留空串
- `aspect`:塑造维度(取上述锚点之一或贴切的简短维度名);
- `where`:本章定位(哪一段/哪个场景体现该问题,便于作者定位);
- `quote`:从本章草稿**逐字摘录**的支撑片段(含标点,不得改写;无法逐字锚定则不要输出该条);
- `diagnosis`:诊断说明(为何此处塑造偏弱/失真,扣住动机/声音/行为逻辑等客观点);
- `suggestion`:改进建议(方向性,不含改写后的正文);
- `confidence`:置信度浮点 `0.01.0`(越确信越高,主观/边缘给低值)。
- 若全章人物塑造无明显问题,`issues` 为空数组 `[]`
## 纪律(不可违反)
- 你**只读、只报人物塑造诊断**,绝不改稿、不写库、不输出任何改写后的正文(不变量 #3);你的结论是 advisory 建议,不阻断验收。
- **显式忽略文本长度与辞藻华丽度**——只判塑造,不判文笔与篇幅。
- 每条诊断必须带逐字摘录的 `quote``confidence`,不臆造、不空泛;无逐字证据则不报。
- 无明显问题则 `issues` 为空数组;宁缺毋滥、少而准,避免维度噪声淹没作者注意力。
- 只输出上述字段的 JSON不附加旁白、不解释推理过程。

View File

@@ -0,0 +1,32 @@
你是中文网文的「立项方案师」project-plan分析档。你像一位既懂创作、又懂平台数据的资深网文主编在作者刚有一个题材 + 一句话故事的种子时,为其构思一份**结构化立项方案**,把模糊的灵感推演成可开写的书级蓝图,供作者预填立项向导。你只产结构化方案,**不改稿、不写库**(纯预览,不变量 #3)。
## 输入材料
- 作品种子向导草稿序列化文本题材、一句话故事logline为**必给种子**;可能另附暂定书名、立意、主题、故事结构、基调、结局取向、叙事视角、核心卖点等——**有则贴合,无则不臆造**。
- 作者的一句话方向/需求(可空——空则围绕种子自由构思)。
读不到的字段就当它不存在:不编造与已知种子无关的设定,宁缺毋造;已给的字段要顺着推演、不要推翻。
## 核心纪律
- **忠于种子**:方案必须从题材 + logline 生长出来,是对种子的**放大与落地**,不是另起炉灶换一个故事。
- **可开写**:每个维度都要具体到能指导后续世界观/大纲/写章,不写正确的废话(「主角很强」「结局很爽」这类无信息量表述一律不要)。
- **服务连载**:站在平台/读者视角想「黄金三章靠什么钩人、凭什么比同类更想追读、长线靠什么留存」,而非只站作者立场自嗨。
## 各维度字段要求(严格对应输出结构)
- **title_candidates**书名候选清单35 个差异化书名候选,每个都要贴题材、有记忆点、能一眼看出爽点/赛道;已给暂定书名时可保留并另给替代。避免雷同——若两个候选互换后几乎一样则重拟其一。
- **setting**(时空背景):故事发生的时代/地域/世界底层规则概述——是仙侠的洞天宗门、都市的现代都会、还是架空王朝?点明与主线强相关的设定支点,别写百科式罗列。
- **narrative_structure**(叙事结构):全书主线推进的骨架——开篇怎么立钩、主线如何升级/推进、阶段性爽点与转折大致怎么铺(可呼应作者已选的三幕/故事圈/雪花等结构,但要落到本故事的具体节拍上)。
- **story_core**(故事核心):这本书的立意与核心冲突——主角要什么、挡路的是什么、代价是什么,一句到一段说清「凭什么是一个值得追的故事」。这是立项的灵魂,务必具体。
- **ending_design**结局设计故事大致收束方向与情绪落点——主角最终抵达何处、核心冲突如何了结、留给读者什么。呼应作者已选的结局取向HE/BE/开放/正剧)时要贴合,未选时给一个与 story_core 自洽的收束设想。
- **tone**(基调):全书的情绪底色与阅读质感(热血 / 轻松 / 沉重 / 治愈 / 暗黑 / 爽文 等,或其组合描述)。呼应作者已选基调;未选时据题材与 story_core 判定。
## 输出格式(结构化)
产出一个立项方案对象,含字段 `title_candidates`(字符串数组)、`setting``narrative_structure``story_core``ending_design``tone`(均为字符串)。无从判断的字段可留空(对应结构上的可空默认),**但不要为凑字数编造与种子无关的内容**。除该结构外不输出任何解释性文字。
## 边界
- 贴合题材 + logline 种子推演,作者已给的其它字段顺着用、不推翻;
- 只产结构化立项方案,**不改稿、不写任何库**(纯预览,不变量 #3)。

View File

@@ -0,0 +1,32 @@
# 写章创作纪律(品类无关内核)
你是一位深耕中文网络小说的职业写手。下面是贯穿所有题材的创作纪律——它们是**灵活的原则**,不是逐条打勾的清单。按当下这一章真正要写的情节去取舍运用,不要机械套用、不要为了满足某条规则而牺牲叙事本身。
## 语域总纲
- 网文是**读起来快**的文体:信息密度偏低、句子偏短、段落偏碎。读者靠画面与动作往前滑,而不是靠密集的说明与铺陈。
- **重行动、轻独白**:优先用发生的事推进故事,少写大段静止的心理分析或情绪反刍。心理活动点到即止,让位于外部动作、对话与场景。
- **情节持续推进**:每个场景都要往前走一步——带来新信息、新冲突、新关系变化或新目标。避免原地打转的交代和没有推进的过场堆叠。
## show别 tell
- 把情绪**外化**为可见可感的东西:动作、姿态、生理反应、环境细节、对话里的停顿与语气,而不是直接抛出「他很愤怒」「她很紧张」这类概括标签。
- 让读者从画面里**自己推断**人物的状态,而不是由叙述者代为总结。越关键的情绪,越要用细节演出来,而不是一句话说破。
## 对话的声音
- 每个角色的说话方式要**可区分**:依据角色档案里的性格、出身、处境,赋予各自的用词习惯、句子长短、口头禅与说话节奏。
- **忌所有人一个腔调**——反派、少年、长者、市井之徒不该共用同一套措辞与句式。对话要能让读者仅凭语气就认出是谁在说。
- 对话标签(「他说」「她道」)的**位置要变化**:时而前置、时而后置、时而用一个动作 beat 代替,避免每句都以同一模式收尾。
## 反注水:按情节功能分配篇幅
- 字数**跟着情节功能走**,不是往一个固定目标硬凑。关键的冲突、转折、情绪高点要写**厚**——给足动作分解、感官细节与张力;过场、赶路、次要交代要写**薄**——一两句带过。
- 密点厚、疏点薄。**绝不为凑字数注水**:删掉重复的心理复述、冗余的形容词堆叠、无推进的对话与说明。
- 篇幅是结果而非目标——先想清楚这一章每个部分承担什么功能,再决定它值多少笔墨。
## 文字质感
- **句长错落**:长短句交替,避免连续同长度、同结构的句子带来的机械节奏。
- **避免用情绪概括收尾**:段落或章节的结尾不要落在「他心中五味杂陈」「一切都不一样了」这类抽象总结上,用具体的动作、画面或悬念收束。
- **慎用连续排比与套路化修辞**:偶一为之可以蓄势,密集堆砌则显空洞、露出 AI 腔。宁可朴素准确,不要华而不实。

View File

@@ -2,7 +2,7 @@
Pydantic 类型承载结构化解析与 `isinstance` 校验,无法从文本推导、无法运行时安全构造, Pydantic 类型承载结构化解析与 `isinstance` 校验,无法从文本推导、无法运行时安全构造,
必须留 Python。`SPECS[name].output_schema` 一律从这里派生(单向派生,避免双真相)。 必须留 Python。`SPECS[name].output_schema` 一律从这里派生(单向派生,避免双真相)。
`input_schema` 全 21 个恒为 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,
@@ -30,6 +31,7 @@ from .schemas import (
OutlineResult, OutlineResult,
PaceReview, PaceReview,
PolishResult, PolishResult,
ProjectPlanResult,
StyleDriftReview, StyleDriftReview,
StyleFingerprintResult, StyleFingerprintResult,
TitleListResult, TitleListResult,
@@ -42,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, # 唯一纯文本 writerNone 是合法值 "refiner": None, # 唯一纯文本 writerNone 是合法值
@@ -59,6 +62,7 @@ SCHEMA_CATALOG: Final[dict[str, type[BaseModel] | None]] = {
"expand": PolishResult, "expand": PolishResult,
"de-ai": DeAiResult, "de-ai": DeAiResult,
"teardown": BookTeardownResult, "teardown": BookTeardownResult,
"project-plan": ProjectPlanResult,
} }

View File

@@ -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.01.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----
@@ -285,10 +329,18 @@ class CharacterCard(BaseModel):
role: str = Field(description="角色定位(主角 / CP / 对手 / 导师 / 工具人 等)") role: str = Field(description="角色定位(主角 / CP / 对手 / 导师 / 工具人 等)")
traits: list[str] = Field( traits: list[str] = Field(
default_factory=list, default_factory=list,
description="性格特质清单(核心-表层-阴影 / 动机 / 欲望 / 恐惧", description="性格特质清单(核心-表层-阴影三层;动机/欲望/恐惧落 motive",
)
appearance: str = Field(
default="",
description="外貌形象(身形/五官/气质/标志性外观/常着装扮);缺则空串(守解析韧性)",
)
motive: str = Field(
default="",
description="核心动机/欲望/恐惧驱动行动的唯一内驱arc 由此推动);缺则空串",
) )
backstory: str = Field(description="背景故事(出身、关键经历、创伤/转折点)") backstory: str = Field(description="背景故事(出身、关键经历、创伤/转折点)")
arc: str = Field(description="人物弧光(起点 → 转变 → 终点,与剧情挂钩)") arc: str = Field(description="人物弧光(起点 → 转变 → 终点,与剧情挂钩,由 motive 推动")
speech_tics: list[str] = Field( speech_tics: list[str] = Field(
default_factory=list, default_factory=list,
description="口癖/语言风格(用词偏好、口头禅,喂给文风一致性)", description="口癖/语言风格(用词偏好、口头禅,喂给文风一致性)",
@@ -594,3 +646,31 @@ class BookTeardownResult(BaseModel):
default_factory=list, default_factory=list,
description="抓人钩子/爽点套路清单;无则空列表", description="抓人钩子/爽点套路清单;无则空列表",
) )
# ---- 立项方案生成器project-plananalyst纯预览 writes=()----
class ProjectPlanResult(BaseModel):
"""立项方案生成器结构化产出:书级蓝图,供预填立项向导(灵感⑤)。
纯预览产物——不映射任何业务表、不入库(`project_plan_spec.writes=()`,不变量 #3
**全字段给默认值守解析韧性**(仿 `StyleDriftReview` 降级范式LLM 漏产任一字段
不致整个方案解析失败——缺字段降级为空(书名候选空列表 / 文本字段空串),前端只回填
非空交集、不覆盖作者已编辑值。
"""
title_candidates: list[str] = Field(
default_factory=list,
description="书名候选清单35 个差异化候选);无则空列表",
)
setting: str = Field(default="", description="时空背景(时代/地域/世界底层规则概述);缺则空串")
narrative_structure: str = Field(
default="", description="叙事结构(主线推进骨架:立钩/升级/转折节拍);缺则空串"
)
story_core: str = Field(
default="",
description="故事核心(立意 + 核心冲突:主角要什么、挡路的是什么、代价);缺则空串",
)
ending_design: str = Field(default="", description="结局设计(收束方向与情绪落点);缺则空串")
tone: str = Field(default="", description="基调(全书情绪底色与阅读质感);缺则空串")

View File

@@ -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",
@@ -42,6 +43,7 @@ __all__ = [
"opening_spec", "opening_spec",
"outliner_spec", "outliner_spec",
"pace_spec", "pace_spec",
"project_plan_spec",
"refiner_spec", "refiner_spec",
"style_drift_spec", "style_drift_spec",
"style_extract_spec", "style_extract_spec",
@@ -118,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=(), # 只读(不变量 #3advisory 唯一落库=collect 留痕,不进 conflicts/验收 gate
scope="builtin",
)
# ---- 回炉refinerwriter 档;纯文本重写;非 Agent 流水线、非持久M4-e---- # ---- 回炉refinerwriter 档;纯文本重写;非 Agent 流水线、非持久M4-e----
refiner_spec = AgentSpec( refiner_spec = AgentSpec(
name="refiner", name="refiner",
@@ -298,6 +314,18 @@ teardown_spec = AgentSpec(
scope="builtin", scope="builtin",
) )
# ---- project-plan立项方案师分析档纯预览 writes=(),灵感⑤)----
project_plan_spec = AgentSpec(
name="project-plan",
tier="analyst", # 不变量 #2只声明档位不写 model立项方案构思用分析档
system_prompt=load_prompt("project-plan"),
input_schema=None, # 向导阶段 project 未建;材料为序列化的向导草稿(端点注入),非结构化入参
output_schema=SCHEMA_CATALOG["project-plan"],
reads=(), # 立项前 project 未建、无库可读;种子经端点序列化注入
writes=(), # 纯预览,不写库(不变量 #3
scope="builtin",
)
# 集中注册表name → spec同一实例兼容期 *_spec 与 SPECS[name] 为同对象,不变量) # 集中注册表name → spec同一实例兼容期 *_spec 与 SPECS[name] 为同对象,不变量)
# MappingProxyType只读视图运行时 `SPECS[x] = ...` / `del SPECS[x]` 抛 TypeError # MappingProxyType只读视图运行时 `SPECS[x] = ...` / `del SPECS[x]` 抛 TypeError
@@ -309,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,
@@ -326,9 +355,10 @@ _SPECS_BY_NAME: dict[str, AgentSpec] = {
expand_spec, expand_spec,
de_ai_spec, de_ai_spec,
teardown_spec, teardown_spec,
project_plan_spec,
) )
} }
assert len(_SPECS_BY_NAME) == 21, "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)
# 四审受信保留名 —— 独立显式白名单(安全边界锚在此,不依附派生集合) # 四审受信保留名 —— 独立显式白名单(安全边界锚在此,不依附派生集合)

View File

@@ -0,0 +1,47 @@
"""GENRE_CRAFT 配置单测F1 · 品类相关 craft 片段表)。
GENRE_CRAFT 是「题材 → 语域/节奏/钩子/字数区间/常见套路」的**文本片段**映射;
品类无关内核在 `write_craft.md`品类相关片段在此表。lookup 对未知题材优雅返回 None。
"""
from __future__ import annotations
from ww_config import GENRE_CRAFT, genre_craft_fragment
# 与前端 wizard.ts 的 GENRES 预设保持一致(玄幻/仙侠/都市/科幻/历史/悬疑)。
_FRONTEND_GENRES = ["玄幻", "仙侠", "都市", "科幻", "历史", "悬疑"]
def test_genre_craft_covers_frontend_presets() -> None:
# Arrange / Act / Assert前端每个预设题材都有对应片段。
for genre in _FRONTEND_GENRES:
assert genre in GENRE_CRAFT, f"题材 {genre!r} 缺 GENRE_CRAFT 片段"
def test_genre_craft_fragments_are_nonempty_text() -> None:
for genre, fragment in GENRE_CRAFT.items():
assert isinstance(fragment, str)
assert fragment.strip(), f"题材 {genre!r} 片段为空"
def test_genre_craft_fragment_returns_matching_text() -> None:
# Act
fragment = genre_craft_fragment("玄幻")
# Assert
assert fragment is not None
assert fragment == GENRE_CRAFT["玄幻"]
def test_genre_craft_fragment_unknown_returns_none() -> None:
assert genre_craft_fragment("不存在的题材") is None
def test_genre_craft_fragment_none_returns_none() -> None:
assert genre_craft_fragment(None) is None
assert genre_craft_fragment("") is None
def test_genre_fragments_mention_wordcount_dimension() -> None:
# 每个片段应含字数/篇幅维度(灵活片段,非穷举清单)。
for genre, fragment in GENRE_CRAFT.items():
assert "字数" in fragment or "篇幅" in fragment, f"{genre!r} 片段未含字数区间"

View File

@@ -1,3 +1,4 @@
from ww_config.genre_craft import GENRE_CRAFT, genre_craft_fragment
from ww_config.settings import Settings, get_settings from ww_config.settings import Settings, get_settings
__all__ = ["Settings", "get_settings"] __all__ = ["GENRE_CRAFT", "Settings", "genre_craft_fragment", "get_settings"]

View File

@@ -0,0 +1,67 @@
"""品类相关 craft 配置(灵感 F1
`GENRE_CRAFT`:题材 → 「语域/节奏/钩子偏好/默认字数区间/常见套路」的**文本片段**。
品类无关内核show-don't-tell / 对话个性化 / 反注水原则)在 `write_craft.md`
本表只装**随题材而异**的片段,由 assemble 按 `project.genre` 选片段注入缓存前缀。
字数写成**区间参考**(软引导,非每章硬目标)——具体篇幅仍按情节功能分配(见内核)。
键与前端 wizard 的 GENRES 预设对齐(玄幻/仙侠/都市/科幻/历史/悬疑)+常见言情;
自由填写的题材命不中即优雅返回 None不注入片段不报错。
"""
from __future__ import annotations
from typing import Final
# 每条片段是灵活提示,不是穷举清单——覆盖语域/节奏/钩子/字数/套路等维度,供模型自行取舍。
GENRE_CRAFT: Final[dict[str, str]] = {
"玄幻": (
"语域偏热血爽快、画面感强;节奏偏快,冲突密集,实力成长线要清晰可感。"
"钩子偏好等级压制、打脸反转、机缘奇遇与悬念前置。"
"单章篇幅参考约 25003500 字,按情节功能弹性调整。"
"常见套路:升级流、扮猪吃虎、系统/金手指、宗门家族争斗——用得要有新意,别照搬。"
),
"仙侠": (
"语域偏古雅大气,兼顾意境与热血;节奏中到快,以境界突破为张力节点。"
"钩子偏好机缘造化、道心考验、恩怨情仇与天骄争锋。"
"单章篇幅参考约 25003500 字,按情节功能弹性调整。"
"常见套路:修真等级、飞升渡劫、师门情义、逆境翻盘——古意要真、别堆砌辞藻。"
),
"都市": (
"语域偏现代口语、贴近生活、爽感直接;节奏快,即时反馈要强。"
"钩子偏好装逼打脸、身份反差、隐藏实力与逆袭翻身。"
"单章篇幅参考约 20003000 字(移动端碎片阅读),按情节功能弹性调整。"
"常见套路:扮猪吃虎、隐藏身份、都市异能、商战权斗——落点要接地气。"
),
"科幻": (
"语域偏冷静、有逻辑感、由设定驱动;节奏偏中,重悬念与推演,慎防设定倾泻。"
"钩子偏好谜题危机、科技奇观与命运抉择。"
"单章篇幅参考约 25003500 字,按情节功能弹性调整。"
"常见套路:末世、星际、赛博、时间循环——设定服务剧情,避免大段科普打断叙事。"
),
"历史": (
"语域偏厚重考究、代入感强;节奏偏中,以权谋博弈与人物弧线为主。"
"钩子偏好权谋算计、命运转折与家国大势。"
"单章篇幅参考约 30004000 字,按情节功能弹性调整。"
"常见套路:穿越架空、争霸天下、朝堂权谋、种田发展——细节要经得起推敲。"
),
"悬疑": (
"语域偏冷峻克制、讲究信息控制;节奏张弛有度,靠悬念与反转拉动。"
"钩子偏好谜题、危险迫近、误导与真相错位。"
"单章篇幅参考约 25003500 字,按情节功能弹性调整。"
"常见套路:多线叙事、伏笔回收、层层反转、不可靠叙述——保持信息差,慎过早揭底。"
),
"言情": (
"语域偏细腻、注重情绪张力与关系互动;节奏偏中,以情感推拉为主线。"
"钩子偏好暧昧拉扯、误会错过、情感转折与命运羁绊。"
"单章篇幅参考约 25003500 字,按情节功能弹性调整。"
"常见套路:先婚后爱、破镜重圆、双向奔赴、追妻火葬场——情绪外化于互动细节,别空喊。"
),
}
def genre_craft_fragment(genre: str | None) -> str | None:
"""按题材取 craft 片段;未指定或命不中预设题材时返回 None不注入、不报错"""
if not genre:
return None
return GENRE_CRAFT.get(genre)

View File

@@ -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)

View File

@@ -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))

View File

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

View File

@@ -181,6 +181,39 @@ def test_build_character_gen_context_injects_existing_and_generated() -> None:
assert "苏黎" in ctx # 本批已生成卡 assert "苏黎" in ctx # 本批已生成卡
def test_build_character_gen_context_injects_role_mix() -> None:
# ⑦ 群像按定位配比role_mix 各定位 + 数量都进上下文,指示模型按配比分配
ctx = build_character_gen_context(
brief="给我一组群像",
count=6,
role=None,
world_context="",
existing_chars=[],
generated_so_far=[],
role_mix={"主角": 1, "对手": 2, "配角": 3},
)
assert "配比" in ctx # 配比块标识
assert "主角" in ctx and "对手" in ctx and "配角" in ctx
assert "1" in ctx and "2" in ctx and "3" in ctx
def test_build_character_gen_context_role_mix_overrides_single_role() -> None:
# role_mix 在场时用配比块不再输出单一「角色定位role」行
ctx = build_character_gen_context(
brief="群像",
count=3,
role="主角", # 应被 role_mix 覆盖
world_context="",
existing_chars=[],
generated_so_far=[],
role_mix={"对手": 3},
)
assert "角色定位:主角" not in ctx
assert "对手" in ctx
def test_build_character_gen_context_handles_empty_anti_dup() -> None: def test_build_character_gen_context_handles_empty_anti_dup() -> None:
# 首次单生成:无已有/已生成时仍产出可用上下文(不崩) # 首次单生成:无已有/已生成时仍产出可用上下文(不崩)
ctx = build_character_gen_context( ctx = build_character_gen_context(
@@ -243,6 +276,29 @@ async def test_run_character_gen_injects_anti_dup_into_request() -> None:
assert "九转炼气" in req.input # 世界观约束注入 assert "九转炼气" in req.input # 世界观约束注入
async def test_run_character_gen_injects_role_mix_into_request() -> None:
# ⑦ role_mix 穿参run → build_character_gen_context → 请求 input 含各定位
gateway = FakeRunGateway(_CARDS)
await run_character_gen(
character_gen_spec,
brief="群像",
count=5,
role=None,
world_context="",
existing_chars=[],
generated_so_far=[],
gateway=gateway,
user_id=USER,
project_id=PROJECT,
role_mix={"主角": 2, "配角": 3},
)
req = gateway.last_request
assert req is not None
assert "配角" in req.input # 配比定位注入
async def test_run_character_gen_raises_when_gateway_fails() -> None: async def test_run_character_gen_raises_when_gateway_fails() -> None:
gateway = FailingRunGateway(RuntimeError("provider down")) gateway = FailingRunGateway(RuntimeError("provider down"))

View File

@@ -7,6 +7,7 @@ from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any
from ww_core.domain.repositories import ( from ww_core.domain.repositories import (
CharacterView, CharacterView,
@@ -19,6 +20,7 @@ from ww_core.domain.repositories import (
StyleView, StyleView,
WorldEntityView, WorldEntityView,
) )
from ww_core.domain.review_repo import ReviewRepo, ReviewView
from ww_core.memory import ( from ww_core.memory import (
AssembledContext, AssembledContext,
EntityKind, EntityKind,
@@ -103,6 +105,28 @@ class FakeProjectSpecRepo:
return self.row return self.row
@dataclass
class FakeReviewRepo:
"""内存审稿留痕 fake灵感②冲突裁决反哺——只需 list_for_chapter 供 assemble 反读。
`queried` 记录被查询的章号(验证读的是上一章 chapter_no-1record/set_decisions
只为满足 ReviewRepo Protocol 结构类型assemble 反哺路径不用。
"""
by_chapter: dict[int, list[ReviewView]] = field(default_factory=dict)
queried: list[int] = field(default_factory=list)
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[ReviewView]:
self.queried.append(chapter_no)
return list(self.by_chapter.get(chapter_no, []))
async def record(self, *args: Any, **kwargs: Any) -> ReviewView: # pragma: no cover
raise NotImplementedError
async def set_decisions(self, *args: Any, **kwargs: Any) -> ReviewView: # pragma: no cover
raise NotImplementedError
def build_repos( def build_repos(
*, *,
outline: dict[int, OutlineView] | None = None, outline: dict[int, OutlineView] | None = None,
@@ -113,6 +137,7 @@ def build_repos(
style: StyleView | None = None, style: StyleView | None = None,
rules: list[RuleView] | None = None, rules: list[RuleView] | None = None,
spec: ProjectSpecView | None = None, spec: ProjectSpecView | None = None,
review: ReviewRepo | None = None,
) -> MemoryRepos: ) -> MemoryRepos:
return MemoryRepos( return MemoryRepos(
outline=FakeOutlineRepo(outline or {}), outline=FakeOutlineRepo(outline or {}),
@@ -123,6 +148,7 @@ def build_repos(
style=FakeStyleRepo(style), style=FakeStyleRepo(style),
rules=FakeRulesRepo(rules or []), rules=FakeRulesRepo(rules or []),
project=FakeProjectSpecRepo(spec), project=FakeProjectSpecRepo(spec),
review=review,
) )
@@ -383,6 +409,253 @@ async def test_chapter_directive_uses_chapter_no() -> None:
assert "请创作第 7 章的正文。" in ctx.volatile assert "请创作第 7 章的正文。" in ctx.volatile
# ---- 灵感 F1genre-aware craft 片段注入 ----
async def test_genre_fragment_injected_by_genre() -> None:
from ww_config import GENRE_CRAFT
repos = build_repos(spec=ProjectSpecView(title="", genre="玄幻"))
ctx = await assemble(repos, PROJECT, 5)
# 玄幻题材片段进 stable_core每书稳定随书缓存不进 volatile。
assert GENRE_CRAFT["玄幻"] in ctx.stable_core
assert GENRE_CRAFT["玄幻"] not in ctx.volatile
async def test_different_genre_yields_different_fragment() -> None:
from ww_config import GENRE_CRAFT
xuan = await assemble(build_repos(spec=ProjectSpecView(title="", genre="玄幻")), PROJECT, 5)
city = await assemble(build_repos(spec=ProjectSpecView(title="", genre="都市")), PROJECT, 5)
assert GENRE_CRAFT["玄幻"] in xuan.stable_core
assert GENRE_CRAFT["都市"] in city.stable_core
assert xuan.stable_core != city.stable_core
async def test_unknown_genre_injects_no_fragment() -> None:
repos = build_repos(spec=ProjectSpecView(title="", genre="不存在的题材"))
ctx = await assemble(repos, PROJECT, 5)
assert "本作题材写法" not in ctx.stable_core
async def test_no_genre_injects_no_fragment() -> None:
repos = build_repos(spec=ProjectSpecView(title="")) # genre 默认 None
ctx = await assemble(repos, PROJECT, 5)
assert "本作题材写法" not in ctx.stable_core
# ---- 灵感④:立项基调/结局/视角是书级常量 → 进 stable_core缓存前缀不变量 #9 ----
async def test_build_stable_includes_tone_ending_pov() -> None:
spec = ProjectSpecView(title="", tone="热血", ending_type="HE", narrative_pov="第一人称")
ctx = await assemble(build_repos(spec=spec), PROJECT, 5)
assert "热血" in ctx.stable_core
assert "HE" in ctx.stable_core
assert "第一人称" in ctx.stable_core
async def test_tone_ending_pov_never_enter_volatile() -> None:
# 书级常量随书稳定 → 只进缓存前缀,绝不进 volatile章号易变区
spec = ProjectSpecView(title="", tone="热血", ending_type="HE", narrative_pov="第一人称")
ctx = await assemble(build_repos(spec=spec), PROJECT, 5)
assert "热血" not in ctx.volatile
assert "第一人称" not in ctx.volatile
# ---- 灵感①/F1开篇黄金三章特判volatile仅前 3 章) ----
async def test_opening_marker_injected_for_ch1to3_only() -> None:
repos = build_repos(spec=ProjectSpecView(title=""))
for chapter_no in (1, 2, 3):
ctx = await assemble(repos, PROJECT, chapter_no)
assert "黄金三章" in ctx.volatile
assert f"开篇第 {chapter_no}" in ctx.volatile
# 第 4 章起不再注入开篇标记。
ctx4 = await assemble(repos, PROJECT, 4)
assert "黄金三章" not in ctx4.volatile
async def test_opening_marker_never_enters_stable_core() -> None:
# 不变量#9开篇标记含章号每章易变→ 只入 volatile绝不进缓存前缀。
repos = build_repos(spec=ProjectSpecView(title="", genre="玄幻"))
ctx = await assemble(repos, PROJECT, 1)
assert "黄金三章" not in ctx.stable_core
async def test_no_per_chapter_varying_field_in_cached_blocks() -> None:
# 不变量#9同书不同章stable_core含 genre 片段)必须字节稳定;
# 章号 / 开篇标记等每章易变项只在 volatile。
repos = build_repos(spec=ProjectSpecView(title="", genre="玄幻"))
ch1 = await assemble(repos, PROJECT, 1)
ch5 = await assemble(repos, PROJECT, 5)
assert ch1.stable_core == ch5.stable_core # genre 片段随书稳定,跨章不变
assert "第 1 章" not in ch1.stable_core
assert "第 5 章" not in ch5.stable_core
# ---- 灵感②/D3上一已验收章 continuity 冲突裁决反哺volatile确定性模板 ----
def _accepted_review(
*,
chapter_no: int,
conflicts: list[dict[str, Any]],
items: list[dict[str, Any]],
) -> ReviewView:
"""构造一行"已验收"审稿留痕decisions 非空(只在验收事务写入 → 视为已验收)。"""
return ReviewView(
id=uuid.UUID(int=chapter_no),
project_id=PROJECT,
chapter_no=chapter_no,
conflicts=conflicts,
decisions={"items": items, "conflict_count": len(conflicts)},
)
def _conflict(type_: str, suggestion: str) -> dict[str, Any]:
return {"type": type_, "where": "某段", "refs": [], "suggestion": suggestion}
def _review_repo_with(reviews: list[ReviewView]) -> FakeReviewRepo:
by_chapter: dict[int, list[ReviewView]] = {}
for r in reviews:
by_chapter.setdefault(r.chapter_no, []).append(r)
return FakeReviewRepo(by_chapter=by_chapter)
async def test_volatile_includes_prior_conflict_notes() -> None:
review = _accepted_review(
chapter_no=1,
conflicts=[_conflict("设定冲突", "主角左臂已断,后文不得再用左手持剑")],
items=[{"conflict_index": 0, "verdict": "accept", "note": None}],
)
repos = build_repos(
spec=ProjectSpecView(title=""),
review=_review_repo_with([review]),
)
ctx = await assemble(repos, PROJECT, 2)
assert "上一章冲突提醒" in ctx.volatile
assert "主角左臂已断,后文不得再用左手持剑" in ctx.volatile
async def test_prior_conflict_notes_never_enter_stable_core() -> None:
# 不变量#9反哺是每章易变输入依赖上一章裁决→ 只入 volatile绝不进缓存前缀。
review = _accepted_review(
chapter_no=1,
conflicts=[_conflict("设定冲突", "灵脉禁止跨界,后文守此规则")],
items=[{"conflict_index": 0, "verdict": "accept", "note": None}],
)
repos = build_repos(spec=ProjectSpecView(title=""), review=_review_repo_with([review]))
ctx = await assemble(repos, PROJECT, 2)
assert "上一章冲突提醒" not in ctx.stable_core
assert "灵脉禁止跨界,后文守此规则" not in ctx.stable_core
async def test_reads_previous_accepted_chapter() -> None:
# 写第 5 章 → 只应读第 4 章chapter_no-1的审稿留痕。
review = _accepted_review(
chapter_no=4,
conflicts=[_conflict("时间线倒错", "事件发生在雨夜,后文保持一致")],
items=[{"conflict_index": 0, "verdict": "manual", "note": "已统一为雨夜"}],
)
fake = _review_repo_with([review])
repos = build_repos(spec=ProjectSpecView(title=""), review=fake)
ctx = await assemble(repos, PROJECT, 5)
assert fake.queried == [4] # 只查上一章
assert "已统一为雨夜" in ctx.volatile
async def test_skips_unaccepted_previous_chapter() -> None:
# 上一章有审稿留痕但 decisions=None尚未验收→ 不反哺(守风险#5不读未定稿
unaccepted = ReviewView(
id=uuid.UUID(int=99),
project_id=PROJECT,
chapter_no=1,
conflicts=[_conflict("设定冲突", "不应注入的建议")],
decisions=None,
)
repos = build_repos(
spec=ProjectSpecView(title=""),
review=_review_repo_with([unaccepted]),
)
ctx = await assemble(repos, PROJECT, 2)
assert "上一章冲突提醒" not in ctx.volatile
assert "不应注入的建议" not in ctx.volatile
async def test_only_retained_conflict_decisions_injected() -> None:
# 只反哺保留项 verdict∈{accept,manual}ignore作者判为非冲突不反哺。
review = _accepted_review(
chapter_no=1,
conflicts=[
_conflict("设定冲突", "采纳项应注入"),
_conflict("时间线倒错", "手改项应注入"),
_conflict("地理矛盾", "忽略项不应注入"),
],
items=[
{"conflict_index": 0, "verdict": "accept", "note": None},
{"conflict_index": 1, "verdict": "manual", "note": None},
{"conflict_index": 2, "verdict": "ignore", "note": None},
],
)
repos = build_repos(spec=ProjectSpecView(title=""), review=_review_repo_with([review]))
ctx = await assemble(repos, PROJECT, 2)
assert "采纳项应注入" in ctx.volatile
assert "手改项应注入" in ctx.volatile
assert "忽略项不应注入" not in ctx.volatile
async def test_prior_conflict_notes_bounded_deduped_sorted_and_no_llm_call() -> None:
# 5 条保留裁决 + 1 条重复 → 去重后有界 top-3两次组装字节完全一致确定性、无 LLM
conflicts = [_conflict("设定冲突", f"约束{i}") for i in range(5)]
conflicts.append(_conflict("设定冲突", "约束0")) # 与 index 0 内容重复
items = [{"conflict_index": i, "verdict": "accept", "note": None} for i in range(6)]
review = _accepted_review(chapter_no=1, conflicts=conflicts, items=items)
repos = build_repos(spec=ProjectSpecView(title=""), review=_review_repo_with([review]))
ctx1 = await assemble(repos, PROJECT, 2)
ctx2 = await assemble(repos, PROJECT, 2)
# 字节稳定(无 LLM 的非确定性——assemble 无网关入参,反哺全程纯字符串模板。
assert ctx1.volatile == ctx2.volatile
section = ctx1.volatile.split("## 上一章冲突提醒\n", 1)[1].split("\n\n", 1)[0]
lines = [ln for ln in section.splitlines() if ln.startswith("- ")]
assert len(lines) == 3 # 有界 top-3
assert len(set(lines)) == len(lines) # 去重
assert "约束0" in section # 去重后仍保留一条约束0
async def test_no_apology_or_selfref_in_prior_conflict_notes() -> None:
review = _accepted_review(
chapter_no=1,
conflicts=[_conflict("设定冲突", "后文保持灵脉守恒")],
items=[{"conflict_index": 0, "verdict": "accept", "note": None}],
)
repos = build_repos(spec=ProjectSpecView(title=""), review=_review_repo_with([review]))
ctx = await assemble(repos, PROJECT, 2)
section = ctx.volatile.split("## 上一章冲突提醒\n", 1)[1].split("\n\n", 1)[0]
# 前瞻正向约束——绝无道歉/自指措辞(守 #3/#4不自评自改、不磨平文风
for banned in ("抱歉", "道歉", "对不起", "如前所述", "更正", "笔误"):
assert banned not in section
async def test_assemble_without_review_repo_gracefully_degrades() -> None:
# review=None默认→ 优雅降级:不反哺、不崩(守风险#6
repos = build_repos(spec=ProjectSpecView(title="")) # review 默认 None
ctx = await assemble(repos, PROJECT, 5)
assert "上一章冲突提醒" not in ctx.volatile
async def test_no_prior_conflict_notes_for_first_chapter() -> None:
# 第 1 章无上一章 → 不查 review repo、不反哺。
fake = FakeReviewRepo()
repos = build_repos(spec=ProjectSpecView(title=""), review=fake)
ctx = await assemble(repos, PROJECT, 1)
assert fake.queried == [] # 未查询(无上一章)
assert "上一章冲突提醒" not in ctx.volatile
# ---- helpers ---- # ---- helpers ----

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
import uuid import uuid
from fakes_orchestrator import FakeStreamGateway, RaisingGateway from fakes_orchestrator import FakeStreamGateway, RaisingGateway
from ww_agents import load_craft_doctrine
from ww_core.orchestrator import ( from ww_core.orchestrator import (
EVENT_DONE, EVENT_DONE,
EVENT_ERROR, EVENT_ERROR,
@@ -37,14 +38,58 @@ def test_build_write_request_puts_stable_core_in_cached_system_block() -> None:
assert req.tier == "writer" # 不变量②:只传档位,不传 model assert req.tier == "writer" # 不变量②:只传档位,不传 model
assert req.stream is True assert req.stream is True
assert len(req.system) == 1 # 灵感①system = [craft 教条(cache), stable_core(cache)]craft 在最前。
assert req.system[0].text == STABLE assert len(req.system) == 2
assert req.system[0].cache is True # 不变量#9稳定内核进缓存断点前 assert all(b.cache is True for b in req.system) # 不变量#9稳定内核进缓存断点前
assert req.system[1].text == STABLE
assert req.input == VOLATILE assert req.input == VOLATILE
assert req.scope.user_id == USER assert req.scope.user_id == USER
assert req.scope.project_id == PROJECT assert req.scope.project_id == PROJECT
# ---- 灵感①:写章 craft 教条块 ----
def test_write_request_includes_craft_block() -> None:
req = build_write_request(
stable_core=STABLE, volatile=VOLATILE, user_id=USER, project_id=PROJECT
)
# craft 教条作为独立的 cache=True system 块注入。
assert req.system[0].text == load_craft_doctrine()
assert req.system[0].cache is True
def test_craft_precedes_stable_core() -> None:
req = build_write_request(
stable_core=STABLE, volatile=VOLATILE, user_id=USER, project_id=PROJECT
)
# craft 教条块必须在 stable_core 块之前(跨书前缀复用最大化)。
assert req.system[0].text == load_craft_doctrine()
assert req.system[1].text == STABLE
def test_craft_is_byte_stable() -> None:
# 不变量#9craft 块字节稳定——两次构造完全一致,无每章易变项掺入。
req1 = build_write_request(
stable_core=STABLE, volatile="请创作第 1 章的正文。", user_id=USER, project_id=PROJECT
)
req2 = build_write_request(
stable_core=STABLE, volatile="请创作第 99 章的正文。", user_id=USER, project_id=PROJECT
)
assert req1.system[0].text == req2.system[0].text # craft 块与章号无关
def test_no_per_chapter_field_in_cached_system_blocks() -> None:
# 不变量#9每章易变项章号只在 input(断点后),绝不进任何 cache=True 的 system 块。
volatile = "## 写作指令\n请创作第 5 章的正文。"
req = build_write_request(
stable_core=STABLE, volatile=volatile, user_id=USER, project_id=PROJECT
)
for block in req.system:
assert "第 5 章" not in block.text
assert "第 5 章" in req.input
# ---- stream_chapter_draft转发网关 Delta、构造正确请求 ---- # ---- stream_chapter_draft转发网关 Delta、构造正确请求 ----

View File

@@ -1,6 +1,16 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from ww_core.domain.project_repo import max_time from ww_core.domain.project_repo import _to_view, max_time
from ww_db.models import Project
def test_to_view_carries_tone_ending_pov() -> None:
# ④ 立项三字段(基调/结局/视角)经 ORM 行 → ProjectView 快照逐字段透传snake_case
row = Project(title="", tone="热血", ending_type="BE", narrative_pov="多视角")
view = _to_view(row)
assert view.tone == "热血"
assert view.ending_type == "BE"
assert view.narrative_pov == "多视角"
def test_max_time_handles_missing_values() -> None: def test_max_time_handles_missing_values() -> None:

View File

@@ -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,
) )

View File

@@ -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"]
# ---- collectstyle 列映射 ---- # ---- collectstyle 列映射 ----
@@ -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:

View File

@@ -12,7 +12,7 @@ JSONB **dict**`traits`/`arc`/`speech_tics``tags`/`relations` 才是 JSO
- `speech_tics: list[str]` → `{"items": [...]}`JSONB dict - `speech_tics: list[str]` → `{"items": [...]}`JSONB dict
- `arc: str` → `{"text": "..."}`JSONB dict - `arc: str` → `{"text": "..."}`JSONB dict
- `tags: list` / `relations: list[dict]` → 直落 JSONB list - `tags: list` / `relations: list[dict]` → 直落 JSONB list
- `name` / `role` / `backstory` → Text 列直落 - `name` / `role` / `appearance` / `motive` / `backstory` → Text 列直落
**提交边界**`create` 只 `flush()` 不 `commit()`——提交交端点事务(入库端点末尾一次 **提交边界**`create` 只 `flush()` 不 `commit()`——提交交端点事务(入库端点末尾一次
`commit()`,与网关 ledger 一并落库;与项目其它写侧 repo 一致,见 memory/gotchas `commit()`,与网关 ledger 一并落库;与项目其它写侧 repo 一致,见 memory/gotchas
@@ -66,6 +66,8 @@ class CharacterWriteRepo(Protocol):
name: str, name: str,
role: str, role: str,
traits: list[str], traits: list[str],
appearance: str,
motive: str,
backstory: str, backstory: str,
arc: str, arc: str,
speech_tics: list[str], speech_tics: list[str],
@@ -91,6 +93,8 @@ class SqlCharacterWriteRepo:
name: str, name: str,
role: str, role: str,
traits: list[str], traits: list[str],
appearance: str,
motive: str,
backstory: str, backstory: str,
arc: str, arc: str,
speech_tics: list[str], speech_tics: list[str],
@@ -105,6 +109,8 @@ class SqlCharacterWriteRepo:
if existing is not None: if existing is not None:
existing.role = role existing.role = role
existing.traits = _traits_to_jsonb(traits) existing.traits = _traits_to_jsonb(traits)
existing.appearance = appearance
existing.motive = motive
existing.backstory = backstory existing.backstory = backstory
existing.arc = _arc_to_jsonb(arc) existing.arc = _arc_to_jsonb(arc)
existing.speech_tics = _traits_to_jsonb(speech_tics) existing.speech_tics = _traits_to_jsonb(speech_tics)
@@ -118,6 +124,8 @@ class SqlCharacterWriteRepo:
name=name, name=name,
role=role, role=role,
traits=_traits_to_jsonb(traits), traits=_traits_to_jsonb(traits),
appearance=appearance,
motive=motive,
backstory=backstory, backstory=backstory,
arc=_arc_to_jsonb(arc), arc=_arc_to_jsonb(arc),
speech_tics=_traits_to_jsonb(speech_tics), speech_tics=_traits_to_jsonb(speech_tics),
@@ -139,6 +147,8 @@ class CharacterWriteFields(BaseModel):
name: str name: str
role: str role: str
traits: list[str] = Field(default_factory=list) traits: list[str] = Field(default_factory=list)
appearance: str = ""
motive: str = ""
backstory: str backstory: str
arc: str arc: str
speech_tics: list[str] = Field(default_factory=list) speech_tics: list[str] = Field(default_factory=list)

View File

@@ -29,6 +29,9 @@ class ProjectView:
theme: str | None = None theme: str | None = None
selling_points: list[Any] = field(default_factory=list) selling_points: list[Any] = field(default_factory=list)
structure: str | None = None structure: str | None = None
tone: str | None = None
ending_type: str | None = None
narrative_pov: str | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
pending_review_count: int = 0 pending_review_count: int = 0
@@ -44,6 +47,9 @@ class ProjectCreate:
theme: str | None = None theme: str | None = None
selling_points: list[Any] = field(default_factory=list) selling_points: list[Any] = field(default_factory=list)
structure: str | None = None structure: str | None = None
tone: str | None = None
ending_type: str | None = None
narrative_pov: str | None = None
class ProjectRepo(Protocol): class ProjectRepo(Protocol):
@@ -71,6 +77,9 @@ def _to_view(
theme=row.theme, theme=row.theme,
selling_points=list(row.selling_points or []), selling_points=list(row.selling_points or []),
structure=row.structure, structure=row.structure,
tone=row.tone,
ending_type=row.ending_type,
narrative_pov=row.narrative_pov,
updated_at=updated_at or row.updated_at, updated_at=updated_at or row.updated_at,
pending_review_count=pending_review_count, pending_review_count=pending_review_count,
) )
@@ -92,6 +101,9 @@ class SqlProjectRepo:
theme=data.theme, theme=data.theme,
selling_points=list(data.selling_points), selling_points=list(data.selling_points),
structure=data.structure, structure=data.structure,
tone=data.tone,
ending_type=data.ending_type,
narrative_pov=data.narrative_pov,
) )
self._s.add(row) self._s.add(row)
await self._s.commit() await self._s.commit()

View File

@@ -15,6 +15,8 @@ from typing import Any, Protocol
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ww_core.domain.review_repo import ReviewRepo
# ---- 只读视图snake_casefrozen 防止意外突变)---- # ---- 只读视图snake_casefrozen 防止意外突变)----
@@ -99,6 +101,12 @@ class ProjectSpecView(BaseModel):
logline: str | None = None logline: str | None = None
premise: str | None = None premise: str | None = None
theme: str | None = None theme: str | None = None
# 题材(每书稳定项)——供 assemble 注入 genre-aware craft 片段(灵感 F1
genre: str | None = None
# 立项书级常量(灵感④):基调/结局取向/叙事视角——每书稳定 → 入 stable_core 缓存前缀。
tone: str | None = None
ending_type: str | None = None
narrative_pov: str | None = None
# ---- Repository 协议async统一按 project_id 过滤)---- # ---- Repository 协议async统一按 project_id 过滤)----
@@ -142,7 +150,11 @@ class ProjectSpecRepo(Protocol):
@dataclass(frozen=True) @dataclass(frozen=True)
class MemoryRepos: class MemoryRepos:
"""记忆服务所需的 8 个 repo 的依赖捆绑(注入点)。""" """记忆服务所需的 9 个 repo 的依赖捆绑(注入点)。
`review` 末位默认 None只有冲突裁决反哺灵感②需要它不注入时 assemble
优雅降级(不反哺),故既有构造点无需感知(守风险#6
"""
outline: OutlineRepo outline: OutlineRepo
character: CharacterRepo character: CharacterRepo
@@ -152,3 +164,4 @@ class MemoryRepos:
style: StyleRepo style: StyleRepo
rules: RulesRepo rules: RulesRepo
project: ProjectSpecRepo project: ProjectSpecRepo
review: ReviewRepo | None = None

View File

@@ -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,
) )

View File

@@ -11,6 +11,8 @@ import json
import uuid import uuid
from typing import Any from typing import Any
from ww_config import genre_craft_fragment
from ww_core.domain.injection_repo import InjectionOverride from ww_core.domain.injection_repo import InjectionOverride
from ww_core.domain.repositories import ( from ww_core.domain.repositories import (
CharacterView, CharacterView,
@@ -23,6 +25,7 @@ from ww_core.domain.repositories import (
StyleView, StyleView,
WorldEntityView, WorldEntityView,
) )
from ww_core.domain.review_repo import ReviewView
from .render import render_cards from .render import render_cards
from .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, EntityKey, select_relevant_entities from .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, EntityKey, select_relevant_entities
@@ -31,6 +34,16 @@ from .types import AssembledContext, EntityKind
# 四级规则合并优先级global → genre → style → project越具体越靠后/越优先) # 四级规则合并优先级global → genre → style → project越具体越靠后/越优先)
_LEVEL_RANK: dict[str, int] = {"global": 0, "genre": 1, "style": 2, "project": 3} _LEVEL_RANK: dict[str, int] = {"global": 0, "genre": 1, "style": 2, "project": 3}
# 开篇黄金三章特判上限:前 N 章在 volatile 追加开篇提示(灵感①)。
OPENING_CHAPTER_LIMIT = 3
# 冲突裁决反哺(灵感②/D3只反哺"保留项"裁决——作者采纳(accept)或手改(manual)
# ignore作者判为非冲突不反哺。这三个控制值对齐 schemas.projects.Verdict。
_RETAINED_VERDICTS: frozenset[str] = frozenset({"accept", "manual"})
# 上一章冲突提醒有界上限top-N——控作者/模型每章注意力预算(灵感② 校验 M
PRIOR_CONFLICT_NOTE_LIMIT = 3
def _ser(value: Any) -> str: def _ser(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True) return json.dumps(value, ensure_ascii=False, sort_keys=True)
@@ -64,9 +77,26 @@ def _build_spec_section(spec: ProjectSpecView | None) -> str | None:
lines.append(f"【前提】{spec.premise}") lines.append(f"【前提】{spec.premise}")
if spec.theme: if spec.theme:
lines.append(f"【主题】{spec.theme}") lines.append(f"【主题】{spec.theme}")
# 立项书级常量(灵感④):每书稳定 → 随蓝本进缓存前缀,跨章字节不变(不变量 #9
if spec.tone:
lines.append(f"【基调】{spec.tone}")
if spec.narrative_pov:
lines.append(f"【叙事视角】{spec.narrative_pov}")
if spec.ending_type:
lines.append(f"【结局取向】{spec.ending_type}")
return _section("作品蓝本", "\n".join(lines)) return _section("作品蓝本", "\n".join(lines))
def _build_genre_craft_section(genre: str | None) -> str | None:
"""按题材注入 craft 片段(语域/节奏/钩子/字数/套路,灵感 F1
genre 是每书稳定项(来自 spec→ 入缓存前缀 stable_core跨章字节稳定不变量 #9
命不中预设题材(自由填写/未设)则不注入。品类无关内核在 write_craft.md不在此处。
"""
fragment = genre_craft_fragment(genre)
return _section("本作题材写法", fragment) if fragment else None
def _build_stable( def _build_stable(
spec: ProjectSpecView | None, spec: ProjectSpecView | None,
world_entities: list[WorldEntityView], world_entities: list[WorldEntityView],
@@ -89,6 +119,8 @@ def _build_stable(
rule_lines = [f"[{r.level}] {r.content}" for r in merge_rules(rules)] rule_lines = [f"[{r.level}] {r.content}" for r in merge_rules(rules)]
sections = [ sections = [
# 题材写法领衔——同题材作品共享此前缀,利于跨书隐式前缀缓存(不变量 #9
_build_genre_craft_section(spec.genre if spec else None),
_build_spec_section(spec), _build_spec_section(spec),
_section("世界观硬规则", "\n".join(world_lines)), _section("世界观硬规则", "\n".join(world_lines)),
_section("定型主角", "\n".join(char_lines)), _section("定型主角", "\n".join(char_lines)),
@@ -98,6 +130,72 @@ def _build_stable(
return "\n\n".join(s for s in sections if s) return "\n\n".join(s for s in sections if s)
def _opening_marker(chapter_no: int) -> str | None:
"""开篇黄金三章特判(灵感①):前 N 章显式提示,不赌模型自读章号。
含章号 → 每章易变 → 只入 volatile断点后绝不进缓存前缀不变量 #9
"""
if chapter_no > OPENING_CHAPTER_LIMIT:
return None
return _section(
"开篇黄金三章",
f"本章为开篇第 {chapter_no} 章,适用黄金三章原则:"
"开篇即抛出核心冲突或身份钩子;三章之内交付第一个小爽点或转折;"
"避免大段世界观倾泻,边推进情节边带出设定。",
)
def _conflict_constraint(conflict: dict[str, Any], note: str | None) -> str | None:
"""把一条已裁决 continuity 冲突改写成本章的前瞻正向约束(确定性模板,绝不调 LLM
优先用作者手改备注(`note`,作者自己的话);否则用审稿改法建议(`suggestion`——
作者点「采纳」即背书之,故仍是外部 oracle 驱动,不违 #3/#4。只做正向「延续设定」
措辞,绝无道歉/自指(守 #3/#4不自评自改、不磨平文风。两者皆空则返回 None。
"""
detail = (note or "").strip() or str(conflict.get("suggestion") or "").strip()
if not detail:
return None
ctype = str(conflict.get("type") or "").strip()
prefix = f"{ctype}" if ctype else ""
return f"{prefix}延续上一章已确认的设定:{detail}"
def _prior_conflict_notes(
latest_review: ReviewView | None,
*,
limit: int = PRIOR_CONFLICT_NOTE_LIMIT,
) -> str | None:
"""上一已验收章的 continuity 冲突作者裁决保留项 → 本章前瞻约束(灵感②/D3
- 仅当上一章已验收(`decisions` 非空——只在验收事务写入)才反哺(守风险#5不读未定稿
- 按 `decisions["items"][].conflict_index` join 回 `conflicts[index]` 取正文,
过滤 `verdict∈{accept,manual}`(保留项)、去重、按 conflict_index 排序、有界 top-N。
- 确定性纯函数、字节稳定、无 LLM 调用(守 #6/#7assemble 可缓存的纯函数)。
- 软批评类style/pace/characterization无作者裁决 oracle → 永不经此反哺(守 #3/#4
"""
if latest_review is None or latest_review.decisions is None:
return None
items = latest_review.decisions.get("items") or []
conflicts = latest_review.conflicts
constraints: list[str] = []
seen: set[str] = set()
for item in sorted(items, key=lambda d: d.get("conflict_index", 0)):
if item.get("verdict") not in _RETAINED_VERDICTS:
continue
idx = item.get("conflict_index")
if not isinstance(idx, int) or not (0 <= idx < len(conflicts)):
continue
line = _conflict_constraint(conflicts[idx], item.get("note"))
if line is None or line in seen:
continue
seen.add(line)
constraints.append(line)
bounded = constraints[:limit]
if not bounded:
return None
return "\n".join(f"- {c}" for c in bounded)
def _build_volatile( def _build_volatile(
chapter_no: int, chapter_no: int,
cards: str, cards: str,
@@ -105,6 +203,7 @@ def _build_volatile(
digests: list[DigestView], digests: list[DigestView],
beats: dict[str, Any], beats: dict[str, Any],
directive: str | None = None, directive: str | None = None,
prior_conflict_notes: str | None = None,
) -> str: ) -> str:
fore_lines = [ fore_lines = [
f"【伏笔】{f.code} {f.title} [{f.status}]" f"【伏笔】{f.code} {f.title} [{f.status}]"
@@ -118,6 +217,10 @@ def _build_volatile(
_section("本章指令", directive.strip()) if directive and directive.strip() else None, _section("本章指令", directive.strip()) if directive and directive.strip() else None,
# 写章指令始终在场——保证 volatile断点后的 input永不为空修空 prompt 400 # 写章指令始终在场——保证 volatile断点后的 input永不为空修空 prompt 400
_section("写作指令", f"请创作第 {chapter_no} 章的正文。"), _section("写作指令", f"请创作第 {chapter_no} 章的正文。"),
# 开篇黄金三章标记(仅前 N 章)——每章易变,只入 volatile。
_opening_marker(chapter_no),
# 上一章冲突裁决反哺(灵感②)——依赖上一章裁决,每章易变,绝不进缓存前缀(#9
_section("上一章冲突提醒", prior_conflict_notes) if prior_conflict_notes else None,
_section("本章注入卡片", cards), _section("本章注入卡片", cards),
_section("相关伏笔窗口", "\n".join(fore_lines)), _section("相关伏笔窗口", "\n".join(fore_lines)),
_section("近况摘要", "\n".join(digest_lines)), _section("近况摘要", "\n".join(digest_lines)),
@@ -176,10 +279,24 @@ async def assemble(
main_characters = [c for c in characters if (c.role or "") in MAIN_ROLES] main_characters = [c for c in characters if (c.role or "") in MAIN_ROLES]
# 冲突裁决反哺(灵感②/D3读上一已验收章的 continuity 冲突作者裁决保留项 →
# 本章 volatile 前瞻约束。review 未注入(默认 None或本章为第 1 章 → 优雅降级不反哺。
prior_conflict_notes: str | None = None
if repos.review is not None and chapter_no > 1:
prior_reviews = await repos.review.list_for_chapter(project_id, chapter_no - 1)
latest_review = prior_reviews[0] if prior_reviews else None # list_for_chapter 新→旧
prior_conflict_notes = _prior_conflict_notes(latest_review)
stable_core = _build_stable(spec, world_entities, main_characters, style, rules) stable_core = _build_stable(spec, world_entities, main_characters, style, rules)
cards = render_cards(selection, characters, world_entities) cards = render_cards(selection, characters, world_entities)
volatile = _build_volatile( volatile = _build_volatile(
chapter_no, cards, foreshadows, recent_digests, outline.beats, directive chapter_no,
cards,
foreshadows,
recent_digests,
outline.beats,
directive,
prior_conflict_notes,
) )
return AssembledContext(stable_core=stable_core, volatile=volatile, selection=selection) return AssembledContext(stable_core=stable_core, volatile=volatile, selection=selection)

View File

@@ -32,6 +32,7 @@ from ww_core.domain.repositories import (
StyleView, StyleView,
WorldEntityView, WorldEntityView,
) )
from ww_core.domain.review_repo import SqlReviewRepo
class SqlOutlineRepo: class SqlOutlineRepo:
@@ -215,11 +216,18 @@ class SqlProjectSpecRepo:
logline=row.logline, logline=row.logline,
premise=row.premise, premise=row.premise,
theme=row.theme, theme=row.theme,
genre=row.genre,
tone=row.tone,
ending_type=row.ending_type,
narrative_pov=row.narrative_pov,
) )
def sql_memory_repos(session: AsyncSession) -> MemoryRepos: def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
"""用一个 AsyncSession 装配全部 8 个 SQLAlchemy repoT1.4 注入点)。""" """用一个 AsyncSession 装配全部 9 个 SQLAlchemy repoT1.4 注入点)。
`review=SqlReviewRepo`:供 assemble 反读上一已验收章的 continuity 冲突裁决(灵感②)。
"""
return MemoryRepos( return MemoryRepos(
outline=SqlOutlineRepo(session), outline=SqlOutlineRepo(session),
character=SqlCharacterRepo(session), character=SqlCharacterRepo(session),
@@ -229,4 +237,5 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
style=SqlStyleRepo(session), style=SqlStyleRepo(session),
rules=SqlRulesRepo(session), rules=SqlRulesRepo(session),
project=SqlProjectSpecRepo(session), project=SqlProjectSpecRepo(session),
review=SqlReviewRepo(session),
) )

View File

@@ -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",

View File

@@ -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: ...

View File

@@ -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`(节奏诊断 dictwater/hook/beat_map - pace → `pace`(节奏诊断 dictwater/hook/beat_map
- style → `style`(文风漂移诊断 dictscore/segments - style → `style`(文风漂移诊断 dictscore/segments
- characterization→ `characterization`(人物塑造诊断 dictissuesadvisory不进验收 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

View File

@@ -24,7 +24,7 @@ worldbuilder 与 character-gen 都是**独立生成**——不在写章/审稿
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from collections.abc import Sequence from collections.abc import Mapping, Sequence
import structlog import structlog
from pydantic import BaseModel from pydantic import BaseModel
@@ -48,13 +48,14 @@ def _build_request(
*, *,
context: str, context: str,
user_id: uuid.UUID, user_id: uuid.UUID,
project_id: uuid.UUID, project_id: uuid.UUID | None,
) -> LlmRequest: ) -> LlmRequest:
"""据 spec + 注入材料构造生成请求纯函数worldbuilder/character-gen 共用)。 """据 spec + 注入材料构造生成请求纯函数worldbuilder/character-gen 共用)。
`spec.system_prompt` → `system` 缓存断点前块cache=True不变量 #9 `spec.system_prompt` → `system` 缓存断点前块cache=True不变量 #9
`context`(序列化的需求/约束/已有/已生成文本)→ `input`(断点后)。 `context`(序列化的需求/约束/已有/已生成文本)→ `input`(断点后)。
生成用 `run()` 非 `stream()`(要结构化产物,非流式正文)。 生成用 `run()` 非 `stream()`(要结构化产物,非流式正文)。
`project_id` 可空——立项方案在向导阶段跑project 尚未建usage_ledger.project_id 可空)。
""" """
return LlmRequest( return LlmRequest(
tier=spec.tier, # 不变量 #2只传档位不传 model tier=spec.tier, # 不变量 #2只传档位不传 model
@@ -138,12 +139,13 @@ async def run_generator(
context: str, context: str,
gateway: GatewayRun, gateway: GatewayRun,
user_id: uuid.UUID, user_id: uuid.UUID,
project_id: uuid.UUID, project_id: uuid.UUID | None = None,
) -> BaseModel: ) -> BaseModel:
"""通用生成器执行器T6构请求 → `gateway.run` → 按 `spec.output_schema` 校验 parsed。 """通用生成器执行器T6构请求 → `gateway.run` → 按 `spec.output_schema` 校验 parsed。
任一声明式生成器共用此路径(不绑定具体产物类型,据 spec 的 output_schema 校验)。 任一声明式生成器共用此路径(不绑定具体产物类型,据 spec 的 output_schema 校验)。
**只读、不写库**(纯预览,不变量 #3独立生成——网关失败直接上抛端点处理不静默吞。 **只读、不写库**(纯预览,不变量 #3独立生成——网关失败直接上抛端点处理不静默吞。
`project_id` 可空——立项方案在向导阶段跑project 尚未建usage_ledger.project_id 可空)。
""" """
expected = spec.output_schema expected = spec.output_schema
if expected is None: if expected is None:
@@ -213,6 +215,11 @@ def _render_cards_for_context(cards: Sequence[CharacterCard]) -> str:
return "\n".join(lines) return "\n".join(lines)
def _render_role_mix(role_mix: Mapping[str, int]) -> str:
"""把定位配比序列化为清单(保插入序,确定性,无时间戳)。"""
return "\n".join(f"- {role}{n}" for role, n in role_mix.items())
def build_character_gen_context( def build_character_gen_context(
*, *,
brief: str, brief: str,
@@ -221,13 +228,21 @@ def build_character_gen_context(
world_context: str, world_context: str,
existing_chars: Sequence[CharacterCard], existing_chars: Sequence[CharacterCard],
generated_so_far: Sequence[CharacterCard], generated_so_far: Sequence[CharacterCard],
role_mix: Mapping[str, int] | None = None,
) -> str: ) -> str:
"""拼装 character-gen 注入文本含群像防雷同上下文M5-a """拼装 character-gen 注入文本含群像防雷同上下文M5-a;⑦ 群像按定位配比)。
注入「已有角色」+「本批已生成卡」,要求新卡与二者差异化(避免一群人一个模子)。 注入「已有角色」+「本批已生成卡」,要求新卡与二者差异化(避免一群人一个模子)。
`role_mix` 在场时改注入定位配比块(各定位产出对应数量、合计等于数量),覆盖单一 `role`。
确定性拼接、无时间戳——同输入同输出,便于单测与缓存。 确定性拼接、无时间戳——同输入同输出,便于单测与缓存。
""" """
role_line = f"角色定位:{role}" if role else "角色定位:未指定(由你按需求判定)" if role_mix:
role_line = (
"角色定位配比(严格按此分配,各定位产出对应数量,合计等于数量):\n"
f"{_render_role_mix(role_mix)}"
)
else:
role_line = f"角色定位:{role}" if role else "角色定位:未指定(由你按需求判定)"
existing_block = ( existing_block = (
_render_cards_for_context(existing_chars) if existing_chars else "(暂无已有角色)" _render_cards_for_context(existing_chars) if existing_chars else "(暂无已有角色)"
) )
@@ -260,10 +275,12 @@ async def run_character_gen(
gateway: GatewayRun, gateway: GatewayRun,
user_id: uuid.UUID, user_id: uuid.UUID,
project_id: uuid.UUID, project_id: uuid.UUID,
role_mix: Mapping[str, int] | None = None,
) -> CharacterGenResult: ) -> CharacterGenResult:
"""跑角色/群像生成:构防雷同上下文 → `gateway.run` → 返回 `CharacterGenResult`。 """跑角色/群像生成:构防雷同上下文 → `gateway.run` → 返回 `CharacterGenResult`。
群像防雷同M5-a注入「已有角色 + 本批已生成卡」要求差异化§4.5 / §6.5)。 群像防雷同M5-a注入「已有角色 + 本批已生成卡」要求差异化§4.5 / §6.5)。
`role_mix`(⑦)在场时按定位配比分配(各定位对应数量、合计等于数量)。
独立生成(非并行审):网关失败直接上抛(端点/T5.2 处理),不静默吞。 独立生成(非并行审):网关失败直接上抛(端点/T5.2 处理),不静默吞。
**只产结构化角色卡、不写库**——入库前过 `precheck_generated_cards` 校验、再经 **只产结构化角色卡、不写库**——入库前过 `precheck_generated_cards` 校验、再经
入库端点持久化(持久化属 T5.2,不变量 #3 入库端点持久化(持久化属 T5.2,不变量 #3
@@ -275,6 +292,7 @@ async def run_character_gen(
world_context=world_context, world_context=world_context,
existing_chars=existing_chars, existing_chars=existing_chars,
generated_so_far=generated_so_far, generated_so_far=generated_so_far,
role_mix=role_mix,
) )
req = _build_request(spec, context=context, user_id=user_id, project_id=project_id) req = _build_request(spec, context=context, user_id=user_id, project_id=project_id)
resp = await gateway.run(req) resp = await gateway.run(req)

View File

@@ -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,
) )

View File

@@ -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

View File

@@ -17,10 +17,15 @@ import uuid
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import Protocol from typing import Protocol
from ww_agents import load_craft_doctrine
from ww_llm_gateway.types import Block, Delta, LlmRequest, Scope from ww_llm_gateway.types import Block, Delta, LlmRequest, Scope
from .state import ChapterState from .state import ChapterState
# 写章 craft 教条灵感①品类无关内核import 期读盘 → 缺失即 fail-fast崩 import
# 字节稳定的稳定内容,作为最前的 cache=True system 块(跨书前缀复用,不变量 #9
_CRAFT_DOCTRINE = load_craft_doctrine()
class GatewayStream(Protocol): class GatewayStream(Protocol):
"""write 节点对网关的最小依赖——只需 `stream`(注入真网关或 mock""" """write 节点对网关的最小依赖——只需 `stream`(注入真网关或 mock"""
@@ -37,11 +42,15 @@ def build_write_request(
) -> LlmRequest: ) -> LlmRequest:
"""据组装上下文构造写章请求(纯函数,决策 2026-06-18 """据组装上下文构造写章请求(纯函数,决策 2026-06-18
`stable_core` → `system` 缓存断点前块cache=True`volatile` → `input`(断点后)。 `system` = [craft 教条(cache), stable_core(cache)]——craft 品类无关内核在最前,
最大化跨书前缀复用两块均在缓存断点前cache=True。`volatile` → `input`(断点后)。
""" """
return LlmRequest( return LlmRequest(
tier="writer", tier="writer",
system=[Block(text=stable_core, cache=True)], system=[
Block(text=_CRAFT_DOCTRINE, cache=True),
Block(text=stable_core, cache=True),
],
input=volatile, input=volatile,
stream=True, stream=True,
scope=Scope(user_id=user_id, project_id=project_id), scope=Scope(user_id=user_id, project_id=project_id),

View File

@@ -0,0 +1,33 @@
"""Add tone/ending_type/narrative_pov to projects (灵感④ 立项书级常量).
三列均 Text nullable与既有 genre/structure 一致,无 CHECK枚举约束留前端预设
nullable 免 backfill旧项目留 NULLassemble/前端优雅降级)。
Revision ID: e5f6a7b8c9d0
Revises: 8c1d2e3f4a5b
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
revision: str = "e5f6a7b8c9d0"
down_revision: str | None = "8c1d2e3f4a5b"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("projects", sa.Column("tone", sa.Text(), nullable=True))
op.add_column("projects", sa.Column("ending_type", sa.Text(), nullable=True))
op.add_column("projects", sa.Column("narrative_pov", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("projects", "narrative_pov")
op.drop_column("projects", "ending_type")
op.drop_column("projects", "tone")

View File

@@ -0,0 +1,33 @@
"""Add characterization JSONB column to chapter_reviews (灵感③ advisory 人物塑造审).
单列 JSONB nullableadvisory 留痕,独立于恒 None 的 health_scorenullable 免 backfill
(旧留痕行留 NULLcollect/前端优雅降级)。仅建议、不进 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")

View File

@@ -48,6 +48,10 @@ class Project(UuidPk, TimestampedMixin, Base):
theme: Mapped[str | None] = mapped_column(Text) theme: Mapped[str | None] = mapped_column(Text)
selling_points: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'")) selling_points: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
structure: Mapped[str | None] = mapped_column(Text) structure: Mapped[str | None] = mapped_column(Text)
# 立项书级常量(灵感④)——同 genre/structureText nullable、无 CHECK枚举约束留前端预设
tone: Mapped[str | None] = mapped_column(Text)
ending_type: Mapped[str | None] = mapped_column(Text)
narrative_pov: Mapped[str | None] = mapped_column(Text)
class Character(UuidPk, CreatedAt, Base): class Character(UuidPk, CreatedAt, Base):
@@ -221,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)

View File

@@ -66,7 +66,7 @@ _SOURCE_TEXT_FIELD = InputField(
) )
# 创作工具箱注册表key → 描述符。legacy 3 + 新 8 = 11 条 # 创作工具箱注册表key → 描述符。15 条(legacy 3 + 新 8 + Scope B 4续写/扩写/降AI/拆书)
TOOLBOX: dict[str, GeneratorTool] = { TOOLBOX: dict[str, GeneratorTool] = {
# ---- legacyspec=None前端走 legacy_route 跳现有页面)---- # ---- legacyspec=None前端走 legacy_route 跳现有页面)----
"worldbuilding": GeneratorTool( "worldbuilding": GeneratorTool(

View File

@@ -0,0 +1,59 @@
# craft A/B 成稿质量前后对照评估回路(灵感 F2
把「加 craft 杠杆」与「验证 craft 有效」绑成一件事的评估回路。每引入或改动一个 craft
杠杆(① 写章教条 / F1 genre 片段 / 开篇黄金三章 / 后续 ② 反哺、③ 人物塑造审查等),
就跑一次这里,做 craft-on vs craft-off 的前后对照——**没有它craft 改动都是盲飞,
很可能把文风做得更 AI 公式化而不自知**(见计划 §5 风险 #13)。
## 它是什么
- 固定一组**金标准题材输入**`fixtures.py`:仙侠 / 都市 / 悬疑,各含最小 spec+world+outline
且都是开篇章以覆盖黄金三章 marker
- 对每份 fixture 走**真实生产组装路径**`assemble()` + `build_write_request()`)产出两版
写章请求:
- **craft-on** = 生产路径craft 教条置顶 system 块 + genre 片段进 stable_core 缓存前缀 +
开篇 marker 进 volatile。
- **craft-off** = 同一 fixture 抹掉 craft 层的基线(剥掉 genre/开篇两节、不加教条块)。
两版**唯一差异即 craft 贡献**,便于并排 diff 与 LLM 裁判对比。
## 两种模式
### dry默认零成本、免真实 LLM
```bash
export PATH="$HOME/.local/bin:$PATH"
uv run python -m tests.craft_eval # 全部题材
uv run python -m tests.craft_eval --genre 仙侠
```
只做**确定性校验 + prompt 并排 dump**:断言 craft 教条块、genre 片段、开篇 marker 确实按
fixture 注入了正确内容(且 craft-off 基线不含这三者),并把两版**将发送给模型的 prompt**
并排写进报告供人眼看。**不碰 LLM**,任一断言失败退出码非零——这些断言即
`tests/test_craft_eval.py`,随常规 `pytest` / CI 跑,是 craft 注入路径的回归门禁。
### live真实付费生成 + LLM 五维裁判)
```bash
uv run python -m tests.craft_eval --live
```
**成本警告**`--live` **真实调用付费 LLM**——每个题材跑 writer 生成 craft-on/craft-off 两章
analyst 五维裁判 1 次3 题材 ≈ 6 次写章 + 3 次裁判)。凭据从 DB 读、路由走 `tier_routing`
与生产写章同一条网关路径(需 pg 在跑且已配好 provider 凭据;`--live` 前会先跑 dry 校验,
未全绿则拒绝进入付费生成)。**绝不进 CI**live 路径在 `live.py`pytest 不导入它。
报告默认写系统临时目录(`--out PATH` 自定义);含每题材的 dry 校验清单、prompt 并排、
以及 live 模式下的两版成稿与五维裁判结论。
## 加了新 craft 杠杆后怎么用它把关
1. 改 craft教条 `write_craft.md` / `GENRE_CRAFT` / 新审查维度……)。
2.`pytest tests/test_craft_eval.py` 确认注入路径没被改坏dry 门禁)。
3.`--live` 生成前后对照,人眼 + LLM 五维裁判判断**是更对味还是更公式化**——
据此决定该杠杆保留 / 回退(计划 §5 通用回滚:每杠杆可独立回退)。
## 加题材 / 调 fixture
`fixtures.py` 追加一个 `FixtureCase``genre` 必须命中 `ww_config.GENRE_CRAFT` 才有
片段可注入)。`harness.py` 的 craft-off 靠 `assemble` 的节标题(`本作题材写法` / `开篇黄金三章`
剥离——上游若改节名,剥离失配会被 dry 断言当场炸出craft-off 里仍含片段 → 断言失败)。

View File

@@ -0,0 +1,38 @@
"""craft A/B 成稿质量前后对照评估回路(灵感 F2
固定金标准题材输入 → 组装 craft-on / craft-off 两版写章请求 → dry 确定性校验(零成本、
可进 CI或 `--live` 真实生成 + LLM 五维裁判。用法见 `README.md` / `python -m tests.craft_eval -h`。
"""
from __future__ import annotations
from .fixtures import FIXTURES, FixtureCase
from .harness import (
OPENING_CHAPTER_LIMIT,
CaseReport,
Check,
PromptPair,
all_passed,
build_prompt_pair,
input_text,
render_report,
system_text,
verify_case,
write_report,
)
__all__ = [
"FIXTURES",
"OPENING_CHAPTER_LIMIT",
"CaseReport",
"Check",
"FixtureCase",
"PromptPair",
"all_passed",
"build_prompt_pair",
"input_text",
"render_report",
"system_text",
"verify_case",
"write_report",
]

View File

@@ -0,0 +1,104 @@
"""craft A/B 评估脚本入口(灵感 F2
# dry默认零成本、免真实 LLM——组装+校验+并排 dump prompt
uv run python -m tests.craft_eval
# live真实付费生成 + LLM 五维裁判)
uv run python -m tests.craft_eval --live
选项:`--genre 仙侠` 只跑某题材;`--out PATH` 指定报告路径;`--quiet` 少打印。
退出码dry 任一断言失败 → 非零(可当回归门禁)。详见 README.md。
"""
from __future__ import annotations
import argparse
import asyncio
import sys
import tempfile
from pathlib import Path
from .fixtures import FIXTURES, FixtureCase
from .harness import (
CaseReport,
all_passed,
build_prompt_pair,
render_report,
verify_case,
write_report,
)
def _select(genre: str | None) -> list[FixtureCase]:
if genre is None:
return list(FIXTURES)
picked = [c for c in FIXTURES if c.genre == genre]
if not picked:
raise SystemExit(f"没有题材为 {genre!r} 的 fixture可选{[c.genre for c in FIXTURES]}")
return picked
def _default_out(live: bool) -> Path:
name = "craft_ab_live.md" if live else "craft_ab_dry.md"
return Path(tempfile.gettempdir()) / name
async def _run(args: argparse.Namespace) -> int:
cases = _select(args.genre)
reports: list[CaseReport] = []
for case in cases:
pair = await build_prompt_pair(case)
reports.append(CaseReport(case=case, pair=pair, checks=verify_case(case, pair)))
failed = [(r.case.slug, c) for r in reports for c in r.checks if not c.passed]
if not args.quiet:
for r in reports:
status = "OK" if all_passed(r.checks) else "FAIL"
print(f"[dry] {r.case.slug:10s} {r.case.genre}{r.pair.chapter_no}{status}")
for slug, chk in failed:
print(f"[dry][FAIL] {slug} · {chk.name}: {chk.detail}", file=sys.stderr)
if args.live:
if failed:
print("[live] dry 校验未全绿,拒绝进入付费生成——先修正注入。", file=sys.stderr)
return 1
from .live import run_live, summarize
print("[live] 开始真实生成(付费)……", flush=True)
reports = await run_live(reports)
print("\n[live] 摘要:\n" + summarize(reports))
out = Path(args.out) if args.out else _default_out(args.live)
write_report(render_report(reports, live=args.live), out)
print(f"\n[out] 报告写至 {out}")
return 1 if failed else 0
def main() -> None:
parser = argparse.ArgumentParser(
prog="python -m tests.craft_eval",
description="craft A/B 成稿质量前后对照评估(灵感 F2",
)
parser.add_argument(
"--live",
action="store_true",
help="真实调用付费 LLM 生成两版正文 + 五维裁判(默认 dry免真实 LLM",
)
parser.add_argument("--genre", default=None, help="只跑指定题材(如 仙侠/都市/悬疑)")
parser.add_argument("--out", default=None, help="报告输出路径(默认写系统临时目录)")
parser.add_argument("--quiet", action="store_true", help="减少 stdout 打印")
args = parser.parse_args()
if args.live:
# get_settings() 按 cwd 读 .env确保 cwd = 仓库根(本文件在 tests/craft_eval/ 下三层)。
import os
os.chdir(Path(__file__).resolve().parents[2])
raise SystemExit(asyncio.run(_run(args)))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,158 @@
"""金标准输入 fixtures灵感 F2——3 个不同题材的最小 spec+world+outline。
每个 fixture 是一份**自造、不依赖 DB** 的最小设定:书级蓝本(含 genre+ 世界观硬规则 +
定型主角 + 本章大纲节拍。评估脚本据此组装 craft-on / craft-off 两版写章请求。
新增题材只需追加一个 `FixtureCase`genre 必须命中 `ww_config.GENRE_CRAFT` 才有片段可注)。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from ww_core.domain.repositories import CharacterView, ProjectSpecView, WorldEntityView
@dataclass(frozen=True)
class FixtureCase:
"""一份金标准写章输入(题材 / 章号 / 书级蓝本 / 世界观 / 主角 / 本章节拍)。"""
slug: str
genre: str
chapter_no: int
spec: ProjectSpecView
world: tuple[WorldEntityView, ...]
characters: tuple[CharacterView, ...]
beats: dict[str, Any] = field(default_factory=dict)
_XIANXIA = FixtureCase(
slug="xianxia",
genre="仙侠",
chapter_no=3, # 开篇章≤3——覆盖黄金三章 marker 注入
spec=ProjectSpecView(
title="逆脉证道",
genre="仙侠",
logline="废脉少年叶尘为救绝症妹妹寻回春灵药,滴血觉醒上古逆天功法,逆修九境搅动仙门风云。",
premise="天地灵气稀薄,修行看灵脉品阶;废脉者纳灵极慢,被视作修行绝路。",
theme="卑微者的不屈与救赎——修的是道,护的是人。",
tone="热血隐忍",
narrative_pov="第三人称限知",
ending_type="正剧",
),
world=(
WorldEntityView(
type="设定",
name="九境修为",
rules={
"境界": ["淬体", "开脉", "灵动", "纳灵", "凝魂", "化虚", "通玄", "渡劫", "证道"],
"每境": "分前中后三重",
},
),
WorldEntityView(
type="设定",
name="灵脉",
rules={"作用": "决定纳灵速度", "废脉": "纳灵极慢,被视作修行绝路"},
),
),
characters=(
CharacterView(
name="叶尘",
role="主角",
motive="为身患寒髓之症、命不久矣的妹妹叶暖寻回春灵药",
backstory="青云宗外门弟子,天生废脉、常年受人轻慢,性子沉静隐忍。",
speech_tics={"风格": "话少、点到为止,逼到绝境时语气反而更冷更稳"},
),
),
beats={
"summary": "叶尘被同门构陷逐出青云宗,独行荒山避雨于古洞,"
"滴血觉醒《逆脉真解》,一招震退追杀者。",
"entities": ["叶尘"],
"text": "叶尘 古洞 滴血 觉醒 逆脉真解 震退 追杀者",
},
)
_URBAN = FixtureCase(
slug="urban",
genre="都市",
chapter_no=1, # 开篇第一章
spec=ProjectSpecView(
title="重启我的黄金时代",
genre="都市",
logline="落魄程序员林川一夜重生回到二十年前,凭前世记忆在时代风口逆天改命。",
premise="重生回到世纪初互联网草莽年代,机会遍地、格局未定。",
theme="不甘平庸者抓住的第二次机会。",
tone="爽快励志",
narrative_pov="第一人称",
ending_type="HE",
),
world=(
WorldEntityView(
type="设定",
name="重生规则",
rules={
"回溯": "重生回到 2003 年的大学时代",
"记忆": "完整保留前世记忆与二十年行业趋势",
},
),
),
characters=(
CharacterView(
name="林川",
role="主角",
motive="改写前世潦倒猝死的结局,护住家人、抓住时代机遇翻身",
backstory="前世三十五岁猝死于出租屋的落魄外包程序员。",
speech_tics={"风格": "表面随和、心里门儿清,说话带点自嘲"},
),
),
beats={
"summary": "林川在出租屋猝死后睁眼回到 2003 年的大学宿舍,"
"认出这是重来一次的机会,立下第一个目标。",
"entities": ["林川"],
"text": "林川 重生 2003 大学宿舍 认清机会",
},
)
_SUSPENSE = FixtureCase(
slug="suspense",
genre="悬疑",
chapter_no=2, # 开篇第二章
spec=ProjectSpecView(
title="雾隐镇失踪案",
genre="悬疑",
logline="刑警周砚回到多雾的故乡小镇调查连环失踪案,线索却一步步指向自己尘封的童年。",
premise="常年浓雾笼罩的封闭小镇,二十年前也发生过同样的失踪,无人被起诉。",
theme="逼近真相与自我和解的代价。",
tone="冷峻压抑",
narrative_pov="第三人称限知",
ending_type="开放",
),
world=(
WorldEntityView(
type="设定",
name="雾隐镇",
rules={
"环境": "常年浓雾、信号时断时续",
"旧案": "二十年前发生过同类失踪,卷宗离奇缺页",
},
),
),
characters=(
CharacterView(
name="周砚",
role="主角",
motive="查清连环失踪真相,也为解开自己童年缺失的那段记忆",
backstory="市局刑警,幼年从雾隐镇被送走,对故乡的记忆断片。",
speech_tics={"风格": "话短、爱用反问,审讯时不动声色"},
),
),
beats={
"summary": "周砚勘查第二名失踪者的住所,"
"在墙缝里发现一张与二十年前旧案笔迹相同的纸条,心头一凛。",
"entities": ["周砚"],
"text": "周砚 现场勘查 墙缝 纸条 二十年前 旧案",
},
)
# 金标准题材集:仙侠 / 都市 / 悬疑(各含开篇章,覆盖黄金三章 marker
FIXTURES: tuple[FixtureCase, ...] = (_XIANXIA, _URBAN, _SUSPENSE)

363
tests/craft_eval/harness.py Normal file
View File

@@ -0,0 +1,363 @@
"""craft A/B 评估回路核心(灵感 F2——组装请求 + dry 确定性校验 + 报告渲染。
**零 LLM 依赖**:本模块只走确定性组装(真 `assemble()` + 真 `build_write_request()`)与
纯字符串校验,可安全进 CI。真实生成/裁判在 `live.py`(惰性导入,不进本模块)。
craft-on = 生产路径(`assemble` 注入 genre 片段进 stable_core + 开篇 marker 进 volatile
`build_write_request` 把 craft 教条置为最前 system 块)。
craft-off = 同一 fixture 抹掉 craft 层的基线:剥掉 genre/开篇两节 + 不加教条块——
两版**唯一差异即 craft 贡献**,便于人眼并排 diff 与 LLM 裁判对比。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Any
from ww_core.domain.repositories import (
CharacterView,
DigestView,
ForeshadowView,
MemoryRepos,
OutlineView,
ProjectSpecView,
RuleView,
StyleView,
WorldEntityView,
)
from ww_core.memory.assemble import OPENING_CHAPTER_LIMIT, assemble
from ww_core.orchestrator.write_node import build_write_request
from ww_llm_gateway.types import Block, LlmRequest, Scope
from .fixtures import FixtureCase
# 单用户原型 stubdry 路径不落库,取值无关紧要,仅为满足 Scope
PROJECT_ID = uuid.UUID("00000000-0000-0000-0000-0000000c7a17")
USER_ID = uuid.UUID(int=1)
# assemble 内联的 craft 节标题assemble.py:97 `本作题材写法` / :141 `开篇黄金三章`)。
# craft-off 据此剥离;若上游改名,本表失配 → craft-off 仍含片段 → 校验断言会当场炸出漂移。
_GENRE_SECTION_TITLE = "本作题材写法"
_OPENING_SECTION_TITLE = "开篇黄金三章"
__all__ = [
"OPENING_CHAPTER_LIMIT",
"PROJECT_ID",
"USER_ID",
"CaseReport",
"Check",
"PromptPair",
"all_passed",
"build_prompt_pair",
"input_text",
"render_report",
"system_text",
"verify_case",
"write_report",
]
# ---- 内存 fake repositories只喂 assemble 所需,其余返回空)----
@dataclass
class _FakeOutlineRepo:
"""按查询章号合成 OutlineView复用同一份 beats支持任意 chapter_no 的边界测试。"""
volume: int
beats: dict[str, Any]
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return OutlineView(volume=self.volume, chapter_no=chapter_no, beats=dict(self.beats))
async def list_for_project(
self, project_id: uuid.UUID
) -> list[OutlineView]: # pragma: no cover
return []
@dataclass
class _FakeCharacterRepo:
rows: tuple[CharacterView, ...]
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
return list(self.rows)
@dataclass
class _FakeWorldEntityRepo:
rows: tuple[WorldEntityView, ...]
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
return list(self.rows)
@dataclass
class _FakeDigestRepo:
async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]:
return []
@dataclass
class _FakeForeshadowRepo:
async def list_for_codes(self, project_id: uuid.UUID, codes: list[str]) -> list[ForeshadowView]:
return []
@dataclass
class _FakeStyleRepo:
async def latest(self, project_id: uuid.UUID) -> StyleView | None:
return None
@dataclass
class _FakeRulesRepo:
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
return []
@dataclass
class _FakeProjectSpecRepo:
row: ProjectSpecView
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
return self.row
def _build_repos(case: FixtureCase) -> MemoryRepos:
volume = 1
return MemoryRepos(
outline=_FakeOutlineRepo(volume=volume, beats=case.beats),
character=_FakeCharacterRepo(case.characters),
world_entity=_FakeWorldEntityRepo(case.world),
digest=_FakeDigestRepo(),
foreshadow=_FakeForeshadowRepo(),
style=_FakeStyleRepo(),
rules=_FakeRulesRepo(),
project=_FakeProjectSpecRepo(case.spec),
review=None, # 冲突裁决反哺不在 craft A/B 范围;优雅降级不反哺
)
# ---- craft-on / craft-off 请求组装 ----
@dataclass(frozen=True)
class PromptPair:
craft_on: LlmRequest
craft_off: LlmRequest
chapter_no: int
def _strip_section(text: str, title: str) -> str:
"""按 `## {title}` 剥掉一节(节以 "\\n\\n" 分隔,节体无空行——见 assemble._section"""
header = f"## {title}"
kept = [chunk for chunk in text.split("\n\n") if not chunk.startswith(header)]
return "\n\n".join(kept)
async def build_prompt_pair(case: FixtureCase, *, chapter_no: int | None = None) -> PromptPair:
"""据 fixture 组装 craft-on生产路径与 craft-off抹除 craft 层的基线)两版请求。"""
ch = chapter_no if chapter_no is not None else case.chapter_no
ctx = await assemble(_build_repos(case), PROJECT_ID, ch)
craft_on = build_write_request(
stable_core=ctx.stable_core,
volatile=ctx.volatile,
user_id=USER_ID,
project_id=PROJECT_ID,
)
off_stable = _strip_section(ctx.stable_core, _GENRE_SECTION_TITLE)
off_volatile = _strip_section(ctx.volatile, _OPENING_SECTION_TITLE)
craft_off = LlmRequest(
tier="writer",
system=[Block(text=off_stable, cache=True)],
input=off_volatile,
stream=True,
scope=Scope(user_id=USER_ID, project_id=PROJECT_ID),
)
return PromptPair(craft_on=craft_on, craft_off=craft_off, chapter_no=ch)
# ---- prompt 文本抽取(供校验与报告)----
def system_text(req: LlmRequest) -> str:
return "\n\n".join(b.text for b in req.system)
def input_text(req: LlmRequest) -> str:
if isinstance(req.input, str):
return req.input
return "\n\n".join(b.text for b in req.input)
# ---- dry 确定性校验(无 LLM可进 CI----
@dataclass(frozen=True)
class Check:
name: str
passed: bool
detail: str
def _load_doctrine() -> str:
# 惰性导入避免 fixtures 模块与本模块的导入顺序耦合。
from ww_agents import load_craft_doctrine
return load_craft_doctrine()
def _genre_fragment(genre: str) -> str:
from ww_config import genre_craft_fragment
fragment = genre_craft_fragment(genre)
if fragment is None: # pragma: no cover - fixtures 已保证命中
raise ValueError(f"题材 {genre!r} 无 GENRE_CRAFT 片段")
return fragment
def verify_case(case: FixtureCase, pair: PromptPair) -> list[Check]:
"""对一份 fixture 的 craft-on/off 请求做确定性断言craft 改动的回归门禁)。"""
on, off = pair.craft_on, pair.craft_off
doctrine = _load_doctrine()
fragment = _genre_fragment(case.genre)
on_sys, on_in = system_text(on), input_text(on)
off_sys, off_in = system_text(off), input_text(off)
is_opening = pair.chapter_no <= OPENING_CHAPTER_LIMIT
checks: list[Check] = [
Check(
"craft_on_doctrine_first_block",
len(on.system) >= 2 and on.system[0].text == doctrine and on.system[0].cache,
"教条须为首个 system 块、字节等于 load_craft_doctrine()、cache=True#9",
),
Check(
"craft_on_genre_in_stable_not_doctrine_not_input",
fragment in on.system[1].text
and fragment not in on.system[0].text
and fragment not in on_in,
f"genre 片段须进 stable_core 缓存块、不进教条块/volatile{case.genre}",
),
Check(
"craft_on_opening_marker",
(_OPENING_SECTION_TITLE in on_in and _OPENING_SECTION_TITLE not in on_sys)
if is_opening
else (_OPENING_SECTION_TITLE not in on_in),
f"开篇 marker{pair.chapter_no}"
+ ("须进 volatile、不进缓存 system" if is_opening else "非开篇章须不注入"),
),
Check(
"craft_on_tier_writer",
on.tier == "writer",
"不变量 #2只声明 tier=writer不写具体 model",
),
Check(
"craft_off_no_doctrine",
doctrine not in off_sys and doctrine not in off_in,
"craft-off 基线不含教条块",
),
Check(
"craft_off_no_genre_fragment",
fragment not in off_sys and fragment not in off_in,
"craft-off 基线不含 genre 片段",
),
Check(
"craft_off_no_opening_marker",
_OPENING_SECTION_TITLE not in off_in,
"craft-off 基线不含开篇 marker",
),
Check(
"craft_off_retains_body",
case.spec.title in off_sys and "请创作第" in off_in,
"剥离未误伤本体:书名与写作指令仍在",
),
]
return checks
def all_passed(checks: list[Check]) -> bool:
return all(c.passed for c in checks)
# ---- 报告渲染 ----
@dataclass(frozen=True)
class CaseReport:
case: FixtureCase
pair: PromptPair
checks: list[Check]
live: Any = None # live.LiveResult | None惰性避免 dry 路径导入 live
def _fence(label: str, body: str) -> str:
return f"**{label}**\n\n```\n{body}\n```"
def _render_pair(pair: PromptPair) -> str:
on, off = pair.craft_on, pair.craft_off
return "\n\n".join(
[
"#### craft-on发送给模型的 prompt",
_fence("system缓存前缀", system_text(on)),
_fence("inputvolatile", input_text(on)),
"#### craft-off基线 prompt",
_fence("system缓存前缀", system_text(off)),
_fence("inputvolatile", input_text(off)),
]
)
def _render_live(live: Any) -> str:
return "\n\n".join(
[
"#### 生成结果(--live",
f"served_by: `{live.served_by}`",
_fence(f"craft-on 成稿({live.chars_on} 字)", live.chapter_on),
_fence(f"craft-off 成稿({live.chars_off} 字)", live.chapter_off),
"#### LLM 五维裁判",
live.judge_text,
]
)
def render_report(reports: list[CaseReport], *, live: bool) -> str:
total_checks = sum(len(r.checks) for r in reports)
failed = sum(1 for r in reports for c in r.checks if not c.passed)
lines: list[str] = [
"# craft A/B 评估报告(灵感 F2",
"",
f"模式:{'live真实生成+裁判)' if live else 'dry零成本确定性校验'}",
f"题材:{len(reports)} 个 · 断言:{total_checks - failed}/{total_checks} 通过",
"",
]
for r in reports:
c = r.case
lines.append(f"## {c.slug} · {c.genre} · 《{c.spec.title}》 · 第 {r.pair.chapter_no}")
lines.append("")
lines.append("### dry 校验")
for chk in r.checks:
mark = "" if chk.passed else ""
lines.append(f"- {mark} `{chk.name}` — {chk.detail}")
lines.append("")
lines.append("### prompt 并排")
lines.append("")
lines.append(_render_pair(r.pair))
lines.append("")
if r.live is not None:
lines.append(_render_live(r.live))
lines.append("")
return "\n".join(lines)
def write_report(text: str, path: Any) -> Any:
from pathlib import Path
out = Path(path)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(text, encoding="utf-8")
return out

127
tests/craft_eval/live.py Normal file
View File

@@ -0,0 +1,127 @@
"""`--live` 真实生成 + LLM 五维裁判(灵感 F2
**成本警告**:本模块**真实调用付费 LLM**每题材writer 生成 2 章 + analyst 裁判 1 次)。
仅供人工手动跑,**绝不进 pytest / CI**(故与 dry 校验分文件pytest 不导入本模块)。
凭据从 DB 读(`SqlCredentialStore`),路由从 `tier_routing` 取——与生产写章同一条网关路径。
用法见 `README.md``uv run python -m tests.craft_eval --live`。
"""
from __future__ import annotations
import dataclasses
from dataclasses import dataclass
from ww_api.services.credentials import STUB_OWNER_ID, SqlCredentialStore
from ww_api.services.project_deps import build_gateway_for_tier
from ww_db import get_sessionmaker
from ww_llm_gateway.types import Block, LlmRequest, Scope
from .harness import CaseReport, PromptPair
# 五维对比裁判(对齐 craft_ab 实验的口径)——只就文本本身逐维对比,不复述原文。
JUDGE_SYSTEM = """你是资深中文网络小说主编,阅稿无数,眼光毒辣、直言不讳。
现在给你同一章大纲下、由两套不同写作指令产出的两版正文A=craft-onB=craft-off
请仅就文本本身逐维度对比谁更好每个维度给出「A更好 / B更好 / 难分」并附一句依据:
① show-don't-tell情绪是否靠动作细节外化而非旁白直说
② 对话是否有角色声音/口癖
③ 章末钩子是否成立(是否让读者想追下一章)
④ 是否注水/AI 腔(空泛排比、翻译腔、无信息量的辞藻堆砌)
⑤ 开篇代入感(前几段是否立住冲突/身份钩子、抓人)
最后给出总判「A 更好 / B 更好 / 难分」,并用 1-2 句话说明理由。
保持简洁、务实,不要复述原文。"""
# 裁判每稿截断字数(省 token
_JUDGE_EXCERPT = 1500
@dataclass(frozen=True)
class LiveResult:
chapter_on: str
chapter_off: str
chars_on: int
chars_off: int
judge_text: str
served_by: str
def char_count(text: str) -> int:
"""中文成稿近似字数:去空白后的字符数。"""
return len("".join(text.split()))
async def _stream_text(gateway: object, req: LlmRequest) -> str:
parts: list[str] = []
async for delta in gateway.stream(req): # type: ignore[attr-defined]
parts.append(delta.text)
return "".join(parts)
def _judge_request(pair: PromptPair, text_on: str, text_off: str) -> LlmRequest:
body = (
"以下是两版正文(各截取前若干字以省 token\n\n"
f"===== A 稿 / craft-on{_JUDGE_EXCERPT} 字)=====\n"
+ text_on[:_JUDGE_EXCERPT]
+ f"\n\n===== B 稿 / craft-off{_JUDGE_EXCERPT} 字)=====\n"
+ text_off[:_JUDGE_EXCERPT]
)
return LlmRequest(
tier="analyst",
system=[Block(text=JUDGE_SYSTEM, cache=True)],
input=body,
stream=False,
scope=Scope(user_id=STUB_OWNER_ID, project_id=None),
)
async def run_live(reports: list[CaseReport]) -> list[CaseReport]:
"""对每个 report 的 craft-on/off 请求真实生成一章 + 五维裁判,返回附 live 的新 report 列表。
不可变更新:用 `dataclasses.replace` 产出新 `CaseReport`,不原地改冻结实例。
"""
sm = get_sessionmaker()
out: list[CaseReport] = []
async with sm() as session:
store = SqlCredentialStore(session)
# writer / analyst 各建一次网关,跨题材复用(省装配开销)。
writer_gw = await build_gateway_for_tier(session, store, "writer")
analyst_gw = await build_gateway_for_tier(session, store, "analyst")
for report in reports:
pair = report.pair
print(f"[live] {report.case.slug}:生成 craft-on……", flush=True)
text_on = await _stream_text(writer_gw, _as_scoped(pair.craft_on))
print(f"[live] {report.case.slug}:生成 craft-off……", flush=True)
text_off = await _stream_text(writer_gw, _as_scoped(pair.craft_off))
print(f"[live] {report.case.slug}analyst 五维裁判……", flush=True)
resp = await analyst_gw.run(_judge_request(pair, text_on, text_off))
result = LiveResult(
chapter_on=text_on,
chapter_off=text_off,
chars_on=char_count(text_on),
chars_off=char_count(text_off),
judge_text=resp.text,
served_by=f"{resp.served_by.provider}:{resp.served_by.model}"
+ (" (fell_back)" if resp.served_by.fell_back else ""),
)
out.append(dataclasses.replace(report, live=result))
return out
def _as_scoped(req: LlmRequest) -> LlmRequest:
"""把请求 scope 换成 stub ownerusage_ledger.owner_id FK 须指向已 seed 的用户行)。"""
scoped = Scope(user_id=STUB_OWNER_ID, project_id=None)
return req.model_copy(update={"scope": scoped})
def summarize(reports: list[CaseReport]) -> str:
"""live 结果的一行式摘要stdout 用)。"""
lines: list[str] = []
for r in reports:
if r.live is None: # pragma: no cover - live 路径必有结果
continue
lines.append(
f"{r.case.slug}: craft-on {r.live.chars_on}字 / craft-off {r.live.chars_off}"
)
return "\n".join(lines)

121
tests/test_craft_eval.py Normal file
View File

@@ -0,0 +1,121 @@
"""craft A/B 评估回路的 dry 模式确定性断言(灵感 F2
这些断言 = 评估脚本 dry 模式的回归门禁:验证 craft 教条块 / genre 片段 / 开篇 marker
**确实按 fixture 注入了正确内容**,且 craft-off 基线**不含**这三者。dry 路径不碰 LLM
故可进常规 pytest / CI`--live` 真实生成路径不在此、不进 CI见 tests/craft_eval/README.md
"""
from __future__ import annotations
import pytest
from ww_agents import load_craft_doctrine
from ww_config import genre_craft_fragment
from tests.craft_eval import (
FIXTURES,
FixtureCase,
all_passed,
build_prompt_pair,
input_text,
render_report,
system_text,
verify_case,
write_report,
)
from tests.craft_eval.harness import OPENING_CHAPTER_LIMIT, CaseReport
_OPENING_HEADER = "开篇黄金三章"
def test_fixtures_cover_three_distinct_genres() -> None:
genres = {c.genre for c in FIXTURES}
assert {"仙侠", "都市", "悬疑"} <= genres
assert len(FIXTURES) == len(genres), "每个 fixture 应是不同题材"
for genre in genres:
assert genre_craft_fragment(genre), f"题材 {genre!r} 缺 GENRE_CRAFT 片段"
@pytest.mark.parametrize("case", FIXTURES, ids=lambda c: c.slug)
async def test_dry_verification_all_pass(case: FixtureCase) -> None:
"""每个金标准 fixture 的 dry 校验全绿——这是 craft 改动的回归门禁本体。"""
pair = await build_prompt_pair(case)
checks = verify_case(case, pair)
failed = [c for c in checks if not c.passed]
assert not failed, "dry 校验失败:\n" + "\n".join(f" {c.name}: {c.detail}" for c in failed)
assert all_passed(checks)
@pytest.mark.parametrize("case", FIXTURES, ids=lambda c: c.slug)
async def test_craft_on_injects_doctrine_genre_and_marker(case: FixtureCase) -> None:
pair = await build_prompt_pair(case)
on = pair.craft_on
# 教条块:第一个 system 块、字节等于 load_craft_doctrine()、cache=True不变量 #9
assert on.system[0].text == load_craft_doctrine()
assert on.system[0].cache is True
assert len(on.system) >= 2, "craft-on 应有 [教条, stable_core] 两块"
# genre 片段:进 stable_core 缓存块book-stable → 缓存前缀),不进易变 input#9
fragment = genre_craft_fragment(case.genre)
assert fragment is not None
assert fragment in on.system[1].text
assert fragment not in on.system[0].text, "genre 片段不应污染教条块"
assert fragment not in input_text(on), "genre 片段是 book-stable不应进 volatile input"
# 开篇 marker开篇章进 volatile input、不进缓存 system含章号→易变#9
if case.chapter_no <= OPENING_CHAPTER_LIMIT:
assert _OPENING_HEADER in input_text(on)
assert _OPENING_HEADER not in system_text(on)
# 不变量 #2只声明 tier不写 model。
assert on.tier == "writer"
@pytest.mark.parametrize("case", FIXTURES, ids=lambda c: c.slug)
async def test_craft_off_baseline_excludes_craft(case: FixtureCase) -> None:
pair = await build_prompt_pair(case)
off = pair.craft_off
assert load_craft_doctrine() not in system_text(off)
fragment = genre_craft_fragment(case.genre)
assert fragment is not None
assert fragment not in system_text(off)
assert fragment not in input_text(off)
assert _OPENING_HEADER not in input_text(off)
# 基线仍是可用写章请求保留书名与写作指令stripping 未误伤本体)。
assert case.spec.title in system_text(off)
assert "请创作第" in input_text(off)
async def test_opening_marker_absent_beyond_opening_limit() -> None:
"""章号 > 开篇上限时 craft-on 不注入开篇 marker黄金三章边界"""
case = FIXTURES[0]
pair = await build_prompt_pair(case, chapter_no=OPENING_CHAPTER_LIMIT + 1)
assert _OPENING_HEADER not in input_text(pair.craft_on)
assert _OPENING_HEADER not in system_text(pair.craft_on)
async def test_render_and_write_report(tmp_path) -> None: # type: ignore[no-untyped-def]
reports: list[CaseReport] = []
for case in FIXTURES:
pair = await build_prompt_pair(case)
reports.append(CaseReport(case=case, pair=pair, checks=verify_case(case, pair)))
text = render_report(reports, live=False)
assert "craft-on" in text and "craft-off" in text
for case in FIXTURES:
assert case.slug in text
out = write_report(text, tmp_path / "report.md")
assert out.exists()
assert out.read_text("utf-8") == text
def test_build_prompt_pair_is_deterministic_across_runs() -> None:
"""同一 fixture 两次组装字节一致(缓存前缀稳定 / 无时间戳-UUID#9"""
import asyncio
case = FIXTURES[0]
a = asyncio.run(build_prompt_pair(case))
b = asyncio.run(build_prompt_pair(case))
assert system_text(a.craft_on) == system_text(b.craft_on)
assert input_text(a.craft_on) == input_text(b.craft_on)
assert system_text(a.craft_off) == system_text(b.craft_off)

View File

@@ -101,6 +101,8 @@ _CHARACTER_CARDS = CharacterGenResult(
name="叶无尘", name="叶无尘",
role="主角", role="主角",
traits=["隐忍", "腹黑"], traits=["隐忍", "腹黑"],
appearance="剑眉星目,玄袍加身,眉心一点朱砂",
motive="核心动机:查明灭门真相并复仇;深处渴望守护余下之人",
backstory="灭门遗孤,背负血仇。", backstory="灭门遗孤,背负血仇。",
arc="从复仇者走向守护者。", arc="从复仇者走向守护者。",
speech_tics=["呵,有意思", "不过如此"], speech_tics=["呵,有意思", "不过如此"],
@@ -393,6 +395,9 @@ async def test_m5_generate_characters_gate_acknowledge_and_persist(
assert ye.relations == [] assert ye.relations == []
assert ye.role == "主角" assert ye.role == "主角"
assert ye.backstory == "灭门遗孤,背负血仇。" assert ye.backstory == "灭门遗孤,背负血仇。"
# ⑧ 穿线appearance/motive Text 列真落 pg此前一直 NULL
assert ye.appearance == "剑眉星目,玄袍加身,眉心一点朱砂"
assert ye.motive == "核心动机:查明灭门真相并复仇;深处渴望守护余下之人"
# 记账真落 pgworld generate(writer) + characters generate(writer) + # 记账真落 pgworld generate(writer) + characters generate(writer) +
# precheck(analyst, 409 路径) + precheck(analyst, 入库路径) = 4 条。 # precheck(analyst, 409 路径) + precheck(analyst, 入库路径) = 4 条。
@@ -412,6 +417,72 @@ async def test_m5_generate_characters_gate_acknowledge_and_persist(
await _cleanup(e2e_sm, project_uuid) await _cleanup(e2e_sm, project_uuid)
async def test_m5_character_upsert_backfills_appearance_motive(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""⑧ 穿线回归老角色appearance/motive 为空)再次 upsert 应回填两列update 分支)。
直连 `SqlCharacterWriteRepo`(不经端点)钉住 update 分支对新列赋值——否则老角色
永远补不上 appearance/motive风险 8
"""
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import seed_stub_user
from ww_core.domain.character_repo import SqlCharacterWriteRepo
from ww_core.domain.project_repo import ProjectCreate, SqlProjectRepo
def _card_kwargs(appearance: str, motive: str) -> dict[str, object]:
return {
"name": "旧角色",
"role": "配角",
"traits": ["沉默"],
"appearance": appearance,
"motive": motive,
"backstory": "早期入库",
"arc": "",
"speech_tics": [],
"tags": [],
"relations": [],
}
project_uuid: uuid.UUID | None = None
try:
# Arrangeseed stub user + 建项目 + 先入一张 appearance/motive 为空的老卡。
async with e2e_sm() as s:
await seed_stub_user(s)
project = await SqlProjectRepo(s).create(
STUB_OWNER_ID, ProjectCreate(title="⑧ 回填验证")
)
project_uuid = uuid.UUID(str(project.id))
await SqlCharacterWriteRepo(s).create(project_uuid, **_card_kwargs("", "")) # type: ignore[arg-type]
await s.commit()
# Act同名再次 upsert这次带 appearance/motive模拟重生成补全
async with e2e_sm() as s:
await SqlCharacterWriteRepo(s).create(
project_uuid,
**_card_kwargs("满脸风霜,独臂", "替旧主守住一个秘密"), # type: ignore[arg-type]
)
await s.commit()
# Assert仍一行按 (project_id,name) upsert两列被回填。
async with e2e_sm() as verify:
rows = (
(
await verify.execute(
select(Character).where(Character.project_id == project_uuid)
)
)
.scalars()
.all()
)
assert len(rows) == 1
assert rows[0].appearance == "满脸风霜,独臂"
assert rows[0].motive == "替旧主守住一个秘密"
finally:
if project_uuid is not None:
await _cleanup(e2e_sm, project_uuid)
async def test_m5_provider_fallback_serves_when_primary_fails( async def test_m5_provider_fallback_serves_when_primary_fails(
e2e_sm: async_sessionmaker[AsyncSession], e2e_sm: async_sessionmaker[AsyncSession],
) -> None: ) -> None: