Files
writer-work-flow/packages/agents/tests/test_generation_specs.py
Yaojia Wang acde715515 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 集成。
2026-07-06 14:36:11 +02:00

233 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""T5.1 worldbuilder + character-gen 契约测试spec + schemaC6 扩 / ARCH §5.4 §6.5)。
契约测试——构造符合 schema 的 mock 响应校验字段、tier、读写权限。
worldbuilder`{entities:[{type,name,rules}]}` 硬规则显式写手档reads=projects
character-gen`{cards:[{name,role,traits,backstory,arc,speech_tics,tags,relations}]}`
写手档reads=world_entities+characterswrites=characters。不联网、无 DB。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from ww_agents import (
AgentSpec,
CharacterCard,
CharacterGenResult,
CharacterRelation,
WorldEntityCard,
WorldGenResult,
character_gen_spec,
worldbuilder_spec,
)
# ---- WorldGenResult schemaworldbuilder----
def test_world_gen_parses_mock_response() -> None:
# Arrange模拟网关 instructor 校验后的结构化世界观产出(硬规则显式)
mock = {
"entities": [
{
"type": "力量体系",
"name": "九转炼气",
"rules": ["只能逐境突破,不可越级", "突破必有反噬代价"],
},
{"type": "地理", "name": "无雨城", "rules": ["终年无雨"]},
]
}
# Act
result = WorldGenResult.model_validate(mock)
# Assert
assert len(result.entities) == 2
assert result.entities[0].type == "力量体系"
assert result.entities[0].name == "九转炼气"
assert result.entities[0].rules == ["只能逐境突破,不可越级", "突破必有反噬代价"]
assert result.entities[1].rules == ["终年无雨"]
def test_world_gen_defaults_to_empty_entities() -> None:
assert WorldGenResult().entities == []
def test_world_entity_rules_default_empty() -> None:
card = WorldEntityCard.model_validate({"type": "势力", "name": "天剑宗"})
assert card.rules == []
def test_world_entity_requires_type_and_name() -> None:
with pytest.raises(ValidationError):
WorldEntityCard.model_validate({"type": "势力"})
# ---- CharacterGenResult schemacharacter-gen----
def test_character_gen_parses_mock_response() -> None:
mock = {
"cards": [
{
"name": "苏黎",
"role": "对手",
"traits": ["表面冷漠", "内心挣扎"],
"backstory": "出身敌对势力,幼年遭灭门。",
"arc": "复仇者 → 动摇 → 救赎",
"speech_tics": ["惯用反问", "句末带『罢了』"],
"tags": ["亦正亦邪", "宿命纠葛"],
"relations": [{"name": "主角", "kind": "宿敌", "note": "灭门之仇"}],
}
]
}
result = CharacterGenResult.model_validate(mock)
assert len(result.cards) == 1
card = result.cards[0]
assert card.name == "苏黎"
assert card.role == "对手"
assert card.traits == ["表面冷漠", "内心挣扎"]
assert card.speech_tics == ["惯用反问", "句末带『罢了』"]
assert card.relations[0].name == "主角"
assert card.relations[0].kind == "宿敌"
assert card.relations[0].note == "灭门之仇"
def test_character_gen_defaults_to_empty_cards() -> None:
assert CharacterGenResult().cards == []
def test_character_card_optional_collections_default_empty() -> None:
card = CharacterCard.model_validate(
{"name": "", "role": "工具人", "backstory": "无名小卒", "arc": "始终如一"}
)
assert card.traits == []
assert card.speech_tics == []
assert card.tags == []
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):
CharacterCard.model_validate({"name": "", "role": "对手"})
def test_character_relation_note_optional() -> None:
rel = CharacterRelation.model_validate({"name": "师父", "kind": "师徒"})
assert rel.note is None
# ---- worldbuilder_spec 声明 ----
def test_worldbuilder_spec_is_writer_tier() -> None:
# 不变量 #2世界观创意用写手档只声明 tier
assert worldbuilder_spec.tier == "writer"
assert worldbuilder_spec.name == "worldbuilder"
def test_worldbuilder_spec_reads_projects_writes_world_entities() -> None:
assert worldbuilder_spec.reads == ("projects",)
assert worldbuilder_spec.writes == ("world_entities",)
def test_worldbuilder_spec_output_schema() -> None:
assert worldbuilder_spec.output_schema is WorldGenResult
def test_worldbuilder_spec_prompt_documents_hard_rules() -> None:
# 硬规则须显式(供 continuity 校验引用——system_prompt 必须强调
assert "硬规则" in worldbuilder_spec.system_prompt
# ---- character_gen_spec 声明 ----
def test_character_gen_spec_is_writer_tier() -> None:
assert character_gen_spec.tier == "writer"
assert character_gen_spec.name == "character-gen"
def test_character_gen_spec_reads_world_and_characters() -> None:
# reads=world_entities+characters约束 + 防雷同对照writes=characters
assert character_gen_spec.reads == ("world_entities", "characters")
assert character_gen_spec.writes == ("characters",)
def test_character_gen_spec_output_schema() -> None:
assert character_gen_spec.output_schema is CharacterGenResult
def test_character_gen_spec_prompt_documents_anti_duplication() -> None:
# M5-a 群像防雷同prompt 必须注入「已生成卡 + 已有角色」差异化要求
prompt = character_gen_spec.system_prompt
assert "已有角色" in prompt
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 ----
def test_generation_specs_are_agent_specs() -> None:
assert isinstance(worldbuilder_spec, AgentSpec)
assert isinstance(character_gen_spec, AgentSpec)
def test_generation_specs_are_immutable() -> None:
with pytest.raises(ValidationError):
worldbuilder_spec.tier = "analyst"
with pytest.raises(ValidationError):
character_gen_spec.tier = "analyst"