UpgradeAITranslation.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Helpers\LlmResponseParser;
  4. use App\Http\Api\ChannelApi;
  5. use App\Http\Resources\AiModelResource;
  6. use App\Models\PaliText;
  7. use App\Models\Sentence;
  8. use App\Services\AIAssistant\NissayaTranslateService;
  9. use App\Services\AIAssistant\PaliTranslateService;
  10. use App\Services\AIModelService;
  11. use App\Services\AuthService;
  12. use App\Services\OpenAIService;
  13. use App\Services\SentenceService;
  14. use Illuminate\Console\Command;
  15. use Illuminate\Support\Facades\Cache;
  16. use Illuminate\Support\Facades\Log;
  17. class UpgradeAITranslation extends Command
  18. {
  19. /**
  20. * The name and signature of the console command.
  21. * php artisan upgrade:ai.translation translation --book=131 --para=27
  22. * php artisan upgrade:ai.translation nissaya --book=207 --para=1247
  23. *
  24. * nissaya 参考资料用法示例(--nissaya 指定哪些步骤注入 nissaya 逐词缅文释义):
  25. * - 默认(不传) translate/review/evaluate 全部注入 nissaya
  26. * php artisan upgrade:ai.translation translation {channel} --book=131 --para=27 --steps=translate,review,revise,evaluate
  27. * - 仅 review 注入
  28. * php artisan upgrade:ai.translation translation {channel} --book=131 --para=27 --steps=translate,review,evaluate --nissaya=review
  29. * - review + evaluate 注入,translate 不注入
  30. * php artisan upgrade:ai.translation translation {channel} --book=131 --para=27 --steps=translate,review,evaluate --nissaya=review,evaluate
  31. * - 全部不注入
  32. * php artisan upgrade:ai.translation translation {channel} --book=131 --para=27 --steps=translate,review,evaluate --nissaya=
  33. *
  34. * @var string
  35. */
  36. protected $signature = 'upgrade:ai.translation
  37. {type}
  38. {channel}
  39. {--book=}
  40. {--para=}
  41. {--resume}
  42. {--model=}
  43. {--thinking= : 开启和关闭deepseek thinking true | false}
  44. {--steps=translate : translation 工作流步骤,逗号分隔,可选 translate,term,review,revise,evaluate(term 为术语标注,可单独运行;evaluate 为质量评估,须放最后)}
  45. {--nissaya=translate,review,evaluate : 启用 nissaya 参考资料的步骤,逗号分隔,可选 translate,review,evaluate;传空字符串则全部不注入}
  46. {--fresh : 清除缓存断点,从头开始}';
  47. // 缓存键前缀:以 type、channel 区分,记录已完成的 "book|para" 集合,中断后重跑自动跳过
  48. private const CACHE_KEY_PREFIX = 'upgrade:ai.translation:done';
  49. /**
  50. * The console command description.
  51. *
  52. * @var string
  53. */
  54. protected $description = 'Command description';
  55. protected AiModelResource $model;
  56. protected string $modelToken;
  57. protected array $workChannel;
  58. protected string $accessToken;
  59. protected bool $thinking;
  60. /**
  61. * Create a new command instance.
  62. *
  63. * @return void
  64. */
  65. public function __construct(
  66. protected AIModelService $modelService,
  67. protected SentenceService $sentenceService,
  68. protected OpenAIService $openAIService,
  69. protected NissayaTranslateService $nissayaTranslateService,
  70. protected PaliTranslateService $paliTranslateService
  71. ) {
  72. parent::__construct();
  73. }
  74. /**
  75. * Execute the console command.
  76. *
  77. * @return int
  78. */
  79. public function handle()
  80. {
  81. /**
  82. * model
  83. */
  84. if (! $this->option('model')) {
  85. $this->error('model is request');
  86. return 1;
  87. }
  88. $this->model = $this->modelService->getModelById($this->option('model'));
  89. if (empty($this->model)) {
  90. $this->error('invalid model id ');
  91. return 1;
  92. }
  93. $this->info("model:{$this->model['model']}");
  94. $this->modelToken = AuthService::getUserToken($this->model['uid']);
  95. // channel
  96. $this->workChannel = ChannelApi::getById($this->argument('channel'));
  97. // 需要判断输入channel 与翻译类型是否一致 nissaya -> nissaya channel
  98. if ($this->workChannel['type'] !== $this->argument('type')) {
  99. $this->error('channel type not match request '.$this->argument('type').' input is '.$this->workChannel['type']);
  100. return 1;
  101. }
  102. if ($this->option('thinking')) {
  103. $this->thinking = $this->option('thinking') === 'true';
  104. $this->line('thinking is '.$this->option('thinking'));
  105. }
  106. // translation 工作流步骤校验
  107. $steps = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('steps')))));
  108. $invalid = array_diff($steps, PaliTranslateService::STEPS);
  109. if (! empty($invalid)) {
  110. $this->error('invalid steps: '.implode(',', $invalid).'. allowed: '.implode(',', PaliTranslateService::STEPS));
  111. return 1;
  112. }
  113. // nissaya 参考资料注入步骤校验(哪些步骤启用 nissaya)
  114. $nissayaSteps = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('nissaya')))));
  115. $invalidNissaya = array_diff($nissayaSteps, PaliTranslateService::NISSAYA_STEPS);
  116. if (! empty($invalidNissaya)) {
  117. $this->error('invalid nissaya steps: '.implode(',', $invalidNissaya).'. allowed: '.implode(',', PaliTranslateService::NISSAYA_STEPS));
  118. return 1;
  119. }
  120. $type = $this->argument('type');
  121. $channelId = $this->workChannel['id'] ?? '';
  122. // 缓存键:按 type、channel 区分不同任务的断点
  123. $cacheKey = self::CACHE_KEY_PREFIX.':'.$type.':'.$channelId;
  124. if ($this->option('fresh')) {
  125. //Cache::forget($cacheKey);
  126. $this->info('Cleared cached cursor.');
  127. }
  128. // 是否为完整遍历(未指定 book/para),仅此情形在结束后清空断点缓存
  129. $isFullRun = ! $this->option('book') && ! $this->option('para');
  130. // 从缓存恢复已完成的 (book, para) 集合,作为重入时的稳定游标
  131. $done = [];// Cache::get($cacheKey, []);
  132. $books = [];
  133. if ($this->option('book')) {
  134. $books = [$this->option('book')];
  135. } else {
  136. // 未指定 book 时,若已有断点缓存,从上次处理到的 book 继续,无需从 1 开始
  137. $startBook = 1;
  138. if (! empty($done)) {
  139. $doneBooks = array_map(fn ($cursor) => (int) explode('|', $cursor)[0], array_keys($done));
  140. $startBook = max($doneBooks);
  141. $this->info("resume from book {$startBook}");
  142. }
  143. $books = range($startBook, 217);
  144. }
  145. foreach ($books as $book) {
  146. $maxParagraph = PaliText::where('book', $book)->max('paragraph');
  147. $paragraphs = range(1, $maxParagraph);
  148. if ($this->option('para')) {
  149. $paragraphs = [$this->option('para')];
  150. }
  151. foreach ($paragraphs as $paragraph) {
  152. // 稳定游标:缓存键已含 type、channel,此处仅以 book|para 标识处理单元
  153. $cursor = $book.'|'.$paragraph;
  154. if (isset($done[$cursor])) {
  155. $this->info("skip {$cursor}");
  156. continue;
  157. }
  158. $start = time();
  159. $data = [];
  160. switch ($this->argument('type')) {
  161. case 'translation':
  162. $data = $this->paliTranslateService
  163. ->setModel($this->model)
  164. ->setChannel($this->workChannel)
  165. ->setThinking($this->thinking ?? null)
  166. ->setNissayaSteps($nissayaSteps)
  167. ->run($steps, (int) $book, (int) $paragraph);
  168. break;
  169. case 'nissaya':
  170. $data = $this->aiNissayaTranslate($book, $paragraph);
  171. break;
  172. case 'wbw':
  173. $data = $this->aiWBW($book, $paragraph);
  174. break;
  175. default:
  176. // code...
  177. break;
  178. }
  179. $this->save($data);
  180. $time = time() - $start;
  181. $this->info($this->argument('type')." {$book}-{$paragraph} ".count($data).' sentences time='.$time);
  182. // 该处理单元全部写库完成后再标记游标,确保中途中断不会误跳过
  183. $done[$cursor] = true;
  184. //Cache::put($cacheKey, $done, now()->addHours(24));
  185. }
  186. $param = [
  187. '--book' => $book,
  188. '--channel' => $this->workChannel['id'],
  189. ];
  190. if ($this->option('para')) {
  191. $param['--para'] = $this->option('para');
  192. }
  193. $this->call('upgrade:progress.para', $param);
  194. $this->call('upgrade:progress.chapter', $param);
  195. $param = [
  196. 'book' => $book,
  197. '--channel' => $this->workChannel['id'],
  198. '--summary' => 'off',
  199. '--granularity' => 'chapter',
  200. ];
  201. if ($this->option('para')) {
  202. $param['--para'] = $this->option('para');
  203. }
  204. $this->call('opensearch:index-tipitaka', $param);
  205. }
  206. // 完整遍历正常结束,清空断点缓存
  207. if ($isFullRun) {
  208. //Cache::forget($cacheKey);
  209. }
  210. return 0;
  211. }
  212. private function aiWBW($book, $para)
  213. {
  214. $sysPrompt = <<<'md'
  215. 你是一个佛教翻译专家,精通巴利文和缅文,精通巴利文逐词解析
  216. ## 翻译要求:
  217. - 请将用户提供的巴利句子单词表中的每个巴利文单词翻译为中文
  218. - 这些单词是一个完整的句子,请根据单词的上下文翻译
  219. - original 里面的数据是巴利文单词
  220. - 输入格式为 json 数组
  221. - 输出jsonl格式
  222. 在原来的数据中添加下列输出字段
  223. 1. meaning:单词的中文意思,如果有两个可能的意思,两个意思之间用/符号分隔
  224. 5. confidence:你认为你给出的这个单词的信息的信心指数(准确程度) 数值1-100 如果觉得非常有把握100, 如果觉得把握不大,适当降低信心指数
  225. 6. note:如果你认为信心指数很低,这个是疑难单词,请在note字段写明原因,如果不是疑难单词,请不要填写note
  226. **范例**:
  227. {"id":1,"original":"bhikkhusanghassa","meaning":"比库僧团[的]","confidence":100}
  228. 直接输出jsonl, 无需其他内容
  229. md;
  230. $channelId = ChannelApi::getSysChannel('_System_Wbw_VRI_');
  231. $sentences = Sentence::where('channel_uid', $channelId)
  232. ->where('book_id', $book)
  233. ->where('paragraph', $para)
  234. ->get();
  235. $result = [];
  236. foreach ($sentences as $key => $sentence) {
  237. $wbw = json_decode($sentence->content);
  238. $tpl = [];
  239. foreach ($wbw as $key => $word) {
  240. if (
  241. ! empty($word->real->value) &&
  242. $word->type->value !== '.ctl.'
  243. ) {
  244. $tpl[] = [
  245. 'id' => $word->sn[0],
  246. 'original' => $word->real->value,
  247. ];
  248. }
  249. }
  250. $tplText = json_encode($tpl, JSON_UNESCAPED_UNICODE);
  251. Log::debug($tplText);
  252. $startAt = time();
  253. $llm = $this->openAIService->setApiUrl($this->model['url'])
  254. ->setModel($this->model['model'])
  255. ->setApiKey($this->model['key'])
  256. ->setSystemPrompt($sysPrompt)
  257. ->setTemperature(0.7)
  258. ->setStream(false);
  259. if (isset($this->thinking)) {
  260. $llm = $llm->setThinking($this->thinking);
  261. }
  262. $response = $llm->send("```json\n{$tplText}\n```");
  263. $complete = time() - $startAt;
  264. $content = $response['choices'][0]['message']['content'] ?? '[]';
  265. Log::debug("ai response in {$complete}s content=".$content);
  266. $json = LlmResponseParser::jsonl($content);
  267. $id = "{$sentence->book_id}-{$sentence->paragraph}-{$sentence->word_start}-{$sentence->word_end}";
  268. $result[] = [
  269. 'id' => $id,
  270. 'content' => json_encode($json, JSON_UNESCAPED_UNICODE),
  271. ];
  272. }
  273. return $result;
  274. }
  275. private function aiNissayaTranslate(int $book, int $para)
  276. {
  277. $sentences = Sentence::nissaya()
  278. ->language('my') // 过滤缅文
  279. ->where('book_id', $book)
  280. ->where('paragraph', $para)
  281. ->orderBy('word_start')
  282. ->get();
  283. $result = [];
  284. foreach ($sentences as $key => $sentence) {
  285. if (! empty($sentence->content)) {
  286. $id = "{$sentence->book_id}-{$sentence->paragraph}-{$sentence->word_start}-{$sentence->word_end}";
  287. $aiNissaya = $this->nissayaTranslateService
  288. ->setModel($this->model)
  289. ->setThinking($this->thinking ?? null)
  290. ->translate($sentence->content, false);
  291. Log::debug('ai response ', ['content' => $aiNissaya['data']]);
  292. $result[] = [
  293. 'id' => $id,
  294. 'content' => json_encode($aiNissaya['data'] ?? [], JSON_UNESCAPED_UNICODE),
  295. 'content_type' => 'json',
  296. ];
  297. }
  298. }
  299. return $result;
  300. }
  301. private function save(array $data)
  302. {
  303. // 写入句子库
  304. try {
  305. // 过滤掉 id 不存在的 item(LLM 输出可能缺失或畸形),避免后续 explode 报错
  306. $data = array_filter($data, fn ($n) => ! empty($n['id']));
  307. $sentData = [];
  308. $sentData = array_map(function ($n) {
  309. $sId = explode('-', $n['id']);
  310. return [
  311. 'book_id' => $sId[0],
  312. 'paragraph' => $sId[1],
  313. 'word_start' => $sId[2],
  314. 'word_end' => $sId[3],
  315. 'channel_uid' => $this->workChannel['id'],
  316. 'content' => $n['content'] ?? '',
  317. 'content_type' => $n['content_type'] ?? 'markdown',
  318. 'lang' => $this->workChannel['lang'],
  319. 'status' => $this->workChannel['status'],
  320. 'editor_uid' => $this->model['uid'],
  321. ];
  322. }, $data);
  323. foreach ($sentData as $key => $value) {
  324. $this->sentenceService->saveWithHistory($value);
  325. }
  326. } catch (\Exception $e) {
  327. $this->error($e->getMessage());
  328. throw $e;
  329. }
  330. }
  331. }