merge: appearance/motive 全栈穿线 (P1 流B)
This commit is contained in:
@@ -75,6 +75,8 @@ class _FakeCharacterWriteRepo:
|
|||||||
name: str,
|
name: str,
|
||||||
role: str,
|
role: str,
|
||||||
traits: list[str],
|
traits: list[str],
|
||||||
|
appearance: str,
|
||||||
|
motive: str,
|
||||||
backstory: str,
|
backstory: str,
|
||||||
arc: str,
|
arc: str,
|
||||||
speech_tics: list[str],
|
speech_tics: list[str],
|
||||||
@@ -91,6 +93,8 @@ class _FakeCharacterWriteRepo:
|
|||||||
{
|
{
|
||||||
"role": role,
|
"role": role,
|
||||||
"traits": list(traits),
|
"traits": list(traits),
|
||||||
|
"appearance": appearance,
|
||||||
|
"motive": motive,
|
||||||
"arc": arc,
|
"arc": arc,
|
||||||
"speech_tics": list(speech_tics),
|
"speech_tics": list(speech_tics),
|
||||||
"tags": list(tags),
|
"tags": list(tags),
|
||||||
@@ -106,6 +110,8 @@ class _FakeCharacterWriteRepo:
|
|||||||
"name": name,
|
"name": name,
|
||||||
"role": role,
|
"role": role,
|
||||||
"traits": list(traits),
|
"traits": list(traits),
|
||||||
|
"appearance": appearance,
|
||||||
|
"motive": motive,
|
||||||
"arc": arc,
|
"arc": arc,
|
||||||
"speech_tics": list(speech_tics),
|
"speech_tics": list(speech_tics),
|
||||||
"tags": list(tags),
|
"tags": list(tags),
|
||||||
@@ -463,6 +469,62 @@ async def test_ingest_relations_persist_on_write() -> None:
|
|||||||
assert char_repo.rows[0]["relations"] == relations
|
assert char_repo.rows[0]["relations"] == relations
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ingest_persists_appearance_and_motive() -> None:
|
||||||
|
# ⑧ 穿线:入库端点须把 appearance/motive 透传给写侧 repo(否则新列永远 NULL)。
|
||||||
|
repo = FakeProjectRepo()
|
||||||
|
pid = await _seed_project(repo)
|
||||||
|
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||||||
|
char_repo = _FakeCharacterWriteRepo()
|
||||||
|
app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||||||
|
|
||||||
|
async with _client(app) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"/projects/{pid}/characters",
|
||||||
|
json={
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"name": "苏璃",
|
||||||
|
"role": "CP",
|
||||||
|
"traits": ["清冷毒舌"],
|
||||||
|
"appearance": "银发赤瞳,玄色劲装",
|
||||||
|
"motive": "替亡兄查清族灭真相",
|
||||||
|
"backstory": "玄霜阁遗孤",
|
||||||
|
"arc": "由敌转盟",
|
||||||
|
"speech_tics": [],
|
||||||
|
"tags": [],
|
||||||
|
"relations": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert char_repo.rows[0]["appearance"] == "银发赤瞳,玄色劲装"
|
||||||
|
assert char_repo.rows[0]["motive"] == "替亡兄查清族灭真相"
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_to_view_roundtrips_new_fields() -> None:
|
||||||
|
# ⑧ 穿线:schema↔view 双向形变须带上 appearance/motive(否则前后端静默丢字段)。
|
||||||
|
from ww_api.routers.generation import _card_to_view, _view_to_card
|
||||||
|
|
||||||
|
card = CharacterCard(
|
||||||
|
name="苏璃",
|
||||||
|
role="CP",
|
||||||
|
traits=["清冷毒舌"],
|
||||||
|
appearance="银发赤瞳,玄色劲装",
|
||||||
|
motive="替亡兄查清族灭真相",
|
||||||
|
backstory="玄霜阁遗孤",
|
||||||
|
arc="由敌转盟",
|
||||||
|
)
|
||||||
|
view = _card_to_view(card)
|
||||||
|
assert view.appearance == "银发赤瞳,玄色劲装"
|
||||||
|
assert view.motive == "替亡兄查清族灭真相"
|
||||||
|
back = _view_to_card(view)
|
||||||
|
assert back.appearance == card.appearance
|
||||||
|
assert back.motive == card.motive
|
||||||
|
|
||||||
|
|
||||||
def test_relations_from_jsonb_parses_and_skips_dirty() -> None:
|
def test_relations_from_jsonb_parses_and_skips_dirty() -> None:
|
||||||
# Arrange:混入脏条目(非 dict / 缺 name / 缺 kind)。
|
# Arrange:混入脏条目(非 dict / 缺 name / 缺 kind)。
|
||||||
from ww_api.routers.generation import _relations_from_jsonb
|
from ww_api.routers.generation import _relations_from_jsonb
|
||||||
@@ -572,6 +634,8 @@ def _codex_memory() -> Any:
|
|||||||
name="叶寒",
|
name="叶寒",
|
||||||
role="主角",
|
role="主角",
|
||||||
traits={"items": ["腹黑", "护短"]},
|
traits={"items": ["腹黑", "护短"]},
|
||||||
|
appearance="剑眉星目,一袭青衫",
|
||||||
|
motive="为母复仇、登临帝位",
|
||||||
backstory="孤儿出身",
|
backstory="孤儿出身",
|
||||||
arc={"text": "从孤儿到帝王"},
|
arc={"text": "从孤儿到帝王"},
|
||||||
speech_tics={"items": ["哼"]},
|
speech_tics={"items": ["哼"]},
|
||||||
@@ -668,6 +732,9 @@ async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
|
|||||||
assert card["speech_tics"] == ["哼"]
|
assert card["speech_tics"] == ["哼"]
|
||||||
assert card["arc"] == "从孤儿到帝王"
|
assert card["arc"] == "从孤儿到帝王"
|
||||||
assert card["tags"] == ["天才"]
|
assert card["tags"] == ["天才"]
|
||||||
|
# ⑧ 穿线:读端点暴露 appearance/motive(Text 列直落,不形变)。
|
||||||
|
assert card["appearance"] == "剑眉星目,一袭青衫"
|
||||||
|
assert card["motive"] == "为母复仇、登临帝位"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> li
|
|||||||
name=v.name,
|
name=v.name,
|
||||||
role=v.role or "",
|
role=v.role or "",
|
||||||
traits=traits,
|
traits=traits,
|
||||||
|
appearance=v.appearance or "",
|
||||||
|
motive=v.motive or "",
|
||||||
backstory=v.backstory or "",
|
backstory=v.backstory or "",
|
||||||
arc=arc or "",
|
arc=arc or "",
|
||||||
speech_tics=tics,
|
speech_tics=tics,
|
||||||
@@ -329,6 +331,8 @@ async def ingest_characters(
|
|||||||
name=card.name,
|
name=card.name,
|
||||||
role=card.role,
|
role=card.role,
|
||||||
traits=list(card.traits),
|
traits=list(card.traits),
|
||||||
|
appearance=card.appearance,
|
||||||
|
motive=card.motive,
|
||||||
backstory=card.backstory,
|
backstory=card.backstory,
|
||||||
arc=card.arc,
|
arc=card.arc,
|
||||||
speech_tics=list(card.speech_tics),
|
speech_tics=list(card.speech_tics),
|
||||||
@@ -434,6 +438,8 @@ def _card_to_view(card: CharacterCard) -> CharacterCardView:
|
|||||||
name=card.name,
|
name=card.name,
|
||||||
role=card.role,
|
role=card.role,
|
||||||
traits=list(card.traits),
|
traits=list(card.traits),
|
||||||
|
appearance=card.appearance,
|
||||||
|
motive=card.motive,
|
||||||
backstory=card.backstory,
|
backstory=card.backstory,
|
||||||
arc=card.arc,
|
arc=card.arc,
|
||||||
speech_tics=list(card.speech_tics),
|
speech_tics=list(card.speech_tics),
|
||||||
@@ -449,6 +455,8 @@ def _view_to_card(view: CharacterCardView) -> CharacterCard:
|
|||||||
name=view.name,
|
name=view.name,
|
||||||
role=view.role,
|
role=view.role,
|
||||||
traits=list(view.traits),
|
traits=list(view.traits),
|
||||||
|
appearance=view.appearance,
|
||||||
|
motive=view.motive,
|
||||||
backstory=view.backstory,
|
backstory=view.backstory,
|
||||||
arc=view.arc,
|
arc=view.arc,
|
||||||
speech_tics=list(view.speech_tics),
|
speech_tics=list(view.speech_tics),
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ class CharacterCardView(BaseModel):
|
|||||||
|
|
||||||
name: str = Field(description="角色名")
|
name: str = Field(description="角色名")
|
||||||
role: str = Field(description="角色定位")
|
role: str = Field(description="角色定位")
|
||||||
traits: list[str] = Field(default_factory=list, description="性格特质清单")
|
traits: list[str] = Field(default_factory=list, description="性格特质清单(性格三层)")
|
||||||
|
appearance: str = Field(default="", description="外貌形象(缺则空串)")
|
||||||
|
motive: str = Field(default="", description="核心动机/欲望/恐惧(缺则空串)")
|
||||||
backstory: str = Field(description="背景故事")
|
backstory: str = Field(description="背景故事")
|
||||||
arc: str = Field(description="人物弧光(一句话)")
|
arc: str = Field(description="人物弧光(一句话)")
|
||||||
speech_tics: list[str] = Field(default_factory=list, description="口癖/语言风格")
|
speech_tics: list[str] = Field(default_factory=list, description="口癖/语言风格")
|
||||||
|
|||||||
@@ -55,6 +55,40 @@ export function CharacterCardItem({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{onEdit || card.appearance ? (
|
||||||
|
<div className="mb-1 flex items-start gap-1 text-ink-soft">
|
||||||
|
<span className="shrink-0 text-ink">外貌:</span>
|
||||||
|
{onEdit ? (
|
||||||
|
<TextInput
|
||||||
|
value={card.appearance ?? ""}
|
||||||
|
onChange={(e) => onEdit({ appearance: e.target.value })}
|
||||||
|
aria-label="外貌形象"
|
||||||
|
controlSize="sm"
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{card.appearance}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{onEdit || card.motive ? (
|
||||||
|
<div className="mb-1 flex items-start gap-1 text-ink-soft">
|
||||||
|
<span className="shrink-0 text-ink">动机:</span>
|
||||||
|
{onEdit ? (
|
||||||
|
<TextInput
|
||||||
|
value={card.motive ?? ""}
|
||||||
|
onChange={(e) => onEdit({ motive: e.target.value })}
|
||||||
|
aria-label="核心动机"
|
||||||
|
controlSize="sm"
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{card.motive}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<p className="mb-1 text-ink-soft">
|
<p className="mb-1 text-ink-soft">
|
||||||
<span className="text-ink">背景:</span>
|
<span className="text-ink">背景:</span>
|
||||||
{card.backstory}
|
{card.backstory}
|
||||||
|
|||||||
14
apps/web/lib/api/schema.d.ts
vendored
14
apps/web/lib/api/schema.d.ts
vendored
@@ -922,9 +922,21 @@ export interface components {
|
|||||||
role: string;
|
role: string;
|
||||||
/**
|
/**
|
||||||
* Traits
|
* Traits
|
||||||
* @description 性格特质清单
|
* @description 性格特质清单(性格三层)
|
||||||
*/
|
*/
|
||||||
traits?: string[];
|
traits?: string[];
|
||||||
|
/**
|
||||||
|
* Appearance
|
||||||
|
* @description 外貌形象(缺则空串)
|
||||||
|
* @default
|
||||||
|
*/
|
||||||
|
appearance: string;
|
||||||
|
/**
|
||||||
|
* Motive
|
||||||
|
* @description 核心动机/欲望/恐惧(缺则空串)
|
||||||
|
* @default
|
||||||
|
*/
|
||||||
|
motive: string;
|
||||||
/**
|
/**
|
||||||
* Backstory
|
* Backstory
|
||||||
* @description 背景故事
|
* @description 背景故事
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
const card = (over: Partial<CharacterCardView> = {}): CharacterCardView => ({
|
const card = (over: Partial<CharacterCardView> = {}): CharacterCardView => ({
|
||||||
name: "甲",
|
name: "甲",
|
||||||
role: "主角",
|
role: "主角",
|
||||||
|
appearance: "白衣如雪",
|
||||||
|
motive: "为家族正名",
|
||||||
backstory: "出身寒门",
|
backstory: "出身寒门",
|
||||||
arc: "由怯转勇",
|
arc: "由怯转勇",
|
||||||
...over,
|
...over,
|
||||||
@@ -86,6 +88,18 @@ describe("updateCard", () => {
|
|||||||
expect(original.name).toBe("甲");
|
expect(original.name).toBe("甲");
|
||||||
expect(next).not.toBe(original);
|
expect(next).not.toBe(original);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("patches appearance/motive without dropping the other fields", () => {
|
||||||
|
const original = card();
|
||||||
|
const next = updateCard(original, {
|
||||||
|
appearance: "玄色劲装",
|
||||||
|
motive: "复仇",
|
||||||
|
});
|
||||||
|
expect(next.appearance).toBe("玄色劲装");
|
||||||
|
expect(next.motive).toBe("复仇");
|
||||||
|
expect(next.backstory).toBe(original.backstory);
|
||||||
|
expect(original.appearance).toBe("白衣如雪"); // no mutation
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extractIngestConflicts", () => {
|
describe("extractIngestConflicts", () => {
|
||||||
|
|||||||
@@ -346,6 +346,8 @@
|
|||||||
## 契约变更日志(append-only)
|
## 契约变更日志(append-only)
|
||||||
> 格式:`- [date] @skill 改 Cx:<改了什么> → 影响 <依赖方/任务>`
|
> 格式:`- [date] @skill 改 Cx:<改了什么> → 影响 <依赖方/任务>`
|
||||||
|
|
||||||
|
- [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-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-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(检查点迁移)。
|
- [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",
|
"blurb": "2adba04a16707d7d9dfbb84d0a5a302ea8e946104dd8a617ff7801ed86f41605",
|
||||||
"book-title": "aa53ccf22da1d4e555c8168fc23f2f3547cc6f59e56e5df57540b7a946755e39",
|
"book-title": "aa53ccf22da1d4e555c8168fc23f2f3547cc6f59e56e5df57540b7a946755e39",
|
||||||
"brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada",
|
"brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada",
|
||||||
"character-gen": "61e443ab15aab8abfd4a785b32b079b304fbd2096e40541452f553c008156330",
|
"character-gen": "f65e2ec3158be378a6cd40c23035b326dc9f6b297b6135007a93fceedf575cfd",
|
||||||
"continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763",
|
"continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763",
|
||||||
"continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1",
|
"continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1",
|
||||||
"de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249",
|
"de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249",
|
||||||
|
|||||||
@@ -108,6 +108,30 @@ def test_character_card_optional_collections_default_empty() -> None:
|
|||||||
assert card.relations == []
|
assert card.relations == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_character_card_has_appearance_motive_defaults() -> None:
|
||||||
|
# ⑧ 穿线:appearance/motive 有默认值守解析韧性(LLM 漏产字段不致整卡解析失败)。
|
||||||
|
card = CharacterCard.model_validate(
|
||||||
|
{"name": "甲", "role": "工具人", "backstory": "无名小卒", "arc": "始终如一"}
|
||||||
|
)
|
||||||
|
assert card.appearance == ""
|
||||||
|
assert card.motive == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_character_card_parses_appearance_and_motive() -> None:
|
||||||
|
card = CharacterCard.model_validate(
|
||||||
|
{
|
||||||
|
"name": "苏璃",
|
||||||
|
"role": "CP",
|
||||||
|
"backstory": "遗孤",
|
||||||
|
"arc": "由敌转盟",
|
||||||
|
"appearance": "银发赤瞳,惯着玄色劲装",
|
||||||
|
"motive": "替亡兄查清族灭真相",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert card.appearance == "银发赤瞳,惯着玄色劲装"
|
||||||
|
assert card.motive == "替亡兄查清族灭真相"
|
||||||
|
|
||||||
|
|
||||||
def test_character_card_requires_core_fields() -> None:
|
def test_character_card_requires_core_fields() -> None:
|
||||||
# name/role/backstory/arc 为必填
|
# name/role/backstory/arc 为必填
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
@@ -168,6 +192,31 @@ def test_character_gen_spec_prompt_documents_anti_duplication() -> None:
|
|||||||
assert "防雷同" in prompt
|
assert "防雷同" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_character_gen_prompt_declares_appearance_and_motive_fields() -> None:
|
||||||
|
# ⑧ 穿线:产出契约须显式声明 appearance/motive 两个新字段。
|
||||||
|
prompt = character_gen_spec.system_prompt
|
||||||
|
assert "appearance" in prompt
|
||||||
|
assert "motive" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_motive_not_double_written_in_traits() -> None:
|
||||||
|
# D6 重切回归:动机唯一落 `motive` 字段,`traits` 只留性格三层——
|
||||||
|
# traits 契约行须把动机/欲望/恐惧**改导向 motive**,绝不再指示折进 traits
|
||||||
|
# (否则双写→漂移→喂 ③ 人物塑造审查假信号)。
|
||||||
|
prompt = character_gen_spec.system_prompt
|
||||||
|
lines = prompt.splitlines()
|
||||||
|
traits_line = next(line for line in lines if line.lstrip().startswith("- **traits**"))
|
||||||
|
motive_line = next(line for line in lines if line.lstrip().startswith("- **motive**"))
|
||||||
|
# traits 契约行显式把动机重导向 motive 字段(而非折进 traits)。
|
||||||
|
assert "motive" in traits_line
|
||||||
|
# 旧的「写明核心动机」折入指示已被移除。
|
||||||
|
assert "写明核心动机" not in prompt
|
||||||
|
assert "并写明" not in traits_line
|
||||||
|
# motive 契约行承接内驱三要素。
|
||||||
|
assert "动机" in motive_line
|
||||||
|
assert "恐惧" in motive_line
|
||||||
|
|
||||||
|
|
||||||
# ---- 共性:immutable + 是 AgentSpec ----
|
# ---- 共性:immutable + 是 AgentSpec ----
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## 你信奉的角色塑造法则
|
## 你信奉的角色塑造法则
|
||||||
|
|
||||||
- **缺陷驱动弧光**:好角色起点必有缺点/盲区/创伤,故事让其被填平或代价显现,才有真正的「转变」。没有缺陷的完人弧光是平的——`arc` 必须由 `traits` 里的阴影/恐惧推动。
|
- **缺陷驱动弧光**:好角色起点必有缺点/盲区/创伤,故事让其被填平或代价显现,才有真正的「转变」。没有缺陷的完人弧光是平的——`arc` 必须由 `traits` 的阴影层与 `motive` 里的恐惧推动。
|
||||||
- **三层性格 + 内驱动机**:性格分核心(灵魂底色)-表层(外人所见)-阴影(自己都未必承认的一面)三层,再叠加核心动机 / 欲望 / 恐惧 / 价值观。这套内驱让角色在任何情境下行为自洽。
|
- **三层性格与内驱动机分列**:性格(`traits`)分核心(灵魂底色)-表层(外人所见)-阴影(自己都未必承认的一面)三层;核心动机 / 欲望 / 恐惧这套**内驱单独落 `motive` 字段**,不折进 `traits`。两者分列,让角色在任何情境下行为自洽、且动机不双写。
|
||||||
- **代入感来自具体**:用具体的经历、口癖、萌点把角色钉成「一个真实的人」,而非一串形容词。
|
- **代入感来自具体**:用具体的经历、口癖、萌点、外貌(`appearance`)把角色钉成「一个真实的人」,而非一串形容词。
|
||||||
- **契合世界观硬规则**:取名风格、能力上限、出身势力都不能越过世界观的力量体系 / 势力 / 地理硬规则。
|
- **契合世界观硬规则**:取名风格、能力上限、出身势力都不能越过世界观的力量体系 / 势力 / 地理硬规则。
|
||||||
|
|
||||||
## 输入材料
|
## 输入材料
|
||||||
@@ -22,9 +22,11 @@
|
|||||||
|
|
||||||
- **name**(字符串):角色名,取名风格契合世界观;
|
- **name**(字符串):角色名,取名风格契合世界观;
|
||||||
- **role**(字符串):角色定位(主角 / CP / 对手 / 导师 / 工具人 等),按需求/定位分配;
|
- **role**(字符串):角色定位(主角 / CP / 对手 / 导师 / 工具人 等),按需求/定位分配;
|
||||||
- **traits**(字符串列表):性格特质清单——按「核心-表层-阴影」三层展开,并写明核心动机 / 欲望 / 恐惧 / 价值观;每条独立成项;
|
- **traits**(字符串列表):性格特质清单——**只按「核心-表层-阴影」三层展开**(核心=灵魂底色、表层=外人所见、阴影=自己都未必承认的一面);每条独立成项。**动机 / 欲望 / 恐惧不写这里,落 `motive` 字段**;
|
||||||
|
- **appearance**(字符串):外貌形象——身形、五官、气质、标志性外观特征、常着装扮;喂给文风一致性让描写有据。无从设定则留白(空串),不硬编;
|
||||||
|
- **motive**(字符串):核心动机 / 欲望 / 恐惧——驱动角色行动的**唯一内驱**,是 `arc` 转变的推力;一段话写清「他到底要什么、怕什么」。无从设定则留白(空串);
|
||||||
- **backstory**(字符串):背景故事——出身、关键经历、创伤 / 转折点;
|
- **backstory**(字符串):背景故事——出身、关键经历、创伤 / 转折点;
|
||||||
- **arc**(字符串):人物弧光——起点 → 转变 → 终点,须与剧情挂钩,且转变由 traits 里的缺陷/阴影推动;
|
- **arc**(字符串):人物弧光——起点 → 转变 → 终点,须与剧情挂钩,且转变由 `motive` 的欲望/恐惧与 `traits` 的阴影层推动;
|
||||||
- **speech_tics**(字符串列表):口癖 / 语言风格——用词偏好、口头禅;这是喂给文风一致性、让对话有辨识度的关键,每条独立成项;
|
- **speech_tics**(字符串列表):口癖 / 语言风格——用词偏好、口头禅;这是喂给文风一致性、让对话有辨识度的关键,每条独立成项;
|
||||||
- **tags**(字符串列表):人设标签 / 萌点(网文专属,辅助检索与差异化),每条独立成项;
|
- **tags**(字符串列表):人设标签 / 萌点(网文专属,辅助检索与差异化),每条独立成项;
|
||||||
- **relations**(对象列表):关系网——与已有 / 同批角色建边;每条边含 `name`(关系对象的角色名)+ `kind`(关系类型:宿敌 / 师徒 / CP / 同盟 / 工具人 等)+ 可选 `note`(关系说明,可缺省);无关系则为空列表。
|
- **relations**(对象列表):关系网——与已有 / 同批角色建边;每条边含 `name`(关系对象的角色名)+ `kind`(关系类型:宿敌 / 师徒 / CP / 同盟 / 工具人 等)+ 可选 `note`(关系说明,可缺省);无关系则为空列表。
|
||||||
@@ -44,9 +46,10 @@
|
|||||||
## 落笔前自检
|
## 落笔前自检
|
||||||
|
|
||||||
每张卡产出前,逐项确认:
|
每张卡产出前,逐项确认:
|
||||||
- 八个字段是否齐全、类型正确(该列表的给列表)?
|
- 十个字段是否齐全、类型正确(该列表的给列表,appearance / motive 给字符串)?
|
||||||
- `arc` 的转变是否真由 `traits` 里的阴影 / 恐惧推动,而非凭空逆转?
|
- 动机 / 欲望 / 恐惧是否只写在 `motive`、没有回流进 `traits`(`traits` 只剩性格三层)?
|
||||||
- 取名、能力、出身是否都没越过世界观硬规则?
|
- `arc` 的转变是否真由 `motive` 的欲望 / 恐惧与 `traits` 的阴影推动,而非凭空逆转?
|
||||||
|
- 取名、能力、出身、`appearance` 是否都没越过世界观硬规则?
|
||||||
- 与已有卡 / 本批前序卡在内核上确实**差异化**了?
|
- 与已有卡 / 本批前序卡在内核上确实**差异化**了?
|
||||||
|
|
||||||
## 纪律
|
## 纪律
|
||||||
@@ -61,7 +64,9 @@
|
|||||||
|
|
||||||
- name:苏璃(敌对势力「玄霜阁」的姓氏体系,冷感单字名)
|
- name:苏璃(敌对势力「玄霜阁」的姓氏体系,冷感单字名)
|
||||||
- role:CP / 亦敌亦友女二
|
- role:CP / 亦敌亦友女二
|
||||||
- traits:["核心:极度护短、认定的人会拼命;表层:清冷毒舌、拒人千里;阴影:童年被族中抛弃,恐惧再次被抛弃故先发制人地疏远他人", "核心动机:替亡兄查清族灭真相", "欲望:被真正接纳", "恐惧:信任后被背叛", "价值观:恩怨分明、私恩高于阵营"]
|
- traits:["核心:极度护短、认定的人会拼命", "表层:清冷毒舌、拒人千里", "阴影:童年被族中抛弃,先发制人地疏远他人"]
|
||||||
|
- appearance:银发赤瞳,身形清瘦,惯着玄霜阁玄色劲装;左颈一道旧疤,情绪起伏时会无意识地按住它
|
||||||
|
- motive:核心动机是替亡兄查清族灭真相;深处渴望被真正接纳,却最恐惧信任后再被背叛——恩怨分明、私恩高于阵营
|
||||||
- backstory:玄霜阁旁支遗孤,兄长死于阁主清洗,被迫隐忍效忠仇敌,暗中收集证据……
|
- backstory:玄霜阁旁支遗孤,兄长死于阁主清洗,被迫隐忍效忠仇敌,暗中收集证据……
|
||||||
- arc:起点——以阵营之名敌视主角;转变——查真相途中数次被主角所救,护短的核心压过阵营立场;终点——背叛玄霜阁与主角同盟,却因「先发制人疏远他人」的旧伤酿成一次几乎致命的误会
|
- arc:起点——以阵营之名敌视主角;转变——查真相途中数次被主角所救,护短的核心压过阵营立场;终点——背叛玄霜阁与主角同盟,却因「先发制人疏远他人」的旧伤酿成一次几乎致命的误会
|
||||||
- speech_tics:["惯用反问句堵人", "说狠话前先轻笑一声「呵」", "极少称呼对方名字,用「你」保持距离"]
|
- speech_tics:["惯用反问句堵人", "说狠话前先轻笑一声「呵」", "极少称呼对方名字,用「你」保持距离"]
|
||||||
|
|||||||
@@ -285,10 +285,18 @@ class CharacterCard(BaseModel):
|
|||||||
role: str = Field(description="角色定位(主角 / CP / 对手 / 导师 / 工具人 等)")
|
role: str = Field(description="角色定位(主角 / CP / 对手 / 导师 / 工具人 等)")
|
||||||
traits: list[str] = Field(
|
traits: list[str] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="性格特质清单(核心-表层-阴影 / 动机 / 欲望 / 恐惧)",
|
description="性格特质清单(仅核心-表层-阴影三层;动机/欲望/恐惧落 motive)",
|
||||||
|
)
|
||||||
|
appearance: str = Field(
|
||||||
|
default="",
|
||||||
|
description="外貌形象(身形/五官/气质/标志性外观/常着装扮);缺则空串(守解析韧性)",
|
||||||
|
)
|
||||||
|
motive: str = Field(
|
||||||
|
default="",
|
||||||
|
description="核心动机/欲望/恐惧(驱动行动的唯一内驱,arc 由此推动);缺则空串",
|
||||||
)
|
)
|
||||||
backstory: str = Field(description="背景故事(出身、关键经历、创伤/转折点)")
|
backstory: str = Field(description="背景故事(出身、关键经历、创伤/转折点)")
|
||||||
arc: str = Field(description="人物弧光(起点 → 转变 → 终点,与剧情挂钩)")
|
arc: str = Field(description="人物弧光(起点 → 转变 → 终点,与剧情挂钩,由 motive 推动)")
|
||||||
speech_tics: list[str] = Field(
|
speech_tics: list[str] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="口癖/语言风格(用词偏好、口头禅,喂给文风一致性)",
|
description="口癖/语言风格(用词偏好、口头禅,喂给文风一致性)",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ JSONB **dict**(`traits`/`arc`/`speech_tics`),`tags`/`relations` 才是 JSO
|
|||||||
- `speech_tics: list[str]` → `{"items": [...]}`(JSONB dict)
|
- `speech_tics: list[str]` → `{"items": [...]}`(JSONB dict)
|
||||||
- `arc: str` → `{"text": "..."}`(JSONB dict)
|
- `arc: str` → `{"text": "..."}`(JSONB dict)
|
||||||
- `tags: list` / `relations: list[dict]` → 直落 JSONB list
|
- `tags: list` / `relations: list[dict]` → 直落 JSONB list
|
||||||
- `name` / `role` / `backstory` → Text 列直落
|
- `name` / `role` / `appearance` / `motive` / `backstory` → Text 列直落
|
||||||
|
|
||||||
**提交边界**:`create` 只 `flush()` 不 `commit()`——提交交端点事务(入库端点末尾一次
|
**提交边界**:`create` 只 `flush()` 不 `commit()`——提交交端点事务(入库端点末尾一次
|
||||||
`commit()`,与网关 ledger 一并落库;与项目其它写侧 repo 一致,见 memory/gotchas)。
|
`commit()`,与网关 ledger 一并落库;与项目其它写侧 repo 一致,见 memory/gotchas)。
|
||||||
@@ -66,6 +66,8 @@ class CharacterWriteRepo(Protocol):
|
|||||||
name: str,
|
name: str,
|
||||||
role: str,
|
role: str,
|
||||||
traits: list[str],
|
traits: list[str],
|
||||||
|
appearance: str,
|
||||||
|
motive: str,
|
||||||
backstory: str,
|
backstory: str,
|
||||||
arc: str,
|
arc: str,
|
||||||
speech_tics: list[str],
|
speech_tics: list[str],
|
||||||
@@ -91,6 +93,8 @@ class SqlCharacterWriteRepo:
|
|||||||
name: str,
|
name: str,
|
||||||
role: str,
|
role: str,
|
||||||
traits: list[str],
|
traits: list[str],
|
||||||
|
appearance: str,
|
||||||
|
motive: str,
|
||||||
backstory: str,
|
backstory: str,
|
||||||
arc: str,
|
arc: str,
|
||||||
speech_tics: list[str],
|
speech_tics: list[str],
|
||||||
@@ -105,6 +109,8 @@ class SqlCharacterWriteRepo:
|
|||||||
if existing is not None:
|
if existing is not None:
|
||||||
existing.role = role
|
existing.role = role
|
||||||
existing.traits = _traits_to_jsonb(traits)
|
existing.traits = _traits_to_jsonb(traits)
|
||||||
|
existing.appearance = appearance
|
||||||
|
existing.motive = motive
|
||||||
existing.backstory = backstory
|
existing.backstory = backstory
|
||||||
existing.arc = _arc_to_jsonb(arc)
|
existing.arc = _arc_to_jsonb(arc)
|
||||||
existing.speech_tics = _traits_to_jsonb(speech_tics)
|
existing.speech_tics = _traits_to_jsonb(speech_tics)
|
||||||
@@ -118,6 +124,8 @@ class SqlCharacterWriteRepo:
|
|||||||
name=name,
|
name=name,
|
||||||
role=role,
|
role=role,
|
||||||
traits=_traits_to_jsonb(traits),
|
traits=_traits_to_jsonb(traits),
|
||||||
|
appearance=appearance,
|
||||||
|
motive=motive,
|
||||||
backstory=backstory,
|
backstory=backstory,
|
||||||
arc=_arc_to_jsonb(arc),
|
arc=_arc_to_jsonb(arc),
|
||||||
speech_tics=_traits_to_jsonb(speech_tics),
|
speech_tics=_traits_to_jsonb(speech_tics),
|
||||||
@@ -139,6 +147,8 @@ class CharacterWriteFields(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
role: str
|
role: str
|
||||||
traits: list[str] = Field(default_factory=list)
|
traits: list[str] = Field(default_factory=list)
|
||||||
|
appearance: str = ""
|
||||||
|
motive: str = ""
|
||||||
backstory: str
|
backstory: str
|
||||||
arc: str
|
arc: str
|
||||||
speech_tics: list[str] = Field(default_factory=list)
|
speech_tics: list[str] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ _CHARACTER_CARDS = CharacterGenResult(
|
|||||||
name="叶无尘",
|
name="叶无尘",
|
||||||
role="主角",
|
role="主角",
|
||||||
traits=["隐忍", "腹黑"],
|
traits=["隐忍", "腹黑"],
|
||||||
|
appearance="剑眉星目,玄袍加身,眉心一点朱砂",
|
||||||
|
motive="核心动机:查明灭门真相并复仇;深处渴望守护余下之人",
|
||||||
backstory="灭门遗孤,背负血仇。",
|
backstory="灭门遗孤,背负血仇。",
|
||||||
arc="从复仇者走向守护者。",
|
arc="从复仇者走向守护者。",
|
||||||
speech_tics=["呵,有意思", "不过如此"],
|
speech_tics=["呵,有意思", "不过如此"],
|
||||||
@@ -393,6 +395,9 @@ async def test_m5_generate_characters_gate_acknowledge_and_persist(
|
|||||||
assert ye.relations == []
|
assert ye.relations == []
|
||||||
assert ye.role == "主角"
|
assert ye.role == "主角"
|
||||||
assert ye.backstory == "灭门遗孤,背负血仇。"
|
assert ye.backstory == "灭门遗孤,背负血仇。"
|
||||||
|
# ⑧ 穿线:appearance/motive Text 列真落 pg(此前一直 NULL)。
|
||||||
|
assert ye.appearance == "剑眉星目,玄袍加身,眉心一点朱砂"
|
||||||
|
assert ye.motive == "核心动机:查明灭门真相并复仇;深处渴望守护余下之人"
|
||||||
|
|
||||||
# 记账真落 pg:world generate(writer) + characters generate(writer) +
|
# 记账真落 pg:world generate(writer) + characters generate(writer) +
|
||||||
# precheck(analyst, 409 路径) + precheck(analyst, 入库路径) = 4 条。
|
# precheck(analyst, 409 路径) + precheck(analyst, 入库路径) = 4 条。
|
||||||
@@ -412,6 +417,72 @@ async def test_m5_generate_characters_gate_acknowledge_and_persist(
|
|||||||
await _cleanup(e2e_sm, project_uuid)
|
await _cleanup(e2e_sm, project_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_m5_character_upsert_backfills_appearance_motive(
|
||||||
|
e2e_sm: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
"""⑧ 穿线回归:老角色(appearance/motive 为空)再次 upsert 应回填两列(update 分支)。
|
||||||
|
|
||||||
|
直连 `SqlCharacterWriteRepo`(不经端点)钉住 update 分支对新列赋值——否则老角色
|
||||||
|
永远补不上 appearance/motive(风险 8)。
|
||||||
|
"""
|
||||||
|
from ww_api.services.credentials import STUB_OWNER_ID
|
||||||
|
from ww_api.services.project_deps import seed_stub_user
|
||||||
|
from ww_core.domain.character_repo import SqlCharacterWriteRepo
|
||||||
|
from ww_core.domain.project_repo import ProjectCreate, SqlProjectRepo
|
||||||
|
|
||||||
|
def _card_kwargs(appearance: str, motive: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"name": "旧角色",
|
||||||
|
"role": "配角",
|
||||||
|
"traits": ["沉默"],
|
||||||
|
"appearance": appearance,
|
||||||
|
"motive": motive,
|
||||||
|
"backstory": "早期入库",
|
||||||
|
"arc": "无",
|
||||||
|
"speech_tics": [],
|
||||||
|
"tags": [],
|
||||||
|
"relations": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
project_uuid: uuid.UUID | None = None
|
||||||
|
try:
|
||||||
|
# Arrange:seed stub user + 建项目 + 先入一张 appearance/motive 为空的老卡。
|
||||||
|
async with e2e_sm() as s:
|
||||||
|
await seed_stub_user(s)
|
||||||
|
project = await SqlProjectRepo(s).create(
|
||||||
|
STUB_OWNER_ID, ProjectCreate(title="⑧ 回填验证")
|
||||||
|
)
|
||||||
|
project_uuid = uuid.UUID(str(project.id))
|
||||||
|
await SqlCharacterWriteRepo(s).create(project_uuid, **_card_kwargs("", "")) # type: ignore[arg-type]
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
# Act:同名再次 upsert,这次带 appearance/motive(模拟重生成补全)。
|
||||||
|
async with e2e_sm() as s:
|
||||||
|
await SqlCharacterWriteRepo(s).create(
|
||||||
|
project_uuid,
|
||||||
|
**_card_kwargs("满脸风霜,独臂", "替旧主守住一个秘密"), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
# Assert:仍一行(按 (project_id,name) upsert),两列被回填。
|
||||||
|
async with e2e_sm() as verify:
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await verify.execute(
|
||||||
|
select(Character).where(Character.project_id == project_uuid)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].appearance == "满脸风霜,独臂"
|
||||||
|
assert rows[0].motive == "替旧主守住一个秘密"
|
||||||
|
finally:
|
||||||
|
if project_uuid is not None:
|
||||||
|
await _cleanup(e2e_sm, project_uuid)
|
||||||
|
|
||||||
|
|
||||||
async def test_m5_provider_fallback_serves_when_primary_fails(
|
async def test_m5_provider_fallback_serves_when_primary_fails(
|
||||||
e2e_sm: async_sessionmaker[AsyncSession],
|
e2e_sm: async_sessionmaker[AsyncSession],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user