From 20bb85a7ee5c9d3ef89dec82d34c393d8290d38a Mon Sep 17 00:00:00 2001 From: fomichev Date: Tue, 5 May 2026 12:19:41 +0300 Subject: [PATCH] llm verification fixes --- erp24/commands/AutoMarkController.php | 73 +++++++- erp24/config/env.php | 4 + erp24/controllers/AutoMarkController.php | 17 +- ...o_decimal_in_automark_and_nomenclature.php | 41 ++++ .../records/Products1cAutomarkPrediction.php | 5 +- erp24/records/Products1cNomenclature.php | 4 +- .../records/Products1cNomenclatureSearch.php | 2 +- erp24/services/AutoMarkService.php | 44 ++++- .../automark/CoordinatorLlmTransport.php | 5 +- erp24/services/automark/LlmClient.php | 40 +++- erp24/services/automark/LlmVerifier.php | 175 ++++++++++++++++-- erp24/services/automark/LlmVerifyResult.php | 14 ++ erp24/services/automark/ParseResult.php | 2 +- erp24/services/automark/RuleBasedParser.php | 27 ++- erp24/services/automark/SimilarityMatcher.php | 3 +- .../unit/services/automark/LlmClientTest.php | 28 +++ .../services/automark/LlmVerifierTest.php | 16 +- .../services/automark/RuleBasedParserTest.php | 22 ++- erp24/views/auto-mark/index.php | 80 +++++++- erp24/views/auto-mark/review.php | 6 +- 20 files changed, 557 insertions(+), 51 deletions(-) create mode 100644 erp24/migrations/m260505_120000_change_size_to_decimal_in_automark_and_nomenclature.php diff --git a/erp24/commands/AutoMarkController.php b/erp24/commands/AutoMarkController.php index f4fd225b..2fd84408 100644 --- a/erp24/commands/AutoMarkController.php +++ b/erp24/commands/AutoMarkController.php @@ -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 $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) { diff --git a/erp24/config/env.php b/erp24/config/env.php index a3c5057a..7b640efe 100644 --- a/erp24/config/env.php +++ b/erp24/config/env.php @@ -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"); diff --git a/erp24/controllers/AutoMarkController.php b/erp24/controllers/AutoMarkController.php index b5738f9b..37e164d8 100644 --- a/erp24/controllers/AutoMarkController.php +++ b/erp24/controllers/AutoMarkController.php @@ -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 index 00000000..9c13c9e2 --- /dev/null +++ b/erp24/migrations/m260505_120000_change_size_to_decimal_in_automark_and_nomenclature.php @@ -0,0 +1,41 @@ +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()); + } + } +} diff --git a/erp24/records/Products1cAutomarkPrediction.php b/erp24/records/Products1cAutomarkPrediction.php index 46b46372..43800904 100644 --- a/erp24/records/Products1cAutomarkPrediction.php +++ b/erp24/records/Products1cAutomarkPrediction.php @@ -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]], diff --git a/erp24/records/Products1cNomenclature.php b/erp24/records/Products1cNomenclature.php index 36c39858..bc524e29 100644 --- a/erp24/records/Products1cNomenclature.php +++ b/erp24/records/Products1cNomenclature.php @@ -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'], diff --git a/erp24/records/Products1cNomenclatureSearch.php b/erp24/records/Products1cNomenclatureSearch.php index 100d509a..52bd331c 100644 --- a/erp24/records/Products1cNomenclatureSearch.php +++ b/erp24/records/Products1cNomenclatureSearch.php @@ -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'], ]; } diff --git a/erp24/services/AutoMarkService.php b/erp24/services/AutoMarkService.php index bceec595..494090b2 100644 --- a/erp24/services/AutoMarkService.php +++ b/erp24/services/AutoMarkService.php @@ -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 $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 */ diff --git a/erp24/services/automark/CoordinatorLlmTransport.php b/erp24/services/automark/CoordinatorLlmTransport.php index 250c4fa8..98b9df5c 100644 --- a/erp24/services/automark/CoordinatorLlmTransport.php +++ b/erp24/services/automark/CoordinatorLlmTransport.php @@ -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; diff --git a/erp24/services/automark/LlmClient.php b/erp24/services/automark/LlmClient.php index d89146df..4cc22821 100644 --- a/erp24/services/automark/LlmClient.php +++ b/erp24/services/automark/LlmClient.php @@ -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>|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 $env * @return array diff --git a/erp24/services/automark/LlmVerifier.php b/erp24/services/automark/LlmVerifier.php index d08721b0..cbf2b86e 100644 --- a/erp24/services/automark/LlmVerifier.php +++ b/erp24/services/automark/LlmVerifier.php @@ -49,9 +49,10 @@ TAXONOMY; /** * Верифицировать все pending-предсказания без LLM-вердикта. * + * @param callable|null $progressCallback function(string $event, array $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 + */ + 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":,"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":,"verdict":"approved"|"rejected","comment":"<причина если rejected, иначе null>","corrected_prediction":{"category":"","subcategory":"","species":"","size":,"color":""}}] +Если 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; + } } diff --git a/erp24/services/automark/LlmVerifyResult.php b/erp24/services/automark/LlmVerifyResult.php index 4b9c6a89..721b7337 100644 --- a/erp24/services/automark/LlmVerifyResult.php +++ b/erp24/services/automark/LlmVerifyResult.php @@ -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; + } } diff --git a/erp24/services/automark/ParseResult.php b/erp24/services/automark/ParseResult.php index a7ea9c96..5aa9e87e 100644 --- a/erp24/services/automark/ParseResult.php +++ b/erp24/services/automark/ParseResult.php @@ -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, diff --git a/erp24/services/automark/RuleBasedParser.php b/erp24/services/automark/RuleBasedParser.php index 80ea9141..c73d89cd 100644 --- a/erp24/services/automark/RuleBasedParser.php +++ b/erp24/services/automark/RuleBasedParser.php @@ -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; diff --git a/erp24/services/automark/SimilarityMatcher.php b/erp24/services/automark/SimilarityMatcher.php index 45623d12..9025380b 100644 --- a/erp24/services/automark/SimilarityMatcher.php +++ b/erp24/services/automark/SimilarityMatcher.php @@ -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, diff --git a/erp24/tests/unit/services/automark/LlmClientTest.php b/erp24/tests/unit/services/automark/LlmClientTest.php index 8d8352b8..ee59ea2b 100644 --- a/erp24/tests/unit/services/automark/LlmClientTest.php +++ b/erp24/tests/unit/services/automark/LlmClientTest.php @@ -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([ diff --git a/erp24/tests/unit/services/automark/LlmVerifierTest.php b/erp24/tests/unit/services/automark/LlmVerifierTest.php index 8adebe54..2a1d98a3 100644 --- a/erp24/tests/unit/services/automark/LlmVerifierTest.php +++ b/erp24/tests/unit/services/automark/LlmVerifierTest.php @@ -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 diff --git a/erp24/tests/unit/services/automark/RuleBasedParserTest.php b/erp24/tests/unit/services/automark/RuleBasedParserTest.php index 02d94f7d..cb1b86de 100644 --- a/erp24/tests/unit/services/automark/RuleBasedParserTest.php +++ b/erp24/tests/unit/services/automark/RuleBasedParserTest.php @@ -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); + } } diff --git a/erp24/views/auto-mark/index.php b/erp24/views/auto-mark/index.php index 4366b872..f52d2d46 100644 --- a/erp24/views/auto-mark/index.php +++ b/erp24/views/auto-mark/index.php @@ -1,18 +1,44 @@ 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'), '.'); +}; ?>

title) ?>

- Products1cAutomarkPrediction::STATUS_PENDING], ['class' => 'btn btn-warning']) ?> - Products1cAutomarkPrediction::STATUS_APPROVED], ['class' => 'btn btn-success']) ?> - Products1cAutomarkPrediction::STATUS_REJECTED], ['class' => 'btn btn-secondary']) ?> + Products1cAutomarkPrediction::STATUS_PENDING, 'llm' => $llmFilter], ['class' => 'btn btn-warning']) ?> + Products1cAutomarkPrediction::STATUS_APPROVED, 'llm' => $llmFilter], ['class' => 'btn btn-success']) ?> + Products1cAutomarkPrediction::STATUS_REJECTED, 'llm' => $llmFilter], ['class' => 'btn btn-secondary']) ?> +
+ +
+ $status, 'llm' => 'verified'], ['class' => 'btn btn-info']) ?> + $status, 'llm' => 'unverified'], ['class' => 'btn btn-default']) ?> + $status, 'llm' => Products1cAutomarkPrediction::LLM_VERDICT_APPROVED], ['class' => 'btn btn-success']) ?> + $status, 'llm' => Products1cAutomarkPrediction::LLM_VERDICT_REJECTED], ['class' => 'btn btn-danger']) ?> + $status], ['class' => 'btn btn-link']) ?>
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', diff --git a/erp24/views/auto-mark/review.php b/erp24/views/auto-mark/review.php index 8e3a27c3..c4486de7 100644 --- a/erp24/views/auto-mark/review.php +++ b/erp24/views/auto-mark/review.php @@ -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'), '.'); +} ?>

title) ?>

@@ -30,7 +34,7 @@ $this->title = 'Проверка разметки #' . $prediction->id; Видspecies ?? '—') ?> Сортsort ?? '—') ?> Типtype ?? '—') ?> - Размерsize ?? '—' ?> + Размер Цветcolor ?? '—') ?>
-- 2.39.5