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

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

View File

@@ -9,7 +9,7 @@
## 输入材料
- **角色需求**(一句话,如「亦正亦邪的女二、与主角有宿命纠葛、出身敌对势力」)+ 数量count+ 定位;
- **角色需求**(一句话,如「亦正亦邪的女二、与主角有宿命纠葛、出身敌对势力」)+ 数量count+ 定位;若给出**角色定位配比**(各定位对应数量的清单),按配比给每个定位产出对应数量的卡、合计等于总数量;
- **世界观约束**world_entities力量体系、势力、地理硬规则——取名 / 能力须契合);
- **已有角色**characters现有角色卡新角色须与其区分、可与其建关系
- **本批已生成卡**(同一次批量里先产出的卡——后续卡须与它们差异化,避免雷同)。
@@ -55,7 +55,7 @@
## 纪律
- 你只产结构化角色卡,**不改稿、不写库、不直接互调其他 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 # 本批已生成卡
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"))

View File

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