muse-agent-example/.claude/skills/parse-book/scripts/test_parse_upgrade_offline.py

213 lines
13 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.

#!/usr/bin/env python3
"""parse_upgrade 升格拆卡执行器·纯逻辑离线自测(升格卡改造 3 洞补齐)。
红线:**不连库、不发任何网络/嵌入/LLM 调用**——只覆盖纯函数与提示词构造器:
· 机械判重分类 _classify_new_name(各分支)
· 登场兜底 _debut_milestone(有/无登场、无出场章、摘要超长)
· 里程碑台阶守卫 _clean_milestone(超 80 字截断)
· 归并材料 _merge_material(演变历程完整不截、其余字段截断)
· embed_drafts.build_embed_text 对 type 键的型修正
· observe/update/relation 三个提示词含关键纪律语句
连库/联网路径(prejudge_semantic / recall_neighbors / semantic_dedup / embed_touched_cards)
本测试**不触碰**——它们需真连接,属放量前零落库小样阶段的活体验收,不在离线自测范围。
跑法:仓根 `.venv/bin/python .claude/skills/parse-book/scripts/test_parse_upgrade_offline.py`
"""
import pathlib
import sys
# 与 parse_upgrade 同目录:直接 import 触发其 sys.path 装配(含 embed/llm scripts),随后可导 embed_drafts
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import parse_upgrade as pu # noqa: E402
import embed_drafts # noqa: E402 (型修正验证在 embed skill 本体)
_passed = 0
def check(name, cond, detail=""):
"""单项断言:通过打 [PASS],失败抛 AssertionError(带上下文,令 CI/人工一眼定位)。"""
global _passed
assert cond, f"[FAIL] {name} :: {detail}"
_passed += 1
print(f"[PASS] {name}")
# ── ① 机械判重分类 _classify_new_name(洞①:预判段与写段共用判据)──
def test_classify_new_name():
name_map = {
"张三": (10, "character", "主角"),
"生物机甲": (20, "power_system", "机甲体系"),
"开心果子": (30, "item", "道具"),
}
presence = {("character", "李四"): {5, 12}} # 李四靠留档跨章
check("classify-empty",
pu._classify_new_name({"名称": " ", "型": "character"}, name_map, presence) == ("empty", None))
check("classify-merge(在册直接归并)",
pu._classify_new_name({"名称": "张三", "型": "character"}, name_map, presence) == ("merge", "张三"))
check("classify-alias(疑似别名指向在册)",
pu._classify_new_name({"名称": "阿三", "型": "character", "疑似别名指向": "张三"},
name_map, presence) == ("alias", "张三"))
check("classify-substr(同型互为子串)",
pu._classify_new_name({"名称": "果子", "型": "item", "出场章": [1]},
name_map, presence) == ("substr", "开心果子"))
# 子串判据须同型:果子(character) 不应命中 开心果子(item),单章 → presence
check("classify-substr跨型不命中",
pu._classify_new_name({"名称": "果子", "型": "character", "出场章": [1]},
name_map, presence) == ("presence", None))
check("classify-new(本窗跨章立卡)",
pu._classify_new_name({"名称": "王五", "型": "character", "出场章": [3, 8]},
name_map, presence) == ("new", None))
check("classify-new(留档补足跨章)",
pu._classify_new_name({"名称": "李四", "型": "character", "出场章": [20]},
name_map, presence) == ("new", None))
check("classify-presence(单章龙套留档)",
pu._classify_new_name({"名称": "路人甲", "型": "character", "出场章": [7]},
name_map, presence) == ("presence", None))
# ── ② 登场兜底 _debut_milestone(洞②机械那一保险)──
def test_debut_milestone():
# 已有登场 → 原样返回,不重复补
has = [{"章": 10, "台阶": "训练机登场", "周期": "登场"}, {"章": 50, "台阶": "进化", "周期": "成长"}]
out = pu._debut_milestone(has, "摘要", [8], 3)
check("debut-已有登场不补", len(out) == 2 and out[0]["台阶"] == "训练机登场")
# 无登场 + 有出场章 → 头部补登场,章=min(出场章),_win 盖窗号
no_debut = [{"章": 50, "台阶": "V代编队", "周期": "成长"}]
out = pu._debut_milestone(no_debut, "4级训练机", [15, 3, 8], 7)
check("debut-无登场则补一条",
len(out) == 2 and out[0]["周期"] == "登场" and out[0]["章"] == 3 and out[0]["_win"] == 7)
check("debut-台阶取摘要", out[0]["台阶"].startswith("登场:4级训练机"))
check("debut-进化台阶仍在尾部", out[1]["台阶"] == "V代编队")
# 无出场章 → 省略「章」键(诚实边界:不编造章号)
out = pu._debut_milestone([], "无章摘要", [], 5)
check("debut-无出场章省略章键", len(out) == 1 and "章" not in out[0] and out[0]["周期"] == "登场")
# milestones=None 也当空处理
out = pu._debut_milestone(None, "空列表摘要", [2], 5)
check("debut-None当空补登场", len(out) == 1 and out[0]["周期"] == "登场" and out[0]["章"] == 2)
# 摘要超长 → 台阶截 40 字摘要 + 过 STEP_MAX 守卫(总长 ≤ STEP_MAX)
out = pu._debut_milestone([], "甲" * 100, [2], 9)
step = out[0]["台阶"]
check("debut-摘要超长台阶受守卫", len(step) <= pu.STEP_MAX and step.startswith("登场:") and "甲" * 40 in step)
check("debut-摘要只取前40字", "甲" * 41 not in step)
# ── ③ 里程碑台阶守卫 _clean_milestone(洞③:超 80 字截断)──
def test_clean_milestone_guard():
# 对象入参:台阶 90 字 → 截到 80
m = pu._clean_milestone({"章": 100, "台阶": "阶" * 90, "周期": "成长"}, 1)
check("clean-对象台阶截至80", m is not None and len(m["台阶"]) == 80 and m["台阶"] == "阶" * 80)
check("clean-对象保真章周期", m["章"] == 100 and m["周期"] == "成长" and m["_win"] == 1)
# 字符串入参:整串当台阶,同样受守卫
m = pu._clean_milestone("步" * 120, 2)
check("clean-字符串台阶截至80", m is not None and len(m["台阶"]) == 80)
# 正常短台阶不动
m = pu._clean_milestone({"章": 5, "台阶": "短台阶", "周期": "高光"}, 3)
check("clean-短台阶不截", m["台阶"] == "短台阶" and len(m["台阶"]) == 3)
# 截断顺序回归(主代理核验修复):跑飞长文缺章缺周期时,藏在 80 字外的内嵌章号/周期关键词
# 必须先用**全文**抽取/推断、抽完再截——若先截后抽,兜底线索被截丢(章=None、周期误判成长)。
m = pu._clean_milestone({"台阶": "阶" * 85 + "突破至V代(见第432章)"}, 4)
check("clean-截断前先抽全文章号", m is not None and m["章"] == 432)
check("clean-截断前先推全文周期", m["周期"] == "高光")
check("clean-抽后仍截至80", len(m["台阶"]) == 80)
# ── ④ 归并材料 _merge_material(洞②:演变历程完整不截、其余字段截断)──
def test_merge_material():
milestones = [{"章": i, "台阶": f"台阶第{i}条", "周期": "成长"} for i in range(1, 21)]
ent = {"型": "item", "名称": "影杀者", "一句话摘要": "IV代机甲",
"字段": {"演变历程": milestones, "能力与限制": "填充" * 500}} # 其余字段 1000 字
mat = pu._merge_material(ent, rest_limit=600)
# 演变历程每条台阶完整保留(尤其末条不被截断丢失)
check("material-演变历程首条在", "台阶第1条" in mat)
check("material-演变历程末条在", "台阶第20条" in mat)
check("material-摘要在", "IV代机甲" in mat and "影杀者" in mat)
# 其余字段被截断(1000 字巨型字段不应整段进材料)
check("material-其余字段被截断", ("填充" * 500) not in mat)
# 无字段时不崩
check("material-空字段不崩", pu._merge_material({"型": "character", "名称": "甲", "一句话摘要": "s"}))
# ── ⑤ build_embed_text 型键修正(洞①配套;改在 embed skill 本体)──
def test_build_embed_text_type_fix():
# 升格卡用 type 键存型:修正后应认出(修正前会产「【】名称…」丢型)
txt = embed_drafts.build_embed_text({"type": "power_system", "名称": "生物机甲",
"一句话摘要": "体系", "字段": {"境界阶梯": "一代<二代"}})
check("embed-type键认出型", txt.startswith("【power_system】生物机甲"))
check("embed-非空型不再丢", not txt.startswith("【】"))
# 型 键仍优先(既有行为不动)
check("embed-型键优先",
embed_drafts.build_embed_text({"型": "item", "名称": "剑"}).startswith("【item】"))
# target_type 回退仍在
check("embed-target_type回退",
embed_drafts.build_embed_text({"target_type": "faction", "名称": "军团"}).startswith("【faction】"))
# 三者优先级:型 > type > target_type
check("embed-型优先于type与target_type",
embed_drafts.build_embed_text({"型": "A", "type": "B", "target_type": "C",
"名称": "x"}).startswith("【A】"))
def _fake_contracts():
"""离线假合同(覆盖六实体型 + 关系型),供三个提示词构造器渲染,不连库。"""
c = {}
for t in pu.ENTITY_TYPES + (pu.RELATION_TYPE,):
c[t] = {"中文名": t, "判据": f"{t}判据",
"字段": [{"key": "演变历程", "说明": "里程碑数组"},
{"key": "一句话摘要", "说明": "摘要"}]}
return c
# ── ⑦ 出场章归一化 _int_chaps(窗113 实证 bug:int/str 混排炸 + 并集虚增计数)──
def test_int_chaps():
# 核心复现:模型给字符串章号 "508" 与库内 int 507 混合,旧代码 sorted 直接炸
mixed = [507, "508", 509]
got = pu._int_chaps(mixed)
check("intchaps-混类型归一为int", got == {507, 508, 509})
check("intchaps-归一后可安全排序", sorted(got) == [507, 508, 509])
# 并集不再虚增:{"508"} 与 {508} 旧代码算 2 个(误判跨章立卡),归一后算 1 个
union = pu._int_chaps(["508"]) | {508}
check("intchaps-并集不虚增计数", union == {508} and len(union) == 1)
# 脏值/区间/bool 丢弃(出场章只应是单章整数)
check("intchaps-丢非数字与区间", pu._int_chaps(["420-423", "abc", None, 12]) == {12})
check("intchaps-排除bool", pu._int_chaps([True, False, 5]) == {5})
check("intchaps-空输入", pu._int_chaps([]) == set() and pu._int_chaps(None) == set())
# ── ⑥ 三个提示词含关键纪律语句(洞②登场必须 / 洞③≤40字 + 体系级 / 洞②归并去重)──
def test_prompts_disciplines():
c = _fake_contracts()
obs = pu.observe_prompt(c, "测试书", 1, 12, "正文占位", {"张三": (10, "character", "主角")})
check("observe-登场必须硬约束", "必须给「演变历程」的首条登场里程碑" in obs)
check("observe-台阶≤40字", "≤40 字" in obs)
check("observe-体系级纪律", "体系级纪律" in obs)
upd = pu.update_prompt(c, "测试书", 1, 12, "正文占位",
[(10, {"type": "power_system", "名称": "生物机甲"}, ["观察点"])])
check("update-台阶≤40字", "≤40 字" in upd)
check("update-体系级纪律", "体系级纪律" in upd)
check("update-登场归并去重", "若本卡演变历程已有登场里程碑则不再重复追加" in upd)
rel = pu.relation_prompt(c, "测试书", 1, 12, "正文占位",
[(10, {"名称": "张三"}), (11, {"名称": "李四"})], [])
check("relation-构造器可渲染", "人物关系增量" in rel)
# 判重终判 prompt(小样校准后):近邻六元组带字段摘选进证据 + 名称互含判据
nbs = [(6223, "power_system", "生物机甲", "机甲体系", '{"境界阶梯":"IV代→V代"}', 0.66)]
jp = pu.dedup_judge_prompt({"型": "power_system", "名称": "联邦生物机甲技术",
"一句话摘要": "s", "字段": {}}, nbs)
check("judge-近邻字段摘选入证据", "字段摘选:" in jp and "境界阶梯" in jp)
check("judge-名称互含判据", "同型且名称互含" in jp)
check("judge-跨型禁判同一实体仍在", "绝不判同一实体" in jp)
if __name__ == "__main__":
for fn in (test_classify_new_name, test_debut_milestone, test_clean_milestone_guard,
test_merge_material, test_build_embed_text_type_fix, test_int_chaps,
test_prompts_disciplines):
fn()
print(f"\n全部离线自测通过:{_passed} 项(未连库、未发任何网络/嵌入/LLM 调用)")