From 7a1a1acdd50f587cdacc67bfc64ec11cfec09790 Mon Sep 17 00:00:00 2001 From: fomichev Date: Thu, 30 Apr 2026 18:22:30 +0300 Subject: [PATCH] =?utf8?q?feat(ERP-292):=20LlmVerifier=20=E2=80=94=20?= =?utf8?q?=D0=B1=D0=B0=D1=82=D1=87=D0=B8=D0=BD=D0=B3,=20=D0=BE=D0=B1=D0=BE?= =?utf8?q?=D0=B3=D0=B0=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=B8?= =?utf8?q?=D0=BC=D0=B5=D1=80=D0=B0=D0=BC=D0=B8,=20=D0=BF=D0=B0=D1=80=D1=81?= =?utf8?q?=D0=B8=D0=BD=D0=B3=20=D0=B2=D0=B5=D1=80=D0=B4=D0=B8=D0=BA=D1=82?= =?utf8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- erp24/services/automark/LlmVerifier.php | 182 ++++++++++++++++++ .../services/automark/LlmVerifierTest.php | 78 ++++++++ 2 files changed, 260 insertions(+) create mode 100644 erp24/services/automark/LlmVerifier.php create mode 100644 erp24/tests/unit/services/automark/LlmVerifierTest.php diff --git a/erp24/services/automark/LlmVerifier.php b/erp24/services/automark/LlmVerifier.php new file mode 100644 index 00000000..33e84e88 --- /dev/null +++ b/erp24/services/automark/LlmVerifier.php @@ -0,0 +1,182 @@ +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 <<,"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 index 00000000..3d2b912e --- /dev/null +++ b/erp24/tests/unit/services/automark/LlmVerifierTest.php @@ -0,0 +1,78 @@ +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); + } +} -- 2.39.5