master-skill 0.12.1 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.cursor-plugin/plugin.json +1 -1
  4. package/README.md +2 -2
  5. package/README_EN.md +2 -2
  6. package/gemini-extension.json +1 -1
  7. package/package.json +2 -2
  8. package/prebuilt/master-curriculum/references/jingtu.md +2 -2
  9. package/prebuilt/master-debate/SKILL.md +3 -3
  10. package/prebuilt/master-kumarajiva/SKILL.md +6 -0
  11. package/prebuilt/master-kumarajiva/meta.json +10 -0
  12. package/prebuilt/master-ouyi/SKILL.md +5 -0
  13. package/prebuilt/master-ouyi/meta.json +5 -0
  14. package/prebuilt/master-xuanzang/SKILL.md +6 -0
  15. package/prebuilt/master-xuanzang/meta.json +10 -0
  16. package/prebuilt/master-yinguang/SKILL.md +16 -13
  17. package/prebuilt/master-yinguang/meta.json +18 -15
  18. package/prebuilt/master-yinguang/references/teaching.md +6 -6
  19. package/prebuilt/master-yinguang/references/voice.md +1 -1
  20. package/prebuilt/master-yinguang/sources/INDEX.md +7 -6
  21. package/prebuilt/master-yinguang/sources/wenchao-excerpts.md +5 -5
  22. package/prebuilt/master-yinguang/sources/yihanbianfu-excerpts.md +4 -4
  23. package/prebuilt/master-yinguang/tests/fidelity.jsonl +7 -7
  24. package/prebuilt/master-zhiyi/SKILL.md +4 -1
  25. package/prebuilt/master-zhiyi/meta.json +5 -0
  26. package/references/source-conventions.md +2 -2
  27. package/scripts/check-pe-subsystem.py +64 -0
  28. package/scripts/reaudit-report.py +45 -3
  29. package/scripts/validate-citation-references.py +80 -7
  30. package/scripts/validate-self-audit-sources.py +116 -0
  31. package/scripts/verify_citations.py +92 -6
  32. package/tools/verify_sources.py +157 -5
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env python3
2
+ """Check which Windows subsystem a PE executable was linked for.
3
+
4
+ The field is the Subsystem word of the PE optional header: 2 for a GUI
5
+ program, 3 for a console one (`file` prints the latter as `PE32+ executable
6
+ (console)`). The desktop manager is kept a console program. A GUI-subsystem
7
+ build stops double-clicking from opening a console window, but on
8
+ release-desktop run 34858072308 PowerShell did not wait for it, closed the
9
+ pipe, and `--help > help.txt` panicked. The release smoke test runs this with
10
+ `--expect 3` so the switch cannot come back unmeasured.
11
+
12
+ Output is ASCII only. This runs on the Windows release runner, whose console
13
+ encoding is cp1252; a check mark there raised UnicodeEncodeError after the
14
+ check itself had passed.
15
+
16
+ Usage:
17
+ python scripts/check-pe-subsystem.py <exe> --expect 2
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import struct
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ SUBSYSTEMS = {2: "Windows GUI", 3: "Windows console"}
28
+
29
+
30
+ def pe_subsystem(data: bytes) -> int:
31
+ """Return the Subsystem field of a PE image's optional header."""
32
+ if data[:2] != b"MZ":
33
+ raise ValueError("not a PE file: no MZ header")
34
+ (pe_offset,) = struct.unpack_from("<I", data, 0x3C)
35
+ if data[pe_offset:pe_offset + 4] != b"PE\0\0":
36
+ raise ValueError("not a PE file: no PE signature")
37
+ # The optional header follows the 4-byte signature and the 20-byte COFF
38
+ # header; Subsystem sits 68 bytes into it in both PE32 and PE32+.
39
+ (subsystem,) = struct.unpack_from("<H", data, pe_offset + 24 + 68)
40
+ return subsystem
41
+
42
+
43
+ def main(argv: list[str] | None = None) -> int:
44
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
45
+ parser.add_argument("exe", type=Path)
46
+ parser.add_argument("--expect", type=int, required=True, help="2 = GUI, 3 = console")
47
+ args = parser.parse_args(argv)
48
+ try:
49
+ with args.exe.open("rb") as handle:
50
+ subsystem = pe_subsystem(handle.read(4096))
51
+ except (OSError, ValueError, struct.error) as exc:
52
+ print(f"FAIL {args.exe}: {exc}")
53
+ return 1
54
+ found = f"{subsystem} ({SUBSYSTEMS.get(subsystem, 'other')})"
55
+ if subsystem != args.expect:
56
+ wanted = f"{args.expect} ({SUBSYSTEMS.get(args.expect, 'other')})"
57
+ print(f"FAIL {args.exe}: subsystem {found}, expected {wanted}")
58
+ return 1
59
+ print(f"OK {args.exe}: subsystem {found}")
60
+ return 0
61
+
62
+
63
+ if __name__ == "__main__":
64
+ sys.exit(main())
@@ -13,6 +13,12 @@ The ¥3.89 sweep is re-measurable for nothing, and a family added to the auditor
13
13
  has to show what it bought instead of asserting it.
14
14
 
15
15
  python3 scripts/reaudit-report.py eval/reports/0.11.0-06b8142-deepseek.json
16
+ python3 scripts/reaudit-report.py eval/reports/0.11.0-06b8142-deepseek.json --online
17
+
18
+ `--online` also asks FoJin about the run's live citations, the ones that passed
19
+ offline on a link alone: does each link open, and is it the work the citation
20
+ names (same sutra number, agreeing title)? It needs the network and reads
21
+ nothing back into the report.
16
22
 
17
23
  The report file itself is never rewritten: it is the record of what that run
18
24
  measured with that instrument, and editing it would be rewriting the experiment.
@@ -29,6 +35,7 @@ from verify_citations import ( # noqa: E402
29
35
  load_declared_ids,
30
36
  load_member_aliases,
31
37
  load_title_aliases,
38
+ verify_online,
32
39
  )
33
40
 
34
41
 
@@ -80,6 +87,7 @@ def reaudit(report: dict) -> dict:
80
87
  continue
81
88
 
82
89
  checked = unparsed = 0
90
+ live: list[dict] = []
83
91
  fabricated: list[str] = []
84
92
  noncitation: list[str] = []
85
93
  for result in suite["results"]:
@@ -92,6 +100,7 @@ def reaudit(report: dict) -> dict:
92
100
  len(audit["offline"]) + len(audit["live"]) + len(audit["fabricated"])
93
101
  )
94
102
  unparsed += len(audit["unparsed"])
103
+ live.extend(audit["live_detail"])
95
104
  fabricated.extend(audit["fabricated"])
96
105
  noncitation.extend(audit.get("noncitation", ()))
97
106
  recomputed = {"checked": checked, "unparsed": unparsed}
@@ -104,6 +113,8 @@ def reaudit(report: dict) -> dict:
104
113
  "recorded": recorded,
105
114
  "recomputed": recomputed,
106
115
  "fabricated": sorted(set(fabricated)),
116
+ # Passed on a FoJin link alone; only `--online` checks the link.
117
+ "live": live,
107
118
  # 判定为「不是引文」的【…】块。不计入覆盖率的分母,但必须
108
119
  # 数出来:排除而不申报,和静默跳过没有区别。
109
120
  "noncitation": sorted(set(noncitation)),
@@ -121,12 +132,41 @@ def _coverage(counts: dict | None) -> str:
121
132
  return f"{counts['checked']}/{total} {counts['checked'] / total:.0%}"
122
133
 
123
134
 
135
+ def _print_online(out: dict) -> None:
136
+ """Ask FoJin whether each stored live citation's link is the work it names."""
137
+ cited = [(s["master"], c) for s in out["suites"] for c in s.get("live", ())]
138
+ if not cited:
139
+ print("\nlive 引文在线核验:这次运行没有 live 引文")
140
+ return
141
+ tids = [c["text_id"] for _, c in cited]
142
+ res = verify_online(tids, citations=[c for _, c in cited])
143
+ print(f"\nlive 引文在线核验:{len(cited)} 条,{len(set(tids))} 个 FoJin 链接")
144
+ if res.unreachable:
145
+ print(f" ⚠ 未能核验:FoJin 不可达({res.unreachable})")
146
+ return
147
+ groups: dict[tuple[str, str], int] = {}
148
+ for master, citation in cited:
149
+ key = (master, citation["text_id"])
150
+ groups[key] = groups.get(key, 0) + 1
151
+ for (master, tid), count in sorted(groups.items()):
152
+ verdict = res.verdicts.get(tid)
153
+ if verdict is True:
154
+ continue
155
+ mark = "✗" if verdict is False else "?"
156
+ print(f" {mark} {master} ×{count} {res.reasons.get(tid, '未能核验')}")
157
+ passed = sum(count for (_, tid), count in groups.items() if res.verdicts.get(tid) is True)
158
+ print(f" ✓ {passed} 条的链接是所引之书")
159
+
160
+
124
161
  def main(argv: list[str]) -> int:
125
- if len(argv) != 2:
162
+ args = argv[1:]
163
+ online = "--online" in args
164
+ paths = [arg for arg in args if arg != "--online"]
165
+ if len(paths) != 1:
126
166
  print(__doc__.strip().splitlines()[0])
127
- print(f"usage: {Path(argv[0]).name} <eval/reports/*.json>")
167
+ print(f"usage: {Path(argv[0]).name} <eval/reports/*.json> [--online]")
128
168
  return 2
129
- path = Path(argv[1])
169
+ path = Path(paths[0])
130
170
  report = json.loads(path.read_text())
131
171
  out = reaudit(report)
132
172
 
@@ -156,6 +196,8 @@ def main(argv: list[str]) -> int:
156
196
  )
157
197
  for block in skipped:
158
198
  print(f" 【{block}】")
199
+ if online:
200
+ _print_online(out)
159
201
  return 0
160
202
 
161
203
 
@@ -14,17 +14,29 @@ as the prescribed format while `meta.json` declares five sources, none of them
14
14
  That was found by a ¥3.89 graded run over 211 fixtures, which caught it only
15
15
  because one fixture happened to trigger it. This finds every instance of the
16
16
  class deterministically, for free, on every PR.
17
+
18
+ It reads source ids outside 【…】 too. A routing table is an instruction as
19
+ much as a citation template is, and until 2026-09-14 this gate saw only
20
+ bracketed citations — while six genuine works that personas' own tables and
21
+ prose pointed at had never been declared (see `_bare_ids`).
17
22
  """
18
23
  from __future__ import annotations
19
24
 
20
25
  import json
21
26
  import re
22
27
  import sys
28
+ from collections import Counter
23
29
  from dataclasses import dataclass
24
30
  from pathlib import Path
25
31
 
26
32
  sys.path.insert(0, str(Path(__file__).resolve().parent))
27
- from verify_citations import audit_answer, load_declared_ids, load_member_aliases # noqa: E402
33
+ from verify_citations import ( # noqa: E402
34
+ audit_answer,
35
+ extract_citation_ids,
36
+ load_declared_ids,
37
+ load_member_aliases,
38
+ load_title_aliases,
39
+ )
28
40
 
29
41
  PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
30
42
 
@@ -47,12 +59,14 @@ _TEMPLATE_MARKERS = (
47
59
  # been permitted. Do not add to it to turn a red build green — that is exactly
48
60
  # the failure this gate exists to prevent.
49
61
  #
50
- # Empty as of 2026-09-03. Both findings this gate ever recorded were resolved
62
+ # Empty as of 2026-09-14. The first two findings this gate recorded were resolved
51
63
  # by declaring the source: `Toh:3861` in master-tsongkhapa/meta.json (月称《入
52
64
  # 中论》is a real Tengyur text Tsongkhapa's tradition treats as its own
53
65
  # foundation) and `J36nB348` in master-ouyi/meta.json (《灵峰宗论》is Ouyi's own
54
66
  # collected works). Neither needed a B1 contract change — both simply belonged
55
- # in the declared set. See CHANGELOG.md for the maintainer decision.
67
+ # in the declared set. See CHANGELOG.md for the maintainer decision. The six the
68
+ # bare-id sweep found on 2026-09-14 were resolved the same way, never entered
69
+ # here.
56
70
  KNOWN_UNDECLARED: dict[tuple[str, str], str] = {}
57
71
 
58
72
 
@@ -77,6 +91,33 @@ def _strip_template_citations(text: str) -> str:
77
91
  return "".join(out)
78
92
 
79
93
 
94
+ # `BDRC W-number` in a persona's own rules names a field, it is not an id:
95
+ # master-tsongkhapa says 不得编造未验证的 BDRC W-number, master-atisha asks for
96
+ # a BDRC W-ID. `_FAMILY_ID` stays loose on purpose — in an answer, reading too
97
+ # much fails safe as fabricated (see the note above `_FOJIN_TEXT_LINK` in
98
+ # verify_citations.py) — so it reads both as ids. Only this prose sweep drops
99
+ # them: a real BDRC work id is W followed by a digit (W22272, W1KG14334), the
100
+ # rule the auditor's own bare-W branch already applies.
101
+ _BDRC_FIELD_NAME = re.compile(r"^BDRC:W(?![0-9])")
102
+
103
+
104
+ def _bare_ids(text: str) -> list[str]:
105
+ """Source ids written outside every 【…】 block: table cells, prose, frontmatter.
106
+
107
+ `audit_answer` reads only bracketed citations, so a persona's routing table
108
+ was invisible to this gate. master-xuanzang's SKILL.md sent 五位百法
109
+ questions to `《百法明门论》,T31n1614` in a table cell; master-ouyi had an
110
+ offline excerpt file for 《教觀綱宗》 T46n1939. Neither id was declared.
111
+
112
+ Callers audit each id alone, as `【id】` with nothing after it, so a FoJin
113
+ link in the same table row cannot make it `live`: in an answer a link can
114
+ vouch for one citation, but in the persona's own material a link is not a
115
+ declaration.
116
+ """
117
+ ids = extract_citation_ids(_BLOCK.sub(" ", text))
118
+ return [cid for cid in ids if not _BDRC_FIELD_NAME.match(cid)]
119
+
120
+
80
121
  @dataclass(frozen=True)
81
122
  class Finding:
82
123
  master: str
@@ -84,8 +125,12 @@ class Finding:
84
125
  path: str
85
126
 
86
127
 
87
- def find_undeclared(prebuilt_dir: Path) -> list[Finding]:
88
- """Every citation the personas' own material makes that meta.json omits."""
128
+ def find_undeclared(prebuilt_dir: Path, reach: Counter | None = None) -> list[Finding]:
129
+ """Every citation the personas' own material makes that meta.json omits.
130
+
131
+ `reach`, when given, counts what was read: bracketed citations the audit
132
+ could resolve to an id, and bare ids outside brackets.
133
+ """
89
134
  findings: list[Finding] = []
90
135
  for persona in sorted(Path(prebuilt_dir).iterdir()):
91
136
  meta_path = persona / "meta.json"
@@ -100,6 +145,13 @@ def find_undeclared(prebuilt_dir: Path) -> list[Finding]:
100
145
  try:
101
146
  declared = load_declared_ids(persona.name, base=str(prebuilt_dir))
102
147
  aliases = load_member_aliases(persona.name, base=str(prebuilt_dir))
148
+ # Declared titles, as reaudit-report.py and test-fidelity.py already
149
+ # pass them. Without them a source with no sutra number is unreadable:
150
+ # master-yinguang's own 【《印光法師文鈔正編》卷一】 examples counted as
151
+ # nothing, and the sweep read fewer citations after the Wenchao was
152
+ # re-declared. An alias only makes a block readable; it cannot turn an
153
+ # undeclared id into a declared one.
154
+ titles = load_title_aliases(persona.name, base=str(prebuilt_dir))
103
155
  except (FileNotFoundError, ValueError):
104
156
  continue # meta.json exists (meta_path.is_file() above) but is unreadable
105
157
  if not declared:
@@ -111,7 +163,17 @@ def find_undeclared(prebuilt_dir: Path) -> list[Finding]:
111
163
  if not doc.is_file():
112
164
  continue
113
165
  text = _strip_template_citations(doc.read_text(encoding="utf-8"))
114
- for citation in dict.fromkeys(audit_answer(declared, text, aliases)["fabricated"]):
166
+ bracketed = audit_answer(declared, text, aliases, titles)
167
+ undeclared = list(bracketed["fabricated"])
168
+ bare = _bare_ids(text)
169
+ for cid in bare:
170
+ undeclared += audit_answer(declared, f"【{cid}】", aliases)["fabricated"]
171
+ if reach is not None:
172
+ reach["bracketed"] += sum(
173
+ len(bracketed[bucket]) for bucket in ("offline", "live", "fabricated")
174
+ )
175
+ reach["bare"] += len(bare)
176
+ for citation in dict.fromkeys(undeclared):
115
177
  findings.append(
116
178
  Finding(persona.name, citation, str(doc.relative_to(prebuilt_dir.parent)))
117
179
  )
@@ -119,7 +181,18 @@ def find_undeclared(prebuilt_dir: Path) -> list[Finding]:
119
181
 
120
182
 
121
183
  def main() -> int:
122
- findings = find_undeclared(PREBUILT_DIR)
184
+ reach: Counter = Counter()
185
+ findings = find_undeclared(PREBUILT_DIR, reach)
186
+ print(
187
+ f"Read {reach['bracketed']} bracketed citations and {reach['bare']} bare "
188
+ "source ids in the personas' own docs."
189
+ )
190
+ if not reach["bracketed"] or not reach["bare"]:
191
+ print(
192
+ "FAIL: the sweep read nothing of one kind — it is not looking at what "
193
+ "it claims to check."
194
+ )
195
+ return 1
123
196
  known, new = [], []
124
197
  for f in findings:
125
198
  (known if (f.master, f.citation) in KNOWN_UNDECLARED else new).append(f)
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env python3
2
+ """Gate: a persona's self-audit list must include every source it declares.
3
+
4
+ Nine personas end SKILL.md with the same pre-answer rule (B1): before replying,
5
+ check that each offline citation's identifier "∈ 本 master frontmatter `sources:`
6
+ 声明的对应字段", and strip any claim that fails. The list the model checks
7
+ against is the frontmatter, not meta.json, and nothing compared the two.
8
+
9
+ master-ouyi declares 《灵峰宗论》 `J36nB348` in meta.json, and its own
10
+ references/teaching.md cites it, but its frontmatter never listed it. A model
11
+ following the rule to the letter deletes a correct citation of Ouyi's own
12
+ collected works. The audit would never notice: it reads meta.json.
13
+
14
+ Personas whose rule points at meta.json instead are not examined here.
15
+
16
+ Usage:
17
+ python3 scripts/validate-self-audit-sources.py
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ import yaml
27
+
28
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
29
+ from verify_citations import audit_answer # noqa: E402
30
+
31
+ PREBUILT = Path(__file__).resolve().parent.parent / "prebuilt"
32
+
33
+ # The phrase every frontmatter-pointing rule uses. If the wording changes, the
34
+ # gate examines nothing and says so instead of passing.
35
+ _RULE = "frontmatter `sources:`"
36
+
37
+ # The identifier fields the rule itself enumerates.
38
+ _ID_FIELDS = (
39
+ "cbeta_id", "toh_id", "bdrc_id", "pts_id", "suttacentral", "suttacentral_id", "teaching_id",
40
+ )
41
+
42
+
43
+ def _frontmatter_ids(text: str) -> list[str]:
44
+ parts = text.split("---", 2)
45
+ if len(parts) < 3 or parts[0].strip():
46
+ return []
47
+ front = yaml.safe_load(parts[1]) or {}
48
+ ids: list[str] = []
49
+ for src in front.get("sources") or []:
50
+ if isinstance(src, dict):
51
+ ids += [str(src[field]) for field in _ID_FIELDS if src.get(field) is not None]
52
+ return ids
53
+
54
+
55
+ def _covered(declared_id: str, listed: list[str]) -> bool:
56
+ """Does some frontmatter identifier name `declared_id`, in a spelling the audit accepts?
57
+
58
+ The resolution is the auditor's own. master-zhiyi's frontmatter writes
59
+ `T1716` for the declared `T33n1716`; master-milarepa writes `W22272` for
60
+ `BDRC:W22272`. A model citing either passes the audit, so the list covers
61
+ the source. Reimplementing those rules here would drift from them.
62
+ """
63
+ return any(
64
+ declared_id in audit_answer({declared_id}, f"【{value}】")["offline"] for value in listed
65
+ )
66
+
67
+
68
+ def check_persona(persona: Path) -> tuple[list[str], bool]:
69
+ """Return (problems, whether this persona was examined)."""
70
+ skill, meta = persona / "SKILL.md", persona / "meta.json"
71
+ if not skill.is_file() or not meta.is_file():
72
+ return [], False
73
+ text = skill.read_text(encoding="utf-8")
74
+ if _RULE not in text:
75
+ return [], False
76
+ rule_line = text[: text.index(_RULE)].count("\n") + 1
77
+ listed = _frontmatter_ids(text)
78
+ problems: list[str] = []
79
+ for src in json.loads(meta.read_text(encoding="utf-8")).get("sources", []):
80
+ source_id = src.get("id")
81
+ if source_id and not _covered(source_id, listed):
82
+ problems.append(
83
+ f"{persona.name}: meta.json declares {source_id} ({src.get('title', '')}) "
84
+ f"but the frontmatter `sources:` never lists it. The self-audit rule at "
85
+ f"SKILL.md:{rule_line} checks citations against that list, so a correct "
86
+ f"citation of this source would be stripped."
87
+ )
88
+ return problems, True
89
+
90
+
91
+ def main(prebuilt: Path = PREBUILT) -> int:
92
+ problems: list[str] = []
93
+ examined: list[str] = []
94
+ for persona in sorted(p for p in prebuilt.iterdir() if p.is_dir()):
95
+ found, looked = check_persona(persona)
96
+ problems += found
97
+ if looked:
98
+ examined.append(persona.name)
99
+
100
+ if not examined:
101
+ print(f"✗ no persona's SKILL.md contains {_RULE!r} — this check examined nothing")
102
+ return 1
103
+ if problems:
104
+ print(f"✗ {len(problems)} declared source(s) missing from a self-audit list:\n")
105
+ for problem in problems:
106
+ print(f" - {problem}")
107
+ return 1
108
+ print(
109
+ f"✓ self-audit sources ok — {len(examined)} persona(s) check citations against "
110
+ f"their frontmatter: {', '.join(examined)}"
111
+ )
112
+ return 0
113
+
114
+
115
+ if __name__ == "__main__":
116
+ sys.exit(main())
@@ -617,6 +617,7 @@ def audit_answer(
617
617
  fabricated: list[str] = []
618
618
  unparsed: list[str] = []
619
619
  noncitation: list[str] = []
620
+ live_detail: list[dict] = []
620
621
 
621
622
  blocks = list(_CITATION_BLOCK.finditer(answer))
622
623
  for idx, m in enumerate(blocks):
@@ -679,11 +680,22 @@ def audit_answer(
679
680
  unresolved.append(cid)
680
681
  if len(unresolved) == 1 and links:
681
682
  live.append((unresolved[0], links[0]))
683
+ # `--online` needs more than the pair to tell whether the link is the
684
+ # work the citation names: the title the block gives it.
685
+ title = _WORK_TITLE.search(m.group(1))
686
+ live_detail.append(
687
+ {
688
+ "cited_id": unresolved[0],
689
+ "text_id": links[0],
690
+ "title": title.group(1) if title else None,
691
+ }
692
+ )
682
693
  else:
683
694
  fabricated.extend(unresolved)
684
695
  return {
685
696
  "offline": offline,
686
697
  "live": live,
698
+ "live_detail": live_detail,
687
699
  "fabricated": fabricated,
688
700
  "unparsed": unparsed,
689
701
  "noncitation": noncitation,
@@ -701,8 +713,58 @@ VERIFY_TIMEOUT = 15
701
713
  VERIFY_WORKERS = 8
702
714
 
703
715
 
704
- def _check_one_text_id(session_factory, base_url: str, tid: str, timeout: int):
705
- """核验单个 text_id,返回 (三态结果, 说明)。异常一律收敛成 None,不外抛。"""
716
+ def _cbeta_key(cid: str) -> tuple[str, str] | None:
717
+ """`T30n1568` / `T1568` / `JB348` → (藏别, 去零经号);不是 CBETA 号 → None"""
718
+ m = _FULL_FORM.match(cid) or _SHORT_FORM.match(cid)
719
+ if not m:
720
+ return None
721
+ return m.group(1), _normalize_cbeta_work_number(m.group(2))
722
+
723
+
724
+ def _titles_agree(cited: str, linked: str) -> bool | None:
725
+ """借用 tools/verify_sources.titles_agree(读音子序列);加载不了返回 None。"""
726
+ tools = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "tools")
727
+ if tools not in sys.path:
728
+ sys.path.insert(0, tools)
729
+ try:
730
+ from verify_sources import titles_agree
731
+ except ImportError:
732
+ return None
733
+ return titles_agree(cited, linked)
734
+
735
+
736
+ def _live_link_mismatch(citation: dict, payload: dict) -> str | None:
737
+ """一个解析得到的 FoJin 文本,为什么仍不是这条 live 引文所说的那部书;可能是则 None。
738
+
739
+ 只验「打得开」时,【《伪经》,T99n9999】→ texts/20 能过:texts/20 是
740
+ 《佛說阿彌陀經》。master-yinguang 的存档引文更隐蔽:X62n1182 → texts/12977,
741
+ 链接的 cbeta_id 正是 X1182,经号自洽,可那部书是《徹悟禪師語錄》而不是引文
742
+ 写的《印光法師文鈔正編》。所以比两样:经号(只对 CBETA 号),书名(引文给了
743
+ 才比)。书名先截掉「·品名」「〈篇名〉」,篇章不是书名的一部分。
744
+ """
745
+ tid = citation.get("text_id")
746
+ cited = citation.get("cited_id") or ""
747
+ linked_id = payload.get("cbeta_id")
748
+ cited_key = _cbeta_key(cited)
749
+ if cited_key and linked_id and _cbeta_key(str(linked_id)) != cited_key:
750
+ return f"texts/{tid} 是 {linked_id},不是引文写的 {cited}"
751
+ title = citation.get("title")
752
+ linked_title = payload.get("title_zh")
753
+ if title and linked_title:
754
+ book = re.split(r"[·・‧〈<]", title, maxsplit=1)[0].strip()
755
+ if book and _titles_agree(book, str(linked_title)) is False:
756
+ return f"引文题名《{title}》对不上 texts/{tid} 的《{linked_title}》"
757
+ return None
758
+
759
+
760
+ def _check_one_text_id(
761
+ session_factory, base_url: str, tid: str, timeout: int, citations=()
762
+ ):
763
+ """核验单个 text_id,返回 (三态结果, 说明)。异常一律收敛成 None,不外抛。
764
+
765
+ `citations` 是用这个 text_id 作链接的 live 引文;给了就还要核对链接是不是
766
+ 它们所说的那部书(见 `_live_link_mismatch`),对不上判 False。
767
+ """
706
768
  try:
707
769
  resp = session_factory().get(f"{base_url}/api/texts/{tid}", timeout=timeout)
708
770
  except Exception as e: # noqa: BLE001 — 传输层失败是"不知道",不是"不存在"
@@ -717,6 +779,11 @@ def _check_one_text_id(session_factory, base_url: str, tid: str, timeout: int):
717
779
  # 200 却不是 JSON:通常是网关错误页,不能据此断定 id 不存在。
718
780
  return None, "200 但正文不是 JSON"
719
781
  if payload:
782
+ if isinstance(payload, dict):
783
+ for citation in citations:
784
+ mismatch = _live_link_mismatch(citation, payload)
785
+ if mismatch:
786
+ return False, mismatch
720
787
  return True, ""
721
788
  # 200 + 空正文归 None,不归 False —— 上面刚写下「404 是唯一该硬失败的信号」,
722
789
  # 这里返回 False 就是在自己的契约上开口子。空信封可能来自 FoJin 换了外层结构、
@@ -758,6 +825,7 @@ def verify_online(
758
825
  base_url: str = "https://fojin.app",
759
826
  timeout: int = VERIFY_TIMEOUT,
760
827
  workers: int = VERIFY_WORKERS,
828
+ citations: list[dict] | None = None,
761
829
  ) -> OnlineVerification:
762
830
  """best-effort:GET /api/texts/{id} 看 live 引文的 text_id 是否真解析。
763
831
 
@@ -770,6 +838,9 @@ def verify_online(
770
838
  return OnlineVerification({}, {}, "requests 未安装")
771
839
 
772
840
  unique = sorted(set(text_ids))
841
+ by_tid: dict[str, list[dict]] = {}
842
+ for citation in citations or ():
843
+ by_tid.setdefault(str(citation.get("text_id")), []).append(citation)
773
844
  if not unique:
774
845
  return OnlineVerification({}, {})
775
846
 
@@ -785,7 +856,14 @@ def verify_online(
785
856
  reasons: dict[str, str] = {}
786
857
  with ThreadPoolExecutor(max_workers=max(1, min(workers, len(unique)))) as pool:
787
858
  futures = {
788
- pool.submit(_check_one_text_id, session_factory, base_url, tid, timeout): tid
859
+ pool.submit(
860
+ _check_one_text_id,
861
+ session_factory,
862
+ base_url,
863
+ tid,
864
+ timeout,
865
+ by_tid.get(tid, ()),
866
+ ): tid
789
867
  for tid in unique
790
868
  }
791
869
  for future in as_completed(futures):
@@ -807,7 +885,10 @@ def main() -> int:
807
885
  p = argparse.ArgumentParser(description="B1 引证核验器")
808
886
  p.add_argument("--master", required=True, help="master slug,如 huineng")
809
887
  p.add_argument("--answer-file", help="答案文件;省略则从 stdin 读")
810
- p.add_argument("--online", action="store_true", help="额外验证 live 引文 text_id 可解析")
888
+ p.add_argument(
889
+ "--online", action="store_true",
890
+ help="额外验证 live 引文:链接可解析,且是引文所说的那部书(经号、书名)",
891
+ )
811
892
  args = p.parse_args()
812
893
 
813
894
  try:
@@ -830,7 +911,9 @@ def main() -> int:
830
911
  exit_code = 1
831
912
 
832
913
  if args.online and report["live"]:
833
- res = verify_online([tid for _, tid in report["live"]])
914
+ res = verify_online(
915
+ [tid for _, tid in report["live"]], citations=report["live_detail"]
916
+ )
834
917
  if res.unreachable:
835
918
  print(f"⚠ --online 跳过:FoJin 不可达({res.unreachable})", file=sys.stderr)
836
919
  else:
@@ -838,7 +921,10 @@ def main() -> int:
838
921
  # 网络状况,不是引文真伪,拿它判 fabricated 会在 FoJin 抖动时把正确
839
922
  # 引用打成伪造,那比漏检更糟。
840
923
  if res.fabricated:
841
- print(f"✗ live 引文 text_id 无法解析: {res.fabricated}", file=sys.stderr)
924
+ detail = "; ".join(
925
+ f"{t}({res.reasons.get(t, '?')})" for t in res.fabricated
926
+ )
927
+ print(f"✗ live 引文链接无法解析或不是所引之书: {detail}", file=sys.stderr)
842
928
  exit_code = 1
843
929
  if res.unknown:
844
930
  # 报出来而不是静默算过 —— 「没查成」必须与「查过没问题」可区分。