114 lines
4.0 KiB
PHP
114 lines
4.0 KiB
PHP
<?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 OpenAiPredictionProvider 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.openai.api_key')) {
|
||
return null;
|
||
}
|
||
|
||
$priceList = $prices->sortBy('date')
|
||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
||
->implode("\n");
|
||
|
||
$url = 'https://api.openai.com/v1/chat/completions';
|
||
|
||
try {
|
||
$response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15)
|
||
->withToken(config('services.openai.api_key'))
|
||
->post($url, [
|
||
'model' => config('services.openai.model', 'gpt-4o-mini'),
|
||
'response_format' => [
|
||
'type' => 'json_schema',
|
||
'json_schema' => [
|
||
'name' => 'oil_prediction',
|
||
'strict' => true,
|
||
'schema' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
|
||
'confidence' => ['type' => 'integer'],
|
||
'reasoning' => ['type' => 'string'],
|
||
],
|
||
'required' => ['direction', 'confidence', 'reasoning'],
|
||
'additionalProperties' => false,
|
||
],
|
||
],
|
||
],
|
||
'messages' => [[
|
||
'role' => 'user',
|
||
'content' => $this->prompt($priceList),
|
||
]],
|
||
]));
|
||
|
||
if (! $response->successful()) {
|
||
Log::error('OpenAiPredictionProvider: request failed', ['status' => $response->status()]);
|
||
|
||
return null;
|
||
}
|
||
|
||
$data = json_decode($response->json('choices.0.message.content') ?? '{}', true);
|
||
|
||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||
Log::error('OpenAiPredictionProvider: unexpected response format', ['data' => $data]);
|
||
|
||
return null;
|
||
}
|
||
|
||
$direction = TrendDirection::tryFrom($data['direction']);
|
||
|
||
if ($direction === null) {
|
||
Log::error('OpenAiPredictionProvider: 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('OpenAiPredictionProvider: 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 3–5 days.
|
||
|
||
Recent Brent crude prices (USD/barrel):
|
||
{$priceList}
|
||
|
||
Respond with direction (rising, falling, or flat), a confidence score (0–85),
|
||
and a one-sentence reasoning.
|
||
PROMPT;
|
||
}
|
||
}
|