]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
llm verification
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Mon, 4 May 2026 15:22:25 +0000 (18:22 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Mon, 4 May 2026 15:22:25 +0000 (18:22 +0300)
AGENTS.md [new file with mode: 0644]
erp24/.env.example
erp24/commands/AutoMarkController.php
erp24/services/AutoMarkService.php
erp24/services/automark/CoordinatorLlmTransport.php [new file with mode: 0644]
erp24/services/automark/LlmClient.php
erp24/services/automark/LlmTransportInterface.php [new file with mode: 0644]
erp24/services/automark/LlmVerifier.php
erp24/services/automark/OpenAiCompatibleLlmTransport.php [new file with mode: 0644]
erp24/tests/unit/services/automark/LlmClientTest.php
erp24/tests/unit/services/automark/LlmVerifierTest.php

diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644 (file)
index 0000000..ff03196
--- /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_
index f0535f662c6e1be451dd6e68abdd5d87322d7c6e..971d75cc62e1227329812027e7002526f8d023e2 100644 (file)
@@ -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
-
index 72a1b101f0f9d31e887185634a065bdf1c91bbf4..f4fd225bae4fbe3e35644747f3a632819836bc73 100644 (file)
@@ -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;
index b7b8c05b1a8f35bad659db5fdd5d526c0db78fb9..bceec595961d50ce6222917b430c7c498fabe6e3 100644 (file)
@@ -246,4 +246,17 @@ class AutoMarkService
             ->andWhere(['llm_verdict' => null])
             ->count();
     }
+
+    /**
+     * @return array<string, mixed>
+     */
+    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 (file)
index 0000000..250c4fa
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
+
+final class CoordinatorLlmTransport implements LlmTransportInterface
+{
+    private Client $http;
+
+    /**
+     * @param array<string, mixed> $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<int, array<string, mixed>>
+     */
+    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);
+    }
+}
index 1c5a6f28621161c80404795e999b4a57abb4ab8c..d89146df95d3d52dbb4874f706dc4a2acc58e435 100644 (file)
@@ -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<string, mixed>
+     */
+    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<string, string> $env
+     * @return array<string, mixed>
+     */
+    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<string, string>
+     */
+    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<string, string> $env
+     */
+    private static function resolveTimeout(array $env): int
+    {
+        $timeout = (int) ($env['LLM_TIMEOUT'] ?? 120);
+        return $timeout > 0 ? $timeout : 120;
+    }
+
+    /**
+     * @param array<string, string> $env
+     */
+    private static function resolveMaxTokens(array $env): int
+    {
+        $maxTokens = (int) ($env['LLM_MAX_TOKENS'] ?? 256);
+        return $maxTokens > 0 ? $maxTokens : 256;
+    }
+
+    /**
+     * @param array<string, string> $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 (file)
index 0000000..f71f24d
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+interface LlmTransportInterface
+{
+    /**
+     * Отправить prompt в backend и вернуть нормализованный JSON-массив вердиктов.
+     *
+     * @return array<int, array<string, mixed>>
+     */
+    public function chat(string $systemPrompt, string $userMessage): array;
+
+    /**
+     * Диагностическая информация о текущем backend.
+     *
+     * @return array<string, mixed>
+     */
+    public function getDiagnostics(): array;
+}
index 33e84e880cebb735ec025635f023205f176ebccb..d08721b08ae71b6c4a90922c476b6a1005baa70b 100644 (file)
@@ -10,21 +10,40 @@ use yii_app\records\Products1cNomenclature;
 class LlmVerifier
 {
     private const TAXONOMY = <<<'TAXONOMY'
-ТÐ\90Ð\9aСÐ\9eÐ\9dÐ\9eÐ\9cÐ\98Я Ð\9aÐ\90ТÐ\95Ð\93Ð\9eРÐ\98Ð\99 (иÑ\81полÑ\8cзÑ\83й Ñ\82оÑ\87нÑ\8bе Ð½Ð°Ð·Ð²Ð°Ð½Ð¸Ñ\8f):
+ТÐ\90Ð\9aСÐ\9eÐ\9dÐ\9eÐ\9cÐ\98Я Ð\98 Ð Ð\95Ð\90Ð\9bЬÐ\9dЫÐ\95 Ð\97Ð\9dÐ\90ЧÐ\95Ð\9dÐ\98Я CATEGORY Ð\92 Ð\94Ð\90Ð\9dÐ\9dЫХ:
 - Срезка → Розы | Цветущие | Зелень | Экзотика | Прочее
+- Срезы → 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<string, mixed>
+     */
+    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<int, array<string, mixed>> $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<string, mixed> $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":<int>,"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 (file)
index 0000000..12b8416
--- /dev/null
@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
+
+final class OpenAiCompatibleLlmTransport implements LlmTransportInterface
+{
+    private Client $http;
+
+    /**
+     * @param array<string, mixed> $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<int, array<string, mixed>>
+     */
+    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;
+        }
+    }
+}
index 5a6008326d6ad3c07b0032813873f43c3bbc7c46..8d8352b8006391b138829b72e70609f6e0cbf9aa 100644 (file)
@@ -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',
+        ]);
+    }
 }
index 3d2b912e5cad9a204c823fc8abac4809ffc735b2..8adebe54700eeec8244c53633ed4c2341063634f 100644 (file)
@@ -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');
+        }
+    }
 }