UpdateCorpus.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Http\Api\UserApi;
  4. use App\Models\Channel;
  5. use App\Services\SentenceService;
  6. use App\Services\TermService;
  7. use Illuminate\Console\Attributes\Description;
  8. use Illuminate\Console\Attributes\Signature;
  9. use Illuminate\Console\Command;
  10. use Illuminate\Database\Eloquent\Collection;
  11. use Illuminate\Support\Facades\DB;
  12. use Illuminate\Support\Facades\Log;
  13. #[Signature('app:update-corpus {--dir=} {--es}')]
  14. #[Description('Update corpus from JSONL files in corpus directory')]
  15. class UpdateCorpus extends Command
  16. {
  17. /**
  18. * The SentenceService instance.
  19. */
  20. protected SentenceService $sentenceService;
  21. protected TermService $termService;
  22. /**
  23. * Create a new command instance.
  24. */
  25. public function __construct(SentenceService $sentenceService, TermService $termService)
  26. {
  27. parent::__construct();
  28. $this->sentenceService = $sentenceService;
  29. $this->termService = $termService;
  30. }
  31. /**
  32. * Execute the console command.
  33. */
  34. public function handle(): int
  35. {
  36. $this->info('Starting corpus update process...');
  37. // Get the corpus base path from config
  38. if ($this->option('dir')) {
  39. $corpusBasePath = $this->option('dir');
  40. } else {
  41. $corpusBasePath = config('mint.path.corpus');
  42. }
  43. if (! is_dir($corpusBasePath)) {
  44. $this->error("Corpus directory not found: {$corpusBasePath}");
  45. return self::FAILURE;
  46. }
  47. // Scan subdirectories of the corpus path
  48. $stores = $this->getSubdirectories($corpusBasePath);
  49. if (empty($stores)) {
  50. $this->warn('No subdirectories found in corpus path.');
  51. return self::SUCCESS;
  52. }
  53. $this->info('Found '.count($stores).' subdirectories to process.');
  54. $totalProcessed = 0;
  55. $totalErrors = 0;
  56. foreach ($stores as $store) {
  57. $this->info("Processing directory: {$store}");
  58. try {
  59. $stats = $this->processCorpusDirectory($store);
  60. $totalProcessed += $stats['processed'];
  61. $totalErrors += $stats['errors'];
  62. $this->info("Directory processed: {$stats['processed']} records saved, {$stats['errors']} errors");
  63. if ($this->option('es') && isset($stats['channels'])) {
  64. foreach ($stats['channels'] as $key => $channelId) {
  65. $this->call('upgrade:progress', ['--channel' => $channelId]);
  66. $this->call('upgrade:progress.chapter', ['--channel' => $channelId]);
  67. $this->call('opensearch:index-tipitaka', [
  68. 'book' => 0,
  69. '--channel' => $channelId,
  70. '--granularity' => 'chapter',
  71. '--summary' => 'off',
  72. ]);
  73. }
  74. }
  75. } catch (\Exception $e) {
  76. $this->error("Failed to process directory {$store}: {$e->getMessage()}");
  77. Log::error('Failed to process directory', [
  78. 'dir' => $store,
  79. 'message' => $e->getMessage(),
  80. 'file' => $e->getFile(),
  81. 'line' => $e->getLine(),
  82. 'trace' => $e->getTraceAsString(),
  83. ]);
  84. $totalErrors++;
  85. }
  86. }
  87. $this->info("Corpus update completed. Total processed: {$totalProcessed}, Total errors: {$totalErrors}");
  88. return $totalErrors > 0 ? self::FAILURE : self::SUCCESS;
  89. }
  90. /**
  91. * Get all subdirectories of a given directory.
  92. */
  93. protected function getSubdirectories(string $path): array
  94. {
  95. $directories = [];
  96. $items = scandir($path);
  97. foreach ($items as $item) {
  98. if ($item === '.' || $item === '..') {
  99. continue;
  100. }
  101. $fullPath = $path.DIRECTORY_SEPARATOR.$item;
  102. if (is_dir($fullPath)) {
  103. $directories[] = $fullPath;
  104. }
  105. }
  106. return $directories;
  107. }
  108. /**
  109. * Process a single corpus directory.
  110. *
  111. * @throws \Exception
  112. */
  113. protected function processCorpusDirectory(string $directoryPath): array
  114. {
  115. $stats = [
  116. 'processed' => 0,
  117. 'errors' => 0,
  118. ];
  119. // Read meta.json file
  120. $metaFile = $directoryPath.DIRECTORY_SEPARATOR.'meta.json';
  121. if (! file_exists($metaFile)) {
  122. $this->warn("meta.json not found in directory: {$directoryPath}");
  123. return $stats;
  124. }
  125. $metaData = json_decode(file_get_contents($metaFile), true);
  126. if (! isset($metaData['id'])) {
  127. $this->error("Invalid meta.json: missing 'id' field in {$directoryPath}");
  128. return $stats;
  129. }
  130. $sourceId = $metaData['id'];
  131. $this->info("Processing {$directoryPath} source ID: {$sourceId}");
  132. // Find all channel records with matching source_id
  133. $channels = Channel::where('source_id', $sourceId)->get();
  134. if ($channels->isEmpty()) {
  135. $this->warn("No channels found with source_id: {$sourceId}");
  136. return $stats;
  137. }
  138. $this->info("Found {$channels->count()} channel(s) for source ID: {$sourceId}");
  139. $glossaryFile = $directoryPath.DIRECTORY_SEPARATOR.'glossary.csv';
  140. if (file_exists($glossaryFile)) {
  141. $status = $this->processGlossary($glossaryFile, $channels);
  142. $this->line('glossary load');
  143. }
  144. // Scan subdirectories of the current directory for JSONL files
  145. $childDirectories = $this->getSubdirectories($directoryPath);
  146. foreach ($childDirectories as $childDir) {
  147. $this->info("Scanning directory for JSONL files: {$childDir}");
  148. $jsonlFiles = glob($childDir.DIRECTORY_SEPARATOR.'*.jsonl');
  149. foreach ($jsonlFiles as $jsonlFile) {
  150. $this->line("Processing file: {$jsonlFile}");
  151. $fileStats = $this->processJsonlFile($jsonlFile, $channels);
  152. $stats['processed'] += $fileStats['processed'];
  153. $stats['errors'] += $fileStats['errors'];
  154. }
  155. }
  156. $stats['channels'] = array_map(fn ($item) => $item['uid'], $channels->toArray());
  157. return $stats;
  158. }
  159. /**
  160. * Process a glossary csv file and save glossary for each channel.
  161. *
  162. * @param Collection $channels
  163. */
  164. protected function processGlossary(string $filePath, $channels): array
  165. {
  166. $stats = [
  167. 'processed' => 0,
  168. 'errors' => 0,
  169. ];
  170. $handle = fopen($filePath, 'r');
  171. if (! $handle) {
  172. $this->error("Failed to open file: {$filePath}");
  173. return $stats;
  174. }
  175. $robotUid = config('mint.admin.robot_uuid');
  176. if (! $robotUid) {
  177. $this->error('robot_uuid not configured in mint.admin.robot_uid');
  178. fclose($handle);
  179. return $stats;
  180. }
  181. // 读取表头行
  182. $headers = fgetcsv($handle);
  183. if ($headers === false) {
  184. $this->error("Failed to read CSV headers from: {$filePath}");
  185. fclose($handle);
  186. return $stats;
  187. }
  188. $lineNumber = 0;
  189. while (($row = fgetcsv($handle)) !== false) {
  190. $lineNumber++;
  191. if (count($row) !== count($headers)) {
  192. $this->error("Column count mismatch at line {$lineNumber} in file: {$filePath}");
  193. $stats['errors']++;
  194. continue;
  195. }
  196. $data = array_combine($headers, $row);
  197. $editor_id = UserApi::getIdByUuid($robotUid);
  198. foreach ($channels as $channel) {
  199. try {
  200. $saveData = [
  201. 'word' => $data['pali_word'],
  202. 'tag' => $data['tag'] ?? null,
  203. 'channel_id' => $channel->uid,
  204. 'meaning' => $data['meaning'],
  205. 'redirect' => $data['redirect'] ?? null,
  206. 'other_meaning' => $data['meaning2'] ?: null,
  207. 'note' => $data['note'] ?: null,
  208. 'editor_id' => $editor_id,
  209. ];
  210. DB::transaction(function () use ($saveData) {
  211. $this->termService->updateOrCreateByWord($saveData);
  212. });
  213. $stats['processed']++;
  214. } catch (\Exception $e) {
  215. $this->error("Failed to save glossary for channel {$channel->uid} at line {$lineNumber}: {$e->getMessage()}");
  216. $stats['errors']++;
  217. }
  218. }
  219. }
  220. fclose($handle);
  221. $this->line("glossary {$lineNumber} lines processed");
  222. return $stats;
  223. }
  224. /**
  225. * Process a single JSONL file and save records for each channel.
  226. *
  227. * @param Collection $channels
  228. */
  229. protected function processJsonlFile(string $filePath, $channels): array
  230. {
  231. $stats = [
  232. 'processed' => 0,
  233. 'errors' => 0,
  234. ];
  235. $handle = fopen($filePath, 'r');
  236. if (! $handle) {
  237. $this->error("Failed to open file: {$filePath}");
  238. return $stats;
  239. }
  240. $lineNumber = 0;
  241. $robotUid = config('mint.admin.robot_uuid');
  242. if (! $robotUid) {
  243. $this->error('robot_uuid not configured in mint.admin.robot_uuid');
  244. fclose($handle);
  245. return $stats;
  246. }
  247. while (($line = fgets($handle)) !== false) {
  248. $lineNumber++;
  249. $line = trim($line);
  250. if (empty($line)) {
  251. continue;
  252. }
  253. // Parse JSON line
  254. $data = json_decode($line, true);
  255. if ($data === null) {
  256. $this->error("Failed to parse JSON at line {$lineNumber} in file: {$filePath}");
  257. $stats['errors']++;
  258. continue;
  259. }
  260. // Save for each channel
  261. foreach ($channels as $channel) {
  262. try {
  263. [$book, $para, $start, $end] = explode('-', $data['id']);
  264. $saveData = [
  265. 'book_id' => $book,
  266. 'paragraph' => $para,
  267. 'word_start' => $start,
  268. 'word_end' => $end,
  269. 'content' => $data['content'],
  270. 'channel_uid' => $channel->uid,
  271. 'editor_uid' => $robotUid,
  272. ];
  273. DB::transaction(function () use ($saveData) {
  274. $this->sentenceService->save($saveData);
  275. });
  276. $stats['processed']++;
  277. // $this->line("Saved record for channel: {$channel->uid}");
  278. } catch (\Exception $e) {
  279. $this->error("Failed to save record for channel {$channel->uid} at line {$lineNumber}: {$e->getMessage()}");
  280. $stats['errors']++;
  281. }
  282. }
  283. }
  284. fclose($handle);
  285. $this->line("$lineNumber lines write");
  286. return $stats;
  287. }
  288. }