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

@@ -101,6 +101,8 @@ _CHARACTER_CARDS = CharacterGenResult(
name="叶无尘",
role="主角",
traits=["隐忍", "腹黑"],
appearance="剑眉星目,玄袍加身,眉心一点朱砂",
motive="核心动机:查明灭门真相并复仇;深处渴望守护余下之人",
backstory="灭门遗孤,背负血仇。",
arc="从复仇者走向守护者。",
speech_tics=["呵,有意思", "不过如此"],
@@ -393,6 +395,9 @@ async def test_m5_generate_characters_gate_acknowledge_and_persist(
assert ye.relations == []
assert ye.role == "主角"
assert ye.backstory == "灭门遗孤,背负血仇。"
# ⑧ 穿线appearance/motive Text 列真落 pg此前一直 NULL
assert ye.appearance == "剑眉星目,玄袍加身,眉心一点朱砂"
assert ye.motive == "核心动机:查明灭门真相并复仇;深处渴望守护余下之人"
# 记账真落 pgworld generate(writer) + characters generate(writer) +
# 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)
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:
# Arrangeseed 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(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None: