From: fomichev Date: Thu, 30 Apr 2026 15:18:58 +0000 (+0300) Subject: feat(ERP-292): LlmClient — HTTP-клиент и парсинг ответа X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=2a3fecdb901c9acb4b7a04aeff67a9ce70730c5e;p=erp24_rep%2Fyii-erp24%2F.git feat(ERP-292): LlmClient — HTTP-клиент и парсинг ответа Co-Authored-By: Claude Sonnet 4.6 --- diff --git a/erp24/services/automark/LlmClient.php b/erp24/services/automark/LlmClient.php new file mode 100644 index 00000000..1c5a6f28 --- /dev/null +++ b/erp24/services/automark/LlmClient.php @@ -0,0 +1,83 @@ +endpoint = (string)(getenv('LLM_ENDPOINT') ?: ''); + $this->apiKey = (string)(getenv('LLM_API_KEY') ?: ''); + $this->model = (string)(getenv('LLM_MODEL') ?: 'llama3.2'); + $this->http = new Client(['timeout' => 120]); + } + + /** + * Отправить запрос к LLM и вернуть распарсенный массив вердиктов. + * + * @throws \RuntimeException при сетевой ошибке или невалидном JSON + */ + public function chat(string $systemPrompt, string $userMessage): array + { + if ($this->endpoint === '') { + throw new \RuntimeException('LLM_ENDPOINT не задан в env'); + } + + try { + $response = $this->http->post($this->endpoint, [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'model' => $this->model, + 'temperature' => 0.1, + 'max_tokens' => 2000, + 'messages' => [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $userMessage], + ], + ], + ]); + } catch (GuzzleException $e) { + throw new \RuntimeException('LLM HTTP ошибка: ' . $e->getMessage(), 0, $e); + } + + $body = (string) $response->getBody(); + $decoded = json_decode($body, true); + $content = $decoded['choices'][0]['message']['content'] ?? ''; + + return self::parseResponse($content); + } + + /** + * Извлечь JSON-массив из текстового ответа LLM. + * Модель иногда оборачивает JSON в пояснительный текст. + */ + public static function parseResponse(string $raw): array + { + $direct = json_decode(trim($raw), true); + if (is_array($direct) && array_is_list($direct)) { + return $direct; + } + + if (preg_match('/\[.*\]/su', $raw, $m)) { + $extracted = json_decode($m[0], true); + if (is_array($extracted) && array_is_list($extracted)) { + return $extracted; + } + } + + throw new \RuntimeException('LLM вернул невалидный JSON: ' . mb_substr($raw, 0, 200)); + } +} diff --git a/erp24/tests/unit/services/automark/LlmClientTest.php b/erp24/tests/unit/services/automark/LlmClientTest.php new file mode 100644 index 00000000..5a600832 --- /dev/null +++ b/erp24/tests/unit/services/automark/LlmClientTest.php @@ -0,0 +1,44 @@ +assertSame( + [['id' => 1, 'verdict' => 'approved', 'comment' => null]], + LlmClient::parseResponse($raw) + ); + } + + public function testExtractsJsonEmbeddedInText(): void + { + $raw = "Вот результат:\n[{\"id\":2,\"verdict\":\"rejected\",\"comment\":\"Неверная категория\"}]\nГотово."; + $result = LlmClient::parseResponse($raw); + $this->assertSame('rejected', $result[0]['verdict']); + $this->assertSame(2, $result[0]['id']); + } + + public function testThrowsOnUnparsableResponse(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('LLM вернул невалидный JSON'); + LlmClient::parseResponse('Я не знаю что ответить.'); + } + + public function testThrowsOnNonArrayJson(): void + { + $this->expectException(\RuntimeException::class); + LlmClient::parseResponse('{"error":"bad request"}'); + } +}