feat(web): 角色群像按定位配比生成(role_mix 缩减版)

- 契约: CharacterGenerateRequest 加 role_mix{定位:数量},model_validator 归一
  count=各值之和(每项≥1、总和≤12),缺省退回单一 role+count
- @llm: build_character_gen_context/run_character_gen 加 role_mix 参并注入配比块
  (确定性、无时间戳,守 assemble 纯函数);router 穿参
- character-gen.md 加"按配比严格分配 role"纪律 + regen prompt_hashes 金标准
- 前端: sanitizeRoleMix/roleMixTotal + buildCharacterGenerateRequest 第4参 +
  useCharacterGen 穿参 + RoleMixEditor + CharacterGenerator 单一/配比双模
This commit is contained in:
Yaojia Wang
2026-07-06 17:24:16 +02:00
parent 72b3c81146
commit 7babb4854b
16 changed files with 428 additions and 50 deletions

View File

@@ -161,6 +161,8 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
- [2026-07-06] @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] @llm/@db/@backend/@frontend**✅ 灵感③ 人物塑造 advisory 审develop 直提)**。写→审链新增第五审「人物塑造」:**advisory 单维——仅建议、不阻断验收**(不进 conflicts、不碰 `assert_conflicts_resolved`、**不入 `REVIEW_RESERVED_NAMES`**D2只做人物塑造、不做氛围氛围噪声大折进 style/pace。**新 SPEC** `characterization_spec``tier="analyst"` 不写 model`reads=("characters",)` 以人物卡 motive/appearance 为客观锚点,`writes=()` 只读,`prompts/characterization.md` **显式忽略文本长度与辞藻华丽度**、引用逐字片段+置信度对抗 LLM-judge 冗长/辞藻偏见)+ schema `CharacterizationReview{issues:[…character/aspect/where/quote/diagnosis/suggestion/confidence]}`(全字段默认值守解析韧性)+ 注册 `SCHEMA_CATALOG["characterization"]`**计数三处 22→23**`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring+ **regen `prompt_hashes.json`**(仅加一条,无其余漂移)。**DB** `chapter_reviews` 加 1 列 `characterization`**JSONB nullable**,迁移 `f6a7b8c9d0e1`nullable 免 backfill。**契约变更** `ReviewHistoryItem`+`ReviewView``characterization`;接线链 `graph.py REVIEW_SPECS` 末位追加 + `chain/nodes.py ReviewRecordRepo` + `collect.py {CHARACTERIZATION/extract_characterization/ReviewRecorder/collect_reviews}` + `review_repo.py {Protocol/SqlReviewRepo/_to_view}` + `sse.py {EVENT_CHARACTERIZATION/characterization_event/_section_result_events}`normalize 按 sorted 键自动纳入,字典序排最前不打乱既有四审断言)。**前端** `lib/review/{sse.ts,history.ts,useReviewStream.ts}` + 新 `CharacterizationPanel.tsx`advisory 只展示、**无裁决 UI**、按置信度降序 + 低置信度折叠控注意力预算)+ `ReviewReport.tsx` 挂面板 + 双 seed。**门禁全绿**:后端 ruff/format 干净 · mypy **221** · pytest **848**+核心 char 审 12 + agents spec 11 + api accept 2 等)· alembic 无漂移;前端 gen:api/lint/typecheck 干净 · vitest **536**+10 含 `test_characterization_does_not_block_accept` 对应前端 reducer/normalize/parse· coverage 95% · build OK。记 contracts C3+C4。守不变量 #2/#3/#9。依赖 ⑧motive/appearance 已入 CharacterCard 契约,作客观锚点)。
- [2026-07-06] @backend/@llm/@frontend**✅ 灵感⑤ AI 立项方案生成+预填develop 直提)**。向导阶段project 未建)一键推演结构化书级蓝图预填立项向导。**新 SPEC** `project_plan_spec``tier="analyst"` 不写 model`reads=()`/`writes=()` 纯预览,`prompts/project-plan.md`+ schema `ProjectPlanResult`(书名候选 list + 时空背景/叙事结构/故事核心/结局设计/基调,**全字段默认值守解析韧性**+ 注册 `SCHEMA_CATALOG["project-plan"]`**计数三处 21→22**`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring+ **regen `prompt_hashes.json`**(仅加一条)。**新端点** `POST /skills/project-plan/generate`**不带 project 前缀**绕开 toolbox 404`ProjectPlanGenerateRequest`(全字段 `max_length` CR-H9`brief``str|None` 令 codegen 可选)→ `ProjectPlanView`**种子门控**缺 genre/logline→422无凭据→503。复用 `build_brief_context`+`run_generator``run_generator`/`_build_request``project_id` 放宽 `uuid.UUID|None`——向导无 projectledger.project_id 本 nullable新缝 `get_project_plan_gateway`analyst。**零迁移**。**前端** `wizard.ts``mapPlanResultToWizardForm`(书名/结构/基调→对应字段;时空背景/结局设计→折进 premise **不静默丢**;结局取向/视角预设枚举不臆造)+ `applyPlanPatch`**仅回填空字段、保护已编辑**+ `canGeneratePlan`/`toPlanSeedRequest` + `useProjectPlan` hookrenderHook 测试)+ `ProjectWizard.tsx` 第 2 步 `PlanAssistant`(生成→预览→按字段回填)。**门禁全绿**:后端 ruff/format 干净 · mypy **218** · pytest **814**+新端点 6 + agents spec 8 测)· alembic 无漂移(零 ORM 变更);前端 gen:api/lint/typecheck 干净 · vitest **526**+28· coverage 95%wizard/generation 100%)· build OK。记 contracts C3 新端点。守不变量 #2/#3/#9。依赖 ④(已交付)。 - [2026-07-06] @backend/@llm/@frontend**✅ 灵感⑤ AI 立项方案生成+预填develop 直提)**。向导阶段project 未建)一键推演结构化书级蓝图预填立项向导。**新 SPEC** `project_plan_spec``tier="analyst"` 不写 model`reads=()`/`writes=()` 纯预览,`prompts/project-plan.md`+ schema `ProjectPlanResult`(书名候选 list + 时空背景/叙事结构/故事核心/结局设计/基调,**全字段默认值守解析韧性**+ 注册 `SCHEMA_CATALOG["project-plan"]`**计数三处 21→22**`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring+ **regen `prompt_hashes.json`**(仅加一条)。**新端点** `POST /skills/project-plan/generate`**不带 project 前缀**绕开 toolbox 404`ProjectPlanGenerateRequest`(全字段 `max_length` CR-H9`brief``str|None` 令 codegen 可选)→ `ProjectPlanView`**种子门控**缺 genre/logline→422无凭据→503。复用 `build_brief_context`+`run_generator``run_generator`/`_build_request``project_id` 放宽 `uuid.UUID|None`——向导无 projectledger.project_id 本 nullable新缝 `get_project_plan_gateway`analyst。**零迁移**。**前端** `wizard.ts``mapPlanResultToWizardForm`(书名/结构/基调→对应字段;时空背景/结局设计→折进 premise **不静默丢**;结局取向/视角预设枚举不臆造)+ `applyPlanPatch`**仅回填空字段、保护已编辑**+ `canGeneratePlan`/`toPlanSeedRequest` + `useProjectPlan` hookrenderHook 测试)+ `ProjectWizard.tsx` 第 2 步 `PlanAssistant`(生成→预览→按字段回填)。**门禁全绿**:后端 ruff/format 干净 · mypy **218** · pytest **814**+新端点 6 + agents spec 8 测)· alembic 无漂移(零 ORM 变更);前端 gen:api/lint/typecheck 干净 · vitest **526**+28· coverage 95%wizard/generation 100%)· build OK。记 contracts C3 新端点。守不变量 #2/#3/#9。依赖 ④(已交付)。

View File

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

View File

@@ -293,6 +293,7 @@ async def generate_characters(
gateway=gateway, gateway=gateway,
user_id=STUB_OWNER_ID, user_id=STUB_OWNER_ID,
project_id=project_id, project_id=project_id,
role_mix=body.role_mix,
) )
await session.commit() await session.commit()
log.info( log.info(

View File

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

View File

@@ -7,6 +7,7 @@ import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field"; import { Field } from "@/components/ui/Field";
import { SectionHeader } from "@/components/ui/SectionHeader"; import { SectionHeader } from "@/components/ui/SectionHeader";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
import type { CharacterCardView } from "@/lib/api/types"; import type { CharacterCardView } from "@/lib/api/types";
@@ -14,11 +15,26 @@ import {
clampCharacterCount, clampCharacterCount,
MAX_CHARACTER_COUNT, MAX_CHARACTER_COUNT,
MIN_CHARACTER_COUNT, MIN_CHARACTER_COUNT,
roleMixTotal,
sanitizeRoleMix,
updateCard, updateCard,
} from "@/lib/generation/cards"; } from "@/lib/generation/cards";
import { useCharacterGen } from "@/lib/generation/useCharacterGen"; import { useCharacterGen } from "@/lib/generation/useCharacterGen";
import { CharacterCardItem } from "./CharacterCardItem"; import { CharacterCardItem } from "./CharacterCardItem";
import { ConflictAdjudication } from "./ConflictAdjudication"; import { ConflictAdjudication } from "./ConflictAdjudication";
import { RoleMixEditor, type RoleMixRow } from "./RoleMixEditor";
type GenMode = "single" | "mix";
const MODE_OPTIONS: Array<{ value: GenMode; label: string }> = [
{ value: "single", label: "单一定位" },
{ value: "mix", label: "按定位配比" },
];
const DEFAULT_MIX_ROWS: RoleMixRow[] = [
{ role: "主角", count: 1 },
{ role: "对手", count: 1 },
];
interface CharacterGeneratorProps { interface CharacterGeneratorProps {
projectId: string; projectId: string;
@@ -35,6 +51,10 @@ export function CharacterGenerator({
const [brief, setBrief] = useState(""); const [brief, setBrief] = useState("");
const [count, setCount] = useState(MIN_CHARACTER_COUNT); const [count, setCount] = useState(MIN_CHARACTER_COUNT);
const [role, setRole] = useState(""); const [role, setRole] = useState("");
const [mode, setMode] = useState<GenMode>("single");
const [mixRows, setMixRows] = useState<RoleMixRow[]>(DEFAULT_MIX_ROWS);
// 配比模式下的有效合计sanitize 后,已按 MAX 截断)——门控生成按钮。
const mixTotal = roleMixTotal(sanitizeRoleMix(mixRows));
// 编辑态卡片(预览返回后拷贝进本地,允许就地改)。 // 编辑态卡片(预览返回后拷贝进本地,允许就地改)。
const [drafts, setDrafts] = useState<CharacterCardView[]>([]); const [drafts, setDrafts] = useState<CharacterCardView[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set()); const [selected, setSelected] = useState<Set<number>>(new Set());
@@ -46,8 +66,17 @@ export function CharacterGenerator({
}, []); }, []);
const onGenerate = useCallback(async () => { const onGenerate = useCallback(async () => {
if (mode === "mix") {
await gen.generate({
projectId,
brief,
count,
roleMix: sanitizeRoleMix(mixRows),
});
return;
}
await gen.generate({ projectId, brief, count, role }); await gen.generate({ projectId, brief, count, role });
}, [gen, projectId, brief, count, role]); }, [gen, projectId, brief, count, role, mode, mixRows]);
// gen.cards 变化(新预览)→ 重新同步本地草稿。 // gen.cards 变化(新预览)→ 重新同步本地草稿。
const previewKey = gen.cards.map((c) => c.name).join("|"); const previewKey = gen.cards.map((c) => c.name).join("|");
@@ -96,7 +125,25 @@ export function CharacterGenerator({
placeholder="如:一个亦正亦邪的剑修,背负灭门血仇" placeholder="如:一个亦正亦邪的剑修,背负灭门血仇"
/> />
</Field> </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 <Field
label="数量" label="数量"
className="w-28" className="w-28"
@@ -119,13 +166,17 @@ export function CharacterGenerator({
placeholder="主角 / CP / 对手 / 导师 / 工具人" placeholder="主角 / CP / 对手 / 导师 / 工具人"
/> />
</Field> </Field>
</div>
)}
<Button <Button
onClick={() => void onGenerate()} onClick={() => void onGenerate()}
disabled={ disabled={
generating || generating ||
brief.trim().length === 0 || brief.trim().length === 0 ||
count < MIN_CHARACTER_COUNT || (mode === "mix"
count > MAX_CHARACTER_COUNT ? mixTotal < MIN_CHARACTER_COUNT
: count < MIN_CHARACTER_COUNT || count > MAX_CHARACTER_COUNT)
} }
variant="primary" variant="primary"
> >
@@ -133,7 +184,6 @@ export function CharacterGenerator({
{generating ? "生成中…" : "生成"} {generating ? "生成中…" : "生成"}
</Button> </Button>
</div> </div>
</div>
{generating ? ( {generating ? (
<div className="rounded border border-line bg-panel/50 p-4 text-cinnabar"> <div className="rounded border border-line bg-panel/50 p-4 text-cinnabar">

View File

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

View File

@@ -997,6 +997,10 @@ export interface components {
/** /**
* CharacterGenerateRequest * CharacterGenerateRequest
* @description POST /projects/:id/characters/generate据需求生成角色卡预览群像防雷同 * @description POST /projects/:id/characters/generate据需求生成角色卡预览群像防雷同
*
* `role_mix`(⑦ 群像按定位配比)与 `count` 的关系:`role_mix` 在场时 `count` 取各值之和
* (忽略客户端显式 count每项数量须 ≥ 1、总和须 ≤ `MAX_GENERATE_COUNT`;缺省则退回
* 单一 `role` + `count` 语义。
*/ */
CharacterGenerateRequest: { CharacterGenerateRequest: {
/** /**
@@ -1006,7 +1010,7 @@ export interface components {
brief: string; brief: string;
/** /**
* Count * Count
* @description 生成数量 * @description 生成数量role_mix 在场时取各值之和)
* @default 1 * @default 1
*/ */
count: number; count: number;
@@ -1015,6 +1019,13 @@ export interface components {
* @description 角色定位(主角/CP/对手/导师/工具人 等) * @description 角色定位(主角/CP/对手/导师/工具人 等)
*/ */
role?: string | null; role?: string | null;
/**
* Role Mix
* @description 按定位配比生成 {定位: 数量};在场时 count=各值之和每项≥1、总和≤12
*/
role_mix?: {
[key: string]: number;
} | null;
}; };
/** /**
* CharacterIngestRequest * CharacterIngestRequest

View File

@@ -16,6 +16,8 @@ import {
mergeCharacterCards, mergeCharacterCards,
mergeWorldEntities, mergeWorldEntities,
MIN_CHARACTER_COUNT, MIN_CHARACTER_COUNT,
roleMixTotal,
sanitizeRoleMix,
updateCard, updateCard,
worldEntityRules, worldEntityRules,
} from "./cards"; } from "./cards";
@@ -62,6 +64,62 @@ describe("buildCharacterGenerateRequest", () => {
role: "对手", role: "对手",
}); });
}); });
it("uses role_mix (count=sum, no single role) when a mix is supplied", () => {
expect(
buildCharacterGenerateRequest("群像", 1, "忽略", {
主角: 1,
对手: 2,
配角: 3,
}),
).toEqual({
brief: "群像",
count: 6,
role_mix: { 主角: 1, 对手: 2, 配角: 3 },
});
});
it("falls back to count/role when role_mix is empty", () => {
expect(buildCharacterGenerateRequest("群像", 4, "主角", {})).toEqual({
brief: "群像",
count: 4,
role: "主角",
});
});
});
describe("sanitizeRoleMix", () => {
it("trims roles, drops blanks, clamps counts to >=1, dedups", () => {
expect(
sanitizeRoleMix([
{ role: " 主角 ", count: 2 },
{ role: "", count: 3 }, // 空定位丢弃
{ role: "对手", count: 0 }, // clamp 到 1
{ role: "主角", count: 5 }, // 重复定位丢弃(先到先得)
]),
).toEqual({ 主角: 2, 对手: 1 });
});
it("caps the running total at MAX_CHARACTER_COUNT", () => {
const out = sanitizeRoleMix([
{ role: "主角", count: 10 },
{ role: "对手", count: 5 }, // 只剩 2 额度
{ role: "配角", count: 3 }, // 无额度,丢弃
]);
expect(out).toEqual({ 主角: 10, 对手: 2 });
expect(roleMixTotal(out)).toBe(MAX_CHARACTER_COUNT);
});
it("returns empty object when no valid rows", () => {
expect(sanitizeRoleMix([{ role: " ", count: 3 }])).toEqual({});
});
});
describe("roleMixTotal", () => {
it("sums counts", () => {
expect(roleMixTotal({ 主角: 1, 对手: 2 })).toBe(3);
expect(roleMixTotal({})).toBe(0);
});
}); });
describe("buildCharacterIngestRequest", () => { describe("buildCharacterIngestRequest", () => {

Binary file not shown.

View File

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

View File

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

View File

@@ -137,7 +137,7 @@
### C3 扩展T5.2, 2026-06-19· 生成/入库 + 读端点(独立 `routers/generation.py`已注册 owner @backend 状态: 稳定 ### C3 扩展T5.2, 2026-06-19· 生成/入库 + 读端点(独立 `routers/generation.py`已注册 owner @backend 状态: 稳定
- snake_case改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`**T5.6 前置)。生成走**即时返回**预览→作者确认→入库M5-d非 jobs - snake_case改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`**T5.6 前置)。生成走**即时返回**预览→作者确认→入库M5-d非 jobs
- `POST /projects/{project_id}/world/generate``WorldGenerateRequest{brief:str(min1)}`**200** `WorldGenPreviewResponse{entities:[WorldEntityCardView{type:str,name:str,rules:list[str]}]}`。**预览不入库**worldbuilder writer 网关 `run_worldbuilder`);末尾 `commit()` 仅落网关 ledger。项目不存在→404无凭据→**503** `LLM_UNAVAILABLE``get_worldbuilder_gateway` dep 解析阶段拦下)。 - `POST /projects/{project_id}/world/generate``WorldGenerateRequest{brief:str(min1)}`**200** `WorldGenPreviewResponse{entities:[WorldEntityCardView{type:str,name:str,rules:list[str]}]}`。**预览不入库**worldbuilder writer 网关 `run_worldbuilder`);末尾 `commit()` 仅落网关 ledger。项目不存在→404无凭据→**503** `LLM_UNAVAILABLE``get_worldbuilder_gateway` dep 解析阶段拦下)。
- `POST /projects/{project_id}/characters/generate``CharacterGenerateRequest{brief:str(min1), count:int=1(ge1,le12), role:str|None}`**200** `CharacterGenPreviewResponse{cards:[CharacterCardView{name,role,traits:list[str],backstory,arc:str,speech_tics:list[str],tags:list[str],relations:[CharacterRelationView{name,kind,note?}]}]}`。**预览不入库**character-gen writer 网关注入已有角色防雷同generated_so_far=[]);末尾 commit 落 ledger。404/503 同上。 - `POST /projects/{project_id}/characters/generate``CharacterGenerateRequest{brief:str(min1), count:int=1(ge1,le12), role:str|None, role_mix:dict[str,int]|None}`**200** `CharacterGenPreviewResponse{cards:[CharacterCardView{name,role,traits:list[str],backstory,arc:str,speech_tics:list[str],tags:list[str],relations:[CharacterRelationView{name,kind,note?}]}]}`。**预览不入库**character-gen writer 网关注入已有角色防雷同generated_so_far=[]);末尾 commit 落 ledger。404/503 同上。**⑦ 群像按定位配比**`role_mix`{定位:数量})在场时 `count` 取各值之和(忽略客户端 count每项≥1、总和≤12`model_validator` 归一化);缺省退回单一 `role`+`count`
- `POST /projects/{project_id}/characters`(入库)← `CharacterIngestRequest{cards:[CharacterCardView](min1), acknowledge_conflicts:bool=false}`**201** `CharacterIngestResponse{created:list[str], rejected_tables:list[str]}`。**入库 gate**:① `precheck_generated_cards`(continuity 预检, analyst 网关) 比对卡 vs 世界观/已有角色真相源——有冲突且 `acknowledge_conflicts=false`**409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[{type,where,refs,suggestion}], conflict_count}`(仿 accept gate不静默入库不变量#3`acknowledge_conflicts=true` 即作者裁决放行)。② `partition_writes(character_gen_spec, {"characters":cards})` 白名单过滤(越权表→`rejected_tables` 审计 log+丢弃character-gen 只声明 writes=characters 故正常为空)。③ 写 `characters`schema list/str → DB JSONB **dict** 形变,见写侧 repo。404/503 同上。提交边界precheck 网关 ledger + 角色写侧均只 flush → 端点末尾一次 `commit()`(含冲突 409 路径也 commit 落 precheck usage - `POST /projects/{project_id}/characters`(入库)← `CharacterIngestRequest{cards:[CharacterCardView](min1), acknowledge_conflicts:bool=false}`**201** `CharacterIngestResponse{created:list[str], rejected_tables:list[str]}`。**入库 gate**:① `precheck_generated_cards`(continuity 预检, analyst 网关) 比对卡 vs 世界观/已有角色真相源——有冲突且 `acknowledge_conflicts=false`**409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[{type,where,refs,suggestion}], conflict_count}`(仿 accept gate不静默入库不变量#3`acknowledge_conflicts=true` 即作者裁决放行)。② `partition_writes(character_gen_spec, {"characters":cards})` 白名单过滤(越权表→`rejected_tables` 审计 log+丢弃character-gen 只声明 writes=characters 故正常为空)。③ 写 `characters`schema list/str → DB JSONB **dict** 形变,见写侧 repo。404/503 同上。提交边界precheck 网关 ledger + 角色写侧均只 flush → 端点末尾一次 `commit()`(含冲突 409 路径也 commit 落 precheck usage
- `GET /projects/{project_id}/rules`**200** `RuleListResponse{rules:[RuleView{level,content}]}`(规则页;复用 C5 读侧 `SqlRulesRepo.all_for_project`,不动 assemble - `GET /projects/{project_id}/rules`**200** `RuleListResponse{rules:[RuleView{level,content}]}`(规则页;复用 C5 读侧 `SqlRulesRepo.all_for_project`,不动 assemble
- `GET /skills`(独立 `skills_router`prefix `/skills`)→ **200** `SkillListResponse{skills:[SkillView{name,scope,tier,reads:list[str],writes:list[str],genre?}]}`(技能库 UI`get_skill_registry` 读 registry按 name 升序)。 - `GET /skills`(独立 `skills_router`prefix `/skills`)→ **200** `SkillListResponse{skills:[SkillView{name,scope,tier,reads:list[str],writes:list[str],genre?}]}`(技能库 UI`get_skill_registry` 读 registry按 name 升序)。
@@ -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] @llm/@backend C3(⑧ appearance/motive 穿线`CharacterCardView``schemas/generation.py`+ `ww_agents.CharacterCard` 各加 `appearance:str=""` + `motive:str=""`**均给默认值守解析韧性**DB `characters.appearance/motive` 早已在初始迁移此前一直 NULL——**无新迁移**)。写侧 `CharacterWriteRepo.create`/`SqlCharacterWriteRepo`create+**update backfill 分支**/`CharacterWriteFields` 同步加两列`routers/generation.py` `_card_to_view`/`_view_to_card`/`_existing_characters` 双向形变带上两字段同批**重切 motive/traits/arc 口径**动机唯一落 `motive``traits[]` 只留核心/表层/阴影三层`arc` 引用 motive `character-gen.md` regen `prompt_hashes.json` 金标准)。**codegen 注意**因带 `default`openapi-typescript 把两字段渲染为**必填**响应恒有值前端 `CharacterCardView` 字面量构造点须显式给两字段。→ 影响 @frontend `pnpm gen:api` + `CharacterCardItem` 展示/编辑两字段未来 人物塑造审查以 motive/appearance 为客观锚点
- [2026-07-06] @backend/@llm/@frontend C3(⑦ 群像按定位配比·缩减版`CharacterGenerateRequest``schemas/generation.py` `role_mix:dict[str,int]|None`——`model_validator(mode="after")` 在场时校验非空每项1总和`MAX_GENERATE_COUNT`=12并把 `count` 归一为各值之和构造期归一化忽略客户端 count缺省退回单一 `role`+`count`。**归属纠偏**`build_character_gen_context`+`run_character_gen``ww_core/orchestrator/generation_node.py`=**@llm **加末位默认 `role_mix=None` 关键字参在场时注入角色定位配比保插入序确定性无时间戳 #6/#7覆盖单一 role `routers/generation.py` 穿 `role_mix=body.role_mix``character-gen.md` 按配比分配 role纪律 regen `prompt_hashes.json` character-gen 哈希变**不进 SPECS/ bump 计数**)。前端 `cards.ts` `sanitizeRoleMix`/`roleMixTotal` + `buildCharacterGenerateRequest` 4 roleMix在场时 count=Σ、发 role_mix role`useCharacterGen` `CharacterGenInput.roleMix` 穿参 `RoleMixEditor.tsx` + `CharacterGenerator` SegmentedControl 单一/配比双模。**codegen**`role_mix` 渲染 `?: {[k:string]:number} | null` 可选。→ 影响 @frontend `pnpm gen:api`)。**无迁移**。
- [2026-06-24] @llm/@backend C6-extPrompt 外置方案A21 prompt 散文外置 `prompts/<name>.md` + `load_prompt`/`SPECS`/`SCHEMA_CATALOG`/`REVIEW_RESERVED_NAMES`@llm+ `SpecResolver` + SkillRegistry 入库守卫前移 + toolbox 桥接 `SPECS`@backend+ `.gitattributes`/wheel package-data/CI 冒烟@devops)。随后全量重写 21 prompt 内容任务对齐schema 契约/不变量保持金标准 fixture 重生成)。门禁绿ruff/format/mypy(209)/pytest(744)。→ 影响后续波次可将编排器 + apps/api 路由切 `SpecResolver` 并删 `*_spec` 导出前端零影响GeneratorTool 描述符字段不变)。 - [2026-06-24] @llm/@backend C6-extPrompt 外置方案A21 prompt 散文外置 `prompts/<name>.md` + `load_prompt`/`SPECS`/`SCHEMA_CATALOG`/`REVIEW_RESERVED_NAMES`@llm+ `SpecResolver` + SkillRegistry 入库守卫前移 + toolbox 桥接 `SPECS`@backend+ `.gitattributes`/wheel package-data/CI 冒烟@devops)。随后全量重写 21 prompt 内容任务对齐schema 契约/不变量保持金标准 fixture 重生成)。门禁绿ruff/format/mypy(209)/pytest(744)。→ 影响后续波次可将编排器 + apps/api 路由切 `SpecResolver` 并删 `*_spec` 导出前端零影响GeneratorTool 描述符字段不变)。
- [2026-06-23] @backend C-ChainC23 端点 + `schemas/chain.py` + `services/{chain_runner,chain_deps}.py` + `jobs` 零迁移复用 `status="awaiting_input"` + `JobRepo.set_awaiting`+ `ErrorCode.CONFLICT`(409) + `project_deps` `build_chain_gateway`/`get_chain_gateway`/`get_digest_gateway_builder`/`get_checkpointer_factory`OpenAPI 2 POSTGET /jobs 复用)。门禁绿ruff/format/mypy(193) 干净 + pytest 600 passed + alembic 无漂移。→ 影响 @frontendfollow-up `pnpm gen:api`)、@db/@devops C3检查点迁移)。 - [2026-06-23] @backend C-ChainC23 端点 + `schemas/chain.py` + `services/{chain_runner,chain_deps}.py` + `jobs` 零迁移复用 `status="awaiting_input"` + `JobRepo.set_awaiting`+ `ErrorCode.CONFLICT`(409) + `project_deps` `build_chain_gateway`/`get_chain_gateway`/`get_digest_gateway_builder`/`get_checkpointer_factory`OpenAPI 2 POSTGET /jobs 复用)。门禁绿ruff/format/mypy(193) 干净 + pytest 600 passed + alembic 无漂移。→ 影响 @frontendfollow-up `pnpm gen:api`)、@db/@devops C3检查点迁移)。

View File

@@ -2,7 +2,7 @@
"blurb": "2adba04a16707d7d9dfbb84d0a5a302ea8e946104dd8a617ff7801ed86f41605", "blurb": "2adba04a16707d7d9dfbb84d0a5a302ea8e946104dd8a617ff7801ed86f41605",
"book-title": "aa53ccf22da1d4e555c8168fc23f2f3547cc6f59e56e5df57540b7a946755e39", "book-title": "aa53ccf22da1d4e555c8168fc23f2f3547cc6f59e56e5df57540b7a946755e39",
"brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada", "brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada",
"character-gen": "f65e2ec3158be378a6cd40c23035b326dc9f6b297b6135007a93fceedf575cfd", "character-gen": "e0f97a5d7bb622fdd7582cdeef85ba13b3f434b2633f4d22a8c6daeba48636bf",
"characterization": "643988cb899a66472acb3e18709f3299d2025f9bda132c774ee26c589266b51f", "characterization": "643988cb899a66472acb3e18709f3299d2025f9bda132c774ee26c589266b51f",
"continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763", "continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763",
"continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1", "continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1",

View File

@@ -9,7 +9,7 @@
## 输入材料 ## 输入材料
- **角色需求**(一句话,如「亦正亦邪的女二、与主角有宿命纠葛、出身敌对势力」)+ 数量count+ 定位; - **角色需求**(一句话,如「亦正亦邪的女二、与主角有宿命纠葛、出身敌对势力」)+ 数量count+ 定位;若给出**角色定位配比**(各定位对应数量的清单),按配比给每个定位产出对应数量的卡、合计等于总数量;
- **世界观约束**world_entities力量体系、势力、地理硬规则——取名 / 能力须契合); - **世界观约束**world_entities力量体系、势力、地理硬规则——取名 / 能力须契合);
- **已有角色**characters现有角色卡新角色须与其区分、可与其建关系 - **已有角色**characters现有角色卡新角色须与其区分、可与其建关系
- **本批已生成卡**(同一次批量里先产出的卡——后续卡须与它们差异化,避免雷同)。 - **本批已生成卡**(同一次批量里先产出的卡——后续卡须与它们差异化,避免雷同)。
@@ -55,7 +55,7 @@
## 纪律 ## 纪律
- 你只产结构化角色卡,**不改稿、不写库、不直接互调其他 agent**——入库前由编排器追加一道 continuity 校验确认不与世界观 / 力量体系冲突§6.5 - 你只产结构化角色卡,**不改稿、不写库、不直接互调其他 agent**——入库前由编排器追加一道 continuity 校验确认不与世界观 / 力量体系冲突§6.5
- count 是几就产几张,不多不少;定位若给出则按定位分配 - count 是几就产几张,不多不少;给出**角色定位配比**时严格按配比分配——每个定位产出清单标注的数量、各定位之和等于总数量;只给单一定位则整批按该定位分配;两者皆无则按需求自行判定
- 不臆造与需求 / 世界观无关的设定,宁可留白也不硬填。 - 不臆造与需求 / 世界观无关的设定,宁可留白也不硬填。
## 示例(单卡,仅示范字段填法与质感,勿照抄设定) ## 示例(单卡,仅示范字段填法与质感,勿照抄设定)

View File

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

View File

@@ -24,7 +24,7 @@ worldbuilder 与 character-gen 都是**独立生成**——不在写章/审稿
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from collections.abc import Sequence from collections.abc import Mapping, Sequence
import structlog import structlog
from pydantic import BaseModel from pydantic import BaseModel
@@ -215,6 +215,11 @@ def _render_cards_for_context(cards: Sequence[CharacterCard]) -> str:
return "\n".join(lines) return "\n".join(lines)
def _render_role_mix(role_mix: Mapping[str, int]) -> str:
"""把定位配比序列化为清单(保插入序,确定性,无时间戳)。"""
return "\n".join(f"- {role}{n}" for role, n in role_mix.items())
def build_character_gen_context( def build_character_gen_context(
*, *,
brief: str, brief: str,
@@ -223,12 +228,20 @@ def build_character_gen_context(
world_context: str, world_context: str,
existing_chars: Sequence[CharacterCard], existing_chars: Sequence[CharacterCard],
generated_so_far: Sequence[CharacterCard], generated_so_far: Sequence[CharacterCard],
role_mix: Mapping[str, int] | None = None,
) -> str: ) -> str:
"""拼装 character-gen 注入文本含群像防雷同上下文M5-a """拼装 character-gen 注入文本含群像防雷同上下文M5-a;⑦ 群像按定位配比)。
注入「已有角色」+「本批已生成卡」,要求新卡与二者差异化(避免一群人一个模子)。 注入「已有角色」+「本批已生成卡」,要求新卡与二者差异化(避免一群人一个模子)。
`role_mix` 在场时改注入定位配比块(各定位产出对应数量、合计等于数量),覆盖单一 `role`。
确定性拼接、无时间戳——同输入同输出,便于单测与缓存。 确定性拼接、无时间戳——同输入同输出,便于单测与缓存。
""" """
if role_mix:
role_line = (
"角色定位配比(严格按此分配,各定位产出对应数量,合计等于数量):\n"
f"{_render_role_mix(role_mix)}"
)
else:
role_line = f"角色定位:{role}" if role else "角色定位:未指定(由你按需求判定)" role_line = f"角色定位:{role}" if role else "角色定位:未指定(由你按需求判定)"
existing_block = ( existing_block = (
_render_cards_for_context(existing_chars) if existing_chars else "(暂无已有角色)" _render_cards_for_context(existing_chars) if existing_chars else "(暂无已有角色)"
@@ -262,10 +275,12 @@ async def run_character_gen(
gateway: GatewayRun, gateway: GatewayRun,
user_id: uuid.UUID, user_id: uuid.UUID,
project_id: uuid.UUID, project_id: uuid.UUID,
role_mix: Mapping[str, int] | None = None,
) -> CharacterGenResult: ) -> CharacterGenResult:
"""跑角色/群像生成:构防雷同上下文 → `gateway.run` → 返回 `CharacterGenResult`。 """跑角色/群像生成:构防雷同上下文 → `gateway.run` → 返回 `CharacterGenResult`。
群像防雷同M5-a注入「已有角色 + 本批已生成卡」要求差异化§4.5 / §6.5)。 群像防雷同M5-a注入「已有角色 + 本批已生成卡」要求差异化§4.5 / §6.5)。
`role_mix`(⑦)在场时按定位配比分配(各定位对应数量、合计等于数量)。
独立生成(非并行审):网关失败直接上抛(端点/T5.2 处理),不静默吞。 独立生成(非并行审):网关失败直接上抛(端点/T5.2 处理),不静默吞。
**只产结构化角色卡、不写库**——入库前过 `precheck_generated_cards` 校验、再经 **只产结构化角色卡、不写库**——入库前过 `precheck_generated_cards` 校验、再经
入库端点持久化(持久化属 T5.2,不变量 #3 入库端点持久化(持久化属 T5.2,不变量 #3
@@ -277,6 +292,7 @@ async def run_character_gen(
world_context=world_context, world_context=world_context,
existing_chars=existing_chars, existing_chars=existing_chars,
generated_so_far=generated_so_far, generated_so_far=generated_so_far,
role_mix=role_mix,
) )
req = _build_request(spec, context=context, user_id=user_id, project_id=project_id) req = _build_request(spec, context=context, user_id=user_id, project_id=project_id)
resp = await gateway.run(req) resp = await gateway.run(req)