]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
llm verification
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Mon, 4 May 2026 08:31:10 +0000 (11:31 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Mon, 4 May 2026 08:31:10 +0000 (11:31 +0300)
docs/superpowers/plans/2026-04-17-erp292-automark-ml-engine.md [new file with mode: 0644]
docs/superpowers/plans/2026-04-17-erp292-manual-testing.md [new file with mode: 0644]
docs/superpowers/plans/2026-04-30-llm-automark-verification.md [new file with mode: 0644]
erp24/.env.example
erp24/controllers/AutoMarkController.php
erp24/docs/erp292_automarkup.html [new file with mode: 0644]
erp24/services/automark/RuleBasedParser.php
erp24/tests/unit/records/Products1cAutomarkPredictionTest.php
erp24/tests/unit/services/automark/RuleBasedParserTest.php
llm-integration copy.md [new file with mode: 0644]

diff --git a/docs/superpowers/plans/2026-04-17-erp292-automark-ml-engine.md b/docs/superpowers/plans/2026-04-17-erp292-automark-ml-engine.md
new file mode 100644 (file)
index 0000000..cbb36c7
--- /dev/null
@@ -0,0 +1,1520 @@
+# ERP-292: ML-движок авторазметки товаров из 1С — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Автоматически присваивать атрибуты номенклатуры (category, species, sort, type, size, color) новым товарам из 1С на основе уже размеченных данных — без ручного ввода.
+
+**Architecture:** Гибридный 3-слойный движок: (1) Rule-based парсинг имени по словарям/regex — покрывает ~70% кейсов с confidence ≥ 0.9; (2) TF-IDF similarity matching по корпусу размеченных товаров — для неочевидных случаев; (3) предсказания хранятся в отдельной таблице `products_1c_automark_predictions` с confidence score и статусом валидации. Оркестратор (`AutoMarkService`) выбирает слой по порогу уверенности.
+
+**Tech Stack:** PHP 8.1, Yii2 2.0.45, PostgreSQL, Codeception (unit tests), RabbitMQ Queue
+
+---
+
+## Схема данных
+
+### Входные данные (обучающая выборка)
+```
+products_1c                      products_1c_nomenclature
+─────────────────────────        ────────────────────────────────────
+id (GUID)  ←─────────────────── id (GUID)
+name       ← feature для ML     category, subcategory, species
+code                             sort, type, size, color
+articule                         (уже размеченные атрибуты)
+```
+
+### Выходные данные (предсказания)
+```
+products_1c_automark_predictions
+─────────────────────────────────────────
+id, product_id → products_1c.id
+category, subcategory, species
+sort, type, size, color
+confidence (0.0..1.0)
+method ('rule' | 'similarity')
+status (0=pending, 1=approved, 2=rejected)
+approved_by, created_at, updated_at
+```
+
+---
+
+## File Structure
+
+```
+erp24/
+├── migrations/
+│   └── m260417_000001_create_products_1c_automark_predictions.php   [NEW]
+├── records/
+│   └── Products1cAutomarkPrediction.php                             [NEW]
+├── services/
+│   ├── automark/
+│   │   ├── ParseResult.php          [NEW] — Value object результата разметки
+│   │   ├── RuleBasedParser.php      [NEW] — Словари + regex парсинг name
+│   │   └── SimilarityMatcher.php   [NEW] — TF-IDF cosine similarity
+│   └── AutoMarkService.php         [NEW] — Оркестратор
+├── commands/
+│   └── AutoMarkController.php      [NEW] — CLI: php yii auto-mark/run
+├── jobs/
+│   └── AutoMarkPredictionJob.php   [NEW] — Queue job для async разметки
+├── controllers/
+│   └── AutoMarkController.php      [NEW] — Web UI валидации
+└── views/
+    └── auto-mark/
+        ├── index.php               [NEW] — Список pending предсказаний
+        └── review.php              [NEW] — Карточка товара + форма валидации
+
+tests/unit/
+├── services/
+│   ├── automark/
+│   │   ├── RuleBasedParserTest.php   [NEW]
+│   │   └── SimilarityMatcherTest.php [NEW]
+│   └── AutoMarkServiceTest.php       [NEW]
+└── records/
+    └── Products1cAutomarkPredictionTest.php [NEW]
+```
+
+---
+
+## Task 1: Миграция — таблица predictions
+
+**Files:**
+- Create: `erp24/migrations/m260417_000001_create_products_1c_automark_predictions.php`
+
+- [ ] **Step 1: Создать файл миграции**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+use yii\db\Migration;
+
+class m260417_000001_create_products_1c_automark_predictions extends Migration
+{
+    public function safeUp(): void
+    {
+        $this->createTable('products_1c_automark_predictions', [
+            'id'          => $this->primaryKey(),
+            'product_id'  => $this->string(36)->notNull(),
+            'category'    => $this->string(255)->null(),
+            'subcategory' => $this->string(255)->null(),
+            'species'     => $this->string(255)->null(),
+            'sort'        => $this->string(255)->null(),
+            'type'        => $this->string(255)->null(),
+            'size'        => $this->integer()->null(),
+            'color'       => $this->string(255)->null(),
+            'confidence'  => $this->float()->notNull(),
+            'method'      => $this->string(50)->notNull(),
+            'status'      => $this->smallInteger()->notNull()->defaultValue(0),
+            'approved_by' => $this->integer()->null(),
+            'created_at'  => $this->timestamp()->notNull()->defaultExpression('NOW()'),
+            'updated_at'  => $this->timestamp()->null(),
+        ]);
+
+        $this->createIndex('idx_automark_product_id', 'products_1c_automark_predictions', 'product_id');
+        $this->createIndex('idx_automark_status', 'products_1c_automark_predictions', 'status');
+        $this->addForeignKey(
+            'fk_automark_product',
+            'products_1c_automark_predictions', 'product_id',
+            'products_1c', 'id',
+            'CASCADE', 'CASCADE'
+        );
+    }
+
+    public function safeDown(): void
+    {
+        $this->dropTable('products_1c_automark_predictions');
+    }
+}
+```
+
+- [ ] **Step 2: Применить миграцию**
+
+```bash
+cd /Users/vladfo/development/yii-erp24/erp24
+php yii migrate --migrationPath=@app/migrations 1
+```
+
+Expected: `Applied 1 migration.`
+
+- [ ] **Step 3: Убедиться что таблица создана**
+
+```bash
+php yii migrate/history 5
+```
+
+Expected: вывод включает `m260417_000001_create_products_1c_automark_predictions`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add erp24/migrations/m260417_000001_create_products_1c_automark_predictions.php
+git commit -m "feat(ERP-292): миграция таблицы products_1c_automark_predictions"
+```
+
+---
+
+## Task 2: Model — Products1cAutomarkPrediction
+
+**Files:**
+- Create: `erp24/records/Products1cAutomarkPrediction.php`
+- Create: `erp24/tests/unit/records/Products1cAutomarkPredictionTest.php`
+
+- [ ] **Step 1: Написать failing тест**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\records;
+
+use Codeception\Test\Unit;
+use yii_app\records\Products1cAutomarkPrediction;
+
+/**
+ * @covers \yii_app\records\Products1cAutomarkPrediction
+ */
+class Products1cAutomarkPredictionTest extends Unit
+{
+    public function testTableName(): void
+    {
+        $this->assertSame('products_1c_automark_predictions', Products1cAutomarkPrediction::tableName());
+    }
+
+    public function testStatusConstants(): void
+    {
+        $this->assertSame(0, Products1cAutomarkPrediction::STATUS_PENDING);
+        $this->assertSame(1, Products1cAutomarkPrediction::STATUS_APPROVED);
+        $this->assertSame(2, Products1cAutomarkPrediction::STATUS_REJECTED);
+    }
+
+    public function testMethodConstants(): void
+    {
+        $this->assertSame('rule', Products1cAutomarkPrediction::METHOD_RULE);
+        $this->assertSame('similarity', Products1cAutomarkPrediction::METHOD_SIMILARITY);
+    }
+
+    public function testIsPendingMethod(): void
+    {
+        $model = new Products1cAutomarkPrediction();
+        $model->status = Products1cAutomarkPrediction::STATUS_PENDING;
+        $this->assertTrue($model->isPending());
+    }
+
+    public function testIsApprovedMethod(): void
+    {
+        $model = new Products1cAutomarkPrediction();
+        $model->status = Products1cAutomarkPrediction::STATUS_APPROVED;
+        $this->assertTrue($model->isApproved());
+    }
+}
+```
+
+- [ ] **Step 2: Запустить тест — убедиться что FAIL**
+
+```bash
+cd /Users/vladfo/development/yii-erp24/erp24
+vendor/bin/codecept run unit tests/unit/records/Products1cAutomarkPredictionTest.php -v
+```
+
+Expected: FAIL — `Class "yii_app\records\Products1cAutomarkPrediction" not found`
+
+- [ ] **Step 3: Создать модель**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use yii\db\ActiveQuery;
+
+/**
+ * Предсказание авторазметки товара из 1С.
+ *
+ * @property int         $id
+ * @property string      $product_id   GUID товара из products_1c.id
+ * @property string|null $category
+ * @property string|null $subcategory
+ * @property string|null $species
+ * @property string|null $sort
+ * @property string|null $type
+ * @property int|null    $size
+ * @property string|null $color
+ * @property float       $confidence   0.0–1.0
+ * @property string      $method       'rule' | 'similarity'
+ * @property int         $status       0=pending, 1=approved, 2=rejected
+ * @property int|null    $approved_by
+ * @property string      $created_at
+ * @property string|null $updated_at
+ */
+class Products1cAutomarkPrediction extends \yii\db\ActiveRecord
+{
+    public const STATUS_PENDING  = 0;
+    public const STATUS_APPROVED = 1;
+    public const STATUS_REJECTED = 2;
+
+    public const METHOD_RULE       = 'rule';
+    public const METHOD_SIMILARITY = 'similarity';
+
+    public static function tableName(): string
+    {
+        return 'products_1c_automark_predictions';
+    }
+
+    public function rules(): array
+    {
+        return [
+            [['product_id', 'confidence', 'method', 'status'], 'required'],
+            [['product_id', 'category', 'subcategory', 'species', 'sort', 'type', 'color', 'method'], 'string', 'max' => 255],
+            [['size', 'status', 'approved_by'], 'integer'],
+            [['confidence'], 'number', 'min' => 0, 'max' => 1],
+            [['method'], 'in', 'range' => [self::METHOD_RULE, self::METHOD_SIMILARITY]],
+            [['status'], 'in', 'range' => [self::STATUS_PENDING, self::STATUS_APPROVED, self::STATUS_REJECTED]],
+            [['created_at', 'updated_at'], 'safe'],
+            [['category', 'subcategory', 'species', 'sort', 'type', 'color', 'approved_by', 'updated_at', 'size'], 'default', 'value' => null],
+        ];
+    }
+
+    public function isPending(): bool
+    {
+        return $this->status === self::STATUS_PENDING;
+    }
+
+    public function isApproved(): bool
+    {
+        return $this->status === self::STATUS_APPROVED;
+    }
+
+    public function getProduct(): ActiveQuery
+    {
+        return $this->hasOne(Products1c::class, ['id' => 'product_id']);
+    }
+}
+```
+
+- [ ] **Step 4: Запустить тест — убедиться что PASS**
+
+```bash
+vendor/bin/codecept run unit tests/unit/records/Products1cAutomarkPredictionTest.php -v
+```
+
+Expected: `OK (5 tests, 5 assertions)`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add erp24/records/Products1cAutomarkPrediction.php erp24/tests/unit/records/Products1cAutomarkPredictionTest.php
+git commit -m "feat(ERP-292): модель Products1cAutomarkPrediction"
+```
+
+---
+
+## Task 3: Value Object — ParseResult
+
+**Files:**
+- Create: `erp24/services/automark/ParseResult.php`
+
+Этот DTO передаётся между всеми слоями движка. Нет смысла тестировать отдельно — покроется через RuleBasedParserTest.
+
+- [ ] **Step 1: Создать ParseResult**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+final class ParseResult
+{
+    public function __construct(
+        public readonly ?string $category,
+        public readonly ?string $subcategory,
+        public readonly ?string $species,
+        public readonly ?string $sort,
+        public readonly ?string $type,
+        public readonly ?int    $size,
+        public readonly ?string $color,
+        public readonly float   $confidence,
+        public readonly string  $method,
+    ) {}
+
+    public function isConfident(float $threshold = 0.7): bool
+    {
+        return $this->confidence >= $threshold;
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'category'    => $this->category,
+            'subcategory' => $this->subcategory,
+            'species'     => $this->species,
+            'sort'        => $this->sort,
+            'type'        => $this->type,
+            'size'        => $this->size,
+            'color'       => $this->color,
+            'confidence'  => $this->confidence,
+            'method'      => $this->method,
+        ];
+    }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add erp24/services/automark/ParseResult.php
+git commit -m "feat(ERP-292): value object ParseResult"
+```
+
+---
+
+## Task 4: RuleBasedParser — парсинг имени товара
+
+**Files:**
+- Create: `erp24/services/automark/RuleBasedParser.php`
+- Create: `erp24/tests/unit/services/automark/RuleBasedParserTest.php`
+
+Логика: словари ключевых слов → атрибутов + regex для size. Если найдено species → category автоматически = Срезы (если не горшечное).
+
+- [ ] **Step 1: Написать failing тест**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services\automark;
+
+use Codeception\Test\Unit;
+use yii_app\services\automark\RuleBasedParser;
+use yii_app\services\automark\ParseResult;
+
+/**
+ * @covers \yii_app\services\automark\RuleBasedParser
+ */
+class RuleBasedParserTest extends Unit
+{
+    private RuleBasedParser $parser;
+
+    protected function setUp(): void
+    {
+        $this->parser = new RuleBasedParser();
+    }
+
+    public function testParsesRoseWithSizeAndColor(): void
+    {
+        $result = $this->parser->parse('Роза Premium 50см Красная');
+
+        $this->assertInstanceOf(ParseResult::class, $result);
+        $this->assertSame('Срезы', $result->category);
+        $this->assertSame('Роза', $result->species);
+        $this->assertSame('Premium', $result->sort);
+        $this->assertSame(50, $result->size);
+        $this->assertSame('Красная', $result->color);
+        $this->assertGreaterThanOrEqual(0.9, $result->confidence);
+        $this->assertSame('rule', $result->method);
+    }
+
+    public function testParsesPottedPlant(): void
+    {
+        $result = $this->parser->parse('Орхидея Фаленопсис горшок 12');
+
+        $this->assertSame('Горшечные', $result->category);
+        $this->assertGreaterThanOrEqual(0.8, $result->confidence);
+    }
+
+    public function testParsesPackaging(): void
+    {
+        $result = $this->parser->parse('Пакет целлофановый 60х80');
+
+        $this->assertSame('Упаковка', $result->category);
+        $this->assertGreaterThanOrEqual(0.85, $result->confidence);
+    }
+
+    public function testExtractsSizeInCm(): void
+    {
+        $result = $this->parser->parse('Хризантема Экстра 70 см Белая');
+
+        $this->assertSame(70, $result->size);
+        $this->assertSame('Белая', $result->color);
+        $this->assertSame('Хризантема', $result->species);
+    }
+
+    public function testReturnsNullForUnknownName(): void
+    {
+        $result = $this->parser->parse('XYZ-неизвестный-товар-123');
+
+        $this->assertNull($result);
+    }
+
+    public function testChrysanthemumIsSrezy(): void
+    {
+        $result = $this->parser->parse('Хризантема Белая 60см');
+
+        $this->assertSame('Срезы', $result->category);
+        $this->assertSame('Хризантема', $result->species);
+    }
+}
+```
+
+- [ ] **Step 2: Запустить — убедиться что FAIL**
+
+```bash
+vendor/bin/codecept run unit tests/unit/services/automark/RuleBasedParserTest.php -v
+```
+
+Expected: FAIL — `Class "yii_app\services\automark\RuleBasedParser" not found`
+
+- [ ] **Step 3: Создать RuleBasedParser**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+class RuleBasedParser
+{
+    private const SPECIES_SREZY = [
+        'роза' => 'Роза', 'хризантема' => 'Хризантема', 'гербера' => 'Гербера',
+        'тюльпан' => 'Тюльпан', 'лилия' => 'Лилия', 'пион' => 'Пион',
+        'эустома' => 'Эустома', 'альстромерия' => 'Альстромерия',
+        'гипсофила' => 'Гипсофила', 'ирис' => 'Ирис', 'гвоздика' => 'Гвоздика',
+        'нарцисс' => 'Нарцисс', 'фрезия' => 'Фрезия', 'лизиантус' => 'Лизиантус',
+    ];
+
+    private const SORT_KEYWORDS = [
+        'premium' => 'Premium', 'экстра' => 'Экстра', 'extra' => 'Extra',
+        'select' => 'Select', 'стандарт' => 'Стандарт', 'премиум' => 'Премиум',
+        'супер' => 'Супер', 'элит' => 'Элит',
+    ];
+
+    private const COLOR_KEYWORDS = [
+        'красная' => 'Красная', 'красный' => 'Красный',
+        'белая' => 'Белая', 'белый' => 'Белый',
+        'розовая' => 'Розовая', 'розовый' => 'Розовый',
+        'желтая' => 'Желтая', 'желтый' => 'Желтый',
+        'оранжевая' => 'Оранжевая', 'оранжевый' => 'Оранжевый',
+        'фиолетовая' => 'Фиолетовая', 'фиолетовый' => 'Фиолетовый',
+        'синяя' => 'Синяя', 'синий' => 'Синий',
+        'кремовая' => 'Кремовая', 'кремовый' => 'Кремовый',
+        'бордовая' => 'Бордовая', 'бордовый' => 'Бордовый',
+        'микс' => 'Микс', 'коралловая' => 'Коралловая',
+    ];
+
+    private const POTTED_KEYWORDS = ['горшок', 'горшечн', 'кашпо', 'вазон'];
+
+    private const PACKAGING_KEYWORDS = ['пакет', 'коробк', 'упаков', 'лента', 'бумага', 'сетка', 'сизаль'];
+
+    public function parse(string $name): ?ParseResult
+    {
+        $nameLower = mb_strtolower($name);
+
+        $category    = $this->detectCategory($nameLower);
+        $species     = $this->detectSpecies($nameLower);
+        $sort        = $this->detectSort($nameLower);
+        $color       = $this->detectColor($nameLower);
+        $size        = $this->detectSize($name);
+
+        // Ничего не нашли — не возвращаем пустой результат
+        if ($category === null && $species === null) {
+            return null;
+        }
+
+        // Если нашли species среза, но category не определена — ставим Срезы
+        if ($species !== null && $category === null) {
+            $category = 'Срезы';
+        }
+
+        $confidence = $this->calcConfidence($category, $species, $sort, $color, $size);
+
+        return new ParseResult(
+            category:    $category,
+            subcategory: null,
+            species:     $species,
+            sort:        $sort,
+            type:        null,
+            size:        $size,
+            color:       $color,
+            confidence:  $confidence,
+            method:      'rule',
+        );
+    }
+
+    private function detectCategory(string $nameLower): ?string
+    {
+        foreach (self::POTTED_KEYWORDS as $kw) {
+            if (str_contains($nameLower, $kw)) {
+                return 'Горшечные';
+            }
+        }
+        foreach (self::PACKAGING_KEYWORDS as $kw) {
+            if (str_contains($nameLower, $kw)) {
+                return 'Упаковка';
+            }
+        }
+        return null;
+    }
+
+    private function detectSpecies(string $nameLower): ?string
+    {
+        foreach (self::SPECIES_SREZY as $keyword => $label) {
+            if (str_contains($nameLower, $keyword)) {
+                return $label;
+            }
+        }
+        return null;
+    }
+
+    private function detectSort(string $nameLower): ?string
+    {
+        foreach (self::SORT_KEYWORDS as $keyword => $label) {
+            if (str_contains($nameLower, $keyword)) {
+                return $label;
+            }
+        }
+        return null;
+    }
+
+    private function detectColor(string $nameLower): ?string
+    {
+        foreach (self::COLOR_KEYWORDS as $keyword => $label) {
+            if (str_contains($nameLower, $keyword)) {
+                return $label;
+            }
+        }
+        return null;
+    }
+
+    private function detectSize(string $name): ?int
+    {
+        if (preg_match('/(\d+)\s*(?:см|cm|СМ|CM)/iu', $name, $m)) {
+            return (int) $m[1];
+        }
+        return null;
+    }
+
+    private function calcConfidence(?string $category, ?string $species, ?string $sort, ?string $color, ?int $size): float
+    {
+        $score = 0.5; // base
+        if ($category !== null) $score += 0.2;
+        if ($species !== null)  $score += 0.15;
+        if ($color !== null)    $score += 0.1;
+        if ($size !== null)     $score += 0.05;
+        if ($sort !== null)     $score += 0.05;
+        return min(1.0, $score);
+    }
+}
+```
+
+- [ ] **Step 4: Запустить тест — убедиться что PASS**
+
+```bash
+vendor/bin/codecept run unit tests/unit/services/automark/RuleBasedParserTest.php -v
+```
+
+Expected: `OK (6 tests, 14 assertions)`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add erp24/services/automark/RuleBasedParser.php erp24/tests/unit/services/automark/RuleBasedParserTest.php
+git commit -m "feat(ERP-292): RuleBasedParser — словарный парсинг имени товара"
+```
+
+---
+
+## Task 5: SimilarityMatcher — TF-IDF cosine similarity
+
+**Files:**
+- Create: `erp24/services/automark/SimilarityMatcher.php`
+- Create: `erp24/tests/unit/services/automark/SimilarityMatcherTest.php`
+
+Логика: токенизация → TF вектор → cosine similarity с каждой записью корпуса → возвращает ParseResult из лучшего совпадения.
+
+- [ ] **Step 1: Написать failing тест**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services\automark;
+
+use Codeception\Test\Unit;
+use yii_app\services\automark\SimilarityMatcher;
+use yii_app\services\automark\ParseResult;
+
+/**
+ * @covers \yii_app\services\automark\SimilarityMatcher
+ */
+class SimilarityMatcherTest extends Unit
+{
+    private SimilarityMatcher $matcher;
+
+    protected function setUp(): void
+    {
+        $this->matcher = new SimilarityMatcher();
+    }
+
+    private function makeCorpus(): array
+    {
+        return [
+            ['name' => 'Роза красная 50см Premium', 'category' => 'Срезы', 'subcategory' => null, 'species' => 'Роза', 'sort' => 'Premium', 'type' => null, 'size' => 50, 'color' => 'Красная'],
+            ['name' => 'Роза белая 60см Экстра',    'category' => 'Срезы', 'subcategory' => null, 'species' => 'Роза', 'sort' => 'Экстра',  'type' => null, 'size' => 60, 'color' => 'Белая'],
+            ['name' => 'Хризантема белая 70 см',    'category' => 'Срезы', 'subcategory' => null, 'species' => 'Хризантема', 'sort' => null, 'type' => null, 'size' => 70, 'color' => 'Белая'],
+        ];
+    }
+
+    public function testFindsBestMatchForSimilarName(): void
+    {
+        $result = $this->matcher->findBestMatch('Роза красная 50 Premium', $this->makeCorpus());
+
+        $this->assertInstanceOf(ParseResult::class, $result);
+        $this->assertSame('Срезы', $result->category);
+        $this->assertSame('Роза', $result->species);
+        $this->assertSame('similarity', $result->method);
+        $this->assertGreaterThan(0.0, $result->confidence);
+    }
+
+    public function testReturnsNullForEmptyCorpus(): void
+    {
+        $result = $this->matcher->findBestMatch('Роза красная', []);
+
+        $this->assertNull($result);
+    }
+
+    public function testConfidenceIsHigherForCloseMatch(): void
+    {
+        $corpus = $this->makeCorpus();
+        $closeMatch = $this->matcher->findBestMatch('Роза красная 50 Premium', $corpus);
+        $farMatch   = $this->matcher->findBestMatch('Нечто совсем другое', $corpus);
+
+        $this->assertGreaterThan($farMatch?->confidence ?? 0.0, $closeMatch->confidence);
+    }
+
+    public function testTokenizePublicMethod(): void
+    {
+        $tokens = SimilarityMatcher::tokenize('Роза Premium 50см');
+
+        $this->assertContains('роза', $tokens);
+        $this->assertContains('premium', $tokens);
+        // числа и "см" не входят в токены
+        $this->assertNotContains('50см', $tokens);
+    }
+}
+```
+
+- [ ] **Step 2: Запустить — убедиться что FAIL**
+
+```bash
+vendor/bin/codecept run unit tests/unit/services/automark/SimilarityMatcherTest.php -v
+```
+
+Expected: FAIL — `Class "yii_app\services\automark\SimilarityMatcher" not found`
+
+- [ ] **Step 3: Создать SimilarityMatcher**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+class SimilarityMatcher
+{
+    // Стоп-слова: не несут смысловой нагрузки для сравнения
+    private const STOP_WORDS = ['и', 'в', 'на', 'с', 'по', 'для', 'из', 'от', 'до', 'за', 'при', 'под'];
+
+    /**
+     * Найти лучшее совпадение из размеченного корпуса.
+     *
+     * @param string $name    Имя нового товара
+     * @param array  $corpus  Массив размеченных товаров: [name, category, species, ...]
+     */
+    public function findBestMatch(string $name, array $corpus): ?ParseResult
+    {
+        if (empty($corpus)) {
+            return null;
+        }
+
+        $queryTokens = self::tokenize($name);
+        if (empty($queryTokens)) {
+            return null;
+        }
+
+        $bestScore = -1.0;
+        $bestItem  = null;
+
+        foreach ($corpus as $item) {
+            $corpusTokens = self::tokenize($item['name'] ?? '');
+            $score = $this->cosineSimilarity($queryTokens, $corpusTokens);
+            if ($score > $bestScore) {
+                $bestScore = $score;
+                $bestItem  = $item;
+            }
+        }
+
+        if ($bestItem === null || $bestScore <= 0.0) {
+            return null;
+        }
+
+        return new ParseResult(
+            category:    $bestItem['category'] ?? null,
+            subcategory: $bestItem['subcategory'] ?? null,
+            species:     $bestItem['species'] ?? null,
+            sort:        $bestItem['sort'] ?? null,
+            type:        $bestItem['type'] ?? null,
+            size:        isset($bestItem['size']) ? (int) $bestItem['size'] : null,
+            color:       $bestItem['color'] ?? null,
+            confidence:  round($bestScore, 4),
+            method:      'similarity',
+        );
+    }
+
+    /**
+     * Токенизация: lowercase, только буквы/цифры, без стоп-слов, без коротких токенов.
+     */
+    public static function tokenize(string $text): array
+    {
+        $text = mb_strtolower($text);
+        // Убрать числа со смешанными словами (50см → '')
+        $text = preg_replace('/\d+\s*(?:см|cm|СМ|CM)/iu', '', $text);
+        // Только кириллица и латиница
+        preg_match_all('/[а-яёa-z]{2,}/u', $text, $matches);
+        $tokens = $matches[0] ?? [];
+        return array_values(array_filter($tokens, fn($t) => !in_array($t, self::STOP_WORDS, true)));
+    }
+
+    private function cosineSimilarity(array $a, array $b): float
+    {
+        $vecA = array_count_values($a);
+        $vecB = array_count_values($b);
+
+        $allKeys = array_unique(array_merge(array_keys($vecA), array_keys($vecB)));
+        $dotProduct = 0.0;
+        $normA = 0.0;
+        $normB = 0.0;
+
+        foreach ($allKeys as $key) {
+            $va = $vecA[$key] ?? 0;
+            $vb = $vecB[$key] ?? 0;
+            $dotProduct += $va * $vb;
+            $normA += $va * $va;
+            $normB += $vb * $vb;
+        }
+
+        if ($normA === 0.0 || $normB === 0.0) {
+            return 0.0;
+        }
+
+        return $dotProduct / (sqrt($normA) * sqrt($normB));
+    }
+}
+```
+
+- [ ] **Step 4: Запустить тест — убедиться что PASS**
+
+```bash
+vendor/bin/codecept run unit tests/unit/services/automark/SimilarityMatcherTest.php -v
+```
+
+Expected: `OK (4 tests, 7 assertions)`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add erp24/services/automark/SimilarityMatcher.php erp24/tests/unit/services/automark/SimilarityMatcherTest.php
+git commit -m "feat(ERP-292): SimilarityMatcher — TF-IDF cosine similarity"
+```
+
+---
+
+## Task 6: AutoMarkService — оркестратор
+
+**Files:**
+- Create: `erp24/services/AutoMarkService.php`
+- Create: `erp24/tests/unit/services/AutoMarkServiceTest.php`
+
+Логика: Rule → confidence ≥ 0.9 → сохранить. Иначе Similarity → confidence ≥ 0.7 → сохранить. Иначе сохранить с низким confidence для ручной проверки.
+
+- [ ] **Step 1: Написать failing тест**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services;
+
+use Codeception\Test\Unit;
+use yii_app\services\AutoMarkService;
+
+/**
+ * @covers \yii_app\services\AutoMarkService
+ */
+class AutoMarkServiceTest extends Unit
+{
+    public function testRuleThresholdConstant(): void
+    {
+        $this->assertSame(0.9, AutoMarkService::RULE_THRESHOLD);
+    }
+
+    public function testSimilarityThresholdConstant(): void
+    {
+        $this->assertSame(0.7, AutoMarkService::SIMILARITY_THRESHOLD);
+    }
+
+    public function testPredictForProductMethodExists(): void
+    {
+        $this->assertTrue(method_exists(AutoMarkService::class, 'predictForProduct'));
+    }
+
+    public function testApplyApprovedPredictionMethodExists(): void
+    {
+        $this->assertTrue(method_exists(AutoMarkService::class, 'applyApprovedPrediction'));
+    }
+
+    public function testBatchPredictMethodExists(): void
+    {
+        $this->assertTrue(method_exists(AutoMarkService::class, 'batchPredict'));
+    }
+}
+```
+
+- [ ] **Step 2: Запустить — убедиться что FAIL**
+
+```bash
+vendor/bin/codecept run unit tests/unit/services/AutoMarkServiceTest.php -v
+```
+
+Expected: FAIL — `Class "yii_app\services\AutoMarkService" not found`
+
+- [ ] **Step 3: Создать AutoMarkService**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services;
+
+use Yii;
+use yii_app\records\Products1c;
+use yii_app\records\Products1cAutomarkPrediction;
+use yii_app\records\Products1cNomenclature;
+use yii_app\services\automark\ParseResult;
+use yii_app\services\automark\RuleBasedParser;
+use yii_app\services\automark\SimilarityMatcher;
+
+class AutoMarkService
+{
+    public const RULE_THRESHOLD       = 0.9;
+    public const SIMILARITY_THRESHOLD = 0.7;
+
+    private RuleBasedParser  $ruleParser;
+    private SimilarityMatcher $matcher;
+
+    public function __construct()
+    {
+        $this->ruleParser = new RuleBasedParser();
+        $this->matcher    = new SimilarityMatcher();
+    }
+
+    /**
+     * Предсказать и сохранить разметку для одного товара.
+     * Если предсказание уже существует — пропустить.
+     */
+    public function predictForProduct(string $productId): ?Products1cAutomarkPrediction
+    {
+        $existing = Products1cAutomarkPrediction::find()
+            ->where(['product_id' => $productId, 'status' => Products1cAutomarkPrediction::STATUS_PENDING])
+            ->one();
+
+        if ($existing !== null) {
+            return $existing;
+        }
+
+        $product = Products1c::findOne($productId);
+        if ($product === null) {
+            return null;
+        }
+
+        $result = $this->runPipeline($product->name);
+        if ($result === null) {
+            return null;
+        }
+
+        return $this->savePrediction($productId, $result);
+    }
+
+    /**
+     * Применить одобренное предсказание → записать в products_1c_nomenclature.
+     */
+    public function applyApprovedPrediction(int $predictionId): bool
+    {
+        $prediction = Products1cAutomarkPrediction::findOne($predictionId);
+        if ($prediction === null || !$prediction->isApproved()) {
+            return false;
+        }
+
+        $nomenclature = Products1cNomenclature::findOne($prediction->product_id);
+        if ($nomenclature === null) {
+            $nomenclature     = new Products1cNomenclature();
+            $nomenclature->id = $prediction->product_id;
+            $product = Products1c::findOne($prediction->product_id);
+            $nomenclature->name     = $product?->name ?? '';
+            $nomenclature->location = '';
+            $nomenclature->type_num = '';
+        }
+
+        $nomenclature->category    = $prediction->category    ?? $nomenclature->category;
+        $nomenclature->subcategory = $prediction->subcategory ?? $nomenclature->subcategory;
+        $nomenclature->species     = $prediction->species     ?? $nomenclature->species;
+        $nomenclature->sort        = $prediction->sort        ?? $nomenclature->sort;
+        $nomenclature->type        = $prediction->type        ?? $nomenclature->type;
+        $nomenclature->size        = $prediction->size        ?? $nomenclature->size;
+        $nomenclature->color       = $prediction->color       ?? $nomenclature->color;
+
+        return $nomenclature->save();
+    }
+
+    /**
+     * Пакетная разметка товаров без номенклатуры.
+     *
+     * @param string[] $productIds  Если пусто — обработать все без номенклатуры
+     */
+    public function batchPredict(array $productIds = []): int
+    {
+        if (empty($productIds)) {
+            $productIds = $this->getUnmarkedProductIds();
+        }
+
+        $count = 0;
+        foreach ($productIds as $id) {
+            if ($this->predictForProduct($id) !== null) {
+                $count++;
+            }
+        }
+        return $count;
+    }
+
+    private function runPipeline(string $name): ?ParseResult
+    {
+        // Слой 1: Rule-based
+        $ruleResult = $this->ruleParser->parse($name);
+        if ($ruleResult !== null && $ruleResult->confidence >= self::RULE_THRESHOLD) {
+            return $ruleResult;
+        }
+
+        // Слой 2: Similarity
+        $corpus = $this->loadCorpus();
+        $simResult = $this->matcher->findBestMatch($name, $corpus);
+        if ($simResult !== null && $simResult->confidence >= self::SIMILARITY_THRESHOLD) {
+            return $simResult;
+        }
+
+        // Вернуть лучшее из доступного с низкой уверенностью для ручной проверки
+        return $ruleResult ?? $simResult;
+    }
+
+    private function savePrediction(string $productId, ParseResult $result): ?Products1cAutomarkPrediction
+    {
+        $model = new Products1cAutomarkPrediction();
+        $model->product_id  = $productId;
+        $model->category    = $result->category;
+        $model->subcategory = $result->subcategory;
+        $model->species     = $result->species;
+        $model->sort        = $result->sort;
+        $model->type        = $result->type;
+        $model->size        = $result->size;
+        $model->color       = $result->color;
+        $model->confidence  = $result->confidence;
+        $model->method      = $result->method;
+        $model->status      = Products1cAutomarkPrediction::STATUS_PENDING;
+
+        return $model->save() ? $model : null;
+    }
+
+    private function loadCorpus(): array
+    {
+        return Products1cNomenclature::find()
+            ->select(['id', 'name', 'category', 'subcategory', 'species', 'sort', 'type', 'size', 'color'])
+            ->where(['not', ['category' => null]])
+            ->asArray()
+            ->all();
+    }
+
+    private function getUnmarkedProductIds(): array
+    {
+        return Products1c::find()
+            ->select('products_1c.id')
+            ->leftJoin('products_1c_nomenclature', 'products_1c.id = products_1c_nomenclature.id')
+            ->where(['products_1c_nomenclature.id' => null])
+            ->andWhere(['products_1c.tip' => Products1c::TYPE_PRODUCTS])
+            ->column();
+    }
+}
+```
+
+- [ ] **Step 4: Запустить тест — убедиться что PASS**
+
+```bash
+vendor/bin/codecept run unit tests/unit/services/AutoMarkServiceTest.php -v
+```
+
+Expected: `OK (5 tests, 5 assertions)`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add erp24/services/AutoMarkService.php erp24/tests/unit/services/AutoMarkServiceTest.php
+git commit -m "feat(ERP-292): AutoMarkService — оркестратор разметки"
+```
+
+---
+
+## Task 7: Console-команда AutoMarkController
+
+**Files:**
+- Create: `erp24/commands/AutoMarkController.php`
+
+- [ ] **Step 1: Создать консольный контроллер**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\commands;
+
+use Yii;
+use yii\console\Controller;
+use yii\console\ExitCode;
+use yii_app\services\AutoMarkService;
+
+/**
+ * Консольный контроллер авторазметки товаров из 1С (ERP-292).
+ *
+ * Использование:
+ *   php yii auto-mark/run-new          — разметить все товары без номенклатуры
+ *   php yii auto-mark/run-product GUID — разметить конкретный товар
+ *   php yii auto-mark/apply PRED_ID    — применить одобренное предсказание
+ */
+class AutoMarkController extends Controller
+{
+    public string $defaultAction = 'run-new';
+
+    /**
+     * Разметить все товары без номенклатуры.
+     */
+    public function actionRunNew(): int
+    {
+        $this->stdout("Запуск авторазметки новых товаров...\n");
+
+        $service = new AutoMarkService();
+        $count   = $service->batchPredict();
+
+        $this->stdout("Создано предсказаний: {$count}\n");
+        return ExitCode::OK;
+    }
+
+    /**
+     * Разметить конкретный товар по GUID.
+     */
+    public function actionRunProduct(string $guid): int
+    {
+        $service = new AutoMarkService();
+        $result  = $service->predictForProduct($guid);
+
+        if ($result === null) {
+            $this->stderr("Не удалось создать предсказание для {$guid}\n");
+            return ExitCode::UNSPECIFIED_ERROR;
+        }
+
+        $this->stdout("Предсказание создано (ID={$result->id}, confidence={$result->confidence}, method={$result->method})\n");
+        return ExitCode::OK;
+    }
+
+    /**
+     * Применить одобренное предсказание к products_1c_nomenclature.
+     */
+    public function actionApply(int $predictionId): int
+    {
+        $service = new AutoMarkService();
+        $success = $service->applyApprovedPrediction($predictionId);
+
+        if (!$success) {
+            $this->stderr("Не удалось применить предсказание ID={$predictionId}\n");
+            return ExitCode::UNSPECIFIED_ERROR;
+        }
+
+        $this->stdout("Предсказание ID={$predictionId} применено.\n");
+        return ExitCode::OK;
+    }
+}
+```
+
+- [ ] **Step 2: Зарегистрировать контроллер в console-конфиге**
+
+Открыть `erp24/config/console.php`, найти массив `controllerMap` или `controllerNamespace` и убедиться что `yii_app\commands` уже включён. Если используется автодискавери — ничего делать не нужно.
+
+```bash
+grep -n "AutoMark\|controllerMap\|commands" erp24/config/console.php | head -20
+```
+
+- [ ] **Step 3: Проверить что команда доступна**
+
+```bash
+php yii help auto-mark
+```
+
+Expected: вывод с описанием `run-new`, `run-product`, `apply`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add erp24/commands/AutoMarkController.php
+git commit -m "feat(ERP-292): консольная команда auto-mark"
+```
+
+---
+
+## Task 8: Queue Job — асинхронная разметка
+
+**Files:**
+- Create: `erp24/jobs/AutoMarkPredictionJob.php`
+
+Job запускается при поступлении нового товара из 1С, чтобы не блокировать sync-процесс.
+
+- [ ] **Step 1: Создать Job**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\jobs;
+
+use yii\base\BaseObject;
+use yii\queue\JobInterface;
+use yii_app\services\AutoMarkService;
+
+class AutoMarkPredictionJob extends BaseObject implements JobInterface
+{
+    public string $productId;
+
+    public function execute($queue): void
+    {
+        (new AutoMarkService())->predictForProduct($this->productId);
+    }
+}
+```
+
+- [ ] **Step 2: Добавить вызов Job в sync-процесс**
+
+Найти место где происходит запись нового товара из 1С в `products_1c` (обычно в API2 или команде синхронизации):
+
+```bash
+grep -rn "products_1c.*insert\|Products1c.*save\|upsertProducts" erp24/ --include="*.php" | grep -v test | head -10
+```
+
+После найденного места добавить:
+
+```php
+\Yii::$app->queue->push(new \yii_app\jobs\AutoMarkPredictionJob([
+    'productId' => $product->id,
+]));
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add erp24/jobs/AutoMarkPredictionJob.php
+git commit -m "feat(ERP-292): queue job AutoMarkPredictionJob"
+```
+
+---
+
+## Task 9: Web-контроллер UI валидации
+
+**Files:**
+- Create: `erp24/controllers/AutoMarkController.php`
+- Create: `erp24/views/auto-mark/index.php`
+- Create: `erp24/views/auto-mark/review.php`
+
+- [ ] **Step 1: Создать Web-контроллер**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\controllers;
+
+use Yii;
+use yii\web\Controller;
+use yii\web\NotFoundHttpException;
+use yii\data\ActiveDataProvider;
+use yii_app\records\Products1cAutomarkPrediction;
+use yii_app\services\AutoMarkService;
+
+class AutoMarkController extends Controller
+{
+    /**
+     * Список pending предсказаний с фильтрацией.
+     */
+    public function actionIndex(): string
+    {
+        $query = Products1cAutomarkPrediction::find()
+            ->with('product')
+            ->orderBy(['confidence' => SORT_DESC, 'created_at' => SORT_DESC]);
+
+        $status = Yii::$app->request->get('status');
+        if ($status !== null) {
+            $query->andWhere(['status' => (int) $status]);
+        } else {
+            $query->andWhere(['status' => Products1cAutomarkPrediction::STATUS_PENDING]);
+        }
+
+        $dataProvider = new ActiveDataProvider([
+            'query'      => $query,
+            'pagination' => ['pageSize' => 50],
+        ]);
+
+        return $this->render('index', ['dataProvider' => $dataProvider]);
+    }
+
+    /**
+     * Карточка товара + форма валидации предсказания.
+     */
+    public function actionReview(int $id): string
+    {
+        $prediction = $this->findPrediction($id);
+
+        if (Yii::$app->request->isPost) {
+            $action = Yii::$app->request->post('action');
+            $service = new AutoMarkService();
+
+            if ($action === 'approve') {
+                $prediction->status      = Products1cAutomarkPrediction::STATUS_APPROVED;
+                $prediction->approved_by = Yii::$app->user->id;
+                $prediction->updated_at  = date('Y-m-d H:i:s');
+                $prediction->save();
+                $service->applyApprovedPrediction($prediction->id);
+                Yii::$app->session->setFlash('success', 'Разметка применена.');
+            } elseif ($action === 'reject') {
+                $prediction->status     = Products1cAutomarkPrediction::STATUS_REJECTED;
+                $prediction->updated_at = date('Y-m-d H:i:s');
+                $prediction->save();
+                Yii::$app->session->setFlash('info', 'Предсказание отклонено.');
+            }
+
+            return $this->redirect(['index']);
+        }
+
+        return $this->render('review', ['prediction' => $prediction]);
+    }
+
+    private function findPrediction(int $id): Products1cAutomarkPrediction
+    {
+        $model = Products1cAutomarkPrediction::find()->with('product')->where(['id' => $id])->one();
+        if ($model === null) {
+            throw new NotFoundHttpException("Предсказание #{$id} не найдено.");
+        }
+        return $model;
+    }
+}
+```
+
+- [ ] **Step 2: Создать view/auto-mark/index.php**
+
+```php
+<?php
+/** @var \yii\data\ActiveDataProvider $dataProvider */
+use yii\grid\GridView;
+use yii\helpers\Html;
+use yii_app\records\Products1cAutomarkPrediction;
+
+$this->title = 'Авторазметка товаров';
+?>
+<div class="container-fluid">
+    <h2><?= Html::encode($this->title) ?></h2>
+
+    <div class="mb-3">
+        <?= Html::a('Pending', ['index', 'status' => Products1cAutomarkPrediction::STATUS_PENDING], ['class' => 'btn btn-warning']) ?>
+        <?= Html::a('Одобренные', ['index', 'status' => Products1cAutomarkPrediction::STATUS_APPROVED], ['class' => 'btn btn-success']) ?>
+        <?= Html::a('Отклонённые', ['index', 'status' => Products1cAutomarkPrediction::STATUS_REJECTED], ['class' => 'btn btn-secondary']) ?>
+    </div>
+
+    <?= GridView::widget([
+        'dataProvider' => $dataProvider,
+        'columns' => [
+            'id',
+            [
+                'label' => 'Товар',
+                'value' => fn($m) => $m->product?->name ?? $m->product_id,
+            ],
+            'category',
+            'species',
+            'sort',
+            'color',
+            'size',
+            [
+                'label' => 'Confidence',
+                'value' => fn($m) => number_format($m->confidence * 100, 1) . '%',
+            ],
+            'method',
+            [
+                'label' => 'Действия',
+                'format' => 'raw',
+                'value' => fn($m) => Html::a('Проверить', ['review', 'id' => $m->id], ['class' => 'btn btn-sm btn-primary']),
+            ],
+        ],
+    ]) ?>
+</div>
+```
+
+- [ ] **Step 3: Создать view/auto-mark/review.php**
+
+```php
+<?php
+/** @var \yii_app\records\Products1cAutomarkPrediction $prediction */
+use yii\helpers\Html;
+use yii_app\records\Products1cAutomarkPrediction;
+
+$this->title = 'Проверка разметки #' . $prediction->id;
+?>
+<div class="container">
+    <h2><?= Html::encode($this->title) ?></h2>
+
+    <div class="card mb-4">
+        <div class="card-header"><strong>Товар из 1С</strong></div>
+        <div class="card-body">
+            <p><strong>GUID:</strong> <?= Html::encode($prediction->product_id) ?></p>
+            <p><strong>Название:</strong> <?= Html::encode($prediction->product?->name ?? '—') ?></p>
+            <p><strong>Код:</strong> <?= Html::encode($prediction->product?->code ?? '—') ?></p>
+        </div>
+    </div>
+
+    <div class="card mb-4">
+        <div class="card-header">
+            <strong>Предсказание</strong>
+            — метод: <code><?= Html::encode($prediction->method) ?></code>,
+            уверенность: <strong><?= number_format($prediction->confidence * 100, 1) ?>%</strong>
+        </div>
+        <div class="card-body">
+            <table class="table table-bordered">
+                <tr><th>Категория</th><td><?= Html::encode($prediction->category ?? '—') ?></td></tr>
+                <tr><th>Подкатегория</th><td><?= Html::encode($prediction->subcategory ?? '—') ?></td></tr>
+                <tr><th>Вид</th><td><?= Html::encode($prediction->species ?? '—') ?></td></tr>
+                <tr><th>Сорт</th><td><?= Html::encode($prediction->sort ?? '—') ?></td></tr>
+                <tr><th>Тип</th><td><?= Html::encode($prediction->type ?? '—') ?></td></tr>
+                <tr><th>Размер</th><td><?= $prediction->size ?? '—' ?></td></tr>
+                <tr><th>Цвет</th><td><?= Html::encode($prediction->color ?? '—') ?></td></tr>
+            </table>
+        </div>
+    </div>
+
+    <?php if ($prediction->isPending()): ?>
+    <div class="d-flex gap-2">
+        <?= Html::beginForm(['review', 'id' => $prediction->id], 'post') ?>
+            <?= Html::hiddenInput('action', 'approve') ?>
+            <?= Html::submitButton('Применить разметку', ['class' => 'btn btn-success']) ?>
+        <?= Html::endForm() ?>
+
+        <?= Html::beginForm(['review', 'id' => $prediction->id], 'post') ?>
+            <?= Html::hiddenInput('action', 'reject') ?>
+            <?= Html::submitButton('Отклонить', ['class' => 'btn btn-danger']) ?>
+        <?= Html::endForm() ?>
+    </div>
+    <?php endif; ?>
+
+    <div class="mt-3">
+        <?= Html::a('← Назад к списку', ['index'], ['class' => 'btn btn-secondary']) ?>
+    </div>
+</div>
+```
+
+- [ ] **Step 4: Проверить что маршрут доступен**
+
+```bash
+php yii help auto-mark
+# если не консольный — проверить urlManager
+grep -n "auto-mark\|AutoMark" erp24/config/web.php | head -10
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add erp24/controllers/AutoMarkController.php erp24/views/auto-mark/
+git commit -m "feat(ERP-292): web-контроллер и views для валидации авторазметки"
+```
+
+---
+
+## Task 10: Финальная проверка
+
+- [ ] **Step 1: Запустить все тесты ERP-292**
+
+```bash
+cd /Users/vladfo/development/yii-erp24/erp24
+vendor/bin/codecept run unit tests/unit/records/Products1cAutomarkPredictionTest.php tests/unit/services/automark/ tests/unit/services/AutoMarkServiceTest.php -v
+```
+
+Expected: все тесты PASS, 0 failures.
+
+- [ ] **Step 2: Запустить regression suite**
+
+```bash
+vendor/bin/codecept run unit -v 2>&1 | tail -20
+```
+
+Expected: нет новых failures по сравнению с базовой веткой.
+
+- [ ] **Step 3: Smoke-тест консольной команды (dev-окружение)**
+
+```bash
+php yii auto-mark/run-new
+```
+
+Expected: вывод вида `Создано предсказаний: N`
+
+- [ ] **Step 4: Итоговый коммит**
+
+```bash
+git log --oneline feature_fomichev_ERP-292.. 2>/dev/null || git log --oneline -10
+git push origin HEAD
+```
+
+---
+
+## Self-Review
+
+### Spec coverage
+
+| Требование из ERP-292 | Задача |
+|-----------------------|--------|
+| Авторазметка category, species, sort, type, size, color | Task 4 (Rule), Task 5 (Similarity) |
+| Основа для автозаказа | Task 6 (applyApprovedPrediction → products_1c_nomenclature) |
+| Работа с уже размеченными данными | Task 6 (loadCorpus из products_1c_nomenclature) |
+| Запуск при поступлении товара | Task 8 (Queue Job) |
+| UI для ручной проверки | Task 9 |
+| Хранение предсказаний | Task 1-2 |
+
+### Type consistency check
+
+- `ParseResult` определён в Task 3, используется в Task 4, 5, 6 — ✅
+- `Products1cAutomarkPrediction` определён в Task 2, используется в Task 6, 7, 9 — ✅
+- `AutoMarkService` определён в Task 6, используется в Task 7, 8, 9 — ✅
+- Константы `STATUS_PENDING/APPROVED/REJECTED`, `METHOD_RULE/SIMILARITY` — используются консистентно — ✅
+
+### Placeholder scan
+
+Нет TBD, TODO, "implement later" — ✅
diff --git a/docs/superpowers/plans/2026-04-17-erp292-manual-testing.md b/docs/superpowers/plans/2026-04-17-erp292-manual-testing.md
new file mode 100644 (file)
index 0000000..442306b
--- /dev/null
@@ -0,0 +1,176 @@
+# ERP-292: План ручного тестирования — ML-движок авторазметки
+
+**Ветка:** `feature_fomichev_ERP-292_automarkup`
+**Окружение:** dev (Docker)
+**Дата:** 2026-04-17
+
+---
+
+## Подготовка
+
+```bash
+# 1. Применить миграцию
+docker exec php-yii_erp24 php yii migrate 1
+
+# 2. Запустить unit-тесты
+docker exec php-yii_erp24 vendor/bin/codecept run unit \
+  tests/unit/records/Products1cAutomarkPredictionTest.php \
+  tests/unit/services/automark/ \
+  tests/unit/services/AutoMarkServiceTest.php
+
+# 3. Убедиться что таблица создана
+docker exec php-yii_erp24 php yii migrate/history 3
+```
+
+---
+
+## TC-01: Rule-based разметка — известный вид цветка
+
+**Предусловие:** В `products_1c` есть товар с именем типа "Роза Premium 50см Красная" без записи в `products_1c_nomenclature`.
+
+**Шаги:**
+1. Найти GUID: `SELECT id, name FROM products_1c WHERE tip='products' AND id NOT IN (SELECT id FROM products_1c_nomenclature) LIMIT 5;`
+2. Запустить: `docker exec php-yii_erp24 php yii auto-mark/run-product {GUID}`
+
+**Ожидаемый результат:**
+- Вывод: `Предсказание создано (ID=N, confidence=X.XX, method=rule)`
+- В `products_1c_automark_predictions`: запись с `method='rule'`, `confidence >= 0.9`, `status=0`
+- `category='Срезы'`, `species='Роза'`, `sort='Premium'`, `size=50`, `color='Красная'`
+
+---
+
+## TC-02: Similarity matching — нестандартное название
+
+**Предусловие:** Товар с названием без явных ключевых слов, но похожим на размеченные в `products_1c_nomenclature`.
+
+**Шаги:**
+1. Взять GUID товара с нестандартным названием
+2. `docker exec php-yii_erp24 php yii auto-mark/run-product {GUID}`
+
+**Ожидаемый результат:**
+- `method='similarity'`, `confidence` в диапазоне 0.7–0.89
+- Атрибуты взяты из наиболее похожего товара по TF-IDF
+
+---
+
+## TC-03: Неизвестный товар — предсказание не создаётся
+
+**Предусловие:** Товар с именем без ключевых слов и без аналогов в корпусе.
+
+**Шаги:**
+1. `docker exec php-yii_erp24 php yii auto-mark/run-product {GUID_неизвестного}`
+
+**Ожидаемый результат:**
+- Вывод: `Не удалось создать предсказание для {GUID}`
+- Запись в `products_1c_automark_predictions` НЕ создана
+
+---
+
+## TC-04: Пакетная разметка — без дублей
+
+**Шаги:**
+1. `docker exec php-yii_erp24 php yii auto-mark/run-new`
+2. Запустить повторно
+
+**Ожидаемый результат:**
+- Первый запуск: `Создано предсказаний: N` (N > 0)
+- Повторный запуск: `Создано предсказаний: 0` (дубли не создаются)
+
+---
+
+## TC-05: Web UI — список предсказаний
+
+**Шаги:**
+1. Открыть `http://localhost/auto-mark`
+2. Проверить фильтры: Pending / Одобренные / Отклонённые
+
+**Ожидаемый результат:**
+- GridView с колонками: ID, Товар, Категория, Вид, Сорт, Цвет, Размер, Confidence%, Метод, кнопка Проверить
+- Confidence отображается в % (например `95.0%`)
+- Фильтры переключают список
+
+---
+
+## TC-06: Web UI — одобрение предсказания
+
+**Предусловие:** Есть pending предсказание из TC-01/TC-04.
+
+**Шаги:**
+1. Открыть `/auto-mark/review?id={ID}`
+2. Убедиться что отображается: GUID товара, Название, таблица атрибутов, Confidence%
+3. Нажать "Применить разметку"
+
+**Ожидаемый результат:**
+- Редирект на список с flash "Разметка применена"
+- `products_1c_automark_predictions`: `status=1`, `approved_by` заполнен, `updated_at` заполнен
+- `products_1c_nomenclature`: новая запись с атрибутами из предсказания
+- При повторном открытии review — кнопки Применить/Отклонить не показываются
+
+---
+
+## TC-07: Web UI — отклонение предсказания
+
+**Предусловие:** Другое pending предсказание.
+
+**Шаги:**
+1. Открыть `/auto-mark/review?id={ID}`
+2. Нажать "Отклонить"
+
+**Ожидаемый результат:**
+- Редирект с flash "Предсказание отклонено"
+- `products_1c_automark_predictions`: `status=2`
+- `products_1c_nomenclature`: запись НЕ создана / не изменена
+
+---
+
+## TC-08: CLI apply — применение одобренного
+
+**Шаги:**
+```sql
+UPDATE products_1c_automark_predictions SET status=1 WHERE id={N};
+```
+2. `docker exec php-yii_erp24 php yii auto-mark/apply {N}`
+
+**Ожидаемый результат:**
+- Вывод: `Предсказание ID=N применено.`
+- В `products_1c_nomenclature` появилась/обновлена запись
+
+---
+
+## TC-09: Горшечные и упаковка
+
+**Шаги:**
+1. Найти товары с именами типа "Орхидея горшок 12" и "Пакет целлофановый 60х80"
+2. Запустить разметку для каждого
+
+**Ожидаемый результат:**
+- Орхидея: `category='Горшечные'`, `method='rule'`
+- Пакет: `category='Упаковка'`, `method='rule'`
+
+---
+
+## TC-10: XSS-защита (security smoke)
+
+**Шаги:**
+1. Добавить в `products_1c.name` строку вида `<script>alert(1)</script>` тестовой записи
+2. Запустить разметку, открыть список в UI
+
+**Ожидаемый результат:**
+- Текст отображается как `&lt;script&gt;alert(1)&lt;/script&gt;` — без исполнения JS
+
+---
+
+## Чеклист
+
+| TC | Описание | Результат | Комментарий |
+|----|----------|-----------|-------------|
+| TC-01 | Rule-based: роза с size/color | ⬜ Pass / ⬜ Fail | |
+| TC-02 | Similarity: нестандартное имя | ⬜ Pass / ⬜ Fail | |
+| TC-03 | Null для неизвестного товара | ⬜ Pass / ⬜ Fail | |
+| TC-04 | Batch без дублей | ⬜ Pass / ⬜ Fail | |
+| TC-05 | Web UI список + фильтры | ⬜ Pass / ⬜ Fail | |
+| TC-06 | Одобрение → запись в номенклатуру | ⬜ Pass / ⬜ Fail | |
+| TC-07 | Отклонение → no changes | ⬜ Pass / ⬜ Fail | |
+| TC-08 | CLI apply одобренного | ⬜ Pass / ⬜ Fail | |
+| TC-09 | Горшечные и упаковка | ⬜ Pass / ⬜ Fail | |
+| TC-10 | XSS-защита | ⬜ Pass / ⬜ Fail | |
diff --git a/docs/superpowers/plans/2026-04-30-llm-automark-verification.md b/docs/superpowers/plans/2026-04-30-llm-automark-verification.md
new file mode 100644 (file)
index 0000000..24541d6
--- /dev/null
@@ -0,0 +1,1057 @@
+# LLM Auto-Markup Verification Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Добавить третий путь верификации авторазметки товаров — LLM-верификатор, который пакетно проверяет pending-предсказания, обогащая контекст похожими товарами из nomenclature, и записывает вердикт в таблицу.
+
+**Architecture:** LLM-клиент (Guzzle, OpenAI-compatible API) вызывается из `LlmVerifier`, который разбивает pending-предсказания на батчи, обогащает каждый товар 5 похожими размеченными примерами через ILIKE-запрос, отправляет в LLM и записывает `llm_verdict` + `llm_verified_at` обратно в `products_1c_automark_predictions`. Запуск: автоматически в `actionRunNew` когда нет новых товаров, вручную через `actionVerifyLlm`.
+
+**Tech Stack:** PHP 8.1, Yii2 2.0.45, GuzzleHTTP 7.x, PostgreSQL 12+, Codeception 4.x
+
+---
+
+## File Map
+
+| Действие | Файл | Назначение |
+|---|---|---|
+| Create | `erp24/migrations/m260430_000001_add_llm_fields_to_automark_predictions.php` | Добавить llm_verdict, llm_verified_at, llm_comment |
+| Modify | `erp24/records/Products1cAutomarkPrediction.php` | Новые @property, константа METHOD_LLM, правила |
+| Create | `erp24/services/automark/LlmVerifyResult.php` | DTO: вердикт LLM для одного товара |
+| Create | `erp24/services/automark/LlmClient.php` | Guzzle HTTP-клиент к OpenAI-compatible LLM API |
+| Create | `erp24/services/automark/LlmVerifier.php` | Оркестратор: батчинг, обогащение, вызов клиента, сохранение |
+| Modify | `erp24/services/AutoMarkService.php` | Методы llmBatchVerify(), getPendingUnverifiedCount() |
+| Modify | `erp24/commands/AutoMarkController.php` | Новый actionVerifyLlm(), триггер в actionRunNew() |
+| Modify | `docker/db/dev.db.env` | Новые env-переменные LLM_* |
+| Create | `erp24/tests/unit/services/automark/LlmClientTest.php` | Юнит-тесты парсинга ответа |
+| Create | `erp24/tests/unit/services/automark/LlmVerifierTest.php` | Юнит-тесты построения промпта и парсинга вердиктов |
+
+---
+
+## Task 1: Migration — добавить LLM-поля в таблицу предсказаний
+
+**Files:**
+- Create: `erp24/migrations/m260430_000001_add_llm_fields_to_automark_predictions.php`
+
+- [ ] **Step 1: Создать файл миграции**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+use yii\db\Migration;
+
+class m260430_000001_add_llm_fields_to_automark_predictions extends Migration
+{
+    public function safeUp(): void
+    {
+        $this->addColumn(
+            'products_1c_automark_predictions',
+            'llm_verdict',
+            $this->string(20)->null()->defaultValue(null)->after('method')
+        );
+        $this->addColumn(
+            'products_1c_automark_predictions',
+            'llm_verified_at',
+            $this->timestamp()->null()->defaultValue(null)->after('llm_verdict')
+        );
+        $this->addColumn(
+            'products_1c_automark_predictions',
+            'llm_comment',
+            $this->string(500)->null()->defaultValue(null)->after('llm_verified_at')
+        );
+        $this->createIndex(
+            'idx_automark_llm_verdict',
+            'products_1c_automark_predictions',
+            'llm_verdict'
+        );
+    }
+
+    public function safeDown(): void
+    {
+        $this->dropIndex('idx_automark_llm_verdict', 'products_1c_automark_predictions');
+        $this->dropColumn('products_1c_automark_predictions', 'llm_comment');
+        $this->dropColumn('products_1c_automark_predictions', 'llm_verified_at');
+        $this->dropColumn('products_1c_automark_predictions', 'llm_verdict');
+    }
+}
+```
+
+- [ ] **Step 2: Применить миграцию**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 php yii migrate --migrationPath=@app/migrations --interactive=0
+```
+
+Ожидаем: `Applied 1 migration.`
+
+- [ ] **Step 3: Проверить схему**
+
+```bash
+docker exec yii-erp24-db-pgsql-yii_erp24-1 psql -U postgres -d erp24 \
+  -c "\d erp24.products_1c_automark_predictions" 2>&1 | grep llm
+```
+
+Ожидаем три строки: `llm_verdict`, `llm_verified_at`, `llm_comment`.
+
+- [ ] **Step 4: Коммит**
+
+```bash
+git add erp24/migrations/m260430_000001_add_llm_fields_to_automark_predictions.php
+git commit -m "feat(ERP-292): миграция — llm_verdict, llm_verified_at, llm_comment"
+```
+
+---
+
+## Task 2: Обновить модель Products1cAutomarkPrediction
+
+**Files:**
+- Modify: `erp24/records/Products1cAutomarkPrediction.php`
+
+- [ ] **Step 1: Обновить файл модели**
+
+Заменить содержимое `erp24/records/Products1cAutomarkPrediction.php`:
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use yii\db\ActiveQuery;
+
+/**
+ * Предсказание авторазметки товара из 1С.
+ *
+ * @property int         $id
+ * @property string      $product_id
+ * @property string|null $category
+ * @property string|null $subcategory
+ * @property string|null $species
+ * @property string|null $sort
+ * @property string|null $type
+ * @property int|null    $size
+ * @property string|null $color
+ * @property float       $confidence
+ * @property string      $method
+ * @property int         $status
+ * @property int|null    $approved_by
+ * @property string|null $llm_verdict
+ * @property string|null $llm_verified_at
+ * @property string|null $llm_comment
+ * @property string      $created_at
+ * @property string|null $updated_at
+ */
+class Products1cAutomarkPrediction extends \yii\db\ActiveRecord
+{
+    public const STATUS_PENDING  = 0;
+    public const STATUS_APPROVED = 1;
+    public const STATUS_REJECTED = 2;
+
+    public const METHOD_RULE       = 'rule';
+    public const METHOD_SIMILARITY = 'similarity';
+    public const METHOD_LLM        = 'llm';
+
+    public const LLM_VERDICT_APPROVED = 'approved';
+    public const LLM_VERDICT_REJECTED = 'rejected';
+
+    public static function tableName(): string
+    {
+        return 'products_1c_automark_predictions';
+    }
+
+    public function rules(): array
+    {
+        return [
+            [['product_id', 'confidence', 'method', 'status'], 'required'],
+            [['product_id', 'category', 'subcategory', 'species', 'sort', 'type', 'color', 'method'], 'string', 'max' => 255],
+            [['llm_verdict', 'llm_comment'], 'string', 'max' => 500],
+            [['size', 'status', 'approved_by'], 'integer'],
+            [['confidence'], 'number', 'min' => 0, 'max' => 1],
+            [['method'], 'in', 'range' => [self::METHOD_RULE, self::METHOD_SIMILARITY, self::METHOD_LLM]],
+            [['status'], 'in', 'range' => [self::STATUS_PENDING, self::STATUS_APPROVED, self::STATUS_REJECTED]],
+            [['llm_verdict'], 'in', 'range' => [self::LLM_VERDICT_APPROVED, self::LLM_VERDICT_REJECTED], 'skipOnEmpty' => true],
+            [['created_at', 'updated_at', 'llm_verified_at'], 'safe'],
+            [['category', 'subcategory', 'species', 'sort', 'type', 'color', 'approved_by', 'updated_at', 'size',
+              'llm_verdict', 'llm_verified_at', 'llm_comment'], 'default', 'value' => null],
+        ];
+    }
+
+    public function isPending(): bool
+    {
+        return $this->status === self::STATUS_PENDING;
+    }
+
+    public function isApproved(): bool
+    {
+        return $this->status === self::STATUS_APPROVED;
+    }
+
+    public function isRejected(): bool
+    {
+        return $this->status === self::STATUS_REJECTED;
+    }
+
+    public function isLlmVerified(): bool
+    {
+        return $this->llm_verdict !== null;
+    }
+
+    public function getProduct(): ActiveQuery
+    {
+        return $this->hasOne(Products1c::class, ['id' => 'product_id']);
+    }
+}
+```
+
+- [ ] **Step 2: Прогнать существующие тесты модели**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 \
+  php vendor/bin/codecept run unit "records/Products1cAutomarkPredictionTest.php" --no-colors
+```
+
+Ожидаем: `OK (5 tests, 8 assertions)`
+
+- [ ] **Step 3: Коммит**
+
+```bash
+git add erp24/records/Products1cAutomarkPrediction.php
+git commit -m "feat(ERP-292): модель — METHOD_LLM, llm_verdict поля и валидация"
+```
+
+---
+
+## Task 3: LlmVerifyResult — DTO для вердикта
+
+**Files:**
+- Create: `erp24/services/automark/LlmVerifyResult.php`
+
+- [ ] **Step 1: Создать value object**
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+final class LlmVerifyResult
+{
+    public function __construct(
+        public readonly int     $predictionId,
+        public readonly string  $verdict,    // 'approved' | 'rejected'
+        public readonly ?string $comment,
+    ) {}
+
+    public function isApproved(): bool
+    {
+        return $this->verdict === 'approved';
+    }
+}
+```
+
+- [ ] **Step 2: Коммит**
+
+```bash
+git add erp24/services/automark/LlmVerifyResult.php
+git commit -m "feat(ERP-292): LlmVerifyResult DTO"
+```
+
+---
+
+## Task 4: LlmClient — HTTP-клиент к LLM API
+
+**Files:**
+- Create: `erp24/services/automark/LlmClient.php`
+- Create: `erp24/tests/unit/services/automark/LlmClientTest.php`
+
+Клиент работает с OpenAI-compatible endpoint (Ollama, vLLM, LM Studio и др.).
+Конфигурация читается из env: `LLM_ENDPOINT`, `LLM_API_KEY`, `LLM_MODEL`.
+
+- [ ] **Step 1: Написать failing-тест на parseResponse()**
+
+Создать `erp24/tests/unit/services/automark/LlmClientTest.php`:
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services\automark;
+
+use Codeception\Test\Unit;
+use yii_app\services\automark\LlmClient;
+
+/**
+ * @covers \yii_app\services\automark\LlmClient
+ */
+class LlmClientTest extends Unit
+{
+    public function testExtractsJsonFromCleanResponse(): void
+    {
+        $raw = '[{"id":1,"verdict":"approved","comment":null}]';
+        $this->assertSame(
+            [['id' => 1, 'verdict' => 'approved', 'comment' => null]],
+            LlmClient::parseResponse($raw)
+        );
+    }
+
+    public function testExtractsJsonEmbeddedInText(): void
+    {
+        $raw = "Вот результат:\n[{\"id\":2,\"verdict\":\"rejected\",\"comment\":\"Неверная категория\"}]\nГотово.";
+        $result = LlmClient::parseResponse($raw);
+        $this->assertSame('rejected', $result[0]['verdict']);
+        $this->assertSame(2, $result[0]['id']);
+    }
+
+    public function testThrowsOnUnparsableResponse(): void
+    {
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('LLM вернул невалидный JSON');
+        LlmClient::parseResponse('Я не знаю что ответить.');
+    }
+
+    public function testThrowsOnNonArrayJson(): void
+    {
+        $this->expectException(\RuntimeException::class);
+        LlmClient::parseResponse('{"error":"bad request"}');
+    }
+}
+```
+
+- [ ] **Step 2: Запустить — убедиться, что FAIL**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 \
+  php vendor/bin/codecept run unit "services/automark/LlmClientTest" --no-colors
+```
+
+Ожидаем: `FAIL` — класс не существует.
+
+- [ ] **Step 3: Создать LlmClient**
+
+Создать `erp24/services/automark/LlmClient.php`:
+
+```php
+<?php
+
+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 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]);
+    }
+
+    /**
+     * Отправить запрос к LLM и вернуть распарсенный массив вердиктов.
+     *
+     * @throws \RuntimeException при сетевой ошибке или невалидном JSON
+     */
+    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 self::parseResponse($content);
+    }
+
+    /**
+     * Извлечь JSON-массив из текстового ответа LLM.
+     * Модель иногда оборачивает JSON в пояснительный текст.
+     */
+    public static function parseResponse(string $raw): array
+    {
+        // Попробовать распарсить напрямую
+        $direct = json_decode(trim($raw), true);
+        if (is_array($direct) && array_is_list($direct)) {
+            return $direct;
+        }
+
+        // Извлечь первый JSON-массив из текста
+        if (preg_match('/\[.*?\]/su', $raw, $m)) {
+            $extracted = json_decode($m[0], true);
+            if (is_array($extracted) && array_is_list($extracted)) {
+                return $extracted;
+            }
+        }
+
+        throw new \RuntimeException('LLM вернул невалидный JSON: ' . mb_substr($raw, 0, 200));
+    }
+}
+```
+
+- [ ] **Step 4: Запустить тесты — убедиться, что PASS**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 \
+  php vendor/bin/codecept run unit "services/automark/LlmClientTest" --no-colors
+```
+
+Ожидаем: `OK (4 tests, ...)`
+
+- [ ] **Step 5: Коммит**
+
+```bash
+git add erp24/services/automark/LlmClient.php \
+        erp24/tests/unit/services/automark/LlmClientTest.php
+git commit -m "feat(ERP-292): LlmClient — HTTP-клиент и парсинг ответа"
+```
+
+---
+
+## Task 5: LlmVerifier — оркестратор пакетной верификации
+
+**Files:**
+- Create: `erp24/services/automark/LlmVerifier.php`
+- Create: `erp24/tests/unit/services/automark/LlmVerifierTest.php`
+
+- [ ] **Step 1: Написать failing-тесты**
+
+Создать `erp24/tests/unit/services/automark/LlmVerifierTest.php`:
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services\automark;
+
+use Codeception\Test\Unit;
+use yii_app\services\automark\LlmVerifier;
+
+/**
+ * @covers \yii_app\services\automark\LlmVerifier
+ */
+class LlmVerifierTest extends Unit
+{
+    public function testSystemPromptContainsTaxonomy(): void
+    {
+        $prompt = LlmVerifier::buildSystemPrompt();
+
+        $this->assertStringContainsString('Срезка', $prompt);
+        $this->assertStringContainsString('Розы', $prompt);
+        $this->assertStringContainsString('Горшечные_растения', $prompt);
+        $this->assertStringContainsString('approved', $prompt);
+        $this->assertStringContainsString('rejected', $prompt);
+    }
+
+    public function testBuildBatchPayloadContainsNameAndPrediction(): void
+    {
+        $items = [
+            [
+                'id'       => 42,
+                'name'     => 'Роза Эквадор 80 алая',
+                'category' => 'Срезка',
+                'subcategory' => 'Розы',
+                'species'  => 'Роза',
+                'size'     => 80,
+                'color'    => 'Алая',
+                'examples' => [],
+            ],
+        ];
+
+        $payload = LlmVerifier::buildBatchPayload($items);
+        $decoded = json_decode($payload, true);
+
+        $this->assertIsArray($decoded);
+        $this->assertSame(42, $decoded[0]['id']);
+        $this->assertSame('Роза Эквадор 80 алая', $decoded[0]['name']);
+        $this->assertArrayHasKey('prediction', $decoded[0]);
+        $this->assertSame('Срезка', $decoded[0]['prediction']['category']);
+    }
+
+    public function testParseVerifyResultsReturnsLlmVerifyResultObjects(): void
+    {
+        $raw = [
+            ['id' => 10, 'verdict' => 'approved', 'comment' => null],
+            ['id' => 11, 'verdict' => 'rejected', 'comment' => 'Неверный цвет'],
+        ];
+
+        $results = LlmVerifier::parseVerifyResults($raw);
+
+        $this->assertCount(2, $results);
+        $this->assertTrue($results[0]->isApproved());
+        $this->assertSame(10, $results[0]->predictionId);
+        $this->assertFalse($results[1]->isApproved());
+        $this->assertSame('Неверный цвет', $results[1]->comment);
+    }
+
+    public function testParseVerifyResultsSkipsItemsWithoutRequiredFields(): void
+    {
+        $raw = [
+            ['id' => 10, 'verdict' => 'approved', 'comment' => null],
+            ['id' => 11],  // нет verdict
+            ['verdict' => 'approved'],  // нет id
+        ];
+
+        $results = LlmVerifier::parseVerifyResults($raw);
+        $this->assertCount(1, $results);
+    }
+}
+```
+
+- [ ] **Step 2: Запустить — убедиться, что FAIL**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 \
+  php vendor/bin/codecept run unit "services/automark/LlmVerifierTest" --no-colors
+```
+
+Ожидаем: FAIL — класс не существует.
+
+- [ ] **Step 3: Создать LlmVerifier**
+
+Создать `erp24/services/automark/LlmVerifier.php`:
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+use yii_app\records\Products1cAutomarkPrediction;
+use yii_app\records\Products1cNomenclature;
+
+class LlmVerifier
+{
+    private const SYSTEM_PROMPT_TAXONOMY = <<<TAXONOMY
+    ТАКСОНОМИЯ КАТЕГОРИЙ (используй точные названия):
+    - Срезка → Розы | Цветущие | Зелень | Экзотика | Прочее
+    - Горшечные_растения → Цветущие | Лиственные | Прочее
+    - Сухоцветы → Поштучно | Пучек | Прочее
+    - Упаковка → Пленка | Подарок | Прочее
+    - Сопутствующие_товары → Уход | Праздник | Игрушки | Пиротехника | Упаковка | Прочее
+    TAXONOMY;
+
+    private LlmClient $client;
+    private int       $batchSize;
+
+    public function __construct(int $batchSize = 20)
+    {
+        $this->client    = new LlmClient();
+        $this->batchSize = $batchSize;
+    }
+
+    /**
+     * Верифицировать все pending-предсказания без LLM-вердикта.
+     *
+     * @return int Количество верифицированных предсказаний
+     */
+    public function verifyPending(): int
+    {
+        $predictions = Products1cAutomarkPrediction::find()
+            ->with('product')
+            ->where(['status' => Products1cAutomarkPrediction::STATUS_PENDING])
+            ->andWhere(['llm_verdict' => null])
+            ->all();
+
+        if (empty($predictions)) {
+            return 0;
+        }
+
+        $batches = array_chunk($predictions, $this->batchSize);
+        $verified = 0;
+
+        foreach ($batches as $batch) {
+            $items = $this->enrichBatch($batch);
+            $payload = self::buildBatchPayload($items);
+
+            try {
+                $raw = $this->client->chat(self::buildSystemPrompt(), $payload);
+                $results = self::parseVerifyResults($raw);
+                $verified += $this->saveResults($results);
+            } catch (\RuntimeException) {
+                // Один батч упал — продолжаем следующий
+                continue;
+            }
+        }
+
+        return $verified;
+    }
+
+    /**
+     * Обогатить батч похожими примерами из nomenclature.
+     */
+    private function enrichBatch(array $predictions): array
+    {
+        $items = [];
+        foreach ($predictions as $prediction) {
+            $items[] = [
+                'id'          => $prediction->id,
+                'name'        => $prediction->product->name ?? '',
+                'category'    => $prediction->category,
+                'subcategory' => $prediction->subcategory,
+                'species'     => $prediction->species,
+                'size'        => $prediction->size,
+                'color'       => $prediction->color,
+                'examples'    => $this->getSimilarExamples($prediction->product->name ?? ''),
+            ];
+        }
+        return $items;
+    }
+
+    /**
+     * Получить до 5 похожих размеченных товаров из nomenclature по ключевому слову.
+     */
+    private function getSimilarExamples(string $productName): array
+    {
+        preg_match('/[а-яёА-ЯЁa-zA-Z]{4,}/u', $productName, $m);
+        $keyword = $m[0] ?? '';
+        if ($keyword === '') {
+            return [];
+        }
+
+        return Products1cNomenclature::find()
+            ->select(['name', 'category', 'subcategory', 'species', 'size', 'color'])
+            ->where(['not', ['category' => null]])
+            ->andWhere(['ilike', 'name', $keyword])
+            ->limit(5)
+            ->asArray()
+            ->all();
+    }
+
+    /**
+     * Сохранить вердикты в БД.
+     *
+     * @param LlmVerifyResult[] $results
+     */
+    private function saveResults(array $results): int
+    {
+        $count = 0;
+        $now   = date('Y-m-d H:i:s');
+
+        foreach ($results as $result) {
+            $rows = Products1cAutomarkPrediction::updateAll(
+                [
+                    'llm_verdict'     => $result->verdict,
+                    'llm_verified_at' => $now,
+                    'llm_comment'     => $result->comment,
+                ],
+                ['id' => $result->predictionId]
+            );
+            $count += $rows;
+        }
+
+        return $count;
+    }
+
+    public static function buildSystemPrompt(): string
+    {
+        return <<<PROMPT
+        Ты — система верификации авторазметки товаров цветочного магазина.
+
+        {$this->SYSTEM_PROMPT_TAXONOMY}
+
+        ЗАДАЧА: Для каждого товара в массиве:
+        1. Проверь соответствие разметки (category, subcategory, species, size, color) названию товара.
+        2. Используй similar_examples как эталон правильной разметки.
+        3. Вынеси вердикт: approved (разметка верна) или rejected (есть ошибки).
+
+        ОТВЕТ — только JSON-массив, без пояснений вне него:
+        [{"id":<int>,"verdict":"approved"|"rejected","comment":"<причина если rejected, иначе null>"}]
+        PROMPT;
+    }
+
+    public static function buildBatchPayload(array $items): string
+    {
+        $payload = [];
+        foreach ($items as $item) {
+            $payload[] = [
+                'id'   => $item['id'],
+                'name' => $item['name'],
+                'prediction' => [
+                    'category'    => $item['category'],
+                    'subcategory' => $item['subcategory'],
+                    'species'     => $item['species'],
+                    'size'        => $item['size'],
+                    'color'       => $item['color'],
+                ],
+                'similar_examples' => $item['examples'],
+            ];
+        }
+        return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+    }
+
+    /**
+     * @return LlmVerifyResult[]
+     */
+    public static function parseVerifyResults(array $raw): array
+    {
+        $results = [];
+        foreach ($raw as $item) {
+            if (!isset($item['id'], $item['verdict'])) {
+                continue;
+            }
+            $results[] = new LlmVerifyResult(
+                predictionId: (int) $item['id'],
+                verdict:      (string) $item['verdict'],
+                comment:      isset($item['comment']) ? (string) $item['comment'] : null,
+            );
+        }
+        return $results;
+    }
+}
+```
+
+- [ ] **Step 4: Исправить `buildSystemPrompt` — SYSTEM_PROMPT_TAXONOMY не может быть `$this->` в static методе**
+
+В `LlmVerifier.php` заменить `buildSystemPrompt()`:
+
+```php
+    public static function buildSystemPrompt(): string
+    {
+        $taxonomy = self::SYSTEM_PROMPT_TAXONOMY;
+        return <<<PROMPT
+        Ты — система верификации авторазметки товаров цветочного магазина.
+
+        {$taxonomy}
+
+        ЗАДАЧА: Для каждого товара в массиве:
+        1. Проверь соответствие разметки (category, subcategory, species, size, color) названию товара.
+        2. Используй similar_examples как эталон правильной разметки.
+        3. Вынеси вердикт: approved (разметка верна) или rejected (есть ошибки).
+
+        ОТВЕТ — только JSON-массив, без пояснений вне него:
+        [{"id":<int>,"verdict":"approved"|"rejected","comment":"<причина если rejected, иначе null>"}]
+        PROMPT;
+    }
+```
+
+- [ ] **Step 5: Запустить тесты — убедиться, что PASS**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 \
+  php vendor/bin/codecept run unit "services/automark/LlmVerifierTest" --no-colors
+```
+
+Ожидаем: `OK (4 tests, ...)`
+
+- [ ] **Step 6: Прогнать все automark-тесты**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 \
+  php vendor/bin/codecept run unit "services/automark" --no-colors
+```
+
+Ожидаем: все зелёные.
+
+- [ ] **Step 7: Коммит**
+
+```bash
+git add erp24/services/automark/LlmVerifier.php \
+        erp24/services/automark/LlmVerifyResult.php \
+        erp24/tests/unit/services/automark/LlmVerifierTest.php
+git commit -m "feat(ERP-292): LlmVerifier — батчинг, обогащение примерами, парсинг вердиктов"
+```
+
+---
+
+## Task 6: AutoMarkService — добавить llmBatchVerify и getPendingUnverifiedCount
+
+**Files:**
+- Modify: `erp24/services/AutoMarkService.php`
+
+- [ ] **Step 1: Добавить два публичных метода в AutoMarkService**
+
+В конец `erp24/services/AutoMarkService.php` перед закрывающей `}` добавить:
+
+```php
+    /**
+     * Верифицировать pending-предсказания через LLM.
+     *
+     * @return int Количество верифицированных предсказаний
+     */
+    public function llmBatchVerify(): int
+    {
+        $batchSize = (int)(getenv('LLM_BATCH_SIZE') ?: 20);
+        $verifier  = new \yii_app\services\automark\LlmVerifier($batchSize);
+        return $verifier->verifyPending();
+    }
+
+    /**
+     * Количество pending-предсказаний, ещё не прошедших LLM-верификацию.
+     */
+    public function getPendingUnverifiedCount(): int
+    {
+        return (int) Products1cAutomarkPrediction::find()
+            ->where(['status' => Products1cAutomarkPrediction::STATUS_PENDING])
+            ->andWhere(['llm_verdict' => null])
+            ->count();
+    }
+```
+
+- [ ] **Step 2: Добавить use-импорт LlmVerifier**
+
+В блок `use` в `AutoMarkService.php` добавить:
+
+```php
+use yii_app\services\automark\LlmVerifier;
+```
+
+И заменить `new \yii_app\services\automark\LlmVerifier(...)` на `new LlmVerifier(...)`.
+
+- [ ] **Step 3: Проверить синтаксис**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 php -l /www/services/AutoMarkService.php
+```
+
+Ожидаем: `No syntax errors detected`
+
+- [ ] **Step 4: Коммит**
+
+```bash
+git add erp24/services/AutoMarkService.php
+git commit -m "feat(ERP-292): AutoMarkService — llmBatchVerify, getPendingUnverifiedCount"
+```
+
+---
+
+## Task 7: Console command — триггер и новая команда verify-llm
+
+**Files:**
+- Modify: `erp24/commands/AutoMarkController.php`
+
+- [ ] **Step 1: Обновить AutoMarkController**
+
+Заменить содержимое `erp24/commands/AutoMarkController.php`:
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\commands;
+
+use yii\console\Controller;
+use yii\console\ExitCode;
+use yii_app\services\AutoMarkService;
+
+/**
+ * Авторазметка товаров из 1С (ERP-292).
+ *
+ * Использование:
+ *   php yii auto-mark/run-new         — разметить новые товары; если нет новых — запустить LLM-верификацию
+ *   php yii auto-mark/run-product GUID — разметить конкретный товар
+ *   php yii auto-mark/apply PRED_ID   — применить одобренное предсказание
+ *   php yii auto-mark/verify-llm      — запустить LLM-верификацию принудительно
+ */
+class AutoMarkController extends Controller
+{
+    public $defaultAction = 'run-new';
+
+    public function actionRunNew(): int
+    {
+        $service = new AutoMarkService();
+
+        $this->stdout("Запуск авторазметки новых товаров...\n");
+        $newCount = $service->batchPredict();
+        $this->stdout("Создано предсказаний: {$newCount}\n");
+
+        if ($newCount === 0) {
+            $pendingCount = $service->getPendingUnverifiedCount();
+            if ($pendingCount > 0) {
+                $this->stdout("Новых товаров нет. Запуск LLM-верификации ({$pendingCount} ожидают)...\n");
+                return $this->runLlmVerify($service);
+            }
+            $this->stdout("Нет новых товаров и нет ожидающих верификации.\n");
+        }
+
+        return ExitCode::OK;
+    }
+
+    public function actionRunProduct(string $guid): int
+    {
+        $service = new AutoMarkService();
+        $result  = $service->predictForProduct($guid);
+
+        if ($result === null) {
+            $this->stderr("Не удалось создать предсказание для {$guid}\n");
+            return ExitCode::UNSPECIFIED_ERROR;
+        }
+
+        $this->stdout("Предсказание создано (ID={$result->id}, confidence={$result->confidence}, method={$result->method})\n");
+        return ExitCode::OK;
+    }
+
+    public function actionApply(int $predictionId): int
+    {
+        $service = new AutoMarkService();
+        $success = $service->applyApprovedPrediction($predictionId);
+
+        if (!$success) {
+            $this->stderr("Не удалось применить предсказание ID={$predictionId}\n");
+            return ExitCode::UNSPECIFIED_ERROR;
+        }
+
+        $this->stdout("Предсказание ID={$predictionId} применено.\n");
+        return ExitCode::OK;
+    }
+
+    public function actionVerifyLlm(): int
+    {
+        $service      = new AutoMarkService();
+        $pendingCount = $service->getPendingUnverifiedCount();
+
+        if ($pendingCount === 0) {
+            $this->stdout("Нет предсказаний для LLM-верификации.\n");
+            return ExitCode::OK;
+        }
+
+        $this->stdout("Запуск LLM-верификации ({$pendingCount} предсказаний)...\n");
+        return $this->runLlmVerify($service);
+    }
+
+    private function runLlmVerify(AutoMarkService $service): int
+    {
+        try {
+            $verified = $service->llmBatchVerify();
+            $this->stdout("LLM верифицировано: {$verified}\n");
+            return ExitCode::OK;
+        } catch (\RuntimeException $e) {
+            $this->stderr("Ошибка LLM-верификации: {$e->getMessage()}\n");
+            return ExitCode::UNSPECIFIED_ERROR;
+        }
+    }
+}
+```
+
+- [ ] **Step 2: Проверить синтаксис**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 php -l /www/commands/AutoMarkController.php
+```
+
+Ожидаем: `No syntax errors detected`
+
+- [ ] **Step 3: Убедиться, что команды регистрируются**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 php yii help auto-mark 2>&1
+```
+
+Ожидаем вывод с `run-new`, `run-product`, `apply`, `verify-llm`.
+
+- [ ] **Step 4: Прогнать все automark-тесты**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 \
+  php vendor/bin/codecept run unit "services/automark" \
+    unit "records/Products1cAutomarkPredictionTest.php" --no-colors
+```
+
+Ожидаем: все зелёные.
+
+- [ ] **Step 5: Коммит**
+
+```bash
+git add erp24/commands/AutoMarkController.php
+git commit -m "feat(ERP-292): консольная команда verify-llm, триггер в run-new"
+```
+
+---
+
+## Task 8: Переменные окружения
+
+**Files:**
+- Modify: `docker/db/dev.db.env`
+
+- [ ] **Step 1: Добавить LLM env-переменные**
+
+В конец `docker/db/dev.db.env` добавить:
+
+```ini
+# LLM Verification (ERP-292)
+LLM_ENDPOINT=http://your-llm-server:11434/v1/chat/completions
+LLM_API_KEY=your-api-key-here
+LLM_MODEL=llama3.2
+LLM_BATCH_SIZE=20
+```
+
+Значения `LLM_ENDPOINT` и `LLM_API_KEY` запросить у команды / взять из инфраструктуры.
+
+- [ ] **Step 2: Проверить, что сервис читает переменные**
+
+```bash
+docker exec yii-erp24-php-yii_erp24-1 php -r "echo getenv('LLM_ENDPOINT') ?: 'NOT SET';"
+```
+
+Ожидаем: URL вашего LLM-сервера (не `NOT SET`).
+
+- [ ] **Step 3: Коммит**
+
+```bash
+git add docker/db/dev.db.env
+git commit -m "feat(ERP-292): env — LLM_ENDPOINT, LLM_API_KEY, LLM_MODEL, LLM_BATCH_SIZE"
+```
+
+---
+
+## Self-Review
+
+### Spec coverage
+
+| Требование | Задача |
+|---|---|
+| LLM-клиент с endpoint + API key из env | Task 4, Task 8 |
+| Батчинг (не по одному) | Task 5 — `array_chunk` по `LLM_BATCH_SIZE` |
+| Триггер: нет новых → верифицировать pending | Task 7 — `actionRunNew()` |
+| Принудительный запуск | Task 7 — `actionVerifyLlm()` |
+| Системный промпт с таксономией | Task 5 — `buildSystemPrompt()` |
+| Обогащение контекста похожими примерами (ILIKE) | Task 5 — `getSimilarExamples()` |
+| Запись вердикта в таблицу (llm_verdict) | Task 1 (миграция), Task 5 (`saveResults()`) |
+| Маркировка "прошло LLM-верификацию" | `llm_verdict` + `llm_verified_at` поля |
+| METHOD_LLM константа | Task 2 |
+
+### Placeholder scan
+
+Найдено: нет TBD/TODO/placeholder.
+
+### Type consistency
+
+- `LlmVerifyResult` создаётся в Task 3 и используется в Task 5 — сигнатуры совпадают.
+- `llmBatchVerify()` / `getPendingUnverifiedCount()` добавляются в Task 6, вызываются в Task 7 — совпадают.
+- `LlmVerifier::verifyPending()` возвращает `int` — совпадает с вызовом в `AutoMarkService`.
index 7051d8f1cd85be0ff360af50eb02058a1d10eea5..f0535f662c6e1be451dd6e68abdd5d87322d7c6e 100644 (file)
@@ -252,6 +252,16 @@ MYSQL_BZ24_DB=bazacvetov24
 # Token for Salebot Google Sheets import endpoint
 SALEBOT_IMPORT_TOKEN=
 
+# === LLM (ERP-292 Automarkup Verification) ===
+# OpenAI-compatible endpoint (e.g. http://ollama:11434/v1, https://api.openai.com/v1)
+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)
 SITE_API_URL=
index 779a1be668729da824e466672b12ab0d58caad37..b5738f9bb1c331e900b3342bb8fc9755a738ba86 100644 (file)
@@ -7,6 +7,7 @@ namespace app\controllers;
 use Yii;
 use yii\web\Controller;
 use yii\web\NotFoundHttpException;
+use yii\web\Response;
 use yii\data\ActiveDataProvider;
 use yii_app\records\Products1cAutomarkPrediction;
 use yii_app\services\AutoMarkService;
@@ -34,7 +35,7 @@ class AutoMarkController extends Controller
         return $this->render('index', ['dataProvider' => $dataProvider]);
     }
 
-    public function actionReview(int $id): string
+    public function actionReview(int $id): string|Response
     {
         $prediction = $this->findPrediction($id);
 
@@ -53,7 +54,7 @@ class AutoMarkController extends Controller
                     }
                     $transaction->commit();
                     Yii::$app->session->setFlash('success', 'Разметка применена.');
-                } catch (\Exception $e) {
+                } catch (\Exception) {
                     $transaction->rollBack();
                     Yii::$app->session->setFlash('error', 'Ошибка при применении разметки.');
                 }
diff --git a/erp24/docs/erp292_automarkup.html b/erp24/docs/erp292_automarkup.html
new file mode 100644 (file)
index 0000000..47797e8
--- /dev/null
@@ -0,0 +1,810 @@
+<!DOCTYPE html>
+<html lang="ru">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>ERP-292 Авторазметка товаров</title>
+<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
+<style>
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.6; }
+
+  .header { background: linear-gradient(135deg, #1a1f2e 0%, #16213e 100%); border-bottom: 1px solid #2d3748; padding: 32px 48px; }
+  .header h1 { font-size: 28px; font-weight: 700; color: #f7fafc; }
+  .header .tag { display: inline-block; background: #2b6cb0; color: #bee3f8; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; margin-left: 12px; vertical-align: middle; }
+  .header p { color: #a0aec0; margin-top: 8px; font-size: 15px; }
+
+  .nav { display: flex; background: #1a1f2e; border-bottom: 1px solid #2d3748; padding: 0 48px; overflow-x: auto; }
+  .nav button { background: none; border: none; color: #a0aec0; padding: 14px 20px; cursor: pointer; font-size: 14px; font-weight: 500; border-bottom: 2px solid transparent; transition: all .2s; white-space: nowrap; }
+  .nav button:hover { color: #e2e8f0; }
+  .nav button.active { color: #63b3ed; border-bottom-color: #63b3ed; }
+
+  .content { max-width: 1200px; margin: 0 auto; padding: 40px 48px; }
+  .section { display: none; }
+  .section.active { display: block; }
+
+  h2 { font-size: 22px; font-weight: 700; color: #f7fafc; margin-bottom: 8px; }
+  h3 { font-size: 16px; font-weight: 600; color: #90cdf4; margin: 28px 0 12px; }
+  p { color: #cbd5e0; margin-bottom: 12px; }
+
+  .card { background: #1a1f2e; border: 1px solid #2d3748; border-radius: 10px; padding: 24px; margin-bottom: 20px; }
+  .card-title { font-size: 14px; font-weight: 700; color: #90cdf4; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 16px; }
+
+  .diagram-wrap { background: #111827; border: 1px solid #2d3748; border-radius: 10px; padding: 32px; margin: 20px 0; overflow-x: auto; }
+  .diagram-wrap .mermaid { display: flex; justify-content: center; }
+
+  .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
+  .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
+  @media (max-width: 900px) { .grid-2, .grid-3 { grid-template-columns: 1fr; } .content { padding: 24px 20px; } }
+
+  table { width: 100%; border-collapse: collapse; font-size: 14px; }
+  th { background: #2d3748; color: #90cdf4; padding: 10px 14px; text-align: left; font-weight: 600; }
+  td { padding: 9px 14px; border-bottom: 1px solid #2d3748; color: #cbd5e0; }
+  tr:hover td { background: #1e2a3a; }
+
+  .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 600; }
+  .badge-yellow { background: #744210; color: #fbd38d; }
+  .badge-green  { background: #1c4532; color: #9ae6b4; }
+  .badge-red    { background: #63171b; color: #feb2b2; }
+  .badge-blue   { background: #1a365d; color: #90cdf4; }
+  .badge-purple { background: #44337a; color: #d6bcfa; }
+
+  code { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 13px; }
+  pre  { background: #0d1117; border: 1px solid #2d3748; border-radius: 8px; padding: 20px; overflow-x: auto; margin: 12px 0; }
+  pre code { color: #d1fae5; }
+
+  .pipeline-step { display: flex; align-items: flex-start; gap: 16px; padding: 16px 0; border-bottom: 1px solid #2d3748; }
+  .pipeline-step:last-child { border-bottom: none; }
+  .step-num { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px; flex-shrink: 0; }
+  .step-body { flex: 1; }
+  .step-title { font-weight: 600; color: #e2e8f0; margin-bottom: 4px; }
+  .step-desc { font-size: 14px; color: #a0aec0; }
+
+  .threshold-bar { height: 8px; background: #2d3748; border-radius: 4px; margin-top: 6px; }
+  .threshold-fill { height: 100%; border-radius: 4px; }
+
+  .file-list { list-style: none; }
+  .file-list li { padding: 8px 0; border-bottom: 1px solid #2d3748; font-size: 14px; display: flex; align-items: center; gap: 10px; }
+  .file-list li:last-child { border-bottom: none; }
+  .file-icon { font-size: 16px; }
+  .file-path { color: #a0aec0; font-family: monospace; font-size: 13px; }
+  .file-desc { color: #cbd5e0; }
+
+  .note { background: #1a2744; border-left: 3px solid #63b3ed; padding: 12px 16px; border-radius: 0 6px 6px 0; margin: 12px 0; font-size: 14px; color: #a0aec0; }
+</style>
+</head>
+<body>
+
+<div class="header">
+  <h1>Авторазметка товаров 1С <span class="tag">ERP-292</span></h1>
+  <p>Автоматическая классификация товаров по категории, виду, цвету, размеру и сорту</p>
+</div>
+
+<div class="nav">
+  <button class="active" onclick="show('overview', this)">Обзор</button>
+  <button onclick="show('pipeline', this)">Пайплайн предсказания</button>
+  <button onclick="show('integration', this)">Интеграция в систему</button>
+  <button onclick="show('database', this)">База данных</button>
+  <button onclick="show('files', this)">Файлы</button>
+  <button onclick="show('workflow', this)">Workflow разметки</button>
+</div>
+
+<div class="content">
+
+<!-- ═══════════════════════════════════════════════════════ OVERVIEW -->
+<div id="overview" class="section active">
+  <h2>Архитектура авторазметки</h2>
+  <p>Система автоматически анализирует названия товаров из 1С и предсказывает их атрибуты. Результаты проходят ревью менеджером перед сохранением в номенклатуру.</p>
+
+  <div class="diagram-wrap">
+    <div class="mermaid">
+graph TB
+    subgraph INPUT["Источники запуска"]
+      CLI["🖥️  CLI<br/>php yii auto-mark/run-new"]
+      QUEUE["📨 RabbitMQ Queue<br/>AutoMarkPredictionJob"]
+      WEB_RUN["🌐 Web (ручной запуск)"]
+    end
+
+    subgraph CORE["AutoMarkService — ядро"]
+      SERVICE["AutoMarkService"]
+      RULE["RuleBasedParser<br/>🔍 поиск по ключевым словам"]
+      SIM["SimilarityMatcher<br/>📐 косинусное сходство"]
+      DTO["ParseResult DTO"]
+    end
+
+    subgraph STORAGE["Хранилище"]
+      DB_PRED["🗄️  products_1c_automark_predictions<br/>pending / approved / rejected"]
+      DB_PRODUCTS["🗄️  products_1c<br/>товары из 1С"]
+      DB_NOM["🗄️  products_1c_nomenclature<br/>размеченная номенклатура"]
+    end
+
+    subgraph REVIEW["Ревью (Web UI)"]
+      LIST["📋 Список предсказаний<br/>/auto-mark/index"]
+      DETAIL["🔎 Форма проверки<br/>/auto-mark/review"]
+    end
+
+    CLI --> SERVICE
+    QUEUE --> SERVICE
+    WEB_RUN --> SERVICE
+    DB_PRODUCTS --> SERVICE
+    SERVICE --> RULE
+    SERVICE --> SIM
+    SIM -- "загружает корпус" --> DB_NOM
+    RULE --> DTO
+    SIM --> DTO
+    DTO --> DB_PRED
+    DB_PRED --> LIST
+    LIST --> DETAIL
+    DETAIL -- "approve" --> DB_NOM
+    DETAIL -- "reject" --> DB_PRED
+
+    style INPUT fill:#1a2744,stroke:#2b6cb0
+    style CORE  fill:#1c2f1c,stroke:#2f855a
+    style STORAGE fill:#2d1515,stroke:#c53030
+    style REVIEW fill:#2d2215,stroke:#c05621
+    </div>
+  </div>
+
+  <div class="grid-2">
+    <div class="card">
+      <div class="card-title">Что делает система</div>
+      <div class="pipeline-step">
+        <div class="step-num" style="background:#2b6cb0;color:#bee3f8">1</div>
+        <div class="step-body">
+          <div class="step-title">Находит размеченные товары</div>
+          <div class="step-desc">Ищет в products_1c товары без записи в products_1c_nomenclature</div>
+        </div>
+      </div>
+      <div class="pipeline-step">
+        <div class="step-num" style="background:#276749;color:#9ae6b4">2</div>
+        <div class="step-body">
+          <div class="step-title">Анализирует название</div>
+          <div class="step-desc">Прогоняет через 2 алгоритма: правила и схожесть</div>
+        </div>
+      </div>
+      <div class="pipeline-step">
+        <div class="step-num" style="background:#6b21a8;color:#d6bcfa">3</div>
+        <div class="step-body">
+          <div class="step-title">Сохраняет предсказание</div>
+          <div class="step-desc">Создаёт запись в products_1c_automark_predictions со статусом pending</div>
+        </div>
+      </div>
+      <div class="pipeline-step">
+        <div class="step-num" style="background:#9c4221;color:#fbd38d">4</div>
+        <div class="step-body">
+          <div class="step-title">Менеджер проверяет</div>
+          <div class="step-desc">В Web UI: approve → запись попадает в номенклатуру</div>
+        </div>
+      </div>
+    </div>
+
+    <div class="card">
+      <div class="card-title">Предсказываемые атрибуты</div>
+      <table>
+        <tr><th>Атрибут</th><th>Пример</th></tr>
+        <tr><td>category</td><td>Срезы / Горшечные / Упаковка</td></tr>
+        <tr><td>subcategory</td><td>Розы голландские</td></tr>
+        <tr><td>species</td><td>Роза / Хризантема / Пион</td></tr>
+        <tr><td>sort</td><td>Premium / Экстра / Select</td></tr>
+        <tr><td>color</td><td>Красная / Белая / Микс</td></tr>
+        <tr><td>size</td><td>50 (в см)</td></tr>
+        <tr><td>type</td><td>— (зарезервировано)</td></tr>
+        <tr><td>confidence</td><td>0.0 – 1.0</td></tr>
+      </table>
+    </div>
+  </div>
+</div>
+
+<!-- ═══════════════════════════════════════════════════════ PIPELINE -->
+<div id="pipeline" class="section">
+  <h2>Пайплайн предсказания</h2>
+  <p>Два алгоритма работают последовательно. Приоритет — у правил (выше точность), fallback — у схожести.</p>
+
+  <div class="diagram-wrap">
+    <div class="mermaid">
+flowchart TD
+    START([Название товара]) --> RULE_PARSE
+
+    subgraph RULES["1. RuleBasedParser"]
+      RULE_PARSE["Привести к нижнему регистру"]
+      RULE_CAT["Определить категорию<br/>горшок/кашпо → Горшечные<br/>пакет/коробка → Упаковка"]
+      RULE_SPEC["Определить вид<br/>роза/пион/лилия/..."]
+      RULE_COLOR["Определить цвет<br/>красная/белая/микс/..."]
+      RULE_SIZE["Извлечь размер<br/>regex: /(\d+)\s*см/"]
+      RULE_SORT["Определить сорт<br/>premium/экстра/select/..."]
+      RULE_CONF["Вычислить confidence<br/>base=0.5, cat+0.2, species+0.15,<br/>color+0.1, size+0.05, sort+0.05"]
+      RULE_PARSE --> RULE_CAT --> RULE_SPEC --> RULE_COLOR --> RULE_SIZE --> RULE_SORT --> RULE_CONF
+    end
+
+    RULE_CONF --> CHECK1{"rule confidence<br/>≥ 0.9?"}
+    CHECK1 -- "✅ Да" --> SAVE
+    CHECK1 -- "❌ Нет" --> SIM_LOAD
+
+    subgraph SIMILARITY["2. SimilarityMatcher"]
+      SIM_LOAD["Загрузить корпус<br/>products_1c_nomenclature<br/>(только размеченные)"]
+      SIM_TOK["Токенизация запроса<br/>lowercase + стоп-слова убрать<br/>числа+см убрать"]
+      SIM_LOOP["Для каждого элемента корпуса:<br/>токенизировать → cosine similarity"]
+      SIM_BEST["Взять лучший результат"]
+      SIM_LOAD --> SIM_TOK --> SIM_LOOP --> SIM_BEST
+    end
+
+    SIM_BEST --> CHECK2{"sim confidence<br/>≥ 0.7?"}
+    CHECK2 -- "✅ Да" --> SAVE
+    CHECK2 -- "❌ Нет" --> FALLBACK{"Есть хоть<br/>какой-то результат?"}
+    FALLBACK -- "Да" --> SAVE
+    FALLBACK -- "Нет" --> NULL([null — товар пропущен])
+
+    SAVE["💾 savePrediction()<br/>→ STATUS_PENDING"]
+
+    style RULES fill:#1a2744,stroke:#2b6cb0
+    style SIMILARITY fill:#1c2f1c,stroke:#2f855a
+    </div>
+  </div>
+
+  <div class="grid-2">
+    <div class="card">
+      <div class="card-title">RuleBasedParser — ключевые слова</div>
+      <h3>Виды (14 позиций)</h3>
+      <p style="font-size:13px; color:#a0aec0">роза, хризантема, гербера, тюльпан, лилия, пион, эустома, альстромерия, гипсофила, ирис, гвоздика, нарцисс, фрезия, лизиантус</p>
+      <h3>Цвета (15 позиций)</h3>
+      <p style="font-size:13px; color:#a0aec0">красная, белая, розовая, желтая, оранжевая, фиолетовая, синяя, кремовая, бордовая, микс, коралловая...</p>
+      <h3>Сорта (8 позиций)</h3>
+      <p style="font-size:13px; color:#a0aec0">premium, экстра, extra, select, стандарт, премиум, супер, элит</p>
+      <div class="note">Если обнаружен вид, но нет категории → категория автоматически = "Срезы"</div>
+    </div>
+
+    <div class="card">
+      <div class="card-title">Расчёт confidence (правила)</div>
+      <table>
+        <tr><th>Признак</th><th>+к score</th></tr>
+        <tr><td>base</td><td>0.50</td></tr>
+        <tr><td>category найдена</td><td>+0.20</td></tr>
+        <tr><td>species найден</td><td>+0.15</td></tr>
+        <tr><td>color найден</td><td>+0.10</td></tr>
+        <tr><td>size найден</td><td>+0.05</td></tr>
+        <tr><td>sort найден</td><td>+0.05</td></tr>
+        <tr><th>Максимум</th><th>1.00</th></tr>
+      </table>
+      <div class="note" style="margin-top:12px">Порог для авто-прохода правил: <strong>≥ 0.9</strong> (нужно знать ≥4 признака)</div>
+
+      <h3>Пример: "Роза Premium 50см Красная"</h3>
+      <table>
+        <tr><td>base</td><td>0.50</td></tr>
+        <tr><td>species: Роза</td><td>+0.15</td></tr>
+        <tr><td>category: Срезы</td><td>+0.20</td></tr>
+        <tr><td>color: Красная</td><td>+0.10</td></tr>
+        <tr><td>size: 50</td><td>+0.05</td></tr>
+        <tr><td>sort: Premium</td><td>+0.05</td></tr>
+        <tr><th>Итого</th><th>1.00 ✅</th></tr>
+      </table>
+    </div>
+  </div>
+
+  <div class="card">
+    <div class="card-title">SimilarityMatcher — косинусное сходство</div>
+    <div class="grid-2">
+      <div>
+        <h3>Алгоритм</h3>
+        <pre><code>// 1. Токенизация
+"Роза красная 50см" → ["роза", "красная"]
+// числа+см удаляются, стоп-слова убираются
+
+// 2. TF-вектор (bag of words)
+{"роза": 1, "красная": 1}
+
+// 3. Cosine similarity с каждым элементом корпуса:
+cos(A,B) = dot(A,B) / (|A| × |B|)
+
+// 4. Взять максимальный score → взять атрибуты этого элемента</code></pre>
+      </div>
+      <div>
+        <h3>Источник корпуса</h3>
+        <p>Все записи из <code>products_1c_nomenclature</code>, у которых поле <code>category IS NOT NULL</code></p>
+        <p>Это уже размеченные вручную или через предыдущие одобренные предсказания товары.</p>
+        <div class="note">Чем больше одобренных разметок — тем точнее работает SimilarityMatcher.</div>
+        <h3>Порог</h3>
+        <p>Минимальный confidence для принятия: <strong>0.7</strong></p>
+        <p>Ниже порога — результат всё равно сохраняется как pending, но с низким confidence для ревью.</p>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- ═══════════════════════════════════════════════════════ INTEGRATION -->
+<div id="integration" class="section">
+  <h2>Интеграция в общий пайплайн</h2>
+  <p>Авторазметка — это отдельный слой обработки, который работает параллельно с основным потоком товаров из 1С.</p>
+
+  <div class="diagram-wrap">
+    <div class="mermaid">
+sequenceDiagram
+    participant 1C as 1С (синхронизация)
+    participant DB as products_1c
+    participant CLI as CLI / Queue
+    participant SVC as AutoMarkService
+    participant PRED as products_1c_automark_predictions
+    participant NOM as products_1c_nomenclature
+    participant MGR as Менеджер (Web UI)
+
+    1C->>DB: Синхронизация новых товаров
+    Note over DB: Новые товары без номенклатуры
+
+    CLI->>SVC: auto-mark/run-new
+    SVC->>DB: SELECT products без номенклатуры
+    DB-->>SVC: [productId, ...]
+
+    loop Для каждого товара
+      SVC->>PRED: Проверить existing pending
+      alt Нет pending
+        SVC->>SVC: runPipeline(product.name)
+        SVC->>PRED: INSERT prediction (status=0)
+      end
+    end
+
+    MGR->>PRED: GET /auto-mark/index (status=pending)
+    PRED-->>MGR: Список предсказаний
+
+    MGR->>MGR: Проверяет каждое предсказание
+    alt Одобрить
+      MGR->>PRED: POST action=approve → status=1
+      MGR->>SVC: applyApprovedPrediction(id)
+      SVC->>NOM: INSERT/UPDATE номенклатура
+    else Отклонить
+      MGR->>PRED: POST action=reject → status=2
+    end
+
+    Note over NOM: Товар теперь размечен<br/>и виден в SimilarityMatcher
+    </div>
+  </div>
+
+  <div class="grid-2">
+    <div class="card">
+      <div class="card-title">Запуск через Queue (Async)</div>
+      <pre><code>// Добавить задачу в очередь:
+Yii::$app->queue->push(
+    new AutoMarkPredictionJob([
+        'productId' => $guid,
+    ])
+);
+
+// Job выполняет:
+public function execute($queue): void
+{
+    (new AutoMarkService())
+        ->predictForProduct($this->productId);
+}</code></pre>
+      <div class="note">Queue используется для обработки по одному товару асинхронно, например при webhook от 1С.</div>
+    </div>
+
+    <div class="card">
+      <div class="card-title">Запуск через CLI (Batch)</div>
+      <pre><code># Разметить все товары без номенклатуры:
+php yii auto-mark/run-new
+
+# Разметить конкретный товар по GUID:
+php yii auto-mark/run-product {GUID}
+
+# Применить одобренное предсказание:
+php yii auto-mark/apply {PRED_ID}</code></pre>
+      <div class="note">CLI удобен для первоначальной массовой разметки или крон-задачи.</div>
+    </div>
+  </div>
+
+  <div class="card">
+    <div class="card-title">Место в системе — диаграмма компонентов</div>
+    <div class="diagram-wrap" style="background:transparent;border:none;padding:0;">
+      <div class="mermaid">
+C4Component
+    title Авторазметка в контексте ERP24
+
+    Container_Boundary(erp, "ERP24 Backend (Yii2)") {
+        Component(sync, "1С Синхронизация", "Yii2 Service", "Загружает товары из 1С в products_1c")
+        Component(automark_svc, "AutoMarkService", "PHP Service", "Пайплайн предсказания атрибутов")
+        Component(automark_ctrl, "AutoMarkController (Web)", "Yii2 Controller", "UI для ревью предсказаний")
+        Component(automark_cmd, "AutoMarkController (CLI)", "Yii2 Console", "Пакетная разметка / apply")
+        Component(automark_job, "AutoMarkPredictionJob", "Yii Queue Job", "Async разметка одного товара")
+        Component(nom_svc, "Сервисы номенклатуры", "PHP Services", "Работа с products_1c_nomenclature")
+    }
+
+    ContainerDb(db, "PostgreSQL / MySQL", "Database", "products_1c, products_1c_nomenclature, products_1c_automark_predictions")
+    Container(queue, "RabbitMQ", "Message Queue", "Очередь задач")
+
+    Rel(sync, db, "Пишет товары")
+    Rel(automark_cmd, automark_svc, "Вызывает")
+    Rel(automark_job, automark_svc, "Вызывает")
+    Rel(automark_ctrl, automark_svc, "Вызывает applyApprovedPrediction")
+    Rel(automark_svc, db, "Читает товары, пишет предсказания, обновляет номенклатуру")
+    Rel(queue, automark_job, "Публикует задачи")
+    Rel(nom_svc, db, "Читает/пишет номенклатуру")
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- ═══════════════════════════════════════════════════════ DATABASE -->
+<div id="database" class="section">
+  <h2>Схема базы данных</h2>
+
+  <div class="diagram-wrap">
+    <div class="mermaid">
+erDiagram
+    products_1c {
+        string id PK "GUID (36 символов)"
+        string name "Название товара"
+        string code "Код товара"
+        int tip "Тип (1=продукт)"
+        timestamp created_at
+    }
+
+    products_1c_nomenclature {
+        string id PK "FK → products_1c.id"
+        string name "Название"
+        string category "Категория"
+        string subcategory "Подкатегория"
+        string species "Вид"
+        string sort "Сорт"
+        string type "Тип"
+        int size "Размер в см"
+        string color "Цвет"
+        string location "Локация"
+        string type_num "Номер типа"
+    }
+
+    products_1c_automark_predictions {
+        int id PK "AUTO_INCREMENT"
+        string product_id "FK → products_1c.id"
+        string category "Предсказанная категория"
+        string subcategory "Предсказанная подкатегория"
+        string species "Предсказанный вид"
+        string sort "Предсказанный сорт"
+        string type "Предсказанный тип"
+        int size "Предсказанный размер"
+        string color "Предсказанный цвет"
+        float confidence "Уверенность 0.0-1.0"
+        string method "rule | similarity"
+        smallint status "0=pending 1=approved 2=rejected"
+        int approved_by "ID пользователя"
+        timestamp created_at
+        timestamp updated_at
+    }
+
+    products_1c ||--o{ products_1c_automark_predictions : "имеет предсказания"
+    products_1c ||--o| products_1c_nomenclature : "имеет номенклатуру"
+    </div>
+  </div>
+
+  <div class="grid-2">
+    <div class="card">
+      <div class="card-title">Индексы таблицы предсказаний</div>
+      <table>
+        <tr><th>Имя индекса</th><th>Колонка</th><th>Цель</th></tr>
+        <tr><td>PRIMARY KEY</td><td>id</td><td>Идентификация записи</td></tr>
+        <tr><td>idx_automark_product_id</td><td>product_id</td><td>Поиск по товару</td></tr>
+        <tr><td>idx_automark_status</td><td>status</td><td>Фильтрация pending/approved</td></tr>
+        <tr><td>fk_automark_product</td><td>product_id → products_1c.id</td><td>CASCADE delete</td></tr>
+      </table>
+    </div>
+
+    <div class="card">
+      <div class="card-title">Статусы предсказания</div>
+      <table>
+        <tr><th>Код</th><th>Константа</th><th>Значение</th></tr>
+        <tr><td><span class="badge badge-yellow">0</span></td><td>STATUS_PENDING</td><td>Ожидает ревью менеджера</td></tr>
+        <tr><td><span class="badge badge-green">1</span></td><td>STATUS_APPROVED</td><td>Одобрено, данные в номенклатуре</td></tr>
+        <tr><td><span class="badge badge-red">2</span></td><td>STATUS_REJECTED</td><td>Отклонено, данные НЕ применены</td></tr>
+      </table>
+      <div class="note" style="margin-top:12px">Один товар может иметь несколько предсказаний, но только одно pending (проверяется в predictForProduct).</div>
+    </div>
+  </div>
+
+  <div class="card">
+    <div class="card-title">Поток данных: apply approved prediction</div>
+    <div class="diagram-wrap" style="background:transparent;border:none;padding:0">
+      <div class="mermaid">
+flowchart LR
+    A["products_1c_automark_predictions<br/>status = APPROVED"] --> B["AutoMarkService<br/>applyApprovedPrediction(id)"]
+    B --> C{"Есть запись в<br/>products_1c_nomenclature?"}
+    C -- "Нет" --> D["CREATE новую запись<br/>nomenclature с данными товара"]
+    C -- "Да" --> E["UPDATE существующую запись<br/>только те поля, что есть в предсказании"]
+    D --> F["Транзакция COMMIT"]
+    E --> F
+    F --> G["✅ Товар размечен"]
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- ═══════════════════════════════════════════════════════ FILES -->
+<div id="files" class="section">
+  <h2>Файлы ветки ERP-292</h2>
+  <p>Все новые файлы, добавленные в рамках задачи авторазметки.</p>
+
+  <div class="grid-2">
+    <div class="card">
+      <div class="card-title">Сервисный слой</div>
+      <ul class="file-list">
+        <li>
+          <span class="file-icon">⚙️</span>
+          <div>
+            <div class="file-desc">Основной сервис — оркестрация пайплайна</div>
+            <div class="file-path">erp24/services/AutoMarkService.php</div>
+          </div>
+        </li>
+        <li>
+          <span class="file-icon">🔍</span>
+          <div>
+            <div class="file-desc">Поиск по ключевым словам</div>
+            <div class="file-path">erp24/services/automark/RuleBasedParser.php</div>
+          </div>
+        </li>
+        <li>
+          <span class="file-icon">📐</span>
+          <div>
+            <div class="file-desc">Косинусное сходство с корпусом</div>
+            <div class="file-path">erp24/services/automark/SimilarityMatcher.php</div>
+          </div>
+        </li>
+        <li>
+          <span class="file-icon">📦</span>
+          <div>
+            <div class="file-desc">DTO результата предсказания</div>
+            <div class="file-path">erp24/services/automark/ParseResult.php</div>
+          </div>
+        </li>
+      </ul>
+    </div>
+
+    <div class="card">
+      <div class="card-title">Точки входа</div>
+      <ul class="file-list">
+        <li>
+          <span class="file-icon">🖥️</span>
+          <div>
+            <div class="file-desc">CLI команды: run-new / run-product / apply</div>
+            <div class="file-path">erp24/commands/AutoMarkController.php</div>
+          </div>
+        </li>
+        <li>
+          <span class="file-icon">📨</span>
+          <div>
+            <div class="file-desc">Queue Job для async разметки</div>
+            <div class="file-path">erp24/jobs/AutoMarkPredictionJob.php</div>
+          </div>
+        </li>
+        <li>
+          <span class="file-icon">🌐</span>
+          <div>
+            <div class="file-desc">Web контроллер (index + review)</div>
+            <div class="file-path">erp24/controllers/AutoMarkController.php</div>
+          </div>
+        </li>
+      </ul>
+    </div>
+
+    <div class="card">
+      <div class="card-title">Модели и Views</div>
+      <ul class="file-list">
+        <li>
+          <span class="file-icon">🗄️</span>
+          <div>
+            <div class="file-desc">ActiveRecord модель предсказаний</div>
+            <div class="file-path">erp24/records/Products1cAutomarkPrediction.php</div>
+          </div>
+        </li>
+        <li>
+          <span class="file-icon">📋</span>
+          <div>
+            <div class="file-desc">Список предсказаний с фильтрацией по статусу</div>
+            <div class="file-path">erp24/views/auto-mark/index.php</div>
+          </div>
+        </li>
+        <li>
+          <span class="file-icon">🔎</span>
+          <div>
+            <div class="file-desc">Форма ревью + кнопки approve/reject</div>
+            <div class="file-path">erp24/views/auto-mark/review.php</div>
+          </div>
+        </li>
+        <li>
+          <span class="file-icon">🗃️</span>
+          <div>
+            <div class="file-desc">Миграция: создание таблицы предсказаний</div>
+            <div class="file-path">erp24/migrations/m260417_000001_create_products_1c_automark_predictions.php</div>
+          </div>
+        </li>
+      </ul>
+    </div>
+
+    <div class="card">
+      <div class="card-title">Тесты</div>
+      <ul class="file-list">
+        <li>
+          <span class="file-icon">🧪</span>
+          <div>
+            <div class="file-desc">Unit тесты сервиса</div>
+            <div class="file-path">erp24/tests/unit/services/AutoMarkServiceTest.php</div>
+          </div>
+        </li>
+        <li>
+          <span class="file-icon">🧪</span>
+          <div>
+            <div class="file-desc">Тесты парсера правил</div>
+            <div class="file-path">erp24/tests/unit/services/automark/RuleBasedParserTest.php</div>
+          </div>
+        </li>
+        <li>
+          <span class="file-icon">🧪</span>
+          <div>
+            <div class="file-desc">Тесты матчера схожести</div>
+            <div class="file-path">erp24/tests/unit/services/automark/SimilarityMatcherTest.php</div>
+          </div>
+        </li>
+        <li>
+          <span class="file-icon">🧪</span>
+          <div>
+            <div class="file-desc">Тесты ActiveRecord модели</div>
+            <div class="file-path">erp24/tests/unit/records/Products1cAutomarkPredictionTest.php</div>
+          </div>
+        </li>
+      </ul>
+    </div>
+  </div>
+</div>
+
+<!-- ═══════════════════════════════════════════════════════ WORKFLOW -->
+<div id="workflow" class="section">
+  <h2>Workflow разметки — жизненный цикл предсказания</h2>
+
+  <div class="diagram-wrap">
+    <div class="mermaid">
+stateDiagram-v2
+    [*] --> Pending : predictForProduct()
+
+    Pending --> Approved : Менеджер нажимает "Применить"
+    Pending --> Rejected : Менеджер нажимает "Отклонить"
+
+    Approved --> NomenclatureUpdated : applyApprovedPrediction()
+    NomenclatureUpdated --> [*] : Товар размечен
+
+    Rejected --> [*] : Предсказание архивируется
+
+    note right of Pending
+        Видно в Web UI на вкладке "Pending"
+        Sorted by confidence DESC
+    end note
+
+    note right of Approved
+        approved_by = user.id
+        Транзакция: UPDATE products_1c_nomenclature
+    end note
+
+    note right of Rejected
+        Остаётся в таблице
+        Не влияет на номенклатуру
+    end note
+    </div>
+  </div>
+
+  <div class="grid-2">
+    <div class="card">
+      <div class="card-title">Web UI — список предсказаний (/auto-mark/index)</div>
+      <p>Страница отображает предсказания из <code>products_1c_automark_predictions</code>, по умолчанию только <span class="badge badge-yellow">pending</span>.</p>
+      <h3>Фильтры по статусу</h3>
+      <table>
+        <tr>
+          <td><span class="badge badge-yellow">Pending</span></td>
+          <td>status=0, ожидают ревью</td>
+        </tr>
+        <tr>
+          <td><span class="badge badge-green">Одобренные</span></td>
+          <td>status=1, применены</td>
+        </tr>
+        <tr>
+          <td><span class="badge badge-red">Отклонённые</span></td>
+          <td>status=2, пропущены</td>
+        </tr>
+      </table>
+      <h3>Сортировка</h3>
+      <p>Сначала показывает самые уверенные предсказания (confidence DESC), потом по дате.</p>
+    </div>
+
+    <div class="card">
+      <div class="card-title">Web UI — форма ревью (/auto-mark/review)</div>
+      <p>Показывает детали товара из 1С и все предсказанные атрибуты.</p>
+      <h3>Метод предсказания</h3>
+      <table>
+        <tr>
+          <td><span class="badge badge-blue">rule</span></td>
+          <td>Распознано по ключевым словам — высокая точность</td>
+        </tr>
+        <tr>
+          <td><span class="badge badge-purple">similarity</span></td>
+          <td>Найдено похожее в корпусе — средняя точность</td>
+        </tr>
+      </table>
+      <h3>Действия (только для pending)</h3>
+      <table>
+        <tr><td>Применить разметку</td><td>approve → запись в номенклатуру</td></tr>
+        <tr><td>Отклонить</td><td>reject → запись пропускается</td></tr>
+      </table>
+    </div>
+  </div>
+
+  <div class="card">
+    <div class="card-title">Защита от дублирования</div>
+    <p>Перед созданием нового предсказания <code>predictForProduct()</code> проверяет наличие существующего <span class="badge badge-yellow">pending</span> для этого товара — и если оно есть, возвращает его, не создавая дубликат.</p>
+    <pre><code>$existing = Products1cAutomarkPrediction::find()
+    ->where(['product_id' => $productId, 'status' => STATUS_PENDING])
+    ->one();
+
+if ($existing !== null) {
+    return $existing;  // Не создаём дубликат
+}</code></pre>
+  </div>
+
+  <div class="card">
+    <div class="card-title">Транзакционная безопасность</div>
+    <p>Операция approve выполняется в транзакции как в Web контроллере, так и в сервисе:</p>
+    <div class="grid-2">
+      <div>
+        <h3>В контроллере</h3>
+        <pre><code>$tx = Yii::$app->db->beginTransaction();
+try {
+    $prediction->status = STATUS_APPROVED;
+    $prediction->save();
+    $service->applyApprovedPrediction($id);
+    $tx->commit();
+} catch (Exception $e) {
+    $tx->rollBack();
+}</code></pre>
+      </div>
+      <div>
+        <h3>В сервисе (applyApprovedPrediction)</h3>
+        <pre><code>$tx = Yii::$app->db->beginTransaction();
+try {
+    // INSERT или UPDATE номенклатуры
+    $nomenclature->save();
+    $tx->commit();
+    return true;
+} catch (Exception $e) {
+    $tx->rollBack();
+    throw $e;
+}</code></pre>
+      </div>
+    </div>
+    <div class="note">Если apply не удался — статус предсказания откатывается обратно в pending.</div>
+  </div>
+</div>
+
+</div><!-- /content -->
+
+<script>
+  mermaid.initialize({
+    startOnLoad: true,
+    theme: 'dark',
+    themeVariables: {
+      primaryColor: '#1a2744',
+      primaryBorderColor: '#2b6cb0',
+      primaryTextColor: '#e2e8f0',
+      secondaryColor: '#1c2f1c',
+      tertiaryColor: '#2d1515',
+      background: '#0f1117',
+      mainBkg: '#1a1f2e',
+      nodeBorder: '#2d3748',
+      lineColor: '#4a5568',
+      textColor: '#e2e8f0',
+      edgeLabelBackground: '#1a1f2e',
+    },
+    flowchart: { htmlLabels: true, curve: 'basis' },
+    sequence: { actorMargin: 50, messageMargin: 35 },
+  });
+
+  function show(id, btn) {
+    document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
+    document.querySelectorAll('.nav button').forEach(b => b.classList.remove('active'));
+    document.getElementById(id).classList.add('active');
+    btn.classList.add('active');
+  }
+</script>
+</body>
+</html>
index 1b104d2d0446bdae9fd7b5d74d74df2064d14f8d..80ea9141856cc43bea08ae1822a68e174007f64f 100644 (file)
@@ -24,6 +24,7 @@ class RuleBasedParser
 
     private const COLOR_KEYWORDS = [
         'красная' => 'Красная', 'красный' => 'Красный',
+        'алая' => 'Алая', 'алый' => 'Алый',
         'белая' => 'Белая', 'белый' => 'Белый',
         'розовая' => 'Розовая', 'розовый' => 'Розовый',
         'желтая' => 'Желтая', 'желтый' => 'Желтый',
@@ -32,7 +33,32 @@ class RuleBasedParser
         'синяя' => 'Синяя', 'синий' => 'Синий',
         'кремовая' => 'Кремовая', 'кремовый' => 'Кремовый',
         'бордовая' => 'Бордовая', 'бордовый' => 'Бордовый',
-        'микс' => 'Микс', 'коралловая' => 'Коралловая',
+        'малиновая' => 'Малиновая', 'малиновый' => 'Малиновый',
+        'сиреневая' => 'Сиреневая', 'сиреневый' => 'Сиреневый',
+        'лиловая' => 'Лиловая', 'лиловый' => 'Лиловый',
+        'персиковая' => 'Персиковая', 'персиковый' => 'Персиковый',
+        'салатовая' => 'Салатовая', 'салатовый' => 'Салатовый',
+        'терракот' => 'Терракот', 'терракотовая' => 'Терракот',
+        'шампань' => 'Шампань', 'шампанское' => 'Шампань',
+        'коралловая' => 'Коралловая', 'коралловый' => 'Коралловый',
+        'мультиколор' => 'Микс', 'микс' => 'Микс',
+    ];
+
+    private const SPECIES_SUBCATEGORY = [
+        'Роза'         => 'Розы',
+        'Гипсофила'    => 'Зелень',
+        'Хризантема'   => 'Цветущие',
+        'Гербера'      => 'Цветущие',
+        'Тюльпан'      => 'Цветущие',
+        'Лилия'        => 'Цветущие',
+        'Пион'         => 'Цветущие',
+        'Эустома'      => 'Цветущие',
+        'Альстромерия' => 'Цветущие',
+        'Ирис'         => 'Цветущие',
+        'Гвоздика'     => 'Цветущие',
+        'Нарцисс'      => 'Цветущие',
+        'Фрезия'       => 'Цветущие',
+        'Лизиантус'    => 'Цветущие',
     ];
 
     private const POTTED_KEYWORDS = ['горшок', 'горшечн', 'кашпо', 'вазон'];
@@ -54,14 +80,16 @@ class RuleBasedParser
         }
 
         if ($species !== null && $category === null) {
-            $category = 'Срезы';
+            $category = 'Срезка';
         }
 
+        $subcategory = $species !== null ? (self::SPECIES_SUBCATEGORY[$species] ?? null) : null;
+
         $confidence = $this->calcConfidence($category, $species, $sort, $color, $size);
 
         return new ParseResult(
             category:    $category,
-            subcategory: null,
+            subcategory: $subcategory,
             species:     $species,
             sort:        $sort,
             type:        null,
@@ -76,7 +104,7 @@ class RuleBasedParser
     {
         foreach (self::POTTED_KEYWORDS as $kw) {
             if (str_contains($nameLower, $kw)) {
-                return 'Горшечные';
+                return 'Горшечные_растения';
             }
         }
         foreach (self::PACKAGING_KEYWORDS as $kw) {
@@ -122,6 +150,13 @@ class RuleBasedParser
         if (preg_match('/(\d+)\s*(?:см|cm|СМ|CM)/iu', $name, $m)) {
             return (int) $m[1];
         }
+        // Bare number in [20, 200] surrounded by spaces — stem height without unit
+        if (preg_match('/(?:^|\s)(\d{2,3})(?:\s|$)/', $name, $m)) {
+            $n = (int) $m[1];
+            if ($n >= 20 && $n <= 200) {
+                return $n;
+            }
+        }
         return null;
     }
 
index ef6e43a049dc33918e8d5d48ee0405e4541f9000..ceb8dc2c8b93163f8abf62d374bd470b96ec1b28 100644 (file)
@@ -32,15 +32,21 @@ class Products1cAutomarkPredictionTest extends Unit
 
     public function testIsPendingMethod(): void
     {
-        $model = new Products1cAutomarkPrediction();
-        $model->status = Products1cAutomarkPrediction::STATUS_PENDING;
+        $model = $this->makeModel(['status' => Products1cAutomarkPrediction::STATUS_PENDING]);
         $this->assertTrue($model->isPending());
     }
 
     public function testIsApprovedMethod(): void
     {
-        $model = new Products1cAutomarkPrediction();
-        $model->status = Products1cAutomarkPrediction::STATUS_APPROVED;
+        $model = $this->makeModel(['status' => Products1cAutomarkPrediction::STATUS_APPROVED]);
         $this->assertTrue($model->isApproved());
     }
+
+    private function makeModel(array $attributes): Products1cAutomarkPrediction
+    {
+        $model = new Products1cAutomarkPrediction();
+        $ref = new \ReflectionProperty(\yii\db\BaseActiveRecord::class, '_attributes');
+        $ref->setValue($model, $attributes);
+        return $model;
+    }
 }
index a4ca346535e9abacc088e0d67de530f1db7a1ea1..02d94f7dfbb45fcd9608d0898e7be6b69ab1c3ef 100644 (file)
@@ -25,7 +25,8 @@ class RuleBasedParserTest extends Unit
         $result = $this->parser->parse('Роза Premium 50см Красная');
 
         $this->assertInstanceOf(ParseResult::class, $result);
-        $this->assertSame('Срезы', $result->category);
+        $this->assertSame('Срезка', $result->category);
+        $this->assertSame('Розы', $result->subcategory);
         $this->assertSame('Роза', $result->species);
         $this->assertSame('Premium', $result->sort);
         $this->assertSame(50, $result->size);
@@ -38,7 +39,7 @@ class RuleBasedParserTest extends Unit
     {
         $result = $this->parser->parse('Орхидея Фаленопсис горшок 12');
 
-        $this->assertSame('Горшечные', $result->category);
+        $this->assertSame('Горшечные_растения', $result->category);
         $this->assertGreaterThanOrEqual(0.7, $result->confidence);
     }
 
@@ -70,7 +71,26 @@ class RuleBasedParserTest extends Unit
     {
         $result = $this->parser->parse('Хризантема Белая 60см');
 
-        $this->assertSame('Срезы', $result->category);
+        $this->assertSame('Срезка', $result->category);
+        $this->assertSame('Цветущие', $result->subcategory);
         $this->assertSame('Хризантема', $result->species);
     }
+
+    public function testDetectsBareNumberAsStemHeight(): void
+    {
+        $result = $this->parser->parse('Роза Эквадор 80 алая');
+
+        $this->assertSame(80, $result->size);
+        $this->assertSame('Алая', $result->color);
+        $this->assertSame('Роза', $result->species);
+        $this->assertSame('Розы', $result->subcategory);
+    }
+
+    public function testDoesNotConfuseDimensionStringWithSize(): void
+    {
+        $result = $this->parser->parse('Пакет целлофановый 60х80');
+
+        $this->assertSame('Упаковка', $result->category);
+        $this->assertNull($result->size);
+    }
 }
diff --git a/llm-integration copy.md b/llm-integration copy.md
new file mode 100644 (file)
index 0000000..ef1639b
--- /dev/null
@@ -0,0 +1,252 @@
+# LLM Integration Guide
+
+> Для новых текстовых интеграций используйте `POST /v1/llm-analyze`.  
+> `POST /v1/vlm-analyze` оставлен как legacy alias для совместимости.
+
+## Назначение
+
+Coordinator принимает текстовый запрос от внешней системы, проверяет `X-API-Key`, проксирует запрос на GPU worker и возвращает ответ модели.
+
+Подходит для:
+- ERP: классификация товара, разбор названий, выделение атрибутов, нормализация в JSON
+- Transcribe: анализ смен, анализ диалогов, факт продажи, качество обслуживания, структура разговора
+
+## Endpoint
+
+```http
+POST /v1/llm-analyze
+X-API-Key: <service-key>
+Content-Type: application/json
+```
+
+## Auth
+
+Используются отдельные входящие ключи:
+
+- `TRANSCRIBE_VLM_API_KEY` для запросов из Transcribe
+- `ERP_VLM_API_KEY` для запросов из ERP
+
+Внутренний вызов Coordinator -> Worker защищается Bearer token:
+
+- `CV_WORKER_INTERNAL_TOKEN` на координаторе
+- `WORKER_INTERNAL_TOKEN` на воркере
+
+Рекомендуется дополнительно ограничить доступ к Coordinator по IP / WireGuard / reverse proxy allowlist.
+
+## Request Contract
+
+```json
+{
+  "store_id": "shop01",
+  "system_prompt": "Return valid JSON only.",
+  "prompt": "Analyze the transcript and determine whether a sale happened.",
+  "structured_output": true
+}
+```
+
+Поля:
+
+| Поле | Обязательное | Тип | Описание |
+|------|--------------|-----|----------|
+| `prompt` | да | string | Основной пользовательский prompt |
+| `system_prompt` | нет | string | Системные инструкции для модели |
+| `store_id` | нет | string | Нужен, если логика запроса привязана к магазину |
+| `structured_output` | нет | bool | Сейчас информационное поле; формат ответа фактически задаётся prompt-ом |
+
+## Response Contract
+
+```json
+{
+  "ok": true,
+  "store_id": "shop01",
+  "response_text": "{\"has_sales\": true, \"confidence\": 0.91}",
+  "response_json": {
+    "has_sales": true,
+    "confidence": 0.91
+  },
+  "vlm_model": "google/gemma-3-12b-it",
+  "latency_ms": 842
+}
+```
+
+Поля:
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `ok` | bool | Статус обработки |
+| `store_id` | string \| null | Возвращается как есть из запроса |
+| `response_text` | string | Сырой текст модели |
+| `response_json` | object \| null | Распарсенный JSON, если модель его вернула |
+| `vlm_model` | string \| null | Модель, которая ответила |
+| `latency_ms` | int \| null | Время ответа модели |
+
+## ERP Integration
+
+### Когда вызывать
+
+Вызывать после получения или изменения названия товара, если нужно нормализовать карточку товара или выделить атрибуты.
+
+### Пример запроса
+
+```http
+POST /v1/llm-analyze
+X-API-Key: <ERP_VLM_API_KEY>
+Content-Type: application/json
+```
+
+```json
+{
+  "system_prompt": "You are a retail taxonomy classifier. Return valid JSON only.",
+  "prompt": "Classify flower item into JSON with keys: category, subcategory, flower_type, color, stem_length_cm, quantity, confidence. Input item: 'Роза Эквадор микс 70 см 25 шт'.",
+  "structured_output": true
+}
+```
+
+### Пример ответа
+
+```json
+{
+  "ok": true,
+  "store_id": null,
+  "response_text": "{\"category\":\"flowers\",\"subcategory\":\"rose\",\"flower_type\":\"ecuador_rose\",\"color\":\"mix\",\"stem_length_cm\":70,\"quantity\":25,\"confidence\":0.94}",
+  "response_json": {
+    "category": "flowers",
+    "subcategory": "rose",
+    "flower_type": "ecuador_rose",
+    "color": "mix",
+    "stem_length_cm": 70,
+    "quantity": 25,
+    "confidence": 0.94
+  },
+  "vlm_model": "google/gemma-3-12b-it",
+  "latency_ms": 615
+}
+```
+
+### Рекомендации для ERP
+
+- Формулируйте JSON schema прямо в prompt.
+- Требуйте `Return valid JSON only`.
+- Не отправляйте один giant prompt на тысячи товаров; лучше 1 товар = 1 запрос.
+- На своей стороне валидируйте `response_json` по ожидаемой схеме.
+- Если пришёл только `response_text`, пытайтесь fallback-парсить JSON локально и логируйте отклонения.
+
+## Transcribe Integration
+
+### Когда вызывать
+
+Вызывать после подготовки структурированного текста по смене или по отдельному диалогу.
+
+### Пример запроса для анализа смены
+
+```http
+POST /v1/llm-analyze
+X-API-Key: <TRANSCRIBE_VLM_API_KEY>
+Content-Type: application/json
+```
+
+```json
+{
+  "store_id": "shop01",
+  "system_prompt": "You analyze flower shop sales dialogs. Return valid JSON only.",
+  "prompt": "Analyze shift conversations. Return JSON with keys: has_sales, sales_count, dialogs_with_sales, objections, sentiment, manager_score, summary. Data: [{\"dialog_id\":\"d1\",\"timestamps\":[[0,12],[13,40]],\"transcript\":\"...\"},{\"dialog_id\":\"d2\",\"timestamps\":[[0,20]],\"transcript\":\"...\"}]",
+  "structured_output": true
+}
+```
+
+### Пример ответа
+
+```json
+{
+  "ok": true,
+  "store_id": "shop01",
+  "response_text": "{\"has_sales\":true,\"sales_count\":3,\"dialogs_with_sales\":[\"d1\",\"d4\",\"d7\"],\"objections\":[\"price\"],\"sentiment\":\"positive\",\"manager_score\":0.82,\"summary\":\"Three sales conversations identified during the shift.\"}",
+  "response_json": {
+    "has_sales": true,
+    "sales_count": 3,
+    "dialogs_with_sales": ["d1", "d4", "d7"],
+    "objections": ["price"],
+    "sentiment": "positive",
+    "manager_score": 0.82,
+    "summary": "Three sales conversations identified during the shift."
+  },
+  "vlm_model": "google/gemma-3-12b-it",
+  "latency_ms": 1094
+}
+```
+
+### Рекомендации для Transcribe
+
+- Передавайте уже очищенный и структурированный текст, а не сырой ASR-мусор.
+- Если payload большой, режьте по диалогам или по сменным блокам.
+- Всегда включайте список ключей, которые модель обязана вернуть.
+- Для аналитики смен лучше добавлять `dialog_id`, `speaker`, `timestamp`, чтобы потом связывать ответ с исходными данными.
+
+## Errors
+
+Типовые ответы:
+
+```json
+{
+  "detail": {
+    "type": "https://example.com/errors/invalid-api-key",
+    "title": "Invalid API key",
+    "status": 401
+  }
+}
+```
+
+```json
+{
+  "detail": {
+    "type": "https://example.com/errors/worker-unreachable",
+    "title": "Worker unreachable",
+    "status": 502,
+    "detail": "..."
+  }
+}
+```
+
+```json
+{
+  "ok": false,
+  "error": "All VLM backends exhausted. Last error: ...",
+  "code": "VLM_UNAVAILABLE"
+}
+```
+
+## Rollout Checklist
+
+1. Сгенерировать и записать ключи `TRANSCRIBE_VLM_API_KEY`, `ERP_VLM_API_KEY`.
+2. Настроить `CV_WORKER_INTERNAL_TOKEN` и `WORKER_INTERNAL_TOKEN`.
+3. Перезапустить coordinator и worker.
+4. Проверить smoke request в ERP.
+5. Проверить smoke request в Transcribe.
+6. Включить логирование входящих ошибок на стороне интегрируемых систем.
+
+## Smoke Examples
+
+### ERP smoke test
+
+```bash
+curl -X POST http://<coordinator-host>:8000/v1/llm-analyze \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: <ERP_VLM_API_KEY>' \
+  -d '{
+    "system_prompt": "Return valid JSON only.",
+    "prompt": "Classify item into JSON: category, subcategory. Item: Роза красная 60 см"
+  }'
+```
+
+### Transcribe smoke test
+
+```bash
+curl -X POST http://<coordinator-host>:8000/v1/llm-analyze \
+  -H 'Content-Type: application/json' \
+  -H 'X-API-Key: <TRANSCRIBE_VLM_API_KEY>' \
+  -d '{
+    "store_id": "shop01",
+    "system_prompt": "Return valid JSON only.",
+    "prompt": "Analyze transcript and return JSON with has_sales and summary."
+  }'
+```