build_book_config.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. <?php
  2. /**
  3. * 生成书级配置表 book_config.json:每本 VRI 文件的类型(mūla/义注/复注)与
  4. * 书名分段(哪一段段落属于哪个 book_name)。
  5. *
  6. * 为什么需要:123 本文件的 wbw 标记里完全没有书名信息(只有裸 paraN),书名要从
  7. * 别处来。证据按可信度排序:
  8. * 1. wbw 的 book_tag 标记(dn1@3、an3@323)——文件自带的书界,最硬
  9. * 2. wbw 的 paraN_xxx 后缀——逐段自带书名
  10. * 3. 旧表的主导书名——最后兜底。旧表的错在 cs 值不在书名上,唯一例外是
  11. * kn10_A/kn10_B,而那两本恰好有 book_tag 证据,会被 1 覆盖掉。
  12. *
  13. * overrides 区是人工修正区,脚本不动它,每次重跑都保留。
  14. *
  15. * 用法:php scripts/related/build_book_config.php [--baseline=related_paragraphs_bak_20260909]
  16. */
  17. require __DIR__.'/lib/boot.php';
  18. use Illuminate\Support\Facades\DB;
  19. [$options] = rp_args($argv);
  20. $baseline = rp_assert_table($options['baseline'] ?? 'related_paragraphs_bak_20260909');
  21. $markers = $options['markers'] ?? 'rp_markers';
  22. $configPath = __DIR__.'/book_config.json';
  23. $log = new RpLogger('build_book_config', rp_report_dir());
  24. $log->info("书名兜底基线:$baseline");
  25. // 已有的人工修正区必须原样带过来,否则每次重跑都把人的判断冲掉。
  26. $overrides = new stdClass;
  27. if (file_exists($configPath)) {
  28. $existing = rp_json_read($configPath)['overrides'] ?? [];
  29. $overrides = $existing === [] ? new stdClass : (object) $existing;
  30. $log->info('保留已有 overrides:'.count((array) $overrides).' 条');
  31. }
  32. // ── 文件类型:按 level-1 段落的 tag 判定,粒度是 book_id 不是 VRI 文件 ──────
  33. // book 207 一个文件里既有 pāḷi 又有 ṭīkā,按文件判会判错。
  34. $typeRows = DB::select("
  35. select p.book, p.paragraph, bt.sn as book_id, string_agg(t.name, ',') as tags
  36. from pali_texts p
  37. join tag_maps m on m.anchor_id = p.uid and m.table_name = 'pali_texts'
  38. join tags t on t.id = m.tag_id
  39. left join book_titles bt on bt.book = p.book and bt.paragraph = p.paragraph
  40. where p.level = 1
  41. group by p.book, p.paragraph, bt.sn
  42. order by p.book, p.paragraph
  43. ");
  44. $fileTypes = [];
  45. foreach ($typeRows as $row) {
  46. $tags = explode(',', $row->tags);
  47. $type = match (true) {
  48. (bool) array_intersect($tags, ['ṭīkā', 'mūlaṭīkā', 'anuṭīkā', 'abhinavaṭīkā', 'purāṇaṭīkā']) => 'tika',
  49. in_array('aṭṭhakathā', $tags, true) => 'atthakatha',
  50. (bool) array_intersect($tags, ['mūla', 'pāḷi']) => 'mula',
  51. default => 'unknown',
  52. };
  53. $fileTypes[$row->book][] = [
  54. 'book_id' => $row->book_id,
  55. 'from_paragraph' => $row->paragraph,
  56. 'type' => $type,
  57. 'tags' => $tags,
  58. ];
  59. $log->count("file_type.$type");
  60. if ($type === 'unknown') {
  61. $log->unresolved('file_type_unknown', [
  62. 'book' => $row->book, 'paragraph' => $row->paragraph, 'tags' => $row->tags,
  63. ]);
  64. }
  65. }
  66. // ── 书名证据 ─────────────────────────────────────────────────────────────
  67. $books = DB::select('select distinct book from pali_texts order by book');
  68. $config = [];
  69. foreach ($books as $b) {
  70. $book = $b->book;
  71. $maxPara = (int) DB::table('pali_texts')->where('book', $book)->max('paragraph');
  72. // 证据 1:book_tag 标记,给出文件内的书界切换点
  73. $tagMarks = DB::select(
  74. "select paragraph, book_name from $markers where book = ? and kind = 'book_tag' order by paragraph",
  75. [$book]
  76. );
  77. // 证据 2:paraN_xxx 后缀,逐段自带书名
  78. $suffixMarks = DB::select(
  79. "select paragraph, book_name from $markers
  80. where book = ? and kind in ('single','interval') and book_name is not null
  81. order by paragraph, wid",
  82. [$book]
  83. );
  84. // 证据 3:旧表逐段落的名字,按连续段压缩。
  85. // 空书名也必须成段——book 177 一个文件里装了四部书,只有头一部是 abhi8,
  86. // 后三部(nāmarūpaparicchedo 等)本来就没有 SC 书名。早先过滤掉空名,
  87. // 结果 abhi8 越过书界铺满全文件,把 10695 行错配进了 abhi8 的注释链。
  88. $oldNames = DB::select(
  89. "select para, max(book_name) as book_name from $baseline where book = ?
  90. group by para order by para",
  91. [$book]
  92. );
  93. $source = match (true) {
  94. $tagMarks !== [] => 'wbw_book_tag',
  95. $suffixMarks !== [] => 'wbw_suffix',
  96. $oldNames !== [] => 'old_table',
  97. default => 'none',
  98. };
  99. $points = []; // paragraph => book_name 的切换点
  100. if ($source === 'wbw_book_tag') {
  101. foreach ($tagMarks as $m) {
  102. $points[(int) $m->paragraph] = $m->book_name;
  103. }
  104. // book_tag 只给书界,段内若还有后缀且与之矛盾,记下来复核
  105. foreach ($suffixMarks as $m) {
  106. $expected = null;
  107. foreach ($points as $p => $n) {
  108. if ($p <= $m->paragraph) {
  109. $expected = $n;
  110. }
  111. }
  112. if ($expected !== null && $expected !== $m->book_name) {
  113. $log->count('name_conflict.tag_vs_suffix');
  114. $log->unresolved('name_conflict_tag_vs_suffix', [
  115. 'book' => $book, 'paragraph' => (int) $m->paragraph,
  116. 'book_tag_says' => $expected, 'suffix_says' => $m->book_name,
  117. ]);
  118. }
  119. }
  120. } elseif ($source === 'wbw_suffix') {
  121. $prev = null;
  122. foreach ($suffixMarks as $m) {
  123. if ($m->book_name !== $prev) {
  124. $points[(int) $m->paragraph] = $m->book_name;
  125. $prev = $m->book_name;
  126. }
  127. }
  128. } elseif ($source === 'old_table') {
  129. $prev = null;
  130. foreach ($oldNames as $m) {
  131. // 序言名 dn1_A 是义注开头的正常设计,保留;其余同名连续段合并
  132. if ($m->book_name !== $prev) {
  133. $points[(int) $m->para] = $m->book_name;
  134. $prev = $m->book_name;
  135. }
  136. }
  137. }
  138. // 义注/复注开头的序言段用 {book}_A 命名(dn1_A 型,全库 12 本)。wbw 里带
  139. // {mula}_0 章标记的书(99/103/130/133)导出器能自己认出来,但另有 6 本
  140. // (181/185/188/192 及 179/211 的文件中段)wbw 完全没有痕迹,序言范围只存在于
  141. // 旧表里。这里把旧表的 _A 连续段叠加进切换点,免得序言被并进正文名下。
  142. if ($source !== 'old_table') {
  143. // 注意:序言段里出现 paraN_mula 标记是正常的,不能拿首个 cs 标记当序言终点。
  144. // book 130 的序言(mn1_0@5)里就有 para1_mn1@40,序言真正的终点是章标记
  145. // mn1_1@47;book 192 同样。序言段照旧表原样叠加即可。
  146. $runName = null;
  147. $runFrom = null;
  148. $flushRun = function (int $endPara) use (&$points, &$runName, &$runFrom, $log, $book, $oldNames) {
  149. if ($runName === null) {
  150. return;
  151. }
  152. $base = preg_replace('/_A$/', '', $runName);
  153. // 真序言的后面一定跟着正文(基名)。book 143 的 kn10_A 铺满整个文件、
  154. // 后面根本没有 kn10——那是 E3 的错名而不是序言,绝不能叠加进来。
  155. $baseFollows = false;
  156. foreach ($oldNames as $row) {
  157. if ((int) $row->para > $endPara && $row->book_name === $base) {
  158. $baseFollows = true;
  159. break;
  160. }
  161. }
  162. if (! $baseFollows) {
  163. $log->count('prologue_overlay.rejected');
  164. $log->info("book $book 拒绝叠加 $runName@$runFrom-{$endPara}:其后无 $base 正文,判为错名而非序言");
  165. $runName = null;
  166. $runFrom = null;
  167. return;
  168. }
  169. // 序言区间内的旧切换点要清掉:book_tag 的 dn1@3 落在 dn1_A@1-198 中间,
  170. // 不清就会把序言从 para 3 截断,只剩两段。
  171. $base = $points[$endPara + 1] ?? $base;
  172. foreach (array_keys($points) as $existing) {
  173. if ($existing > $runFrom && $existing <= $endPara) {
  174. unset($points[$existing]);
  175. }
  176. }
  177. $points[$runFrom] = $runName;
  178. if (! isset($points[$endPara + 1])) {
  179. $points[$endPara + 1] = $base;
  180. }
  181. $log->count('prologue_overlay');
  182. $log->info("book $book 叠加序言段 $runName@$runFrom-$endPara");
  183. $runName = null;
  184. $runFrom = null;
  185. };
  186. $lastPara = null;
  187. foreach ($oldNames as $m) {
  188. $para = (int) $m->para;
  189. $isPrologue = str_ends_with((string) $m->book_name, '_A');
  190. if ($isPrologue && $m->book_name === $runName && $para === $lastPara + 1) {
  191. $lastPara = $para;
  192. continue;
  193. }
  194. if ($runName !== null) {
  195. $flushRun($lastPara);
  196. }
  197. if ($isPrologue) {
  198. $runName = $m->book_name;
  199. $runFrom = $para;
  200. }
  201. $lastPara = $para;
  202. }
  203. if ($runName !== null) {
  204. $flushRun($lastPara);
  205. }
  206. ksort($points);
  207. }
  208. // 切换点 → 区间
  209. $segments = [];
  210. $keys = array_keys($points);
  211. foreach ($keys as $i => $from) {
  212. $segments[] = [
  213. 'para_from' => $from,
  214. 'para_to' => isset($keys[$i + 1]) ? $keys[$i + 1] - 1 : $maxPara,
  215. 'book_name' => $points[$from],
  216. ];
  217. }
  218. $config[(string) $book] = [
  219. 'max_paragraph' => $maxPara,
  220. 'name_source' => $source,
  221. 'file_types' => $fileTypes[$book] ?? [],
  222. 'segments' => $segments,
  223. ];
  224. $log->count("name_source.$source");
  225. if ($source === 'none') {
  226. $log->unresolved('book_name_unknown', ['book' => $book]);
  227. }
  228. if (count($segments) > 1) {
  229. $log->info(sprintf('book %-3d %-14s %d 段:%s', $book, $source, count($segments),
  230. implode(' ', array_map(fn ($s) => "{$s['book_name']}@{$s['para_from']}", $segments))));
  231. }
  232. }
  233. rp_json_write($configPath, [
  234. 'generated_at' => date('c'),
  235. 'baseline_table' => $baseline,
  236. 'purpose' => '每本 VRI 文件的类型与书名分段。导出器优先用 wbw 现场证据,此表只在文件本身没有书名信息时兜底。overrides 是人工修正区,重跑不覆盖。',
  237. 'books' => $config,
  238. 'overrides' => $overrides,
  239. ]);
  240. $log->info("配置表:{$configPath}(".count($config).' 本)');
  241. $log->finish();