]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
llm verification fixes
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Tue, 5 May 2026 09:19:41 +0000 (12:19 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Tue, 5 May 2026 09:19:41 +0000 (12:19 +0300)
20 files changed:
erp24/commands/AutoMarkController.php
erp24/config/env.php
erp24/controllers/AutoMarkController.php
erp24/migrations/m260505_120000_change_size_to_decimal_in_automark_and_nomenclature.php [new file with mode: 0644]
erp24/records/Products1cAutomarkPrediction.php
erp24/records/Products1cNomenclature.php
erp24/records/Products1cNomenclatureSearch.php
erp24/services/AutoMarkService.php
erp24/services/automark/CoordinatorLlmTransport.php
erp24/services/automark/LlmClient.php
erp24/services/automark/LlmVerifier.php
erp24/services/automark/LlmVerifyResult.php
erp24/services/automark/ParseResult.php
erp24/services/automark/RuleBasedParser.php
erp24/services/automark/SimilarityMatcher.php
erp24/tests/unit/services/automark/LlmClientTest.php
erp24/tests/unit/services/automark/LlmVerifierTest.php
erp24/tests/unit/services/automark/RuleBasedParserTest.php
erp24/views/auto-mark/index.php
erp24/views/auto-mark/review.php

index f4fd225bae4fbe3e35644747f3a632819836bc73..2fd84408bca6959cf5ec73340c16bab51506f959 100644 (file)
@@ -73,12 +73,17 @@ class AutoMarkController extends Controller
     {
         $service      = new AutoMarkService();
         $pendingCount = $service->getPendingUnverifiedCount();
+        $verifiedCount = $service->getLlmVerifiedCount();
+        $nextPredictionId = $service->getNextPendingPredictionId();
 
         if ($pendingCount === 0) {
             $this->stdout("Нет предсказаний для LLM-верификации.\n");
             return ExitCode::OK;
         }
 
+        $this->stdout("LLM already verified: {$verifiedCount}\n");
+        $this->stdout("LLM pending left: {$pendingCount}\n");
+        $this->stdout("LLM next prediction id: " . ($nextPredictionId ?? 'none') . "\n");
         $this->stdout("Запуск LLM-верификации ({$pendingCount} предсказаний)...\n");
         return $this->runLlmVerify($service);
     }
@@ -101,7 +106,73 @@ class AutoMarkController extends Controller
                 $this->stdout("LLM config warning: используется legacy-конфиг LLM_ENDPOINT/LLM_API_KEY/LLM_MODEL\n");
             }
 
-            $verified = $service->llmBatchVerify();
+            $verified = $service->llmBatchVerify(function (string $event, array $data): void {
+                switch ($event) {
+                    case 'batch_start':
+                        $batchNumber = (int) ($data['batchNumber'] ?? 0);
+                        $totalBatches = (int) ($data['totalBatches'] ?? 0);
+                        $predictionIds = implode(',', $data['predictionIds'] ?? []);
+                        $processedPredictions = (int) ($data['processedPredictions'] ?? 0);
+                        $totalPredictions = (int) ($data['totalPredictions'] ?? 0);
+                        $this->stdout(
+                            sprintf(
+                                "[%d/%d] request ids=[%s] processed=%d/%d\n",
+                                $batchNumber,
+                                $totalBatches,
+                                $predictionIds,
+                                $processedPredictions,
+                                $totalPredictions
+                            )
+                        );
+                        break;
+
+                    case 'batch_success':
+                        /** @var array<int, \yii_app\services\automark\LlmVerifyResult> $results */
+                        $results = $data['results'] ?? [];
+                        $summary = [];
+                        foreach ($results as $result) {
+                            $suffix = $result->hasCorrections() ? ':patched' : '';
+                            $summary[] = sprintf('%d:%s%s', $result->predictionId, $result->verdict, $suffix);
+                        }
+                        $batchNumber = (int) ($data['batchNumber'] ?? 0);
+                        $totalBatches = (int) ($data['totalBatches'] ?? 0);
+                        $verifiedCount = (int) ($data['verified'] ?? 0);
+                        $processedPredictions = (int) ($data['processedPredictions'] ?? 0);
+                        $totalPredictions = (int) ($data['totalPredictions'] ?? 0);
+                        $this->stdout(
+                            sprintf(
+                                "[%d/%d] ok %s total_verified=%d processed=%d/%d\n",
+                                $batchNumber,
+                                $totalBatches,
+                                implode(', ', $summary),
+                                $verifiedCount,
+                                $processedPredictions,
+                                $totalPredictions
+                            )
+                        );
+                        break;
+
+                    case 'batch_error':
+                        $batchNumber = (int) ($data['batchNumber'] ?? 0);
+                        $totalBatches = (int) ($data['totalBatches'] ?? 0);
+                        $predictionIds = implode(',', $data['predictionIds'] ?? []);
+                        $error = (string) ($data['error'] ?? 'unknown error');
+                        $processedPredictions = (int) ($data['processedPredictions'] ?? 0);
+                        $totalPredictions = (int) ($data['totalPredictions'] ?? 0);
+                        $this->stderr(
+                            sprintf(
+                                "[%d/%d] error ids=[%s] processed=%d/%d %s\n",
+                                $batchNumber,
+                                $totalBatches,
+                                $predictionIds,
+                                $processedPredictions,
+                                $totalPredictions,
+                                $error
+                            )
+                        );
+                        break;
+                }
+            });
             $this->stdout("LLM верифицировано: {$verified}\n");
             return ExitCode::OK;
         } catch (\RuntimeException $e) {
index a3c5057a7fe165cd9b7211eac0e43088d8a30e65..7b640efeac8b788a8463bd318c3353215628fa09 100644 (file)
@@ -34,6 +34,10 @@ try {
     $dotenv->ifPresent('POSTGRES_PORT')->isInteger();
     $dotenv->ifPresent('DB_PORT')->isInteger();
     $dotenv->ifPresent('DB_REMOTE_PORT')->isInteger();
+    $dotenv->ifPresent('LLM_BATCH_SIZE')->isInteger();
+    $dotenv->ifPresent('LLM_TIMEOUT')->isInteger();
+    $dotenv->ifPresent('LLM_MAX_TOKENS')->isInteger();
+    $dotenv->ifPresent('LLM_BACKEND')->allowedValues(['local', 'remote']);
 
 } catch (\Dotenv\Exception\InvalidPathException $e) {
     putenv("APP_ENV=development");
index b5738f9bb1c331e900b3342bb8fc9755a738ba86..37e164d82dc18d7435a944b801743507ef88405b 100644 (file)
@@ -27,12 +27,27 @@ class AutoMarkController extends Controller
             $query->andWhere(['status' => Products1cAutomarkPrediction::STATUS_PENDING]);
         }
 
+        $llm = Yii::$app->request->get('llm');
+        if ($llm === 'verified') {
+            $query->andWhere(['not', ['llm_verdict' => null]]);
+        } elseif ($llm === 'unverified') {
+            $query->andWhere(['llm_verdict' => null]);
+        } elseif (in_array($llm, [
+            Products1cAutomarkPrediction::LLM_VERDICT_APPROVED,
+            Products1cAutomarkPrediction::LLM_VERDICT_REJECTED,
+        ], true)) {
+            $query->andWhere(['llm_verdict' => $llm]);
+        }
+
         $dataProvider = new ActiveDataProvider([
             'query'      => $query,
             'pagination' => ['pageSize' => 50],
         ]);
 
-        return $this->render('index', ['dataProvider' => $dataProvider]);
+        return $this->render('index', [
+            'dataProvider' => $dataProvider,
+            'llmFilter' => is_string($llm) ? $llm : null,
+        ]);
     }
 
     public function actionReview(int $id): string|Response
diff --git a/erp24/migrations/m260505_120000_change_size_to_decimal_in_automark_and_nomenclature.php b/erp24/migrations/m260505_120000_change_size_to_decimal_in_automark_and_nomenclature.php
new file mode 100644 (file)
index 0000000..9c13c9e
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+use yii\db\Migration;
+
+class m260505_120000_change_size_to_decimal_in_automark_and_nomenclature extends Migration
+{
+    public const AUTOMARK_TABLE = 'erp24.products_1c_automark_predictions';
+    public const NOMENCLATURE_TABLE = 'erp24.products_1c_nomenclature';
+
+    public function safeUp()
+    {
+        $automarkSchema = $this->db->getTableSchema(self::AUTOMARK_TABLE);
+        if (isset($automarkSchema)) {
+            $this->alterColumn(self::AUTOMARK_TABLE, 'size', $this->decimal(10, 2)->null());
+            $this->update(self::AUTOMARK_TABLE, ['size' => null], ['size' => 0]);
+        }
+
+        $nomenclatureSchema = $this->db->getTableSchema(self::NOMENCLATURE_TABLE);
+        if (isset($nomenclatureSchema)) {
+            $this->alterColumn(self::NOMENCLATURE_TABLE, 'size', $this->decimal(10, 2)->null());
+            $this->update(self::NOMENCLATURE_TABLE, ['size' => null], ['size' => 0]);
+        }
+    }
+
+    public function safeDown()
+    {
+        $automarkSchema = $this->db->getTableSchema(self::AUTOMARK_TABLE);
+        if (isset($automarkSchema)) {
+            $this->update(self::AUTOMARK_TABLE, ['size' => 0], ['size' => null]);
+            $this->alterColumn(self::AUTOMARK_TABLE, 'size', $this->integer()->null());
+        }
+
+        $nomenclatureSchema = $this->db->getTableSchema(self::NOMENCLATURE_TABLE);
+        if (isset($nomenclatureSchema)) {
+            $this->update(self::NOMENCLATURE_TABLE, ['size' => 0], ['size' => null]);
+            $this->alterColumn(self::NOMENCLATURE_TABLE, 'size', $this->integer()->null());
+        }
+    }
+}
index 46b4637208fad6f99919f2e33a938cd6a1340ff3..438009043d72b8c9e97828ddd4385bee094f8ebd 100644 (file)
@@ -16,7 +16,7 @@ use yii\db\ActiveQuery;
  * @property string|null $species
  * @property string|null $sort
  * @property string|null $type
- * @property int|null    $size
+ * @property float|null  $size
  * @property string|null $color
  * @property float       $confidence
  * @property string      $method
@@ -52,7 +52,8 @@ class Products1cAutomarkPrediction extends \yii\db\ActiveRecord
             [['product_id', 'confidence', 'method', 'status'], 'required'],
             [['product_id', 'category', 'subcategory', 'species', 'sort', 'type', 'color', 'method'], 'string', 'max' => 255],
             [['llm_verdict', 'llm_comment'], 'string', 'max' => 500],
-            [['size', 'status', 'approved_by'], 'integer'],
+            [['size'], 'number', 'min' => 0],
+            [['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]],
index 36c3985802264950e109f678372ab41ef2a4c5b5..bc524e2923b4295c0b3e10c655674012359cd03e 100644 (file)
@@ -17,7 +17,7 @@ use yii\db\ActiveQuery;
  * @property string|null $species Вид
  * @property string|null $sort Сорт
  * @property string|null $type Тип
- * @property int|null $size Размер
+ * @property float|null $size Размер
  * @property string|null $measure Единица измерения
  * @property string|null $color Цвет
  */
@@ -48,7 +48,7 @@ class Products1cNomenclature extends \yii\db\ActiveRecord
         return [
             [['id', 'location', 'name', 'type_num', 'category'], 'required'],
             [['size', 'species', 'subcategory', 'sort', 'measure', 'color', 'type'], 'default', 'value' => null],
-            [['size'], 'integer'],
+            [['size'], 'number', 'min' => 0],
             [['id', 'location', 'name', 'type_num',
                 'category', 'subcategory', 'species', 'sort', 'measure', 'color', 'type'], 'string', 'max' => 255],
             [['id'], 'unique'],
index 100d509aafeb9840c86d97506fd52c7e17d9b839..52bd331c1cea79b0b058cd048c397c23861ac8e7 100644 (file)
@@ -18,7 +18,7 @@ class Products1cNomenclatureSearch extends Products1cNomenclature
     {
         return [
             [['id', 'location', 'name', 'type_num', 'category', 'subcategory', 'species', 'sort', 'measure', 'color', 'type'], 'safe'],
-            [['size'], 'integer'],
+            [['size'], 'number'],
         ];
     }
 
index bceec595961d50ce6222917b430c7c498fabe6e3..494090b29842826a09a4009c1b63e71e561ef553 100644 (file)
@@ -45,12 +45,13 @@ class AutoMarkService
 
     /**
      * Предсказать и сохранить разметку для одного товара.
-     * Если pending предсказание уже существует — вернуть его.
+     * Если предсказание уже существует — вернуть последнее и не создавать дубликат.
      */
     public function predictForProduct(string $productId): ?Products1cAutomarkPrediction
     {
         $existing = Products1cAutomarkPrediction::find()
-            ->where(['product_id' => $productId, 'status' => Products1cAutomarkPrediction::STATUS_PENDING])
+            ->where(['product_id' => $productId])
+            ->orderBy(['id' => SORT_DESC])
             ->one();
 
         if ($existing !== null) {
@@ -136,6 +137,10 @@ class AutoMarkService
 
         $count = 0;
         foreach ($productIds as $id) {
+            if ($this->hasPrediction((string) $id)) {
+                continue;
+            }
+
             if ($this->predictForProduct((string) $id) !== null) {
                 $count++;
             }
@@ -176,7 +181,7 @@ class AutoMarkService
             species:     $sim->species     ?? $rule->species,
             sort:        $sim->sort        ?? $rule->sort,
             type:        $sim->type        ?? $rule->type,
-            size:        ($sim->size === null || $sim->size === 0) ? $rule->size : $sim->size,
+            size:        $rule->size,
             color:       $sim->color       ?? $rule->color,
             confidence:  $sim->confidence,
             method:      $sim->method,
@@ -215,7 +220,9 @@ class AutoMarkService
         return Products1c::find()
             ->select('products_1c.id')
             ->leftJoin('products_1c_nomenclature', 'products_1c.id = products_1c_nomenclature.id')
+            ->leftJoin('products_1c_automark_predictions', 'products_1c.id = products_1c_automark_predictions.product_id')
             ->where(['products_1c_nomenclature.id' => null])
+            ->andWhere(['products_1c_automark_predictions.id' => null])
             ->andWhere(['products_1c.tip' => Products1c::TYPE_PRODUCTS])
             ->andWhere(['OR',
                 ['products_1c.type' => null],
@@ -224,16 +231,24 @@ class AutoMarkService
             ->column();
     }
 
+    private function hasPrediction(string $productId): bool
+    {
+        return Products1cAutomarkPrediction::find()
+            ->where(['product_id' => $productId])
+            ->exists();
+    }
+
     /**
      * Верифицировать pending-предсказания через LLM.
      *
+     * @param callable|null $progressCallback function(string $event, array<string, mixed> $data): void
      * @return int Количество верифицированных предсказаний
      */
-    public function llmBatchVerify(): int
+    public function llmBatchVerify(?callable $progressCallback = null): int
     {
         $batchSize = (int)(getenv('LLM_BATCH_SIZE') ?: 20);
         $verifier  = new LlmVerifier($batchSize);
-        return $verifier->verifyPending();
+        return $verifier->verifyPending($progressCallback);
     }
 
     /**
@@ -247,6 +262,25 @@ class AutoMarkService
             ->count();
     }
 
+    public function getLlmVerifiedCount(): int
+    {
+        return (int) Products1cAutomarkPrediction::find()
+            ->where(['not', ['llm_verdict' => null]])
+            ->count();
+    }
+
+    public function getNextPendingPredictionId(): ?int
+    {
+        $nextId = Products1cAutomarkPrediction::find()
+            ->select('id')
+            ->where(['status' => Products1cAutomarkPrediction::STATUS_PENDING])
+            ->andWhere(['llm_verdict' => null])
+            ->orderBy(['id' => SORT_ASC])
+            ->scalar();
+
+        return $nextId !== false && $nextId !== null ? (int) $nextId : null;
+    }
+
     /**
      * @return array<string, mixed>
      */
index 250c4fa8d8c9eecd5cf771dd5e22e6756cfe3877..98b9df5c8811c5a277c5b79fcabae3ac40a24a10 100644 (file)
@@ -74,8 +74,9 @@ final class CoordinatorLlmTransport implements LlmTransportInterface
         }
 
         $responseJson = $decoded['response_json'] ?? null;
-        if (is_array($responseJson) && array_is_list($responseJson)) {
-            return $responseJson;
+        $normalized = LlmClient::normalizeVerifyResponse($responseJson);
+        if ($normalized !== null) {
+            return $normalized;
         }
 
         $responseText = $decoded['response_text'] ?? null;
index d89146df95d3d52dbb4874f706dc4a2acc58e435..4cc22821f6877a3cdc66456c28c9f1417475c25b 100644 (file)
@@ -45,14 +45,21 @@ class LlmClient
      */
     public static function parseResponse(string $raw): array
     {
-        $direct = json_decode(trim($raw), true);
-        if (is_array($direct) && array_is_list($direct)) {
+        $direct = self::normalizeVerifyResponse(json_decode(trim($raw), true));
+        if ($direct !== null) {
             return $direct;
         }
 
         if (preg_match('/\[.*\]/su', $raw, $m)) {
-            $extracted = json_decode($m[0], true);
-            if (is_array($extracted) && array_is_list($extracted)) {
+            $extracted = self::normalizeVerifyResponse(json_decode($m[0], true));
+            if ($extracted !== null) {
+                return $extracted;
+            }
+        }
+
+        if (preg_match('/\{.*\}/su', $raw, $m)) {
+            $extracted = self::normalizeVerifyResponse(json_decode($m[0], true));
+            if ($extracted !== null) {
                 return $extracted;
             }
         }
@@ -60,6 +67,31 @@ class LlmClient
         throw new \RuntimeException('LLM вернул невалидный JSON: ' . mb_substr($raw, 0, 200));
     }
 
+    /**
+     * @param mixed $decoded
+     * @return array<int, array<string, mixed>>|null
+     */
+    public static function normalizeVerifyResponse(mixed $decoded): ?array
+    {
+        if (!is_array($decoded)) {
+            return null;
+        }
+
+        if (array_is_list($decoded)) {
+            return $decoded;
+        }
+
+        if (isset($decoded['id'], $decoded['verdict'])) {
+            return [$decoded];
+        }
+
+        if (isset($decoded['results']) && is_array($decoded['results']) && array_is_list($decoded['results'])) {
+            return $decoded['results'];
+        }
+
+        return null;
+    }
+
     /**
      * @param array<string, string> $env
      * @return array<string, mixed>
index d08721b08ae71b6c4a90922c476b6a1005baa70b..cbf2b86ea430a20caabccf3bf6cd1202c760177c 100644 (file)
@@ -49,9 +49,10 @@ TAXONOMY;
     /**
      * Верифицировать все pending-предсказания без LLM-вердикта.
      *
+     * @param callable|null $progressCallback function(string $event, array<string, mixed> $data): void
      * @return int Количество верифицированных предсказаний
      */
-    public function verifyPending(): int
+    public function verifyPending(?callable $progressCallback = null): int
     {
         $predictions = Products1cAutomarkPrediction::find()
             ->with('product')
@@ -64,21 +65,61 @@ TAXONOMY;
         }
 
         $batches  = array_chunk($predictions, $this->batchSize);
+        $totalBatches = count($batches);
+        $totalPredictions = count($predictions);
         $verified = 0;
         $failedBatches = 0;
         $firstError = null;
+        $processedPredictions = 0;
 
         foreach ($batches as $index => $batch) {
+            $batchNumber = $index + 1;
             $items   = $this->enrichBatch($batch);
             $payload = $this->buildEffectiveUserPrompt(self::buildBatchPayload($items));
+            $batchIds = array_map(
+                static fn(Products1cAutomarkPrediction $prediction): int => (int) $prediction->id,
+                $batch
+            );
+
+            $progressCallback?->__invoke('batch_start', [
+                'batchNumber' => $batchNumber,
+                'totalBatches' => $totalBatches,
+                'batchSize' => count($batch),
+                'predictionIds' => $batchIds,
+                'processedPredictions' => $processedPredictions,
+                'totalPredictions' => $totalPredictions,
+            ]);
 
             try {
                 $raw     = $this->client->chat($this->buildEffectiveSystemPrompt(), $payload);
                 $results = self::parseVerifyResults($raw);
-                $verified += $this->saveResults($results);
+                $saved = $this->saveResults($results);
+                $verified += $saved;
+                $processedPredictions += count($batch);
+
+                $progressCallback?->__invoke('batch_success', [
+                    'batchNumber' => $batchNumber,
+                    'totalBatches' => $totalBatches,
+                    'saved' => $saved,
+                    'verified' => $verified,
+                    'results' => $results,
+                    'predictionIds' => $batchIds,
+                    'processedPredictions' => $processedPredictions,
+                    'totalPredictions' => $totalPredictions,
+                ]);
             } catch (\RuntimeException $e) {
                 $failedBatches++;
                 $firstError ??= sprintf('batch %d: %s', $index + 1, $e->getMessage());
+                $processedPredictions += count($batch);
+
+                $progressCallback?->__invoke('batch_error', [
+                    'batchNumber' => $batchNumber,
+                    'totalBatches' => $totalBatches,
+                    'predictionIds' => $batchIds,
+                    'error' => $e->getMessage(),
+                    'processedPredictions' => $processedPredictions,
+                    'totalPredictions' => $totalPredictions,
+                ]);
                 continue;
             }
         }
@@ -215,20 +256,76 @@ TAXONOMY;
         $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]
-            );
+            $prediction = Products1cAutomarkPrediction::find()
+                ->with('product')
+                ->where(['id' => $result->predictionId])
+                ->one();
+
+            if ($prediction === null) {
+                continue;
+            }
+
+            $attributes = [
+                'llm_verdict' => $result->verdict,
+                'llm_verified_at' => $now,
+                'llm_comment' => $result->comment,
+                'updated_at' => $now,
+            ];
+
+            $corrections = $this->resolveCorrections($prediction, $result);
+            if ($corrections !== []) {
+                $attributes = array_merge($attributes, $corrections);
+                $attributes['method'] = Products1cAutomarkPrediction::METHOD_LLM;
+            }
+
+            $rows = Products1cAutomarkPrediction::updateAll($attributes, ['id' => $prediction->id]);
             $count += $rows;
         }
 
         return $count;
     }
 
+    /**
+     * @return array<string, int|string|null>
+     */
+    private function resolveCorrections(Products1cAutomarkPrediction $prediction, LlmVerifyResult $result): array
+    {
+        $corrections = [];
+
+        if (!$result->isApproved() && $result->hasCorrections()) {
+            $corrections['category'] = $result->category;
+            $corrections['subcategory'] = $result->subcategory;
+            $corrections['species'] = $result->species;
+            $corrections['size'] = $result->size;
+            $corrections['color'] = $result->color;
+        }
+
+        $name = (string) ($prediction->product->name ?? '');
+        $explicitSize = $this->extractExplicitSize($name);
+        if ($explicitSize !== null && (float) ($prediction->size ?? 0) !== $explicitSize && !array_key_exists('size', $corrections)) {
+            $corrections['size'] = $explicitSize;
+        }
+
+        return $corrections;
+    }
+
+    private function extractExplicitSize(string $name): ?float
+    {
+        if (preg_match('/(\d+)\s*(?:см|cm|СМ|CM)/iu', $name, $matches)) {
+            return (float) $matches[1];
+        }
+
+        if (preg_match('/(\d+(?:[.,]\d+)?)\s*(?:л|литр(?:а|ов)?)/iu', $name, $matches)) {
+            return self::normalizeNullableFloat($matches[1]);
+        }
+
+        if (preg_match('/(\d+(?:[.,]\d+)?)\s*(?:м|метр(?:а|ов)?)(?![а-яa-z])/iu', $name, $matches)) {
+            return self::normalizeNullableFloat($matches[1]);
+        }
+
+        return null;
+    }
+
     public static function buildSystemPrompt(): string
     {
         $taxonomy = self::TAXONOMY;
@@ -242,12 +339,18 @@ TAXONOMY;
 2. Используй similar_examples как эталон правильной разметки.
 3. Если `similar_examples` пустой, принимай решение по `name` и `prediction`, но с более консервативной оценкой.
 4. Не отклоняй товар только потому, что category использует legacy-название (`Срезы`, `Горшечные`, `букет`, `сборка`, `сервис`), если смысл разметки корректен.
-5. Вынеси вердикт: approved (разметка верна) или rejected (есть ошибки).
-6. `comment` должен быть очень коротким: не более 12 слов, без длинных объяснений.
-7. Если товар approved, `comment` должен быть null.
-
-ОТВЕТ — только JSON-массив, без пояснений вне него:
-[{"id":<int>,"verdict":"approved"|"rejected","comment":"<причина если rejected, иначе null>"}]
+5. Если в названии явно указан размер (`100см`, `65 см`, `1м`, `3.5л`, `0,5 л`), цвет, species или category, эти поля должны совпадать с prediction. Иначе verdict = rejected.
+6. Вынеси вердикт: approved (разметка верна) или rejected (есть ошибки).
+7. `comment` должен быть очень коротким: не более 12 слов, без длинных объяснений.
+8. Если товар approved, `comment` должен быть null.
+9. Если verdict = rejected, обязательно верни `corrected_prediction`.
+10. В `corrected_prediction` верни полную исправленную разметку для полей `category`, `subcategory`, `species`, `size`, `color`.
+11. Если какое-то поле уже было верным, повтори его текущее корректное значение в `corrected_prediction`, а не оставляй пустым.
+12. Если размер в названии не указан явно, не придумывай его по `similar_examples`: верни `size = null`.
+
+ОТВЕТ — только JSON. Допустим массив или одиночный объект:
+[{"id":<int>,"verdict":"approved"|"rejected","comment":"<причина если rejected, иначе null>","corrected_prediction":{"category":"<string|null>","subcategory":"<string|null>","species":"<string|null>","size":<int|null>,"color":"<string|null>"}}]
+Если verdict = approved, `corrected_prediction` должен быть null.
 PROMPT;
     }
 
@@ -295,14 +398,54 @@ PROMPT;
             if (!isset($item['id'], $item['verdict'])) {
                 continue;
             }
+
+            $corrected = $item['corrected_prediction'] ?? null;
             $results[] = new LlmVerifyResult(
                 predictionId: (int) $item['id'],
                 verdict:      (string) $item['verdict'],
                 comment:      isset($item['comment']) && $item['comment'] !== null
                               ? (string) $item['comment']
                               : null,
+                category:     is_array($corrected) ? self::normalizeNullableString($corrected['category'] ?? null) : null,
+                subcategory:  is_array($corrected) ? self::normalizeNullableString($corrected['subcategory'] ?? null) : null,
+                species:      is_array($corrected) ? self::normalizeNullableString($corrected['species'] ?? null) : null,
+                size:         is_array($corrected) ? self::normalizeNullableFloat($corrected['size'] ?? null) : null,
+                color:        is_array($corrected) ? self::normalizeNullableString($corrected['color'] ?? null) : null,
             );
         }
         return $results;
     }
+
+    private static function normalizeNullableString(mixed $value): ?string
+    {
+        if (!is_string($value)) {
+            return null;
+        }
+
+        $value = trim($value);
+        return $value === '' ? null : $value;
+    }
+
+    private static function normalizeNullableFloat(mixed $value): ?float
+    {
+        if ($value === null || $value === '') {
+            return null;
+        }
+
+        if (is_int($value) || is_float($value)) {
+            $numeric = (float) $value;
+            return $numeric > 0 ? $numeric : null;
+        }
+
+        if (is_string($value)) {
+            $value = str_replace(',', '.', trim($value));
+        }
+
+        if (is_numeric($value)) {
+            $numeric = (float) $value;
+            return $numeric > 0 ? $numeric : null;
+        }
+
+        return null;
+    }
 }
index 4b9c6a89e2c5288b37c78df8bbef820126a4e2e7..721b7337908b3420fea4da9b6caa65fb569749b0 100644 (file)
@@ -10,10 +10,24 @@ final class LlmVerifyResult
         public readonly int     $predictionId,
         public readonly string  $verdict,
         public readonly ?string $comment,
+        public readonly ?string $category = null,
+        public readonly ?string $subcategory = null,
+        public readonly ?string $species = null,
+        public readonly ?float  $size = null,
+        public readonly ?string $color = null,
     ) {}
 
     public function isApproved(): bool
     {
         return $this->verdict === 'approved';
     }
+
+    public function hasCorrections(): bool
+    {
+        return $this->category !== null
+            || $this->subcategory !== null
+            || $this->species !== null
+            || $this->size !== null
+            || $this->color !== null;
+    }
 }
index a7ea9c96dd38f9b4449e75529bde8922ef574481..5aa9e87ef11cd1a9e08307337bf3ed10820cf0e7 100644 (file)
@@ -12,7 +12,7 @@ final class ParseResult
         public readonly ?string $species,
         public readonly ?string $sort,
         public readonly ?string $type,
-        public readonly ?int    $size,
+        public readonly ?float  $size,
         public readonly ?string $color,
         public readonly float   $confidence,
         public readonly string  $method,
index 80ea9141856cc43bea08ae1822a68e174007f64f..c73d89cdd0a99031454dc2f19c51b69e95423d2f 100644 (file)
@@ -63,7 +63,7 @@ class RuleBasedParser
 
     private const POTTED_KEYWORDS = ['горшок', 'горшечн', 'кашпо', 'вазон'];
 
-    private const PACKAGING_KEYWORDS = ['пакет', 'коробк', 'упаков', 'лента', 'бумага', 'сетка', 'сизаль'];
+    private const PACKAGING_KEYWORDS = ['пакет', 'коробк', 'упаков', 'лента', 'бумага', 'сетка', 'сизаль', 'пленк'];
 
     public function parse(string $name): ?ParseResult
     {
@@ -145,27 +145,44 @@ class RuleBasedParser
         return null;
     }
 
-    private function detectSize(string $name): ?int
+    private function detectSize(string $name): ?float
     {
         if (preg_match('/(\d+)\s*(?:см|cm|СМ|CM)/iu', $name, $m)) {
-            return (int) $m[1];
+            return (float) $m[1];
+        }
+        if (preg_match('/(\d+(?:[.,]\d+)?)\s*(?:л|литр(?:а|ов)?)/iu', $name, $m)) {
+            return $this->normalizeDecimal($m[1]);
+        }
+        if (preg_match('/(\d+(?:[.,]\d+)?)\s*(?:м|метр(?:а|ов)?)(?![а-яa-z])/iu', $name, $m)) {
+            return $this->normalizeDecimal($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 (float) $n;
             }
         }
         return null;
     }
 
+    private function normalizeDecimal(string $raw): ?float
+    {
+        $normalized = str_replace(',', '.', trim($raw));
+        if (!is_numeric($normalized)) {
+            return null;
+        }
+
+        $value = (float) $normalized;
+        return $value > 0 ? $value : null;
+    }
+
     private function calcConfidence(
         ?string $category,
         ?string $species,
         ?string $sort,
         ?string $color,
-        ?int $size
+        ?float $size
     ): float {
         $score = 0.5;
         if ($category !== null) $score += 0.2;
index 45623d1263ec89f2b4a34e3567145494ba368687..9025380bf69f96e05483f883c84e380427a76596 100644 (file)
@@ -49,7 +49,8 @@ class SimilarityMatcher
             species:     $bestItem['species'] ?? null,
             sort:        $bestItem['sort'] ?? null,
             type:        $bestItem['type'] ?? null,
-            size:        isset($bestItem['size']) ? (int) $bestItem['size'] : null,
+            // Size must be extracted from the current product name, not copied from a similar item.
+            size:        null,
             color:       $bestItem['color'] ?? null,
             confidence:  round($bestScore, 4),
             method:      Products1cAutomarkPrediction::METHOD_SIMILARITY,
index 8d8352b8006391b138829b72e70609f6e0cbf9aa..ee59ea2bfb1da5083823a9fb7a0a21f8c6faead7 100644 (file)
@@ -44,6 +44,16 @@ class LlmClientTest extends Unit
         LlmClient::parseResponse('{"error":"bad request"}');
     }
 
+    public function testParsesSingleVerifyObjectResponse(): void
+    {
+        $raw = '{"id":74,"verdict":"approved","comment":null}';
+        $result = LlmClient::parseResponse($raw);
+
+        $this->assertCount(1, $result);
+        $this->assertSame(74, $result[0]['id']);
+        $this->assertSame('approved', $result[0]['verdict']);
+    }
+
     public function testParsesOpenAiCompatibleResponse(): void
     {
         $body = json_encode([
@@ -102,6 +112,24 @@ class LlmClientTest extends Unit
         $this->assertSame('approved', $result[0]['verdict']);
     }
 
+    public function testCoordinatorAcceptsSingleObjectInResponseJson(): void
+    {
+        $body = json_encode([
+            'ok' => true,
+            'response_json' => [
+                'id' => 6,
+                'verdict' => 'rejected',
+                'comment' => 'bad species',
+            ],
+        ], JSON_UNESCAPED_UNICODE);
+
+        $result = CoordinatorLlmTransport::parseAnalyzeResponse((string) $body);
+
+        $this->assertCount(1, $result);
+        $this->assertSame(6, $result[0]['id']);
+        $this->assertSame('rejected', $result[0]['verdict']);
+    }
+
     public function testCoordinatorFallsBackToResponseText(): void
     {
         $body = json_encode([
index 8adebe54700eeec8244c53633ed4c2341063634f..2a1d98a37094b92f9dd036181a577d4da0db2ff7 100644 (file)
@@ -53,7 +53,18 @@ class LlmVerifierTest extends Unit
     {
         $raw = [
             ['id' => 10, 'verdict' => 'approved', 'comment' => null],
-            ['id' => 11, 'verdict' => 'rejected', 'comment' => 'Неверный цвет'],
+            [
+                'id' => 11,
+                'verdict' => 'rejected',
+                'comment' => 'Неверный цвет',
+                'corrected_prediction' => [
+                    'category' => 'Срезка',
+                    'subcategory' => 'Розы',
+                    'species' => 'Роза',
+                    'size' => 60,
+                    'color' => 'Белая',
+                ],
+            ],
         ];
 
         $results = LlmVerifier::parseVerifyResults($raw);
@@ -63,6 +74,9 @@ class LlmVerifierTest extends Unit
         $this->assertSame(10, $results[0]->predictionId);
         $this->assertFalse($results[1]->isApproved());
         $this->assertSame('Неверный цвет', $results[1]->comment);
+        $this->assertTrue($results[1]->hasCorrections());
+        $this->assertSame('Срезка', $results[1]->category);
+        $this->assertSame(60.0, $results[1]->size);
     }
 
     public function testParseVerifyResultsSkipsItemsWithoutRequiredFields(): void
index 02d94f7dfbb45fcd9608d0898e7be6b69ab1c3ef..cb1b86de33574a73dfeeb9e03198851ad6a862e4 100644 (file)
@@ -29,7 +29,7 @@ class RuleBasedParserTest extends Unit
         $this->assertSame('Розы', $result->subcategory);
         $this->assertSame('Роза', $result->species);
         $this->assertSame('Premium', $result->sort);
-        $this->assertSame(50, $result->size);
+        $this->assertSame(50.0, $result->size);
         $this->assertSame('Красная', $result->color);
         $this->assertGreaterThanOrEqual(0.9, $result->confidence);
         $this->assertSame('rule', $result->method);
@@ -55,7 +55,7 @@ class RuleBasedParserTest extends Unit
     {
         $result = $this->parser->parse('Хризантема Экстра 70 см Белая');
 
-        $this->assertSame(70, $result->size);
+        $this->assertSame(70.0, $result->size);
         $this->assertSame('Белая', $result->color);
         $this->assertSame('Хризантема', $result->species);
     }
@@ -80,7 +80,7 @@ class RuleBasedParserTest extends Unit
     {
         $result = $this->parser->parse('Роза Эквадор 80 алая');
 
-        $this->assertSame(80, $result->size);
+        $this->assertSame(80.0, $result->size);
         $this->assertSame('Алая', $result->color);
         $this->assertSame('Роза', $result->species);
         $this->assertSame('Розы', $result->subcategory);
@@ -93,4 +93,20 @@ class RuleBasedParserTest extends Unit
         $this->assertSame('Упаковка', $result->category);
         $this->assertNull($result->size);
     }
+
+    public function testExtractsSizeInMeters(): void
+    {
+        $result = $this->parser->parse('Пленка матовая 1м белый');
+
+        $this->assertSame('Упаковка', $result->category);
+        $this->assertSame(1.0, $result->size);
+    }
+
+    public function testExtractsSizeInLiters(): void
+    {
+        $result = $this->parser->parse('Горшок-кашпо Камелия Люкс 3,5л. (Бело-Красный)');
+
+        $this->assertSame('Горшечные_растения', $result->category);
+        $this->assertSame(3.5, $result->size);
+    }
 }
index 4366b8724e6f71f593f1dbb1388f508decba0160..f52d2d4612306c139739930465c137f40b8101f3 100644 (file)
@@ -1,18 +1,44 @@
 <?php
 /** @var \yii\data\ActiveDataProvider $dataProvider */
+/** @var string|null $llmFilter */
+
 use yii\grid\GridView;
 use yii\helpers\Html;
 use yii_app\records\Products1cAutomarkPrediction;
 
 $this->title = 'Авторазметка товаров';
+
+$status = Yii::$app->request->get('status', Products1cAutomarkPrediction::STATUS_PENDING);
+
+$badgeMap = [
+    Products1cAutomarkPrediction::LLM_VERDICT_APPROVED => ['#1f7a1f', 'Одобрено LLM'],
+    Products1cAutomarkPrediction::LLM_VERDICT_REJECTED => ['#c62828', 'Не одобрено LLM'],
+];
+
+$formatSize = static function ($value): string {
+    if ($value === null || (float) $value <= 0.0) {
+        return '—';
+    }
+
+    $formatted = number_format((float) $value, 2, '.', '');
+    return rtrim(rtrim($formatted, '0'), '.');
+};
 ?>
 <div class="container-fluid">
     <h2><?= Html::encode($this->title) ?></h2>
 
     <div class="mb-3">
-        <?= Html::a('Pending', ['index', 'status' => Products1cAutomarkPrediction::STATUS_PENDING], ['class' => 'btn btn-warning']) ?>
-        <?= Html::a('Одобренные', ['index', 'status' => Products1cAutomarkPrediction::STATUS_APPROVED], ['class' => 'btn btn-success']) ?>
-        <?= Html::a('Отклонённые', ['index', 'status' => Products1cAutomarkPrediction::STATUS_REJECTED], ['class' => 'btn btn-secondary']) ?>
+        <?= Html::a('В ожидании', ['index', 'status' => Products1cAutomarkPrediction::STATUS_PENDING, 'llm' => $llmFilter], ['class' => 'btn btn-warning']) ?>
+        <?= Html::a('Одобренные', ['index', 'status' => Products1cAutomarkPrediction::STATUS_APPROVED, 'llm' => $llmFilter], ['class' => 'btn btn-success']) ?>
+        <?= Html::a('Отклонённые', ['index', 'status' => Products1cAutomarkPrediction::STATUS_REJECTED, 'llm' => $llmFilter], ['class' => 'btn btn-secondary']) ?>
+    </div>
+
+    <div class="mb-3">
+        <?= Html::a('Все с LLM', ['index', 'status' => $status, 'llm' => 'verified'], ['class' => 'btn btn-info']) ?>
+        <?= Html::a('Без LLM', ['index', 'status' => $status, 'llm' => 'unverified'], ['class' => 'btn btn-default']) ?>
+        <?= Html::a('Одобрено LLM', ['index', 'status' => $status, 'llm' => Products1cAutomarkPrediction::LLM_VERDICT_APPROVED], ['class' => 'btn btn-success']) ?>
+        <?= Html::a('Не одобрено LLM', ['index', 'status' => $status, 'llm' => Products1cAutomarkPrediction::LLM_VERDICT_REJECTED], ['class' => 'btn btn-danger']) ?>
+        <?= Html::a('Сбросить LLM фильтр', ['index', 'status' => $status], ['class' => 'btn btn-link']) ?>
     </div>
 
     <?= GridView::widget([
@@ -27,12 +53,56 @@ $this->title = 'Авторазметка товаров';
             'species',
             'sort',
             'color',
-            'size',
+            [
+                'attribute' => 'size',
+                'value' => static fn(Products1cAutomarkPrediction $m): string => $formatSize($m->size),
+            ],
             [
                 'label' => 'Confidence',
                 'value' => fn($m) => number_format($m->confidence * 100, 1) . '%',
             ],
-            'method',
+            [
+                'attribute' => 'method',
+                'format' => 'raw',
+                'value' => static function (Products1cAutomarkPrediction $m): string {
+                    $labels = [
+                        Products1cAutomarkPrediction::METHOD_RULE => ['rule', '#374151'],
+                        Products1cAutomarkPrediction::METHOD_SIMILARITY => ['similarity', '#374151'],
+                        Products1cAutomarkPrediction::METHOD_LLM => ['llm', '#1565c0'],
+                    ];
+                    [$label, $color] = $labels[$m->method] ?? [$m->method, '#374151'];
+
+                    return Html::tag('span', Html::encode($label), [
+                        'style' => sprintf('color:%s;font-weight:600;', $color),
+                    ]);
+                },
+            ],
+            [
+                'label' => 'LLM',
+                'format' => 'raw',
+                'value' => static function (Products1cAutomarkPrediction $m) use ($badgeMap): string {
+                    if ($m->llm_verdict === null) {
+                        return Html::tag('span', 'Не проверено LLM', [
+                            'style' => 'color:#6b7280;',
+                        ]);
+                    }
+
+                    [$color, $label] = $badgeMap[$m->llm_verdict] ?? ['#374151', $m->llm_verdict];
+                    return Html::tag('span', $label, [
+                        'style' => sprintf('color:%s;font-weight:700;', $color),
+                    ]);
+                },
+            ],
+            [
+                'attribute' => 'llm_verified_at',
+                'label' => 'LLM at',
+                'value' => static fn(Products1cAutomarkPrediction $m): string => $m->llm_verified_at ?? '—',
+            ],
+            [
+                'attribute' => 'llm_comment',
+                'label' => 'LLM comment',
+                'value' => static fn(Products1cAutomarkPrediction $m): string => $m->llm_comment ?? '—',
+            ],
             [
                 'label' => 'Действия',
                 'format' => 'raw',
index 8e3a27c3a66907eec03753425afc49e37f905a60..c4486de714b503fe74ab83c4b37425fd86ea455e 100644 (file)
@@ -4,6 +4,10 @@ use yii\helpers\Html;
 use yii_app\records\Products1cAutomarkPrediction;
 
 $this->title = 'Проверка разметки #' . $prediction->id;
+$formattedSize = '—';
+if ($prediction->size !== null && (float) $prediction->size > 0.0) {
+    $formattedSize = rtrim(rtrim(number_format((float) $prediction->size, 2, '.', ''), '0'), '.');
+}
 ?>
 <div class="container">
     <h2><?= Html::encode($this->title) ?></h2>
@@ -30,7 +34,7 @@ $this->title = 'Проверка разметки #' . $prediction->id;
                 <tr><th>Вид</th><td><?= Html::encode($prediction->species ?? '—') ?></td></tr>
                 <tr><th>Сорт</th><td><?= Html::encode($prediction->sort ?? '—') ?></td></tr>
                 <tr><th>Тип</th><td><?= Html::encode($prediction->type ?? '—') ?></td></tr>
-                <tr><th>Размер</th><td><?= $prediction->size ?? '—' ?></td></tr>
+                <tr><th>Размер</th><td><?= Html::encode($formattedSize) ?></td></tr>
                 <tr><th>Цвет</th><td><?= Html::encode($prediction->color ?? '—') ?></td></tr>
             </table>
         </div>