feat: add LLM prediction providers with structured output support
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ovidiu U
2026-04-07 14:42:44 +01:00
parent e9612666e3
commit 6a80c11f38
18 changed files with 1101 additions and 484 deletions

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\ApiLogger;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class GeminiPredictionProvider implements OilPredictionProvider
{
private const int LLM_MAX_CONFIDENCE = 85;
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
public function predict(Collection $prices): ?PricePrediction
{
if (! config('services.gemini.api_key')) {
return null;
}
$priceList = $prices->sortBy('date')
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
->implode("\n");
$model = config('services.gemini.model', 'gemini-2.0-flash');
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent";
try {
$response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15)
->withQueryParameters(['key' => config('services.gemini.api_key')])
->post($url, [
'contents' => [[
'parts' => [['text' => $this->prompt($priceList)]],
]],
'generationConfig' => [
'responseMimeType' => 'application/json',
'responseSchema' => [
'type' => 'OBJECT',
'properties' => [
'direction' => [
'type' => 'STRING',
'enum' => ['rising', 'falling', 'flat'],
],
'confidence' => ['type' => 'INTEGER'],
'reasoning' => ['type' => 'STRING'],
],
'required' => ['direction', 'confidence', 'reasoning'],
],
],
]));
if (! $response->successful()) {
Log::error('GeminiPredictionProvider: request failed', ['status' => $response->status()]);
return null;
}
$text = $response->json('candidates.0.content.parts.0.text') ?? '';
$data = json_decode($text, true);
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error('GeminiPredictionProvider: unexpected response format', ['text' => $text]);
return null;
}
$direction = TrendDirection::tryFrom($data['direction']);
if ($direction === null) {
Log::error('GeminiPredictionProvider: invalid direction', ['direction' => $data['direction']]);
return null;
}
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => PredictionSource::Llm,
'direction' => $direction,
'confidence' => min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE),
'reasoning' => $data['reasoning'],
'generated_at' => now(),
]);
} catch (Throwable $e) {
Log::error('GeminiPredictionProvider: predict failed', ['error' => $e->getMessage()]);
return null;
}
}
private function prompt(string $priceList): string
{
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Respond with direction (rising, falling, or flat), a confidence score (085),
and a one-sentence reasoning.
PROMPT;
}
}