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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +2 -2
- package/README_EN.md +2 -2
- package/gemini-extension.json +1 -1
- package/package.json +2 -2
- package/prebuilt/master-curriculum/references/jingtu.md +2 -2
- package/prebuilt/master-debate/SKILL.md +3 -3
- package/prebuilt/master-fazang/SKILL.md +6 -3
- package/prebuilt/master-fazang/meta.json +5 -0
- package/prebuilt/master-fazang/references/teaching.md +10 -5
- package/prebuilt/master-fazang/sources/INDEX.md +1 -1
- package/prebuilt/master-fazang/sources/jinshizi-excerpts.md +12 -8
- package/prebuilt/master-fazang/sources/wujiao-zhang-excerpts.md +20 -12
- package/prebuilt/master-fazang/tests/fidelity.jsonl +2 -2
- package/prebuilt/master-kumarajiva/SKILL.md +9 -0
- package/prebuilt/master-kumarajiva/meta.json +15 -0
- package/prebuilt/master-kumarajiva/references/teaching.md +4 -2
- package/prebuilt/master-kumarajiva/references/voice.md +1 -1
- package/prebuilt/master-kumarajiva/sources/zhonglun-excerpts.md +6 -2
- package/prebuilt/master-nagarjuna/references/teaching.md +5 -3
- package/prebuilt/master-nagarjuna/sources/dazhidulun-excerpts.md +13 -19
- package/prebuilt/master-nagarjuna/sources/shizhu-yixing-excerpts.md +5 -5
- package/prebuilt/master-nagarjuna/sources/zhonglun-excerpts.md +22 -1
- package/prebuilt/master-ouyi/SKILL.md +8 -0
- package/prebuilt/master-ouyi/meta.json +10 -0
- package/prebuilt/master-ouyi/references/teaching.md +2 -2
- package/prebuilt/master-ouyi/sources/jiaoguan-gangzong-excerpts.md +9 -7
- package/prebuilt/master-ouyi/sources/mituo-yaojie-excerpts.md +7 -6
- package/prebuilt/master-xuanzang/SKILL.md +6 -0
- package/prebuilt/master-xuanzang/meta.json +10 -0
- package/prebuilt/master-xuanzang/sources/chengweishi-excerpts.md +2 -2
- package/prebuilt/master-xuyun/SKILL.md +6 -0
- package/prebuilt/master-xuyun/meta.json +17 -1
- package/prebuilt/master-xuyun/references/teaching.md +5 -5
- package/prebuilt/master-xuyun/sources/lengyanjing-excerpts.md +1 -1
- package/prebuilt/master-yinguang/SKILL.md +16 -13
- package/prebuilt/master-yinguang/meta.json +18 -15
- package/prebuilt/master-yinguang/references/teaching.md +6 -6
- package/prebuilt/master-yinguang/references/voice.md +1 -1
- package/prebuilt/master-yinguang/sources/INDEX.md +7 -6
- package/prebuilt/master-yinguang/sources/wenchao-excerpts.md +5 -5
- package/prebuilt/master-yinguang/sources/yihanbianfu-excerpts.md +4 -4
- package/prebuilt/master-yinguang/tests/fidelity.jsonl +7 -7
- package/prebuilt/master-zhiyi/SKILL.md +4 -1
- package/prebuilt/master-zhiyi/meta.json +6 -1
- package/prebuilt/master-zhiyi/references/teaching.md +3 -3
- package/prebuilt/master-zhiyi/references/voice.md +1 -1
- package/prebuilt/master-zhiyi/sources/INDEX.md +1 -1
- package/prebuilt/master-zhiyi/sources/fahua-xuanyi-excerpts.md +18 -15
- package/prebuilt/master-zhiyi/sources/mohezhiguan-excerpts.md +6 -2
- package/references/source-conventions.md +2 -2
- package/scripts/check-pe-subsystem.py +64 -0
- package/scripts/reaudit-report.py +45 -3
- package/scripts/validate-citation-references.py +80 -7
- package/scripts/validate-self-audit-sources.py +116 -0
- package/scripts/verify_citations.py +92 -6
- package/tools/verify_sources.py +496 -5
package/tools/verify_sources.py
CHANGED
|
@@ -365,13 +365,13 @@ def classify_cbeta_volumes(
|
|
|
365
365
|
return mismatched, sorted(unknown)
|
|
366
366
|
|
|
367
367
|
|
|
368
|
-
def
|
|
369
|
-
"""向 CBETA
|
|
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,
|
|
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] =
|
|
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
|
-
|
|
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:
|