visuddhinanda 1 неделя назад
Родитель
Сommit
aea209aba9

+ 40 - 0
api-v12/app/Console/Commands/TestAIArticleTranslate.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Services\AIAssistant\ArticleTranslateService;
+
+class TestAIArticleTranslate extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan test:ai.article.translate
+     * @var string
+     */
+    protected $signature = 'test:ai.article.translate';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        //
+        // ===== 创建 Service =====
+        $service = app(ArticleTranslateService::class);
+        // ===== 执行 =====
+        $result = $service->setModel('dd81ce6c-e9ff-46b2-b1af-947728ba996e')
+            ->translate('2deaf8dd-65b3-4a76-86c4-deec7afabc38')
+            ->get();
+
+        // ===== 调试输出(建议保留)=====
+        dump($result);
+    }
+}

+ 38 - 0
api-v12/app/DTO/BaseDTO.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\DTO;
+
+abstract readonly class BaseDTO implements \JsonSerializable
+{
+    public function toArray(): array
+    {
+        $result = [];
+
+        foreach (get_object_vars($this) as $key => $value) {
+            $result[$key] = $this->normalizeValue($value);
+        }
+
+        return $result;
+    }
+
+    protected function normalizeValue(mixed $value): mixed
+    {
+        if ($value instanceof self) {
+            return $value->toArray();
+        }
+
+        if (is_array($value)) {
+            return array_map(
+                fn($item) => $this->normalizeValue($item),
+                $value
+            );
+        }
+
+        return $value;
+    }
+
+    public function jsonSerialize(): array
+    {
+        return $this->toArray();
+    }
+}

+ 21 - 0
api-v12/app/DTO/LLMTranslation/TranslationItemDTO.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\DTO\LLMTranslation;
+
+use App\DTO\BaseDTO;
+
+readonly class TranslationItemDTO extends BaseDTO
+{
+    public function __construct(
+        public string $id,
+        public string $content,
+    ) {}
+
+    public static function fromArray(array $data): self
+    {
+        return new self(
+            id: $data['id'],
+            content: $data['content'],
+        );
+    }
+}

+ 23 - 0
api-v12/app/DTO/LLMTranslation/TranslationMetaDTO.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\DTO\LLMTranslation;
+
+use App\DTO\BaseDTO;
+
+readonly class TranslationMetaDTO  extends BaseDTO
+{
+    public function __construct(
+        public int $duration,
+        public int $itemsCount,
+        public TranslationUsageDTO $usage,
+    ) {}
+
+    public static function fromArray(array $data): self
+    {
+        return new self(
+            duration: $data['duration'],
+            itemsCount: $data['items_count'],
+            usage: TranslationUsageDTO::fromArray($data['usage']),
+        );
+    }
+}

+ 19 - 0
api-v12/app/DTO/LLMTranslation/TranslationPromptTokenDetailsDTO.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\DTO\LLMTranslation;
+
+use App\DTO\BaseDTO;
+
+readonly class TranslationPromptTokenDetailsDTO  extends BaseDTO
+{
+    public function __construct(
+        public int $cachedTokens,
+    ) {}
+
+    public static function fromArray(array $data): self
+    {
+        return new self(
+            cachedTokens: $data['cached_tokens'],
+        );
+    }
+}

+ 41 - 0
api-v12/app/DTO/LLMTranslation/TranslationResponseDTO.php

@@ -0,0 +1,41 @@
+<?php
+
+/**
+ * 使用方式
+ * use App\DTO\LLMTranslation\TranslationResponseDTO;
+
+$dto = TranslationResponseDTO::fromArray($response);
+
+dd($dto->data[0]->content);
+ */
+
+namespace App\DTO\LLMTranslation;
+
+use App\DTO\BaseDTO;
+
+readonly class TranslationResponseDTO extends BaseDTO
+{
+    /**
+     * @param TranslationItemDTO[] $data
+     */
+    public function __construct(
+        public bool $success,
+        public string $error,
+        public array $data,
+        public TranslationMetaDTO $meta,
+    ) {}
+
+    public static function fromArray(array $payload): self
+    {
+        return new self(
+            success: $payload['success'],
+            error: '',
+            data: array_map(
+                fn(array $item) => TranslationItemDTO::fromArray($item),
+                $payload['data']
+            ),
+
+            meta: TranslationMetaDTO::fromArray($payload['meta']),
+        );
+    }
+}

+ 27 - 0
api-v12/app/DTO/LLMTranslation/TranslationUsageDTO.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\DTO\LLMTranslation;
+
+use App\DTO\BaseDTO;
+
+readonly class TranslationUsageDTO extends BaseDTO
+{
+    public function __construct(
+        public int $promptTokens,
+        public int $completionTokens,
+        public int $totalTokens,
+        public TranslationPromptTokenDetailsDTO $promptTokensDetails,
+    ) {}
+
+    public static function fromArray(array $data): self
+    {
+        return new self(
+            promptTokens: $data['prompt_tokens'],
+            completionTokens: $data['completion_tokens'],
+            totalTokens: $data['total_tokens'],
+            promptTokensDetails: TranslationPromptTokenDetailsDTO::fromArray(
+                $data['prompt_tokens_details']
+            ),
+        );
+    }
+}

+ 48 - 0
api-v12/app/DTO/Search/HitItemDTO.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace App\DTO\Search;
+
+class HitItemDTO
+{
+    public function __construct(
+        public string $id,
+        public float $score,
+        public string $content,
+        public string $title,
+        public string $path,
+        public array $category,
+    ) {}
+
+    public static function fromArray(array $data): self
+    {
+        $source = $data['_source'];
+
+        return new self(
+            id: $source['id'],
+            score: $data['_score'],
+            title: $source['title']['pali'] ?? '',
+            content: $source['content']['pali'] ?? '',
+            path: $source['path'] ?? '',
+            category: $source['category'] ?? [],
+        );
+    }
+
+    /**
+     * 提取 para 引用ID(核心逻辑🔥)
+     */
+    public function getParaId(): ?string
+    {
+        if (preg_match('/pali_para_(\d+)_(\d+)/', $this->id, $matches)) {
+            return "{$matches[1]}-{$matches[2]}";
+        }
+        return null;
+    }
+
+    public function getParaLink(): ?string
+    {
+        $id = $this->getParaId();
+        if (!$id) return null;
+
+        return "{{para|id={$id}|title={$id}|style=reference}}";
+    }
+}

+ 25 - 0
api-v12/app/DTO/Search/HitsDTO.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\DTO\Search;
+
+class HitsDTO
+{
+    /**
+     * @param HitItemDTO[] $items
+     */
+    public function __construct(
+        public int $total,
+        public array $items,
+    ) {}
+
+    public static function fromArray(array $data): self
+    {
+        return new self(
+            total: $data['total']['value'],
+            items: array_map(
+                fn($item) => HitItemDTO::fromArray($item),
+                $data['hits']
+            )
+        );
+    }
+}

+ 19 - 0
api-v12/app/DTO/Search/SearchDataDTO.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\DTO\Search;
+
+class SearchDataDTO
+{
+    public function __construct(
+        public int $took,
+        public HitsDTO $hits,
+    ) {}
+
+    public static function fromArray(array $data): self
+    {
+        return new self(
+            took: $data['took'],
+            hits: HitsDTO::fromArray($data['hits'])
+        );
+    }
+}

+ 19 - 0
api-v12/app/DTO/Search/SearchResultDTO.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\DTO\Search;
+
+class SearchResultDTO
+{
+    public function __construct(
+        public bool $success,
+        public SearchDataDTO $data,
+    ) {}
+
+    public static function fromArray(array $data): self
+    {
+        return new self(
+            success: $data['success'],
+            data: SearchDataDTO::fromArray($data['data'])
+        );
+    }
+}

+ 104 - 0
api-v12/app/Services/AIAssistant/ArticleTranslateService.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace App\Services\AIAssistant;
+
+use App\Services\ArticleService;
+use App\Services\PaliContentService;
+use App\Models\CustomBook;
+use Illuminate\Support\Facades\Log;
+
+class ArticleTranslateService
+{
+    protected ArticleService $articleService;
+    protected PaliContentService $paliContentService;
+    protected TranslateService $translateService;
+    protected string $modelId;
+    protected array $translation;
+
+    protected string $systemPrompt = <<<PROMPT
+    请根据提供的原文,翻译为简体中文。
+
+    原文为逐句数据,翻译时请依照句子的上下文翻译。
+    id:句子编号
+    content:内容
+
+    # 翻译要求:
+    1. 缅文巴利要给出罗马巴利转写
+    2. 使用现代汉语
+    3. 逐句翻译
+
+
+
+    # 输出格式要求:
+    - jsonl 格式
+    - 每条记录是一个句子
+    - 每个句子只输出两个字段
+      1. id(句子编号)
+      2. content(译文)
+    - 无需输出原文
+    - 只输出jsonl格式的译文 无需出处额外的解释
+    PROMPT;
+
+    public function __construct(
+        ArticleService $article,
+        PaliContentService $paliContent,
+        TranslateService $translateService,
+    ) {
+        $this->articleService = $article;
+        $this->paliContentService = $paliContent;
+        $this->translateService = $translateService;
+    }
+
+    /**
+     * 设置模型配置
+     *
+     * @param string $model
+     * @return self
+     */
+    public function setModel(string $model): self
+    {
+        $this->modelId = $model;
+        return $this;
+    }
+
+    public function translate(string $articleId)
+    {
+        //获取文章中的句子id
+        $sentenceIds = $this->articleService->sentenceIds($articleId);
+        if (!$sentenceIds || count($sentenceIds) === 0) {
+            return null;
+        }
+        $bookId = (int)explode('-', $sentenceIds[0])[0];
+        //提取原文
+        $originalChannelId = CustomBook::where('book_id', $bookId)->value('channel_id');
+
+        $original = $this->paliContentService->sentences($sentenceIds, [$originalChannelId], 'read');
+        $orgData = [];
+        foreach ($original as $key => $paragraph) {
+            foreach ($paragraph['children'] as $key => $sent) {
+                $org = $sent['origin'][0];
+                $orgData[] = [
+                    'id' => "{$org['book']}-{$org['para']}-{$org['wordStart']}-{$org['wordEnd']}",
+                    'content' => !empty($org['content']) ? $org['content'] : $org['html'],
+                ];
+            }
+        }
+        //翻译
+        $result = $this->translateService->setModel($this->modelId)
+            ->setSystemPrompt($this->systemPrompt)
+            ->setTranslatePrompt("# 原文\n\n" .
+                "```json\n" .
+                json_encode($orgData, JSON_UNESCAPED_UNICODE) .
+                "\n```")
+            ->translate();
+        Log::debug('ai translation', ['data' => $result->toArray()['data']]);
+        $this->translation = $result->toArray()['data'];
+        return $this;
+    }
+    //写入结果channel
+    public function save(string $channelId) {}
+    public function get()
+    {
+        return $this->translation;
+    }
+}

+ 265 - 0
api-v12/app/Services/AIAssistant/TranslateService.php

@@ -0,0 +1,265 @@
+<?php
+
+namespace App\Services\AIAssistant;
+
+use App\Services\NissayaParser;
+use App\Services\OpenAIService;
+use App\Services\RomanizeService;
+use App\Services\AIModelService;
+use App\Http\Controllers\AuthController;
+
+use Illuminate\Support\Facades\Log;
+use App\Http\Resources\AiModelResource;
+
+use App\DTO\LLMTranslation\TranslationResponseDTO;
+
+class TranslateService
+{
+    protected OpenAIService $openAIService;
+    protected NissayaParser $nissayaParser;
+    protected RomanizeService $romanizeService;
+    protected AIModelService $aiModelService;
+    protected AiModelResource $model;
+    protected string $modelToken;
+
+    protected bool $stream = false;
+    protected array $original; //需要被翻译的原文
+
+    protected string $systemPrompt = '';
+    /**
+     * 翻译提示词模板
+     */
+    protected string $translatePrompt = '';
+
+    public function __construct(
+        OpenAIService $openAIService,
+        AIModelService $aiModelService,
+    ) {
+        $this->openAIService = $openAIService;
+        $this->aiModelService = $aiModelService;
+    }
+
+    /**
+     * 设置模型配置
+     *
+     * @param string $model
+     * @return self
+     */
+    public function setModel(string $model): self
+    {
+        $this->model = $this->aiModelService->getModelById($model);
+        $this->modelToken = AuthController::getUserToken($model);
+        return $this;
+    }
+    /**
+     * 设置翻译提示词
+     *
+     * @param string $prompt
+     * @return self
+     */
+    public function setSystemPrompt(string $prompt): self
+    {
+        $this->systemPrompt = $prompt;
+        return $this;
+    }
+    /**
+     * 设置翻译提示词
+     *
+     * @param string $prompt
+     * @return self
+     */
+    public function setTranslatePrompt(string $prompt): self
+    {
+        $this->translatePrompt = $prompt;
+        return $this;
+    }
+
+
+    /**
+     * 翻译缅文版逐词解析
+     *
+     * @param string $text 格式: 巴利文=缅文
+     * @param bool $stream 是否流式输出
+     * @return TranslationResponseDTO
+     * @throws \Exception
+     */
+    public function translate(): TranslationResponseDTO
+    {
+        $startAt = time();
+
+        try {
+
+
+            Log::info('准备翻译', [
+                'systemPrompt' => $this->systemPrompt,
+                'translatePrompt' => $this->translatePrompt,
+            ]);
+
+            // 3. 调用LLM进行翻译
+            $response = $this->openAIService
+                ->setApiUrl($this->model['url'])
+                ->setModel($this->model['model'])
+                ->setApiKey($this->model['key'])
+                ->setSystemPrompt($this->systemPrompt)
+                ->setTemperature(0.3)
+                ->setStream($this->stream)
+                ->send($this->translatePrompt);
+
+            $complete = time() - $startAt;
+            $content = $response['choices'][0]['message']['content'] ?? '';
+
+            if (empty($content)) {
+                throw new \Exception('LLM返回内容为空');
+            }
+
+            // 4. 解析JSONL格式的翻译结果
+            $translatedData = $this->jsonlToArray($content);
+
+            Log::info('NissayaTranslate: 翻译完成', [
+                'duration' => $complete,
+                'output_items' => count($translatedData),
+                'input_tokens' => $response['usage']['prompt_tokens'] ?? 0,
+                'output_tokens' => $response['usage']['completion_tokens'] ?? 0,
+            ]);
+
+            return TranslationResponseDTO::fromArray([
+                'success' => true,
+                'data' => $translatedData,
+                'meta' => [
+                    'duration' => $complete,
+                    'items_count' => count($translatedData),
+                    'usage' => $response['usage'] ?? [],
+                ],
+            ]);
+        } catch (\Exception $e) {
+            Log::error('NissayaTranslate: 翻译失败', [
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+            ]);
+
+            return TranslationResponseDTO::fromArray([
+                'success' => false,
+                'error' => $e->getMessage(),
+                'data' => [],
+            ]);
+        }
+    }
+
+
+    /**
+     * 将数组转换为JSONL格式
+     *
+     * @param array $data
+     * @return string
+     */
+    protected function arrayToJsonl(array $data): string
+    {
+        $lines = [];
+        foreach ($data as $item) {
+            $lines[] = json_encode($item, JSON_UNESCAPED_UNICODE);
+        }
+        return implode("\n", $lines);
+    }
+
+    /**
+     * 将JSONL格式转换为数组
+     *
+     * @param string $jsonl
+     * @return array
+     */
+    protected function jsonlToArray(string $jsonl): array
+    {
+        // 清理可能的markdown代码块标记
+        $jsonl = preg_replace('/```json\s*|\s*```/', '', $jsonl);
+        $jsonl = trim($jsonl);
+
+        $lines = explode("\n", $jsonl);
+        $result = [];
+
+        foreach ($lines as $line) {
+            $line = trim($line);
+            if (empty($line)) {
+                continue;
+            }
+
+            $decoded = json_decode($line, true);
+            if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
+                $result[] = $decoded;
+            } else {
+                Log::warning('NissayaTranslate: 无法解析JSON行', [
+                    'line' => $line,
+                    'error' => json_last_error_msg(),
+                ]);
+            }
+        }
+
+        return $result;
+    }
+
+    /**
+     * 批量翻译(将大文本分批处理)
+     *
+     * @param string $text
+     * @param int $batchSize 每批处理的条目数
+     * @return array
+     */
+    public function translateInBatches(string $text, int $batchSize = 50): array
+    {
+        try {
+            $parsedData = $this->nissayaParser->parse($text);
+            $batches = array_chunk($parsedData, $batchSize);
+            $allResults = [];
+            $totalDuration = 0;
+            $totalUsage = [
+                'prompt_tokens' => 0,
+                'completion_tokens' => 0,
+                'total_tokens' => 0,
+            ];
+
+            foreach ($batches as $index => $batch) {
+                Log::info("NissayaTranslate: 处理批次 " . ($index + 1) . "/" . count($batches));
+
+                $jsonlInput = $this->arrayToJsonl($batch);
+                $response = $this->openAIService
+                    ->setApiUrl($this->model['url'])
+                    ->setModel($this->model['model'])
+                    ->setApiKey($this->model['key'])
+                    ->setSystemPrompt($this->translatePrompt)
+                    ->setTemperature(0.7)
+                    ->setStream(false)
+                    ->send($jsonlInput);
+
+                $content = $response['choices'][0]['message']['content'] ?? '';
+                $translatedBatch = $this->jsonlToArray($content);
+                $allResults = array_merge($allResults, $translatedBatch);
+
+                // 累计使用统计
+                if (isset($response['usage'])) {
+                    $totalUsage['prompt_tokens'] += $response['usage']['prompt_tokens'] ?? 0;
+                    $totalUsage['completion_tokens'] += $response['usage']['completion_tokens'] ?? 0;
+                    $totalUsage['total_tokens'] += $response['usage']['total_tokens'] ?? 0;
+                }
+            }
+
+            return [
+                'success' => true,
+                'data' => $allResults,
+                'meta' => [
+                    'batches' => count($batches),
+                    'items_count' => count($allResults),
+                    'usage' => $totalUsage,
+                ],
+            ];
+        } catch (\Exception $e) {
+            Log::error('NissayaTranslate: 批量翻译失败', [
+                'error' => $e->getMessage(),
+            ]);
+
+            return [
+                'success' => false,
+                'error' => $e->getMessage(),
+                'data' => [],
+            ];
+        }
+    }
+}