196 lines
12 KiB
Python
196 lines
12 KiB
Python
#!/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
|
||
|
||
|
||
# ── ⑥ 三个提示词含关键纪律语句(洞②登场必须 / 洞③≤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_prompts_disciplines):
|
||
fn()
|
||
print(f"\n全部离线自测通过:{_passed} 项(未连库、未发任何网络/嵌入/LLM 调用)")
|