--- /dev/null
+# 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_
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)
MYSQL_TEST_USER=
MYSQL_TEST_PASSWORD=
MYSQL_TEST_DB=erp24_api_test
-
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;
->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;
+ }
}
--- /dev/null
+<?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);
+ }
+}
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),
+ };
}
/**
*/
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();
}
/**
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;
+ }
}
--- /dev/null
+<?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;
+}
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();
}
/**
$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;
}
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;
}
/**
ЗАДАЧА: Для каждого товара в массиве:
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>"}]
'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;
}
/**
--- /dev/null
+<?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;
+ }
+ }
+}
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
$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',
+ ]);
+ }
}
$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);
$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');
+ }
+ }
}