From: fomichev Date: Mon, 4 May 2026 15:22:25 +0000 (+0300) Subject: llm verification X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=7f46bebe53c223167d69a98e2bc1adaf49084be6;p=erp24_rep%2Fyii-erp24%2F.git llm verification --- diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ff03196a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,381 @@ +# ERP24 AI Guidelines + +**Версия:** 2.0.0 +**Приоритет:** Этот файл > ~/.Codex/AGENTS.md + +--- + +## 1. Роль и цель + +Ты — опытный архитектор, technical writer и code analyst для проекта ERP24 (Yii2). + +**Главная цель:** Создать полную, структурированную, поддерживаемую документацию ERP24. + +**Ключевые принципы:** + +- Документация на русском языке +- Технические термины на английском +- Markdown + Mermaid диаграммы +- Ссылки между документами +- Соответствие коду в репозитории + +--- + +## 2. Scope проекта + +### Компоненты ERP24 + +| Компонент | Количество | +|-----------|------------| +| API уровни | 3 (api1, api2, api3) | +| Модели ActiveRecord | 390+ | +| Контроллеры | 160+ | +| Сервисы | 51 | +| Actions | 40+ | +| Helpers | 15+ | +| Формы | 20+ | +| Миграции | 278 | + +### Дополнительно + +- Очереди и джобы +- RBAC и права доступа +- Конфигурация приложения +- Обработка ошибок + +--- + +## 3. Output Requirements + +### Точность + +| Метрика | Целевое значение | +|---------|------------------| +| Публичные классы документированы | 100% | +| Публичные методы с примерами | 90% | +| API эндпоинты с запрос/ответ | 100% | +| Таблицы БД со связями | 100% | + +### Стиль документации + +- Кратко, но информативно +- Без воды и общих фраз +- Только проверенные данные из кода +- Единый шаблон для каждого типа компонента +- Развёрнутое описание логики методов +- Все параметры и возвращаемые значения +- Списки вызываемых методов + +--- + +## 4. Yii2 специфика + +### Структура проекта + +``` +erp24/ +├── actions/ # Standalone actions +├── api1/ # API v1 (legacy) +├── api2/ # API v2 (текущий) +├── api3/ # API v3 (новый) +├── commands/ # Console команды +├── controllers/ # Web контроллеры +├── forms/ # Form модели +├── helpers/ # Хелперы +├── jobs/ # Queue джобы +├── models/ # ActiveRecord модели +├── modules/ # Yii2 модули +├── records/ # Search модели +├── services/ # Сервисный слой +├── views/ # Представления +└── migrations/ # Миграции БД +``` + +### Соглашения Yii2 + +- **Namespace:** `app\{layer}` (app\models, app\services, etc.) +- **Модели:** наследуют `yii\db\ActiveRecord` +- **Контроллеры:** наследуют `yii\web\Controller` или `yii\rest\Controller` +- **Сервисы:** не наследуют, инжектятся через конструктор +- **Поведения:** `behaviors()` метод для timestamps, blameable, etc. + +### Стандарты кода + +- PHP 8.1+ +- Yii2 2.0.45+ +- PSR-12 Extended Coding Style +- Строгая типизация (`declare(strict_types=1)`) + +Подробные гайдлайны: [erp24/php_skills/](erp24/php_skills/) + +--- + +## 5. Структура документации + +``` +erp24/docs/ +├── architecture/ # Архитектура системы +├── api/ +│ ├── api1/ # API v1 документация +│ ├── api2/ # API v2 документация +│ └── api3/ # API v3 документация +├── modules/ # Модули системы +├── database/ # Схема БД +├── services/ # Сервисный слой +├── models/ # Модели данных +├── controllers/ # Контроллеры +├── guides/ # Руководства +├── errors/ # Коды ошибок +└── ai/ # AI-слой (см. секцию 8) +``` + +### Правила работы с документацией + +1. **Не переписывать** существующие документы — дополнять +2. **Перед генерацией** — проверить erp24/docs/* +3. **При несоответствиях** — предложить объединение +4. **Устаревшие документы** — отмечать для обновления + +--- + +## 6. Шаблоны документации + +### Шаблон: Класс/Модель + +```markdown +# Class: {{ClassName}} + +## Назначение +Краткое описание роли класса в системе. + +## Namespace +`app\models\{{ClassName}}` + +## Таблица БД +`{{table_name}}` + +## Родительский класс +`yii\db\ActiveRecord` + +## Свойства + +| Поле | Тип | Описание | +|------|-----|----------| +| id | int | Первичный ключ | +| name | string | Название | +| created_at | datetime | Дата создания | + +## Связи (Relations) + +| Метод | Тип | Связанная модель | +|-------|-----|------------------| +| getUser() | hasOne | User | +| getItems() | hasMany | Item | + +## Методы + +### findByStatus(int $status): array +**Описание:** Поиск записей по статусу +**Параметры:** +- `$status` (int) — Код статуса (1=active, 0=inactive) + +**Возвращает:** array — Массив моделей + +**Пример:** +\`\`\`php +$activeItems = Item::findByStatus(1); +\`\`\` + +**Вызывает:** +- `self::find()` — Создание ActiveQuery +- `andWhere()` — Добавление условия + +## Диаграмма + +\`\`\`mermaid +classDiagram + class {{ClassName}} { + +int id + +string name + +findByStatus(int status) + } +\`\`\` +``` + +### Шаблон: API Endpoint + +```markdown +# {{METHOD}} {{URL}} + +## Назначение +Описание назначения эндпоинта. + +## Авторизация +Bearer Token / API Key / Public + +## Запрос + +### Headers +| Header | Значение | +|--------|----------| +| Authorization | Bearer {token} | +| Content-Type | application/json | + +### Body +\`\`\`json +{ + "field": "value" +} +\`\`\` + +### Параметры + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| field | string | Да | Описание поля | + +## Ответ + +### 200 OK +\`\`\`json +{ + "success": true, + "data": {} +} +\`\`\` + +## Ошибки + +| Код | Описание | +|-----|----------| +| 400 | Неверный запрос | +| 401 | Не авторизован | +| 404 | Не найдено | +| 500 | Внутренняя ошибка | +``` + +### Шаблон: Сервис + +```markdown +# Service: {{ServiceName}} + +## Назначение +Описание бизнес-логики сервиса. + +## Namespace +`app\services\{{ServiceName}}` + +## Зависимости +- `UserRepository` — Работа с пользователями +- `NotificationService` — Отправка уведомлений + +## Публичные методы + +### process(array $data): Result +**Описание:** Основной метод обработки +**Параметры:** ... +**Возвращает:** ... +**Исключения:** ... + +## Диаграмма взаимодействия + +\`\`\`mermaid +sequenceDiagram + Controller->>Service: process(data) + Service->>Repository: find(id) + Repository-->>Service: Model + Service-->>Controller: Result +\`\`\` +``` + +--- + +## 7. Memory Bank + +Система постоянного контекста: `coordination/memory_bank/` + +### Ключевые файлы + +| Файл | Когда читать | +|------|--------------| +| `activeContext.md` | **Всегда первым** — текущие задачи | +| `codebaseContext.md` | При работе с кодом | +| `systemPatterns.md` | При архитектурных решениях | +| `progress.md` | Для отслеживания прогресса | + +### Правила + +1. **Начало сессии** → читать `activeContext.md` +2. **Завершение** → обновить точку остановки +3. **Архитектурное решение** → добавить ADR в `systemPatterns.md` + +--- + +## 8. AI-слой + +Расширенная конфигурация для AI-ассистентов: `erp24/docs/ai/` + +### Структура + +``` +erp24/docs/ai/ +├── README.md # Quick Start +├── repo-structure.md # Структура репозитория +├── protocols/ # Протоколы работы +├── adversarial-spec/ # Роли и персоны для review +├── templates/ # Шаблоны документов +└── prompts/ # Системные промпты +``` + +### Context Loading Protocol + +| Команда | Действие | +|---------|----------| +| `/init {TASK-ID}` | Инициализация задачи | +| `/review` | Запуск adversarial review | +| `/finalize` | Финализация с планом | + +Подробнее: [erp24/docs/ai/protocols/context-loading.md](erp24/docs/ai/protocols/context-loading.md) + +--- + +## 9. Workflow Rules + +1. **Уточняй контекст** — если данных недостаточно, спрашивай +2. **Все диаграммы** — только Mermaid +3. **Все API** — в OpenAPI-like Markdown +4. **Единый шаблон** — для каждого типа компонента +5. **Никаких пропусков** — если не найден класс/метод, сообщай +6. **Не дублируй** — проверяй существующие документы +7. **Указывай путь** — при генерации: `/docs/services/PaymentService.md` +8. **Синтаксис Yii2** — строго соответствуй PHP8 и Yii2 + +--- + +## 10. PHP Skills + +Гайдлайны по PHP и Yii2: `erp24/php_skills/` + +### Основы PHP + +| Файл | Тема | +|------|------| +| 01-php-basics.md | Форматирование, синтаксис | +| 02-php-naming.md | Именование | +| 03-php-methods.md | Методы и функции | +| 04-php-classes.md | Классы, интерфейсы | +| 08-php-exceptions.md | Исключения | + +### Yii2 специфика + +| Файл | Тема | +|------|------| +| 10-yii2-structure.md | Структура приложения | +| 11-yii2-models.md | ActiveRecord | +| 12-yii2-controllers.md | Контроллеры | +| 19-yii2-api.md | REST API | + +--- + +_Версия: 2.0.0_ +_Обновлено: 2026-01-27_ diff --git a/erp24/.env.example b/erp24/.env.example index f0535f66..971d75cc 100644 --- a/erp24/.env.example +++ b/erp24/.env.example @@ -253,14 +253,31 @@ MYSQL_BZ24_DB=bazacvetov24 SALEBOT_IMPORT_TOKEN= # === LLM (ERP-292 Automarkup Verification) === -# OpenAI-compatible endpoint (e.g. http://ollama:11434/v1, https://api.openai.com/v1) +# Backend selector: local (LM Studio/OpenAI-compatible) or remote (Coordinator /v1/llm-analyze) +LLM_BACKEND=local +# Batch size for LLM verification (default: 20) +LLM_BATCH_SIZE=20 +# HTTP timeout in seconds +LLM_TIMEOUT=120 +# Max completion tokens. Keep low because automark expects a tiny JSON answer. +LLM_MAX_TOKENS=192 +# Optional prompt suffix for model-specific behavior, e.g. /no_think for Qwen3 +LLM_PROMPT_SUFFIX= + +# Local backend (LM Studio / OpenAI-compatible) +LLM_LOCAL_ENDPOINT=http://127.0.0.1:1234/v1/chat/completions +LLM_LOCAL_API_KEY= +LLM_LOCAL_MODEL=llama3.2 + +# Remote backend (Coordinator /v1/llm-analyze) +LLM_REMOTE_ENDPOINT=http://127.0.0.1:8000/v1/llm-analyze +LLM_REMOTE_API_KEY= +LLM_REMOTE_STORE_ID= + +# Legacy fallback: if LLM_BACKEND is empty, code will use these variables as local OpenAI-compatible config LLM_ENDPOINT=http://localhost:11434/v1 -# API key (required for OpenAI/cloud; can be arbitrary string for local Ollama) LLM_API_KEY= -# Model name to use for verification LLM_MODEL=llama3.2 -# Batch size for LLM verification (default: 20) -LLM_BATCH_SIZE=20 # === SITE API === # URL for external site API (SiteService) @@ -272,4 +289,3 @@ MYSQL_TEST_HOST=127.0.0.1 MYSQL_TEST_USER= MYSQL_TEST_PASSWORD= MYSQL_TEST_DB=erp24_api_test - diff --git a/erp24/commands/AutoMarkController.php b/erp24/commands/AutoMarkController.php index 72a1b101..f4fd225b 100644 --- a/erp24/commands/AutoMarkController.php +++ b/erp24/commands/AutoMarkController.php @@ -86,6 +86,21 @@ class AutoMarkController extends Controller private function runLlmVerify(AutoMarkService $service): int { try { + $diagnostics = $service->getLlmVerificationDiagnostics(); + $backend = (string) ($diagnostics['backend'] ?? 'unknown'); + $endpoint = (string) ($diagnostics['endpoint'] ?? ''); + $model = (string) ($diagnostics['model'] ?? ''); + $batchSize = (int) ($diagnostics['batchSize'] ?? 0); + + $this->stdout("LLM backend: {$backend}\n"); + $this->stdout("LLM endpoint: {$endpoint}\n"); + $this->stdout("LLM model: {$model}\n"); + $this->stdout("LLM batch size: {$batchSize}\n"); + + if (($diagnostics['legacy'] ?? false) === true) { + $this->stdout("LLM config warning: используется legacy-конфиг LLM_ENDPOINT/LLM_API_KEY/LLM_MODEL\n"); + } + $verified = $service->llmBatchVerify(); $this->stdout("LLM верифицировано: {$verified}\n"); return ExitCode::OK; diff --git a/erp24/services/AutoMarkService.php b/erp24/services/AutoMarkService.php index b7b8c05b..bceec595 100644 --- a/erp24/services/AutoMarkService.php +++ b/erp24/services/AutoMarkService.php @@ -246,4 +246,17 @@ class AutoMarkService ->andWhere(['llm_verdict' => null]) ->count(); } + + /** + * @return array + */ + public function getLlmVerificationDiagnostics(): array + { + $batchSize = (int)(getenv('LLM_BATCH_SIZE') ?: 20); + $verifier = new LlmVerifier($batchSize); + $diagnostics = $verifier->getClientDiagnostics(); + $diagnostics['batchSize'] = $batchSize; + + return $diagnostics; + } } diff --git a/erp24/services/automark/CoordinatorLlmTransport.php b/erp24/services/automark/CoordinatorLlmTransport.php new file mode 100644 index 00000000..250c4fa8 --- /dev/null +++ b/erp24/services/automark/CoordinatorLlmTransport.php @@ -0,0 +1,88 @@ + $config + */ + public function __construct( + private readonly array $config, + ?Client $http = null + ) { + $this->http = $http ?? new Client(['timeout' => (int) $this->config['timeout']]); + } + + public function chat(string $systemPrompt, string $userMessage): array + { + $payload = [ + 'system_prompt' => $systemPrompt, + 'prompt' => $userMessage, + 'structured_output' => true, + ]; + + if ($this->config['storeId'] !== null && $this->config['storeId'] !== '') { + $payload['store_id'] = (string) $this->config['storeId']; + } + + try { + $response = $this->http->post((string) $this->config['endpoint'], [ + 'headers' => [ + 'X-API-Key' => (string) $this->config['apiKey'], + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + } catch (GuzzleException $e) { + throw new \RuntimeException('LLM HTTP ошибка: ' . $e->getMessage(), 0, $e); + } + + return self::parseAnalyzeResponse((string) $response->getBody()); + } + + public function getDiagnostics(): array + { + return [ + 'backend' => (string) $this->config['backend'], + 'endpoint' => (string) $this->config['endpoint'], + 'model' => 'remote coordinator', + 'legacy' => false, + ]; + } + + /** + * @return array> + */ + public static function parseAnalyzeResponse(string $body): array + { + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + throw new \RuntimeException('Coordinator вернул невалидный JSON'); + } + + if (($decoded['ok'] ?? true) === false) { + $message = $decoded['error'] ?? $decoded['detail']['title'] ?? 'Remote coordinator error'; + throw new \RuntimeException((string) $message); + } + + $responseJson = $decoded['response_json'] ?? null; + if (is_array($responseJson) && array_is_list($responseJson)) { + return $responseJson; + } + + $responseText = $decoded['response_text'] ?? null; + if (!is_string($responseText) || trim($responseText) === '') { + throw new \RuntimeException('Coordinator не вернул response_json или response_text'); + } + + return LlmClient::parseResponse($responseText); + } +} diff --git a/erp24/services/automark/LlmClient.php b/erp24/services/automark/LlmClient.php index 1c5a6f28..d89146df 100644 --- a/erp24/services/automark/LlmClient.php +++ b/erp24/services/automark/LlmClient.php @@ -4,22 +4,21 @@ declare(strict_types=1); namespace yii_app\services\automark; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\GuzzleException; - class LlmClient { - private Client $http; - private string $endpoint; - private string $apiKey; - private string $model; + public const BACKEND_LOCAL = 'local'; + public const BACKEND_REMOTE = 'remote'; + + private LlmTransportInterface $transport; public function __construct() { - $this->endpoint = (string)(getenv('LLM_ENDPOINT') ?: ''); - $this->apiKey = (string)(getenv('LLM_API_KEY') ?: ''); - $this->model = (string)(getenv('LLM_MODEL') ?: 'llama3.2'); - $this->http = new Client(['timeout' => 120]); + $config = self::resolveConfig(self::readEnv()); + + $this->transport = match ($config['backend']) { + self::BACKEND_REMOTE => new CoordinatorLlmTransport($config), + default => new OpenAiCompatibleLlmTransport($config), + }; } /** @@ -29,35 +28,15 @@ class LlmClient */ public function chat(string $systemPrompt, string $userMessage): array { - if ($this->endpoint === '') { - throw new \RuntimeException('LLM_ENDPOINT не задан в env'); - } - - try { - $response = $this->http->post($this->endpoint, [ - 'headers' => [ - 'Authorization' => 'Bearer ' . $this->apiKey, - 'Content-Type' => 'application/json', - ], - 'json' => [ - 'model' => $this->model, - 'temperature' => 0.1, - 'max_tokens' => 2000, - 'messages' => [ - ['role' => 'system', 'content' => $systemPrompt], - ['role' => 'user', 'content' => $userMessage], - ], - ], - ]); - } catch (GuzzleException $e) { - throw new \RuntimeException('LLM HTTP ошибка: ' . $e->getMessage(), 0, $e); - } - - $body = (string) $response->getBody(); - $decoded = json_decode($body, true); - $content = $decoded['choices'][0]['message']['content'] ?? ''; + return $this->transport->chat($systemPrompt, $userMessage); + } - return self::parseResponse($content); + /** + * @return array + */ + public function getDiagnostics(): array + { + return $this->transport->getDiagnostics(); } /** @@ -80,4 +59,122 @@ class LlmClient throw new \RuntimeException('LLM вернул невалидный JSON: ' . mb_substr($raw, 0, 200)); } + + /** + * @param array $env + * @return array + */ + public static function resolveConfig(array $env): array + { + $backend = trim($env['LLM_BACKEND'] ?? ''); + if ($backend !== '' && $backend !== self::BACKEND_LOCAL && $backend !== self::BACKEND_REMOTE) { + throw new \RuntimeException('LLM_BACKEND должен быть local или remote'); + } + + if ($backend === self::BACKEND_REMOTE) { + $endpoint = trim($env['LLM_REMOTE_ENDPOINT'] ?? ''); + if ($endpoint === '') { + throw new \RuntimeException('LLM_REMOTE_ENDPOINT не задан в env'); + } + + return [ + 'backend' => self::BACKEND_REMOTE, + 'endpoint' => $endpoint, + 'apiKey' => (string) ($env['LLM_REMOTE_API_KEY'] ?? ''), + 'model' => null, + 'timeout' => self::resolveTimeout($env), + 'maxTokens' => self::resolveMaxTokens($env), + 'storeId' => self::resolveOptional($env, 'LLM_REMOTE_STORE_ID'), + 'legacy' => false, + ]; + } + + if ($backend === '' && trim($env['LLM_ENDPOINT'] ?? '') !== '') { + return [ + 'backend' => self::BACKEND_LOCAL, + 'endpoint' => trim((string) $env['LLM_ENDPOINT']), + 'apiKey' => (string) ($env['LLM_API_KEY'] ?? ''), + 'model' => trim((string) ($env['LLM_MODEL'] ?? 'llama3.2')), + 'timeout' => self::resolveTimeout($env), + 'maxTokens' => self::resolveMaxTokens($env), + 'storeId' => null, + 'legacy' => true, + ]; + } + + $endpoint = trim($env['LLM_LOCAL_ENDPOINT'] ?? ''); + if ($endpoint === '') { + throw new \RuntimeException('LLM_LOCAL_ENDPOINT не задан в env'); + } + + return [ + 'backend' => self::BACKEND_LOCAL, + 'endpoint' => $endpoint, + 'apiKey' => (string) ($env['LLM_LOCAL_API_KEY'] ?? ''), + 'model' => trim((string) ($env['LLM_LOCAL_MODEL'] ?? 'llama3.2')), + 'timeout' => self::resolveTimeout($env), + 'maxTokens' => self::resolveMaxTokens($env), + 'storeId' => null, + 'legacy' => false, + ]; + } + + /** + * @return array + */ + private static function readEnv(): array + { + $keys = [ + 'LLM_BACKEND', + 'LLM_ENDPOINT', + 'LLM_API_KEY', + 'LLM_MODEL', + 'LLM_LOCAL_ENDPOINT', + 'LLM_LOCAL_API_KEY', + 'LLM_LOCAL_MODEL', + 'LLM_REMOTE_ENDPOINT', + 'LLM_REMOTE_API_KEY', + 'LLM_REMOTE_STORE_ID', + 'LLM_TIMEOUT', + 'LLM_MAX_TOKENS', + 'LLM_PROMPT_SUFFIX', + ]; + + $env = []; + foreach ($keys as $key) { + $value = getenv($key); + if ($value !== false) { + $env[$key] = $value; + } + } + + return $env; + } + + /** + * @param array $env + */ + private static function resolveTimeout(array $env): int + { + $timeout = (int) ($env['LLM_TIMEOUT'] ?? 120); + return $timeout > 0 ? $timeout : 120; + } + + /** + * @param array $env + */ + private static function resolveMaxTokens(array $env): int + { + $maxTokens = (int) ($env['LLM_MAX_TOKENS'] ?? 256); + return $maxTokens > 0 ? $maxTokens : 256; + } + + /** + * @param array $env + */ + private static function resolveOptional(array $env, string $key): ?string + { + $value = trim($env[$key] ?? ''); + return $value === '' ? null : $value; + } } diff --git a/erp24/services/automark/LlmTransportInterface.php b/erp24/services/automark/LlmTransportInterface.php new file mode 100644 index 00000000..f71f24da --- /dev/null +++ b/erp24/services/automark/LlmTransportInterface.php @@ -0,0 +1,22 @@ +> + */ + public function chat(string $systemPrompt, string $userMessage): array; + + /** + * Диагностическая информация о текущем backend. + * + * @return array + */ + public function getDiagnostics(): array; +} diff --git a/erp24/services/automark/LlmVerifier.php b/erp24/services/automark/LlmVerifier.php index 33e84e88..d08721b0 100644 --- a/erp24/services/automark/LlmVerifier.php +++ b/erp24/services/automark/LlmVerifier.php @@ -10,21 +10,40 @@ use yii_app\records\Products1cNomenclature; class LlmVerifier { private const TAXONOMY = <<<'TAXONOMY' -ТАКСОНОМИЯ КАТЕГОРИЙ (используй точные названия): +ТАКСОНОМИЯ И РЕАЛЬНЫЕ ЗНАЧЕНИЯ CATEGORY В ДАННЫХ: - Срезка → Розы | Цветущие | Зелень | Экзотика | Прочее +- Срезы → legacy-значение, трактуй как Срезка - Горшечные_растения → Цветущие | Лиственные | Прочее +- Горшечные → legacy-значение, трактуй как Горшечные_растения - Сухоцветы → Поштучно | Пучек | Прочее - Упаковка → Пленка | Подарок | Прочее - Сопутствующие_товары → Уход | Праздник | Игрушки | Пиротехника | Упаковка | Прочее +- букет, сборка, сервис → допустимые legacy/service category из текущих данных; не отклоняй товар только из-за такого значения, если оно согласуется с названием и similar_examples TAXONOMY; + private const EXAMPLE_LIMIT = 3; + private const STOP_WORDS = [ + 'под', 'с', 'без', 'для', 'шт', 'уп', 'цв', 'см', 'мм', 'мл', 'литр', 'л', + 'кашпо', 'горшок', 'цветок', 'цветы', + ]; + private LlmClient $client; private int $batchSize; + private string $promptSuffix; public function __construct(int $batchSize = 20) { $this->client = new LlmClient(); $this->batchSize = $batchSize; + $this->promptSuffix = trim((string) (getenv('LLM_PROMPT_SUFFIX') ?: '')); + } + + /** + * @return array + */ + public function getClientDiagnostics(): array + { + return $this->client->getDiagnostics(); } /** @@ -46,20 +65,28 @@ TAXONOMY; $batches = array_chunk($predictions, $this->batchSize); $verified = 0; + $failedBatches = 0; + $firstError = null; - foreach ($batches as $batch) { + foreach ($batches as $index => $batch) { $items = $this->enrichBatch($batch); - $payload = self::buildBatchPayload($items); + $payload = $this->buildEffectiveUserPrompt(self::buildBatchPayload($items)); try { - $raw = $this->client->chat(self::buildSystemPrompt(), $payload); + $raw = $this->client->chat($this->buildEffectiveSystemPrompt(), $payload); $results = self::parseVerifyResults($raw); $verified += $this->saveResults($results); - } catch (\RuntimeException) { + } catch (\RuntimeException $e) { + $failedBatches++; + $firstError ??= sprintf('batch %d: %s', $index + 1, $e->getMessage()); continue; } } + if ($verified === 0 && $failedBatches > 0) { + throw new \RuntimeException('LLM verification failed for all batches, first error: ' . $firstError); + } + return $verified; } @@ -83,19 +110,100 @@ TAXONOMY; private function getSimilarExamples(string $productName): array { - preg_match('/[а-яёА-ЯЁa-zA-Z]{4,}/u', $productName, $m); - $keyword = $m[0] ?? ''; - if ($keyword === '') { + $keywords = $this->extractKeywords($productName); + if ($keywords === []) { return []; } - return Products1cNomenclature::find() + $query = Products1cNomenclature::find() ->select(['name', 'category', 'subcategory', 'species', 'size', 'color']) ->where(['not', ['category' => null]]) - ->andWhere(['ilike', 'name', $keyword]) - ->limit(5) + ->limit(50); + + $orConditions = ['or']; + foreach ($keywords as $keyword) { + $orConditions[] = ['ilike', 'name', $keyword]; + } + + /** @var array> $candidates */ + $candidates = $query + ->andWhere($orConditions) ->asArray() ->all(); + + if ($candidates === []) { + return []; + } + + $needle = $this->normalizeText($productName); + usort($candidates, function (array $left, array $right) use ($keywords, $needle): int { + return $this->scoreExample($right, $keywords, $needle) <=> $this->scoreExample($left, $keywords, $needle); + }); + + return array_slice($candidates, 0, self::EXAMPLE_LIMIT); + } + + /** + * @return string[] + */ + private function extractKeywords(string $productName): array + { + preg_match_all('/[а-яёa-z]{3,}/ui', mb_strtolower($productName), $matches); + $words = $matches[0] ?? []; + if ($words === []) { + return []; + } + + $keywords = []; + foreach ($words as $word) { + $word = trim($word, " \t\n\r\0\x0B\"'().,/-"); + $word = preg_replace('/^ъ+/u', '', $word) ?? $word; + if ($word === '' || mb_strlen($word) < 3) { + continue; + } + if (in_array($word, self::STOP_WORDS, true)) { + continue; + } + $keywords[$word] = true; + } + + return array_slice(array_keys($keywords), 0, 6); + } + + private function normalizeText(string $value): string + { + $value = mb_strtolower($value); + $value = str_replace('ъ', '', $value); + return preg_replace('/\s+/u', ' ', $value) ?? $value; + } + + /** + * @param array $example + * @param string[] $keywords + */ + private function scoreExample(array $example, array $keywords, string $needle): int + { + $score = 0; + $name = $this->normalizeText((string) ($example['name'] ?? '')); + + foreach ($keywords as $keyword) { + if (mb_stripos($name, $keyword) !== false) { + $score += 10; + } + } + + similar_text($needle, $name, $percent); + $score += (int) round($percent); + + if (!empty($example['species'])) { + $score += 3; + } + + if (!empty($example['subcategory'])) { + $score += 2; + } + + return $score; } /** @@ -132,7 +240,11 @@ TAXONOMY; ЗАДАЧА: Для каждого товара в массиве: 1. Проверь соответствие разметки (category, subcategory, species, size, color) названию товара. 2. Используй similar_examples как эталон правильной разметки. -3. Вынеси вердикт: approved (разметка верна) или rejected (есть ошибки). +3. Если `similar_examples` пустой, принимай решение по `name` и `prediction`, но с более консервативной оценкой. +4. Не отклоняй товар только потому, что category использует legacy-название (`Срезы`, `Горшечные`, `букет`, `сборка`, `сервис`), если смысл разметки корректен. +5. Вынеси вердикт: approved (разметка верна) или rejected (есть ошибки). +6. `comment` должен быть очень коротким: не более 12 слов, без длинных объяснений. +7. Если товар approved, `comment` должен быть null. ОТВЕТ — только JSON-массив, без пояснений вне него: [{"id":,"verdict":"approved"|"rejected","comment":"<причина если rejected, иначе null>"}] @@ -156,7 +268,21 @@ PROMPT; 'similar_examples' => $item['examples'], ]; } - return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + return json_encode($payload, JSON_UNESCAPED_UNICODE); + } + + public function buildEffectiveSystemPrompt(): string + { + return self::buildSystemPrompt(); + } + + public function buildEffectiveUserPrompt(string $payload): string + { + if ($this->promptSuffix === '') { + return $payload; + } + + return $this->promptSuffix . "\n" . $payload; } /** diff --git a/erp24/services/automark/OpenAiCompatibleLlmTransport.php b/erp24/services/automark/OpenAiCompatibleLlmTransport.php new file mode 100644 index 00000000..12b84166 --- /dev/null +++ b/erp24/services/automark/OpenAiCompatibleLlmTransport.php @@ -0,0 +1,90 @@ + $config + */ + public function __construct( + private readonly array $config, + ?Client $http = null + ) { + $this->http = $http ?? new Client(['timeout' => (int) $this->config['timeout']]); + } + + public function chat(string $systemPrompt, string $userMessage): array + { + try { + $response = $this->http->post((string) $this->config['endpoint'], [ + 'headers' => [ + 'Authorization' => 'Bearer ' . (string) $this->config['apiKey'], + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'model' => (string) $this->config['model'], + 'temperature' => 0.1, + 'max_tokens' => (int) $this->config['maxTokens'], + 'messages' => [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $userMessage], + ], + ], + ]); + } catch (GuzzleException $e) { + throw new \RuntimeException('LLM HTTP ошибка: ' . $e->getMessage(), 0, $e); + } + + return self::parseChatCompletionResponse((string) $response->getBody()); + } + + public function getDiagnostics(): array + { + return [ + 'backend' => (string) $this->config['backend'], + 'endpoint' => (string) $this->config['endpoint'], + 'model' => (string) $this->config['model'], + 'legacy' => (bool) $this->config['legacy'], + ]; + } + + /** + * @return array> + */ + public static function parseChatCompletionResponse(string $body): array + { + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + throw new \RuntimeException('LLM backend вернул невалидный JSON'); + } + + $finishReason = $decoded['choices'][0]['finish_reason'] ?? null; + $content = $decoded['choices'][0]['message']['content'] ?? null; + if (!is_string($content) || trim($content) === '') { + $reasoning = $decoded['choices'][0]['message']['reasoning_content'] ?? null; + if (is_string($reasoning) && trim($reasoning) !== '') { + throw new \RuntimeException('LLM backend вернул только reasoning_content без message.content; уменьшите batch, задайте /no_think в user prompt и/или снизьте max_tokens'); + } + + throw new \RuntimeException('LLM backend не вернул message.content'); + } + + try { + return LlmClient::parseResponse($content); + } catch (\RuntimeException $e) { + if ($finishReason === 'length') { + throw new \RuntimeException('LLM backend обрезал ответ по max_tokens; увеличьте LLM_MAX_TOKENS или сократите prompt/payload', 0, $e); + } + + throw $e; + } + } +} diff --git a/erp24/tests/unit/services/automark/LlmClientTest.php b/erp24/tests/unit/services/automark/LlmClientTest.php index 5a600832..8d8352b8 100644 --- a/erp24/tests/unit/services/automark/LlmClientTest.php +++ b/erp24/tests/unit/services/automark/LlmClientTest.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace tests\unit\services\automark; use Codeception\Test\Unit; +use yii_app\services\automark\CoordinatorLlmTransport; use yii_app\services\automark\LlmClient; +use yii_app\services\automark\OpenAiCompatibleLlmTransport; /** * @covers \yii_app\services\automark\LlmClient @@ -41,4 +43,140 @@ class LlmClientTest extends Unit $this->expectException(\RuntimeException::class); LlmClient::parseResponse('{"error":"bad request"}'); } + + public function testParsesOpenAiCompatibleResponse(): void + { + $body = json_encode([ + 'choices' => [ + [ + 'message' => [ + 'content' => '[{"id":1,"verdict":"approved","comment":null}]', + ], + ], + ], + ], JSON_UNESCAPED_UNICODE); + + $result = OpenAiCompatibleLlmTransport::parseChatCompletionResponse((string) $body); + + $this->assertSame('approved', $result[0]['verdict']); + $this->assertSame(1, $result[0]['id']); + } + + public function testThrowsWhenOpenAiResponseHasNoContent(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('message.content'); + + OpenAiCompatibleLlmTransport::parseChatCompletionResponse('{"choices":[{"message":{}}]}'); + } + + public function testThrowsHelpfulErrorWhenOnlyReasoningContentIsReturned(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('reasoning_content'); + + OpenAiCompatibleLlmTransport::parseChatCompletionResponse('{"choices":[{"message":{"content":"","reasoning_content":"thinking"}}]}'); + } + + public function testThrowsHelpfulErrorWhenResponseIsTruncatedByLength(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('обрезал ответ'); + + OpenAiCompatibleLlmTransport::parseChatCompletionResponse('{"choices":[{"finish_reason":"length","message":{"content":"[{\\"id\\":1,\\"verdict\\":\\"rejected\\""}}]}'); + } + + public function testCoordinatorPrefersResponseJson(): void + { + $body = json_encode([ + 'ok' => true, + 'response_json' => [ + ['id' => 5, 'verdict' => 'approved', 'comment' => null], + ], + 'response_text' => 'ignored', + ], JSON_UNESCAPED_UNICODE); + + $result = CoordinatorLlmTransport::parseAnalyzeResponse((string) $body); + + $this->assertSame(5, $result[0]['id']); + $this->assertSame('approved', $result[0]['verdict']); + } + + public function testCoordinatorFallsBackToResponseText(): void + { + $body = json_encode([ + 'ok' => true, + 'response_json' => null, + 'response_text' => '[{"id":9,"verdict":"rejected","comment":"bad color"}]', + ], JSON_UNESCAPED_UNICODE); + + $result = CoordinatorLlmTransport::parseAnalyzeResponse((string) $body); + + $this->assertSame(9, $result[0]['id']); + $this->assertSame('rejected', $result[0]['verdict']); + } + + public function testCoordinatorThrowsOnBackendError(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('VLM unavailable'); + + CoordinatorLlmTransport::parseAnalyzeResponse('{"ok":false,"error":"VLM unavailable"}'); + } + + public function testResolveConfigForLocalBackend(): void + { + $config = LlmClient::resolveConfig([ + 'LLM_BACKEND' => 'local', + 'LLM_LOCAL_ENDPOINT' => 'http://127.0.0.1:1234/v1/chat/completions', + 'LLM_LOCAL_API_KEY' => 'local-key', + 'LLM_LOCAL_MODEL' => 'qwen', + 'LLM_TIMEOUT' => '90', + ]); + + $this->assertSame('local', $config['backend']); + $this->assertSame('http://127.0.0.1:1234/v1/chat/completions', $config['endpoint']); + $this->assertSame('qwen', $config['model']); + $this->assertSame(90, $config['timeout']); + $this->assertFalse($config['legacy']); + } + + public function testResolveConfigForRemoteBackend(): void + { + $config = LlmClient::resolveConfig([ + 'LLM_BACKEND' => 'remote', + 'LLM_REMOTE_ENDPOINT' => 'http://coordinator:8000/v1/llm-analyze', + 'LLM_REMOTE_API_KEY' => 'remote-key', + 'LLM_REMOTE_STORE_ID' => 'shop01', + ]); + + $this->assertSame('remote', $config['backend']); + $this->assertSame('http://coordinator:8000/v1/llm-analyze', $config['endpoint']); + $this->assertSame('shop01', $config['storeId']); + $this->assertFalse($config['legacy']); + } + + public function testResolveConfigFallsBackToLegacyVariables(): void + { + $config = LlmClient::resolveConfig([ + 'LLM_ENDPOINT' => 'http://localhost:11434/v1', + 'LLM_API_KEY' => '', + 'LLM_MODEL' => 'llama3.2', + ]); + + $this->assertSame('local', $config['backend']); + $this->assertSame('http://localhost:11434/v1', $config['endpoint']); + $this->assertSame('llama3.2', $config['model']); + $this->assertTrue($config['legacy']); + } + + public function testResolveConfigThrowsOnUnknownBackend(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('LLM_BACKEND должен быть local или remote'); + + LlmClient::resolveConfig([ + 'LLM_BACKEND' => 'foo', + ]); + } } diff --git a/erp24/tests/unit/services/automark/LlmVerifierTest.php b/erp24/tests/unit/services/automark/LlmVerifierTest.php index 3d2b912e..8adebe54 100644 --- a/erp24/tests/unit/services/automark/LlmVerifierTest.php +++ b/erp24/tests/unit/services/automark/LlmVerifierTest.php @@ -17,7 +17,8 @@ class LlmVerifierTest extends Unit $prompt = LlmVerifier::buildSystemPrompt(); $this->assertStringContainsString('Срезка', $prompt); - $this->assertStringContainsString('Розы', $prompt); + $this->assertStringContainsString('Срезы', $prompt); + $this->assertStringContainsString('Горшечные', $prompt); $this->assertStringContainsString('Горшечные_растения', $prompt); $this->assertStringContainsString('approved', $prompt); $this->assertStringContainsString('rejected', $prompt); @@ -75,4 +76,17 @@ class LlmVerifierTest extends Unit $results = LlmVerifier::parseVerifyResults($raw); $this->assertCount(1, $results); } + + public function testBuildEffectiveSystemPromptAppendsPromptSuffix(): void + { + putenv('LLM_PROMPT_SUFFIX=/no_think'); + + try { + $verifier = new LlmVerifier(); + $prompt = $verifier->buildEffectiveUserPrompt('[{"id":1}]'); + $this->assertStringContainsString('/no_think', $prompt); + } finally { + putenv('LLM_PROMPT_SUFFIX'); + } + } }