Compare commits
10 Commits
6854dac98f
...
51bfb6e320
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51bfb6e320 | ||
|
|
3d2a8b8cee | ||
|
|
7babb4854b | ||
|
|
72b3c81146 | ||
|
|
2a7a865e98 | ||
|
|
02d19019f6 | ||
|
|
821ace5989 | ||
|
|
491d9342ef | ||
|
|
acde715515 | ||
|
|
452bd56d95 |
@@ -161,6 +161,11 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
||||
|
||||
> 格式:`- [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`——向导无 project,ledger.project_id 本 nullable);新缝 `get_project_plan_gateway`(analyst)。**零迁移**。**前端** `wizard.ts` 加 `mapPlanResultToWizardForm`(书名/结构/基调→对应字段;时空背景/结局设计→折进 premise **不静默丢**;结局取向/视角预设枚举不臆造)+ `applyPlanPatch`(**仅回填空字段、保护已编辑**)+ `canGeneratePlan`/`toPlanSeedRequest` + `useProjectPlan` hook(renderHook 测试)+ `ProjectWizard.tsx` 第 2 步 `PlanAssistant`(生成→预览→按字段回填)。**门禁全绿**:后端 ruff/format 干净 · mypy **218** · pytest **814**(+新端点 6 + agents spec 8 测)· alembic 无漂移(零 ORM 变更);前端 gen:api/lint/typecheck 干净 · vitest **526**(+28)· coverage 95%(wizard/generation 100%)· build OK。记 contracts C3 新端点。守不变量 #2/#3/#9。依赖 ④(已交付)。
|
||||
- [2026-06-26] @orchestrator — **全项目 Code Review(七维度)完成 → `QA-CR` 整改批立项**。6 个专科 reviewer 并行(后端 FastAPI 架构 / Python 规范 / LangGraph 最佳实践 / 前端 React 架构 / 前端 TS 类型安全 / 应用安全)+ 依赖时效自查;212 py + 168 tsx 全覆盖。绿门禁下仍揪出 **2 CRITICAL + 13 HIGH + 一批 MEDIUM/LOW**:**C1** Next.js 15.1.3 RCE(CVE-2025-66478「React2Shell」,App Router+RSC 受影响,升 15.1.9/15.5.7/16.x + 轮换密钥);**C2** 多章链缺 `recursion_limit`(默认 25 supersteps、每章 4 节点→超 ~6 章即崩,批量产卷不可用)。HIGH 含 Kimi OAuth 占 DB 会话 / httpx 泄漏 / Anthropic 计费记 0 / request_id 未贯通 / `.env.example` 提交可用 Fernet key / 文本输入无 `max_length` / 前端 errorCode 重复 / Drawer 焦点 / RefineView 未取消。**确认无虞**:SSRF(base_url 仅硬编码)/SQL 注入(全 ORM)/XSS(无 dangerouslySetInnerHTML)/错误信封不泄密/Kimi device-flow 无 CSRF。已登记 `QA-CR` 任务表(CR-C1/C2 + CR-H1..H12 + CR-M1..M5 + CR-L1 + CR-D1)按 owner 分派 @frontend/@backend/@llm/@devops,落地顺序见当前阶段 background。**本批为评审立项,尚无代码改动。**
|
||||
- [2026-06-23] @orchestrator — **🎉 多章工作流链(Chain Workflow)交付 + 多 agent 交叉评审 + CRITICAL 修复 + 合并 develop(merge `513bf71`)**。对标星月「一键多章」+ 差异化「每章过四审、遇冲突才停人」。设计契约 `docs/design/chain-workflow.md`(commit `5ee9799`)。**多 agent 逐学科建造**(契约先行、目录单写者):**C1 @llm**(`7f3eaab`) LangGraph **cyclic 链图** `orchestrator/chain/{state,nodes,graph}.py`(`build_chain_graph`:write→review→decide→accept 循环 + **interrupt-on-conflict** + resume;删死代码 `build_write_graph`,`write_node` 本体留用;7 单测 mock 网关+fake session+MemorySaver)·**C2 @backend**(`29349dc`) 服务/3 端点/schema/checkpointer 接线(见下条)·**C3 @db**(`548b7ab`) langgraph 检查点 4 表迁移 `d3e4f5a6b7c8`(`autocommit_block` 避开 `CREATE INDEX CONCURRENTLY` 与 alembic 事务死锁;`env.py` `include_object` 豁免这 4 表防漂移误报)·**C4 @qa**(`061792d`) E2E `tests/test_chain_workflow_e2e.py` 真 pg + mock 网关零 token(无冲突两章全自动 + 冲突→interrupt→awaiting→resume→accept)。**3 路对抗交叉评审**(python/fastapi/不变量)在**绿门禁下仍揪出 1 CRITICAL + 7 HIGH**——证伪"测试过=没问题":CRITICAL=链把**绑请求 session 的 gateway** 传进 BackgroundTask,后台跑时 session 已关→write/review 的 `usage_ledger.record()` 写进死 session **静默丢失**(链计费全丢,违 #1;E2E 清了 ledger 却没断言其存在故漏掉)。**修复**(`d1ea83b`):网关改 `chain_gateway_builder` 按节点 session 重建(仿 `digest_gateway_builder`/`style.py`);日志脱敏(`_classify_job_error`+`exc_type`,去 `str(exc)`,§5);resume 原子 claim `JobRepo.claim_awaiting_to_running`(防并发双跑同 thread_id,非 awaiting→409)+ 所有权校验(`JobView.project_id`,跨项目→404);`ChainResumeAccepted`(去 start/count=0 哨兵);删死导入 `extract_conflicts`;**补回归断言**(链 `usage_ledger` 必有行 + `chapter_reviews` 每章一行)。**出口(DoD)✅**:后端门禁绿 ruff/format 干净 · mypy **194 Success** · alembic **无漂移**(业务表零迁移,复用 `jobs.status=awaiting_input` + `result.awaiting_chapter`;唯一新 DDL=langgraph 检查点表)· pytest **608 passed**(+6 回归测)。首次真正启用 LangGraph checkpointer/interrupt。守不变量 #1/#3/#4/#5/#9。**剩余下一步(未做)**:① 前端链 UI(`pnpm gen:api` 纳入 run/resume + 发起页/进度轮询/awaiting 裁决续跑面板,复用 `ConflictAdjudication`);② §12 文档回写 `ARCHITECTURE.md §5.2`(补 cyclic 链图 + 更正现状:写章直连流式/单章 accept 为事务/链图首启 checkpointer);③ 未 push 远端。可选:续写/扩写节点入链;UGC 链市场需多租户(deferred)。
|
||||
- [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/#4;token 不入 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/无网络/无真 LLM):run→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。**唯一新增 DDL(langgraph 检查点表)= 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,余 4rem),6 个固定高度页改用 `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。TDD:ai-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-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/react;CodexPage 经 `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)。TDD:graphLayout **+22 测**(role 映射/节点去空重名/kind 标签+默认/悬空跳过/自指跳过/无向去重/边 id 唯一/悬空计数/布局确定性+不可变/端到端);组件走人工/E2E。前端门禁全绿:lint 干净 · typecheck 干净 · **vitest 565 passed** · **coverage lib 95.4%(graphLayout.ts 97.6%)** · **build OK**(codex 路由 7.58 kB,React Flow 懒加载独立 chunk)。注:`pnpm build` 门禁与运行中的 `next dev`(3001) 共享 `.next` → 触发 dev 服务临时 500(stale chunk),非代码缺陷、dev watcher 下次重编自愈,未重启服务。
|
||||
|
||||
@@ -55,6 +55,9 @@ class FakeProjectRepo:
|
||||
theme=data.theme,
|
||||
selling_points=list(data.selling_points),
|
||||
structure=data.structure,
|
||||
tone=data.tone,
|
||||
ending_type=data.ending_type,
|
||||
narrative_pov=data.narrative_pov,
|
||||
updated_at=self.now,
|
||||
)
|
||||
self.rows[pid] = (owner_id, view)
|
||||
@@ -148,6 +151,7 @@ class FakeReviewRepo:
|
||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||
style: dict[str, Any] | None = None,
|
||||
pace: dict[str, Any] | None = None,
|
||||
characterization: dict[str, Any] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> ReviewView:
|
||||
view = ReviewView(
|
||||
@@ -159,6 +163,7 @@ class FakeReviewRepo:
|
||||
foreshadow_sug=[dict(s) for s in (foreshadow_sug or [])],
|
||||
style=style,
|
||||
pace=pace,
|
||||
characterization=characterization,
|
||||
health_score=health_score,
|
||||
)
|
||||
# 新→旧:插到列表头部。
|
||||
|
||||
@@ -115,6 +115,7 @@ class _FakeReviewRepo:
|
||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||
style: dict[str, Any] | None = None,
|
||||
pace: dict[str, Any] | None = None,
|
||||
characterization: dict[str, Any] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> Any:
|
||||
rec = _FakeReviewRecord(chapter_no, list(conflicts))
|
||||
|
||||
@@ -75,6 +75,8 @@ class _FakeCharacterWriteRepo:
|
||||
name: str,
|
||||
role: str,
|
||||
traits: list[str],
|
||||
appearance: str,
|
||||
motive: str,
|
||||
backstory: str,
|
||||
arc: str,
|
||||
speech_tics: list[str],
|
||||
@@ -91,6 +93,8 @@ class _FakeCharacterWriteRepo:
|
||||
{
|
||||
"role": role,
|
||||
"traits": list(traits),
|
||||
"appearance": appearance,
|
||||
"motive": motive,
|
||||
"arc": arc,
|
||||
"speech_tics": list(speech_tics),
|
||||
"tags": list(tags),
|
||||
@@ -106,6 +110,8 @@ class _FakeCharacterWriteRepo:
|
||||
"name": name,
|
||||
"role": role,
|
||||
"traits": list(traits),
|
||||
"appearance": appearance,
|
||||
"motive": motive,
|
||||
"arc": arc,
|
||||
"speech_tics": list(speech_tics),
|
||||
"tags": list(tags),
|
||||
@@ -463,6 +469,62 @@ async def test_ingest_relations_persist_on_write() -> None:
|
||||
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:
|
||||
# Arrange:混入脏条目(非 dict / 缺 name / 缺 kind)。
|
||||
from ww_api.routers.generation import _relations_from_jsonb
|
||||
@@ -572,6 +634,8 @@ def _codex_memory() -> Any:
|
||||
name="叶寒",
|
||||
role="主角",
|
||||
traits={"items": ["腹黑", "护短"]},
|
||||
appearance="剑眉星目,一袭青衫",
|
||||
motive="为母复仇、登临帝位",
|
||||
backstory="孤儿出身",
|
||||
arc={"text": "从孤儿到帝王"},
|
||||
speech_tics={"items": ["哼"]},
|
||||
@@ -668,6 +732,9 @@ async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
|
||||
assert card["speech_tics"] == ["哼"]
|
||||
assert card["arc"] == "从孤儿到帝王"
|
||||
assert card["tags"] == ["天才"]
|
||||
# ⑧ 穿线:读端点暴露 appearance/motive(Text 列直落,不形变)。
|
||||
assert card["appearance"] == "剑眉星目,一袭青衫"
|
||||
assert card["motive"] == "为母复仇、登临帝位"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
51
apps/api/tests/test_generation_schema.py
Normal file
51
apps/api/tests/test_generation_schema.py
Normal 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})
|
||||
197
apps/api/tests/test_project_plan_endpoint.py
Normal file
197
apps/api/tests/test_project_plan_endpoint.py
Normal 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
|
||||
@@ -150,6 +150,54 @@ async def test_create_project_returns_201() -> None:
|
||||
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
|
||||
async def test_list_projects() -> None:
|
||||
client, repo, _, _ = _make_client()
|
||||
|
||||
@@ -209,6 +209,70 @@ async def test_accept_promotes_appends_digest_records_decisions() -> None:
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_characterization_does_not_block_accept() -> None:
|
||||
"""灵感③ advisory:人物塑造问题(零 continuity 冲突)不阻断验收(D2)。
|
||||
|
||||
留痕带 characterization issues 但 conflicts=[] → 验收 gate 只读 continuity conflicts,
|
||||
直通晋升;characterization 从不进 conflicts、从不产 decisions。
|
||||
"""
|
||||
from ww_api.services.digest_extraction import ChapterDigestFacts
|
||||
|
||||
review_repo = FakeReviewRepo()
|
||||
pid = uuid.uuid4()
|
||||
# 零 continuity 冲突,但有 advisory 人物塑造问题。
|
||||
await review_repo.record(
|
||||
pid,
|
||||
1,
|
||||
conflicts=[],
|
||||
characterization={
|
||||
"issues": [
|
||||
{
|
||||
"character": "沈砚",
|
||||
"aspect": "动机一致性",
|
||||
"where": "第3段",
|
||||
"quote": "他忽然转身就走。",
|
||||
"diagnosis": "与既定护妹动机断裂",
|
||||
"suggestion": "退缩扣回护妹动机",
|
||||
"confidence": 0.82,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="s"))
|
||||
client, chapter_repo, _, digest_repo, session = _make_client(
|
||||
review_repo=review_repo, digest_gateway=digest_gw
|
||||
)
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/1/accept",
|
||||
# 无需对人物塑造做任何裁决(advisory 无 conflict 通道)。
|
||||
json={"final_text": "作者终稿正文。", "decisions": []},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["accepted_version"] == 1
|
||||
assert session.commits == 1
|
||||
# characterization 留痕不产 decisions(advisory);晋升照常。
|
||||
accepted = await chapter_repo.latest_accepted(pid, 1)
|
||||
assert accepted is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_characterization_surfaces_in_review_history() -> None:
|
||||
"""列 characterization 经 GET .../reviews 透传到历史项(前端面板 seed 用)。"""
|
||||
review_repo = FakeReviewRepo()
|
||||
pid = uuid.uuid4()
|
||||
await review_repo.record(
|
||||
pid, 1, conflicts=[], characterization={"issues": [{"character": "沈砚"}]}
|
||||
)
|
||||
client, _, _, _, _ = _make_client(review_repo=review_repo)
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{pid}/chapters/1/reviews")
|
||||
assert resp.status_code == 200
|
||||
row = resp.json()["reviews"][0]
|
||||
assert row["characterization"]["issues"][0]["character"] == "沈砚"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_with_no_review_succeeds() -> None:
|
||||
"""无审稿留痕(零冲突)→ gate 直通,仍晋升 + 提炼 digest。"""
|
||||
|
||||
@@ -26,8 +26,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_agents import (
|
||||
CharacterCard,
|
||||
CharacterRelation,
|
||||
ProjectPlanResult,
|
||||
character_gen_spec,
|
||||
continuity_spec,
|
||||
project_plan_spec,
|
||||
worldbuilder_spec,
|
||||
)
|
||||
from ww_core.domain import (
|
||||
@@ -37,8 +39,10 @@ from ww_core.domain import (
|
||||
)
|
||||
from ww_core.domain.repositories import MemoryRepos
|
||||
from ww_core.orchestrator import (
|
||||
build_brief_context,
|
||||
precheck_generated_cards,
|
||||
run_character_gen,
|
||||
run_generator,
|
||||
run_worldbuilder,
|
||||
)
|
||||
from ww_db import get_session
|
||||
@@ -64,6 +68,7 @@ from ww_api.schemas.generation import (
|
||||
WorldGenerateRequest,
|
||||
WorldGenPreviewResponse,
|
||||
)
|
||||
from ww_api.schemas.projects import ProjectPlanGenerateRequest, ProjectPlanView
|
||||
from ww_api.schemas.rules import RuleView
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import (
|
||||
@@ -71,6 +76,7 @@ from ww_api.services.project_deps import (
|
||||
get_character_write_repo,
|
||||
get_memory_repos,
|
||||
get_precheck_gateway,
|
||||
get_project_plan_gateway,
|
||||
get_project_repo,
|
||||
get_rule_write_repo,
|
||||
get_skill_registry,
|
||||
@@ -90,6 +96,7 @@ SkillRegistryDep = Annotated[SkillRegistry, Depends(get_skill_registry)]
|
||||
WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)]
|
||||
CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)]
|
||||
PrecheckGatewayDep = Annotated[Gateway, Depends(get_precheck_gateway)]
|
||||
ProjectPlanGatewayDep = Annotated[Gateway, Depends(get_project_plan_gateway)]
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
"""已有角色简表(喂 character-gen 防雷同 + precheck 真相源)。确定性、保序。"""
|
||||
if not cards:
|
||||
@@ -148,6 +195,8 @@ async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> li
|
||||
name=v.name,
|
||||
role=v.role or "",
|
||||
traits=traits,
|
||||
appearance=v.appearance or "",
|
||||
motive=v.motive or "",
|
||||
backstory=v.backstory or "",
|
||||
arc=arc or "",
|
||||
speech_tics=tics,
|
||||
@@ -244,6 +293,7 @@ async def generate_characters(
|
||||
gateway=gateway,
|
||||
user_id=STUB_OWNER_ID,
|
||||
project_id=project_id,
|
||||
role_mix=body.role_mix,
|
||||
)
|
||||
await session.commit()
|
||||
log.info(
|
||||
@@ -329,6 +379,8 @@ async def ingest_characters(
|
||||
name=card.name,
|
||||
role=card.role,
|
||||
traits=list(card.traits),
|
||||
appearance=card.appearance,
|
||||
motive=card.motive,
|
||||
backstory=card.backstory,
|
||||
arc=card.arc,
|
||||
speech_tics=list(card.speech_tics),
|
||||
@@ -426,6 +478,53 @@ async def list_skills(registry: SkillRegistryDep) -> SkillListResponse:
|
||||
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 卡转换 ----
|
||||
|
||||
|
||||
@@ -434,6 +533,8 @@ def _card_to_view(card: CharacterCard) -> CharacterCardView:
|
||||
name=card.name,
|
||||
role=card.role,
|
||||
traits=list(card.traits),
|
||||
appearance=card.appearance,
|
||||
motive=card.motive,
|
||||
backstory=card.backstory,
|
||||
arc=card.arc,
|
||||
speech_tics=list(card.speech_tics),
|
||||
@@ -449,6 +550,8 @@ def _view_to_card(view: CharacterCardView) -> CharacterCard:
|
||||
name=view.name,
|
||||
role=view.role,
|
||||
traits=list(view.traits),
|
||||
appearance=view.appearance,
|
||||
motive=view.motive,
|
||||
backstory=view.backstory,
|
||||
arc=view.arc,
|
||||
speech_tics=list(view.speech_tics),
|
||||
|
||||
@@ -127,6 +127,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
|
||||
theme=body.theme,
|
||||
selling_points=body.selling_points,
|
||||
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))
|
||||
@@ -485,6 +488,7 @@ async def list_reviews(
|
||||
foreshadow_sug=v.foreshadow_sug,
|
||||
style=v.style,
|
||||
pace=v.pace,
|
||||
characterization=v.characterization,
|
||||
health_score=v.health_score,
|
||||
decisions=v.decisions,
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from ww_api.schemas.rules import RuleView
|
||||
|
||||
@@ -47,11 +47,40 @@ class WorldGenPreviewResponse(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="角色生成需求")
|
||||
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_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):
|
||||
@@ -67,7 +96,9 @@ class CharacterCardView(BaseModel):
|
||||
|
||||
name: 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="背景故事")
|
||||
arc: str = Field(description="人物弧光(一句话)")
|
||||
speech_tics: list[str] = Field(default_factory=list, description="口癖/语言风格")
|
||||
|
||||
@@ -22,6 +22,10 @@ class ProjectCreateRequest(BaseModel):
|
||||
theme: str | None = None
|
||||
selling_points: list[str] = Field(default_factory=list)
|
||||
structure: str | None = None
|
||||
# 立项书级常量(灵感④)——free text + 前端预设,无 Literal(Literal 只留 Verdict)。
|
||||
tone: str | None = None
|
||||
ending_type: str | None = None
|
||||
narrative_pov: str | None = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
@@ -35,6 +39,9 @@ class ProjectResponse(BaseModel):
|
||||
theme: str | None = None
|
||||
selling_points: list[str] = Field(default_factory=list)
|
||||
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="项目最近更新时间")
|
||||
pending_review_count: int = Field(default=0, ge=0, description="待审稿章节数")
|
||||
|
||||
@@ -45,6 +52,64 @@ class ProjectListResponse(BaseModel):
|
||||
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):
|
||||
"""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)
|
||||
style: dict[str, Any] | None = None
|
||||
pace: dict[str, Any] | None = None
|
||||
# 灵感③ advisory 人物塑造审留痕(issues 列表 dict);仅建议、不阻断验收。
|
||||
characterization: dict[str, Any] | None = None
|
||||
health_score: int | None = None
|
||||
decisions: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@@ -268,6 +268,14 @@ async def get_precheck_gateway(
|
||||
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:
|
||||
"""验收后到期扫描的**独立 session 工厂**缝。
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"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 { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
@@ -14,17 +15,26 @@ import { TextArea } from "@/components/ui/TextArea";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import type { ProjectPlanView } from "@/lib/api/types";
|
||||
import { useProjectPlan } from "@/lib/wizard/useProjectPlan";
|
||||
import {
|
||||
ENDING_TYPES,
|
||||
GENRES,
|
||||
NARRATIVE_POVS,
|
||||
SELLING_POINT_PRESETS,
|
||||
STEP_TITLES,
|
||||
STRUCTURES,
|
||||
TONES,
|
||||
WIZARD_STEPS,
|
||||
applyPlanPatch,
|
||||
canAdvance,
|
||||
canGeneratePlan,
|
||||
canSubmit,
|
||||
clampStep,
|
||||
emptyWizardForm,
|
||||
mapPlanResultToWizardForm,
|
||||
toCreateRequest,
|
||||
toPlanSeedRequest,
|
||||
type WizardForm,
|
||||
} from "@/lib/wizard/wizard";
|
||||
|
||||
@@ -58,6 +68,21 @@ export function ProjectWizard() {
|
||||
: [...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 goNext = (): void => setStep((s) => clampStep(s + 1));
|
||||
|
||||
@@ -107,6 +132,7 @@ export function ProjectWizard() {
|
||||
form={form}
|
||||
update={update}
|
||||
toggleSellingPoint={toggleSellingPoint}
|
||||
onApplyPlan={applyPlan}
|
||||
/>
|
||||
)}
|
||||
{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({
|
||||
form,
|
||||
update,
|
||||
toggleSellingPoint,
|
||||
}: StepProps & { toggleSellingPoint: (p: string) => void }) {
|
||||
onApplyPlan,
|
||||
}: StepProps & {
|
||||
toggleSellingPoint: (p: string) => void;
|
||||
onApplyPlan: (plan: ProjectPlanView) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Field label="一句话故事(logline)">
|
||||
@@ -211,6 +317,7 @@ function StepStory({
|
||||
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
|
||||
/>
|
||||
</Field>
|
||||
<PlanAssistant form={form} onApplyPlan={onApplyPlan} />
|
||||
<Field label="故事结构">
|
||||
<SegmentedControl
|
||||
ariaLabel="故事结构"
|
||||
@@ -267,6 +374,37 @@ function StepPremise({ form, update }: StepProps) {
|
||||
placeholder="例:抗争与代价"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -302,6 +440,9 @@ function StepConfirm({ form }: { form: WizardForm }) {
|
||||
label: "核心卖点",
|
||||
value: form.sellingPoints.length > 0 ? form.sellingPoints.join("、") : "未选择",
|
||||
},
|
||||
{ label: "基调", value: form.tone || "未选择" },
|
||||
{ label: "叙事视角", value: form.narrativePov || "未选择" },
|
||||
{ label: "结局取向", value: form.endingType || "未选择" },
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
|
||||
149
apps/web/components/characters/RelationshipGraph.tsx
Normal file
149
apps/web/components/characters/RelationshipGraph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
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 { CharacterGenerator } from "@/components/generation/CharacterGenerator";
|
||||
@@ -39,6 +40,23 @@ const TABS: { key: CodexTab; label: string }[] = [
|
||||
{ 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>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
// 设定库 Codex(UX §6.5):管理人物 / 世界观 / 时间线。
|
||||
// 初始列表来自后端读端点(跨会话全量真源);本会话新入库的角色卡再合并补显,
|
||||
// 故刷新后不再为空、且不与真源重复(按 name 去重,见 mergeCharacterCards)。
|
||||
@@ -143,6 +161,15 @@ export function CodexPage({
|
||||
/>
|
||||
)}
|
||||
</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">
|
||||
<CharacterGenerator
|
||||
projectId={project.id}
|
||||
|
||||
@@ -55,6 +55,40 @@ export function CharacterCardItem({
|
||||
</p>
|
||||
) : 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">
|
||||
<span className="text-ink">背景:</span>
|
||||
{card.backstory}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { CharacterCardView } from "@/lib/api/types";
|
||||
@@ -14,11 +15,26 @@ import {
|
||||
clampCharacterCount,
|
||||
MAX_CHARACTER_COUNT,
|
||||
MIN_CHARACTER_COUNT,
|
||||
roleMixTotal,
|
||||
sanitizeRoleMix,
|
||||
updateCard,
|
||||
} from "@/lib/generation/cards";
|
||||
import { useCharacterGen } from "@/lib/generation/useCharacterGen";
|
||||
import { CharacterCardItem } from "./CharacterCardItem";
|
||||
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 {
|
||||
projectId: string;
|
||||
@@ -35,6 +51,10 @@ export function CharacterGenerator({
|
||||
const [brief, setBrief] = useState("");
|
||||
const [count, setCount] = useState(MIN_CHARACTER_COUNT);
|
||||
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 [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
@@ -46,8 +66,17 @@ export function CharacterGenerator({
|
||||
}, []);
|
||||
|
||||
const onGenerate = useCallback(async () => {
|
||||
if (mode === "mix") {
|
||||
await gen.generate({
|
||||
projectId,
|
||||
brief,
|
||||
count,
|
||||
roleMix: sanitizeRoleMix(mixRows),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await gen.generate({ projectId, brief, count, role });
|
||||
}, [gen, projectId, brief, count, role]);
|
||||
}, [gen, projectId, brief, count, role, mode, mixRows]);
|
||||
|
||||
// gen.cards 变化(新预览)→ 重新同步本地草稿。
|
||||
const previewKey = gen.cards.map((c) => c.name).join("|");
|
||||
@@ -96,7 +125,25 @@ export function CharacterGenerator({
|
||||
placeholder="如:一个亦正亦邪的剑修,背负灭门血仇"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="mb-3">
|
||||
<SegmentedControl
|
||||
options={MODE_OPTIONS}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
ariaLabel="生成模式"
|
||||
/>
|
||||
</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"
|
||||
@@ -119,13 +166,17 @@ export function CharacterGenerator({
|
||||
placeholder="主角 / CP / 对手 / 导师 / 工具人"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => void onGenerate()}
|
||||
disabled={
|
||||
generating ||
|
||||
brief.trim().length === 0 ||
|
||||
count < MIN_CHARACTER_COUNT ||
|
||||
count > MAX_CHARACTER_COUNT
|
||||
(mode === "mix"
|
||||
? mixTotal < MIN_CHARACTER_COUNT
|
||||
: count < MIN_CHARACTER_COUNT || count > MAX_CHARACTER_COUNT)
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
@@ -133,7 +184,6 @@ export function CharacterGenerator({
|
||||
{generating ? "生成中…" : "生成"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generating ? (
|
||||
<div className="rounded border border-line bg-panel/50 p-4 text-cinnabar">
|
||||
|
||||
82
apps/web/components/generation/RoleMixEditor.tsx
Normal file
82
apps/web/components/generation/RoleMixEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
apps/web/components/review/CharacterizationPanel.tsx
Normal file
102
apps/web/components/review/CharacterizationPanel.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, CircleDashed, UserRound } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { CharacterizationIssue, CharacterizationReport } from "@/lib/review/sse";
|
||||
|
||||
interface CharacterizationPanelProps {
|
||||
characterization: CharacterizationReport | null;
|
||||
incomplete: boolean;
|
||||
}
|
||||
|
||||
// 高置信度阈值:≥0.7 为主区常显,<0.7 折进「更多低置信度建议」抽屉(控作者注意力预算,灵感 §10)。
|
||||
const HIGH_CONFIDENCE = 0.7;
|
||||
|
||||
// 人物塑造报告区(灵感③ advisory 单维):只读建议、**无裁决 UI**(不进 conflicts/不阻断验收)。
|
||||
// 以人物卡 motive/appearance 为客观锚点;每条带逐字引用 + 置信度。按置信度降序,
|
||||
// 低置信度折叠,避免维度噪声淹没作者。
|
||||
export function CharacterizationPanel({
|
||||
characterization,
|
||||
incomplete,
|
||||
}: CharacterizationPanelProps) {
|
||||
if (incomplete) {
|
||||
return (
|
||||
<section>
|
||||
<Badge variant="warning">
|
||||
<CircleDashed className="h-3 w-3" aria-hidden="true" />
|
||||
未完成
|
||||
</Badge>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const issues = characterization?.issues ?? [];
|
||||
if (characterization === null || issues.length === 0) {
|
||||
return (
|
||||
<section>
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
人物塑造无明显问题
|
||||
</Badge>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// 置信度降序(优先级合并);高/低分区。
|
||||
const sorted = [...issues].sort((a, b) => b.confidence - a.confidence);
|
||||
const high = sorted.filter((i) => i.confidence >= HIGH_CONFIDENCE);
|
||||
const low = sorted.filter((i) => i.confidence < HIGH_CONFIDENCE);
|
||||
|
||||
return (
|
||||
<section className="space-y-2 text-xs">
|
||||
<p className="rounded border border-dashed border-line/70 bg-bg/40 px-3 py-1.5 text-[11px] text-ink-soft">
|
||||
以下为人物塑造建议(仅供参考,不影响验收)。忽略文本长度与辞藻,只看人物是否写得立体。
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{(high.length > 0 ? high : sorted).map((issue, i) => (
|
||||
<CharacterizationIssueCard key={i} issue={issue} />
|
||||
))}
|
||||
</ul>
|
||||
{high.length > 0 && low.length > 0 ? (
|
||||
<details className="group rounded border border-line/70 bg-bg/35 p-2">
|
||||
<summary className="cursor-pointer list-none text-2xs font-semibold text-ink-soft">
|
||||
更多低置信度建议({low.length})
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{low.map((issue, i) => (
|
||||
<CharacterizationIssueCard key={i} issue={issue} />
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CharacterizationIssueCard({ issue }: { issue: CharacterizationIssue }) {
|
||||
const pct = Math.round(Math.max(0, Math.min(1, issue.confidence)) * 100);
|
||||
return (
|
||||
<li className="rounded border border-line/70 bg-bg/35 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-1.5 font-semibold text-ink">
|
||||
<UserRound className="h-3.5 w-3.5 text-ink-soft" aria-hidden="true" />
|
||||
{issue.character || "(未归属角色)"}
|
||||
{issue.aspect ? <Badge variant="accent">{issue.aspect}</Badge> : null}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-2xs text-ink-soft" title="置信度">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
{issue.where ? <p className="mt-1 text-ink-soft">定位:{issue.where}</p> : null}
|
||||
{issue.quote ? (
|
||||
<blockquote className="mt-1 border-l-2 border-line pl-2 text-ink-soft italic">
|
||||
「{issue.quote}」
|
||||
</blockquote>
|
||||
) : null}
|
||||
{issue.diagnosis ? <p className="mt-1 text-ink">{issue.diagnosis}</p> : null}
|
||||
{issue.suggestion ? (
|
||||
<p className="mt-1 text-ink-soft">建议:{issue.suggestion}</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "@/lib/review/decisions";
|
||||
import {
|
||||
latestReview,
|
||||
normalizeCharacterization,
|
||||
normalizeConflicts,
|
||||
normalizeForeshadowSug,
|
||||
normalizePace,
|
||||
@@ -56,6 +57,7 @@ import { useToast } from "@/components/Toast";
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { AcceptPanel } from "./AcceptPanel";
|
||||
import { AnnotatedText } from "./AnnotatedText";
|
||||
import { CharacterizationPanel } from "./CharacterizationPanel";
|
||||
import { ConflictCard } from "./ConflictCard";
|
||||
import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
|
||||
import { PacePanel } from "./PacePanel";
|
||||
@@ -128,6 +130,10 @@ export function ReviewReport({
|
||||
() => normalizeStyleDrift(initialReview),
|
||||
[initialReview],
|
||||
);
|
||||
const seededCharacterization = useMemo(
|
||||
() => normalizeCharacterization(initialReview),
|
||||
[initialReview],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
@@ -135,22 +141,32 @@ export function ReviewReport({
|
||||
seededConflicts.length > 0 ||
|
||||
seededForeshadow.length > 0 ||
|
||||
seededPace !== null ||
|
||||
seededStyle !== null;
|
||||
seededStyle !== null ||
|
||||
seededCharacterization !== null;
|
||||
if (hasSeed) {
|
||||
review.seed({
|
||||
conflicts: seededConflicts,
|
||||
foreshadow: seededForeshadow,
|
||||
pace: seededPace,
|
||||
style: seededStyle,
|
||||
characterization: seededCharacterization,
|
||||
});
|
||||
}
|
||||
}, [review, seededConflicts, seededForeshadow, seededPace, seededStyle]);
|
||||
}, [
|
||||
review,
|
||||
seededConflicts,
|
||||
seededForeshadow,
|
||||
seededPace,
|
||||
seededStyle,
|
||||
seededCharacterization,
|
||||
]);
|
||||
|
||||
// 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。
|
||||
const conflicts: ReviewConflict[] = review.state.conflicts;
|
||||
const foreshadow = review.state.foreshadow;
|
||||
const pace = review.state.pace;
|
||||
const style = review.state.style;
|
||||
const characterization = review.state.characterization;
|
||||
// 伏笔徽标/验收预览统一用「仍待处理数」(随登记/回收递减);未上报前回退原始建议数。
|
||||
const foreshadowOpen = foreshadowRemaining ?? foreshadow.length;
|
||||
const sectionStatus = (name: string): string | undefined =>
|
||||
@@ -359,7 +375,7 @@ export function ReviewReport({
|
||||
const fresh = normalizeConflicts(latestReview(data?.reviews));
|
||||
setMissing(new Set(outcome.missingIndices));
|
||||
if (fresh.length > 0) {
|
||||
review.seed({ conflicts: fresh, foreshadow, pace, style });
|
||||
review.seed({ conflicts: fresh, foreshadow, pace, style, characterization });
|
||||
setDrafts(emptyDecisions(fresh.length));
|
||||
toast("审稿报告已刷新:检测到未裁决冲突,请裁决后再验收", "error");
|
||||
} else {
|
||||
@@ -405,7 +421,8 @@ export function ReviewReport({
|
||||
seededConflicts.length > 0 ||
|
||||
foreshadow.length > 0 ||
|
||||
pace !== null ||
|
||||
style !== null;
|
||||
style !== null ||
|
||||
characterization !== null;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -671,6 +688,35 @@ export function ReviewReport({
|
||||
onRefine={onRefineSegment}
|
||||
/>
|
||||
</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 ? (
|
||||
<RefineView
|
||||
projectId={project.id}
|
||||
|
||||
207
apps/web/lib/api/schema.d.ts
vendored
207
apps/web/lib/api/schema.d.ts
vendored
@@ -560,6 +560,29 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -922,9 +945,21 @@ export interface components {
|
||||
role: string;
|
||||
/**
|
||||
* Traits
|
||||
* @description 性格特质清单
|
||||
* @description 性格特质清单(性格三层)
|
||||
*/
|
||||
traits?: string[];
|
||||
/**
|
||||
* Appearance
|
||||
* @description 外貌形象(缺则空串)
|
||||
* @default
|
||||
*/
|
||||
appearance: string;
|
||||
/**
|
||||
* Motive
|
||||
* @description 核心动机/欲望/恐惧(缺则空串)
|
||||
* @default
|
||||
*/
|
||||
motive: string;
|
||||
/**
|
||||
* Backstory
|
||||
* @description 背景故事
|
||||
@@ -962,6 +997,10 @@ export interface components {
|
||||
/**
|
||||
* CharacterGenerateRequest
|
||||
* @description POST /projects/:id/characters/generate:据需求生成角色卡(预览,群像防雷同)。
|
||||
*
|
||||
* `role_mix`(⑦ 群像按定位配比)与 `count` 的关系:`role_mix` 在场时 `count` 取各值之和
|
||||
* (忽略客户端显式 count),每项数量须 ≥ 1、总和须 ≤ `MAX_GENERATE_COUNT`;缺省则退回
|
||||
* 单一 `role` + `count` 语义。
|
||||
*/
|
||||
CharacterGenerateRequest: {
|
||||
/**
|
||||
@@ -971,7 +1010,7 @@ export interface components {
|
||||
brief: string;
|
||||
/**
|
||||
* Count
|
||||
* @description 生成数量
|
||||
* @description 生成数量(role_mix 在场时取各值之和)
|
||||
* @default 1
|
||||
*/
|
||||
count: number;
|
||||
@@ -980,6 +1019,13 @@ export interface components {
|
||||
* @description 角色定位(主角/CP/对手/导师/工具人 等)
|
||||
*/
|
||||
role?: string | null;
|
||||
/**
|
||||
* Role Mix
|
||||
* @description 按定位配比生成 {定位: 数量};在场时 count=各值之和(每项≥1、总和≤12)
|
||||
*/
|
||||
role_mix?: {
|
||||
[key: string]: number;
|
||||
} | null;
|
||||
};
|
||||
/**
|
||||
* CharacterIngestRequest
|
||||
@@ -1506,6 +1552,12 @@ export interface components {
|
||||
selling_points?: string[];
|
||||
/** Structure */
|
||||
structure?: string | null;
|
||||
/** Tone */
|
||||
tone?: string | null;
|
||||
/** Ending Type */
|
||||
ending_type?: string | null;
|
||||
/** Narrative Pov */
|
||||
narrative_pov?: string | null;
|
||||
};
|
||||
/**
|
||||
* ProjectListResponse
|
||||
@@ -1515,6 +1567,114 @@ export interface components {
|
||||
/** Projects */
|
||||
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
|
||||
* @description 项目视图(创建/列表/详情共用)。
|
||||
@@ -1539,6 +1699,12 @@ export interface components {
|
||||
selling_points?: string[];
|
||||
/** Structure */
|
||||
structure?: string | null;
|
||||
/** Tone */
|
||||
tone?: string | null;
|
||||
/** Ending Type */
|
||||
ending_type?: string | null;
|
||||
/** Narrative Pov */
|
||||
narrative_pov?: string | null;
|
||||
/**
|
||||
* Updated At
|
||||
* @description 项目最近更新时间
|
||||
@@ -1673,6 +1839,10 @@ export interface components {
|
||||
pace?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Characterization */
|
||||
characterization?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Health Score */
|
||||
health_score?: number | null;
|
||||
/** 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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { components } from "./schema";
|
||||
export type ProjectResponse = components["schemas"]["ProjectResponse"];
|
||||
export type ProjectCreateRequest = components["schemas"]["ProjectCreateRequest"];
|
||||
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 DraftResponse = components["schemas"]["DraftResponse"];
|
||||
// GET .../draft 读端点:含正文,供工作台重访时重载编辑器(404→空编辑器)。
|
||||
|
||||
258
apps/web/lib/characters/graphLayout.test.ts
Normal file
258
apps/web/lib/characters/graphLayout.test.ts
Normal 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=name,role 决定分组/配色", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
245
apps/web/lib/characters/graphLayout.ts
Normal file
245
apps/web/lib/characters/graphLayout.ts
Normal 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 };
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
mergeCharacterCards,
|
||||
mergeWorldEntities,
|
||||
MIN_CHARACTER_COUNT,
|
||||
roleMixTotal,
|
||||
sanitizeRoleMix,
|
||||
updateCard,
|
||||
worldEntityRules,
|
||||
} from "./cards";
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
const card = (over: Partial<CharacterCardView> = {}): CharacterCardView => ({
|
||||
name: "甲",
|
||||
role: "主角",
|
||||
appearance: "白衣如雪",
|
||||
motive: "为家族正名",
|
||||
backstory: "出身寒门",
|
||||
arc: "由怯转勇",
|
||||
...over,
|
||||
@@ -60,6 +64,62 @@ describe("buildCharacterGenerateRequest", () => {
|
||||
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", () => {
|
||||
@@ -86,6 +146,18 @@ describe("updateCard", () => {
|
||||
expect(original.name).toBe("甲");
|
||||
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", () => {
|
||||
|
||||
Binary file not shown.
@@ -53,6 +53,24 @@ describe("useCharacterGen", () => {
|
||||
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 () => {
|
||||
post.mockResolvedValue({ data: { cards: null }, error: null });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface CharacterGenInput {
|
||||
brief: string;
|
||||
count: number;
|
||||
role?: string | null;
|
||||
// 群像按定位配比(⑦);在场时后端把 count 定为各值之和,忽略 count/role。
|
||||
roleMix?: Readonly<Record<string, number>> | null;
|
||||
}
|
||||
|
||||
export interface UseCharacterGen {
|
||||
@@ -50,7 +52,7 @@ export function useCharacterGen(): UseCharacterGen {
|
||||
const toast = useToast();
|
||||
|
||||
const generate = useCallback<UseCharacterGen["generate"]>(
|
||||
async ({ projectId, brief, count, role }) => {
|
||||
async ({ projectId, brief, count, role, roleMix }) => {
|
||||
setGenStatus("generating");
|
||||
setCards([]);
|
||||
setConflicts(null);
|
||||
@@ -60,7 +62,7 @@ export function useCharacterGen(): UseCharacterGen {
|
||||
"/projects/{project_id}/characters/generate",
|
||||
{
|
||||
params: { path: { project_id: projectId } },
|
||||
body: buildCharacterGenerateRequest(brief, count, role),
|
||||
body: buildCharacterGenerateRequest(brief, count, role, roleMix),
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
|
||||
108
apps/web/lib/review/characterization-sse.test.ts
Normal file
108
apps/web/lib/review/characterization-sse.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
initialReviewState,
|
||||
parseReviewBlock,
|
||||
reduceReview,
|
||||
type ReviewSseEvent,
|
||||
} from "./sse";
|
||||
|
||||
const FULL_ISSUE = {
|
||||
character: "沈砚",
|
||||
aspect: "动机一致性",
|
||||
where: "第3段",
|
||||
quote: "他忽然转身。",
|
||||
diagnosis: "动机断裂",
|
||||
suggestion: "扣回护妹动机",
|
||||
confidence: 0.82,
|
||||
};
|
||||
|
||||
describe("parseReviewBlock — characterization (灵感③ advisory)", () => {
|
||||
it("parses a characterization frame with a full issue", () => {
|
||||
const evt = parseReviewBlock(
|
||||
`event:characterization\ndata:{"issues":[${JSON.stringify(FULL_ISSUE)}]}`,
|
||||
);
|
||||
expect(evt).toEqual({
|
||||
event: "characterization",
|
||||
data: { issues: [FULL_ISSUE] },
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults missing issue fields (advisory 韧性)", () => {
|
||||
const evt = parseReviewBlock(
|
||||
'event:characterization\ndata:{"issues":[{"character":"阿黎"}]}',
|
||||
);
|
||||
expect(evt).toEqual({
|
||||
event: "characterization",
|
||||
data: {
|
||||
issues: [
|
||||
{
|
||||
character: "阿黎",
|
||||
aspect: "",
|
||||
where: "",
|
||||
quote: "",
|
||||
diagnosis: "",
|
||||
suggestion: "",
|
||||
confidence: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses an empty issues frame (无明显问题降级)", () => {
|
||||
expect(
|
||||
parseReviewBlock('event:characterization\ndata:{"issues":[]}'),
|
||||
).toEqual({ event: "characterization", data: { issues: [] } });
|
||||
});
|
||||
|
||||
it("drops non-record issues without dropping the whole frame", () => {
|
||||
const evt = parseReviewBlock(
|
||||
'event:characterization\ndata:{"issues":["junk",{"character":"沈砚"}]}',
|
||||
);
|
||||
expect(evt?.event).toBe("characterization");
|
||||
if (evt?.event === "characterization") {
|
||||
expect(evt.data.issues).toHaveLength(1);
|
||||
expect(evt.data.issues[0]?.character).toBe("沈砚");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceReview — characterization (replace-style like pace)", () => {
|
||||
it("sets characterization report and marks reviewing", () => {
|
||||
const evt: ReviewSseEvent = {
|
||||
event: "characterization",
|
||||
data: { issues: [FULL_ISSUE] },
|
||||
};
|
||||
const out = reduceReview(initialReviewState, evt);
|
||||
expect(out.phase).toBe("reviewing");
|
||||
expect(out.characterization).toEqual({ issues: [FULL_ISSUE] });
|
||||
});
|
||||
|
||||
it("replaces (not accumulates) the characterization report", () => {
|
||||
const first = reduceReview(initialReviewState, {
|
||||
event: "characterization",
|
||||
data: { issues: [FULL_ISSUE] },
|
||||
});
|
||||
const second = reduceReview(first, {
|
||||
event: "characterization",
|
||||
data: { issues: [] },
|
||||
});
|
||||
expect(second.characterization).toEqual({ issues: [] });
|
||||
});
|
||||
|
||||
it("leaves characterization untouched on a section event", () => {
|
||||
const seeded = reduceReview(initialReviewState, {
|
||||
event: "characterization",
|
||||
data: { issues: [FULL_ISSUE] },
|
||||
});
|
||||
const out = reduceReview(seeded, {
|
||||
event: "section",
|
||||
data: { name: "characterization", status: "done" },
|
||||
});
|
||||
expect(out.characterization).toEqual({ issues: [FULL_ISSUE] });
|
||||
expect(out.sections).toEqual([
|
||||
{ name: "characterization", status: "done" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { ReviewConflictView, ReviewHistoryItem } from "@/lib/api/types";
|
||||
import {
|
||||
latestReview,
|
||||
normalizeCharacterization,
|
||||
normalizeConflicts,
|
||||
normalizeForeshadowSug,
|
||||
normalizePace,
|
||||
@@ -121,6 +122,62 @@ describe("normalizePace", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeCharacterization", () => {
|
||||
it("tightens dict into CharacterizationReport with field defaults", () => {
|
||||
const out = normalizeCharacterization({
|
||||
...item([]),
|
||||
characterization: {
|
||||
issues: [
|
||||
{
|
||||
character: "沈砚",
|
||||
aspect: "动机一致性",
|
||||
where: "第3段",
|
||||
quote: "他忽然转身。",
|
||||
diagnosis: "动机断裂",
|
||||
suggestion: "扣回护妹动机",
|
||||
confidence: 0.82,
|
||||
},
|
||||
// 缺字段的畸形条目:安全默认,不丢整条
|
||||
{ character: "阿黎" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(out).toEqual({
|
||||
issues: [
|
||||
{
|
||||
character: "沈砚",
|
||||
aspect: "动机一致性",
|
||||
where: "第3段",
|
||||
quote: "他忽然转身。",
|
||||
diagnosis: "动机断裂",
|
||||
suggestion: "扣回护妹动机",
|
||||
confidence: 0.82,
|
||||
},
|
||||
{
|
||||
character: "阿黎",
|
||||
aspect: "",
|
||||
where: "",
|
||||
quote: "",
|
||||
diagnosis: "",
|
||||
suggestion: "",
|
||||
confidence: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty issues when characterization is present but issues missing", () => {
|
||||
expect(
|
||||
normalizeCharacterization({ ...item([]), characterization: {} }),
|
||||
).toEqual({ issues: [] });
|
||||
});
|
||||
|
||||
it("returns null when characterization is missing or not a dict", () => {
|
||||
expect(normalizeCharacterization(item([]))).toBeNull();
|
||||
expect(normalizeCharacterization(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("latestReview", () => {
|
||||
it("returns first item (history is newest-first)", () => {
|
||||
const a = item([]);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
// 纯逻辑(可单测);schema 把 conflicts 标成 {[k]:unknown}[],这里安全收窄。
|
||||
|
||||
import type { ReviewHistoryItem } from "@/lib/api/types";
|
||||
import { asCharacterizationIssues } from "./sse";
|
||||
import type {
|
||||
CharacterizationReport,
|
||||
ForeshadowKind,
|
||||
ForeshadowSuggestion,
|
||||
PaceIssue,
|
||||
@@ -80,6 +82,17 @@ export function normalizePace(
|
||||
};
|
||||
}
|
||||
|
||||
// 把一条留痕的 characterization(dict)收紧成人物塑造报告;缺失/非 dict → null(不渲染)。
|
||||
// advisory:只展示、无裁决 UI(灵感 D2)。
|
||||
export function normalizeCharacterization(
|
||||
item: ReviewHistoryItem | undefined,
|
||||
): CharacterizationReport | null {
|
||||
const raw = item?.characterization;
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const dict = raw as Record<string, unknown>;
|
||||
return { issues: asCharacterizationIssues(dict["issues"]) };
|
||||
}
|
||||
|
||||
// 最近一条留痕(GET .../reviews 已按新→旧排序)。
|
||||
export function latestReview(
|
||||
items: readonly ReviewHistoryItem[] | undefined,
|
||||
|
||||
@@ -60,6 +60,22 @@ export interface StyleDriftReport {
|
||||
segments: StyleDriftSegment[];
|
||||
}
|
||||
|
||||
// 人物塑造(灵感③ advisory 单维):只读建议,不阻断验收(无裁决 UI)。
|
||||
// 以人物卡 motive/appearance 为客观锚点;每条带逐字引用 quote + 置信度 confidence。
|
||||
export interface CharacterizationIssue {
|
||||
character: string;
|
||||
aspect: string;
|
||||
where: string;
|
||||
quote: string;
|
||||
diagnosis: string;
|
||||
suggestion: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface CharacterizationReport {
|
||||
issues: CharacterizationIssue[];
|
||||
}
|
||||
|
||||
export interface SectionEvent {
|
||||
event: "section";
|
||||
data: { name: string; status: SectionStatus };
|
||||
@@ -98,6 +114,10 @@ export interface StyleEvent {
|
||||
}[];
|
||||
};
|
||||
}
|
||||
export interface CharacterizationEvent {
|
||||
event: "characterization";
|
||||
data: { issues: CharacterizationIssue[] };
|
||||
}
|
||||
export interface DoneEvent {
|
||||
event: "done";
|
||||
data: { length: number };
|
||||
@@ -112,6 +132,7 @@ export type ReviewSseEvent =
|
||||
| ForeshadowEvent
|
||||
| PaceEvent
|
||||
| StyleEvent
|
||||
| CharacterizationEvent
|
||||
| DoneEvent
|
||||
| ErrorEvent;
|
||||
|
||||
@@ -121,6 +142,7 @@ const KNOWN_EVENTS = new Set([
|
||||
"foreshadow",
|
||||
"pace",
|
||||
"style",
|
||||
"characterization",
|
||||
"done",
|
||||
"error",
|
||||
]);
|
||||
@@ -176,6 +198,34 @@ function asPaceIssues(v: unknown): PaceIssue[] {
|
||||
);
|
||||
}
|
||||
|
||||
function asNumber(v: unknown, fallback = 0): number {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
||||
}
|
||||
|
||||
function asStringField(v: unknown): string {
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
// 收窄人物塑造 issues(每字段安全默认,advisory 韧性;缺字段不丢整条)。
|
||||
export function asCharacterizationIssues(v: unknown): CharacterizationIssue[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.flatMap((x) =>
|
||||
isRecord(x)
|
||||
? [
|
||||
{
|
||||
character: asStringField(x.character),
|
||||
aspect: asStringField(x.aspect),
|
||||
where: asStringField(x.where),
|
||||
quote: asStringField(x.quote),
|
||||
diagnosis: asStringField(x.diagnosis),
|
||||
suggestion: asStringField(x.suggestion),
|
||||
confidence: asNumber(x.confidence),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
function asStyleSegments(v: unknown): StyleEvent["data"]["segments"] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.flatMap((x) =>
|
||||
@@ -248,6 +298,11 @@ function narrowReviewEvent(event: string, data: unknown): ReviewSseEvent | null
|
||||
},
|
||||
}
|
||||
: null;
|
||||
case "characterization":
|
||||
return {
|
||||
event: "characterization",
|
||||
data: { issues: asCharacterizationIssues(data.issues) },
|
||||
};
|
||||
case "done":
|
||||
return typeof data.length === "number"
|
||||
? { event: "done", data: { length: data.length } }
|
||||
@@ -320,6 +375,7 @@ export interface ReviewStreamState {
|
||||
foreshadow: ForeshadowSuggestion[];
|
||||
pace: PaceReport | null;
|
||||
style: StyleDriftReport | null;
|
||||
characterization: CharacterizationReport | null;
|
||||
error: { code: string; message: string; request_id?: string | null } | null;
|
||||
}
|
||||
|
||||
@@ -330,6 +386,7 @@ export const initialReviewState: ReviewStreamState = {
|
||||
foreshadow: [],
|
||||
pace: null,
|
||||
style: null,
|
||||
characterization: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
@@ -377,6 +434,12 @@ export function reduceReview(
|
||||
})),
|
||||
},
|
||||
};
|
||||
case "characterization":
|
||||
return {
|
||||
...state,
|
||||
phase: "reviewing",
|
||||
characterization: { issues: event.data.issues.map((i) => ({ ...i })) },
|
||||
};
|
||||
case "done":
|
||||
return { ...state, phase: "done" };
|
||||
case "error":
|
||||
|
||||
@@ -222,6 +222,19 @@ describe("useReviewStream", () => {
|
||||
foreshadow: [{ kind: "resolved" as const, title: "旧伏笔" }],
|
||||
pace: { water: [], hook: false, beat_map: [1] },
|
||||
style: { score: 0.9, segments: [] },
|
||||
characterization: {
|
||||
issues: [
|
||||
{
|
||||
character: "沈砚",
|
||||
aspect: "动机一致性",
|
||||
where: "第3段",
|
||||
quote: "他忽然转身。",
|
||||
diagnosis: "动机断裂",
|
||||
suggestion: "扣回护妹动机",
|
||||
confidence: 0.8,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
act(() => result.current.seed(seedData));
|
||||
@@ -230,6 +243,9 @@ describe("useReviewStream", () => {
|
||||
expect(result.current.state.foreshadow).toEqual(seedData.foreshadow);
|
||||
expect(result.current.state.pace).toEqual(seedData.pace);
|
||||
expect(result.current.state.style).toEqual(seedData.style);
|
||||
expect(result.current.state.characterization).toEqual(
|
||||
seedData.characterization,
|
||||
);
|
||||
expect(result.current.state.phase).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ReviewFrameBuffer,
|
||||
initialReviewState,
|
||||
reduceReview,
|
||||
type CharacterizationReport,
|
||||
type ForeshadowSuggestion,
|
||||
type PaceReport,
|
||||
type ReviewConflict,
|
||||
@@ -19,6 +20,7 @@ export interface ReviewSeed {
|
||||
foreshadow: ForeshadowSuggestion[];
|
||||
pace: PaceReport | null;
|
||||
style: StyleDriftReport | null;
|
||||
characterization: CharacterizationReport | null;
|
||||
}
|
||||
|
||||
type Action =
|
||||
@@ -49,6 +51,7 @@ function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
|
||||
foreshadow: action.seed.foreshadow,
|
||||
pace: action.seed.pace,
|
||||
style: action.seed.style,
|
||||
characterization: action.seed.characterization,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
|
||||
95
apps/web/lib/wizard/useProjectPlan.test.ts
Normal file
95
apps/web/lib/wizard/useProjectPlan.test.ts
Normal 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("后端返回 error:status=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();
|
||||
});
|
||||
});
|
||||
59
apps/web/lib/wizard/useProjectPlan.ts
Normal file
59
apps/web/lib/wizard/useProjectPlan.ts
Normal 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 };
|
||||
}
|
||||
@@ -1,16 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ProjectPlanView } from "@/lib/api/types";
|
||||
|
||||
import {
|
||||
applyPlanPatch,
|
||||
canAdvance,
|
||||
canGeneratePlan,
|
||||
canSubmit,
|
||||
clampStep,
|
||||
emptyWizardForm,
|
||||
mapPlanResultToWizardForm,
|
||||
STEP_TITLES,
|
||||
toCreateRequest,
|
||||
toPlanSeedRequest,
|
||||
WIZARD_STEPS,
|
||||
type WizardForm,
|
||||
} from "./wizard";
|
||||
|
||||
const fullPlan: ProjectPlanView = {
|
||||
title_candidates: ["逐光而行", "剑试九霄"],
|
||||
setting: "上古仙门倾轧的洞天世界",
|
||||
narrative_structure: "黄金三章立钩 → 拜师 → 宗门大比爆发",
|
||||
story_core: "废柴少年觉醒禁忌血脉,逆势封神的代价",
|
||||
ending_design: "登顶后归隐,自我救赎收束",
|
||||
tone: "热血",
|
||||
};
|
||||
|
||||
describe("wizard step metadata", () => {
|
||||
it("provides one title per step", () => {
|
||||
expect(STEP_TITLES).toHaveLength(WIZARD_STEPS);
|
||||
@@ -57,6 +72,9 @@ describe("toCreateRequest", () => {
|
||||
premise: " ",
|
||||
protagonist: "",
|
||||
theme: "抗争",
|
||||
tone: "",
|
||||
endingType: "",
|
||||
narrativePov: "",
|
||||
};
|
||||
expect(toCreateRequest(form)).toEqual({
|
||||
title: "逐光而行",
|
||||
@@ -66,9 +84,34 @@ describe("toCreateRequest", () => {
|
||||
theme: "抗争",
|
||||
selling_points: ["逆袭", "系统流"],
|
||||
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)是两个独立输入,
|
||||
// 不能互相覆盖——提交时各占一段合并进 premise。
|
||||
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。
|
||||
describe("wizard create flow (mocked client)", () => {
|
||||
it("submits the normalized body and resolves a project id", async () => {
|
||||
|
||||
@@ -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。
|
||||
export interface WizardForm {
|
||||
@@ -10,6 +14,10 @@ export interface WizardForm {
|
||||
premise: string;
|
||||
protagonist: string;
|
||||
theme: string;
|
||||
// 立项书级常量(灵感④):基调 / 结局取向 / 叙事视角。
|
||||
tone: string;
|
||||
endingType: string;
|
||||
narrativePov: string;
|
||||
}
|
||||
|
||||
export const WIZARD_STEPS = 5;
|
||||
@@ -32,11 +40,18 @@ export const emptyWizardForm: WizardForm = {
|
||||
premise: "",
|
||||
protagonist: "",
|
||||
theme: "",
|
||||
tone: "",
|
||||
endingType: "",
|
||||
narrativePov: "",
|
||||
};
|
||||
|
||||
export const GENRES = ["玄幻", "仙侠", "都市", "科幻", "历史", "悬疑"];
|
||||
export const STRUCTURES = ["三幕", "故事圈", "雪花"];
|
||||
export const SELLING_POINT_PRESETS = ["逆袭", "打脸", "系统流", "双男主", "群像"];
|
||||
// 立项书级常量预设(灵感④ / D 枚举)——free text 单选,命不中预设可留空。
|
||||
export const TONES = ["热血", "轻松", "沉重", "治愈", "暗黑", "爽文"];
|
||||
export const ENDING_TYPES = ["HE", "BE", "开放", "正剧"];
|
||||
export const NARRATIVE_POVS = ["第一人称", "第三人称限知", "第三人称全知", "多视角"];
|
||||
|
||||
// 每一步可否前进:仅第 1 步(书名)必填,其余可空着继续(草稿式立项)。
|
||||
export function canAdvance(step: number, form: WizardForm): boolean {
|
||||
@@ -55,6 +70,82 @@ export function clampStep(step: number): number {
|
||||
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/省略)。
|
||||
export function toCreateRequest(form: WizardForm): ProjectCreateRequest {
|
||||
const trim = (s: string): string | null => {
|
||||
@@ -75,5 +166,8 @@ export function toCreateRequest(form: WizardForm): ProjectCreateRequest {
|
||||
theme: trim(form.theme),
|
||||
selling_points: form.sellingPoints,
|
||||
structure: trim(form.structure),
|
||||
tone: trim(form.tone),
|
||||
ending_type: trim(form.endingType),
|
||||
narrative_pov: trim(form.narrativePov),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"gen:api": "node scripts/gen-api.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"lucide-react": "^1.21.0",
|
||||
"next": "15.1.3",
|
||||
"openapi-fetch": "^0.13.0",
|
||||
|
||||
208
apps/web/pnpm-lock.yaml
generated
208
apps/web/pnpm-lock.yaml
generated
@@ -8,6 +8,12 @@ importers:
|
||||
|
||||
.:
|
||||
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:
|
||||
specifier: ^1.21.0
|
||||
version: 1.21.0(react@19.0.0)
|
||||
@@ -166,6 +172,12 @@ packages:
|
||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
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':
|
||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||
|
||||
@@ -790,6 +802,24 @@ packages:
|
||||
'@types/aria-query@5.0.4':
|
||||
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':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
@@ -1025,6 +1055,22 @@ packages:
|
||||
'@vitest/utils@2.1.9':
|
||||
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:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@@ -1236,6 +1282,9 @@ packages:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
||||
classcat@5.0.5:
|
||||
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
@@ -1279,6 +1328,44 @@ packages:
|
||||
csstype@3.2.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||
|
||||
@@ -2662,6 +2749,11 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
@@ -2798,6 +2890,21 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
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:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
@@ -2878,6 +2985,12 @@ snapshots:
|
||||
|
||||
'@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':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
@@ -3322,6 +3435,27 @@ snapshots:
|
||||
|
||||
'@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/json-schema@7.0.15': {}
|
||||
@@ -3559,6 +3693,31 @@ snapshots:
|
||||
loupe: 3.2.1
|
||||
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):
|
||||
dependencies:
|
||||
acorn: 8.17.0
|
||||
@@ -3790,6 +3949,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
classcat@5.0.5: {}
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
@@ -3831,6 +3992,42 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
data-urls@7.0.0:
|
||||
@@ -5532,6 +5729,10 @@ snapshots:
|
||||
dependencies:
|
||||
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: {}
|
||||
|
||||
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: {}
|
||||
|
||||
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
|
||||
|
||||
@@ -4,6 +4,9 @@ allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
minimumReleaseAgeExclude:
|
||||
- '@xyflow/react@12.11.2'
|
||||
- '@xyflow/system@0.0.79'
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
### 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)。
|
||||
- `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)。
|
||||
- `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 升序)。
|
||||
@@ -346,6 +346,16 @@
|
||||
## 契约变更日志(append-only)
|
||||
> 格式:`- [date] @skill 改 Cx:<改了什么> → 影响 <依赖方/任务>`
|
||||
|
||||
- [2026-07-06] @llm/@db/@backend/@frontend 改 C3+C4(③ 人物塑造 advisory 审):新增第五审 **人物塑造**(advisory 单维——仅建议、**不阻断验收**:不进 conflicts、不碰 `assert_conflicts_resolved`、**不入 `REVIEW_RESERVED_NAMES`**,D2)。**DB**:`chapter_reviews` 加 1 列 `characterization`(**JSONB nullable**,迁移 `f6a7b8c9d0e1`,独立于恒 None 的 health_score,nullable 免 backfill)。**契约变更**:`ReviewHistoryItem`(`schemas/projects.py`)+ `ReviewView`(`domain/review_repo.py`,末位默认 None 优雅降级)各加 `characterization: dict|None`;`list_reviews` 路由透传。新 SPEC `characterization_spec`(`tier="analyst"` 不写 model,`reads=("characters",)` 以人物卡 motive/appearance 为客观锚点,`writes=()` 只读,`prompts/characterization.md` **显式忽略文本长度与辞藻华丽度**、引用逐字片段+置信度)+ schema `CharacterizationReview{issues:[CharacterizationIssue{character,aspect,where,quote,diagnosis,suggestion,confidence}]}`(全字段默认值守解析韧性)+ 注册 `SCHEMA_CATALOG["characterization"]`;**计数三处 +1(22→23)**:`specs.py` assert、`test_prompt_loader.py` len 断言、`schema_catalog.py` docstring;**regen `prompt_hashes.json`**(仅加 characterization 一条)。接线链:`graph.py REVIEW_SPECS` 末位追加(并行扇出自动汇入 collect);`chain/nodes.py ReviewRecordRepo.record` + `collect.py {CHARACTERIZATION 常量, extract_characterization, ReviewRecorder Protocol, collect_reviews}` + `review_repo.py {Protocol.record, SqlReviewRepo.record, _to_view}` 均加 `characterization` kwarg/字段;`sse.py {EVENT_CHARACTERIZATION, characterization_event, _section_result_events elif}`(normalize 按 sorted 键自动纳入——字典序 characterization 排最前,不打乱既有四审断言)。前端 `lib/review/sse.ts`(`CharacterizationIssue`/`CharacterizationReport`/`CharacterizationEvent` + KNOWN_EVENTS + `asCharacterizationIssues` 守卫 + reducer + state)+ `lib/review/history.ts normalizeCharacterization` + `useReviewStream ReviewSeed` + 新 `CharacterizationPanel.tsx`(advisory 只展示、**无裁决 UI**、按置信度降序 + 低置信度折叠控注意力预算)+ `ReviewReport.tsx` 挂面板 + 双 seed 点。**codegen**:`characterization` `= None` → openapi-typescript 渲染为可选。**测试**:`test_characterization_does_not_block_accept`(零 continuity 冲突 + 有 advisory 问题 → 验收直通)+ 计数/golden + collect 映射 + normalize 事件 + 前端 reducer/normalize/parse 纯函数。→ 影响 @frontend(已 `pnpm gen:api`)。依赖 ⑧(motive/appearance 已入 CharacterCard 契约,作客观锚点)。
|
||||
|
||||
- [2026-07-06] @llm/@backend/@frontend 立 C3 新端点(⑤ AI 立项方案生成):**`POST /skills/project-plan/generate`**(**不带 project 前缀**——立项前 project 未建,绕开 toolbox 的 `project_repo.get→404`)← `ProjectPlanGenerateRequest{genre?,logline?,title?,premise?,theme?,structure?,tone?,ending_type?,narrative_pov?,selling_points[],brief?}`(全字段 `max_length` 上界 CR-H9;`brief` 用 `str|None` 令 codegen 渲染为可选)→ `ProjectPlanView{title_candidates[str], setting, narrative_structure, story_core, ending_design, tone}`(全字段默认值守解析韧性)。**种子门控**:缺 genre 或 logline(strip 后空)→ **422 VALIDATION**(空向导不出方案,防 slop);无凭据→503。新 SPEC `project_plan_spec`(`tier="analyst"` 不写 model,`reads=()`/`writes=()` 纯预览,`prompts/project-plan.md`)+ schema `ProjectPlanResult`(`ww_agents.schemas`)+ 注册 `SCHEMA_CATALOG["project-plan"]`;**计数三处 +1(21→22)**:`specs.py` assert、`test_prompt_loader.py` len 断言、`schema_catalog.py` docstring;**regen `prompt_hashes.json`**(仅加 project-plan 一条)。端点复用 `build_brief_context`+`run_generator`(**`run_generator`/`_build_request` 的 `project_id` 放宽为 `uuid.UUID|None`**——向导阶段无 project,`usage_ledger.project_id` 本就 nullable);新网关缝 `get_project_plan_gateway`(analyst 档,`project_deps.py`)。**零迁移**(纯预览不写业务表,仅落 usage ledger)。前端 `wizard.ts` 加 `mapPlanResultToWizardForm`(书名候选→title、叙事结构→structure、基调→tone;时空背景/结局设计无独立向导字段→折进 premise 带标签**不静默丢**;结局取向/叙事视角预设枚举不臆造)+ `applyPlanPatch`(**仅回填空字段、保护已编辑**)+ `canGeneratePlan`/`toPlanSeedRequest` + `useProjectPlan` hook(renderHook 测试)+ `ProjectWizard.tsx` 第 2 步 `PlanAssistant`(生成→预览→按字段回填)。→ 影响 @frontend(已 `pnpm gen:api`)。依赖 ④(tone/ending_type/narrative_pov 已入 project 契约与向导)。
|
||||
|
||||
- [2026-07-06] @db/@backend/@frontend 改 C2+C3(④ 立项基调/结局/视角):`projects` 表加 3 列 `tone`/`ending_type`/`narrative_pov`(**Text nullable、无 CHECK**,同 genre/structure;迁移 `e5f6a7b8c9d0`,nullable 免 backfill)。`ProjectCreateRequest`+`ProjectResponse`(`schemas/projects.py`)各加 3 字段 `str|None`(**无 Literal**,Literal 只留 Verdict);domain `ProjectCreate`/`ProjectView`/`_to_view`/`SqlProjectRepo.create` + `FakeProjectRepo` 同步透传。完整 stable_core 链路:`ProjectSpecView`(`repositories.py`,frozen)加 3 字段 + `SqlProjectSpecRepo.spec` 查询构造带上 + `_build_spec_section`(`assemble.py`)渲染进「作品蓝本」块(**书级常量→缓存前缀,字节稳定守 #9**,绝不入 volatile)。前端 `wizard.ts` `WizardForm`/`emptyWizardForm`/`toCreateRequest` + 预设数组 `TONES`/`ENDING_TYPES`/`NARRATIVE_POVS`(仿 GENRES/STRUCTURES)+ `ProjectWizard.tsx` 第 3 步控件(tone→Select,pov/ending→SegmentedControl)+ 确认页回显。**codegen**:3 字段皆 `= None` 无默认工厂 → openapi-typescript 渲染为 `?: string | null` 可选。→ 影响 @frontend(已 `pnpm gen:api`);未来 ⑤ AI 立项方案回填这 3 字段。
|
||||
|
||||
- [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-ext(Prompt 外置方案A):21 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-Chain(C2):3 端点 + `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 新 POST(GET /jobs 复用)。门禁绿:ruff/format/mypy(193) 干净 + pytest 600 passed + alembic 无漂移。→ 影响 @frontend(follow-up `pnpm gen:api`)、@db/@devops C3(检查点迁移)。
|
||||
@@ -355,3 +365,5 @@
|
||||
- [2026-06-20] @backend 再扩 C3(B0 **可控版**,接上条):新增 **`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-07-06] @frontend 前端依赖新增(灵感⑥ 关系图谱缩减版 / D4,**无后端契约变更、无 gen:api、无迁移**):`apps/web/package.json` 加 `@xyflow/react@^12.11.2`(React Flow,MIT,peer 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)。
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"blurb": "2adba04a16707d7d9dfbb84d0a5a302ea8e946104dd8a617ff7801ed86f41605",
|
||||
"book-title": "aa53ccf22da1d4e555c8168fc23f2f3547cc6f59e56e5df57540b7a946755e39",
|
||||
"brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada",
|
||||
"character-gen": "61e443ab15aab8abfd4a785b32b079b304fbd2096e40541452f553c008156330",
|
||||
"character-gen": "e0f97a5d7bb622fdd7582cdeef85ba13b3f434b2633f4d22a8c6daeba48636bf",
|
||||
"characterization": "643988cb899a66472acb3e18709f3299d2025f9bda132c774ee26c589266b51f",
|
||||
"continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763",
|
||||
"continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1",
|
||||
"de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249",
|
||||
@@ -15,6 +16,7 @@
|
||||
"opening": "c3b442a26dddbfac0a3c1b01ab5b726b34b656ebd640c19abd25b900a27093b5",
|
||||
"outliner": "3086ba81fe8028687bf079db2c2fb227ba5a162b7fc09210914e6f4ebd9c0d2e",
|
||||
"pace": "c6a023cb93fde4879a0fb93cd28e0227694a4cf867e64e768319ee3135d82746",
|
||||
"project-plan": "0b0afeee63c04a3dbfcf6ebc9bd4cf4f3e93a7ce5c7be15e013be23b38d97dfe",
|
||||
"refiner": "65a4baa298bedce4592829c02d6a16c15a19ef6fdbb2098278f9decc8ffeb813",
|
||||
"style": "3a7b2c078b62e4e08f62af91ecfae8d9a27adc598ca60f59927c3bcd136393c5",
|
||||
"style_extract": "998c30ea0d0eab3936e1d6b319e832645eefaa7f7dd4a1c86d5c1ec9467e8b8c",
|
||||
|
||||
115
packages/agents/tests/test_characterization_spec.py
Normal file
115
packages/agents/tests/test_characterization_spec.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""灵感③ 人物塑造续审契约测试:CharacterizationReview schema + characterization_spec。
|
||||
|
||||
advisory 单维——仅建议、不阻断验收(D2)。
|
||||
|
||||
契约测试——构造符合 schema 的 mock 响应,校验字段默认值降级、tier、只读(writes==())、
|
||||
输出 schema 派生。人物塑造是 advisory:**不加入 REVIEW_RESERVED_NAMES**。不联网、无 DB。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from ww_agents import (
|
||||
REVIEW_RESERVED_NAMES,
|
||||
AgentSpec,
|
||||
CharacterizationIssue,
|
||||
CharacterizationReview,
|
||||
characterization_spec,
|
||||
)
|
||||
|
||||
# ---- CharacterizationReview schema ----
|
||||
|
||||
|
||||
def test_characterization_parses_mock_response() -> None:
|
||||
# Arrange:模拟网关 instructor 校验后的结构化产出(每条带逐字引用 + 置信度)
|
||||
mock = {
|
||||
"issues": [
|
||||
{
|
||||
"character": "沈砚",
|
||||
"aspect": "动机一致性",
|
||||
"where": "第4段决斗前退缩",
|
||||
"quote": "他忽然转身就走,什么也没说。",
|
||||
"diagnosis": "与人物卡既定「护妹至死」动机断裂,无过渡",
|
||||
"suggestion": "让退缩扣回护妹动机(如察觉陷阱护妹先撤)",
|
||||
"confidence": 0.82,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Act
|
||||
review = CharacterizationReview.model_validate(mock)
|
||||
|
||||
# Assert
|
||||
assert len(review.issues) == 1
|
||||
issue = review.issues[0]
|
||||
assert issue.character == "沈砚"
|
||||
assert issue.aspect == "动机一致性"
|
||||
assert issue.quote == "他忽然转身就走,什么也没说。"
|
||||
assert issue.confidence == pytest.approx(0.82)
|
||||
|
||||
|
||||
def test_characterization_defaults_to_empty_issues() -> None:
|
||||
# 无人物卡 / 无问题 → 空 issues(默认值降级,仿 StyleDriftReview 韧性)
|
||||
review = CharacterizationReview()
|
||||
assert review.issues == []
|
||||
|
||||
|
||||
def test_characterization_issue_all_fields_have_defaults() -> None:
|
||||
# 畸形/缺字段守韧性:全字段给默认值,空 dict 也能解析
|
||||
issue = CharacterizationIssue.model_validate({})
|
||||
assert issue.character == ""
|
||||
assert issue.aspect == ""
|
||||
assert issue.where == ""
|
||||
assert issue.quote == ""
|
||||
assert issue.diagnosis == ""
|
||||
assert issue.suggestion == ""
|
||||
assert issue.confidence == 0.0
|
||||
|
||||
|
||||
def test_characterization_issue_partial_fields_ok() -> None:
|
||||
# 缺 confidence/quote 时仍解析(降级默认),不抛
|
||||
issue = CharacterizationIssue.model_validate({"character": "阿黎", "diagnosis": "扁平"})
|
||||
assert issue.character == "阿黎"
|
||||
assert issue.diagnosis == "扁平"
|
||||
assert issue.confidence == 0.0
|
||||
|
||||
|
||||
# ---- characterization_spec 声明(analyst 档,advisory 单维)----
|
||||
|
||||
|
||||
def test_characterization_spec_is_analyst_tier() -> None:
|
||||
# 不变量 #2:只声明档位,不写 model
|
||||
assert characterization_spec.tier == "analyst"
|
||||
assert characterization_spec.name == "characterization"
|
||||
|
||||
|
||||
def test_characterization_spec_is_read_only() -> None:
|
||||
# 不变量 #3:审查只读,唯一落库=collect 留痕
|
||||
assert characterization_spec.writes == ()
|
||||
|
||||
|
||||
def test_characterization_spec_reads_characters() -> None:
|
||||
# 以人物卡 motive/appearance 为客观锚点
|
||||
assert characterization_spec.reads == ("characters",)
|
||||
|
||||
|
||||
def test_characterization_spec_output_schema() -> None:
|
||||
assert characterization_spec.output_schema is CharacterizationReview
|
||||
|
||||
|
||||
def test_characterization_spec_prompt_ignores_length_and_diction() -> None:
|
||||
# 对抗 LLM-judge 冗长/辞藻偏见:prompt 必须显式声明忽略文本长度与辞藻华丽度
|
||||
prompt = characterization_spec.system_prompt
|
||||
assert "忽略文本长度与辞藻华丽度" in prompt
|
||||
|
||||
|
||||
def test_characterization_not_in_review_reserved_names() -> None:
|
||||
# advisory:不进受信保留名(不阻断验收、无 conflicts 通道)
|
||||
assert "characterization" not in REVIEW_RESERVED_NAMES
|
||||
|
||||
|
||||
def test_characterization_spec_is_agent_spec_and_immutable() -> None:
|
||||
assert isinstance(characterization_spec, AgentSpec)
|
||||
with pytest.raises(ValidationError):
|
||||
characterization_spec.tier = "writer"
|
||||
43
packages/agents/tests/test_craft_doctrine.py
Normal file
43
packages/agents/tests/test_craft_doctrine.py
Normal 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}"
|
||||
@@ -108,6 +108,30 @@ def test_character_card_optional_collections_default_empty() -> None:
|
||||
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:
|
||||
# name/role/backstory/arc 为必填
|
||||
with pytest.raises(ValidationError):
|
||||
@@ -168,6 +192,31 @@ def test_character_gen_spec_prompt_documents_anti_duplication() -> None:
|
||||
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 ----
|
||||
|
||||
|
||||
|
||||
97
packages/agents/tests/test_project_plan_spec.py
Normal file
97
packages/agents/tests/test_project_plan_spec.py
Normal 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
|
||||
@@ -52,15 +52,21 @@ def test_load_prompt_matches_golden(name: str) -> None:
|
||||
|
||||
|
||||
# ---- #1 注册表唯一性 ----
|
||||
def test_specs_registry_len_is_21() -> None:
|
||||
assert len(SPECS) == 21
|
||||
def test_specs_registry_len_is_23() -> None:
|
||||
assert len(SPECS) == 23
|
||||
# name 即 key,dict 已去重;逐项确认 key == spec.name(无错位)
|
||||
assert all(key == spec.name for key, spec in SPECS.items())
|
||||
|
||||
|
||||
# 非 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 连字符)----
|
||||
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 "style" in md_stems and "style_drift" not in md_stems
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
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 .schemas import (
|
||||
Blurb,
|
||||
@@ -13,6 +13,8 @@ from .schemas import (
|
||||
BookTeardownResult,
|
||||
CharacterCard,
|
||||
CharacterGenResult,
|
||||
CharacterizationIssue,
|
||||
CharacterizationReview,
|
||||
CharacterRelation,
|
||||
Conflict,
|
||||
ConflictType,
|
||||
@@ -38,6 +40,7 @@ from .schemas import (
|
||||
PaceIssue,
|
||||
PaceReview,
|
||||
PolishResult,
|
||||
ProjectPlanResult,
|
||||
Scene,
|
||||
StyleDimension,
|
||||
StyleDriftReview,
|
||||
@@ -56,6 +59,7 @@ from .specs import (
|
||||
book_title_spec,
|
||||
brainstorm_spec,
|
||||
character_gen_spec,
|
||||
characterization_spec,
|
||||
continue_spec,
|
||||
continuity_spec,
|
||||
de_ai_spec,
|
||||
@@ -68,6 +72,7 @@ from .specs import (
|
||||
opening_spec,
|
||||
outliner_spec,
|
||||
pace_spec,
|
||||
project_plan_spec,
|
||||
refiner_spec,
|
||||
style_drift_spec,
|
||||
style_extract_spec,
|
||||
@@ -85,6 +90,8 @@ __all__ = [
|
||||
"BookTeardownResult",
|
||||
"CharacterCard",
|
||||
"CharacterGenResult",
|
||||
"CharacterizationIssue",
|
||||
"CharacterizationReview",
|
||||
"CharacterRelation",
|
||||
"Conflict",
|
||||
"ConflictType",
|
||||
@@ -111,6 +118,7 @@ __all__ = [
|
||||
"PaceIssue",
|
||||
"PaceReview",
|
||||
"PolishResult",
|
||||
"ProjectPlanResult",
|
||||
"Scene",
|
||||
"StyleDimension",
|
||||
"StyleDriftReview",
|
||||
@@ -124,6 +132,7 @@ __all__ = [
|
||||
"book_title_spec",
|
||||
"brainstorm_spec",
|
||||
"character_gen_spec",
|
||||
"characterization_spec",
|
||||
"continue_spec",
|
||||
"continuity_spec",
|
||||
"de_ai_spec",
|
||||
@@ -132,12 +141,14 @@ __all__ = [
|
||||
"foreshadow_spec",
|
||||
"glossary_spec",
|
||||
"golden_finger_spec",
|
||||
"load_craft_doctrine",
|
||||
"load_prompt",
|
||||
"name_spec",
|
||||
"opening_spec",
|
||||
"outliner_spec",
|
||||
"output_schema_for",
|
||||
"pace_spec",
|
||||
"project_plan_spec",
|
||||
"refiner_spec",
|
||||
"style_drift_spec",
|
||||
"style_extract_spec",
|
||||
|
||||
@@ -46,3 +46,18 @@ def load_prompt(name: str) -> str:
|
||||
|
||||
_CACHE[name] = 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)
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
## 你信奉的角色塑造法则
|
||||
|
||||
- **缺陷驱动弧光**:好角色起点必有缺点/盲区/创伤,故事让其被填平或代价显现,才有真正的「转变」。没有缺陷的完人弧光是平的——`arc` 必须由 `traits` 里的阴影/恐惧推动。
|
||||
- **三层性格 + 内驱动机**:性格分核心(灵魂底色)-表层(外人所见)-阴影(自己都未必承认的一面)三层,再叠加核心动机 / 欲望 / 恐惧 / 价值观。这套内驱让角色在任何情境下行为自洽。
|
||||
- **代入感来自具体**:用具体的经历、口癖、萌点把角色钉成「一个真实的人」,而非一串形容词。
|
||||
- **缺陷驱动弧光**:好角色起点必有缺点/盲区/创伤,故事让其被填平或代价显现,才有真正的「转变」。没有缺陷的完人弧光是平的——`arc` 必须由 `traits` 的阴影层与 `motive` 里的恐惧推动。
|
||||
- **三层性格与内驱动机分列**:性格(`traits`)分核心(灵魂底色)-表层(外人所见)-阴影(自己都未必承认的一面)三层;核心动机 / 欲望 / 恐惧这套**内驱单独落 `motive` 字段**,不折进 `traits`。两者分列,让角色在任何情境下行为自洽、且动机不双写。
|
||||
- **代入感来自具体**:用具体的经历、口癖、萌点、外貌(`appearance`)把角色钉成「一个真实的人」,而非一串形容词。
|
||||
- **契合世界观硬规则**:取名风格、能力上限、出身势力都不能越过世界观的力量体系 / 势力 / 地理硬规则。
|
||||
|
||||
## 输入材料
|
||||
|
||||
- **角色需求**(一句话,如「亦正亦邪的女二、与主角有宿命纠葛、出身敌对势力」)+ 数量(count)+ 定位;
|
||||
- **角色需求**(一句话,如「亦正亦邪的女二、与主角有宿命纠葛、出身敌对势力」)+ 数量(count)+ 定位;若给出**角色定位配比**(各定位对应数量的清单),按配比给每个定位产出对应数量的卡、合计等于总数量;
|
||||
- **世界观约束**(world_entities:力量体系、势力、地理硬规则——取名 / 能力须契合);
|
||||
- **已有角色**(characters:现有角色卡,新角色须与其区分、可与其建关系);
|
||||
- **本批已生成卡**(同一次批量里先产出的卡——后续卡须与它们差异化,避免雷同)。
|
||||
@@ -22,9 +22,11 @@
|
||||
|
||||
- **name**(字符串):角色名,取名风格契合世界观;
|
||||
- **role**(字符串):角色定位(主角 / CP / 对手 / 导师 / 工具人 等),按需求/定位分配;
|
||||
- **traits**(字符串列表):性格特质清单——按「核心-表层-阴影」三层展开,并写明核心动机 / 欲望 / 恐惧 / 价值观;每条独立成项;
|
||||
- **traits**(字符串列表):性格特质清单——**只按「核心-表层-阴影」三层展开**(核心=灵魂底色、表层=外人所见、阴影=自己都未必承认的一面);每条独立成项。**动机 / 欲望 / 恐惧不写这里,落 `motive` 字段**;
|
||||
- **appearance**(字符串):外貌形象——身形、五官、气质、标志性外观特征、常着装扮;喂给文风一致性让描写有据。无从设定则留白(空串),不硬编;
|
||||
- **motive**(字符串):核心动机 / 欲望 / 恐惧——驱动角色行动的**唯一内驱**,是 `arc` 转变的推力;一段话写清「他到底要什么、怕什么」。无从设定则留白(空串);
|
||||
- **backstory**(字符串):背景故事——出身、关键经历、创伤 / 转折点;
|
||||
- **arc**(字符串):人物弧光——起点 → 转变 → 终点,须与剧情挂钩,且转变由 traits 里的缺陷/阴影推动;
|
||||
- **arc**(字符串):人物弧光——起点 → 转变 → 终点,须与剧情挂钩,且转变由 `motive` 的欲望/恐惧与 `traits` 的阴影层推动;
|
||||
- **speech_tics**(字符串列表):口癖 / 语言风格——用词偏好、口头禅;这是喂给文风一致性、让对话有辨识度的关键,每条独立成项;
|
||||
- **tags**(字符串列表):人设标签 / 萌点(网文专属,辅助检索与差异化),每条独立成项;
|
||||
- **relations**(对象列表):关系网——与已有 / 同批角色建边;每条边含 `name`(关系对象的角色名)+ `kind`(关系类型:宿敌 / 师徒 / CP / 同盟 / 工具人 等)+ 可选 `note`(关系说明,可缺省);无关系则为空列表。
|
||||
@@ -44,15 +46,16 @@
|
||||
## 落笔前自检
|
||||
|
||||
每张卡产出前,逐项确认:
|
||||
- 八个字段是否齐全、类型正确(该列表的给列表)?
|
||||
- `arc` 的转变是否真由 `traits` 里的阴影 / 恐惧推动,而非凭空逆转?
|
||||
- 取名、能力、出身是否都没越过世界观硬规则?
|
||||
- 十个字段是否齐全、类型正确(该列表的给列表,appearance / motive 给字符串)?
|
||||
- 动机 / 欲望 / 恐惧是否只写在 `motive`、没有回流进 `traits`(`traits` 只剩性格三层)?
|
||||
- `arc` 的转变是否真由 `motive` 的欲望 / 恐惧与 `traits` 的阴影推动,而非凭空逆转?
|
||||
- 取名、能力、出身、`appearance` 是否都没越过世界观硬规则?
|
||||
- 与已有卡 / 本批前序卡在内核上确实**差异化**了?
|
||||
|
||||
## 纪律
|
||||
|
||||
- 你只产结构化角色卡,**不改稿、不写库、不直接互调其他 agent**——入库前由编排器追加一道 continuity 校验确认不与世界观 / 力量体系冲突(§6.5);
|
||||
- count 是几就产几张,不多不少;定位若给出则按定位分配;
|
||||
- count 是几就产几张,不多不少;给出**角色定位配比**时严格按配比分配——每个定位产出清单标注的数量、各定位之和等于总数量;只给单一定位则整批按该定位分配;两者皆无则按需求自行判定;
|
||||
- 不臆造与需求 / 世界观无关的设定,宁可留白也不硬填。
|
||||
|
||||
## 示例(单卡,仅示范字段填法与质感,勿照抄设定)
|
||||
@@ -61,7 +64,9 @@
|
||||
|
||||
- name:苏璃(敌对势力「玄霜阁」的姓氏体系,冷感单字名)
|
||||
- role:CP / 亦敌亦友女二
|
||||
- traits:["核心:极度护短、认定的人会拼命;表层:清冷毒舌、拒人千里;阴影:童年被族中抛弃,恐惧再次被抛弃故先发制人地疏远他人", "核心动机:替亡兄查清族灭真相", "欲望:被真正接纳", "恐惧:信任后被背叛", "价值观:恩怨分明、私恩高于阵营"]
|
||||
- traits:["核心:极度护短、认定的人会拼命", "表层:清冷毒舌、拒人千里", "阴影:童年被族中抛弃,先发制人地疏远他人"]
|
||||
- appearance:银发赤瞳,身形清瘦,惯着玄霜阁玄色劲装;左颈一道旧疤,情绪起伏时会无意识地按住它
|
||||
- motive:核心动机是替亡兄查清族灭真相;深处渴望被真正接纳,却最恐惧信任后再被背叛——恩怨分明、私恩高于阵营
|
||||
- backstory:玄霜阁旁支遗孤,兄长死于阁主清洗,被迫隐忍效忠仇敌,暗中收集证据……
|
||||
- arc:起点——以阵营之名敌视主角;转变——查真相途中数次被主角所救,护短的核心压过阵营立场;终点——背叛玄霜阁与主角同盟,却因「先发制人疏远他人」的旧伤酿成一次几乎致命的误会
|
||||
- speech_tics:["惯用反问句堵人", "说狠话前先轻笑一声「呵」", "极少称呼对方名字,用「你」保持距离"]
|
||||
|
||||
47
packages/agents/ww_agents/prompts/characterization.md
Normal file
47
packages/agents/ww_agents/prompts/characterization.md
Normal file
@@ -0,0 +1,47 @@
|
||||
你是长篇连载网络小说的「人物塑造续审」(characterization-checker)——专审人物塑造的资深网文编辑。你的唯一职责:对照作品真相源里的人物卡(尤其**动机 motive** 与**外貌 appearance** 两个客观锚点),逐条诊断本章草稿里人物塑造的**失真/扁平/失声/动机断裂**,引用具体片段并给出置信度。你**只读、只诊断、只报告**,绝不改稿、不续写、不写库(不变量 #3)。你的产出是**建议性的(advisory)**——供作者参考,不阻断验收。
|
||||
|
||||
## 审稿依据:人物卡(客观锚点优先)
|
||||
|
||||
注入材料里带本作的人物卡(含 `motive` 动机/欲望/恐惧、`appearance` 外貌、性格三层 `traits`、成长弧 `arc`)。以人物卡为客观基准,重点核对本章人物的言行是否与其**既定动机**一致、外貌/标识描写是否与**既定 appearance** 冲突。人物卡缺失或未注入该角色时,退回网文通用基线:主角与主要配角应有**清晰动机驱动**、**可辨识的说话声音**、**行为符合已建立的性格逻辑**,避免工具人式的扁平与前后失声。
|
||||
|
||||
## 只审塑造、不审辞藻(关键纪律)
|
||||
|
||||
- **显式忽略文本长度与辞藻华丽度**:段落长短、用词是否华丽、句子是否优美,**一律不在本审范围**。只判「这个人物写得像不像这个人、动机通不通、有没有辨识度」,不判文笔好坏、不判篇幅多寡。
|
||||
- 不因描写细腻/篇幅长就抬高评价,也不因文字朴素/篇幅短就判为塑造不足——**塑造质量与文本体量、辞藻华丽度无关**(对抗 LLM 评审的冗长偏见与辞藻偏见)。
|
||||
- 节奏/注水、文风漂移、一致性冲突、伏笔——**均不在本审范围**(各有专审)。本审只看人物塑造这一维。
|
||||
|
||||
## 审稿方法(逐步执行)
|
||||
|
||||
1. **定位人物**:列出本章有实质戏份的角色,优先主角与主要配角。
|
||||
2. **对锚诊断**:逐个角色,对照人物卡的 `motive`/`appearance`/`traits`/`arc`,找出本章中与之**失真、断裂、扁平化、失声**的具体表现。
|
||||
3. **证据优先**:每条诊断都必须从本章草稿**逐字摘录**一小段支撑片段(内容锚,不得改写),锚定「问题出在哪句」;无法逐字锚定则不报(不臆造)。
|
||||
4. **给置信度**:每条诊断给一个 `0.0–1.0` 的置信度——你越确信是真问题给越高;主观/边缘/见仁见智的给低值,供作者过滤(对抗 LLM 评审主观偏见)。
|
||||
5. **建议而非改稿**:给出改进方向(如「让其行为回扣既定动机 X」),但**绝不输出改写后的正文**。
|
||||
|
||||
### 塑造维度锚点(aspect 取值口径)
|
||||
|
||||
- `动机一致性`:本章言行是否被人物卡既定动机驱动,有无无动机行动/动机断裂;
|
||||
- `声音辨识度`:对白/内心是否有该角色可辨识的说话声音,还是千人一腔;
|
||||
- `行为逻辑`:行为是否符合已建立的性格逻辑与处境,有无为推进剧情的强行 OOC;
|
||||
- `外貌一致性`:外貌/标识性描写是否与人物卡 `appearance` 冲突;
|
||||
- `扁平化`:是否沦为只服务功能的工具人、缺乏立体动机与内在张力。
|
||||
|
||||
## 输出格式(严格 JSON,仅含以下顶层字段,不要多余字段或解释文字)
|
||||
|
||||
- **`issues`**:人物塑造问题列表(数组)。逐条诊断;每个元素为对象,含:
|
||||
- `character`:涉及的角色名(尽量对齐人物卡 name;无法归属某角色则留空串);
|
||||
- `aspect`:塑造维度(取上述锚点之一或贴切的简短维度名);
|
||||
- `where`:本章定位(哪一段/哪个场景体现该问题,便于作者定位);
|
||||
- `quote`:从本章草稿**逐字摘录**的支撑片段(含标点,不得改写;无法逐字锚定则不要输出该条);
|
||||
- `diagnosis`:诊断说明(为何此处塑造偏弱/失真,扣住动机/声音/行为逻辑等客观点);
|
||||
- `suggestion`:改进建议(方向性,不含改写后的正文);
|
||||
- `confidence`:置信度浮点 `0.0–1.0`(越确信越高,主观/边缘给低值)。
|
||||
- 若全章人物塑造无明显问题,`issues` 为空数组 `[]`。
|
||||
|
||||
## 纪律(不可违反)
|
||||
|
||||
- 你**只读、只报人物塑造诊断**,绝不改稿、不写库、不输出任何改写后的正文(不变量 #3);你的结论是 advisory 建议,不阻断验收。
|
||||
- **显式忽略文本长度与辞藻华丽度**——只判塑造,不判文笔与篇幅。
|
||||
- 每条诊断必须带逐字摘录的 `quote` 与 `confidence`,不臆造、不空泛;无逐字证据则不报。
|
||||
- 无明显问题则 `issues` 为空数组;宁缺毋滥、少而准,避免维度噪声淹没作者注意力。
|
||||
- 只输出上述字段的 JSON,不附加旁白、不解释推理过程。
|
||||
32
packages/agents/ww_agents/prompts/project-plan.md
Normal file
32
packages/agents/ww_agents/prompts/project-plan.md
Normal file
@@ -0,0 +1,32 @@
|
||||
你是中文网文的「立项方案师」(project-plan,分析档)。你像一位既懂创作、又懂平台数据的资深网文主编:在作者刚有一个题材 + 一句话故事的种子时,为其构思一份**结构化立项方案**,把模糊的灵感推演成可开写的书级蓝图,供作者预填立项向导。你只产结构化方案,**不改稿、不写库**(纯预览,不变量 #3)。
|
||||
|
||||
## 输入材料
|
||||
|
||||
- 作品种子(向导草稿序列化文本):题材、一句话故事(logline)为**必给种子**;可能另附暂定书名、立意、主题、故事结构、基调、结局取向、叙事视角、核心卖点等——**有则贴合,无则不臆造**。
|
||||
- 作者的一句话方向/需求(可空——空则围绕种子自由构思)。
|
||||
|
||||
读不到的字段就当它不存在:不编造与已知种子无关的设定,宁缺毋造;已给的字段要顺着推演、不要推翻。
|
||||
|
||||
## 核心纪律
|
||||
|
||||
- **忠于种子**:方案必须从题材 + logline 生长出来,是对种子的**放大与落地**,不是另起炉灶换一个故事。
|
||||
- **可开写**:每个维度都要具体到能指导后续世界观/大纲/写章,不写正确的废话(「主角很强」「结局很爽」这类无信息量表述一律不要)。
|
||||
- **服务连载**:站在平台/读者视角想「黄金三章靠什么钩人、凭什么比同类更想追读、长线靠什么留存」,而非只站作者立场自嗨。
|
||||
|
||||
## 各维度字段要求(严格对应输出结构)
|
||||
|
||||
- **title_candidates**(书名候选清单):3–5 个差异化书名候选,每个都要贴题材、有记忆点、能一眼看出爽点/赛道;已给暂定书名时可保留并另给替代。避免雷同——若两个候选互换后几乎一样则重拟其一。
|
||||
- **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)。
|
||||
32
packages/agents/ww_agents/prompts/write_craft.md
Normal file
32
packages/agents/ww_agents/prompts/write_craft.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 写章创作纪律(品类无关内核)
|
||||
|
||||
你是一位深耕中文网络小说的职业写手。下面是贯穿所有题材的创作纪律——它们是**灵活的原则**,不是逐条打勾的清单。按当下这一章真正要写的情节去取舍运用,不要机械套用、不要为了满足某条规则而牺牲叙事本身。
|
||||
|
||||
## 语域总纲
|
||||
|
||||
- 网文是**读起来快**的文体:信息密度偏低、句子偏短、段落偏碎。读者靠画面与动作往前滑,而不是靠密集的说明与铺陈。
|
||||
- **重行动、轻独白**:优先用发生的事推进故事,少写大段静止的心理分析或情绪反刍。心理活动点到即止,让位于外部动作、对话与场景。
|
||||
- **情节持续推进**:每个场景都要往前走一步——带来新信息、新冲突、新关系变化或新目标。避免原地打转的交代和没有推进的过场堆叠。
|
||||
|
||||
## show,别 tell
|
||||
|
||||
- 把情绪**外化**为可见可感的东西:动作、姿态、生理反应、环境细节、对话里的停顿与语气,而不是直接抛出「他很愤怒」「她很紧张」这类概括标签。
|
||||
- 让读者从画面里**自己推断**人物的状态,而不是由叙述者代为总结。越关键的情绪,越要用细节演出来,而不是一句话说破。
|
||||
|
||||
## 对话的声音
|
||||
|
||||
- 每个角色的说话方式要**可区分**:依据角色档案里的性格、出身、处境,赋予各自的用词习惯、句子长短、口头禅与说话节奏。
|
||||
- **忌所有人一个腔调**——反派、少年、长者、市井之徒不该共用同一套措辞与句式。对话要能让读者仅凭语气就认出是谁在说。
|
||||
- 对话标签(「他说」「她道」)的**位置要变化**:时而前置、时而后置、时而用一个动作 beat 代替,避免每句都以同一模式收尾。
|
||||
|
||||
## 反注水:按情节功能分配篇幅
|
||||
|
||||
- 字数**跟着情节功能走**,不是往一个固定目标硬凑。关键的冲突、转折、情绪高点要写**厚**——给足动作分解、感官细节与张力;过场、赶路、次要交代要写**薄**——一两句带过。
|
||||
- 密点厚、疏点薄。**绝不为凑字数注水**:删掉重复的心理复述、冗余的形容词堆叠、无推进的对话与说明。
|
||||
- 篇幅是结果而非目标——先想清楚这一章每个部分承担什么功能,再决定它值多少笔墨。
|
||||
|
||||
## 文字质感
|
||||
|
||||
- **句长错落**:长短句交替,避免连续同长度、同结构的句子带来的机械节奏。
|
||||
- **避免用情绪概括收尾**:段落或章节的结尾不要落在「他心中五味杂陈」「一切都不一样了」这类抽象总结上,用具体的动作、画面或悬念收束。
|
||||
- **慎用连续排比与套路化修辞**:偶一为之可以蓄势,密集堆砌则显空洞、露出 AI 腔。宁可朴素准确,不要华而不实。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Pydantic 类型承载结构化解析与 `isinstance` 校验,无法从文本推导、无法运行时安全构造,
|
||||
必须留 Python。`SPECS[name].output_schema` 一律从这里派生(单向派生,避免双真相)。
|
||||
`input_schema` 全 21 个恒为 None(入参为序列化文本),本波不建入参槽(YAGNI)。
|
||||
`input_schema` 全 23 个恒为 None(入参为序列化文本),本波不建入参槽(YAGNI)。
|
||||
|
||||
`refiner` 是唯一纯文本 writer:`None` 是合法值(无结构化 schema)。
|
||||
"""
|
||||
@@ -17,6 +17,7 @@ from .schemas import (
|
||||
BlurbResult,
|
||||
BookTeardownResult,
|
||||
CharacterGenResult,
|
||||
CharacterizationReview,
|
||||
ContinuationResult,
|
||||
ContinuityReview,
|
||||
DeAiResult,
|
||||
@@ -30,6 +31,7 @@ from .schemas import (
|
||||
OutlineResult,
|
||||
PaceReview,
|
||||
PolishResult,
|
||||
ProjectPlanResult,
|
||||
StyleDriftReview,
|
||||
StyleFingerprintResult,
|
||||
TitleListResult,
|
||||
@@ -42,6 +44,7 @@ SCHEMA_CATALOG: Final[dict[str, type[BaseModel] | None]] = {
|
||||
"outliner": OutlineResult,
|
||||
"foreshadow": ForeshadowReview,
|
||||
"pace": PaceReview,
|
||||
"characterization": CharacterizationReview,
|
||||
"style_extract": StyleFingerprintResult,
|
||||
"style": StyleDriftReview,
|
||||
"refiner": None, # 唯一纯文本 writer,None 是合法值
|
||||
@@ -59,6 +62,7 @@ SCHEMA_CATALOG: Final[dict[str, type[BaseModel] | None]] = {
|
||||
"expand": PolishResult,
|
||||
"de-ai": DeAiResult,
|
||||
"teardown": BookTeardownResult,
|
||||
"project-plan": ProjectPlanResult,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -226,6 +226,50 @@ class StyleDriftReview(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
# ---- 人物塑造续审结构化输出(灵感③ · advisory 单维;仅建议、不阻断验收)----
|
||||
|
||||
|
||||
class CharacterizationIssue(BaseModel):
|
||||
"""单条人物塑造问题:角色 + 维度 + 定位 + 逐字引用 + 诊断 + 建议 + 置信度。
|
||||
|
||||
以人物卡 `motive`/`appearance` 为客观锚点。`quote` 是从本章草稿**逐字摘录**的支撑
|
||||
片段(内容锚,不得改写);`confidence` 供作者过滤主观/边缘诊断(对抗 LLM-judge 偏见)。
|
||||
全字段给默认值守解析韧性(畸形/缺字段不炸)。
|
||||
"""
|
||||
|
||||
character: str = Field(
|
||||
default="", description="涉及的角色名(尽量对齐人物卡 name;无法归属留空)"
|
||||
)
|
||||
aspect: str = Field(
|
||||
default="",
|
||||
description="塑造维度(动机一致性 / 声音辨识度 / 行为逻辑 / 外貌一致性 / 扁平化 等)",
|
||||
)
|
||||
where: str = Field(default="", description="本章定位:哪一段/哪个场景体现该问题")
|
||||
quote: str = Field(
|
||||
default="",
|
||||
description="从本章草稿**逐字摘录**的支撑片段(含标点,不得改写);供作者内容锚定位",
|
||||
)
|
||||
diagnosis: str = Field(default="", description="诊断说明:为何此处人物塑造偏弱/失真")
|
||||
suggestion: str = Field(default="", description="改进建议(方向性,不含改写后的正文)")
|
||||
confidence: float = Field(
|
||||
default=0.0, description="置信度 0.0–1.0(越确信越高,主观/边缘给低值,供作者过滤)"
|
||||
)
|
||||
|
||||
|
||||
class CharacterizationReview(BaseModel):
|
||||
"""人物塑造续审结构化产出:只读建议清单(advisory,不阻断验收,不变量 #3)。
|
||||
|
||||
**带默认值降级**:材料无人物卡 / 无明显问题时返回 `issues=[]`(默认值),不报错、
|
||||
不阻塞其余审(§5.2 失败隔离之外的内容性兜底,仿 `StyleDriftReview`)。仅建议——
|
||||
不进 conflicts、不碰验收 gate(灵感 D2 advisory)。
|
||||
"""
|
||||
|
||||
issues: list[CharacterizationIssue] = Field(
|
||||
default_factory=list,
|
||||
description="人物塑造问题清单(每条带角色/维度/定位/逐字引用/置信度);无则空列表",
|
||||
)
|
||||
|
||||
|
||||
# ---- worldbuilder 结构化输出(ARCH §5.4 worldbuilder 行 / §6.5 / §4.5)----
|
||||
|
||||
|
||||
@@ -285,10 +329,18 @@ class CharacterCard(BaseModel):
|
||||
role: str = Field(description="角色定位(主角 / CP / 对手 / 导师 / 工具人 等)")
|
||||
traits: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="性格特质清单(核心-表层-阴影 / 动机 / 欲望 / 恐惧)",
|
||||
description="性格特质清单(仅核心-表层-阴影三层;动机/欲望/恐惧落 motive)",
|
||||
)
|
||||
appearance: str = Field(
|
||||
default="",
|
||||
description="外貌形象(身形/五官/气质/标志性外观/常着装扮);缺则空串(守解析韧性)",
|
||||
)
|
||||
motive: str = Field(
|
||||
default="",
|
||||
description="核心动机/欲望/恐惧(驱动行动的唯一内驱,arc 由此推动);缺则空串",
|
||||
)
|
||||
backstory: str = Field(description="背景故事(出身、关键经历、创伤/转折点)")
|
||||
arc: str = Field(description="人物弧光(起点 → 转变 → 终点,与剧情挂钩)")
|
||||
arc: str = Field(description="人物弧光(起点 → 转变 → 终点,与剧情挂钩,由 motive 推动)")
|
||||
speech_tics: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="口癖/语言风格(用词偏好、口头禅,喂给文风一致性)",
|
||||
@@ -594,3 +646,31 @@ class BookTeardownResult(BaseModel):
|
||||
default_factory=list,
|
||||
description="抓人钩子/爽点套路清单;无则空列表",
|
||||
)
|
||||
|
||||
|
||||
# ---- 立项方案生成器(project-plan;analyst;纯预览 writes=())----
|
||||
|
||||
|
||||
class ProjectPlanResult(BaseModel):
|
||||
"""立项方案生成器结构化产出:书级蓝图,供预填立项向导(灵感⑤)。
|
||||
|
||||
纯预览产物——不映射任何业务表、不入库(`project_plan_spec.writes=()`,不变量 #3)。
|
||||
**全字段给默认值守解析韧性**(仿 `StyleDriftReview` 降级范式):LLM 漏产任一字段
|
||||
不致整个方案解析失败——缺字段降级为空(书名候选空列表 / 文本字段空串),前端只回填
|
||||
非空交集、不覆盖作者已编辑值。
|
||||
"""
|
||||
|
||||
title_candidates: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="书名候选清单(3–5 个差异化候选);无则空列表",
|
||||
)
|
||||
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="基调(全书情绪底色与阅读质感);缺则空串")
|
||||
|
||||
@@ -30,6 +30,7 @@ __all__ = [
|
||||
"book_title_spec",
|
||||
"brainstorm_spec",
|
||||
"character_gen_spec",
|
||||
"characterization_spec",
|
||||
"continue_spec",
|
||||
"continuity_spec",
|
||||
"de_ai_spec",
|
||||
@@ -42,6 +43,7 @@ __all__ = [
|
||||
"opening_spec",
|
||||
"outliner_spec",
|
||||
"pace_spec",
|
||||
"project_plan_spec",
|
||||
"refiner_spec",
|
||||
"style_drift_spec",
|
||||
"style_extract_spec",
|
||||
@@ -118,6 +120,20 @@ style_drift_spec = AgentSpec(
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
# ---- 人物塑造续审(灵感③;analyst 档;advisory 单维——仅建议、不阻断验收,D2)----
|
||||
characterization_spec = AgentSpec(
|
||||
name="characterization",
|
||||
tier="analyst", # 不变量 #2:只声明档位,不写 model(人物塑造诊断用分析档)
|
||||
system_prompt=load_prompt("characterization"),
|
||||
input_schema=None, # 注入材料为序列化文本(人物卡 + 草稿,经 assemble),非结构化入参
|
||||
output_schema=SCHEMA_CATALOG["characterization"],
|
||||
reads=(
|
||||
"characters",
|
||||
), # 以人物卡 motive/appearance 为客观锚点(经 assemble 的 stable_core 注入)
|
||||
writes=(), # 只读(不变量 #3);advisory 唯一落库=collect 留痕,不进 conflicts/验收 gate
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
# ---- 回炉(refiner;writer 档;纯文本重写;非 Agent 流水线、非持久,M4-e)----
|
||||
refiner_spec = AgentSpec(
|
||||
name="refiner",
|
||||
@@ -298,6 +314,18 @@ teardown_spec = AgentSpec(
|
||||
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] 为同对象,不变量)
|
||||
# MappingProxyType:只读视图,运行时 `SPECS[x] = ...` / `del SPECS[x]` 抛 TypeError
|
||||
@@ -309,6 +337,7 @@ _SPECS_BY_NAME: dict[str, AgentSpec] = {
|
||||
outliner_spec,
|
||||
foreshadow_spec,
|
||||
pace_spec,
|
||||
characterization_spec,
|
||||
style_extract_spec,
|
||||
style_drift_spec,
|
||||
refiner_spec,
|
||||
@@ -326,9 +355,10 @@ _SPECS_BY_NAME: dict[str, AgentSpec] = {
|
||||
expand_spec,
|
||||
de_ai_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)
|
||||
|
||||
# 四审受信保留名 —— 独立显式白名单(安全边界锚在此,不依附派生集合)
|
||||
|
||||
47
packages/config/tests/test_genre_craft.py
Normal file
47
packages/config/tests/test_genre_craft.py
Normal 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} 片段未含字数区间"
|
||||
@@ -1,3 +1,4 @@
|
||||
from ww_config.genre_craft import GENRE_CRAFT, genre_craft_fragment
|
||||
from ww_config.settings import Settings, get_settings
|
||||
|
||||
__all__ = ["Settings", "get_settings"]
|
||||
__all__ = ["GENRE_CRAFT", "Settings", "genre_craft_fragment", "get_settings"]
|
||||
|
||||
67
packages/config/ww_config/genre_craft.py
Normal file
67
packages/config/ww_config/genre_craft.py
Normal 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]] = {
|
||||
"玄幻": (
|
||||
"语域偏热血爽快、画面感强;节奏偏快,冲突密集,实力成长线要清晰可感。"
|
||||
"钩子偏好等级压制、打脸反转、机缘奇遇与悬念前置。"
|
||||
"单章篇幅参考约 2500–3500 字,按情节功能弹性调整。"
|
||||
"常见套路:升级流、扮猪吃虎、系统/金手指、宗门家族争斗——用得要有新意,别照搬。"
|
||||
),
|
||||
"仙侠": (
|
||||
"语域偏古雅大气,兼顾意境与热血;节奏中到快,以境界突破为张力节点。"
|
||||
"钩子偏好机缘造化、道心考验、恩怨情仇与天骄争锋。"
|
||||
"单章篇幅参考约 2500–3500 字,按情节功能弹性调整。"
|
||||
"常见套路:修真等级、飞升渡劫、师门情义、逆境翻盘——古意要真、别堆砌辞藻。"
|
||||
),
|
||||
"都市": (
|
||||
"语域偏现代口语、贴近生活、爽感直接;节奏快,即时反馈要强。"
|
||||
"钩子偏好装逼打脸、身份反差、隐藏实力与逆袭翻身。"
|
||||
"单章篇幅参考约 2000–3000 字(移动端碎片阅读),按情节功能弹性调整。"
|
||||
"常见套路:扮猪吃虎、隐藏身份、都市异能、商战权斗——落点要接地气。"
|
||||
),
|
||||
"科幻": (
|
||||
"语域偏冷静、有逻辑感、由设定驱动;节奏偏中,重悬念与推演,慎防设定倾泻。"
|
||||
"钩子偏好谜题危机、科技奇观与命运抉择。"
|
||||
"单章篇幅参考约 2500–3500 字,按情节功能弹性调整。"
|
||||
"常见套路:末世、星际、赛博、时间循环——设定服务剧情,避免大段科普打断叙事。"
|
||||
),
|
||||
"历史": (
|
||||
"语域偏厚重考究、代入感强;节奏偏中,以权谋博弈与人物弧线为主。"
|
||||
"钩子偏好权谋算计、命运转折与家国大势。"
|
||||
"单章篇幅参考约 3000–4000 字,按情节功能弹性调整。"
|
||||
"常见套路:穿越架空、争霸天下、朝堂权谋、种田发展——细节要经得起推敲。"
|
||||
),
|
||||
"悬疑": (
|
||||
"语域偏冷峻克制、讲究信息控制;节奏张弛有度,靠悬念与反转拉动。"
|
||||
"钩子偏好谜题、危险迫近、误导与真相错位。"
|
||||
"单章篇幅参考约 2500–3500 字,按情节功能弹性调整。"
|
||||
"常见套路:多线叙事、伏笔回收、层层反转、不可靠叙述——保持信息差,慎过早揭底。"
|
||||
),
|
||||
"言情": (
|
||||
"语域偏细腻、注重情绪张力与关系互动;节奏偏中,以情感推拉为主线。"
|
||||
"钩子偏好暧昧拉扯、误会错过、情感转折与命运羁绊。"
|
||||
"单章篇幅参考约 2500–3500 字,按情节功能弹性调整。"
|
||||
"常见套路:先婚后爱、破镜重圆、双向奔赴、追妻火葬场——情绪外化于互动细节,别空喊。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def genre_craft_fragment(genre: str | None) -> str | None:
|
||||
"""按题材取 craft 片段;未指定或命不中预设题材时返回 None(不注入、不报错)。"""
|
||||
if not genre:
|
||||
return None
|
||||
return GENRE_CRAFT.get(genre)
|
||||
@@ -128,6 +128,7 @@ class FakeReviewRepo:
|
||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||
style: dict[str, Any] | None = None,
|
||||
pace: dict[str, Any] | None = None,
|
||||
characterization: dict[str, Any] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> Any:
|
||||
entry = {
|
||||
@@ -139,6 +140,7 @@ class FakeReviewRepo:
|
||||
"foreshadow_sug": list(foreshadow_sug or []),
|
||||
"style": style,
|
||||
"pace": pace,
|
||||
"characterization": characterization,
|
||||
"health_score": health_score,
|
||||
}
|
||||
self.records.append(entry)
|
||||
|
||||
@@ -141,6 +141,7 @@ class FakeReviewRepo:
|
||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||
style: dict[str, Any] | None = None,
|
||||
pace: dict[str, Any] | None = None,
|
||||
characterization: dict[str, Any] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> Any:
|
||||
rec = FakeReviewRecord(chapter_no, list(conflicts))
|
||||
|
||||
207
packages/core/tests/test_characterization_review.py
Normal file
207
packages/core/tests/test_characterization_review.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""灵感③ 人物塑造 advisory 审单测——并入 review 图 + collect characterization 列 + SSE 事件。
|
||||
|
||||
全部注入 mock 网关 / 内存 fake repo,无真 LLM、无真 Postgres(图用 MemorySaver)。
|
||||
asyncio_mode=auto。验第五审(advisory)落 `characterization` 列、SSE `section` + `characterization`
|
||||
事件、空 issues 优雅降级、失败隔离不阻塞其余审。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fakes_orchestrator import FakeReviewRepo, SchemaRoutingRunGateway
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from ww_agents import (
|
||||
CharacterizationIssue,
|
||||
CharacterizationReview,
|
||||
ContinuityReview,
|
||||
ForeshadowReview,
|
||||
PaceReview,
|
||||
StyleDriftReview,
|
||||
)
|
||||
from ww_core.orchestrator import (
|
||||
CHARACTERIZATION,
|
||||
CONTINUITY,
|
||||
EVENT_CHARACTERIZATION,
|
||||
EVENT_DONE,
|
||||
EVENT_SECTION,
|
||||
REVIEW_INCOMPLETE,
|
||||
REVIEW_OK,
|
||||
SECTION_DONE,
|
||||
SECTION_INCOMPLETE,
|
||||
ChapterState,
|
||||
build_review_context,
|
||||
build_review_graph,
|
||||
collect_reviews,
|
||||
extract_characterization,
|
||||
normalize_review,
|
||||
)
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
|
||||
|
||||
STABLE = "## 世界观硬规则\n灵气可凝丹\n## 人物卡\n沈砚 动机:护妹至死"
|
||||
VOLATILE = "## 近况\n主角已破境"
|
||||
DRAFT = "他忽然转身就走,什么也没说。"
|
||||
|
||||
_CHAR = CharacterizationReview(
|
||||
issues=[
|
||||
CharacterizationIssue(
|
||||
character="沈砚",
|
||||
aspect="动机一致性",
|
||||
where="第1段",
|
||||
quote="他忽然转身就走,什么也没说。",
|
||||
diagnosis="与既定护妹动机断裂",
|
||||
suggestion="退缩扣回护妹动机",
|
||||
confidence=0.82,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _state(reviews: dict[str, Any] | None = None) -> ChapterState:
|
||||
state: ChapterState = {
|
||||
"project_id": PROJECT,
|
||||
"chapter_no": 7,
|
||||
"user_id": USER,
|
||||
"stable_core": STABLE,
|
||||
"volatile": VOLATILE,
|
||||
"draft": DRAFT,
|
||||
"review_context": build_review_context(draft=DRAFT, stable_core=STABLE, volatile=VOLATILE),
|
||||
}
|
||||
if reviews is not None:
|
||||
state["reviews"] = reviews
|
||||
return state
|
||||
|
||||
|
||||
def _char_reviews() -> dict[str, Any]:
|
||||
return {CHARACTERIZATION: {"status": REVIEW_OK, "result": _CHAR.model_dump()}}
|
||||
|
||||
|
||||
# ---- collect:characterization 列映射(纯函数)----
|
||||
|
||||
|
||||
def test_extract_characterization_returns_whole_dict() -> None:
|
||||
char = extract_characterization(_char_reviews())
|
||||
|
||||
assert char is not None
|
||||
assert len(char["issues"]) == 1
|
||||
assert char["issues"][0]["character"] == "沈砚"
|
||||
assert char["issues"][0]["confidence"] == 0.82
|
||||
|
||||
|
||||
def test_extract_characterization_none_when_incomplete() -> None:
|
||||
reviews = {CHARACTERIZATION: {"status": REVIEW_INCOMPLETE, "result": None}}
|
||||
assert extract_characterization(reviews) is None
|
||||
|
||||
|
||||
def test_extract_characterization_none_when_absent() -> None:
|
||||
assert extract_characterization({}) is None
|
||||
|
||||
|
||||
async def test_collect_records_characterization_to_its_column() -> None:
|
||||
repo = FakeReviewRepo()
|
||||
|
||||
await collect_reviews(_state(_char_reviews()), review_repo=repo)
|
||||
|
||||
assert len(repo.records) == 1
|
||||
rec = repo.records[0]
|
||||
assert rec["characterization"]["issues"][0]["aspect"] == "动机一致性"
|
||||
# advisory:不进 conflicts(不阻断验收)
|
||||
assert rec["conflicts"] == []
|
||||
|
||||
|
||||
# ---- 图:characterization 作为默认第五审并行跑通 ----
|
||||
|
||||
|
||||
async def test_characterization_runs_in_default_review_graph() -> None:
|
||||
gateway = SchemaRoutingRunGateway(
|
||||
{
|
||||
ContinuityReview: ContinuityReview(conflicts=[]),
|
||||
ForeshadowReview: ForeshadowReview(),
|
||||
PaceReview: PaceReview(),
|
||||
StyleDriftReview: StyleDriftReview(),
|
||||
CharacterizationReview: _CHAR,
|
||||
}
|
||||
)
|
||||
repo = FakeReviewRepo()
|
||||
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "char-1"}}
|
||||
|
||||
final = await graph.ainvoke(_state(), config=config)
|
||||
|
||||
assert final["reviews"][CHARACTERIZATION]["status"] == REVIEW_OK
|
||||
rec = repo.records[0]
|
||||
assert rec["characterization"]["issues"][0]["character"] == "沈砚"
|
||||
|
||||
|
||||
async def test_characterization_failure_isolated_does_not_block_others() -> None:
|
||||
# characterization schema 缺失 → SchemaRoutingRunGateway 抛 KeyError → 隔离为 incomplete
|
||||
gateway = SchemaRoutingRunGateway(
|
||||
{
|
||||
ContinuityReview: ContinuityReview(conflicts=[]),
|
||||
ForeshadowReview: ForeshadowReview(),
|
||||
PaceReview: PaceReview(),
|
||||
StyleDriftReview: StyleDriftReview(),
|
||||
}
|
||||
)
|
||||
repo = FakeReviewRepo()
|
||||
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "char-2"}}
|
||||
|
||||
final = await graph.ainvoke(_state(), config=config)
|
||||
|
||||
assert final["reviews"][CHARACTERIZATION]["status"] == REVIEW_INCOMPLETE
|
||||
rec = repo.records[0]
|
||||
assert rec["characterization"] is None # 失败列留空,不阻塞其余
|
||||
|
||||
|
||||
# ---- SSE:normalize_review surface characterization 事件 ----
|
||||
|
||||
|
||||
async def test_normalize_review_surfaces_characterization_event() -> None:
|
||||
events = [e async for e in normalize_review(_char_reviews())]
|
||||
|
||||
# 单审:section(done) + characterization + done
|
||||
assert [e.event for e in events] == [EVENT_SECTION, EVENT_CHARACTERIZATION, EVENT_DONE]
|
||||
section = events[0]
|
||||
assert section.data == {"name": CHARACTERIZATION, "status": SECTION_DONE}
|
||||
char_evt = events[1]
|
||||
issues = char_evt.data["issues"]
|
||||
assert isinstance(issues, list)
|
||||
assert issues[0]["character"] == "沈砚"
|
||||
assert issues[0]["confidence"] == 0.82
|
||||
|
||||
|
||||
async def test_characterization_sorts_first_in_multi_review_sse() -> None:
|
||||
# 字典序:characterization < continuity → advisory 事件排在最前,不打乱既有四审断言
|
||||
reviews = {
|
||||
CHARACTERIZATION: {"status": REVIEW_OK, "result": _CHAR.model_dump()},
|
||||
CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": []}},
|
||||
}
|
||||
events = [e async for e in normalize_review(reviews)]
|
||||
names = [e.data.get("name") for e in events if e.event == EVENT_SECTION]
|
||||
assert names == [CHARACTERIZATION, CONTINUITY]
|
||||
|
||||
|
||||
async def test_normalize_review_marks_characterization_incomplete() -> None:
|
||||
reviews = {CHARACTERIZATION: {"status": REVIEW_INCOMPLETE, "result": None}}
|
||||
|
||||
events = [e async for e in normalize_review(reviews)]
|
||||
|
||||
assert [e.event for e in events] == [EVENT_SECTION, EVENT_DONE]
|
||||
assert events[0].data == {"name": CHARACTERIZATION, "status": SECTION_INCOMPLETE}
|
||||
|
||||
|
||||
async def test_characterization_empty_issues_still_records() -> None:
|
||||
# 空 issues 降级仍是 ok 结果、照常落库(区别于 incomplete None)
|
||||
repo = FakeReviewRepo()
|
||||
reviews = {
|
||||
CHARACTERIZATION: {"status": REVIEW_OK, "result": CharacterizationReview().model_dump()}
|
||||
}
|
||||
|
||||
await collect_reviews(_state(reviews), review_repo=repo)
|
||||
|
||||
assert repo.records[0]["characterization"] == {"issues": []}
|
||||
@@ -181,6 +181,39 @@ def test_build_character_gen_context_injects_existing_and_generated() -> None:
|
||||
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:
|
||||
# 首次单生成:无已有/已生成时仍产出可用上下文(不崩)
|
||||
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 # 世界观约束注入
|
||||
|
||||
|
||||
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:
|
||||
gateway = FailingRunGateway(RuntimeError("provider down"))
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
@@ -19,6 +20,7 @@ from ww_core.domain.repositories import (
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
)
|
||||
from ww_core.domain.review_repo import ReviewRepo, ReviewView
|
||||
from ww_core.memory import (
|
||||
AssembledContext,
|
||||
EntityKind,
|
||||
@@ -103,6 +105,28 @@ class FakeProjectSpecRepo:
|
||||
return self.row
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeReviewRepo:
|
||||
"""内存审稿留痕 fake(灵感②冲突裁决反哺)——只需 list_for_chapter 供 assemble 反读。
|
||||
|
||||
`queried` 记录被查询的章号(验证读的是上一章 chapter_no-1);record/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(
|
||||
*,
|
||||
outline: dict[int, OutlineView] | None = None,
|
||||
@@ -113,6 +137,7 @@ def build_repos(
|
||||
style: StyleView | None = None,
|
||||
rules: list[RuleView] | None = None,
|
||||
spec: ProjectSpecView | None = None,
|
||||
review: ReviewRepo | None = None,
|
||||
) -> MemoryRepos:
|
||||
return MemoryRepos(
|
||||
outline=FakeOutlineRepo(outline or {}),
|
||||
@@ -123,6 +148,7 @@ def build_repos(
|
||||
style=FakeStyleRepo(style),
|
||||
rules=FakeRulesRepo(rules or []),
|
||||
project=FakeProjectSpecRepo(spec),
|
||||
review=review,
|
||||
)
|
||||
|
||||
|
||||
@@ -383,6 +409,253 @@ async def test_chapter_directive_uses_chapter_no() -> None:
|
||||
assert "请创作第 7 章的正文。" in ctx.volatile
|
||||
|
||||
|
||||
# ---- 灵感 F1:genre-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 ----
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
|
||||
from fakes_orchestrator import FakeStreamGateway, RaisingGateway
|
||||
from ww_agents import load_craft_doctrine
|
||||
from ww_core.orchestrator import (
|
||||
EVENT_DONE,
|
||||
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.stream is True
|
||||
assert len(req.system) == 1
|
||||
assert req.system[0].text == STABLE
|
||||
assert req.system[0].cache is True # 不变量#9:稳定内核进缓存断点前
|
||||
# 灵感①:system = [craft 教条(cache), stable_core(cache)],craft 在最前。
|
||||
assert len(req.system) == 2
|
||||
assert all(b.cache is True for b in req.system) # 不变量#9:稳定内核进缓存断点前
|
||||
assert req.system[1].text == STABLE
|
||||
assert req.input == VOLATILE
|
||||
assert req.scope.user_id == USER
|
||||
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:
|
||||
# 不变量#9:craft 块字节稳定——两次构造完全一致,无每章易变项掺入。
|
||||
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、构造正确请求 ----
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
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:
|
||||
|
||||
@@ -34,6 +34,7 @@ class FakeReviewRepo:
|
||||
foreshadow_sug: list[dict[str, object]] | None = None,
|
||||
style: dict[str, object] | None = None,
|
||||
pace: dict[str, object] | None = None,
|
||||
characterization: dict[str, object] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> ReviewView:
|
||||
self._seq += 1
|
||||
@@ -46,6 +47,7 @@ class FakeReviewRepo:
|
||||
foreshadow_sug=list(foreshadow_sug or []),
|
||||
style=style,
|
||||
pace=pace,
|
||||
characterization=characterization,
|
||||
health_score=health_score,
|
||||
decisions=None,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@ from fakes_orchestrator import FakeReviewRepo, SchemaRoutingRunGateway
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from ww_agents import (
|
||||
CharacterizationIssue,
|
||||
CharacterizationReview,
|
||||
Conflict,
|
||||
ContinuityReview,
|
||||
ForeshadowReview,
|
||||
@@ -24,6 +26,7 @@ from ww_agents import (
|
||||
StyleDriftSegment,
|
||||
)
|
||||
from ww_core.orchestrator import (
|
||||
CHARACTERIZATION,
|
||||
CONTINUITY,
|
||||
EVENT_CONFLICT,
|
||||
EVENT_DONE,
|
||||
@@ -67,6 +70,19 @@ _STYLE = StyleDriftReview(
|
||||
StyleDriftSegment(idx=7, score=55),
|
||||
],
|
||||
)
|
||||
_CHARACTERIZATION = CharacterizationReview(
|
||||
issues=[
|
||||
CharacterizationIssue(
|
||||
character="沈砚",
|
||||
aspect="动机一致性",
|
||||
where="第4段",
|
||||
quote="他忽然转身就走。",
|
||||
diagnosis="与既定护妹动机断裂",
|
||||
suggestion="退缩扣回护妹动机",
|
||||
confidence=0.82,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _state(reviews: dict[str, Any] | None = None) -> ChapterState:
|
||||
@@ -96,9 +112,10 @@ def _four_reviews() -> dict[str, Any]:
|
||||
# ---- REVIEW_SPECS 含第四审 ----
|
||||
|
||||
|
||||
def test_review_specs_include_four_audits() -> None:
|
||||
def test_review_specs_include_five_audits() -> None:
|
||||
# 四审 + 灵感③ advisory 人物塑造第五审(characterization 末位追加)。
|
||||
names = [s.name for s in REVIEW_SPECS]
|
||||
assert names == ["continuity", "foreshadow", "pace", "style"]
|
||||
assert names == ["continuity", "foreshadow", "pace", "style", "characterization"]
|
||||
|
||||
|
||||
# ---- collect:style 列映射 ----
|
||||
@@ -139,13 +156,14 @@ async def test_collect_records_style_to_style_column() -> None:
|
||||
# ---- 四审并行图跑通(mock 网关按 schema 路由产四 parsed)----
|
||||
|
||||
|
||||
async def test_four_review_graph_runs_all_audits_end_to_end() -> None:
|
||||
async def test_five_review_graph_runs_all_audits_end_to_end() -> None:
|
||||
gateway = SchemaRoutingRunGateway(
|
||||
{
|
||||
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
|
||||
ForeshadowReview: _FORESHADOW,
|
||||
PaceReview: _PACE,
|
||||
StyleDriftReview: _STYLE,
|
||||
CharacterizationReview: _CHARACTERIZATION,
|
||||
}
|
||||
)
|
||||
repo = FakeReviewRepo()
|
||||
@@ -158,10 +176,12 @@ async def test_four_review_graph_runs_all_audits_end_to_end() -> None:
|
||||
assert final["reviews"][FORESHADOW]["status"] == REVIEW_OK
|
||||
assert final["reviews"][PACE]["status"] == REVIEW_OK
|
||||
assert final["reviews"][STYLE]["status"] == REVIEW_OK
|
||||
assert len(gateway.calls) == 4 # 四审各调一次网关
|
||||
assert final["reviews"][CHARACTERIZATION]["status"] == REVIEW_OK
|
||||
assert len(gateway.calls) == 5 # 五审各调一次网关(含 advisory 人物塑造)
|
||||
rec = repo.records[0]
|
||||
assert rec["conflicts"] and rec["foreshadow_sug"] and rec["pace"]
|
||||
assert rec["style"]["score"] == 72
|
||||
assert rec["characterization"]["issues"][0]["character"] == "沈砚"
|
||||
|
||||
|
||||
async def test_four_review_graph_isolates_failing_style_audit() -> None:
|
||||
|
||||
@@ -12,7 +12,7 @@ JSONB **dict**(`traits`/`arc`/`speech_tics`),`tags`/`relations` 才是 JSO
|
||||
- `speech_tics: list[str]` → `{"items": [...]}`(JSONB dict)
|
||||
- `arc: str` → `{"text": "..."}`(JSONB dict)
|
||||
- `tags: list` / `relations: list[dict]` → 直落 JSONB list
|
||||
- `name` / `role` / `backstory` → Text 列直落
|
||||
- `name` / `role` / `appearance` / `motive` / `backstory` → Text 列直落
|
||||
|
||||
**提交边界**:`create` 只 `flush()` 不 `commit()`——提交交端点事务(入库端点末尾一次
|
||||
`commit()`,与网关 ledger 一并落库;与项目其它写侧 repo 一致,见 memory/gotchas)。
|
||||
@@ -66,6 +66,8 @@ class CharacterWriteRepo(Protocol):
|
||||
name: str,
|
||||
role: str,
|
||||
traits: list[str],
|
||||
appearance: str,
|
||||
motive: str,
|
||||
backstory: str,
|
||||
arc: str,
|
||||
speech_tics: list[str],
|
||||
@@ -91,6 +93,8 @@ class SqlCharacterWriteRepo:
|
||||
name: str,
|
||||
role: str,
|
||||
traits: list[str],
|
||||
appearance: str,
|
||||
motive: str,
|
||||
backstory: str,
|
||||
arc: str,
|
||||
speech_tics: list[str],
|
||||
@@ -105,6 +109,8 @@ class SqlCharacterWriteRepo:
|
||||
if existing is not None:
|
||||
existing.role = role
|
||||
existing.traits = _traits_to_jsonb(traits)
|
||||
existing.appearance = appearance
|
||||
existing.motive = motive
|
||||
existing.backstory = backstory
|
||||
existing.arc = _arc_to_jsonb(arc)
|
||||
existing.speech_tics = _traits_to_jsonb(speech_tics)
|
||||
@@ -118,6 +124,8 @@ class SqlCharacterWriteRepo:
|
||||
name=name,
|
||||
role=role,
|
||||
traits=_traits_to_jsonb(traits),
|
||||
appearance=appearance,
|
||||
motive=motive,
|
||||
backstory=backstory,
|
||||
arc=_arc_to_jsonb(arc),
|
||||
speech_tics=_traits_to_jsonb(speech_tics),
|
||||
@@ -139,6 +147,8 @@ class CharacterWriteFields(BaseModel):
|
||||
name: str
|
||||
role: str
|
||||
traits: list[str] = Field(default_factory=list)
|
||||
appearance: str = ""
|
||||
motive: str = ""
|
||||
backstory: str
|
||||
arc: str
|
||||
speech_tics: list[str] = Field(default_factory=list)
|
||||
|
||||
@@ -29,6 +29,9 @@ class ProjectView:
|
||||
theme: str | None = None
|
||||
selling_points: list[Any] = field(default_factory=list)
|
||||
structure: str | None = None
|
||||
tone: str | None = None
|
||||
ending_type: str | None = None
|
||||
narrative_pov: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
pending_review_count: int = 0
|
||||
|
||||
@@ -44,6 +47,9 @@ class ProjectCreate:
|
||||
theme: str | None = None
|
||||
selling_points: list[Any] = field(default_factory=list)
|
||||
structure: str | None = None
|
||||
tone: str | None = None
|
||||
ending_type: str | None = None
|
||||
narrative_pov: str | None = None
|
||||
|
||||
|
||||
class ProjectRepo(Protocol):
|
||||
@@ -71,6 +77,9 @@ def _to_view(
|
||||
theme=row.theme,
|
||||
selling_points=list(row.selling_points or []),
|
||||
structure=row.structure,
|
||||
tone=row.tone,
|
||||
ending_type=row.ending_type,
|
||||
narrative_pov=row.narrative_pov,
|
||||
updated_at=updated_at or row.updated_at,
|
||||
pending_review_count=pending_review_count,
|
||||
)
|
||||
@@ -92,6 +101,9 @@ class SqlProjectRepo:
|
||||
theme=data.theme,
|
||||
selling_points=list(data.selling_points),
|
||||
structure=data.structure,
|
||||
tone=data.tone,
|
||||
ending_type=data.ending_type,
|
||||
narrative_pov=data.narrative_pov,
|
||||
)
|
||||
self._s.add(row)
|
||||
await self._s.commit()
|
||||
|
||||
@@ -15,6 +15,8 @@ from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ww_core.domain.review_repo import ReviewRepo
|
||||
|
||||
# ---- 只读视图(snake_case,frozen 防止意外突变)----
|
||||
|
||||
|
||||
@@ -99,6 +101,12 @@ class ProjectSpecView(BaseModel):
|
||||
logline: str | None = None
|
||||
premise: 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 过滤)----
|
||||
@@ -142,7 +150,11 @@ class ProjectSpecRepo(Protocol):
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryRepos:
|
||||
"""记忆服务所需的 8 个 repo 的依赖捆绑(注入点)。"""
|
||||
"""记忆服务所需的 9 个 repo 的依赖捆绑(注入点)。
|
||||
|
||||
`review` 末位默认 None:只有冲突裁决反哺(灵感②)需要它;不注入时 assemble
|
||||
优雅降级(不反哺),故既有构造点无需感知(守风险#6)。
|
||||
"""
|
||||
|
||||
outline: OutlineRepo
|
||||
character: CharacterRepo
|
||||
@@ -152,3 +164,4 @@ class MemoryRepos:
|
||||
style: StyleRepo
|
||||
rules: RulesRepo
|
||||
project: ProjectSpecRepo
|
||||
review: ReviewRepo | None = None
|
||||
|
||||
@@ -33,6 +33,8 @@ class ReviewView(BaseModel):
|
||||
foreshadow_sug: list[dict[str, Any]] = Field(default_factory=list)
|
||||
style: dict[str, Any] | None = None
|
||||
pace: dict[str, Any] | None = None
|
||||
# 灵感③ advisory 人物塑造审留痕(末位默认 None,优雅降级;不进 conflicts/验收 gate)。
|
||||
characterization: dict[str, Any] | None = None
|
||||
health_score: int | None = None
|
||||
decisions: dict[str, Any] | None = None
|
||||
|
||||
@@ -50,6 +52,7 @@ class ReviewRepo(Protocol):
|
||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||
style: dict[str, Any] | None = None,
|
||||
pace: dict[str, Any] | None = None,
|
||||
characterization: dict[str, Any] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> ReviewView:
|
||||
"""append 一行审稿报告(T2.2 collect 写入)。"""
|
||||
@@ -74,6 +77,7 @@ def _to_view(row: ChapterReview) -> ReviewView:
|
||||
foreshadow_sug=list(row.foreshadow_sug or []),
|
||||
style=row.style,
|
||||
pace=row.pace,
|
||||
characterization=row.characterization,
|
||||
health_score=row.health_score,
|
||||
decisions=row.decisions,
|
||||
)
|
||||
@@ -95,6 +99,7 @@ class SqlReviewRepo:
|
||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||
style: dict[str, Any] | None = None,
|
||||
pace: dict[str, Any] | None = None,
|
||||
characterization: dict[str, Any] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> ReviewView:
|
||||
row = ChapterReview(
|
||||
@@ -105,6 +110,7 @@ class SqlReviewRepo:
|
||||
foreshadow_sug=list(foreshadow_sug or []),
|
||||
style=style,
|
||||
pace=pace,
|
||||
characterization=characterization,
|
||||
health_score=health_score,
|
||||
decisions=None,
|
||||
)
|
||||
|
||||
@@ -11,6 +11,8 @@ import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from ww_config import genre_craft_fragment
|
||||
|
||||
from ww_core.domain.injection_repo import InjectionOverride
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
@@ -23,6 +25,7 @@ from ww_core.domain.repositories import (
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
)
|
||||
from ww_core.domain.review_repo import ReviewView
|
||||
|
||||
from .render import render_cards
|
||||
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(越具体越靠后/越优先)
|
||||
_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:
|
||||
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}")
|
||||
if 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))
|
||||
|
||||
|
||||
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(
|
||||
spec: ProjectSpecView | None,
|
||||
world_entities: list[WorldEntityView],
|
||||
@@ -89,6 +119,8 @@ def _build_stable(
|
||||
rule_lines = [f"[{r.level}] {r.content}" for r in merge_rules(rules)]
|
||||
|
||||
sections = [
|
||||
# 题材写法领衔——同题材作品共享此前缀,利于跨书隐式前缀缓存(不变量 #9)。
|
||||
_build_genre_craft_section(spec.genre if spec else None),
|
||||
_build_spec_section(spec),
|
||||
_section("世界观硬规则", "\n".join(world_lines)),
|
||||
_section("定型主角", "\n".join(char_lines)),
|
||||
@@ -98,6 +130,72 @@ def _build_stable(
|
||||
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/#7:assemble 可缓存的纯函数)。
|
||||
- 软批评类(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(
|
||||
chapter_no: int,
|
||||
cards: str,
|
||||
@@ -105,6 +203,7 @@ def _build_volatile(
|
||||
digests: list[DigestView],
|
||||
beats: dict[str, Any],
|
||||
directive: str | None = None,
|
||||
prior_conflict_notes: str | None = None,
|
||||
) -> str:
|
||||
fore_lines = [
|
||||
f"【伏笔】{f.code} {f.title} [{f.status}]"
|
||||
@@ -118,6 +217,10 @@ def _build_volatile(
|
||||
_section("本章指令", directive.strip()) if directive and directive.strip() else None,
|
||||
# 写章指令始终在场——保证 volatile(断点后的 input)永不为空(修空 prompt 400)。
|
||||
_section("写作指令", f"请创作第 {chapter_no} 章的正文。"),
|
||||
# 开篇黄金三章标记(仅前 N 章)——每章易变,只入 volatile。
|
||||
_opening_marker(chapter_no),
|
||||
# 上一章冲突裁决反哺(灵感②)——依赖上一章裁决,每章易变,绝不进缓存前缀(#9)。
|
||||
_section("上一章冲突提醒", prior_conflict_notes) if prior_conflict_notes else None,
|
||||
_section("本章注入卡片", cards),
|
||||
_section("相关伏笔窗口", "\n".join(fore_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]
|
||||
|
||||
# 冲突裁决反哺(灵感②/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)
|
||||
cards = render_cards(selection, characters, world_entities)
|
||||
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)
|
||||
|
||||
@@ -32,6 +32,7 @@ from ww_core.domain.repositories import (
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
)
|
||||
from ww_core.domain.review_repo import SqlReviewRepo
|
||||
|
||||
|
||||
class SqlOutlineRepo:
|
||||
@@ -215,11 +216,18 @@ class SqlProjectSpecRepo:
|
||||
logline=row.logline,
|
||||
premise=row.premise,
|
||||
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:
|
||||
"""用一个 AsyncSession 装配全部 8 个 SQLAlchemy repo(T1.4 注入点)。"""
|
||||
"""用一个 AsyncSession 装配全部 9 个 SQLAlchemy repo(T1.4 注入点)。
|
||||
|
||||
`review=SqlReviewRepo`:供 assemble 反读上一已验收章的 continuity 冲突裁决(灵感②)。
|
||||
"""
|
||||
return MemoryRepos(
|
||||
outline=SqlOutlineRepo(session),
|
||||
character=SqlCharacterRepo(session),
|
||||
@@ -229,4 +237,5 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
|
||||
style=SqlStyleRepo(session),
|
||||
rules=SqlRulesRepo(session),
|
||||
project=SqlProjectSpecRepo(session),
|
||||
review=SqlReviewRepo(session),
|
||||
)
|
||||
|
||||
@@ -10,12 +10,14 @@ from __future__ import annotations
|
||||
|
||||
from ._protocols import GatewayRun
|
||||
from .collect import (
|
||||
CHARACTERIZATION,
|
||||
CONTINUITY,
|
||||
FORESHADOW,
|
||||
PACE,
|
||||
STYLE,
|
||||
ReviewRecorder,
|
||||
collect_reviews,
|
||||
extract_characterization,
|
||||
extract_conflicts,
|
||||
extract_foreshadow_sug,
|
||||
extract_pace,
|
||||
@@ -52,6 +54,7 @@ from .review_node import (
|
||||
run_review,
|
||||
)
|
||||
from .sse import (
|
||||
EVENT_CHARACTERIZATION,
|
||||
EVENT_CONFLICT,
|
||||
EVENT_DONE,
|
||||
EVENT_ERROR,
|
||||
@@ -64,6 +67,7 @@ from .sse import (
|
||||
SECTION_INCOMPLETE,
|
||||
SECTION_STARTED,
|
||||
SseEvent,
|
||||
characterization_event,
|
||||
conflict_event,
|
||||
done_event,
|
||||
error_event,
|
||||
@@ -80,8 +84,10 @@ from .style_extract_node import build_style_extract_request, run_style_extractio
|
||||
from .write_node import GatewayStream, build_write_request, stream_chapter_draft, write_node
|
||||
|
||||
__all__ = [
|
||||
"CHARACTERIZATION",
|
||||
"COLLECT_NODE",
|
||||
"CONTINUITY",
|
||||
"EVENT_CHARACTERIZATION",
|
||||
"EVENT_CONFLICT",
|
||||
"EVENT_DONE",
|
||||
"EVENT_ERROR",
|
||||
@@ -119,10 +125,12 @@ __all__ = [
|
||||
"build_text_input_context",
|
||||
"build_worldbuilder_context",
|
||||
"build_write_request",
|
||||
"characterization_event",
|
||||
"collect_reviews",
|
||||
"conflict_event",
|
||||
"done_event",
|
||||
"error_event",
|
||||
"extract_characterization",
|
||||
"extract_conflicts",
|
||||
"extract_foreshadow_sug",
|
||||
"extract_pace",
|
||||
|
||||
@@ -93,6 +93,7 @@ class ReviewRecordRepo(Protocol):
|
||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||
style: dict[str, Any] | None = None,
|
||||
pace: dict[str, Any] | None = None,
|
||||
characterization: dict[str, Any] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> Any: ...
|
||||
|
||||
|
||||
@@ -11,11 +11,12 @@ AI 产出真正入库经验收事务的裁决)。
|
||||
collect 与 review_repo 同样**只 flush 不 commit**——`commit` 归 HTTP 端点 / 验收事务。
|
||||
本节点绝不 commit。
|
||||
|
||||
M4 四审齐 → 列映射(一次 record 落齐本章四审):
|
||||
M4 四审齐 → 列映射(一次 record 落齐本章四审)+ 灵感③ advisory 人物塑造第五审:
|
||||
- continuity → `conflicts`(冲突清单 list);
|
||||
- foreshadow → `foreshadow_sug`(planted/resolved 扁平为建议 list,每条带 `kind` 标记);
|
||||
- pace → `pace`(节奏诊断 dict:water/hook/beat_map);
|
||||
- style → `style`(文风漂移诊断 dict:score/segments)。
|
||||
- style → `style`(文风漂移诊断 dict:score/segments);
|
||||
- characterization→ `characterization`(人物塑造诊断 dict:issues;advisory,不进验收 gate)。
|
||||
任一审 `incomplete`(网关失败隔离,§5.2)→ 该列留空/None,不阻塞其余。
|
||||
"""
|
||||
|
||||
@@ -35,6 +36,7 @@ CONTINUITY = "continuity"
|
||||
FORESHADOW = "foreshadow"
|
||||
PACE = "pace"
|
||||
STYLE = "style"
|
||||
CHARACTERIZATION = "characterization" # 灵感③ advisory 人物塑造审(仅建议、不阻断验收)
|
||||
|
||||
# foreshadow 建议扁平化时的来源标记(planted=新埋 / resolved=回收)。
|
||||
FORESHADOW_KIND_PLANTED = "planted"
|
||||
@@ -57,6 +59,7 @@ class ReviewRecorder(Protocol):
|
||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||
style: dict[str, Any] | None = None,
|
||||
pace: dict[str, Any] | None = None,
|
||||
characterization: dict[str, Any] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> Any: ...
|
||||
|
||||
@@ -123,6 +126,19 @@ def extract_style(reviews: dict[str, Any]) -> dict[str, Any] | None:
|
||||
return dict(result)
|
||||
|
||||
|
||||
def extract_characterization(reviews: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""从 characterization(灵感③ advisory 审)分项取人物塑造诊断 dict(纯函数)。
|
||||
|
||||
`CharacterizationReview{issues}` 整体入 `characterization` 列(JSONB dict)。
|
||||
未完成/缺席 → None(列留空,§5.2 不阻塞其余)。空 issues 降级仍是 `ok` 结果、照常落库。
|
||||
advisory:只留痕、不进 conflicts、不碰验收 gate(灵感 D2)。
|
||||
"""
|
||||
result = _ok_result(reviews, CHARACTERIZATION)
|
||||
if result is None:
|
||||
return None
|
||||
return dict(result)
|
||||
|
||||
|
||||
async def collect_reviews(
|
||||
state: ChapterState,
|
||||
*,
|
||||
@@ -140,6 +156,7 @@ async def collect_reviews(
|
||||
foreshadow_sug = extract_foreshadow_sug(reviews)
|
||||
pace = extract_pace(reviews)
|
||||
style = extract_style(reviews)
|
||||
characterization = extract_characterization(reviews)
|
||||
await review_repo.record(
|
||||
state["project_id"],
|
||||
state["chapter_no"],
|
||||
@@ -148,6 +165,7 @@ async def collect_reviews(
|
||||
foreshadow_sug=foreshadow_sug,
|
||||
style=style,
|
||||
pace=pace,
|
||||
characterization=characterization,
|
||||
)
|
||||
log.info(
|
||||
"collect_reviews_recorded",
|
||||
@@ -157,6 +175,7 @@ async def collect_reviews(
|
||||
foreshadow_sug_count=len(foreshadow_sug),
|
||||
has_pace=pace is not None,
|
||||
has_style=style is not None,
|
||||
has_characterization=characterization is not None,
|
||||
reviews=sorted(reviews.keys()),
|
||||
)
|
||||
# 留痕已在表(真相源);不在节点 commit(端点/事务层负责,见模块 docstring)。
|
||||
|
||||
@@ -24,7 +24,7 @@ worldbuilder 与 character-gen 都是**独立生成**——不在写章/审稿
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
import structlog
|
||||
from pydantic import BaseModel
|
||||
@@ -48,13 +48,14 @@ def _build_request(
|
||||
*,
|
||||
context: str,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
project_id: uuid.UUID | None,
|
||||
) -> LlmRequest:
|
||||
"""据 spec + 注入材料构造生成请求(纯函数,worldbuilder/character-gen 共用)。
|
||||
|
||||
`spec.system_prompt` → `system` 缓存断点前块(cache=True,不变量 #9);
|
||||
`context`(序列化的需求/约束/已有/已生成文本)→ `input`(断点后)。
|
||||
生成用 `run()` 非 `stream()`(要结构化产物,非流式正文)。
|
||||
`project_id` 可空——立项方案(⑤)在向导阶段跑,project 尚未建(usage_ledger.project_id 可空)。
|
||||
"""
|
||||
return LlmRequest(
|
||||
tier=spec.tier, # 不变量 #2:只传档位,不传 model
|
||||
@@ -138,12 +139,13 @@ async def run_generator(
|
||||
context: str,
|
||||
gateway: GatewayRun,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
project_id: uuid.UUID | None = None,
|
||||
) -> BaseModel:
|
||||
"""通用生成器执行器(T6):构请求 → `gateway.run` → 按 `spec.output_schema` 校验 parsed。
|
||||
|
||||
任一声明式生成器共用此路径(不绑定具体产物类型,据 spec 的 output_schema 校验)。
|
||||
**只读、不写库**(纯预览,不变量 #3);独立生成——网关失败直接上抛(端点处理),不静默吞。
|
||||
`project_id` 可空——立项方案(⑤)在向导阶段跑,project 尚未建(usage_ledger.project_id 可空)。
|
||||
"""
|
||||
expected = spec.output_schema
|
||||
if expected is None:
|
||||
@@ -213,6 +215,11 @@ def _render_cards_for_context(cards: Sequence[CharacterCard]) -> str:
|
||||
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(
|
||||
*,
|
||||
brief: str,
|
||||
@@ -221,12 +228,20 @@ def build_character_gen_context(
|
||||
world_context: str,
|
||||
existing_chars: Sequence[CharacterCard],
|
||||
generated_so_far: Sequence[CharacterCard],
|
||||
role_mix: Mapping[str, int] | None = None,
|
||||
) -> str:
|
||||
"""拼装 character-gen 注入文本(含群像防雷同上下文,M5-a)。
|
||||
"""拼装 character-gen 注入文本(含群像防雷同上下文,M5-a;⑦ 群像按定位配比)。
|
||||
|
||||
注入「已有角色」+「本批已生成卡」,要求新卡与二者差异化(避免一群人一个模子)。
|
||||
`role_mix` 在场时改注入定位配比块(各定位产出对应数量、合计等于数量),覆盖单一 `role`。
|
||||
确定性拼接、无时间戳——同输入同输出,便于单测与缓存。
|
||||
"""
|
||||
if role_mix:
|
||||
role_line = (
|
||||
"角色定位配比(严格按此分配,各定位产出对应数量,合计等于数量):\n"
|
||||
f"{_render_role_mix(role_mix)}"
|
||||
)
|
||||
else:
|
||||
role_line = f"角色定位:{role}" if role else "角色定位:未指定(由你按需求判定)"
|
||||
existing_block = (
|
||||
_render_cards_for_context(existing_chars) if existing_chars else "(暂无已有角色)"
|
||||
@@ -260,10 +275,12 @@ async def run_character_gen(
|
||||
gateway: GatewayRun,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
role_mix: Mapping[str, int] | None = None,
|
||||
) -> CharacterGenResult:
|
||||
"""跑角色/群像生成:构防雷同上下文 → `gateway.run` → 返回 `CharacterGenResult`。
|
||||
|
||||
群像防雷同(M5-a):注入「已有角色 + 本批已生成卡」要求差异化(§4.5 / §6.5)。
|
||||
`role_mix`(⑦)在场时按定位配比分配(各定位对应数量、合计等于数量)。
|
||||
独立生成(非并行审):网关失败直接上抛(端点/T5.2 处理),不静默吞。
|
||||
**只产结构化角色卡、不写库**——入库前过 `precheck_generated_cards` 校验、再经
|
||||
入库端点持久化(持久化属 T5.2,不变量 #3)。
|
||||
@@ -275,6 +292,7 @@ async def run_character_gen(
|
||||
world_context=world_context,
|
||||
existing_chars=existing_chars,
|
||||
generated_so_far=generated_so_far,
|
||||
role_mix=role_mix,
|
||||
)
|
||||
req = _build_request(spec, context=context, user_id=user_id, project_id=project_id)
|
||||
resp = await gateway.run(req)
|
||||
|
||||
@@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from ww_agents import (
|
||||
AgentSpec,
|
||||
characterization_spec,
|
||||
continuity_spec,
|
||||
foreshadow_spec,
|
||||
pace_spec,
|
||||
@@ -38,12 +39,15 @@ WRITE_NODE = "write"
|
||||
COLLECT_NODE = "collect"
|
||||
|
||||
|
||||
# M4:四审齐(continuity + foreshadow + pace + style)。第四审 = 文风漂移打分轨。
|
||||
# M4 四审齐(continuity + foreshadow + pace + style)+ 灵感③ advisory 人物塑造第五审。
|
||||
# characterization 是 advisory(仅建议、不阻断验收):并行扇出自动汇入 collect,
|
||||
# 不进 conflicts、不碰验收 gate、不入 REVIEW_RESERVED_NAMES(灵感 D2)。
|
||||
REVIEW_SPECS: tuple[AgentSpec, ...] = (
|
||||
continuity_spec,
|
||||
foreshadow_spec,
|
||||
pace_spec,
|
||||
style_drift_spec,
|
||||
characterization_spec,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from pydantic import BaseModel
|
||||
from ww_llm_gateway.types import Delta
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from .collect import CONTINUITY, FORESHADOW, PACE, STYLE
|
||||
from .collect import CHARACTERIZATION, CONTINUITY, FORESHADOW, PACE, STYLE
|
||||
from .review_node import REVIEW_OK
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
@@ -30,6 +30,7 @@ EVENT_CONFLICT = "conflict" # continuity 冲突命中(审稿 review)
|
||||
EVENT_FORESHADOW = "foreshadow" # foreshadow 建议命中(新埋/回收,审稿 review)
|
||||
EVENT_PACE = "pace" # pace 节奏诊断(注水/钩子/节拍图,审稿 review)
|
||||
EVENT_STYLE = "style" # style 文风漂移诊断(整体相似度 + 漂移段,审稿 review)
|
||||
EVENT_CHARACTERIZATION = "characterization" # 人物塑造诊断(advisory issues,审稿 review)
|
||||
EVENT_DONE = "done"
|
||||
EVENT_ERROR = "error"
|
||||
|
||||
@@ -129,6 +130,18 @@ def style_event(*, score: int, segments: list[dict[str, object]]) -> SseEvent:
|
||||
)
|
||||
|
||||
|
||||
def characterization_event(*, issues: list[dict[str, object]]) -> SseEvent:
|
||||
"""人物塑造诊断事件(advisory,审稿 review)——前端人物塑造面板只展示、无裁决 UI。
|
||||
|
||||
一次审产一条:人物塑造问题清单(每条 character/aspect/where/quote/diagnosis/suggestion/
|
||||
confidence)。仅建议、不阻断验收(灵感 D2)。
|
||||
"""
|
||||
return SseEvent(
|
||||
event=EVENT_CHARACTERIZATION,
|
||||
data={"issues": issues},
|
||||
)
|
||||
|
||||
|
||||
def error_event(*, code: str, message: str, request_id: str | None = None) -> SseEvent:
|
||||
"""错误事件,形对齐错误信封 §7.1(`code`/`message`),附 `request_id` 便于贯通排查。"""
|
||||
return SseEvent(
|
||||
@@ -231,6 +244,23 @@ def _section_result_events(name: str, result: dict[str, Any]) -> list[SseEvent]:
|
||||
],
|
||||
)
|
||||
)
|
||||
elif name == CHARACTERIZATION:
|
||||
events.append(
|
||||
characterization_event(
|
||||
issues=[
|
||||
{
|
||||
"character": issue.get("character", ""),
|
||||
"aspect": issue.get("aspect", ""),
|
||||
"where": issue.get("where", ""),
|
||||
"quote": issue.get("quote", ""),
|
||||
"diagnosis": issue.get("diagnosis", ""),
|
||||
"suggestion": issue.get("suggestion", ""),
|
||||
"confidence": float(issue.get("confidence", 0.0)),
|
||||
}
|
||||
for issue in result.get("issues") or []
|
||||
]
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
|
||||
@@ -17,10 +17,15 @@ import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Protocol
|
||||
|
||||
from ww_agents import load_craft_doctrine
|
||||
from ww_llm_gateway.types import Block, Delta, LlmRequest, Scope
|
||||
|
||||
from .state import ChapterState
|
||||
|
||||
# 写章 craft 教条(灵感①):品类无关内核,import 期读盘 → 缺失即 fail-fast(崩 import)。
|
||||
# 字节稳定的稳定内容,作为最前的 cache=True system 块(跨书前缀复用,不变量 #9)。
|
||||
_CRAFT_DOCTRINE = load_craft_doctrine()
|
||||
|
||||
|
||||
class GatewayStream(Protocol):
|
||||
"""write 节点对网关的最小依赖——只需 `stream`(注入真网关或 mock)。"""
|
||||
@@ -37,11 +42,15 @@ def build_write_request(
|
||||
) -> LlmRequest:
|
||||
"""据组装上下文构造写章请求(纯函数,决策 2026-06-18)。
|
||||
|
||||
`stable_core` → `system` 缓存断点前块(cache=True);`volatile` → `input`(断点后)。
|
||||
`system` = [craft 教条(cache), stable_core(cache)]——craft 品类无关内核在最前,
|
||||
最大化跨书前缀复用;两块均在缓存断点前(cache=True)。`volatile` → `input`(断点后)。
|
||||
"""
|
||||
return LlmRequest(
|
||||
tier="writer",
|
||||
system=[Block(text=stable_core, cache=True)],
|
||||
system=[
|
||||
Block(text=_CRAFT_DOCTRINE, cache=True),
|
||||
Block(text=stable_core, cache=True),
|
||||
],
|
||||
input=volatile,
|
||||
stream=True,
|
||||
scope=Scope(user_id=user_id, project_id=project_id),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Add tone/ending_type/narrative_pov to projects (灵感④ 立项书级常量).
|
||||
|
||||
三列均 Text nullable(与既有 genre/structure 一致,无 CHECK;枚举约束留前端预设),
|
||||
nullable 免 backfill(旧项目留 NULL,assemble/前端优雅降级)。
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Add characterization JSONB column to chapter_reviews (灵感③ advisory 人物塑造审).
|
||||
|
||||
单列 JSONB nullable(advisory 留痕,独立于恒 None 的 health_score);nullable 免 backfill
|
||||
(旧留痕行留 NULL,collect/前端优雅降级)。仅建议、不进 conflicts、不碰验收 gate(灵感 D2)。
|
||||
|
||||
Revision ID: f6a7b8c9d0e1
|
||||
Revises: e5f6a7b8c9d0
|
||||
Create Date: 2026-07-06 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "f6a7b8c9d0e1"
|
||||
down_revision: str | None = "e5f6a7b8c9d0"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"chapter_reviews",
|
||||
sa.Column("characterization", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("chapter_reviews", "characterization")
|
||||
@@ -48,6 +48,10 @@ class Project(UuidPk, TimestampedMixin, Base):
|
||||
theme: Mapped[str | None] = mapped_column(Text)
|
||||
selling_points: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
|
||||
structure: Mapped[str | None] = mapped_column(Text)
|
||||
# 立项书级常量(灵感④)——同 genre/structure:Text 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):
|
||||
@@ -221,6 +225,8 @@ class ChapterReview(UuidPk, CreatedAt, Base):
|
||||
foreshadow_sug: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
|
||||
style: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
||||
pace: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
||||
# 灵感③ advisory 人物塑造审留痕(JSONB nullable,独立列不复用恒 None 的 health_score)。
|
||||
characterization: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
||||
health_score: Mapped[int | None] = mapped_column(Integer)
|
||||
decisions: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
||||
|
||||
|
||||
@@ -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] = {
|
||||
# ---- legacy(spec=None,前端走 legacy_route 跳现有页面)----
|
||||
"worldbuilding": GeneratorTool(
|
||||
|
||||
59
tests/craft_eval/README.md
Normal file
59
tests/craft_eval/README.md
Normal 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 里仍含片段 → 断言失败)。
|
||||
38
tests/craft_eval/__init__.py
Normal file
38
tests/craft_eval/__init__.py
Normal 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",
|
||||
]
|
||||
104
tests/craft_eval/__main__.py
Normal file
104
tests/craft_eval/__main__.py
Normal 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()
|
||||
158
tests/craft_eval/fixtures.py
Normal file
158
tests/craft_eval/fixtures.py
Normal 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
363
tests/craft_eval/harness.py
Normal 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
|
||||
|
||||
# 单用户原型 stub(dry 路径不落库,取值无关紧要,仅为满足 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("input(volatile)", input_text(on)),
|
||||
"#### craft-off(基线 prompt)",
|
||||
_fence("system(缓存前缀)", system_text(off)),
|
||||
_fence("input(volatile)", 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
127
tests/craft_eval/live.py
Normal 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-on,B=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 owner(usage_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
121
tests/test_craft_eval.py
Normal 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)
|
||||
@@ -101,6 +101,8 @@ _CHARACTER_CARDS = CharacterGenResult(
|
||||
name="叶无尘",
|
||||
role="主角",
|
||||
traits=["隐忍", "腹黑"],
|
||||
appearance="剑眉星目,玄袍加身,眉心一点朱砂",
|
||||
motive="核心动机:查明灭门真相并复仇;深处渴望守护余下之人",
|
||||
backstory="灭门遗孤,背负血仇。",
|
||||
arc="从复仇者走向守护者。",
|
||||
speech_tics=["呵,有意思", "不过如此"],
|
||||
@@ -393,6 +395,9 @@ async def test_m5_generate_characters_gate_acknowledge_and_persist(
|
||||
assert ye.relations == []
|
||||
assert ye.role == "主角"
|
||||
assert ye.backstory == "灭门遗孤,背负血仇。"
|
||||
# ⑧ 穿线:appearance/motive Text 列真落 pg(此前一直 NULL)。
|
||||
assert ye.appearance == "剑眉星目,玄袍加身,眉心一点朱砂"
|
||||
assert ye.motive == "核心动机:查明灭门真相并复仇;深处渴望守护余下之人"
|
||||
|
||||
# 记账真落 pg:world generate(writer) + characters generate(writer) +
|
||||
# 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)
|
||||
|
||||
|
||||
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:
|
||||
# Arrange:seed 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(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user