#!/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 json import pathlib import sys from copy import deepcopy from unittest.mock import patch from click.testing import CliRunner # 与 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-无真实章不补", out == []) # 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) # 新抽取里程碑必须用短原文证据把章号绑定到对应正文;证据只用于校验,不写入卡体。 chapter_texts = { 488: "安若雪率领亲卫继续突进,环星防御火力全面展开。", 489: "安若雪与加特朗激战,同步率飙至100%,击碎能量屏障。", } verified = pu._clean_milestone( { "章": 489, "台阶": "同步率飙至100%击碎加特朗屏障", "周期": "高光", "证据": "同步率飙至100%,击碎能量屏障", }, 84, chapter_texts=chapter_texts, require_evidence=True, ) check( "clean-证据绑定正确章", verified is not None and verified["章"] == 489 and "证据" not in verified, ) wrong_chapter = pu._clean_milestone( { "章": 488, "台阶": "同步率飙至100%击碎加特朗屏障", "周期": "高光", "证据": "同步率飙至100%,击碎能量屏障", }, 84, chapter_texts=chapter_texts, require_evidence=True, ) check("clean-错章证据拒收", wrong_chapter is None) missing_evidence = pu._clean_milestone( {"章": 489, "台阶": "同步率飙至100%", "周期": "高光"}, 84, chapter_texts=chapter_texts, require_evidence=True, ) check("clean-缺证据拒收", missing_evidence is None) short_evidence = {"章": 488, "台阶": "继续突进", "周期": "成长", "证据": "安若雪率领亲卫"} check( "clean-不足8字证据拒收", not pu._milestone_evidence_matches(short_evidence, chapter_texts), ) long_quote = "证" * 31 long_evidence = {"章": 489, "台阶": "长证据", "周期": "成长", "证据": long_quote} check( "clean-超过30字证据拒收", not pu._milestone_evidence_matches(long_evidence, {489: f"前缀{long_quote}后缀"}), ) # ── ④ 归并材料 _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()) class _CardResult: """为立卡与归并离线测试提供最小查询结果对象。""" def __init__(self, row=None, rows=None): self.row = row self.rows = rows or [] def fetchone(self): """返回预置的单行结果。""" return self.row def fetchall(self): """返回预置的多行结果。""" return self.rows class _CardConn: """只模拟卡片写入所需 SQL,并保留最终 payload 供机械断言。""" def __init__(self, payload=None): self.payload = payload self.audits = [] def execute(self, query, params): """按 SQL 用途返回最小结果,或捕获立卡、更新后的 payload。""" normalized = " ".join(query.split()) if normalized.startswith("SELECT draft_payload"): return _CardResult((self.payload,)) if normalized.startswith("SELECT watermark_window"): return _CardResult((0,)) if normalized.startswith("SELECT a.draft_id, a.field_name, a.old_value"): rows = [(did, field_name, old_value) for did, _, field_name, old_value in reversed(self.audits)] return _CardResult(rows=rows) if normalized.startswith("SELECT id, draft_payload FROM muse_knowledge_draft"): return _CardResult(rows=[(101, self.payload)]) if "INSERT INTO muse_knowledge_draft" in query: self.payload = json.loads(params[1]) return _CardResult((101,)) if "INSERT INTO example_upgrade_audit" in query and len(params) >= 4 \ and params[2] == "顶层:出场章": self.audits.append((params[0], params[1], params[2], params[3])) if normalized.startswith("DELETE FROM example_upgrade_audit"): self.audits = [] if "UPDATE muse_knowledge_draft SET draft_payload" in query: self.payload = json.loads(params[0]) return _CardResult() def commit(self): """模拟事务提交;离线测试中的状态已在内存立即生效。""" def test_entity_chapter_evidence_filter(): """模型出场章只能保留规范名或合法别名在对应正文真实出现的章节。""" chapter_texts = { 488: "环星防线开启,亲卫继续突进。", 489: "安若雪同步率飙至100%,击碎能量屏障。", 490: "众人称雪姐已经抵达核心区。", 491: "备忘录提到雪(指挥官)这个带注释称呼。", } got = pu._filter_entity_chapters( "安若雪", ["雪姐", "雪(指挥官)", "新"], [488, "489", 490, 491], chapter_texts, ) check("chapter-evidence-错488过滤为489与合法别名章", got == {489, 490}) parenthetical = pu._filter_entity_chapters( "白色游魂(无名侦察兵)", [], [492], {492: "白色游魂从破损舱门后现身。"}, ) check("chapter-evidence-括号注名称按规范名命中", parenthetical == {492}) false_positive_texts = { 493: "大小姐转身离开大厅。", 494: "小姐姐转身离开大厅。", 495: "雪姐已经抵达核心区。", 496: "安若雪已经抵达核心区。", 497: "小姐转身离开大厅。", } check( "chapter-evidence-单字规范名禁作证据", pu._filter_entity_chapters("雪", [], [495, 496], false_positive_texts) == set(), ) check( "chapter-evidence-通用称谓别名完全禁作证据", pu._filter_entity_chapters("安若雪", ["小姐"], [493, 494, 497], false_positive_texts) == set(), ) generic_titles = ( "队长", "舰长", "指挥官", "司令", "统领", "院长", "校长", "会长", "团长", "主任", "长老", "领主", "城主", "陛下", "殿下", ) titles = {500 + index: f"{title}下令立刻行动。" for index, title in enumerate(generic_titles)} check( "chapter-evidence-规范名常见职务称谓全部禁作证据", all( pu._filter_entity_chapters(title, [], [500 + index], titles) == set() for index, title in enumerate(generic_titles) ), ) check( "chapter-evidence-别名常见职务称谓全部禁作证据", all( pu._filter_entity_chapters("安若雪", [title], [500 + index], titles) == set() for index, title in enumerate(generic_titles) ), ) proper_names = { 530: "银河指挥官越过了环星防线。", 531: "青云院长打开密室入口。", 532: "玄天宗主唤醒护山大阵。", 533: "司令塔发出低沉警报。", } check( "chapter-evidence-完整专名不因职务片段误杀", pu._filter_entity_chapters( "银河指挥官", ["青云院长", "玄天宗主", "司令塔"], ["530", 531, 532, 533], proper_names, ) == {530, 531, 532, 533}, ) check( "chapter-evidence-常规二至四字专名不受损", pu._filter_entity_chapters("安若雪", ["雪姐"], [495, 496], false_positive_texts) == {495, 496}, ) def test_new_card_chapter_evidence_chain(): """串联复现:错章里程碑被拒后,初卡只能用正文实证章补登场。""" conn = _CardConn() pu.new_card( conn, 8, 84, { "型": "character", "名称": "安若雪", "别名": ["雪姐"], "一句话摘要": "联邦战士", "字段": { "演变历程": [ { "章": 488, "台阶": "同步率飙至100%", "周期": "高光", "证据": "同步率飙至100%,击碎能量屏障", } ] }, "出场章": [488, 489], }, {"character"}, chapter_texts={ 488: "环星防线开启,亲卫继续突进。", 489: "安若雪同步率飙至100%,击碎能量屏障。", }, ) milestones = conn.payload["字段"]["演变历程"] check("new-card-错488过滤为489", conn.payload["出场章"] == [489]) check( "new-card-拒错里程碑后仅补实证登场", len(milestones) == 1 and milestones[0]["章"] == 489 and milestones[0]["周期"] == "登场" and milestones[0]["_win"] == 84, ) no_evidence_conn = _CardConn() pu.new_card( no_evidence_conn, 8, 85, { "型": "character", "名称": "安若雪", "别名": [], "一句话摘要": "联邦战士", "字段": {"演变历程": []}, "出场章": [488], }, {"character"}, chapter_texts={488: "环星防线开启,亲卫继续突进。"}, ) check("new-card-无真实章不落出场章", no_evidence_conn.payload["出场章"] == []) check("new-card-无真实章不补里程碑", no_evidence_conn.payload["字段"]["演变历程"] == []) history_conn = _CardConn() pu.new_card( history_conn, 8, 86, { "型": "character", "名称": "安若雪", "别名": [], "一句话摘要": "联邦战士", "字段": {"演变历程": []}, "出场章": [489], }, {"character"}, chapter_texts={489: "安若雪抵达核心区。"}, known_chapters=[480], ) check("new-card-历史留档章不被当前窗过滤", history_conn.payload["出场章"] == [480, 489]) def test_merge_card_chapter_evidence(): """既有卡顶层出场章追加必须复用正文实体命中过滤。""" conn = _CardConn( { "type": "character", "名称": "安若雪", "别名": ["雪姐"], "一句话摘要": "联邦战士", "字段": {}, "出场章": [487], "_work_id": 8, } ) pu.merge_card( conn, 101, 84, {}, [], chapter_texts={ 488: "环星防线开启,亲卫继续突进。", 489: "雪姐同步率飙至100%,击碎能量屏障。", }, appearance_chapters=[488, 489], ) check("merge-card-顶层错488过滤为489", conn.payload["出场章"] == [487, 489]) # 顶层出场章必须随窗可撤销;正文变化后重跑不得残留上一轮已失效的章。 pu.undo_window(conn, 8, 84) check("merge-card-undo精确恢复旧出场章", conn.payload["出场章"] == [487]) pu.merge_card( conn, 101, 84, {}, [], chapter_texts={ 489: "同步率飙至100%,击碎能量屏障。", 490: "安若雪已经抵达核心区。", }, appearance_chapters=[489, 490], ) check("merge-card-正文变化重跑删除旧错章", conn.payload["出场章"] == [487, 490]) def test_redo_cleans_legacy_appearance_chapters(): """显式 redo 清理历史无审计章域,并可由失败撤销或本窗全文重新建立。""" # 修复前历史 payload 没有顶层审计:首次 redo 必须先清空指定窗域,再只重建正文真实出现章。 conn = _CardConn( { "type": "character", "名称": "安若雪", "别名": [], "字段": {}, "出场章": [487, 489], "_work_id": 8, } ) pu.undo_window(conn, 8, 84, from_chapter=487, to_chapter=489) check("redo-历史无审计窗内章清空", conn.payload["出场章"] == []) # 首次失败后的即时重试走“恢复旧值后重新清理”,第二次尝试前窗内仍必须为空。 pu.undo_window(conn, 8, 84, from_chapter=487, to_chapter=489) check("redo-首次失败重试前重新清理", conn.payload["出场章"] == []) # 清理本身带旧值审计:模拟后续处理失败时的既有失败撤销,必须恢复清理前历史值。 pu.undo_window(conn, 8, 84) check("redo-后续失败可恢复历史章", conn.payload["出场章"] == [487, 489]) pu.undo_window(conn, 8, 84, from_chapter=487, to_chapter=489) pu.merge_card( conn, 101, 84, {}, [], chapter_texts={ 487: "环星防线开启,亲卫继续突进。", 488: "同步率继续上升。", 489: "安若雪击碎能量屏障。", }, appearance_chapters=[487, 488, 489], ) check("redo-全文重建只落真实489", conn.payload["出场章"] == [489]) outside_conn = _CardConn( { "type": "character", "名称": "安若雪", "别名": [], "字段": {}, "出场章": [486, 487, 489, 490], "_work_id": 8, } ) pu.undo_window(outside_conn, 8, 84, from_chapter=487, to_chapter=489) check("redo-窗外章完整保留", outside_conn.payload["出场章"] == [486, 490]) normal_conn = _CardConn( { "type": "character", "名称": "安若雪", "别名": [], "字段": {}, "出场章": [487, 489], "_work_id": 8, } ) pu.undo_window(normal_conn, 8, 84) check("redo-非显式redo不做章域清理", normal_conn.payload["出场章"] == [487, 489]) class _RunConn: """run 级离线夹具:只模拟控制流所需 SQL,绝不建立真实连接。""" def __init__(self, state): self.state = state self.writes = [] self.commits = 0 def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, query, params=()): normalized = " ".join(query.split()) if normalized.startswith("SELECT title FROM muse_content_work"): return _CardResult(("离线书",)) if normalized.startswith("SELECT window_no, from_chapter, to_chapter FROM example_upgrade_window"): return _CardResult(rows=self.state.get("windows", [(7, 70, 71)])) if normalized.startswith("SELECT window_no, from_chapter, to_chapter, status FROM example_upgrade_window"): return _CardResult(rows=[(7, 70, 71, "pending")]) if normalized.startswith("SELECT window_no FROM example_upgrade_window"): return _CardResult(rows=[]) if normalized.startswith("SELECT draft_payload FROM muse_knowledge_draft"): return _CardResult((self.state["payload"],)) if normalized.startswith("UPDATE muse_knowledge_draft SET draft_payload=%s"): self.state["payload"] = json.loads(params[0]) if normalized.startswith("UPDATE example_upgrade_window SET status='failed'"): self.state["window_status"] = "failed" if normalized.startswith("UPDATE example_upgrade_window SET status='done'"): self.state["window_status"] = "done" if not normalized.startswith("SELECT"): self.writes.append((normalized, params)) return _CardResult() def commit(self): """内存状态即时生效。""" self.commits += 1 def test_run_redo_preflight_rejects_unsafe_targets(): """所有 redo 非法输入都必须在快照、撤销与写入前失败。""" cases = [ ("历史窗", [(1, 1, 2), (2, 3, 4)], 1, {1: "甲", 2: "乙"}, "仅允许重跑当前末窗"), ("不存在", [(1, 1, 2)], 2, {}, "不存在"), ("窗口号断裂", [(1, 1, 2), (3, 3, 4)], 3, {}, "窗口号不连续"), ("章域断裂", [(1, 1, 2), (2, 4, 5)], 2, {}, "窗章域不连续"), ("正文缺章", [(1, 70, 71)], 1, {70: "正文"}, "正文不完整"), ("正文为空", [(1, 70, 71)], 1, {70: "正文", 71: " "}, "正文不完整"), ] for name, windows, target, chapter_texts, expected_error in cases: state = {"windows": windows} snapshot_calls, undo_calls, connections = [], [], [] def fake_connect(*_): conn = _RunConn(state) connections.append(conn) return conn def fake_material(conn, work_id, a, b): text = "\n".join(str(chapter_texts.get(chapter, "")) for chapter in range(a, b + 1)) return text, chapter_texts with patch.object(pu.psycopg, "connect", side_effect=fake_connect), \ patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \ patch.object(pu, "load_window_material", side_effect=fake_material), \ patch.object(pu, "_snapshot_redo_window", side_effect=lambda *args: snapshot_calls.append(args)), \ patch.object(pu, "undo_window", side_effect=lambda *args, **kwargs: undo_calls.append((args, kwargs))): result = CliRunner().invoke( pu.cli, ["run", "--work-id", "8", "--redo-window", str(target)], ) check( f"redo-preflight-{name}-非零拒绝", result.exit_code != 0 and expected_error in result.output, detail=f"exit={result.exit_code}, output={result.output!r}", ) check(f"redo-preflight-{name}-无副作用", not snapshot_calls and not undo_calls and not any(conn.writes or conn.commits for conn in connections)) check(f"redo-preflight-{name}-无假完成", "完成 0 窗" not in result.output) def test_run_rejects_negative_limits_before_redo_side_effects(): """负调用闸必须由 Click 在函数入口拒绝,即使同时请求合法末窗 redo 也不得接触数据库。""" for option in ("--max-windows", "--max-calls"): with patch.object(pu.psycopg, "connect") as connect, \ patch.object(pu, "_snapshot_redo_window") as snapshot, \ patch.object(pu, "undo_window") as undo: result = CliRunner().invoke( pu.cli, ["run", "--work-id", "8", "--redo-window", "2", option, "-1"], ) check( f"negative-limit-{option}-入口非零拒绝", result.exit_code != 0, detail=f"exit={result.exit_code}, output={result.output!r}", ) check( f"negative-limit-{option}-无snapshot与undo", not snapshot.called and not undo.called, ) check(f"negative-limit-{option}-无DB调用", not connect.called) def _run_with_failures(*, max_calls, failures): """执行单个显式 redo 窗;前 failures 次观察调用失败,返回状态与调用数。""" old_state = { "windows": [(1, 1, 69), (2, 70, 71)], "window_status": "done", "fields": {"阵营": "旧阵营", "经历": ["[窗2] 旧经历"]}, "aliases": ["旧别名"], "presence": [(2, 70, "旧龙套")], "initial_cards": [701], } state = deepcopy(old_state) calls = {"n": 0} undo_calls = [] def fake_undo(conn, work_id, win_no, *, from_chapter=None, to_chapter=None): undo_calls.append((from_chapter, to_chapter)) # 复现旧实现边界:章域 undo 会清完整旧窗;无章域 undo 只能恢复顶层章,救不回其余旧窗状态。 state["fields"] = {} state["aliases"] = [] state["presence"] = [] state["initial_cards"] = [] def fake_m3_json(prompt, model, need_keys, system=None): calls["n"] += 1 if calls["n"] <= failures: raise RuntimeError(f"离线失败{calls['n']}") return ({"新名字": [], "已知实体新信息": [], "纯出场": []}, {}) snapshot = deepcopy(old_state) def fake_restore(conn, work_id, redo_snapshot): state.clear() state.update(deepcopy(redo_snapshot)) with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RunConn(state)), \ patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \ patch.object(pu, "load_window_material", return_value=("正文", {70: "正文", 71: "正文"})), \ patch.object(pu, "load_known", return_value=({}, {}, {})), \ patch.object(pu, "undo_window", side_effect=fake_undo), \ patch.object(pu, "m3_json", side_effect=fake_m3_json), \ patch.object(pu, "_snapshot_redo_window", return_value=snapshot), \ patch.object(pu, "_restore_redo_window", side_effect=fake_restore): pu.run.callback( work_id=8, max_windows=0, max_calls=max_calls, model="MiniMax-M3", redo_window=2, semantic_on=False, ) return old_state, state, calls["n"], undo_calls def test_run_redo_max_calls_finishes_active_retry(): """调用闸不能在首次失败后的已清理中间态退出。""" _, state, call_count, undo_calls = _run_with_failures(max_calls=1, failures=1) check("run-max-calls-已清理窗仍完成即时重试", call_count == 2) check("run-max-calls-重试成功落done", state["window_status"] == "done") check("run-max-calls-首次失败前后均执行章域清理", undo_calls == [(70, 71), (70, 71)]) def test_run_redo_final_failure_restores_full_snapshot(): """显式 redo 两次都失败时,完整恢复字段、别名、presence 与初建卡。""" old_state, state, call_count, _ = _run_with_failures(max_calls=0, failures=2) check("run-redo-final-failure-确实尝试两次", call_count == 2) check("run-redo-final-failure-完整恢复旧窗", state == old_state, detail=f"state={state!r}") class _SnapshotConn: """恢复点 SQL 的离线记录器:提供旧窗快照行,并记录所有恢复写入。""" def __init__(self): self.writes = [] def execute(self, query, params=()): normalized = " ".join(query.split()) if normalized.startswith("SELECT id, draft_payload, revision, updater, deleted"): return _CardResult(rows=[(701, {"字段": {"阵营": "旧阵营"}}, 9, "old", False)]) if normalized.startswith("SELECT id, canonical_name, alias, evidence_window"): return _CardResult(rows=[(801, "旧主角", "旧别名", 7, "ai", "", "t1", "", "t2", False)]) if normalized.startswith("SELECT id, window_no, chapter_no, entity_type"): return _CardResult(rows=[(901, 7, 70, "character", "旧龙套", "旧观察", "", "t3", False)]) if normalized.startswith("SELECT draft_id, watermark_window, update_time"): return _CardResult(rows=[(701, 7, "t4")]) if normalized.startswith("SELECT a.id, a.draft_id, a.window_no"): return _CardResult(rows=[(1001, 701, 7, "阵营", '"更旧阵营"', '"旧阵营"', "t5")]) if normalized.startswith("SELECT status, error_message, updater FROM example_upgrade_window"): return _CardResult(("done", None, "old")) if normalized.startswith("SELECT id FROM muse_knowledge_draft"): return _CardResult(rows=[(701,), (702,)]) self.writes.append((normalized, params)) return _CardResult() def test_redo_snapshot_restore_sql_boundaries(): """恢复点覆盖旧字段、别名、presence、初建卡、水位、审计与窗状态。""" conn = _SnapshotConn() snapshot = pu._snapshot_redo_window(conn, 8, 7) check("redo-snapshot-捕获完整旧卡", snapshot["drafts"][0][1]["字段"]["阵营"] == "旧阵营") check("redo-snapshot-捕获别名presence", snapshot["aliases"] and snapshot["presence"]) check("redo-snapshot-捕获初建卡水位", snapshot["card_states"] == [(701, 7, "t4")]) check("redo-snapshot-捕获旧窗状态", snapshot["window"] == ("done", None, "old")) pu._restore_redo_window(conn, 8, snapshot) writes = conn.writes check( "redo-restore-恢复旧卡字段与revision", any(sql.startswith("UPDATE muse_knowledge_draft SET draft_payload=%s, revision=%s") and params[:3] == (json.dumps({"字段": {"阵营": "旧阵营"}}, ensure_ascii=False), 9, "old") for sql, params in writes), ) check( "redo-restore-软删重试新建卡", any(sql.startswith("UPDATE muse_knowledge_draft SET deleted=TRUE") and params == (702,) for sql, params in writes), ) check( "redo-restore-恢复别名与presence", any("INSERT INTO example_upgrade_alias" in sql and "旧别名" in params for sql, params in writes) and any("INSERT INTO example_upgrade_presence" in sql and "旧龙套" in params for sql, params in writes), ) check( "redo-restore-恢复初建卡水位与旧窗状态", any("INSERT INTO example_upgrade_card_state" in sql and params[0] == 701 for sql, params in writes) and any(sql.startswith("UPDATE example_upgrade_window SET status=%s") and params[:3] == ("done", None, "old") for sql, params in writes), ) def test_presence_chapter_normalization_boundaries(): """归并卡与纯出场章号都必须接受数字字符串,并丢弃非数字值。""" check("presence-归并卡数字字符串", pu._int_chaps(["489", 490, "bad"]) == {489, 490}) check("presence-纯出场数字字符串", pu._int_chaps(["491", " 492 ", None]) == {491, 492}) def test_run_presence_paths_accept_numeric_strings(): """run 的已知更新归并卡与纯出场都把数字字符串章号落成整数。""" state = { "window_status": "pending", "payload": { "type": "character", "名称": "安若雪", "别名": [], "字段": {}, "出场章": [], "_work_id": 8, }, } def fake_m3_json(prompt, model, need_keys, system=None): if need_keys == ("新名字", "已知实体新信息", "纯出场"): return ({ "新名字": [], "已知实体新信息": [ {"名称": "安若雪", "观察点": "出现新变化", "出场章": ["70"]} ], "纯出场": [{"名称": "安若雪", "出场章": ["71"]}], }, {}) return ({"更新": []}, {}) with patch.object(pu.psycopg, "connect", side_effect=lambda *_: _RunConn(state)), \ patch.object(pu, "load_entity_contracts", return_value=_fake_contracts()), \ patch.object( pu, "load_window_material", return_value=("安若雪连续出现", {70: "安若雪出现新变化。", 71: "安若雪继续前进。"}), ), \ patch.object( pu, "load_known", return_value=({"安若雪": (701, "character", "旧摘要")}, {}, {}), ), \ patch.object(pu, "m3_json", side_effect=fake_m3_json): pu.run.callback( work_id=8, max_windows=0, max_calls=0, model="MiniMax-M3", redo_window=0, semantic_on=False, ) check("run-presence-归并与纯出场数字字符串均落库", state["payload"]["出场章"] == [70, 71]) def test_load_known_projection(): """判重底册只投影索引字段,禁止跨网搬运每张卡的完整 payload。""" class Result: def __init__(self, rows): self.rows = rows def fetchall(self): return self.rows class Conn: def __init__(self): self.queries = [] def execute(self, query, params): self.queries.append(query) if "muse_knowledge_draft" in query: return Result([(1, "character", "安若雪", "联邦战士", [])]) if "example_upgrade_alias" in query: return Result([("安若雪", "雪姐")]) return Result([("character", "李四", 7)]) conn = Conn() name_map, presence, aliases_by_draft = pu.load_known(conn, 8) check("known-正名投影", name_map["安若雪"] == (1, "character", "联邦战士")) check("known-别名表投影", name_map["雪姐"] == name_map["安若雪"]) check("known-按卡携带alias表独有别名", aliases_by_draft[1] == {"雪姐"}) check("known-presence保留", presence[("character", "李四")] == {7}) first_query = conn.queries[0] check( "known-禁搬完整payload", "SELECT id, draft_payload FROM" not in first_query and "draft_payload->>'名称'" in first_query, ) merge_conn = _CardConn( { "type": "character", "名称": "安若雪", "别名": [], "一句话摘要": "联邦战士", "字段": {}, "出场章": [488], "_work_id": 8, } ) pu.merge_card( merge_conn, 1, 84, {}, [], chapter_texts={489: "雪姐已经抵达核心区。"}, appearance_chapters=[489], known_aliases=aliases_by_draft[1], ) check("known-alias表独有别名可供merge过滤", merge_conn.payload["出场章"] == [488, 489]) pure_payload = { "名称": "安若雪", "别名": [], "出场章": [488], } pure_conn = _CardConn(pure_payload) changed = pu._append_verified_appearance_chapters( pure_conn, 1, 84, pure_payload, [489], {489: "雪姐已经抵达核心区。"}, known_aliases=aliases_by_draft[1], ) check( "known-alias表独有别名可供纯出场过滤", changed and pure_payload["出场章"] == [488, 489], ) def test_repair_milestone_evidence(): """只修本窗缺证据里程碑;未来重抄项不能借修复调用混回卡体。""" output = { "更新": [ { "draft_id": 1, "变更字段": { "演变历程": [ {"章": 489, "台阶": "同步率飙至100%", "周期": "高光"}, {"章": 493, "台阶": "未来治疗事件", "周期": "高光"}, ] }, } ] } chapter_texts = {488: "防线开启。", 489: "安若雪同步率飙至100%,击碎能量屏障。"} seen = {} def fake_call(prompt, required_keys): seen["prompt"] = prompt seen["required_keys"] = required_keys return ( { "证据修复": [ { "ref": "$.更新[0].变更字段.演变历程[0]", "章": 489, "证据": "同步率飙至100%,击碎能量屏障", } ] }, {}, ) repaired = pu.repair_missing_milestone_evidence( output, title="深空之影", a=488, b=489, text="## 第488章\n防线开启。\n## 第489章\n安若雪同步率飙至100%,击碎能量屏障。", chapter_texts=chapter_texts, call=fake_call, ) milestones = output["更新"][0]["变更字段"]["演变历程"] check("repair-本窗证据补齐", repaired == 1 and "证据" in milestones[0]) check("repair-未来重抄不送修复", "证据" not in milestones[1] and "未来治疗事件" not in seen["prompt"]) check("repair-固定输出键", seen["required_keys"] == ("证据修复",)) # ── ⑥ 三个提示词含关键纪律语句(洞②登场必须 / 洞③≤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) check("observe-里程碑要求原文证据", '"证据"' in obs and "正文原样连续短句" 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) check("update-里程碑要求原文证据", '"证据"' in upd and "正文原样连续短句" 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_entity_chapter_evidence_filter, test_new_card_chapter_evidence_chain, test_merge_card_chapter_evidence, test_redo_cleans_legacy_appearance_chapters, test_run_redo_preflight_rejects_unsafe_targets, test_run_rejects_negative_limits_before_redo_side_effects, test_run_redo_max_calls_finishes_active_retry, test_run_redo_final_failure_restores_full_snapshot, test_redo_snapshot_restore_sql_boundaries, test_presence_chapter_normalization_boundaries, test_run_presence_paths_accept_numeric_strings, test_load_known_projection, test_repair_milestone_evidence, test_prompts_disciplines): fn() print(f"\n全部离线自测通过:{_passed} 项(未连库、未发任何网络/嵌入/LLM 调用)")