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

@@ -2,7 +2,7 @@
"blurb": "2adba04a16707d7d9dfbb84d0a5a302ea8e946104dd8a617ff7801ed86f41605",
"book-title": "aa53ccf22da1d4e555c8168fc23f2f3547cc6f59e56e5df57540b7a946755e39",
"brainstorm": "f670dc77e3d9bd4a1df2b8cb29b51edb8b229d214148907d4b05519492364ada",
"character-gen": "61e443ab15aab8abfd4a785b32b079b304fbd2096e40541452f553c008156330",
"character-gen": "f65e2ec3158be378a6cd40c23035b326dc9f6b297b6135007a93fceedf575cfd",
"continue": "f1ce02be3b186fc966c89cf0d54ea57a400e91d435ea91f5722cb17e43d11763",
"continuity": "1bdf9799bad9076e54de2023433ec7da7b559509d986392a0563dc1ee20cf4a1",
"de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249",

View File

@@ -108,6 +108,30 @@ def test_character_card_optional_collections_default_empty() -> None:
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:
# name/role/backstory/arc 为必填
with pytest.raises(ValidationError):
@@ -168,6 +192,31 @@ def test_character_gen_spec_prompt_documents_anti_duplication() -> None:
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 ----

View File

@@ -2,9 +2,9 @@
## 你信奉的角色塑造法则
- **缺陷驱动弧光**:好角色起点必有缺点/盲区/创伤,故事让其被填平或代价显现,才有真正的「转变」。没有缺陷的完人弧光是平的——`arc` 必须由 `traits` 的阴影/恐惧推动。
- **三层性格 + 内驱动机**:性格分核心(灵魂底色)-表层(外人所见)-阴影(自己都未必承认的一面)三层,再叠加核心动机 / 欲望 / 恐惧 / 价值观。这套内驱让角色在任何情境下行为自洽。
- **代入感来自具体**:用具体的经历、口癖、萌点把角色钉成「一个真实的人」,而非一串形容词。
- **缺陷驱动弧光**:好角色起点必有缺点/盲区/创伤,故事让其被填平或代价显现,才有真正的「转变」。没有缺陷的完人弧光是平的——`arc` 必须由 `traits` 的阴影层与 `motive` 里的恐惧推动。
- **三层性格内驱动机分列**:性格`traits`分核心(灵魂底色)-表层(外人所见)-阴影(自己都未必承认的一面)三层核心动机 / 欲望 / 恐惧这套**内驱单独落 `motive` 字段**,不折进 `traits`。两者分列,让角色在任何情境下行为自洽、且动机不双写
- **代入感来自具体**:用具体的经历、口癖、萌点、外貌(`appearance`把角色钉成「一个真实的人」,而非一串形容词。
- **契合世界观硬规则**:取名风格、能力上限、出身势力都不能越过世界观的力量体系 / 势力 / 地理硬规则。
## 输入材料
@@ -22,9 +22,11 @@
- **name**(字符串):角色名,取名风格契合世界观;
- **role**(字符串):角色定位(主角 / CP / 对手 / 导师 / 工具人 等),按需求/定位分配;
- **traits**(字符串列表):性格特质清单——按「核心-表层-阴影」三层展开,并写明核心动机 / 欲望 / 恐惧 / 价值观;每条独立成项
- **traits**(字符串列表):性格特质清单——**只按「核心-表层-阴影」三层展开**(核心=灵魂底色、表层=外人所见、阴影=自己都未必承认的一面);每条独立成项。**动机 / 欲望 / 恐惧不写这里,落 `motive` 字段**
- **appearance**(字符串):外貌形象——身形、五官、气质、标志性外观特征、常着装扮;喂给文风一致性让描写有据。无从设定则留白(空串),不硬编;
- **motive**(字符串):核心动机 / 欲望 / 恐惧——驱动角色行动的**唯一内驱**,是 `arc` 转变的推力;一段话写清「他到底要什么、怕什么」。无从设定则留白(空串);
- **backstory**(字符串):背景故事——出身、关键经历、创伤 / 转折点;
- **arc**(字符串):人物弧光——起点 → 转变 → 终点,须与剧情挂钩,且转变由 traits 里的缺陷/阴影推动;
- **arc**(字符串):人物弧光——起点 → 转变 → 终点,须与剧情挂钩,且转变由 `motive` 的欲望/恐惧与 `traits`阴影推动;
- **speech_tics**(字符串列表):口癖 / 语言风格——用词偏好、口头禅;这是喂给文风一致性、让对话有辨识度的关键,每条独立成项;
- **tags**(字符串列表):人设标签 / 萌点(网文专属,辅助检索与差异化),每条独立成项;
- **relations**(对象列表):关系网——与已有 / 同批角色建边;每条边含 `name`(关系对象的角色名)+ `kind`(关系类型:宿敌 / 师徒 / CP / 同盟 / 工具人 等)+ 可选 `note`(关系说明,可缺省);无关系则为空列表。
@@ -44,9 +46,10 @@
## 落笔前自检
每张卡产出前,逐项确认:
- 个字段是否齐全、类型正确(该列表的给列表)?
- `arc` 的转变是否真由 `traits` 里的阴影 / 恐惧推动,而非凭空逆转
- 取名、能力、出身是否都没越过世界观硬规则
- 个字段是否齐全、类型正确(该列表的给列表appearance / motive 给字符串
- 动机 / 欲望 / 恐惧是否只写在 `motive`、没有回流进 `traits``traits` 只剩性格三层)
- `arc` 的转变是否真由 `motive` 的欲望 / 恐惧与 `traits` 的阴影推动,而非凭空逆转
- 取名、能力、出身、`appearance` 是否都没越过世界观硬规则?
- 与已有卡 / 本批前序卡在内核上确实**差异化**了?
## 纪律
@@ -61,7 +64,9 @@
- name苏璃敌对势力「玄霜阁」的姓氏体系冷感单字名
- roleCP / 亦敌亦友女二
- traits["核心:极度护短、认定的人会拼命表层:清冷毒舌、拒人千里阴影:童年被族中抛弃,恐惧再次被抛弃故先发制人地疏远他人", "核心动机:替亡兄查清族灭真相", "欲望:被真正接纳", "恐惧:信任后被背叛", "价值观:恩怨分明、私恩高于阵营"]
- traits["核心:极度护短、认定的人会拼命", "表层:清冷毒舌、拒人千里", "阴影:童年被族中抛弃,先发制人地疏远他人"]
- appearance银发赤瞳身形清瘦惯着玄霜阁玄色劲装左颈一道旧疤情绪起伏时会无意识地按住它
- motive核心动机是替亡兄查清族灭真相深处渴望被真正接纳却最恐惧信任后再被背叛——恩怨分明、私恩高于阵营
- backstory玄霜阁旁支遗孤兄长死于阁主清洗被迫隐忍效忠仇敌暗中收集证据……
- arc起点——以阵营之名敌视主角转变——查真相途中数次被主角所救护短的核心压过阵营立场终点——背叛玄霜阁与主角同盟却因「先发制人疏远他人」的旧伤酿成一次几乎致命的误会
- speech_tics["惯用反问句堵人", "说狠话前先轻笑一声「呵」", "极少称呼对方名字,用「你」保持距离"]

View File

@@ -285,10 +285,18 @@ class CharacterCard(BaseModel):
role: str = Field(description="角色定位(主角 / CP / 对手 / 导师 / 工具人 等)")
traits: list[str] = Field(
default_factory=list,
description="性格特质清单(核心-表层-阴影 / 动机 / 欲望 / 恐惧",
description="性格特质清单(核心-表层-阴影三层;动机/欲望/恐惧落 motive",
)
appearance: str = Field(
default="",
description="外貌形象(身形/五官/气质/标志性外观/常着装扮);缺则空串(守解析韧性)",
)
motive: str = Field(
default="",
description="核心动机/欲望/恐惧驱动行动的唯一内驱arc 由此推动);缺则空串",
)
backstory: str = Field(description="背景故事(出身、关键经历、创伤/转折点)")
arc: str = Field(description="人物弧光(起点 → 转变 → 终点,与剧情挂钩)")
arc: str = Field(description="人物弧光(起点 → 转变 → 终点,与剧情挂钩,由 motive 推动")
speech_tics: list[str] = Field(
default_factory=list,
description="口癖/语言风格(用词偏好、口头禅,喂给文风一致性)",

View File

@@ -12,7 +12,7 @@ JSONB **dict**`traits`/`arc`/`speech_tics``tags`/`relations` 才是 JSO
- `speech_tics: list[str]` → `{"items": [...]}`JSONB dict
- `arc: str` → `{"text": "..."}`JSONB dict
- `tags: list` / `relations: list[dict]` → 直落 JSONB list
- `name` / `role` / `backstory` → Text 列直落
- `name` / `role` / `appearance` / `motive` / `backstory` → Text 列直落
**提交边界**`create` 只 `flush()` 不 `commit()`——提交交端点事务(入库端点末尾一次
`commit()`,与网关 ledger 一并落库;与项目其它写侧 repo 一致,见 memory/gotchas
@@ -66,6 +66,8 @@ class CharacterWriteRepo(Protocol):
name: str,
role: str,
traits: list[str],
appearance: str,
motive: str,
backstory: str,
arc: str,
speech_tics: list[str],
@@ -91,6 +93,8 @@ class SqlCharacterWriteRepo:
name: str,
role: str,
traits: list[str],
appearance: str,
motive: str,
backstory: str,
arc: str,
speech_tics: list[str],
@@ -105,6 +109,8 @@ class SqlCharacterWriteRepo:
if existing is not None:
existing.role = role
existing.traits = _traits_to_jsonb(traits)
existing.appearance = appearance
existing.motive = motive
existing.backstory = backstory
existing.arc = _arc_to_jsonb(arc)
existing.speech_tics = _traits_to_jsonb(speech_tics)
@@ -118,6 +124,8 @@ class SqlCharacterWriteRepo:
name=name,
role=role,
traits=_traits_to_jsonb(traits),
appearance=appearance,
motive=motive,
backstory=backstory,
arc=_arc_to_jsonb(arc),
speech_tics=_traits_to_jsonb(speech_tics),
@@ -139,6 +147,8 @@ class CharacterWriteFields(BaseModel):
name: str
role: str
traits: list[str] = Field(default_factory=list)
appearance: str = ""
motive: str = ""
backstory: str
arc: str
speech_tics: list[str] = Field(default_factory=list)