]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
feat(ERP-292): LlmVerifier — батчинг, обогащение примерами, парсинг вердиктов
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Thu, 30 Apr 2026 15:22:30 +0000 (18:22 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Thu, 30 Apr 2026 15:22:30 +0000 (18:22 +0300)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
erp24/services/automark/LlmVerifier.php [new file with mode: 0644]
erp24/tests/unit/services/automark/LlmVerifierTest.php [new file with mode: 0644]

diff --git a/erp24/services/automark/LlmVerifier.php b/erp24/services/automark/LlmVerifier.php
new file mode 100644 (file)
index 0000000..33e84e8
--- /dev/null
@@ -0,0 +1,182 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+use yii_app\records\Products1cAutomarkPrediction;
+use yii_app\records\Products1cNomenclature;
+
+class LlmVerifier
+{
+    private const TAXONOMY = <<<'TAXONOMY'
+ТАКСОНОМИЯ КАТЕГОРИЙ (используй точные названия):
+- Срезка → Розы | Цветущие | Зелень | Экзотика | Прочее
+- Горшечные_растения → Цветущие | Лиственные | Прочее
+- Сухоцветы → Поштучно | Пучек | Прочее
+- Упаковка → Пленка | Подарок | Прочее
+- Сопутствующие_товары → Уход | Праздник | Игрушки | Пиротехника | Упаковка | Прочее
+TAXONOMY;
+
+    private LlmClient $client;
+    private int       $batchSize;
+
+    public function __construct(int $batchSize = 20)
+    {
+        $this->client    = new LlmClient();
+        $this->batchSize = $batchSize;
+    }
+
+    /**
+     * Верифицировать все pending-предсказания без LLM-вердикта.
+     *
+     * @return int Количество верифицированных предсказаний
+     */
+    public function verifyPending(): int
+    {
+        $predictions = Products1cAutomarkPrediction::find()
+            ->with('product')
+            ->where(['status' => Products1cAutomarkPrediction::STATUS_PENDING])
+            ->andWhere(['llm_verdict' => null])
+            ->all();
+
+        if (empty($predictions)) {
+            return 0;
+        }
+
+        $batches  = array_chunk($predictions, $this->batchSize);
+        $verified = 0;
+
+        foreach ($batches as $batch) {
+            $items   = $this->enrichBatch($batch);
+            $payload = self::buildBatchPayload($items);
+
+            try {
+                $raw     = $this->client->chat(self::buildSystemPrompt(), $payload);
+                $results = self::parseVerifyResults($raw);
+                $verified += $this->saveResults($results);
+            } catch (\RuntimeException) {
+                continue;
+            }
+        }
+
+        return $verified;
+    }
+
+    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;
+    }
+
+    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
+    {
+        $taxonomy = self::TAXONOMY;
+        return <<<PROMPT
+Ты — система верификации авторазметки товаров цветочного магазина.
+
+{$taxonomy}
+
+ЗАДАЧА: Для каждого товара в массиве:
+1. Проверь соответствие разметки (category, subcategory, species, size, color) названию товара.
+2. Используй similar_examples как эталон правильной разметки.
+3. Вынеси вердикт: approved (разметка верна) или rejected (есть ошибки).
+
+ОТВЕТ — только JSON-массив, без пояснений вне него:
+[{"id":<int>,"verdict":"approved"|"rejected","comment":"<причина если rejected, иначе null>"}]
+PROMPT;
+    }
+
+    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']) && $item['comment'] !== null
+                              ? (string) $item['comment']
+                              : null,
+            );
+        }
+        return $results;
+    }
+}
diff --git a/erp24/tests/unit/services/automark/LlmVerifierTest.php b/erp24/tests/unit/services/automark/LlmVerifierTest.php
new file mode 100644 (file)
index 0000000..3d2b912
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services\automark;
+
+use Codeception\Test\Unit;
+use yii_app\services\automark\LlmVerifier;
+
+/**
+ * @covers \yii_app\services\automark\LlmVerifier
+ */
+class LlmVerifierTest extends Unit
+{
+    public function testSystemPromptContainsTaxonomy(): void
+    {
+        $prompt = LlmVerifier::buildSystemPrompt();
+
+        $this->assertStringContainsString('Срезка', $prompt);
+        $this->assertStringContainsString('Розы', $prompt);
+        $this->assertStringContainsString('Горшечные_растения', $prompt);
+        $this->assertStringContainsString('approved', $prompt);
+        $this->assertStringContainsString('rejected', $prompt);
+    }
+
+    public function testBuildBatchPayloadContainsNameAndPrediction(): void
+    {
+        $items = [
+            [
+                'id'         => 42,
+                'name'       => 'Роза Эквадор 80 алая',
+                'category'   => 'Срезка',
+                'subcategory'=> 'Розы',
+                'species'    => 'Роза',
+                'size'       => 80,
+                'color'      => 'Алая',
+                'examples'   => [],
+            ],
+        ];
+
+        $payload = LlmVerifier::buildBatchPayload($items);
+        $decoded = json_decode($payload, true);
+
+        $this->assertIsArray($decoded);
+        $this->assertSame(42, $decoded[0]['id']);
+        $this->assertSame('Роза Эквадор 80 алая', $decoded[0]['name']);
+        $this->assertArrayHasKey('prediction', $decoded[0]);
+        $this->assertSame('Срезка', $decoded[0]['prediction']['category']);
+    }
+
+    public function testParseVerifyResultsReturnsLlmVerifyResultObjects(): void
+    {
+        $raw = [
+            ['id' => 10, 'verdict' => 'approved', 'comment' => null],
+            ['id' => 11, 'verdict' => 'rejected', 'comment' => 'Неверный цвет'],
+        ];
+
+        $results = LlmVerifier::parseVerifyResults($raw);
+
+        $this->assertCount(2, $results);
+        $this->assertTrue($results[0]->isApproved());
+        $this->assertSame(10, $results[0]->predictionId);
+        $this->assertFalse($results[1]->isApproved());
+        $this->assertSame('Неверный цвет', $results[1]->comment);
+    }
+
+    public function testParseVerifyResultsSkipsItemsWithoutRequiredFields(): void
+    {
+        $raw = [
+            ['id' => 10, 'verdict' => 'approved', 'comment' => null],
+            ['id' => 11],           // нет verdict
+            ['verdict' => 'approved'], // нет id
+        ];
+
+        $results = LlmVerifier::parseVerifyResults($raw);
+        $this->assertCount(1, $results);
+    }
+}