feat(character): appearance/motive 全栈穿线 + motive/traits 口径重切

打通 appearance/motive 从生成→schema→契约→落库→前端整条穿线
(DB 列早在初始迁移、此前一直 NULL,无新迁移),并原子重切人物
内驱口径。

- @llm: CharacterCard/character-gen.md 加 appearance/motive(给默认值守
  解析韧性),重切——动机唯一落 motive、traits 只留核心/表层/阴影三层、
  arc 引用 motive;regen prompt_hashes.json 金标准。
- @backend: CharacterCardView + CharacterWriteRepo/SqlCharacterWriteRepo
  (create + update backfill 分支)+ CharacterWriteFields + 路由双向形变
  与 _existing_characters 带上两字段。
- @frontend: pnpm gen:api 重生成 TS 客户端;CharacterCardItem 展示/编辑
  两字段。
- 测试: schema 默认值/解析、动机不双写重切回归、card_to_view 双向、
  ingest 透传、list 暴露、SqlCharacterWriteRepo create/backfill 真 pg 集成。
This commit is contained in:
Yaojia Wang
2026-07-06 14:36:11 +02:00
parent 6854dac98f
commit acde715515
13 changed files with 297 additions and 15 deletions

View File

@@ -75,6 +75,8 @@ class _FakeCharacterWriteRepo:
name: str,
role: str,
traits: list[str],
appearance: str,
motive: str,
backstory: str,
arc: str,
speech_tics: list[str],
@@ -91,6 +93,8 @@ class _FakeCharacterWriteRepo:
{
"role": role,
"traits": list(traits),
"appearance": appearance,
"motive": motive,
"arc": arc,
"speech_tics": list(speech_tics),
"tags": list(tags),
@@ -106,6 +110,8 @@ class _FakeCharacterWriteRepo:
"name": name,
"role": role,
"traits": list(traits),
"appearance": appearance,
"motive": motive,
"arc": arc,
"speech_tics": list(speech_tics),
"tags": list(tags),
@@ -463,6 +469,62 @@ async def test_ingest_relations_persist_on_write() -> None:
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:
# Arrange混入脏条目非 dict / 缺 name / 缺 kind
from ww_api.routers.generation import _relations_from_jsonb
@@ -572,6 +634,8 @@ def _codex_memory() -> Any:
name="叶寒",
role="主角",
traits={"items": ["腹黑", "护短"]},
appearance="剑眉星目,一袭青衫",
motive="为母复仇、登临帝位",
backstory="孤儿出身",
arc={"text": "从孤儿到帝王"},
speech_tics={"items": [""]},
@@ -668,6 +732,9 @@ async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
assert card["speech_tics"] == [""]
assert card["arc"] == "从孤儿到帝王"
assert card["tags"] == ["天才"]
# ⑧ 穿线:读端点暴露 appearance/motiveText 列直落,不形变)。
assert card["appearance"] == "剑眉星目,一袭青衫"
assert card["motive"] == "为母复仇、登临帝位"
@pytest.mark.asyncio

View File

@@ -148,6 +148,8 @@ async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> li
name=v.name,
role=v.role or "",
traits=traits,
appearance=v.appearance or "",
motive=v.motive or "",
backstory=v.backstory or "",
arc=arc or "",
speech_tics=tics,
@@ -329,6 +331,8 @@ async def ingest_characters(
name=card.name,
role=card.role,
traits=list(card.traits),
appearance=card.appearance,
motive=card.motive,
backstory=card.backstory,
arc=card.arc,
speech_tics=list(card.speech_tics),
@@ -434,6 +438,8 @@ def _card_to_view(card: CharacterCard) -> CharacterCardView:
name=card.name,
role=card.role,
traits=list(card.traits),
appearance=card.appearance,
motive=card.motive,
backstory=card.backstory,
arc=card.arc,
speech_tics=list(card.speech_tics),
@@ -449,6 +455,8 @@ def _view_to_card(view: CharacterCardView) -> CharacterCard:
name=view.name,
role=view.role,
traits=list(view.traits),
appearance=view.appearance,
motive=view.motive,
backstory=view.backstory,
arc=view.arc,
speech_tics=list(view.speech_tics),

View File

@@ -67,7 +67,9 @@ class CharacterCardView(BaseModel):
name: 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="背景故事")
arc: str = Field(description="人物弧光(一句话)")
speech_tics: list[str] = Field(default_factory=list, description="口癖/语言风格")