diff --git a/PROGRESS.md b/PROGRESS.md index 8fb1ed2..b6b7920 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -161,6 +161,8 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` +- [2026-07-06] @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。依赖 ④(已交付)。 diff --git a/apps/api/tests/test_generation_schema.py b/apps/api/tests/test_generation_schema.py new file mode 100644 index 0000000..7abb711 --- /dev/null +++ b/apps/api/tests/test_generation_schema.py @@ -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}) diff --git a/apps/api/ww_api/routers/generation.py b/apps/api/ww_api/routers/generation.py index bdb5175..0103ff2 100644 --- a/apps/api/ww_api/routers/generation.py +++ b/apps/api/ww_api/routers/generation.py @@ -293,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( diff --git a/apps/api/ww_api/schemas/generation.py b/apps/api/ww_api/schemas/generation.py index 9f8aca4..d527165 100644 --- a/apps/api/ww_api/schemas/generation.py +++ b/apps/api/ww_api/schemas/generation.py @@ -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): diff --git a/apps/web/components/generation/CharacterGenerator.tsx b/apps/web/components/generation/CharacterGenerator.tsx index ae978d5..fea3da3 100644 --- a/apps/web/components/generation/CharacterGenerator.tsx +++ b/apps/web/components/generation/CharacterGenerator.tsx @@ -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("single"); + const [mixRows, setMixRows] = useState(DEFAULT_MIX_ROWS); + // 配比模式下的有效合计(sanitize 后,已按 MAX 截断)——门控生成按钮。 + const mixTotal = roleMixTotal(sanitizeRoleMix(mixRows)); // 编辑态卡片(预览返回后拷贝进本地,允许就地改)。 const [drafts, setDrafts] = useState([]); const [selected, setSelected] = useState>(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,43 +125,64 @@ export function CharacterGenerator({ placeholder="如:一个亦正亦邪的剑修,背负灭门血仇" /> -
- - - setCount(clampCharacterCount(Number(e.target.value))) - } - /> - - - setRole(e.target.value)} - placeholder="主角 / CP / 对手 / 导师 / 工具人" - /> - - +
+
+ + {mode === "mix" ? ( + + + + ) : ( +
+ + + setCount(clampCharacterCount(Number(e.target.value))) + } + /> + + + setRole(e.target.value)} + placeholder="主角 / CP / 对手 / 导师 / 工具人" + /> + +
+ )} + +
{generating ? ( diff --git a/apps/web/components/generation/RoleMixEditor.tsx b/apps/web/components/generation/RoleMixEditor.tsx new file mode 100644 index 0000000..3500957 --- /dev/null +++ b/apps/web/components/generation/RoleMixEditor.tsx @@ -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): 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 ( +
+ {rows.map((row, i) => ( +
+ updateRow(i, { role: e.target.value })} + placeholder="定位:主角 / 对手 / 配角" + aria-label={`第 ${i + 1} 行定位`} + className="min-w-[10rem] flex-1" + /> + + updateRow(i, { count: Math.round(Number(e.target.value)) }) + } + aria-label={`第 ${i + 1} 行数量`} + className="w-20" + /> + +
+ ))} +
+ + + 合计 {effectiveTotal} / {MAX_CHARACTER_COUNT} 张 + +
+
+ ); +} diff --git a/apps/web/lib/api/schema.d.ts b/apps/web/lib/api/schema.d.ts index 1c876fe..c3bb42f 100644 --- a/apps/web/lib/api/schema.d.ts +++ b/apps/web/lib/api/schema.d.ts @@ -997,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: { /** @@ -1006,7 +1010,7 @@ export interface components { brief: string; /** * Count - * @description 生成数量 + * @description 生成数量(role_mix 在场时取各值之和) * @default 1 */ count: number; @@ -1015,6 +1019,13 @@ export interface components { * @description 角色定位(主角/CP/对手/导师/工具人 等) */ role?: string | null; + /** + * Role Mix + * @description 按定位配比生成 {定位: 数量};在场时 count=各值之和(每项≥1、总和≤12) + */ + role_mix?: { + [key: string]: number; + } | null; }; /** * CharacterIngestRequest diff --git a/apps/web/lib/generation/cards.test.ts b/apps/web/lib/generation/cards.test.ts index db30869..f59cc8f 100644 --- a/apps/web/lib/generation/cards.test.ts +++ b/apps/web/lib/generation/cards.test.ts @@ -16,6 +16,8 @@ import { mergeCharacterCards, mergeWorldEntities, MIN_CHARACTER_COUNT, + roleMixTotal, + sanitizeRoleMix, updateCard, worldEntityRules, } from "./cards"; @@ -62,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", () => { diff --git a/apps/web/lib/generation/cards.ts b/apps/web/lib/generation/cards.ts index cec3b2e..f17c962 100644 Binary files a/apps/web/lib/generation/cards.ts and b/apps/web/lib/generation/cards.ts differ diff --git a/apps/web/lib/generation/useCharacterGen.test.ts b/apps/web/lib/generation/useCharacterGen.test.ts index 5eacc34..ec0c84e 100644 --- a/apps/web/lib/generation/useCharacterGen.test.ts +++ b/apps/web/lib/generation/useCharacterGen.test.ts @@ -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()); diff --git a/apps/web/lib/generation/useCharacterGen.ts b/apps/web/lib/generation/useCharacterGen.ts index 12f065f..54e02b9 100644 --- a/apps/web/lib/generation/useCharacterGen.ts +++ b/apps/web/lib/generation/useCharacterGen.ts @@ -21,6 +21,8 @@ export interface CharacterGenInput { brief: string; count: number; role?: string | null; + // 群像按定位配比(⑦);在场时后端把 count 定为各值之和,忽略 count/role。 + roleMix?: Readonly> | null; } export interface UseCharacterGen { @@ -50,7 +52,7 @@ export function useCharacterGen(): UseCharacterGen { const toast = useToast(); const generate = useCallback( - 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) { diff --git a/memory/contracts.md b/memory/contracts.md index 5e231eb..931de89 100644 --- a/memory/contracts.md +++ b/memory/contracts.md @@ -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 升序)。 @@ -354,6 +354,8 @@ - [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/.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(检查点迁移)。 diff --git a/packages/agents/tests/fixtures/prompt_hashes.json b/packages/agents/tests/fixtures/prompt_hashes.json index e7dba0e..f15a2c5 100644 --- a/packages/agents/tests/fixtures/prompt_hashes.json +++ b/packages/agents/tests/fixtures/prompt_hashes.json @@ -2,7 +2,7 @@ "blurb": "2adba04a16707d7d9dfbb84d0a5a302ea8e946104dd8a617ff7801ed86f41605", "book-title": "aa53ccf22da1d4e555c8168fc23f2f3547cc6f59e56e5df57540b7a946755e39", "brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada", - "character-gen": "f65e2ec3158be378a6cd40c23035b326dc9f6b297b6135007a93fceedf575cfd", + "character-gen": "e0f97a5d7bb622fdd7582cdeef85ba13b3f434b2633f4d22a8c6daeba48636bf", "characterization": "643988cb899a66472acb3e18709f3299d2025f9bda132c774ee26c589266b51f", "continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763", "continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1", diff --git a/packages/agents/ww_agents/prompts/character-gen.md b/packages/agents/ww_agents/prompts/character-gen.md index 942da1e..4e5ca25 100644 --- a/packages/agents/ww_agents/prompts/character-gen.md +++ b/packages/agents/ww_agents/prompts/character-gen.md @@ -9,7 +9,7 @@ ## 输入材料 -- **角色需求**(一句话,如「亦正亦邪的女二、与主角有宿命纠葛、出身敌对势力」)+ 数量(count)+ 定位; +- **角色需求**(一句话,如「亦正亦邪的女二、与主角有宿命纠葛、出身敌对势力」)+ 数量(count)+ 定位;若给出**角色定位配比**(各定位对应数量的清单),按配比给每个定位产出对应数量的卡、合计等于总数量; - **世界观约束**(world_entities:力量体系、势力、地理硬规则——取名 / 能力须契合); - **已有角色**(characters:现有角色卡,新角色须与其区分、可与其建关系); - **本批已生成卡**(同一次批量里先产出的卡——后续卡须与它们差异化,避免雷同)。 @@ -55,7 +55,7 @@ ## 纪律 - 你只产结构化角色卡,**不改稿、不写库、不直接互调其他 agent**——入库前由编排器追加一道 continuity 校验确认不与世界观 / 力量体系冲突(§6.5); -- count 是几就产几张,不多不少;定位若给出则按定位分配; +- count 是几就产几张,不多不少;给出**角色定位配比**时严格按配比分配——每个定位产出清单标注的数量、各定位之和等于总数量;只给单一定位则整批按该定位分配;两者皆无则按需求自行判定; - 不臆造与需求 / 世界观无关的设定,宁可留白也不硬填。 ## 示例(单卡,仅示范字段填法与质感,勿照抄设定) diff --git a/packages/core/tests/test_generation_node.py b/packages/core/tests/test_generation_node.py index 8c7cfab..e300a8e 100644 --- a/packages/core/tests/test_generation_node.py +++ b/packages/core/tests/test_generation_node.py @@ -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")) diff --git a/packages/core/ww_core/orchestrator/generation_node.py b/packages/core/ww_core/orchestrator/generation_node.py index 94e5225..4981900 100644 --- a/packages/core/ww_core/orchestrator/generation_node.py +++ b/packages/core/ww_core/orchestrator/generation_node.py @@ -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 @@ -215,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, @@ -223,13 +228,21 @@ 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`。 确定性拼接、无时间戳——同输入同输出,便于单测与缓存。 """ - role_line = f"角色定位:{role}" if role else "角色定位:未指定(由你按需求判定)" + if role_mix: + role_line = ( + "角色定位配比(严格按此分配,各定位产出对应数量,合计等于数量):\n" + f"{_render_role_mix(role_mix)}" + ) + else: + role_line = f"角色定位:{role}" if role else "角色定位:未指定(由你按需求判定)" existing_block = ( _render_cards_for_context(existing_chars) if existing_chars else "(暂无已有角色)" ) @@ -262,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)。 @@ -277,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)