2
0

align_chapters.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. <?php
  2. /**
  3. * 章回绕的跨侧对齐:给每个 book_name 的每个「cs 重置段」定一个章号,两侧用同一套章号。
  4. *
  5. * 为什么不能各干各的:book 143(mūla kn10)有 42 个章标记,book 107(义注 kn10)一个
  6. * 都没有。单方面按标记加后缀,会让 kn10_1 只出现在 mūla 侧、义注侧还叫 kn10,
  7. * 两侧的配对全部打断——比不加后缀更糟。所以必须先对齐,再一起加。
  8. *
  9. * 对齐靠什么:注释侧的 cs 值就是被注书的段落号,所以两侧每一章的 max(cs) 应当接近
  10. * (实测 kn10 前 11 章:663/657、140/134、188/184、113/108…,注释侧略低,因为它不注
  11. * 每章最后几段)。但段数常对不上(kn10 mūla 42 段 vs 义注 34 段,中间还夹着 max=2 的
  12. * 碎段),所以按序号硬配会漂,得做带空位的序列比对。
  13. *
  14. * 用法:
  15. * php scripts/related/align_chapters.php --table=related_paragraphs_v1
  16. * php scripts/related/align_chapters.php --table=related_paragraphs_v1 --name=kn10 --verbose
  17. */
  18. require __DIR__.'/lib/boot.php';
  19. use Illuminate\Support\Facades\DB;
  20. [$options] = rp_args($argv);
  21. $table = rp_assert_table($options['table'] ?? 'related_paragraphs_v1');
  22. $onlyName = $options['name'] ?? null;
  23. $verbose = isset($options['verbose']);
  24. /** 对齐得分低于此值的段判为不可信,不加后缀,转人工/LLM。 */
  25. $minConfidence = (float) ($options['min-confidence'] ?? 0.6);
  26. $log = new RpLogger('align_chapters', rp_report_dir());
  27. $log->info("被对齐表:{$table}");
  28. $log->info("置信度阈值:{$minConfidence}");
  29. $config = rp_json_read(__DIR__.'/book_config.json');
  30. /** 某 VRI 文件在注释链条中的位置。同一文件可能多种,取首个 level-1 的判定。 */
  31. $sideOf = function (int $book) use ($config): string {
  32. $types = $config['books'][(string) $book]['file_types'] ?? [];
  33. return $types[0]['type'] ?? 'unknown';
  34. };
  35. // ── 找出所有有回绕的 book_name ────────────────────────────────────────────
  36. $names = DB::select("
  37. with s as (
  38. select book_name, book, para, cs_para,
  39. lag(cs_para) over (partition by book, book_name order by para, cs_para) as prev
  40. from {$table} where cs_para > 0 and book_name <> ''
  41. )
  42. select book_name, count(*) as wraps
  43. from s where cs_para = 1 and prev > 1
  44. group by book_name order by count(*) desc
  45. ");
  46. if ($onlyName) {
  47. $names = array_values(array_filter($names, fn ($n) => $n->book_name === $onlyName));
  48. }
  49. $log->info('有回绕的 book_name:'.count($names).' 个');
  50. /**
  51. * 把一个 (book_name, book) 的行切成「cs 重置段」。
  52. *
  53. * @return list<array{seg:int, book:int, para_from:int, para_to:int, cs_min:int, cs_max:int, rows:int}>
  54. */
  55. $segmentsOf = function (string $name, int $book) use ($table): array {
  56. $rows = DB::select("
  57. select para, min(cs_para) as cs_min, max(cs_para) as cs_max
  58. from {$table} where book_name = ? and book = ? and cs_para > 0
  59. group by para order by para
  60. ", [$name, $book]);
  61. $segments = [];
  62. $current = null;
  63. $prevMin = null;
  64. foreach ($rows as $r) {
  65. // 按段落的 cs_min 判重置。用 cs_max 会被区间展开骗到:连续的 para1-2 段落
  66. // 每段 cs 都是 {1,2},看 max 就成了 …2→1 的连环「重置」。
  67. $isReset = $prevMin !== null && (int) $r->cs_min === 1 && $prevMin > 1;
  68. if ($current === null || $isReset) {
  69. if ($current !== null) {
  70. $segments[] = $current;
  71. }
  72. $current = [
  73. 'seg' => count($segments), 'book' => $book,
  74. 'para_from' => (int) $r->para, 'para_to' => (int) $r->para,
  75. 'cs_min' => (int) $r->cs_min, 'cs_max' => (int) $r->cs_max, 'rows' => 0,
  76. ];
  77. }
  78. $current['para_to'] = (int) $r->para;
  79. $current['cs_max'] = max($current['cs_max'], (int) $r->cs_max);
  80. $current['cs_min'] = min($current['cs_min'], (int) $r->cs_min);
  81. $current['rows']++;
  82. $prevMin = (int) $r->cs_min;
  83. }
  84. if ($current !== null) {
  85. $segments[] = $current;
  86. }
  87. return $segments;
  88. };
  89. /** 两段 max(cs) 的接近程度,值域约 [-2, 2]。注释侧本就略低于 mūla,故不对称惩罚。 */
  90. $score = function (int $refMax, int $queryMax): float {
  91. $scale = max($refMax, $queryMax, 1);
  92. $diff = abs($refMax - $queryMax) / $scale;
  93. $s = 2.0 - 4.0 * $diff;
  94. if ($queryMax > $refMax) {
  95. $s -= 0.5; // 注释侧超过被注书的章长,不合体例,压一压
  96. }
  97. return $s;
  98. };
  99. /**
  100. * 带空位的序列比对(Needleman–Wunsch)。
  101. * 返回 query 段下标 → ref 段下标;对不上的段返回 null,由调用方并入前一章。
  102. *
  103. * @return array<int, ?int>
  104. */
  105. $align = function (array $refMax, array $queryMax) use ($score): array {
  106. $m = count($refMax);
  107. $n = count($queryMax);
  108. $gapRef = -0.6; // 跳过一章参考段:该章没有注释,常见
  109. $gapQuery = -1.2; // 跳过一个查询段:多半是碎段,代价高些
  110. $dp = array_fill(0, $m + 1, array_fill(0, $n + 1, 0.0));
  111. $from = array_fill(0, $m + 1, array_fill(0, $n + 1, ''));
  112. for ($i = 1; $i <= $m; $i++) {
  113. $dp[$i][0] = $dp[$i - 1][0] + $gapRef;
  114. $from[$i][0] = 'up';
  115. }
  116. for ($j = 1; $j <= $n; $j++) {
  117. $dp[0][$j] = $dp[0][$j - 1] + $gapQuery;
  118. $from[0][$j] = 'left';
  119. }
  120. for ($i = 1; $i <= $m; $i++) {
  121. for ($j = 1; $j <= $n; $j++) {
  122. $diag = $dp[$i - 1][$j - 1] + $score($refMax[$i - 1], $queryMax[$j - 1]);
  123. $up = $dp[$i - 1][$j] + $gapRef;
  124. $left = $dp[$i][$j - 1] + $gapQuery;
  125. $best = max($diag, $up, $left);
  126. $dp[$i][$j] = $best;
  127. $from[$i][$j] = $best === $diag ? 'diag' : ($best === $up ? 'up' : 'left');
  128. }
  129. }
  130. $map = [];
  131. $i = $m;
  132. $j = $n;
  133. while ($i > 0 || $j > 0) {
  134. $dir = $from[$i][$j];
  135. if ($dir === 'diag') {
  136. $map[$j - 1] = $i - 1;
  137. $i--;
  138. $j--;
  139. } elseif ($dir === 'up') {
  140. $i--;
  141. } else {
  142. $map[$j - 1] = null;
  143. $j--;
  144. }
  145. }
  146. ksort($map);
  147. return $map;
  148. };
  149. $result = [];
  150. foreach ($names as $nameRow) {
  151. $name = $nameRow->book_name;
  152. $books = array_map(
  153. fn ($r) => (int) $r->book,
  154. DB::select("select distinct book from {$table} where book_name = ? order by book", [$name])
  155. );
  156. // 按侧分组,mūla 侧作参考;没有 mūla 侧就用行数最多的那一侧
  157. $bySide = [];
  158. foreach ($books as $book) {
  159. $bySide[$sideOf($book)][] = $book;
  160. }
  161. $refSide = isset($bySide['mula']) ? 'mula' : array_key_first($bySide);
  162. if ($refSide === null) {
  163. $log->unresolved('chapter_align_no_side', ['book_name' => $name]);
  164. continue;
  165. }
  166. // 参考侧的段序列(跨文件按 book 顺序拼接)即章号定义
  167. $refSegments = [];
  168. foreach ($bySide[$refSide] as $book) {
  169. foreach ($segmentsOf($name, $book) as $s) {
  170. $refSegments[] = $s;
  171. }
  172. }
  173. if ($refSegments === []) {
  174. continue;
  175. }
  176. $refMax = array_column($refSegments, 'cs_max');
  177. $entry = [
  178. 'book_name' => $name,
  179. 'wraps' => (int) $nameRow->wraps,
  180. 'ref_side' => $refSide,
  181. 'ref_books' => $bySide[$refSide],
  182. 'chapters' => count($refSegments),
  183. 'assignments' => [],
  184. 'unaligned' => 0,
  185. 'confidence' => 1.0,
  186. ];
  187. $alignedTotal = 0;
  188. $segTotal = 0;
  189. foreach ($bySide as $side => $sideBooks) {
  190. foreach ($sideBooks as $book) {
  191. $segments = $segmentsOf($name, $book);
  192. if ($segments === []) {
  193. continue;
  194. }
  195. if ($side === $refSide) {
  196. // 参考侧:段号即章号,但要按跨文件拼接后的全局下标
  197. $offset = 0;
  198. foreach ($bySide[$refSide] as $b) {
  199. if ($b === $book) {
  200. break;
  201. }
  202. $offset += count($segmentsOf($name, $b));
  203. }
  204. $map = [];
  205. foreach (array_keys($segments) as $k) {
  206. $map[$k] = $offset + $k;
  207. }
  208. } else {
  209. $map = $align($refMax, array_column($segments, 'cs_max'));
  210. }
  211. $lastChapter = null;
  212. foreach ($segments as $k => $seg) {
  213. $chapter = $map[$k] ?? null;
  214. $segTotal++;
  215. if ($chapter === null) {
  216. // 对不上的碎段并入前一章:它多半是注释侧的一次多余重置
  217. $chapter = $lastChapter;
  218. $entry['unaligned']++;
  219. $log->count('segment_merged_into_previous');
  220. } else {
  221. $alignedTotal++;
  222. $lastChapter = $chapter;
  223. }
  224. if ($chapter === null) {
  225. $log->unresolved('chapter_align_orphan_segment', [
  226. 'book_name' => $name, 'book' => $book,
  227. 'para_from' => $seg['para_from'], 'para_to' => $seg['para_to'],
  228. 'cs_max' => $seg['cs_max'],
  229. ]);
  230. continue;
  231. }
  232. $entry['assignments'][] = [
  233. 'book' => $book, 'side' => $side,
  234. 'para_from' => $seg['para_from'], 'para_to' => $seg['para_to'],
  235. 'cs_max' => $seg['cs_max'],
  236. 'chapter' => $chapter + 1,
  237. 'ref_cs_max' => $refSegments[$chapter]['cs_max'],
  238. ];
  239. }
  240. }
  241. }
  242. $entry['confidence'] = $segTotal > 0 ? round($alignedTotal / $segTotal, 3) : 0.0;
  243. $entry['applicable'] = $entry['confidence'] >= $minConfidence && $entry['chapters'] > 1;
  244. $result[$name] = $entry;
  245. $log->info(sprintf('%-8s 章数 %-3d 段数 %-4d 对上 %-4d 置信 %.3f %s',
  246. $name, $entry['chapters'], $segTotal, $alignedTotal, $entry['confidence'],
  247. $entry['applicable'] ? '' : '← 置信不足,不加后缀'));
  248. if ($verbose) {
  249. foreach ($entry['assignments'] as $a) {
  250. $log->info(sprintf(' book %-3d %-10s para %-6d-%-6d cs_max %-5d → 章 %-3d (参考 %d)',
  251. $a['book'], $a['side'], $a['para_from'], $a['para_to'], $a['cs_max'],
  252. $a['chapter'], $a['ref_cs_max']));
  253. }
  254. }
  255. }
  256. $applicable = array_filter($result, fn ($e) => $e['applicable']);
  257. $log->info(sprintf('可加后缀的 book_name:%d / %d', count($applicable), count($result)));
  258. $path = rp_report_dir().'/chapter_alignment.json';
  259. rp_json_write($path, [
  260. 'generated_at' => date('c'),
  261. 'table' => $table,
  262. 'min_confidence' => $minConfidence,
  263. 'names' => $result,
  264. ]);
  265. $log->info("对齐表:{$path}");
  266. $log->finish();