AiTranslateService.php 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Support\Facades\Log;
  4. use Illuminate\Support\Facades\Http;
  5. use Illuminate\Http\Client\RequestException;
  6. use App\Tools\RedisClusters;
  7. use App\Models\Task;
  8. use App\Models\PaliText;
  9. use App\Models\PaliSentence;
  10. use App\Models\AiModel;
  11. use App\Models\Sentence;
  12. use App\Http\Api\ChannelApi;
  13. use App\Http\Controllers\AuthController;
  14. use App\Http\Api\MdRender;
  15. use App\Exceptions\SectionTimeoutException;
  16. class DatabaseException extends \Exception {}
  17. class AiTranslateService
  18. {
  19. private $queue = 'ai_translate';
  20. private $modelToken = null;
  21. private $task = null;
  22. protected $mq;
  23. private $apiTimeout = 30;
  24. private $llmTimeout = 300;
  25. private $taskTopicId;
  26. private $stop = false;
  27. private $maxProcessTime = 15 * 60; //一个句子的最大处理时间
  28. private $mqTimeout = 60;
  29. public function __construct() {}
  30. /**
  31. * @param string $messageId
  32. * @param array $translateData
  33. */
  34. public function processTranslate(string $messageId, array $messages): bool
  35. {
  36. $start = time();
  37. if (!is_array($messages) || count($messages) === 0) {
  38. Log::error('message is not array');
  39. return false;
  40. }
  41. $first = $messages[0];
  42. $this->task = $first->task->info;
  43. $taskId = $this->task->id;
  44. RedisClusters::put("/task/{$taskId}/message_id", $messageId);
  45. $pointerKey = "/task/{$taskId}/pointer";
  46. $pointer = 0;
  47. if (RedisClusters::has($pointerKey)) {
  48. //回到上次中断的点
  49. $pointer = RedisClusters::get($pointerKey);
  50. Log::info("last break point {$pointer}");
  51. }
  52. //获取model token
  53. $this->modelToken = $first->model->token;
  54. Log::debug($this->queue . ' ai assistant token', ['token' => $this->modelToken]);
  55. $this->setTaskStatus($this->task->id, 'running');
  56. // 设置task discussion topic
  57. $this->taskTopicId = $this->taskDiscussion(
  58. $this->task->id,
  59. 'task',
  60. $this->task->title,
  61. $this->task->category,
  62. null
  63. );
  64. $time = [$this->maxProcessTime];
  65. for ($i = $pointer; $i < count($messages); $i++) {
  66. // 获取当前内存使用量
  67. Log::debug("memory usage: " . memory_get_usage(true) / 1024 / 1024 . " MB");
  68. // 获取峰值内存使用量
  69. Log::debug("memory peak usage: " . memory_get_peak_usage(true) / 1024 / 1024 . " MB");
  70. if ($this->stop) {
  71. Log::info("收到退出信号 pointer={$i}");
  72. return false;
  73. }
  74. if (\App\Tools\Tools::isStop()) {
  75. //检测到停止标记
  76. return false;
  77. }
  78. RedisClusters::put($pointerKey, $i);
  79. $message = $messages[$i];
  80. $taskDiscussionContent = [];
  81. //推理
  82. try {
  83. $responseLLM = $this->requestLLM($message);
  84. $taskDiscussionContent[] = '- LLM request successful';
  85. } catch (RequestException $e) {
  86. throw $e;
  87. }
  88. if ($this->task->category === 'translate') {
  89. //写入句子库
  90. $message->sentence->content = $responseLLM['content'];
  91. try {
  92. $this->saveSentence($message->sentence);
  93. } catch (\Exception $e) {
  94. Log::error('sentence', ['message' => $e]);
  95. continue;
  96. }
  97. }
  98. if ($this->task->category === 'suggest') {
  99. //写入pr
  100. try {
  101. $this->savePr($message->sentence, $responseLLM['content']);
  102. } catch (\Exception $e) {
  103. Log::error('sentence', ['message' => $e]);
  104. continue;
  105. }
  106. }
  107. #获取句子id
  108. $sUid = $this->getSentenceId($message->sentence);
  109. //写入句子 discussion
  110. $topicId = $this->taskDiscussion(
  111. $sUid,
  112. 'sentence',
  113. $this->task->title,
  114. $this->task->category,
  115. null
  116. );
  117. if ($topicId) {
  118. Log::info($this->queue . ' discussion create topic successful');
  119. $data['parent'] = $topicId;
  120. unset($data['title']);
  121. $topicChildren = [];
  122. //提示词
  123. $topicChildren[] = $message->prompt;
  124. //任务结果
  125. $topicChildren[] = $responseLLM['content'];
  126. //推理过程写入discussion
  127. if (
  128. isset($responseLLM['reasoningContent']) &&
  129. !empty($responseLLM['reasoningContent'])
  130. ) {
  131. $topicChildren[] = $responseLLM['reasoningContent'];
  132. }
  133. foreach ($topicChildren as $content) {
  134. Log::debug($this->queue . ' discussion child request', ['data' => $data]);
  135. $dId = $this->taskDiscussion($sUid, 'sentence', $this->task->title, $content, $topicId);
  136. if ($dId) {
  137. Log::info($this->queue . ' discussion child successful');
  138. }
  139. }
  140. } else {
  141. Log::error($this->queue . ' discussion create topic response is null');
  142. }
  143. //修改task 完成度
  144. $progress = $this->setTaskProgress($message->task->progress);
  145. $taskDiscussionContent[] = "- progress=" . $progress;
  146. //写入task discussion
  147. if ($this->taskTopicId) {
  148. $content = implode('\n', $taskDiscussionContent);
  149. $dId = $this->taskDiscussion(
  150. $this->task->id,
  151. 'task',
  152. $this->task->title,
  153. $content,
  154. $this->taskTopicId
  155. );
  156. } else {
  157. Log::error('no task discussion root');
  158. }
  159. //计算剩余时间是否足够再做一次
  160. $time[] = time() - $start;
  161. rsort($time);
  162. $remain = $this->mqTimeout - (time() - $start);
  163. if ($remain < $time[0]) {
  164. throw new SectionTimeoutException;
  165. }
  166. }
  167. //任务完成 修改任务状态为 done
  168. if ($i === count($messages)) {
  169. $this->setTaskStatus($this->task->id, 'done');
  170. RedisClusters::forget($pointerKey);
  171. Log::info('ai translate task complete');
  172. }
  173. return true;
  174. }
  175. private function setTaskStatus($taskId, $status)
  176. {
  177. $url = config('app.url') . '/api/v2/task-status/' . $taskId;
  178. $data = [
  179. 'status' => $status,
  180. ];
  181. Log::debug('ai_translate task status request', ['url' => $url, 'data' => $data]);
  182. $response = Http::timeout($this->apiTimeout)->withToken($this->modelToken)->patch($url, $data);
  183. //判断状态码
  184. if ($response->failed()) {
  185. Log::error('ai_translate task status error', ['data' => $response->json()]);
  186. } else {
  187. Log::info('ai_translate task status done');
  188. }
  189. }
  190. private function saveModelLog($token, $data)
  191. {
  192. $url = config('app.url') . '/api/v2/model-log';
  193. $response = Http::timeout($this->apiTimeout)->withToken($token)->post($url, $data);
  194. if ($response->failed()) {
  195. Log::error('ai-translate model log create failed', ['data' => $response->json()]);
  196. return false;
  197. }
  198. return true;
  199. }
  200. private function taskDiscussion($resId, $resType, $title, $content, $parentId = null)
  201. {
  202. $url = config('app.url') . '/api/v2/discussion';
  203. $taskDiscussionData = [
  204. 'res_id' => $resId,
  205. 'res_type' => $resType,
  206. 'content' => $content,
  207. 'content_type' => 'markdown',
  208. 'type' => 'discussion',
  209. 'notification' => false,
  210. ];
  211. if ($parentId) {
  212. $taskDiscussionData['parent'] = $parentId;
  213. } else {
  214. $taskDiscussionData['title'] = $title;
  215. }
  216. Log::debug($this->queue . ' discussion create', ['url' => $url, 'data' => json_encode($taskDiscussionData)]);
  217. $response = Http::timeout($this->apiTimeout)
  218. ->withToken($this->modelToken)
  219. ->post($url, $taskDiscussionData);
  220. if ($response->failed()) {
  221. Log::error($this->queue . ' discussion create error', ['data' => $response->json()]);
  222. return false;
  223. }
  224. Log::debug($this->queue . ' discussion create', ['data' => json_encode($response->json())]);
  225. if (isset($response->json()['data']['id'])) {
  226. return $response->json()['data']['id'];
  227. }
  228. return false;
  229. }
  230. private function requestLLM($message)
  231. {
  232. $param = [
  233. "model" => $message->model->model,
  234. "messages" => [
  235. ["role" => "system", "content" => $message->model->system_prompt ?? ''],
  236. ["role" => "user", "content" => $message->prompt],
  237. ],
  238. "temperature" => 0.7,
  239. "stream" => false
  240. ];
  241. Log::info($this->queue . ' LLM request ' . $message->model->url . ' model:' . $param['model']);
  242. Log::debug($this->queue . ' LLM api request', [
  243. 'url' => $message->model->url,
  244. 'data' => json_encode($param),
  245. ]);
  246. //写入 model log
  247. $modelLogData = [
  248. 'model_id' => $message->model->uid,
  249. 'request_at' => now(),
  250. 'request_data' => json_encode($param, JSON_UNESCAPED_UNICODE),
  251. ];
  252. //失败重试
  253. $maxRetries = 3;
  254. $attempt = 0;
  255. try {
  256. while ($attempt < $maxRetries) {
  257. try {
  258. $response = Http::withToken($message->model->key)
  259. ->timeout($this->llmTimeout)
  260. ->post($message->model->url, $param);
  261. // 如果状态码是 4xx 或 5xx,会自动抛出 RequestException
  262. $response->throw();
  263. Log::info($this->queue . ' LLM request successful');
  264. $modelLogData['request_headers'] = json_encode($response->handlerStats(), JSON_UNESCAPED_UNICODE);
  265. $modelLogData['response_headers'] = json_encode($response->headers(), JSON_UNESCAPED_UNICODE);
  266. $modelLogData['status'] = $response->status();
  267. $modelLogData['response_data'] = json_encode($response->json(), JSON_UNESCAPED_UNICODE);
  268. self::saveModelLog($this->modelToken, $modelLogData);
  269. break; // 跳出 while 循环
  270. } catch (RequestException $e) {
  271. $attempt++;
  272. $status = $e->response->status();
  273. // 某些错误不需要重试
  274. if (in_array($status, [400, 401, 403, 404, 422])) {
  275. Log::warning("客户端错误,不重试: {$status}\n");
  276. throw $e; // 重新抛出异常
  277. }
  278. // 服务器错误或网络错误可以重试
  279. if ($attempt < $maxRetries) {
  280. $delay = pow(2, $attempt); // 指数退避
  281. Log::warning("请求失败(第 {$attempt} 次),{$delay} 秒后重试...\n");
  282. sleep($delay);
  283. } else {
  284. Log::error("达到最大重试次数,请求最终失败\n");
  285. throw $e;
  286. }
  287. }
  288. }
  289. } catch (RequestException $e) {
  290. Log::error($this->queue . ' LLM request exception: ' . $e->getMessage());
  291. $failResponse = $e->response;
  292. $modelLogData['request_headers'] = json_encode($failResponse->handlerStats(), JSON_UNESCAPED_UNICODE);
  293. $modelLogData['response_headers'] = json_encode($failResponse->headers(), JSON_UNESCAPED_UNICODE);
  294. $modelLogData['status'] = $failResponse->status();
  295. $modelLogData['response_data'] = $response->body();
  296. $modelLogData['success'] = false;
  297. self::saveModelLog($this->modelToken, $modelLogData);
  298. throw $e;
  299. }
  300. Log::info($this->queue . ' model log saved');
  301. $aiData = $response->json();
  302. Log::debug($this->queue . ' LLM http response', ['data' => $response->json()]);
  303. $responseContent = $aiData['choices'][0]['message']['content'];
  304. if (isset($aiData['choices'][0]['message']['reasoning_content'])) {
  305. $reasoningContent = $aiData['choices'][0]['message']['reasoning_content'];
  306. }
  307. $output = ['content' => $responseContent];
  308. Log::debug($this->queue . ' LLM response content=' . $responseContent);
  309. if (empty($reasoningContent)) {
  310. Log::debug($this->queue . ' no reasoningContent');
  311. } else {
  312. Log::debug($this->queue . ' reasoning=' . $reasoningContent);
  313. $output['reasoningContent'] = $reasoningContent;
  314. }
  315. return $output;
  316. }
  317. /**
  318. * 写入句子库
  319. */
  320. private function saveSentence($sentence)
  321. {
  322. $url = config('app.url') . '/api/v2/sentence';
  323. Log::info($this->queue . " sentence update {$url}");
  324. $response = Http::timeout($this->apiTimeout)->withToken($this->modelToken)->post($url, [
  325. 'sentences' => [$sentence],
  326. ]);
  327. if ($response->failed()) {
  328. Log::error($this->queue . ' sentence update failed', [
  329. 'url' => $url,
  330. 'data' => $response->json(),
  331. ]);
  332. throw new DatabaseException("sentence 数据库写入错误");
  333. }
  334. $count = $response->json()['data']['count'];
  335. Log::info("{$this->queue} sentence update {$count} successful");
  336. }
  337. private function savePr($sentence, $content)
  338. {
  339. $url = config('app.url') . '/api/v2/sentpr';
  340. Log::info($this->queue . " sentence update {$url}");
  341. $response = Http::timeout($this->apiTimeout)->withToken($this->modelToken)->post($url, [
  342. 'book' => $sentence->book_id,
  343. 'para' => $sentence->paragraph,
  344. 'begin' => $sentence->word_start,
  345. 'end' => $sentence->word_end,
  346. 'channel' => $sentence->channel_uid,
  347. 'text' => $content,
  348. 'notification' => false,
  349. 'webhook' => false,
  350. ]);
  351. if ($response->failed()) {
  352. Log::error($this->queue . ' sentence update failed', [
  353. 'url' => $url,
  354. 'data' => $response->json(),
  355. ]);
  356. throw new DatabaseException("pr 数据库写入错误");
  357. }
  358. if ($response->json()['ok']) {
  359. Log::info("{$this->queue} sentence suggest update successful");
  360. } else {
  361. Log::error("{$this->queue} sentence suggest update failed", [
  362. 'url' => $url,
  363. 'data' => $response->json(),
  364. ]);
  365. }
  366. }
  367. private function getSentenceId($sentence)
  368. {
  369. $url = config('app.url') . '/api/v2/sentence-info/aa';
  370. Log::info('ai translate', ['url' => $url]);
  371. $response = Http::timeout($this->apiTimeout)->withToken($this->modelToken)->get($url, [
  372. 'book' => $sentence->book_id,
  373. 'par' => $sentence->paragraph,
  374. 'start' => $sentence->word_start,
  375. 'end' => $sentence->word_end,
  376. 'channel' => $sentence->channel_uid
  377. ]);
  378. if (!$response->json()['ok']) {
  379. Log::error($this->queue . ' sentence id error', ['data' => $response->json()]);
  380. return false;
  381. }
  382. $sUid = $response->json()['data']['id'];
  383. Log::debug("sentence id={$sUid}");
  384. return $sUid;
  385. }
  386. private function setTaskProgress($current)
  387. {
  388. $taskProgress = $current;
  389. if ($taskProgress->total > 0) {
  390. $progress = (int)($taskProgress->current * 100 / $taskProgress->total);
  391. } else {
  392. $progress = 100;
  393. Log::error($this->queue . ' progress total is zero', ['task_id' => $this->task->id]);
  394. }
  395. $url = config('app.url') . '/api/v2/task/' . $this->task->id;
  396. $data = [
  397. 'progress' => $progress,
  398. ];
  399. Log::debug($this->queue . ' task progress request', ['url' => $url, 'data' => $data]);
  400. $response = Http::timeout($this->apiTimeout)->withToken($this->modelToken)->patch($url, $data);
  401. if ($response->failed()) {
  402. Log::error($this->queue . ' task progress error', ['data' => $response->json()]);
  403. } else {
  404. Log::info($this->queue . ' task progress successful progress=' . $response->json()['data']['progress']);
  405. }
  406. return $progress;
  407. }
  408. public function handleFailedTranslate(string $messageId, array $translateData, \Exception $exception): void
  409. {
  410. try {
  411. // 彻底失败时的业务逻辑
  412. // 设置task为失败状态
  413. $this->setTaskStatus($this->task->id, 'stop');
  414. //将故障信息写入task discussion
  415. if ($this->taskTopicId) {
  416. $dId = $this->taskDiscussion(
  417. $this->task->id,
  418. 'task',
  419. $this->task->title,
  420. "**处理失败ai任务时出错** 请重启任务 message id={$messageId} 错误信息:" . $exception->getMessage(),
  421. $this->taskTopicId
  422. );
  423. }
  424. } catch (\Exception $e) {
  425. Log::error('处理失败ai任务时出错', ['error' => $e->getMessage()]);
  426. }
  427. }
  428. /**
  429. * 读取task信息,将任务拆解为单句小任务
  430. *
  431. * @param string $taskId 任务uuid
  432. * @return array 拆解后的提示词数组
  433. */
  434. public function makeByTask(string $taskId, $aiAssistantId, bool $send = true)
  435. {
  436. $task = Task::findOrFail($taskId);
  437. $description = $task->description;
  438. $rows = explode("\n", $description);
  439. $params = [];
  440. foreach ($rows as $key => $row) {
  441. if (strpos($row, '=') !== false) {
  442. $param = explode('=', trim($row, '|'));
  443. $params[$param[0]] = $param[1];
  444. }
  445. }
  446. if (!isset($params['type'])) {
  447. Log::error('no $params.type');
  448. return false;
  449. }
  450. //get sentences in article
  451. $sentences = array();
  452. $totalLen = 0;
  453. switch ($params['type']) {
  454. case 'sentence':
  455. if (!isset($params['id'])) {
  456. Log::error('no $params.id');
  457. return false;
  458. }
  459. $sentences[] = explode('-', $params['id']);
  460. break;
  461. case 'para':
  462. if (!isset($params['book']) || !isset($params['paragraphs'])) {
  463. Log::error('no $params.book or paragraphs');
  464. return false;
  465. }
  466. $sent = PaliSentence::where('book', $params['book'])
  467. ->where('paragraph', $params['paragraphs'])->orderBy('word_begin')->get();
  468. foreach ($sent as $key => $value) {
  469. $sentences[] = [
  470. 'id' => [
  471. $value->book,
  472. $value->paragraph,
  473. $value->word_begin,
  474. $value->word_end,
  475. ],
  476. 'strlen' => $value->length
  477. ];
  478. $totalLen += $value->length;
  479. }
  480. break;
  481. case 'chapter':
  482. if (!isset($params['book']) || !isset($params['paragraphs'])) {
  483. Log::error('no $params.book or paragraphs');
  484. return false;
  485. }
  486. $chapterLen = PaliText::where('book', $params['book'])
  487. ->where('paragraph', $params['paragraphs'])->value('chapter_len');
  488. $sent = PaliSentence::where('book', $params['book'])
  489. ->whereBetween('paragraph', [$params['paragraphs'], $params['paragraphs'] + $chapterLen - 1])
  490. ->orderBy('paragraph')
  491. ->orderBy('word_begin')->get();
  492. foreach ($sent as $key => $value) {
  493. $sentences[] = [
  494. 'id' => [
  495. $value->book,
  496. $value->paragraph,
  497. $value->word_begin,
  498. $value->word_end,
  499. ],
  500. 'strlen' => $value->length
  501. ];
  502. $totalLen += $value->length;
  503. }
  504. break;
  505. default:
  506. return false;
  507. break;
  508. }
  509. //render prompt
  510. $mdRender = new MdRender([
  511. 'format' => 'prompt',
  512. 'footnote' => false,
  513. 'paragraph' => false,
  514. ]);
  515. $m = new \Mustache_Engine(array(
  516. 'entity_flags' => ENT_QUOTES,
  517. 'escape' => function ($value) {
  518. return $value;
  519. }
  520. ));
  521. # ai model
  522. $aiModel = AiModel::findOrFail($aiAssistantId);
  523. $modelToken = AuthController::getUserToken($aiModel->uid);
  524. $aiModel['token'] = $modelToken;
  525. $sumLen = 0;
  526. $mqData = [];
  527. foreach ($sentences as $key => $sentence) {
  528. $sumLen += $sentence['strlen'];
  529. $sid = implode('-', $sentence['id']);
  530. Log::debug($sid);
  531. $sentChannelInfo = explode('@', $params['channel']);
  532. $channelId = $sentChannelInfo[0];
  533. $data = [];
  534. $data['origin'] = '{{' . $sid . '}}';
  535. $data['translation'] = '{{sent|id=' . $sid;
  536. $data['translation'] .= '|channel=' . $channelId;
  537. $data['translation'] .= '|text=translation}}';
  538. if (isset($params['nissaya']) && !empty($params['nissaya'])) {
  539. $nissayaChannel = explode('@', $params['nissaya']);
  540. $channelInfo = ChannelApi::getById($nissayaChannel[0]);
  541. if ($channelInfo) {
  542. //查看句子是否存在
  543. $nissayaSent = Sentence::where('book_id', $sentence['id'][0])
  544. ->where('paragraph', $sentence['id'][1])
  545. ->where('word_start', $sentence['id'][2])
  546. ->where('word_end', $sentence['id'][3])
  547. ->where('channel_uid', $nissayaChannel[0])->first();
  548. if ($nissayaSent && !empty($nissayaSent->content)) {
  549. $nissayaData = [];
  550. $nissayaData['channel'] = $channelInfo;
  551. $nissayaData['data'] = '{{sent|id=' . $sid;
  552. $nissayaData['data'] .= '|channel=' . $nissayaChannel[0];
  553. $nissayaData['data'] .= '|text=translation}}';
  554. $data['nissaya'] = $nissayaData;
  555. }
  556. }
  557. }
  558. $content = $m->render($description, $data);
  559. $prompt = $mdRender->convert($content, []);
  560. //gen mq
  561. $aiMqData = [
  562. 'model' => $aiModel,
  563. 'task' => [
  564. 'info' => $task,
  565. 'progress' => [
  566. 'current' => $sumLen,
  567. 'total' => $totalLen
  568. ],
  569. ],
  570. 'prompt' => $prompt,
  571. 'sentence' => [
  572. 'book_id' => $sentence['id'][0],
  573. 'paragraph' => $sentence['id'][1],
  574. 'word_start' => $sentence['id'][2],
  575. 'word_end' => $sentence['id'][3],
  576. 'channel_uid' => $channelId,
  577. 'content' => $prompt,
  578. 'content_type' => 'markdown',
  579. 'access_token' => $sentChannelInfo[1] ?? $params['token'],
  580. ],
  581. ];
  582. array_push($mqData, $aiMqData);
  583. }
  584. if ($send) {
  585. $mq = app(RabbitMQService::class);
  586. $mq->publishMessage('ai_translate', $mqData);
  587. }
  588. return $mqData;
  589. }
  590. public function stop()
  591. {
  592. $this->stop = true;
  593. }
  594. }