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:
@@ -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。依赖 ④(已交付)。
|
||||
|
||||
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})
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
13
apps/web/lib/api/schema.d.ts
vendored
13
apps/web/lib/api/schema.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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/<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(检查点迁移)。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"blurb": "2adba04a16707d7d9dfbb84d0a5a302ea8e946104dd8a617ff7801ed86f41605",
|
||||
"book-title": "aa53ccf22da1d4e555c8168fc23f2f3547cc6f59e56e5df57540b7a946755e39",
|
||||
"brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada",
|
||||
"character-gen": "f65e2ec3158be378a6cd40c23035b326dc9f6b297b6135007a93fceedf575cfd",
|
||||
"character-gen": "e0f97a5d7bb622fdd7582cdeef85ba13b3f434b2633f4d22a8c6daeba48636bf",
|
||||
"characterization": "643988cb899a66472acb3e18709f3299d2025f9bda132c774ee26c589266b51f",
|
||||
"continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763",
|
||||
"continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
## 输入材料
|
||||
|
||||
- **角色需求**(一句话,如「亦正亦邪的女二、与主角有宿命纠葛、出身敌对势力」)+ 数量(count)+ 定位;
|
||||
- **角色需求**(一句话,如「亦正亦邪的女二、与主角有宿命纠葛、出身敌对势力」)+ 数量(count)+ 定位;若给出**角色定位配比**(各定位对应数量的清单),按配比给每个定位产出对应数量的卡、合计等于总数量;
|
||||
- **世界观约束**(world_entities:力量体系、势力、地理硬规则——取名 / 能力须契合);
|
||||
- **已有角色**(characters:现有角色卡,新角色须与其区分、可与其建关系);
|
||||
- **本批已生成卡**(同一次批量里先产出的卡——后续卡须与它们差异化,避免雷同)。
|
||||
@@ -55,7 +55,7 @@
|
||||
## 纪律
|
||||
|
||||
- 你只产结构化角色卡,**不改稿、不写库、不直接互调其他 agent**——入库前由编排器追加一道 continuity 校验确认不与世界观 / 力量体系冲突(§6.5);
|
||||
- count 是几就产几张,不多不少;定位若给出则按定位分配;
|
||||
- count 是几就产几张,不多不少;给出**角色定位配比**时严格按配比分配——每个定位产出清单标注的数量、各定位之和等于总数量;只给单一定位则整批按该定位分配;两者皆无则按需求自行判定;
|
||||
- 不臆造与需求 / 世界观无关的设定,宁可留白也不硬填。
|
||||
|
||||
## 示例(单卡,仅示范字段填法与质感,勿照抄设定)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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,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 "(暂无已有角色)"
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user