--- /dev/null
+<?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;
+ }
+}
--- /dev/null
+<?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);
+ }
+}