gate_preflight_reference.py 5.3 KB
#!/usr/bin/env python3
"""Step 5 Gate pre-flight for `type=reference` absorbs —— 冻结复现 app.py:_verify_and_apply。

背景(learn_queue #1319,2026-10-11):reference 型的 Step5 指纹门存在结构性病理,
本脚本在收尾前离线复现判定逻辑,PASS 才提交,避免白烧一个 attempt。

病理(两段,均实测):
  1) `fingerprint = result_summary[:30]`,而 `result_summary = result_text[:500]`(Step 4 报告正文)
     → 首 30 字恒为报告表头 `# 学习完成报告\n\n## 学习内容\n- 标题: …`,**结构上永不出现**在
       memory.json / SKILL.md 中 → 主指纹路径恒失效。
  2) ~~有人以为 fallback 会去查目标 SKILL.md——不会~~ **(已过时,2026-10-12 #1330 修)**:
     旧版 app.py 用**带前缀的原始 target**(`new:xxx`)去 rglob,`*/new:xxx/SKILL.md` 永不匹配;
     但 app.py L3069-3075 已加去前缀修复(`tgt_resolved` 剥 `new:`/`update:` 再 rglob)⇒
     **目标 SKILL.md 现在是可达 grounding 通道**(`raw=0` 与 `clean>=1` 同时成立是正常的,
     不是错误)。本脚本旧版只查 memory.json,会误报 FAIL——已按 app.py 现状对齐。
⇒ **reference 的 grounding 通道 = 两处任一命中即 PASS**:
   ①目标 SKILL.md(去前缀后路径)②memory.json 短语回退(L3095–3108)。
   取 `outline = summary[:500] + "\n\n" + plan[:500]`,再取 `outline[i:i+20]`(i=0/20/40/60),
   strip 后 ≥10 字且为 **磁盘原始文本的子串** → 至少 1 段命中即 PASS。

用法:
    python3 gate_preflight_reference.py <content_file> [--target new:xxx]

修复手法(2026-10-12 #1330 实测有效):让 `<content_file>.summary.md` **首行**取**已落盘文本**的
**逐字句子**——优先目标 SKILL.md 的 H1 / 章节标题(≥20 字、首 20 字内不含换行),或 memory.json
目标条目。`outline[0:20]` 命中即 PASS。禁止伪造短语:锚必须真实存在于磁盘文本中。
"""
import argparse
import json
import sys
from pathlib import Path

SKILLS = Path("/data/data/com.termux/files/home/.hermes/skills")
MEM_DEFAULT = Path("/data/data/com.termux/files/home/.hermes/memory.json")


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("content_file")
    ap.add_argument("--target", default="", help="learn_target(含 new:/update: 前缀,用于 skill 路径作证)")
    ap.add_argument("--memory", default=str(MEM_DEFAULT))
    args = ap.parse_args()

    summary = Path(args.content_file + ".summary.md")
    plan = Path(args.content_file + ".plan.md")
    result = Path(args.content_file + ".result.md")
    mem = Path(args.memory)
    if not (summary.exists() and plan.exists()):
        print("FAIL: summary/plan missing -> %s" % summary)
        return 1
    if not mem.exists():
        print("FAIL: memory store missing -> %s" % mem)
        return 1
    mem_text = mem.read_text(encoding="utf-8")

    outline = summary.read_text(encoding="utf-8")[:500] + "\n\n" + plan.read_text(encoding="utf-8")[:500]
    if result.exists():
        result_summary = result.read_text(encoding="utf-8")[:1200][:500]
    else:
        result_summary = "# 学习完成报告\n\n## 学习内容\n- 标题: <未写报告时的模拟表头>"
    fingerprint = (result_summary or outline).strip()[:30]
    print("fingerprint = %r  (表头 -> 主路径恒不可达,走短语回退)" % fingerprint)

    skill_files = []
    if args.target:
        raw = list(SKILLS.rglob(f"*/{args.target}/SKILL.md")) or list(SKILLS.rglob(f"{args.target}/SKILL.md"))
        clean = args.target.replace("new:", "").replace("update:", "")
        skill_files = list(SKILLS.rglob(f"*/{clean}/SKILL.md")) or list(SKILLS.rglob(f"{clean}/SKILL.md"))
        print("skill_files(raw target) = %d  <- 预期 0(app.py 已去前缀;raw 计数仅作证)" % len(raw))
        print("skill_files(clean name) = %d  <- 须 >=1,否则 Step5 会先报 SKILL_MISSING" % len(skill_files))

    windows = [(i, outline[i:i + 20]) for i in range(0, 80, 20)]

    def _scan(label: str, text: str) -> bool:
        if fingerprint and fingerprint in text:
            print("hit: fingerprint in %s" % label)
            return True
        for i, ph in windows:
            hit = len(ph.strip()) >= 10 and ph.strip() in text
            print("  %s phrase[%d:%d] hit=%s :: %r" % (label, i, i + 20, hit, ph))
            if hit:
                return True
        return False

    verified = False
    # 通道①:目标 SKILL.md(app.py L3069-3095;去前缀路径,#1323 修复后可达)
    for sf in skill_files:
        try:
            if _scan("skill:%s" % sf.parent.name, sf.read_text(encoding="utf-8")):
                verified = True
                break
        except OSError as exc:
            print("  (skip unreadable %s: %s)" % (sf, exc))
    # 通道②:memory.json 短语回退(app.py L3095-3108)
    if not verified:
        verified = _scan("memory.json", mem_text)

    print("\nStep5 reference-grounded = %s" % ("PASS" if verified else "FAIL"))
    if not verified:
        print("修复:把 .summary.md 首行换成**已落盘文本**的逐字句子"
              "(目标 SKILL.md 的 H1/章节标题,或 memory.json 目标条目;>=20 字,首 20 字内无换行)")
    return 0 if verified else 2


if __name__ == "__main__":
    sys.exit(main())