]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
feat(ERP-292): LlmClient — HTTP-клиент и парсинг ответа
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Thu, 30 Apr 2026 15:18:58 +0000 (18:18 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Thu, 30 Apr 2026 15:18:58 +0000 (18:18 +0300)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
erp24/services/automark/LlmClient.php [new file with mode: 0644]
erp24/tests/unit/services/automark/LlmClientTest.php [new file with mode: 0644]

diff --git a/erp24/services/automark/LlmClient.php b/erp24/services/automark/LlmClient.php
new file mode 100644 (file)
index 0000000..1c5a6f2
--- /dev/null
@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\automark;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
+
+class LlmClient
+{
+    private Client $http;
+    private string $endpoint;
+    private string $apiKey;
+    private string $model;
+
+    public function __construct()
+    {
+        $this->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 (file)
index 0000000..5a60083
--- /dev/null
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services\automark;
+
+use Codeception\Test\Unit;
+use yii_app\services\automark\LlmClient;
+
+/**
+ * @covers \yii_app\services\automark\LlmClient
+ */
+class LlmClientTest extends Unit
+{
+    public function testExtractsJsonFromCleanResponse(): void
+    {
+        $raw = '[{"id":1,"verdict":"approved","comment":null}]';
+        $this->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"}');
+    }
+}