Files
zyt/server/scripts/install_tcm_ancient_books.php
T

217 lines
9.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
// Run from the project checkout: php server/scripts/install_tcm_ancient_books.php
// Initialize the pinned corpus submodule and build its local search index.
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\TcmAncientBooksReference;
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "CLI only\n");
exit(1);
}
$root = dirname(__DIR__) . '/knowledge/tcm-ancient-books';
$projectRoot = dirname(__DIR__, 2);
$indexPath = TcmAncientBooksReference::indexPath();
function runGit(array $arguments, ?string $directory = null): string
{
$originalDirectory = getcwd();
if ($directory !== null && ($originalDirectory === false || !chdir($directory))) {
throw new RuntimeException('Could not enter Git directory: ' . $directory);
}
try {
$command = 'git ' . implode(' ', array_map('escapeshellarg', $arguments)) . ' 2>&1';
$lines = [];
$code = 0;
exec($command, $lines, $code);
if ($code !== 0) {
throw new RuntimeException('Git command failed: ' . implode("\n", $lines));
}
return trim(implode("\n", $lines));
} finally {
if ($directory !== null) {
chdir($originalDirectory);
}
}
}
try {
if (!is_dir($root . '/.git') && !is_file($root . '/.git')) {
if (is_file($projectRoot . '/.gitmodules')
&& (is_dir($projectRoot . '/.git') || is_file($projectRoot . '/.git'))) {
runGit(['submodule', 'update', '--init', '--', 'server/knowledge/tcm-ancient-books'], $projectRoot);
} else {
runGit(['clone', '--depth', '1', TcmAncientBooksReference::REPOSITORY_URL . '.git', $root]);
}
}
$checkoutRoot = runGit(['rev-parse', '--show-toplevel'], $root);
if (strtolower(str_replace('\\', '/', (string) realpath($checkoutRoot)))
!== strtolower(str_replace('\\', '/', (string) realpath($root)))) {
throw new RuntimeException('Corpus checkout is missing or points to the parent repository');
}
$head = runGit(['rev-parse', 'HEAD'], $root);
if ($head !== TcmAncientBooksReference::COMMIT) {
runGit(['fetch', '--depth', '1', 'origin', TcmAncientBooksReference::COMMIT], $root);
runGit(['checkout', '--detach', TcmAncientBooksReference::COMMIT], $root);
$head = runGit(['rev-parse', 'HEAD'], $root);
}
if ($head !== TcmAncientBooksReference::COMMIT) {
throw new RuntimeException('Corpus commit mismatch');
}
$terms = TcmAncientBooksReference::searchTerms();
$termBytes = [];
$chapterMarker = mb_convert_encoding('<篇名>', 'GB18030', 'UTF-8');
foreach ($terms as $term) {
$termBytes[$term] = mb_convert_encoding($term, 'GB18030', 'UTF-8');
}
$paths = glob($root . '/*.txt') ?: [];
sort($paths, SORT_STRING);
if (count($paths) !== 701) {
throw new RuntimeException('Expected 701 source text files, found ' . count($paths));
}
$byTerm = array_fill_keys($terms, []);
$chapterTerms = [];
$invalidLineCount = 0;
foreach ($paths as $path) {
$filename = basename($path);
$title = preg_replace('/^\d+[\-.]/u', '', preg_replace('/\.txt$/u', '', $filename) ?? $filename) ?? $filename;
$chapter = '';
$chapterTerm = '';
$chapterBodyLines = 0;
$bookHits = [];
$file = new SplFileObject($path, 'r');
$lineNumber = 0;
while (!$file->eof()) {
$raw = $file->fgets();
if ($raw === false) {
break;
}
$lineNumber++;
if (!mb_check_encoding($raw, 'GB18030')) {
// A few upstream files have malformed bytes. Preserve their original
// line numbers and omit only those lines from the search index.
$invalidLineCount++;
continue;
}
if (str_contains($raw, $chapterMarker)) {
$chapter = trim(mb_convert_encoding(str_replace($chapterMarker, '', $raw), 'UTF-8', 'GB18030'));
$chapter = mb_substr($chapter, 0, 80, 'UTF-8');
$chapterTerm = preg_replace('/第[一二三四五六七八九十百千万零〇0-9].*$/u', '', $chapter) ?? $chapter;
$chapterTerm = preg_replace('/(?:脉证|病证|诸证|篇|论)$/u', '', $chapterTerm) ?? $chapterTerm;
$chapterTerm = trim($chapterTerm, " \t\r\n ::");
if (mb_strlen($chapterTerm, 'UTF-8') < 2 || mb_strlen($chapterTerm, 'UTF-8') > 6
|| preg_match('/^\p{Han}+$/u', $chapterTerm) !== 1
|| in_array($chapterTerm, ['序言', '总论', '凡例', '目录', '医论', '医案', '方论', '药论', '本草', '脉法', '用药', '病机', '证治', '杂病'], true)) {
$chapterTerm = '';
}
$chapterBodyLines = 0;
continue;
}
$line = trim(mb_convert_encoding($raw, 'UTF-8', 'GB18030'));
if ($line === '' || str_starts_with($line, '书名:') || str_starts_with($line, '作者:')
|| str_starts_with($line, '<目录>')) {
continue;
}
$chapterBodyLines++;
if ($chapterTerm !== '' && $chapterBodyLines === 1) {
$chapterTerms[$chapterTerm][] = [
'title' => $title, 'chapter' => $chapter,
'path' => $filename, 'line' => $lineNumber,
'excerpt' => mb_substr(preg_replace('/^内容:/u', '', $line) ?? $line, 0, 120, 'UTF-8'),
'score' => 12,
];
}
$matched = [];
foreach ($termBytes as $term => $encoded) {
$bodyMatch = str_contains($raw, $encoded);
$chapterMatch = $chapterBodyLines <= 3 && mb_strpos($chapter, $term, 0, 'UTF-8') !== false;
if ($bodyMatch || $chapterMatch) {
$matched[$term] = [$bodyMatch, $chapterMatch];
}
}
if ($matched === []) {
continue;
}
$line = preg_replace('/^内容:/u', '', $line) ?? $line;
$line = preg_replace('/\s+/u', ' ', $line) ?? $line;
foreach ($matched as $term => [$bodyMatch, $chapterMatch]) {
$pos = mb_strpos($line, $term, 0, 'UTF-8');
$start = max(0, (int) $pos - 55);
$excerpt = mb_substr($line, $start, 180, 'UTF-8');
$score = ($bodyMatch ? 5 : 0) + ($chapterMatch ? 10 : 0)
+ (mb_strpos($title, $term, 0, 'UTF-8') !== false ? 3 : 0);
if ($score > (int) ($bookHits[$term]['score'] ?? -1)) {
$bookHits[$term] = [
'title' => $title, 'chapter' => $chapter,
'path' => $filename, 'line' => $lineNumber,
'excerpt' => $excerpt, 'score' => $score,
];
}
}
}
foreach ($bookHits as $term => $hit) {
$byTerm[$term][] = $hit;
}
}
foreach ($byTerm as &$hits) {
usort($hits, static fn (array $a, array $b): int =>
($b['score'] <=> $a['score']) ?: strcmp($a['path'], $b['path']));
$hits = array_slice($hits, 0, 30);
}
unset($hits);
foreach ($chapterTerms as &$hits) {
usort($hits, static fn (array $a, array $b): int => strcmp($a['path'], $b['path']));
$hits = array_slice($hits, 0, 3);
}
unset($hits);
$shardDirectory = TcmAncientBooksReference::shardDirectory();
if (!is_dir($shardDirectory) && !mkdir($shardDirectory, 0755, true)) {
throw new RuntimeException('Could not create chapter index directory');
}
$shards = [];
foreach ($chapterTerms as $term => $hits) {
$prefix = substr(hash('sha256', (string) $term), 0, 2);
$shards[$prefix][$term] = $hits;
}
foreach ($shards as $prefix => $entries) {
$shard = json_encode([
'commit' => TcmAncientBooksReference::COMMIT,
'index_version' => TcmAncientBooksReference::INDEX_VERSION,
'terms' => $entries,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$shardPath = $shardDirectory . '/' . $prefix . '.json';
$shardTemporary = $shardPath . '.tmp.' . getmypid();
if (file_put_contents($shardTemporary, $shard, LOCK_EX) === false
|| !rename($shardTemporary, $shardPath)) {
throw new RuntimeException('Could not write chapter index shard ' . $prefix);
}
}
$index = [
'repository' => TcmAncientBooksReference::REPOSITORY_URL,
'commit' => TcmAncientBooksReference::COMMIT,
'index_version' => TcmAncientBooksReference::INDEX_VERSION,
'encoding' => 'GB18030',
'book_count' => count($paths),
'skipped_invalid_lines' => $invalidLineCount,
'terms' => $byTerm,
'chapter_term_list' => array_keys($chapterTerms),
];
$encoded = json_encode($index, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$temporary = $indexPath . '.tmp.' . getmypid();
if (file_put_contents($temporary, $encoded, LOCK_EX) === false || !rename($temporary, $indexPath)) {
throw new RuntimeException('Could not write index');
}
echo 'Indexed ' . count($paths) . ' books at commit ' . $head . ', ' . strlen($encoded)
. ' index bytes, skipped ' . $invalidLineCount . " malformed lines.\n";
} catch (Throwable $error) {
fwrite(STDERR, $error->getMessage() . "\n");
exit(1);
}