muse-agent-example/tests/单元/test_事实时点与有效性.py
zizi 2f15c5b3bc W12 本书事实、关系与时间边界基础:本书事实、关系提案、时点边界与角色知情历史。
按 R2 串行阶段整理提交;包内文件为该阶段交付(含后续小增量),状态以工作包清单为准。
2026-09-10 19:25:41 +08:00

332 lines
12 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.

"""逐条承接引文行为;不执行旧Skill脚本、不规范化正文。"""
from dataclasses import asdict, replace
import pytest
from pydantic import TypeAdapter, ValidationError
from muse.元数据.接口 import (
值规则,
元数据错误,
合成结构,
字段定义,
引用规则,
类型定义,
结构定义,
读取内置种子,
)
from muse.故事世界.接口 import (
事实条目,
事实错误,
保留名称提及,
叙事窗口,
定位字面引文,
引用对象,
校验事实批次,
核对原文引文,
解析类型选择,
)
from muse.正文写作.接口 import 换行节点, 文本节点, 正文草稿, 段落
原增量正文 = "林深把黑纹缠上手臂,异种核心在胸腔里低鸣。何岚站在舱门口没有说话。"
原抽取正文 = "林深走进舰桥,何岚把黑色钥匙交给他。"
def 单段(内容):
return 正文草稿((段落("p1", (文本节点(内容),)),))
def test_quote_not_found_fails_closed__3554e5():
with pytest.raises(事实错误) as 错误:
定位字面引文(单段(原增量正文), "正文里不存在的句子")
assert 错误.value.错误码 == "FACT_QUOTE_NOT_FOUND"
def test_quote_is_normalized_before_matching__505864():
# 保留原LC-e3eca2942895样本;格式1不允许把字面反斜杠改成真正换行。
escaped_body = 原增量正文 + "\\n舱灯闪了一下"
quote = "\n舱灯闪了一下"
with pytest.raises(事实错误) as 错误:
定位字面引文(单段(escaped_body), quote)
assert 错误.value.错误码 == "FACT_QUOTE_NOT_FOUND"
assert escaped_body.endswith("\\n舱灯闪了一下")
原文 = 正文草稿((段落("p1", (文本节点(原增量正文), 换行节点(), 文本节点("舱灯闪了一下"))),))
证据 = 定位字面引文(原文, quote)
assert 核对原文引文(原文, 证据) == quote
assert 证据.start == len(原增量正文) and 证据.end == len(原增量正文 + quote)
def test_normalize_rejects_fabricated_evidence__9e1858():
原输入 = {
"type": "item",
"name": "黑钥匙",
"brief": "钥匙",
"fields": {},
"evidence": "正文没有这句",
}
with pytest.raises(事实错误) as 错误:
定位字面引文(单段(原抽取正文), 原输入["evidence"])
assert 错误.value.错误码 == "FACT_QUOTE_NOT_FOUND"
def test_normalize_binds_evidence_and_aliases__1b9888():
原输入 = {
"type": "人物",
"name": "林深",
"brief": "机师",
"fields": {},
"evidence": "林深走进舰桥",
}
种子 = next(s for s in 读取内置种子() if s.类型.type_id == "character")
# 此旧场景中的中文类型名在输入目录登记;解析代码没有固定中文别名表。
类型 = replace(种子.类型, 名称="人物")
选中 = 解析类型选择(原输入["type"], (类型,))
正文 = 单段(原抽取正文)
提案 = 事实条目(
"proposal-lin",
"character-lin",
选中.type_id,
{"名称": 原输入["name"], "一句话摘要": 原输入["brief"], **原输入["fields"]},
(定位字面引文(正文, 原输入["evidence"]),),
)
结果 = 校验事实批次(
(提案,),
作品ID="synthetic-work",
结构目录={"character": (类型, 合成结构(种子.结构))},
正文=正文,
可用对象={},
)
assert 结果[0].type_id == "character"
assert 结果[0].evidence[0].quote in 原抽取正文
assert 结果[0].content["名称"] == "林深"
旧增量样本 = {
"character_location_changed": (
{"characterName": "林深", "toLocation": "舱室底层"},
"异种核心在胸腔里低鸣",
),
"character_knowledge_added": (
{"characterName": "何岚", "knowledge": "林深体内有异种核心"},
"何岚站在舱门口没有说话",
),
"relationship_changed": (
{"fromName": "林深", "toName": "何岚", "relation": "互相提防"},
"何岚站在舱门口没有说话",
),
"hook_advanced": (
{
"hookId": "hook-voice",
"action": "advanced",
"dueWindow": {"fromChapter": 5, "toChapter": 8},
},
"异种核心在胸腔里低鸣",
),
"timeline_event_added": ({"event": "隔离舱首次审讯结束"}, "何岚站在舱门口没有说话"),
"setting_added": ({"factType": "污染规则", "text": "黑纹扩散不可逆"}, "林深把黑纹缠上手臂"),
}
名称映射 = {
"林深": "character-lin",
"何岚": "character-he",
"舱室底层": "location-bottom",
"指挥舱": "location-command",
"hook-voice": "hook-voice",
"h1": "hook-one",
}
@pytest.fixture
def 旧增量环境():
# 六行保留旧输入种类;它们仅是本用例的动态登记,不是生产类型白名单。
字符 = 值规则("text")
def 引用(type_id):
return 值规则("reference", 引用=引用规则((type_id,)))
角色, 地点, 伏笔 = 引用("character"), 引用("location"), 引用("foreshadowing")
字段表 = {
"character_location_changed": [
("角色", "characterName", 角色),
("目标地点", "toLocation", 地点),
],
"character_knowledge_added": [
("角色", "characterName", 角色),
("新增认知", "knowledge", 字符),
],
"relationship_changed": [
("甲方", "fromName", 角色),
("乙方", "toName", 角色),
("关系", "relation", 字符),
],
"hook_advanced": [
("伏笔", "hookId", 伏笔),
("动作", "action", 值规则("enum", 枚举=("planted", "advanced", "paid"))),
],
"timeline_event_added": [("事件", "event", 字符)],
"setting_added": [("设定类型", "factType", 字符), ("内容", "text", 字符)],
}
结构目录 = {}
for id_, fields in 字段表.items():
家族 = "relation" if id_ == "relationship_changed" else "entity"
类型 = 类型定义(id_, id_, "world", 家族, (家族,))
结构 = 结构定义(
id_,
id_,
1,
tuple(
字段定义(id_ + "." + key, key, key, rule, 必填=True, 别名=(alias,))
for key, alias, rule in fields
),
)
结构目录[id_] = (类型, 合成结构(结构))
对象 = {
id_: 引用对象(
id_,
"synthetic-work",
"character"
if id_.startswith("character-")
else "location"
if id_.startswith("location-")
else "foreshadowing",
)
for id_ in 名称映射.values()
}
return {
"作品ID": "synthetic-work",
"结构目录": 结构目录,
"正文": 单段(原增量正文),
"可用对象": 对象,
"章节顺序": {f"chapter-{i}": i for i in range(1, 13)},
}
def 旧样本提案(
类型名="character_location_changed", *, payload=None, quote=None, delta_id="delta-1"
):
原字段, 原引文 = 旧增量样本.get(类型名, 旧增量样本["character_location_changed"])
内容 = dict(原字段 if payload is None else payload)
窗口 = 内容.pop("dueWindow", None)
# 已知旧名称在输入边界转换为固定对象ID;生产事实不由显示名拼接身份。
for key in ("characterName", "fromName", "toName", "toLocation", "hookId"):
if key in 内容:
内容[key] = 名称映射[内容[key]]
return 事实条目(
delta_id,
内容.get("hookId", "object-" + delta_id),
类型名,
内容,
(定位字面引文(单段(原增量正文), 原引文 if quote is None else quote),),
instance_family="relation" if 类型名 == "relationship_changed" else "entity",
planned_window=叙事窗口(f"chapter-{窗口['fromChapter']}", f"chapter-{窗口['toChapter']}")
if 窗口
else None,
)
@pytest.mark.parametrize("delta_type", tuple(旧增量样本), ids=tuple(旧增量样本))
def test_each_delta_type_has_working_contract__d0e17d(旧增量环境, delta_type):
原字段, 引文 = 旧增量样本[delta_type]
提案 = 旧样本提案(delta_type, payload=原字段, quote=引文)
结果 = 校验事实批次((提案,), **旧增量环境)
assert 结果[0].type_id == delta_type and 结果[0].object_id
assert 结果[0].evidence[0].quote == 引文
def test_subject_key_derivation__e42f4b(旧增量环境):
提案 = 旧样本提案(
"relationship_changed",
payload={"fromName": "林深", "toName": "何岚", "relation": "同盟"},
quote="何岚站在舱门口没有说话",
)
结果 = 校验事实批次((提案,), **旧增量环境)[0]
assert (结果.content["甲方"], 结果.content["乙方"]) == ("character-lin", "character-he")
hook = 旧样本提案(
"hook_advanced",
payload={"hookId": "hook-voice", "action": "planted"},
quote="异种核心在胸腔里低鸣",
)
assert 校验事实批次((hook,), **旧增量环境)[0].object_id == "hook-voice"
def test_closed_field_set_and_type_enum__42dd3b(旧增量环境):
extra = asdict(旧样本提案())
extra["extraField"] = 1
with pytest.raises(ValidationError):
TypeAdapter(事实条目).validate_python(extra)
bad_type = 旧样本提案("character_resurrected")
with pytest.raises(事实错误) as 错误:
校验事实批次((bad_type,), **旧增量环境)
assert 错误.value.错误码 == "FACT_TYPE_UNKNOWN"
def test_payload_contract_enforced_per_type__a30962(旧增量环境):
missing = 旧样本提案(payload={"characterName": "林深"})
with pytest.raises(元数据错误):
校验事实批次((missing,), **旧增量环境)
action = 旧样本提案(
"hook_advanced",
payload={"hookId": "h1", "action": "detonated"},
quote="异种核心在胸腔里低鸣",
)
with pytest.raises(元数据错误):
校验事实批次((action,), **旧增量环境)
window = 旧样本提案(
"hook_advanced",
payload={
"hookId": "h1",
"action": "planted",
"dueWindow": {"fromChapter": 9, "toChapter": 3},
},
quote="异种核心在胸腔里低鸣",
)
with pytest.raises(事实错误) as 错误:
校验事实批次((window,), **旧增量环境)
assert 错误.value.错误码 == "FACT_TIME_INVALID"
def test_batch_rejects_duplicate_ids__b430df(旧增量环境):
首 = 旧样本提案(delta_id="delta-1")
后 = 旧样本提案(delta_id="delta-1", payload={"characterName": "何岚", "toLocation": "指挥舱"})
with pytest.raises(事实错误) as 错误:
校验事实批次((首, 后), **旧增量环境)
assert 错误.value.错误码 == "FACT_DUPLICATE_ID"
assert [
x.proposal_id for x in 校验事实批次((首, replace(后, proposal_id="delta-2")), **旧增量环境)
] == ["delta-1", "delta-2"]
def test_salvage_drops_unbound_relation_and_keeps_exact_name_evidence__8101bb():
原输出 = {
"entities": [
{
"type": "item",
"name": "黑色钥匙",
"brief": "钥匙",
"fields": {},
"evidence": "模型编造",
},
{
"type": "item",
"name": "不存在的物件",
"brief": "?",
"fields": {},
"evidence": "模型编造",
},
],
"relations": [
{
"source": "林深",
"target": "何岚",
"type": "伙伴",
"description": "?",
"evidence": "模型编造",
}
],
"state": {},
}
结果 = 保留名称提及(原输出, 单段(原抽取正文), tuple(s.类型 for s in 读取内置种子()))
assert [x["name"] for x in 结果["mentions"]] == ["黑色钥匙"]
assert 结果["mentions"][0]["evidence"].quote == "黑色钥匙"
assert 结果["relations"] == [] and 结果["mechanical_drops"] == {"entities": 1, "relations": 1}
assert "brief" not in 结果["mentions"][0] and 结果["state"] == "needs_confirmation"