--- /dev/null
+<?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));
+ }
+}
--- /dev/null
+<?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"}');
+ }
+}