Skip to content

Commit efbb1fe

Browse files
committed
fix: improve Yandex AI provider response handling and add progress indicators
- Fix Yandex API response parsing to support both 'summary' and 'content' fields - Handle different response structures for different models (gpt-oss-120b vs aliceai-llm) - Add progress indicators in AI commands showing provider, model, and request status - Add loading() method to BaseCliCommand for better user feedback - Improve error messages for truncated or invalid responses
1 parent 6d8fbbb commit efbb1fe

File tree

3 files changed

+374
-1
lines changed

3 files changed

+374
-1
lines changed
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace tommyknocker\pdodb\ai\providers;
6+
7+
use tommyknocker\pdodb\ai\BaseAiProvider;
8+
use tommyknocker\pdodb\exceptions\QueryException;
9+
10+
/**
11+
* Yandex Cloud provider implementation.
12+
* Uses Yandex-specific API format (not OpenAI-compatible).
13+
*
14+
* @see https://cloud.yandex.ru/docs/ai-gpt/api-ref/
15+
*/
16+
class YandexProvider extends BaseAiProvider
17+
{
18+
private const string API_URL = 'https://rest-assistant.api.cloud.yandex.net/v1/responses';
19+
private const string DEFAULT_MODEL = 'gpt-oss-120b/latest';
20+
private const float DEFAULT_TEMPERATURE = 0.7;
21+
private const int DEFAULT_MAX_TOKENS = 4000; // Increased default for Yandex
22+
private const string HEADER_AUTHORIZATION = 'Authorization';
23+
private const string HEADER_BEARER_PREFIX = 'Bearer ';
24+
private const string HEADER_X_FOLDER_ID = 'x-folder-id';
25+
private const string RESPONSE_KEY_OUTPUT = 'output';
26+
private const string RESPONSE_KEY_SUMMARY = 'summary';
27+
private const string RESPONSE_KEY_CONTENT = 'content';
28+
private const string RESPONSE_KEY_TEXT = 'text';
29+
private const string MODEL_PREFIX = 'gpt://';
30+
31+
protected string $apiUrl = self::API_URL;
32+
protected ?string $folderId = null;
33+
34+
protected function initializeDefaults(): void
35+
{
36+
$this->folderId = $this->config->getProviderSetting('yandex', 'folder_id', null);
37+
$model = $this->config->getProviderSetting('yandex', 'model', self::DEFAULT_MODEL);
38+
// Model format: gpt://{folder_id}/{model} or just model name
39+
if ($this->folderId !== null && !str_starts_with($model, self::MODEL_PREFIX)) {
40+
$this->model = self::MODEL_PREFIX . $this->folderId . '/' . $model;
41+
} else {
42+
$this->model = $model;
43+
}
44+
$this->temperature = (float)$this->config->getProviderSetting('yandex', 'temperature', self::DEFAULT_TEMPERATURE);
45+
$this->maxTokens = (int)$this->config->getProviderSetting('yandex', 'max_tokens', self::DEFAULT_MAX_TOKENS);
46+
}
47+
48+
public function getProviderName(): string
49+
{
50+
return 'yandex';
51+
}
52+
53+
public function isAvailable(): bool
54+
{
55+
return $this->config->hasApiKey('yandex') && $this->folderId !== null;
56+
}
57+
58+
public function analyzeQuery(string $sql, array $context = []): string
59+
{
60+
$this->ensureAvailable();
61+
62+
$systemPrompt = $this->buildSystemPrompt('query');
63+
$userPrompt = $this->buildQueryPrompt($sql, $context);
64+
65+
return $this->callApi($systemPrompt, $userPrompt);
66+
}
67+
68+
public function analyzeSchema(array $schema, array $context = []): string
69+
{
70+
$this->ensureAvailable();
71+
72+
$systemPrompt = $this->buildSystemPrompt('schema');
73+
$userPrompt = $this->buildSchemaPrompt($schema, $context);
74+
75+
return $this->callApi($systemPrompt, $userPrompt);
76+
}
77+
78+
public function suggestOptimizations(array $analysis, array $context = []): string
79+
{
80+
$this->ensureAvailable();
81+
82+
$systemPrompt = $this->buildSystemPrompt('optimization');
83+
$userPrompt = $this->buildOptimizationPrompt($analysis, $context);
84+
85+
return $this->callApi($systemPrompt, $userPrompt);
86+
}
87+
88+
/**
89+
* Call Yandex Cloud API.
90+
*
91+
* @param string $systemPrompt System prompt (used as instructions)
92+
* @param string $userPrompt User prompt (used as input)
93+
*
94+
* @return string AI response
95+
*/
96+
protected function callApi(string $systemPrompt, string $userPrompt): string
97+
{
98+
$apiKey = $this->config->getApiKey('yandex');
99+
if ($apiKey === null) {
100+
throw new QueryException('Yandex API key not configured', 0);
101+
}
102+
103+
if ($this->folderId === null) {
104+
throw new QueryException('Yandex folder ID not configured', 0);
105+
}
106+
107+
// Yandex API uses different format: instructions, input, max_output_tokens
108+
$data = [
109+
'model' => $this->model,
110+
'instructions' => $systemPrompt,
111+
'input' => $userPrompt,
112+
'temperature' => $this->temperature,
113+
'max_output_tokens' => $this->maxTokens,
114+
];
115+
116+
$headers = [
117+
self::HEADER_AUTHORIZATION => self::HEADER_BEARER_PREFIX . $apiKey,
118+
self::HEADER_X_FOLDER_ID => $this->folderId,
119+
];
120+
121+
$response = $this->makeRequest($this->apiUrl, $data, $headers);
122+
123+
// Check for errors
124+
if (isset($response['error']) && $response['error'] !== '') {
125+
$error = $response['error'];
126+
$errorMessage = is_string($error) ? $error : json_encode($error);
127+
throw new QueryException(
128+
'Yandex API error: ' . $errorMessage,
129+
0
130+
);
131+
}
132+
133+
// Check if response was truncated
134+
$truncated = false;
135+
$truncatedReason = null;
136+
if (isset($response['incomplete_details']) && is_array($response['incomplete_details'])) {
137+
$truncated = ($response['incomplete_details']['valid'] ?? false) === true;
138+
$truncatedReason = $response['incomplete_details']['reason'] ?? null;
139+
}
140+
141+
// Yandex API returns output as an array with summary/content objects containing text
142+
// Structure: output[0].summary[0].text or output[0].content[0].text
143+
if (isset($response[self::RESPONSE_KEY_OUTPUT]) && is_array($response[self::RESPONSE_KEY_OUTPUT])) {
144+
$outputArray = $response[self::RESPONSE_KEY_OUTPUT];
145+
if (!empty($outputArray) && is_array($outputArray[0])) {
146+
$firstOutput = $outputArray[0];
147+
148+
// Try content first (for models like aliceai-llm) - this is the correct field
149+
$textArray = null;
150+
if (isset($firstOutput[self::RESPONSE_KEY_CONTENT]) && is_array($firstOutput[self::RESPONSE_KEY_CONTENT])) {
151+
// Try content (for models like aliceai-llm)
152+
$textArray = $firstOutput[self::RESPONSE_KEY_CONTENT];
153+
} elseif (isset($firstOutput[self::RESPONSE_KEY_SUMMARY]) && is_array($firstOutput[self::RESPONSE_KEY_SUMMARY])) {
154+
// Try summary (for some models like gpt-oss-120b) - but may contain prompt, not response
155+
$textArray = $firstOutput[self::RESPONSE_KEY_SUMMARY];
156+
}
157+
158+
if ($textArray !== null) {
159+
// Collect all text from all items
160+
$textParts = [];
161+
foreach ($textArray as $item) {
162+
// Handle both object with 'text' field and direct string
163+
if (is_string($item)) {
164+
$textParts[] = $item;
165+
} elseif (is_array($item) && isset($item[self::RESPONSE_KEY_TEXT]) && is_string($item[self::RESPONSE_KEY_TEXT])) {
166+
$textParts[] = $item[self::RESPONSE_KEY_TEXT];
167+
}
168+
}
169+
if (!empty($textParts)) {
170+
$output = implode('', $textParts);
171+
// Filter out prompt-like content (contains instructions or user prompt)
172+
// If output looks like a prompt rather than a response, skip it
173+
$lowerOutput = strtolower($output);
174+
if (str_contains($lowerOutput, 'the user asks') ||
175+
str_contains($lowerOutput, 'need to give recommendations') ||
176+
str_contains($lowerOutput, 'provide suggestions') ||
177+
(str_contains($lowerOutput, 'analyze') && str_contains($lowerOutput, 'query') && strlen($output) < 500)) {
178+
// This looks like a prompt, not a response - continue to error
179+
} else {
180+
if ($truncated && $truncatedReason !== null) {
181+
$warning = "\n\n[Note: Response was truncated due to {$truncatedReason}. Consider increasing max_output_tokens.]";
182+
return $output . $warning;
183+
}
184+
return $output;
185+
}
186+
}
187+
}
188+
}
189+
}
190+
191+
// If output is missing but response was truncated, provide helpful error
192+
if ($truncated) {
193+
$responseJson = json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
194+
if (!is_string($responseJson)) {
195+
$responseJson = 'Unable to encode response';
196+
}
197+
$responseStr = substr($responseJson, 0, 1000);
198+
throw new QueryException(
199+
'Yandex API response was truncated due to max_output_tokens, but output is missing or invalid. ' .
200+
'Please increase max_output_tokens. Response: ' . $responseStr,
201+
0
202+
);
203+
}
204+
205+
// If output is missing and not truncated, return detailed error
206+
$responseJson = json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
207+
if (!is_string($responseJson)) {
208+
$responseJson = 'Unable to encode response';
209+
}
210+
$responseStr = substr($responseJson, 0, 1000);
211+
throw new QueryException(
212+
'Invalid response format from Yandex API: missing or invalid "output" key. Expected structure: output[0].summary[0].text or output[0].content[0].text. Response: ' . $responseStr,
213+
0
214+
);
215+
}
216+
217+
/**
218+
* Build system prompt based on analysis type.
219+
*/
220+
protected function buildSystemPrompt(string $type): string
221+
{
222+
$basePrompt = 'You are an expert database performance analyst. Provide clear, actionable recommendations for database optimization.';
223+
224+
$typePrompts = [
225+
'query' => 'Analyze SQL queries and provide optimization suggestions. Focus on index usage, query structure, and performance bottlenecks.',
226+
'schema' => 'Analyze database schema and provide recommendations for indexes, constraints, and table structure improvements.',
227+
'optimization' => 'Review existing analysis results and provide additional optimization suggestions. Build upon the existing recommendations.',
228+
];
229+
230+
return $basePrompt . ' ' . ($typePrompts[$type] ?? '');
231+
}
232+
233+
/**
234+
* Build prompt for query analysis.
235+
*
236+
* @param array<string, mixed> $context
237+
*/
238+
protected function buildQueryPrompt(string $sql, array $context): string
239+
{
240+
$prompt = "Analyze the following SQL query and provide optimization recommendations:\n\n";
241+
$prompt .= "SQL Query:\n```sql\n{$sql}\n```\n\n";
242+
243+
if (!empty($context)) {
244+
$prompt .= $this->formatContext($context);
245+
}
246+
247+
$prompt .= "\n\nProvide specific, actionable recommendations including:\n";
248+
$prompt .= "- Index suggestions\n";
249+
$prompt .= "- Query structure improvements\n";
250+
$prompt .= "- Performance bottlenecks\n";
251+
$prompt .= '- Estimated impact of optimizations';
252+
253+
return $prompt;
254+
}
255+
256+
/**
257+
* Build prompt for schema analysis.
258+
*
259+
* @param array<string, mixed> $schema
260+
* @param array<string, mixed> $context
261+
*/
262+
protected function buildSchemaPrompt(array $schema, array $context): string
263+
{
264+
$prompt = "Analyze the following database schema and provide optimization recommendations:\n\n";
265+
$prompt .= $this->formatContext(array_merge(['schema' => $schema], $context));
266+
267+
$prompt .= "\n\nProvide specific recommendations for:\n";
268+
$prompt .= "- Missing indexes\n";
269+
$prompt .= "- Redundant indexes\n";
270+
$prompt .= "- Table structure improvements\n";
271+
$prompt .= '- Foreign key optimizations';
272+
273+
return $prompt;
274+
}
275+
276+
/**
277+
* Build prompt for optimization suggestions.
278+
*
279+
* @param array<string, mixed> $analysis
280+
* @param array<string, mixed> $context
281+
*/
282+
protected function buildOptimizationPrompt(array $analysis, array $context): string
283+
{
284+
$prompt = "Review the following database analysis and provide additional optimization suggestions:\n\n";
285+
$prompt .= $this->formatContext(array_merge(['existing_analysis' => $analysis], $context));
286+
287+
$prompt .= "\n\nProvide additional recommendations that complement the existing analysis.";
288+
289+
return $prompt;
290+
}
291+
292+
/**
293+
* Ensure provider is available.
294+
*
295+
* @throws QueryException If provider is not available
296+
*/
297+
protected function ensureAvailable(): void
298+
{
299+
if (!$this->isAvailable()) {
300+
throw new QueryException(
301+
'Yandex provider is not available. Please configure PDODB_AI_YANDEX_KEY and PDODB_AI_YANDEX_FOLDER_ID environment variables.',
302+
0
303+
);
304+
}
305+
}
306+
}
307+

src/cli/BaseCliCommand.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,4 +513,16 @@ protected static function warning(string $message): void
513513
{
514514
echo "{$message}\n";
515515
}
516+
517+
/**
518+
* Show loading message.
519+
*
520+
* @param string $message Loading message
521+
*/
522+
protected static function loading(string $message): void
523+
{
524+
if (getenv('PHPUNIT') === false) {
525+
echo "{$message}...\n";
526+
}
527+
}
516528
}

0 commit comments

Comments
 (0)