master-skill 0.12.1 → 0.12.3

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 (59) 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-fazang/SKILL.md +6 -3
  11. package/prebuilt/master-fazang/meta.json +5 -0
  12. package/prebuilt/master-fazang/references/teaching.md +10 -5
  13. package/prebuilt/master-fazang/sources/INDEX.md +1 -1
  14. package/prebuilt/master-fazang/sources/jinshizi-excerpts.md +12 -8
  15. package/prebuilt/master-fazang/sources/wujiao-zhang-excerpts.md +20 -12
  16. package/prebuilt/master-fazang/tests/fidelity.jsonl +2 -2
  17. package/prebuilt/master-kumarajiva/SKILL.md +9 -0
  18. package/prebuilt/master-kumarajiva/meta.json +15 -0
  19. package/prebuilt/master-kumarajiva/references/teaching.md +4 -2
  20. package/prebuilt/master-kumarajiva/references/voice.md +1 -1
  21. package/prebuilt/master-kumarajiva/sources/zhonglun-excerpts.md +6 -2
  22. package/prebuilt/master-nagarjuna/references/teaching.md +5 -3
  23. package/prebuilt/master-nagarjuna/sources/dazhidulun-excerpts.md +13 -19
  24. package/prebuilt/master-nagarjuna/sources/shizhu-yixing-excerpts.md +5 -5
  25. package/prebuilt/master-nagarjuna/sources/zhonglun-excerpts.md +22 -1
  26. package/prebuilt/master-ouyi/SKILL.md +8 -0
  27. package/prebuilt/master-ouyi/meta.json +10 -0
  28. package/prebuilt/master-ouyi/references/teaching.md +2 -2
  29. package/prebuilt/master-ouyi/sources/jiaoguan-gangzong-excerpts.md +9 -7
  30. package/prebuilt/master-ouyi/sources/mituo-yaojie-excerpts.md +7 -6
  31. package/prebuilt/master-xuanzang/SKILL.md +6 -0
  32. package/prebuilt/master-xuanzang/meta.json +10 -0
  33. package/prebuilt/master-xuanzang/sources/chengweishi-excerpts.md +2 -2
  34. package/prebuilt/master-xuyun/SKILL.md +6 -0
  35. package/prebuilt/master-xuyun/meta.json +17 -1
  36. package/prebuilt/master-xuyun/references/teaching.md +5 -5
  37. package/prebuilt/master-xuyun/sources/lengyanjing-excerpts.md +1 -1
  38. package/prebuilt/master-yinguang/SKILL.md +16 -13
  39. package/prebuilt/master-yinguang/meta.json +18 -15
  40. package/prebuilt/master-yinguang/references/teaching.md +6 -6
  41. package/prebuilt/master-yinguang/references/voice.md +1 -1
  42. package/prebuilt/master-yinguang/sources/INDEX.md +7 -6
  43. package/prebuilt/master-yinguang/sources/wenchao-excerpts.md +5 -5
  44. package/prebuilt/master-yinguang/sources/yihanbianfu-excerpts.md +4 -4
  45. package/prebuilt/master-yinguang/tests/fidelity.jsonl +7 -7
  46. package/prebuilt/master-zhiyi/SKILL.md +4 -1
  47. package/prebuilt/master-zhiyi/meta.json +6 -1
  48. package/prebuilt/master-zhiyi/references/teaching.md +3 -3
  49. package/prebuilt/master-zhiyi/references/voice.md +1 -1
  50. package/prebuilt/master-zhiyi/sources/INDEX.md +1 -1
  51. package/prebuilt/master-zhiyi/sources/fahua-xuanyi-excerpts.md +18 -15
  52. package/prebuilt/master-zhiyi/sources/mohezhiguan-excerpts.md +6 -2
  53. package/references/source-conventions.md +2 -2
  54. package/scripts/check-pe-subsystem.py +64 -0
  55. package/scripts/reaudit-report.py +45 -3
  56. package/scripts/validate-citation-references.py +80 -7
  57. package/scripts/validate-self-audit-sources.py +116 -0
  58. package/scripts/verify_citations.py +92 -6
  59. package/tools/verify_sources.py +496 -5
@@ -365,13 +365,13 @@ def classify_cbeta_volumes(
365
365
  return mismatched, sorted(unknown)
366
366
 
367
367
 
368
- def fetch_cbeta_volumes(full_ids: list[str]) -> dict[str, str | None]:
369
- """向 CBETA 问每个经号所属的卷(或卷区间);问不到的记 None(未知,不是不符)。"""
368
+ def fetch_cbeta_works(full_ids: list[str]) -> dict[str, dict | None]:
369
+ """向 CBETA 问每个经号的卷(或卷区间)与题名;问不到的记 None(未知,不是不符)。"""
370
370
  import urllib.error
371
371
  import urllib.parse
372
372
  import urllib.request
373
373
 
374
- out: dict[str, str | None] = {}
374
+ out: dict[str, dict | None] = {}
375
375
  for full_id in full_ids:
376
376
  short = full_to_short_cbeta(full_id)
377
377
  if not short:
@@ -382,12 +382,424 @@ def fetch_cbeta_volumes(full_ids: list[str]) -> dict[str, str | None]:
382
382
  with urllib.request.urlopen(url, timeout=CBETA_TIMEOUT) as resp:
383
383
  payload = json.loads(resp.read().decode("utf-8"))
384
384
  results = payload.get("results") or []
385
- out[full_id] = results[0].get("vol") if results else None
385
+ out[full_id] = (
386
+ {"vol": results[0].get("vol"), "title": results[0].get("title")}
387
+ if results
388
+ else None
389
+ )
386
390
  except (urllib.error.URLError, OSError, ValueError, KeyError, IndexError):
387
391
  out[full_id] = None
388
392
  return out
389
393
 
390
394
 
395
+ def _title_syllables(title: str | None) -> list[set[str]]:
396
+ """题名 → 逐字读音集合。去掉括注,只留汉字;多音字保留全部读音。"""
397
+ from pypinyin import Style, pinyin
398
+
399
+ bare = re.sub(r"[((][^))]*[))]", "", title or "")
400
+ bare = "".join(ch for ch in bare if "\u3400" <= ch <= "\u9fff" or "\uf900" <= ch <= "\ufaff")
401
+ return [set(readings) for readings in pinyin(bare, style=Style.NORMAL, heteronym=True)]
402
+
403
+
404
+ def titles_agree(declared: str, cbeta: str | None) -> bool | None:
405
+ """声明题名按读音是否为 CBETA 题名的子序列。
406
+
407
+ 经号在 FoJin 查得到、卷号也对,仍可能是另一部书:master-yinguang 把《印光
408
+ 法师文钞》声明成 X62n1182–1184,CBETA 那三号是《徹悟禪師語錄》《淨業知津》
409
+ 《念佛百問》,卷号 X62 分毫不差,这道周检一直是绿的。
410
+
411
+ 声明用简体、常用简称(《大佛顶首楞严经》),CBETA 用繁体全称,逐字比两边都
412
+ 会误报。按读音比,繁简同音即对得上,简称是全称的子序列也对得上;另一部书一
413
+ 个音都对不上。用读音而不用繁简转换表,是因为仓库已依赖 pypinyin。已知边界:
414
+ 过短的题名可能碰巧是别书题名的子序列。
415
+
416
+ 返回 None 表示比不了(任一侧没有汉字),既不是对也不是错。
417
+ """
418
+ mine, theirs = _title_syllables(declared), _title_syllables(cbeta)
419
+ if not mine or not theirs:
420
+ return None
421
+ position = 0
422
+ for readings in theirs:
423
+ if position < len(mine) and mine[position] & readings:
424
+ position += 1
425
+ return position == len(mine)
426
+
427
+
428
+ def collect_declared_titles() -> dict[str, list[str]]:
429
+ """{完整经号: [各 meta.json 为它声明的题名]} —— 同一部经可能被几位祖师声明。"""
430
+ titles: dict[str, list[str]] = {}
431
+ for teacher in sorted(os.listdir(PREBUILT_DIR)):
432
+ meta_path = os.path.join(PREBUILT_DIR, teacher, "meta.json")
433
+ if not os.path.isfile(meta_path):
434
+ continue
435
+ with open(meta_path, encoding="utf-8") as f:
436
+ meta = json.load(f)
437
+ for src in meta.get("sources", []):
438
+ if src.get("type") == "cbeta" and src.get("id") and src.get("title"):
439
+ known = titles.setdefault(src["id"], [])
440
+ if src["title"] not in known:
441
+ known.append(src["title"])
442
+ return titles
443
+
444
+
445
+ def classify_cbeta_titles(
446
+ declared: dict[str, list[str]], cbeta_titles: dict[str, str | None]
447
+ ) -> tuple[dict[str, tuple[list[str], str | None]], list[str]]:
448
+ """把声明题名分成「与 CBETA 对不上」与「比不了」两类,三态同卷号检查。
449
+
450
+ 一个 id 若有一条题名对不上,就算不符 —— 另一条比不了的题名不能把它盖住。
451
+ """
452
+ mismatched: dict[str, tuple[list[str], str | None]] = {}
453
+ unknown: list[str] = []
454
+ for full_id, mine in declared.items():
455
+ theirs = cbeta_titles.get(full_id)
456
+ verdicts = {title: titles_agree(title, theirs) for title in mine}
457
+ wrong = [title for title, verdict in verdicts.items() if verdict is False]
458
+ if wrong:
459
+ mismatched[full_id] = (wrong, theirs)
460
+ elif not mine or any(verdict is None for verdict in verdicts.values()):
461
+ unknown.append(full_id)
462
+ return mismatched, sorted(unknown)
463
+
464
+
465
+ def collect_frontmatter_fojin_ids() -> list[tuple[str, str, str, str]]:
466
+ """(祖师目录, 题名, cbeta_id, fojin_text_id),取自各 SKILL.md frontmatter 的 sources。"""
467
+ import yaml
468
+
469
+ rows: list[tuple[str, str, str, str]] = []
470
+ for teacher in sorted(os.listdir(PREBUILT_DIR)):
471
+ path = os.path.join(PREBUILT_DIR, teacher, "SKILL.md")
472
+ if not os.path.isfile(path):
473
+ continue
474
+ with open(path, encoding="utf-8") as f:
475
+ parts = f.read().split("---", 2)
476
+ if len(parts) < 3 or parts[0].strip():
477
+ continue
478
+ front = yaml.safe_load(parts[1]) or {}
479
+ for src in front.get("sources") or []:
480
+ if isinstance(src, dict) and src.get("cbeta_id") and src.get("fojin_text_id") is not None:
481
+ rows.append(
482
+ (teacher, str(src.get("title", "")), str(src["cbeta_id"]), str(src["fojin_text_id"]))
483
+ )
484
+ return rows
485
+
486
+
487
+ def classify_frontmatter_fojin_ids(
488
+ rows: list[tuple[str, str, str, str]], short_to_text: dict[str, object]
489
+ ) -> tuple[list[tuple[str, str, str, str, str]], list[str]]:
490
+ """frontmatter 的 fojin_text_id 与 FoJin 对该经号的解析结果不符的条目。
491
+
492
+ 这个 id 不进审计,却是人设给读者拼链接用的:master-zhiyi 把《法華玄義》
493
+ (T1716)写成 52,那是《法華文句》的 text id。frontmatter 里完整号与短号
494
+ (`T1716`)两种写法都有,一律折成短号再比。查不到的记为未知,不算错。
495
+ """
496
+ mismatched: list[tuple[str, str, str, str, str]] = []
497
+ unknown: list[str] = []
498
+ for teacher, title, cbeta_id, written in rows:
499
+ short = full_to_short_cbeta(cbeta_id) if FULL_CBETA_RE.match(cbeta_id) else cbeta_id
500
+ actual = short_to_text.get(short)
501
+ if actual is None:
502
+ unknown.append(f"{teacher}:{cbeta_id}")
503
+ elif str(actual) != written:
504
+ mismatched.append((teacher, cbeta_id, title, written, str(actual)))
505
+ return mismatched, sorted(unknown)
506
+
507
+
508
+ _DOC_CITATION = re.compile(r"【([^】]*)】")
509
+ _DOC_FOJIN_LINK = re.compile(r"https://fojin\.app/texts/([0-9]+)")
510
+ _DOC_CBETA_ID = re.compile(r"(?<![0-9A-Za-z])([TXJ])(?:[0-9]{1,3}n)?(B?[0-9]{3,5})[a-z]?(?![0-9A-Za-z])")
511
+ _DOC_TEMPLATE = re.compile(r"\{|卷N|[A-Za-z][xX]{3,}")
512
+
513
+
514
+ def _cbeta_work(cid: str) -> tuple[str, str] | None:
515
+ """`T33n1716` / `T1716` → ("T", "1716"),经号去零;不是 CBETA 号 → None。"""
516
+ m = _DOC_CBETA_ID.fullmatch(cid.strip())
517
+ if not m:
518
+ return None
519
+ number = m.group(2)
520
+ return m.group(1), ("B" + str(int(number[1:]))) if number.startswith("B") else str(int(number))
521
+
522
+
523
+ def collect_doc_citation_links() -> list[tuple[str, str, list[str], str | None]]:
524
+ """(位置, text_id, 引文里的 CBETA 号, 引文书名):人设文档里每个后面同一行跟着
525
+ FoJin 数字链接的引文块。
526
+
527
+ 只认同一行、且在下一个引文块之前的链接。第一次核查用 120 字符窗口,把
528
+ master-yinguang 一条没有链接的引文和两行之后表格里《佛說阿彌陀經》的链接
529
+ 配成了一对。格式模板(`{title}`、`卷N`、`Wxxxxx`)不是引文,跳过。
530
+ """
531
+ pairs: list[tuple[str, str, list[str], str | None]] = []
532
+ base = Path(PREBUILT_DIR)
533
+ files = sorted(base.glob("*/SKILL.md")) + sorted(base.glob("*/references/*.md")) + sorted(base.glob("*/sources/*.md"))
534
+ for path in files:
535
+ where = path.relative_to(base).as_posix()
536
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
537
+ for block in _DOC_CITATION.finditer(line):
538
+ if _DOC_TEMPLATE.search(block.group(1)):
539
+ continue
540
+ rest = line[block.end():]
541
+ following = _DOC_CITATION.search(rest)
542
+ link = _DOC_FOJIN_LINK.search(rest[: following.start()] if following else rest)
543
+ if not link:
544
+ continue
545
+ ids = [m.group(0) for m in _DOC_CBETA_ID.finditer(block.group(1))]
546
+ title = re.search(r"《([^》]+)》", block.group(1))
547
+ pairs.append((f"{where}:{number}", link.group(1), ids, title.group(1) if title else None))
548
+ return pairs
549
+
550
+
551
+ def classify_doc_citation_links(
552
+ pairs: list[tuple[str, str, list[str], str | None]], records: dict[str, dict | None]
553
+ ) -> tuple[list[tuple[str, str, str]], list[str]]:
554
+ """哪些文档链接打开的不是引文所说的那部书;FoJin 没给出记录的记为未知。
555
+
556
+ 比两样:链接文本的经号是否是引文里的某个号,书名(截掉「·品名」)是否与
557
+ `title_zh` 读音对得上(`titles_agree`)。
558
+ """
559
+ mismatched: list[tuple[str, str, str]] = []
560
+ unknown: list[str] = []
561
+ for where, tid, ids, title in pairs:
562
+ record = records.get(tid)
563
+ if not record:
564
+ unknown.append(where)
565
+ continue
566
+ problems = []
567
+ linked = record.get("cbeta_id")
568
+ wanted = {_cbeta_work(c) for c in ids} - {None}
569
+ if wanted and linked and _cbeta_work(str(linked)) not in wanted:
570
+ problems.append(f"texts/{tid} 是 {linked},不是 {'/'.join(ids)}")
571
+ linked_title = record.get("title_zh")
572
+ if title and linked_title:
573
+ book = re.split(r"[·・‧〈<]", title, maxsplit=1)[0].strip()
574
+ if book and titles_agree(book, str(linked_title)) is False:
575
+ problems.append(f"《{title}》对不上 texts/{tid} 的《{linked_title}》")
576
+ if problems:
577
+ mismatched.append((where, tid, ";".join(problems)))
578
+ return mismatched, unknown
579
+
580
+
581
+ # Step 3f:摘录里的「原典」引文是不是所引那一卷的原文。
582
+ #
583
+ # 前面几步核经号、卷号、题名和链接,都看不见引文本身。2026-09-15 逐句比对
584
+ # `sources/*-excerpts.md` 与 lore_triggers:63 段里 19 段有分句不在所引的那一卷,
585
+ # 其中「宁起有见如须弥山」挂在《大智度论》名下、四法界挂在《五教章》名下、
586
+ # 《摩诃止观》序文写错了讲经的寺名,而人设把这些当原文引给用户。
587
+
588
+ CBETA_JUANS_URL = "https://cbdata.dila.edu.tw/stable/juans"
589
+ # 引文没标卷次时,卷数不超过此数的书整部读;更长的记为未知,请补卷次。
590
+ EXCERPT_WHOLE_WORK_MAX_JUANS = 30
591
+ # 短于此数的分句(「第七」「华严经」)哪里都可能出现,不拿来判对错。
592
+ EXCERPT_MIN_CLAUSE = 4
593
+
594
+ _HAN = re.compile(r"[㐀-鿿豈-﫿]")
595
+ _HAN_RUN = re.compile(r"[㐀-鿿豈-﫿]+")
596
+ _CN_DIGITS = {"〇": 0, "零": 0, "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
597
+ _READINGS: dict[str, frozenset[str]] = {}
598
+
599
+
600
+ def _chinese_number(text: str) -> int | None:
601
+ """「五」「二十一」「一百零八」「一一二」「31」→ 整数;认不出 → None。"""
602
+ if text.isdigit():
603
+ return int(text)
604
+ if text and all(ch in _CN_DIGITS for ch in text):
605
+ return int("".join(str(_CN_DIGITS[ch]) for ch in text))
606
+ total, digit = 0, 0
607
+ for ch in text:
608
+ if ch in _CN_DIGITS:
609
+ digit = _CN_DIGITS[ch]
610
+ elif ch in "十百":
611
+ total += (digit or 1) * (10 if ch == "十" else 100)
612
+ digit = 0
613
+ else:
614
+ return None
615
+ return total + digit or None
616
+
617
+
618
+ def cited_juan(detail: str) -> int | None:
619
+ """引文里「卷五上」「卷31」「卷5·易行品」的卷次;没写或是区间(卷五至卷十、卷3-4)→ None。"""
620
+ match = re.search(r"卷([〇零一二三四五六七八九十百0-9]+)", detail)
621
+ if not match or re.match(r"[上中下]?\s*(?:至|[-–~~、])", detail[match.end():]):
622
+ return None
623
+ return _chinese_number(match.group(1))
624
+
625
+
626
+ def _cbeta_api_work(cid: str) -> str | None:
627
+ """CBETA API 的 work 参数:`T46n1911` / `T1911` → `T1911`,`J36nB348` → `JB348`。"""
628
+ parts = _cbeta_work(cid)
629
+ if not parts:
630
+ return None
631
+ canon, number = parts
632
+ return canon + (number if number.startswith("B") else number.zfill(4))
633
+
634
+
635
+ def collect_excerpt_quotes() -> list[tuple[str, str, str, int | None]]:
636
+ """(位置, 引文, CBETA 号, 卷次):摘录文件里每个「原典」块,与每条 source_ref
637
+ 是 CBETA 号的 lore_triggers。
638
+
639
+ 块的形状是一行以「原典」开头的标签、若干 `>` 行、再一行带【《书名》卷N,经号】
640
+ 的「引用格式」。标成「要义」之类的整理文字不是引文,不收。lore 条目只取「——」
641
+ 之前的部分:CONTRIBUTING §6 允许在原文后用「——」接一句浅释。
642
+ """
643
+ base = Path(PREBUILT_DIR)
644
+ quotes: list[tuple[str, str, str, int | None]] = []
645
+ for path in sorted(base.glob("*/sources/*-excerpts.md")):
646
+ where = path.relative_to(base).as_posix()
647
+ label_line, lines = 0, []
648
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
649
+ if line.startswith("原典"):
650
+ label_line, lines = number, []
651
+ elif not label_line:
652
+ continue
653
+ elif line.startswith(">"):
654
+ lines.append(line[1:].strip())
655
+ elif line.startswith("#"):
656
+ label_line, lines = 0, []
657
+ elif "引用格式" in line:
658
+ citation = _DOC_CITATION.search(line)
659
+ cid = _DOC_CBETA_ID.search(citation.group(1)) if citation else None
660
+ if cid and any(lines):
661
+ quotes.append((f"{where}:{label_line}", "\n".join(lines), cid.group(0), cited_juan(citation.group(1))))
662
+ label_line, lines = 0, []
663
+ for path in sorted(base.glob("*/meta.json")):
664
+ where = path.relative_to(base).as_posix()
665
+ entries = json.loads(path.read_text(encoding="utf-8")).get("lore_triggers") or []
666
+ for index, entry in enumerate(entries):
667
+ cid, _, anchor = str(entry.get("source_ref") or "").partition("#")
668
+ if _cbeta_api_work(cid):
669
+ quote = str(entry.get("content") or "").split("——", 1)[0]
670
+ quotes.append((f"{where}:lore_triggers[{index}]", quote, cid, cited_juan(anchor)))
671
+ return quotes
672
+
673
+
674
+ def cbeta_juan_plain_text(html: str) -> str:
675
+ """`/stable/juans` 返回的 HTML → 正文。
676
+
677
+ 校勘注在末尾的 footnote 区,先切掉:注里的异读(如「含【甲】」)不是这一卷
678
+ 的正文,不能让一段改写的引文靠它对上。
679
+ """
680
+ body = re.split(r"<div[^>]*class=['\"][^'\"]*footnote", html, maxsplit=1)[0]
681
+ return re.sub(r"<[^>]+>", "", body)
682
+
683
+
684
+ def fetch_cbeta_juan_count(work: str) -> int | None:
685
+ """CBETA 记这部书有几卷;问不到记 None(未知,不是不符)。"""
686
+ import urllib.error
687
+ import urllib.parse
688
+ import urllib.request
689
+
690
+ url = f"{CBETA_WORKS_URL}?{urllib.parse.urlencode({'work': work})}"
691
+ try:
692
+ with urllib.request.urlopen(url, timeout=CBETA_TIMEOUT) as resp:
693
+ results = json.loads(resp.read().decode("utf-8")).get("results") or []
694
+ return int(results[0]["juan"]) if results else None
695
+ except (urllib.error.URLError, OSError, ValueError, KeyError, TypeError, AttributeError):
696
+ return None
697
+
698
+
699
+ def fetch_cbeta_juan_text(work: str, juan: int) -> str | None:
700
+ """CBETA 某部某卷的正文;问不到记 None(未知,不是不符)。"""
701
+ import urllib.error
702
+ import urllib.parse
703
+ import urllib.request
704
+
705
+ url = f"{CBETA_JUANS_URL}?{urllib.parse.urlencode({'work': work, 'juan': juan})}"
706
+ try:
707
+ with urllib.request.urlopen(url, timeout=CBETA_TIMEOUT) as resp:
708
+ results = json.loads(resp.read().decode("utf-8")).get("results") or []
709
+ except (urllib.error.URLError, OSError, ValueError, AttributeError):
710
+ return None
711
+ html = "".join(r for r in results if isinstance(r, str))
712
+ return cbeta_juan_plain_text(html) if html else None
713
+
714
+
715
+ def quote_clauses(quote: str) -> list[str]:
716
+ """按标点、省略号切成分句;不足 EXCERPT_MIN_CLAUSE 个汉字的不收。"""
717
+ return [run for run in _HAN_RUN.findall(quote) if len(run) >= EXCERPT_MIN_CLAUSE]
718
+
719
+
720
+ def _readings(text: str) -> list[frozenset[str]]:
721
+ """逐个汉字的全部读音,不看上下文:繁简同音即对得上,与 titles_agree 同理。"""
722
+ from pypinyin import Style, pinyin
723
+
724
+ out: list[frozenset[str]] = []
725
+ for ch in _HAN.findall(text):
726
+ if ch not in _READINGS:
727
+ _READINGS[ch] = frozenset(pinyin(ch, style=Style.NORMAL, heteronym=True)[0])
728
+ out.append(_READINGS[ch])
729
+ return out
730
+
731
+
732
+ def _reading_index(text: str) -> tuple[list[frozenset[str]], dict[str, list[int]]]:
733
+ sequence = _readings(text)
734
+ positions: dict[str, list[int]] = {}
735
+ for i, readings in enumerate(sequence):
736
+ for reading in readings:
737
+ positions.setdefault(reading, []).append(i)
738
+ return sequence, positions
739
+
740
+
741
+ def _clause_found(clause: str, index: tuple[list[frozenset[str]], dict[str, list[int]]]) -> bool:
742
+ wanted = _readings(clause)
743
+ sequence, positions = index
744
+ starts: set[int] = set()
745
+ for reading in wanted[0]:
746
+ starts.update(positions.get(reading, ()))
747
+ return any(
748
+ i + len(wanted) <= len(sequence) and all(w & sequence[i + k] for k, w in enumerate(wanted))
749
+ for i in starts
750
+ )
751
+
752
+
753
+ def excerpt_fascicles(juan: int | None, total: int | None) -> list[int] | None:
754
+ """该读哪几卷。标了卷读那一卷;没标而书不长读整部;否则 None(没法核)。
755
+
756
+ 标的卷超出全书卷数时返回 []:那是卷次写错了,不是没法核。
757
+ """
758
+ if not total:
759
+ return None
760
+ if juan is not None:
761
+ return [juan] if 1 <= juan <= total else []
762
+ return list(range(1, total + 1)) if total <= EXCERPT_WHOLE_WORK_MAX_JUANS else None
763
+
764
+
765
+ def classify_excerpt_quotes(
766
+ quotes: list[tuple[str, str, str, int | None]],
767
+ juan_counts: dict[str, int | None],
768
+ juan_texts: dict[tuple[str, int], str | None],
769
+ ) -> tuple[list[tuple[str, str, list[str]]], list[tuple[str, str]]]:
770
+ """哪些引文有分句不在所引的卷里;没法核的记为未知,不算错。
771
+
772
+ 逐分句找,不要求整段连续:《金师子章》本文在 T45n1880 里与净源注文逐句交错,
773
+ 照抄本文整段是找不到的。按读音比,繁简同音即对得上。已知边界:同音字替换
774
+ 看不出来(「不妄不愚」对得上「不忘不愚」);这道检查抓的是改写、增字、换序
775
+ 与张冠李戴,不是错别字。
776
+ """
777
+ mismatched: list[tuple[str, str, list[str]]] = []
778
+ unknown: list[tuple[str, str]] = []
779
+ indexes: dict[tuple[str, tuple[int, ...]], tuple[list[frozenset[str]], dict[str, list[int]]]] = {}
780
+ for where, quote, cid, juan in quotes:
781
+ work = _cbeta_api_work(cid)
782
+ total = juan_counts.get(work) if work else None
783
+ fascicles = excerpt_fascicles(juan, total)
784
+ if fascicles is None:
785
+ unknown.append((where, f"没标卷次,{work} 共 {total} 卷" if total else f"CBETA 没有返回 {cid} 的卷数"))
786
+ continue
787
+ if not fascicles:
788
+ mismatched.append((where, f"{work} 只有 {total} 卷,引文标的是卷{juan}", []))
789
+ continue
790
+ texts = [juan_texts.get((work, j)) for j in fascicles]
791
+ if any(text is None for text in texts):
792
+ unknown.append((where, f"CBETA 没有返回 {work} 的卷文"))
793
+ continue
794
+ key = (work, tuple(fascicles))
795
+ if key not in indexes:
796
+ indexes[key] = _reading_index("\n".join(texts))
797
+ missing = [clause for clause in quote_clauses(quote) if not _clause_found(clause, indexes[key])]
798
+ if missing:
799
+ mismatched.append((where, f"{work} 卷{juan}" if juan is not None else work, missing))
800
+ return mismatched, unknown
801
+
802
+
391
803
  def verify_ids(bridge, cbeta_map: dict[str, list[str]], titles: dict[str, str]) -> dict[str, dict]:
392
804
  """Verify all CBETA IDs and return {full_cbeta_id: {text_id, short_id, title, ...}}.
393
805
 
@@ -566,7 +978,8 @@ def _run_legacy_link_verification(*, fix: bool) -> int:
566
978
 
567
979
  # Step 3b: 卷号。FoJin 的查询把卷号丢掉了,所以上面那一步结构上看不见它。
568
980
  print("\n[3b/4] Checking declared volume numbers against CBETA...")
569
- cbeta_vols = fetch_cbeta_volumes(sorted(combined_map))
981
+ cbeta_works = fetch_cbeta_works(sorted(combined_map))
982
+ cbeta_vols = {k: (v or {}).get("vol") for k, v in cbeta_works.items()}
570
983
  mismatched, unknown_to_cbeta = classify_cbeta_volumes(combined_map, cbeta_vols)
571
984
  if mismatched:
572
985
  print(f" Declared IDs CBETA disagrees with ({len(mismatched)}):")
@@ -579,6 +992,22 @@ def _run_legacy_link_verification(*, fix: bool) -> int:
579
992
  if not mismatched and not unknown_to_cbeta:
580
993
  print(f" All {len(combined_map)} declared IDs sit in a volume CBETA gives this work")
581
994
 
995
+ # Step 3c: 题名。卷号对、FoJin 查得到,仍可能是另一部书(见 titles_agree)。
996
+ print("\n[3c/4] Checking declared titles against CBETA...")
997
+ declared_titles = collect_declared_titles()
998
+ title_mismatched, title_unknown = classify_cbeta_titles(
999
+ {cid: declared_titles.get(cid, []) for cid in cbeta_map},
1000
+ {k: (v or {}).get("title") for k, v in cbeta_works.items()},
1001
+ )
1002
+ for full_id, (mine, theirs) in sorted(title_mismatched.items()):
1003
+ teachers = ", ".join(combined_map.get(full_id, ["?"]))
1004
+ print(f" [WRONG] {full_id} declared as 《{' / '.join(mine)}》 -> CBETA: 《{theirs}》 (used by: {teachers})")
1005
+ if title_unknown:
1006
+ print(f" Could not compare titles for {len(title_unknown)} ID(s) — "
1007
+ "unknown, not wrong: " + ", ".join(title_unknown))
1008
+ if not title_mismatched and not title_unknown:
1009
+ print(f" All {len(cbeta_map)} declared titles match the work CBETA gives each ID")
1010
+
582
1011
  found = {k: v for k, v in verified.items() if v["text_id"] is not None}
583
1012
  all_absent = {k: v for k, v in verified.items() if v["text_id"] is None}
584
1013
  known_absent = load_known_absent()
@@ -618,6 +1047,64 @@ def _run_legacy_link_verification(*, fix: bool) -> int:
618
1047
  f"{found[cid]['text_id']}) —— 从清单里删掉这一条"
619
1048
  )
620
1049
 
1050
+ # Step 3d: SKILL.md frontmatter 的 fojin_text_id(见 classify_frontmatter_fojin_ids)。
1051
+ print("\n[3d/4] Checking SKILL.md frontmatter fojin_text_id values against FoJin...")
1052
+ short_to_text = {
1053
+ info.get("short_cbeta_id"): info["text_id"] for info in found.values()
1054
+ }
1055
+ fm_mismatched, fm_unknown = classify_frontmatter_fojin_ids(
1056
+ collect_frontmatter_fojin_ids(), short_to_text
1057
+ )
1058
+ for teacher, cbeta_id, title, written, actual in fm_mismatched:
1059
+ print(f" [WRONG] {teacher}: 《{title}》 {cbeta_id} fojin_text_id={written} -> FoJin resolves {actual}")
1060
+ if fm_unknown:
1061
+ print(f" FoJin did not resolve {len(fm_unknown)} frontmatter ID(s) — "
1062
+ "unknown, not wrong: " + ", ".join(fm_unknown))
1063
+ if not fm_mismatched and not fm_unknown:
1064
+ print(" Every frontmatter fojin_text_id matches what FoJin resolves")
1065
+
1066
+ # Step 3e: 人设文档里引文后面的 FoJin 链接打开的是不是那部书(见 collect_doc_citation_links)。
1067
+ print("\n[3e/4] Checking FoJin links after citations in persona docs...")
1068
+ doc_pairs = collect_doc_citation_links()
1069
+ doc_records: dict[str, dict | None] = {}
1070
+ for tid in sorted({tid for _, tid, _, _ in doc_pairs}, key=int):
1071
+ try:
1072
+ record = bridge.get_text(tid)
1073
+ except Exception: # noqa: BLE001 — 查不到是「未知」,不是「不符」
1074
+ record = None
1075
+ doc_records[tid] = record if isinstance(record, dict) and record else None
1076
+ doc_mismatched, doc_unknown = classify_doc_citation_links(doc_pairs, doc_records)
1077
+ for where, tid, problem in doc_mismatched:
1078
+ print(f" [WRONG] {where}: {problem}")
1079
+ if doc_unknown:
1080
+ print(f" FoJin returned nothing for {len(doc_unknown)} doc link(s) — "
1081
+ "unknown, not wrong: " + ", ".join(doc_unknown))
1082
+ if not doc_mismatched and not doc_unknown:
1083
+ print(f" All {len(doc_pairs)} citation links in persona docs open the cited work")
1084
+
1085
+ # Step 3f: 摘录里的「原典」引文是否真在所引那一卷(见 classify_excerpt_quotes)。
1086
+ print("\n[3f/4] Checking excerpt quotations against the cited CBETA fascicle...")
1087
+ quotes = collect_excerpt_quotes()
1088
+ quote_works = sorted({work for work in (_cbeta_api_work(cid) for _, _, cid, _ in quotes) if work})
1089
+ juan_counts = {work: fetch_cbeta_juan_count(work) for work in quote_works}
1090
+ wanted_juans = sorted({
1091
+ (work, fascicle)
1092
+ for _, _, cid, juan in quotes
1093
+ for work in [_cbeta_api_work(cid)]
1094
+ if work
1095
+ for fascicle in (excerpt_fascicles(juan, juan_counts.get(work)) or [])
1096
+ })
1097
+ juan_texts = {key: fetch_cbeta_juan_text(*key) for key in wanted_juans}
1098
+ quote_mismatched, quote_unknown = classify_excerpt_quotes(quotes, juan_counts, juan_texts)
1099
+ for where, cited, missing in quote_mismatched:
1100
+ print(f" [WRONG] {where}: {cited}" + (f" has no 「{'」「'.join(missing)}」" if missing else ""))
1101
+ if quote_unknown:
1102
+ print(f" Could not check {len(quote_unknown)} quotation(s) — unknown, not wrong:")
1103
+ for where, reason in quote_unknown:
1104
+ print(f" {where}: {reason}")
1105
+ if not quote_mismatched and not quote_unknown:
1106
+ print(f" All {len(quotes)} quotations appear clause by clause in the cited fascicle")
1107
+
621
1108
  # Step 4: Update URLs
622
1109
  # Build replacement map: full_cbeta_id -> str(internal_text_id)
623
1110
  id_replacement_map: dict[str, str] = {}
@@ -660,6 +1147,10 @@ def _run_legacy_link_verification(*, fix: bool) -> int:
660
1147
  print(f" Stale known-absent entries:{len(stale_absent):>4}")
661
1148
  print(f" URL replacements: {len(all_changes)}")
662
1149
  print(f" CBETA id mismatches: {len(mismatched)}")
1150
+ print(f" CBETA title mismatches: {len(title_mismatched)}")
1151
+ print(f" Frontmatter FoJin id mismatches: {len(fm_mismatched)}")
1152
+ print(f" Doc citation links to another work: {len(doc_mismatched)}")
1153
+ print(f" Excerpt quotes not in the cited text: {len(quote_mismatched)}")
663
1154
  if unknown_to_cbeta:
664
1155
  print(f" CBETA unreachable for: {len(unknown_to_cbeta)} (not counted as wrong)")
665
1156
  if dry_run and all_changes: