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:
@@ -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 == "核心动机:查明灭门真相并复仇;深处渴望守护余下之人"
|
||||
|
||||
# 记账真落 pg:world 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:
|
||||
# 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(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user