2
0

export.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. <?php
  2. /**
  3. * 从 wbw_templates 的锚点标记重放 related_paragraphs 第一版。
  4. *
  5. * 原则是「查不到优于查到错」:任何一处判不定就留空、记 issue,绝不沿用上一个 cs 值。
  6. * 旧生成链路(VRI html → cs6_para.csv → laravel import)翻车的地方全在这条原则上:
  7. * 换书后无标记就把上一个 cs 一路铺下去(E8),缩写区间不认识就跳过或写成六位废值
  8. * (E1/E7),倒序区间 begin>end 让导入的 for 循环空转(book 103 para 1464 的成因)。
  9. *
  10. * 本版不处理章回绕后缀(E2),那要看标题层级,留给下一批;先把确定性规则跑干净,
  11. * 混在一起会看不出是哪条规则错了。
  12. *
  13. * 用法:
  14. * php scripts/related/export.php --to=related_paragraphs_v1
  15. * php scripts/related/export.php --to=related_paragraphs_v1 --book=136,98,207 # 样例验货
  16. */
  17. require __DIR__.'/lib/boot.php';
  18. use Illuminate\Support\Facades\DB;
  19. [$options] = rp_args($argv);
  20. $target = rp_assert_table($options['to'] ?? 'related_paragraphs_v1');
  21. $markersTable = $options['markers'] ?? 'rp_markers';
  22. $onlyBooks = isset($options['book']) ? array_map('intval', explode(',', $options['book'])) : null;
  23. $log = new RpLogger('export', rp_report_dir());
  24. $log->info("目标表:$target");
  25. if ($onlyBooks) {
  26. $log->info('只导出:'.implode(',', $onlyBooks));
  27. }
  28. $config = rp_json_read(__DIR__.'/book_config.json');
  29. $bookConfig = $config['books'];
  30. $overrides = $config['overrides'] ?? [];
  31. // ── 建表 ─────────────────────────────────────────────────────────────────
  32. if (! $onlyBooks) {
  33. DB::statement("drop table if exists $target");
  34. }
  35. DB::statement("
  36. create table if not exists $target (
  37. id bigserial primary key,
  38. book integer not null,
  39. para integer not null,
  40. book_id integer not null,
  41. cs_para integer not null,
  42. book_name varchar(64) not null,
  43. created_at timestamp(0), updated_at timestamp(0)
  44. )
  45. ");
  46. if ($onlyBooks) {
  47. DB::table($target)->whereIn('book', $onlyBooks)->delete();
  48. }
  49. // ── 预载 ─────────────────────────────────────────────────────────────────
  50. $log->info('预载 book_titles…');
  51. $titles = [];
  52. foreach (DB::select('select book, paragraph, sn from book_titles order by book, paragraph') as $t) {
  53. $titles[$t->book][] = ['paragraph' => (int) $t->paragraph, 'sn' => (int) $t->sn];
  54. }
  55. $books = array_map(
  56. fn ($r) => (int) $r->book,
  57. DB::select('select distinct book from pali_texts order by book')
  58. );
  59. if ($onlyBooks) {
  60. $books = array_values(array_intersect($books, $onlyBooks));
  61. }
  62. $log->info('待导出文件数:'.count($books));
  63. $buffer = [];
  64. $written = 0;
  65. $now = date('Y-m-d H:i:s');
  66. $flush = function () use (&$buffer, &$written, $target) {
  67. if ($buffer === []) {
  68. return;
  69. }
  70. foreach (array_chunk($buffer, 5000) as $chunk) {
  71. DB::table($target)->insert($chunk);
  72. }
  73. $written += count($buffer);
  74. $buffer = [];
  75. };
  76. /** 在配置分段里查某段落该用什么书名;段落早于首段时沿用首段的名。 */
  77. $configName = function (int $book, int $para) use ($bookConfig, $overrides): ?string {
  78. // overrides 是人工判定区,优先级高于自动推导出来的分段。
  79. $segments = $overrides[(string) $book]['segments']
  80. ?? $bookConfig[(string) $book]['segments']
  81. ?? [];
  82. if ($segments === []) {
  83. return null;
  84. }
  85. $name = $segments[0]['book_name'];
  86. foreach ($segments as $s) {
  87. if ($para >= $s['para_from']) {
  88. $name = $s['book_name'];
  89. }
  90. }
  91. return $name;
  92. };
  93. foreach ($books as $book) {
  94. $bookTitles = $titles[$book] ?? [];
  95. // 段落全集取 pali_texts 而不是 wbw_templates:pali_texts 是严格超集
  96. // (523284 段 vs 516113 段),wbw 少的那 7171 段散在 21 本里,
  97. // 按 wbw 取全集会白丢这些段落的行。wbw 只负责提供标记。
  98. $paragraphs = array_map(
  99. fn ($r) => (int) $r->paragraph,
  100. DB::select('select distinct paragraph from pali_texts where book = ? order by paragraph', [$book])
  101. );
  102. if ($paragraphs === []) {
  103. continue;
  104. }
  105. $markerRows = DB::select(
  106. "select paragraph, wid, word, kind, book_name, cs_from, cs_to, numbers, error
  107. from $markersTable where book = ? and kind <> 'page' order by paragraph, wid",
  108. [$book]
  109. );
  110. $byParagraph = [];
  111. foreach ($markerRows as $m) {
  112. $byParagraph[(int) $m->paragraph][] = $m;
  113. }
  114. // ── book_id 分段:book_titles 的每个条目开一段,之前的段落是书前页 ────────
  115. $segments = [];
  116. if ($bookTitles === []) {
  117. $segments[] = ['sn' => 0, 'from' => 1, 'to' => PHP_INT_MAX];
  118. } else {
  119. if ($bookTitles[0]['paragraph'] > 1) {
  120. $segments[] = ['sn' => 0, 'from' => 1, 'to' => $bookTitles[0]['paragraph'] - 1];
  121. }
  122. foreach ($bookTitles as $i => $t) {
  123. $segments[] = [
  124. 'sn' => $t['sn'],
  125. 'from' => $t['paragraph'],
  126. 'to' => $bookTitles[$i + 1]['paragraph'] ?? PHP_INT_MAX,
  127. ];
  128. if (isset($bookTitles[$i + 1])) {
  129. $segments[count($segments) - 1]['to'] = $bookTitles[$i + 1]['paragraph'] - 1;
  130. }
  131. }
  132. }
  133. $bookName = null;
  134. $prologue = false;
  135. // 义注文件的开头几段(书前页)在 {mula}_0 序言标记之前,但内容上属于序言。
  136. // 文件里首个章标记是 _0 时,起手就用 {mula}_A,免得前几段挂到正文名下。
  137. foreach ($markerRows as $m) {
  138. if ($m->kind === 'chapter') {
  139. if ($m->numbers === '0') {
  140. $bookName = $m->book_name.'_A';
  141. $prologue = true;
  142. $log->count('event.prologue_seeded');
  143. }
  144. break;
  145. }
  146. }
  147. // 文件自身没有任何书名标记时,书名只能全程由配置表分段决定。
  148. // 配置表在所有书里都当书名基线:它已经把 book_tag 书界、人工 override 和
  149. // 序言段(dn1_A 型)合并成一份完整分段。标记仍然可以就地覆盖——paraN_xxx 后缀
  150. // 是最贴身的证据,book_tag 是书界事件,两者都在下面的标记循环里生效。
  151. $nameFromConfig = true;
  152. foreach ($segments as $seg) {
  153. $segParagraphs = array_values(array_filter(
  154. $paragraphs,
  155. fn ($p) => $p >= $seg['from'] && $p <= $seg['to']
  156. ));
  157. if ($segParagraphs === []) {
  158. continue;
  159. }
  160. // ⭐ 防污染核心规则:整段没有任何 cs 标记,就不许给它任何非零 cs。
  161. // 旧生成器在这里把换书前的 cs 一路铺下去,book 207 的 1499 行恒 cs=75 就是这么来的。
  162. // 但也不能整段不输出:这些段落的行多半是 cs=0 的占位行(E4 的无注释体系书、
  163. // 义注序言、书前页),下游 ParaInfoController 按段落取行,删掉就是白丢覆盖。
  164. // 输出 cs=0 即可——index() 取锚点时要求 cs_para > 0,cs=0 行不会产生任何配对,
  165. // 既保住覆盖又不编造关系。仍然记 issue,标出待 LLM 补齐的位置。
  166. $segMarkerCount = 0;
  167. foreach ($segParagraphs as $p) {
  168. foreach ($byParagraph[$p] ?? [] as $m) {
  169. if (in_array($m->kind, ['single', 'interval'], true)) {
  170. $segMarkerCount++;
  171. }
  172. }
  173. }
  174. if ($segMarkerCount === 0 && $seg['sn'] !== 0) {
  175. $log->count('segment_without_markers');
  176. $log->unresolved('segment_without_markers', [
  177. 'book' => $book, 'book_id' => $seg['sn'],
  178. 'para_from' => $segParagraphs[0], 'para_to' => end($segParagraphs),
  179. 'paragraphs' => count($segParagraphs),
  180. 'detail' => '整段无 cs 标记,全部输出 cs=0 占位,待补(E8/E9)',
  181. ]);
  182. }
  183. // 段起点:cs 归零等待首个标记。
  184. // 书名分两种来源:文件自带 book_tag/后缀标记的,交给标记驱动;没有任何现场
  185. // 证据的(123 本,如 book 98 一个文件里装了 abhi3..abhi7),只能逐段落查配置表
  186. // ——这类文件的书界在 wbw 里没有任何痕迹。
  187. $csList = [0];
  188. $bookName ??= $configName($book, $segParagraphs[0]);
  189. foreach ($segParagraphs as $para) {
  190. $skipParagraph = false;
  191. $configNameHere = $configName($book, $para);
  192. if ($nameFromConfig) {
  193. $bookName = $configNameHere;
  194. }
  195. foreach ($byParagraph[$para] ?? [] as $m) {
  196. switch ($m->kind) {
  197. case 'book_tag':
  198. // 文件内的书界切换(an2@1 an3@323…),新书从 cs=0 起算
  199. if ($bookName !== $m->book_name) {
  200. $log->count('event.book_switch');
  201. }
  202. $bookName = $m->book_name;
  203. $prologue = false;
  204. $csList = [0];
  205. break;
  206. case 'chapter':
  207. $numbers = $m->numbers === null ? [] : array_map('intval', explode('.', $m->numbers));
  208. if ($numbers === [0]) {
  209. // {mula}_0 = 义注开头的序言(Ganthārambhakathā),不对应原文段落
  210. $prologue = true;
  211. $bookName = $m->book_name.'_A';
  212. $csList = [0];
  213. $log->count('event.prologue_begin');
  214. } else {
  215. if ($prologue) {
  216. $prologue = false;
  217. $bookName = $m->book_name;
  218. $csList = [0];
  219. $log->count('event.prologue_end');
  220. }
  221. $log->count('event.chapter');
  222. }
  223. break;
  224. case 'single':
  225. case 'interval':
  226. // 序言段里的 paraN_mula 后缀不夺名:序言归 {mula}_A,
  227. // 后缀只说明它在注释哪一段,不表示它已经进入正文
  228. // (book 181 的 an1_A@1-138 内就有 para1_an1 起的一串)。
  229. if ($m->book_name !== null && $configNameHere !== $m->book_name.'_A') {
  230. $bookName = $m->book_name;
  231. }
  232. // cs 标记不结束序言:序言里带 paraN_mula 是正常体例
  233. // (book 130 的 mn1_0 序言内就有 para1_mn1),序言只由
  234. // {mula}_{N>0} 章标记结束。
  235. $csList = range((int) $m->cs_from, (int) $m->cs_to);
  236. $log->count($m->kind === 'interval' ? 'marker.interval' : 'marker.single');
  237. break;
  238. case 'truncated':
  239. // para179- 型:区间右端缺失,本段留空,上下文不变
  240. $skipParagraph = true;
  241. $log->unresolved('truncated_marker', [
  242. 'book' => $book, 'paragraph' => $para, 'word' => $m->word,
  243. 'detail' => $m->error ?? '区间右端缺失',
  244. ]);
  245. break;
  246. default:
  247. $log->count('marker.ignored.'.$m->kind);
  248. }
  249. }
  250. if ($skipParagraph) {
  251. continue;
  252. }
  253. if ($bookName === null) {
  254. // 没有任何书名证据:E4 的无注释体系书走到这里是正常的,输出空名占位
  255. $bookName = '';
  256. }
  257. foreach ($csList as $cs) {
  258. $buffer[] = [
  259. 'book' => $book, 'para' => $para, 'book_id' => $seg['sn'],
  260. 'cs_para' => $cs, 'book_name' => $bookName,
  261. 'created_at' => $now, 'updated_at' => $now,
  262. ];
  263. }
  264. $log->count('rows.emitted', count($csList));
  265. }
  266. }
  267. $flush();
  268. $log->info(sprintf('book %-3d 完成,累计 %d 行', $book, $written));
  269. }
  270. $flush();
  271. $log->info("写入完成:$written 行");
  272. if (! $onlyBooks) {
  273. $log->info('建索引中…');
  274. DB::statement("create index on $target (book)");
  275. DB::statement("create index on $target (para)");
  276. DB::statement("create index on $target (book_name)");
  277. DB::statement("create index on $target (book_name, cs_para)");
  278. DB::statement("create index on $target (book, para, cs_para)");
  279. }
  280. $log->finish();