From 5c6fee9cb4ad1a56d60bfc80ac4e3c3542889289 Mon Sep 17 00:00:00 2001 From: fomichev Date: Mon, 4 May 2026 11:31:10 +0300 Subject: [PATCH] llm verification --- .../2026-04-17-erp292-automark-ml-engine.md | 1520 +++++++++++++++++ .../plans/2026-04-17-erp292-manual-testing.md | 176 ++ .../2026-04-30-llm-automark-verification.md | 1057 ++++++++++++ erp24/.env.example | 10 + erp24/controllers/AutoMarkController.php | 5 +- erp24/docs/erp292_automarkup.html | 810 +++++++++ erp24/services/automark/RuleBasedParser.php | 43 +- .../Products1cAutomarkPredictionTest.php | 14 +- .../services/automark/RuleBasedParserTest.php | 26 +- llm-integration copy.md | 252 +++ 10 files changed, 3900 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-17-erp292-automark-ml-engine.md create mode 100644 docs/superpowers/plans/2026-04-17-erp292-manual-testing.md create mode 100644 docs/superpowers/plans/2026-04-30-llm-automark-verification.md create mode 100644 erp24/docs/erp292_automarkup.html create mode 100644 llm-integration copy.md 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 index 00000000..cbb36c75 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-erp292-automark-ml-engine.md @@ -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 +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 +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 + 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 +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 +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 + 'Роза', 'хризантема' => 'Хризантема', 'гербера' => 'Гербера', + 'тюльпан' => 'Тюльпан', 'лилия' => 'Лилия', 'пион' => 'Пион', + 'эустома' => 'Эустома', 'альстромерия' => 'Альстромерия', + 'гипсофила' => 'Гипсофила', 'ирис' => 'Ирис', 'гвоздика' => 'Гвоздика', + 'нарцисс' => 'Нарцисс', 'фрезия' => 'Фрезия', 'лизиантус' => 'Лизиантус', + ]; + + 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 +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 +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 +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 +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 +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 +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 +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 +title = 'Авторазметка товаров'; +?> +
+

title) ?>

+ +
+ Products1cAutomarkPrediction::STATUS_PENDING], ['class' => 'btn btn-warning']) ?> + Products1cAutomarkPrediction::STATUS_APPROVED], ['class' => 'btn btn-success']) ?> + Products1cAutomarkPrediction::STATUS_REJECTED], ['class' => 'btn btn-secondary']) ?> +
+ + $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']), + ], + ], + ]) ?> +
+``` + +- [ ] **Step 3: Создать view/auto-mark/review.php** + +```php +title = 'Проверка разметки #' . $prediction->id; +?> +
+

title) ?>

+ +
+
Товар из 1С
+
+

GUID: product_id) ?>

+

Название: product?->name ?? '—') ?>

+

Код: product?->code ?? '—') ?>

+
+
+ +
+
+ Предсказание + — метод: method) ?>, + уверенность: confidence * 100, 1) ?>% +
+
+ + + + + + + + +
Категорияcategory ?? '—') ?>
Подкатегорияsubcategory ?? '—') ?>
Видspecies ?? '—') ?>
Сортsort ?? '—') ?>
Типtype ?? '—') ?>
Размерsize ?? '—' ?>
Цветcolor ?? '—') ?>
+
+
+ + isPending()): ?> +
+ $prediction->id], 'post') ?> + + 'btn btn-success']) ?> + + + $prediction->id], 'post') ?> + + 'btn btn-danger']) ?> + +
+ + +
+ 'btn btn-secondary']) ?> +
+
+``` + +- [ ] **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 index 00000000..442306b1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-erp292-manual-testing.md @@ -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` строку вида `` тестовой записи +2. Запустить разметку, открыть список в UI + +**Ожидаемый результат:** +- Текст отображается как `<script>alert(1)</script>` — без исполнения 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 index 00000000..24541d60 --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-llm-automark-verification.md @@ -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 +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 + 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 +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 +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 +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 +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 +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 <<SYSTEM_PROMPT_TAXONOMY} + + ЗАДАЧА: Для каждого товара в массиве: + 1. Проверь соответствие разметки (category, subcategory, species, size, color) названию товара. + 2. Используй similar_examples как эталон правильной разметки. + 3. Вынеси вердикт: approved (разметка верна) или rejected (есть ошибки). + + ОТВЕТ — только JSON-массив, без пояснений вне него: + [{"id":,"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 <<,"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 +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`. diff --git a/erp24/.env.example b/erp24/.env.example index 7051d8f1..f0535f66 100644 --- a/erp24/.env.example +++ b/erp24/.env.example @@ -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= diff --git a/erp24/controllers/AutoMarkController.php b/erp24/controllers/AutoMarkController.php index 779a1be6..b5738f9b 100644 --- a/erp24/controllers/AutoMarkController.php +++ b/erp24/controllers/AutoMarkController.php @@ -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 index 00000000..47797e8b --- /dev/null +++ b/erp24/docs/erp292_automarkup.html @@ -0,0 +1,810 @@ + + + + + +ERP-292 Авторазметка товаров + + + + + +
+

Авторазметка товаров 1С ERP-292

+

Автоматическая классификация товаров по категории, виду, цвету, размеру и сорту

+
+ + + +
+ + +
+

Архитектура авторазметки

+

Система автоматически анализирует названия товаров из 1С и предсказывает их атрибуты. Результаты проходят ревью менеджером перед сохранением в номенклатуру.

+ +
+
+graph TB + subgraph INPUT["Источники запуска"] + CLI["🖥️ CLI
php yii auto-mark/run-new"] + QUEUE["📨 RabbitMQ Queue
AutoMarkPredictionJob"] + WEB_RUN["🌐 Web (ручной запуск)"] + end + + subgraph CORE["AutoMarkService — ядро"] + SERVICE["AutoMarkService"] + RULE["RuleBasedParser
🔍 поиск по ключевым словам"] + SIM["SimilarityMatcher
📐 косинусное сходство"] + DTO["ParseResult DTO"] + end + + subgraph STORAGE["Хранилище"] + DB_PRED["🗄️ products_1c_automark_predictions
pending / approved / rejected"] + DB_PRODUCTS["🗄️ products_1c
товары из 1С"] + DB_NOM["🗄️ products_1c_nomenclature
размеченная номенклатура"] + end + + subgraph REVIEW["Ревью (Web UI)"] + LIST["📋 Список предсказаний
/auto-mark/index"] + DETAIL["🔎 Форма проверки
/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 +
+
+ +
+
+
Что делает система
+
+
1
+
+
Находит размеченные товары
+
Ищет в products_1c товары без записи в products_1c_nomenclature
+
+
+
+
2
+
+
Анализирует название
+
Прогоняет через 2 алгоритма: правила и схожесть
+
+
+
+
3
+
+
Сохраняет предсказание
+
Создаёт запись в products_1c_automark_predictions со статусом pending
+
+
+
+
4
+
+
Менеджер проверяет
+
В Web UI: approve → запись попадает в номенклатуру
+
+
+
+ +
+
Предсказываемые атрибуты
+ + + + + + + + + + +
АтрибутПример
categoryСрезы / Горшечные / Упаковка
subcategoryРозы голландские
speciesРоза / Хризантема / Пион
sortPremium / Экстра / Select
colorКрасная / Белая / Микс
size50 (в см)
type— (зарезервировано)
confidence0.0 – 1.0
+
+
+
+ + +
+

Пайплайн предсказания

+

Два алгоритма работают последовательно. Приоритет — у правил (выше точность), fallback — у схожести.

+ +
+
+flowchart TD + START([Название товара]) --> RULE_PARSE + + subgraph RULES["1. RuleBasedParser"] + RULE_PARSE["Привести к нижнему регистру"] + RULE_CAT["Определить категорию
горшок/кашпо → Горшечные
пакет/коробка → Упаковка"] + RULE_SPEC["Определить вид
роза/пион/лилия/..."] + RULE_COLOR["Определить цвет
красная/белая/микс/..."] + RULE_SIZE["Извлечь размер
regex: /(\d+)\s*см/"] + RULE_SORT["Определить сорт
premium/экстра/select/..."] + RULE_CONF["Вычислить confidence
base=0.5, cat+0.2, species+0.15,
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
≥ 0.9?"} + CHECK1 -- "✅ Да" --> SAVE + CHECK1 -- "❌ Нет" --> SIM_LOAD + + subgraph SIMILARITY["2. SimilarityMatcher"] + SIM_LOAD["Загрузить корпус
products_1c_nomenclature
(только размеченные)"] + SIM_TOK["Токенизация запроса
lowercase + стоп-слова убрать
числа+см убрать"] + SIM_LOOP["Для каждого элемента корпуса:
токенизировать → cosine similarity"] + SIM_BEST["Взять лучший результат"] + SIM_LOAD --> SIM_TOK --> SIM_LOOP --> SIM_BEST + end + + SIM_BEST --> CHECK2{"sim confidence
≥ 0.7?"} + CHECK2 -- "✅ Да" --> SAVE + CHECK2 -- "❌ Нет" --> FALLBACK{"Есть хоть
какой-то результат?"} + FALLBACK -- "Да" --> SAVE + FALLBACK -- "Нет" --> NULL([null — товар пропущен]) + + SAVE["💾 savePrediction()
→ STATUS_PENDING"] + + style RULES fill:#1a2744,stroke:#2b6cb0 + style SIMILARITY fill:#1c2f1c,stroke:#2f855a +
+
+ +
+
+
RuleBasedParser — ключевые слова
+

Виды (14 позиций)

+

роза, хризантема, гербера, тюльпан, лилия, пион, эустома, альстромерия, гипсофила, ирис, гвоздика, нарцисс, фрезия, лизиантус

+

Цвета (15 позиций)

+

красная, белая, розовая, желтая, оранжевая, фиолетовая, синяя, кремовая, бордовая, микс, коралловая...

+

Сорта (8 позиций)

+

premium, экстра, extra, select, стандарт, премиум, супер, элит

+
Если обнаружен вид, но нет категории → категория автоматически = "Срезы"
+
+ +
+
Расчёт confidence (правила)
+ + + + + + + + + +
Признак+к score
base0.50
category найдена+0.20
species найден+0.15
color найден+0.10
size найден+0.05
sort найден+0.05
Максимум1.00
+
Порог для авто-прохода правил: ≥ 0.9 (нужно знать ≥4 признака)
+ +

Пример: "Роза Premium 50см Красная"

+ + + + + + + + +
base0.50
species: Роза+0.15
category: Срезы+0.20
color: Красная+0.10
size: 50+0.05
sort: Premium+0.05
Итого1.00 ✅
+
+
+ +
+
SimilarityMatcher — косинусное сходство
+
+
+

Алгоритм

+
// 1. Токенизация
+"Роза красная 50см" → ["роза", "красная"]
+// числа+см удаляются, стоп-слова убираются
+
+// 2. TF-вектор (bag of words)
+{"роза": 1, "красная": 1}
+
+// 3. Cosine similarity с каждым элементом корпуса:
+cos(A,B) = dot(A,B) / (|A| × |B|)
+
+// 4. Взять максимальный score → взять атрибуты этого элемента
+
+
+

Источник корпуса

+

Все записи из products_1c_nomenclature, у которых поле category IS NOT NULL

+

Это уже размеченные вручную или через предыдущие одобренные предсказания товары.

+
Чем больше одобренных разметок — тем точнее работает SimilarityMatcher.
+

Порог

+

Минимальный confidence для принятия: 0.7

+

Ниже порога — результат всё равно сохраняется как pending, но с низким confidence для ревью.

+
+
+
+
+ + +
+

Интеграция в общий пайплайн

+

Авторазметка — это отдельный слой обработки, который работает параллельно с основным потоком товаров из 1С.

+ +
+
+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: Товар теперь размечен
и виден в SimilarityMatcher +
+
+ +
+
+
Запуск через Queue (Async)
+
// Добавить задачу в очередь:
+Yii::$app->queue->push(
+    new AutoMarkPredictionJob([
+        'productId' => $guid,
+    ])
+);
+
+// Job выполняет:
+public function execute($queue): void
+{
+    (new AutoMarkService())
+        ->predictForProduct($this->productId);
+}
+
Queue используется для обработки по одному товару асинхронно, например при webhook от 1С.
+
+ +
+
Запуск через CLI (Batch)
+
# Разметить все товары без номенклатуры:
+php yii auto-mark/run-new
+
+# Разметить конкретный товар по GUID:
+php yii auto-mark/run-product {GUID}
+
+# Применить одобренное предсказание:
+php yii auto-mark/apply {PRED_ID}
+
CLI удобен для первоначальной массовой разметки или крон-задачи.
+
+
+ +
+
Место в системе — диаграмма компонентов
+
+
+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, "Читает/пишет номенклатуру") +
+
+
+
+ + +
+

Схема базы данных

+ +
+
+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 : "имеет номенклатуру" +
+
+ +
+
+
Индексы таблицы предсказаний
+ + + + + + +
Имя индексаКолонкаЦель
PRIMARY KEYidИдентификация записи
idx_automark_product_idproduct_idПоиск по товару
idx_automark_statusstatusФильтрация pending/approved
fk_automark_productproduct_id → products_1c.idCASCADE delete
+
+ +
+
Статусы предсказания
+ + + + + +
КодКонстантаЗначение
0STATUS_PENDINGОжидает ревью менеджера
1STATUS_APPROVEDОдобрено, данные в номенклатуре
2STATUS_REJECTEDОтклонено, данные НЕ применены
+
Один товар может иметь несколько предсказаний, но только одно pending (проверяется в predictForProduct).
+
+
+ +
+
Поток данных: apply approved prediction
+
+
+flowchart LR + A["products_1c_automark_predictions
status = APPROVED"] --> B["AutoMarkService
applyApprovedPrediction(id)"] + B --> C{"Есть запись в
products_1c_nomenclature?"} + C -- "Нет" --> D["CREATE новую запись
nomenclature с данными товара"] + C -- "Да" --> E["UPDATE существующую запись
только те поля, что есть в предсказании"] + D --> F["Транзакция COMMIT"] + E --> F + F --> G["✅ Товар размечен"] +
+
+
+
+ + +
+

Файлы ветки ERP-292

+

Все новые файлы, добавленные в рамках задачи авторазметки.

+ +
+
+
Сервисный слой
+
    +
  • + ⚙️ +
    +
    Основной сервис — оркестрация пайплайна
    +
    erp24/services/AutoMarkService.php
    +
    +
  • +
  • + 🔍 +
    +
    Поиск по ключевым словам
    +
    erp24/services/automark/RuleBasedParser.php
    +
    +
  • +
  • + 📐 +
    +
    Косинусное сходство с корпусом
    +
    erp24/services/automark/SimilarityMatcher.php
    +
    +
  • +
  • + 📦 +
    +
    DTO результата предсказания
    +
    erp24/services/automark/ParseResult.php
    +
    +
  • +
+
+ +
+
Точки входа
+
    +
  • + 🖥️ +
    +
    CLI команды: run-new / run-product / apply
    +
    erp24/commands/AutoMarkController.php
    +
    +
  • +
  • + 📨 +
    +
    Queue Job для async разметки
    +
    erp24/jobs/AutoMarkPredictionJob.php
    +
    +
  • +
  • + 🌐 +
    +
    Web контроллер (index + review)
    +
    erp24/controllers/AutoMarkController.php
    +
    +
  • +
+
+ +
+
Модели и Views
+
    +
  • + 🗄️ +
    +
    ActiveRecord модель предсказаний
    +
    erp24/records/Products1cAutomarkPrediction.php
    +
    +
  • +
  • + 📋 +
    +
    Список предсказаний с фильтрацией по статусу
    +
    erp24/views/auto-mark/index.php
    +
    +
  • +
  • + 🔎 +
    +
    Форма ревью + кнопки approve/reject
    +
    erp24/views/auto-mark/review.php
    +
    +
  • +
  • + 🗃️ +
    +
    Миграция: создание таблицы предсказаний
    +
    erp24/migrations/m260417_000001_create_products_1c_automark_predictions.php
    +
    +
  • +
+
+ +
+
Тесты
+
    +
  • + 🧪 +
    +
    Unit тесты сервиса
    +
    erp24/tests/unit/services/AutoMarkServiceTest.php
    +
    +
  • +
  • + 🧪 +
    +
    Тесты парсера правил
    +
    erp24/tests/unit/services/automark/RuleBasedParserTest.php
    +
    +
  • +
  • + 🧪 +
    +
    Тесты матчера схожести
    +
    erp24/tests/unit/services/automark/SimilarityMatcherTest.php
    +
    +
  • +
  • + 🧪 +
    +
    Тесты ActiveRecord модели
    +
    erp24/tests/unit/records/Products1cAutomarkPredictionTest.php
    +
    +
  • +
+
+
+
+ + +
+

Workflow разметки — жизненный цикл предсказания

+ +
+
+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 +
+
+ +
+
+
Web UI — список предсказаний (/auto-mark/index)
+

Страница отображает предсказания из products_1c_automark_predictions, по умолчанию только pending.

+

Фильтры по статусу

+ + + + + + + + + + + + + +
Pendingstatus=0, ожидают ревью
Одобренныеstatus=1, применены
Отклонённыеstatus=2, пропущены
+

Сортировка

+

Сначала показывает самые уверенные предсказания (confidence DESC), потом по дате.

+
+ +
+
Web UI — форма ревью (/auto-mark/review)
+

Показывает детали товара из 1С и все предсказанные атрибуты.

+

Метод предсказания

+ + + + + + + + + +
ruleРаспознано по ключевым словам — высокая точность
similarityНайдено похожее в корпусе — средняя точность
+

Действия (только для pending)

+ + + +
Применить разметкуapprove → запись в номенклатуру
Отклонитьreject → запись пропускается
+
+
+ +
+
Защита от дублирования
+

Перед созданием нового предсказания predictForProduct() проверяет наличие существующего pending для этого товара — и если оно есть, возвращает его, не создавая дубликат.

+
$existing = Products1cAutomarkPrediction::find()
+    ->where(['product_id' => $productId, 'status' => STATUS_PENDING])
+    ->one();
+
+if ($existing !== null) {
+    return $existing;  // Не создаём дубликат
+}
+
+ +
+
Транзакционная безопасность
+

Операция approve выполняется в транзакции как в Web контроллере, так и в сервисе:

+
+
+

В контроллере

+
$tx = Yii::$app->db->beginTransaction();
+try {
+    $prediction->status = STATUS_APPROVED;
+    $prediction->save();
+    $service->applyApprovedPrediction($id);
+    $tx->commit();
+} catch (Exception $e) {
+    $tx->rollBack();
+}
+
+
+

В сервисе (applyApprovedPrediction)

+
$tx = Yii::$app->db->beginTransaction();
+try {
+    // INSERT или UPDATE номенклатуры
+    $nomenclature->save();
+    $tx->commit();
+    return true;
+} catch (Exception $e) {
+    $tx->rollBack();
+    throw $e;
+}
+
+
+
Если apply не удался — статус предсказания откатывается обратно в pending.
+
+
+ +
+ + + + diff --git a/erp24/services/automark/RuleBasedParser.php b/erp24/services/automark/RuleBasedParser.php index 1b104d2d..80ea9141 100644 --- a/erp24/services/automark/RuleBasedParser.php +++ b/erp24/services/automark/RuleBasedParser.php @@ -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; } diff --git a/erp24/tests/unit/records/Products1cAutomarkPredictionTest.php b/erp24/tests/unit/records/Products1cAutomarkPredictionTest.php index ef6e43a0..ceb8dc2c 100644 --- a/erp24/tests/unit/records/Products1cAutomarkPredictionTest.php +++ b/erp24/tests/unit/records/Products1cAutomarkPredictionTest.php @@ -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; + } } diff --git a/erp24/tests/unit/services/automark/RuleBasedParserTest.php b/erp24/tests/unit/services/automark/RuleBasedParserTest.php index a4ca3465..02d94f7d 100644 --- a/erp24/tests/unit/services/automark/RuleBasedParserTest.php +++ b/erp24/tests/unit/services/automark/RuleBasedParserTest.php @@ -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 index 00000000..ef1639bd --- /dev/null +++ b/llm-integration copy.md @@ -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: +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: +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: +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://:8000/v1/llm-analyze \ + -H 'Content-Type: application/json' \ + -H 'X-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://:8000/v1/llm-analyze \ + -H 'Content-Type: application/json' \ + -H 'X-API-Key: ' \ + -d '{ + "store_id": "shop01", + "system_prompt": "Return valid JSON only.", + "prompt": "Analyze transcript and return JSON with has_sales and summary." + }' +``` -- 2.39.5