refactor: extract AbstractLlmPredictionProvider for shared boilerplate
Anthropic, Gemini, and OpenAi providers each repeated: API-key gate, chronological price-list building, response validation (direction/confidence/reasoning), TrendDirection::tryFrom, confidence cap at 85, and the top-level try/catch + Log::error. Now in AbstractLlmPredictionProvider: - LLM_MAX_CONFIDENCE constant - buildPriceList(Collection) helper - buildPrediction(input, ?source) — handles direction validation, confidence cap, model construction - defaultPrompt(priceList) — shared by Gemini and OpenAi - Default predict() flow (apiKey + callProvider + buildPrediction + try/catch). Gemini and OpenAi only implement apiKey() and callProvider(). Anthropic overrides predict() because of its multi-phase web-search + forced-tool flow but reuses the helpers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
99
app/Services/LlmPrediction/AbstractLlmPredictionProvider.php
Normal file
99
app/Services/LlmPrediction/AbstractLlmPredictionProvider.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?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\Log;
|
||||
use Throwable;
|
||||
|
||||
abstract class AbstractLlmPredictionProvider implements OilPredictionProvider
|
||||
{
|
||||
protected const int LLM_MAX_CONFIDENCE = 85;
|
||||
|
||||
public function __construct(
|
||||
protected readonly ApiLogger $apiLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Default flow: gate on API key, call the provider, normalise the payload
|
||||
* to a PricePrediction. Subclasses with multi-phase flows (e.g. Anthropic
|
||||
* web-search) override `predict()` directly and reuse the helper methods.
|
||||
*/
|
||||
public function predict(Collection $prices): ?PricePrediction
|
||||
{
|
||||
$apiKey = $this->apiKey();
|
||||
|
||||
if ($apiKey === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->callProvider($apiKey, $this->buildPriceList($prices));
|
||||
|
||||
return $payload === null ? null : $this->buildPrediction($payload);
|
||||
} catch (Throwable $e) {
|
||||
Log::error(static::class.': predict failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the configured API key or null if not set. */
|
||||
abstract protected function apiKey(): ?string;
|
||||
|
||||
/**
|
||||
* Make the provider HTTP call and return the normalised payload, or null
|
||||
* on failure (already logged by the implementer).
|
||||
*
|
||||
* @return array{direction: string, confidence: int, reasoning: string}|null
|
||||
*/
|
||||
abstract protected function callProvider(string $apiKey, string $priceList): ?array;
|
||||
|
||||
/** @param Collection<int, BrentPrice> $prices */
|
||||
protected function buildPriceList(Collection $prices): string
|
||||
{
|
||||
return $prices->sortBy('date')
|
||||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
||||
->implode("\n");
|
||||
}
|
||||
|
||||
/** @param array{direction: string, confidence: int, reasoning: string} $input */
|
||||
protected function buildPrediction(array $input, PredictionSource $source = PredictionSource::Llm): ?PricePrediction
|
||||
{
|
||||
$direction = TrendDirection::tryFrom($input['direction'] ?? '');
|
||||
|
||||
if ($direction === null) {
|
||||
Log::error(static::class.': invalid direction', ['input' => $input]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PricePrediction([
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => $source,
|
||||
'direction' => $direction,
|
||||
'confidence' => min((int) ($input['confidence'] ?? 0), self::LLM_MAX_CONFIDENCE),
|
||||
'reasoning' => $input['reasoning'] ?? '',
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function defaultPrompt(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;
|
||||
}
|
||||
}
|
||||
@@ -3,31 +3,24 @@
|
||||
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 AnthropicPredictionProvider implements OilPredictionProvider
|
||||
class AnthropicPredictionProvider extends AbstractLlmPredictionProvider
|
||||
{
|
||||
private const int LLM_MAX_CONFIDENCE = 85;
|
||||
|
||||
private const float EWMA_ALPHA = 0.3;
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiLogger $apiLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Tries web-search-enriched prediction first, falls back to basic tool use.
|
||||
* Overrides the parent flow because Anthropic uses two phases (web search
|
||||
* loop + forced tool call) and selects the source dynamically.
|
||||
*/
|
||||
public function predict(Collection $prices): ?PricePrediction
|
||||
{
|
||||
if (! config('services.anthropic.api_key')) {
|
||||
if ($this->apiKey() === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -36,10 +29,21 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
return $prediction ?? $this->predictBasic($prices);
|
||||
}
|
||||
|
||||
protected function apiKey(): ?string
|
||||
{
|
||||
return config('services.anthropic.api_key');
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
protected function callProvider(string $apiKey, string $priceList): ?array
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-turn web search phase, then a forced submit_prediction call.
|
||||
* Phase 1: Let the model search for recent oil/geopolitical news (pause_turn loop).
|
||||
* Phase 2: Force submit_prediction with the full conversation context.
|
||||
* Phase 1: let the model search for recent oil/geopolitical news.
|
||||
* Phase 2: force submit_prediction with the full conversation context.
|
||||
*/
|
||||
private function predictWithWebContext(Collection $prices): ?PricePrediction
|
||||
{
|
||||
@@ -47,7 +51,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
$url = 'https://api.anthropic.com/v1/messages';
|
||||
|
||||
try {
|
||||
// Phase 1: web search loop
|
||||
for ($i = 0, $response = null; $i < 5; $i++) {
|
||||
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
|
||||
->withHeaders($this->headers())
|
||||
@@ -59,7 +62,7 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('AnthropicPredictionProvider: context search request failed', ['status' => $response->status()]);
|
||||
Log::error(self::class.': context search request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -71,7 +74,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||
}
|
||||
|
||||
// Phase 2: forced submit with full context
|
||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||
$messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.'];
|
||||
|
||||
@@ -86,22 +88,61 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
]));
|
||||
|
||||
if (! $submitResponse->successful()) {
|
||||
Log::error('AnthropicPredictionProvider: context submit request failed', ['status' => $submitResponse->status()]);
|
||||
Log::error(self::class.': context submit request failed', ['status' => $submitResponse->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$input = $this->extractToolInput($submitResponse->json('content') ?? []);
|
||||
|
||||
if ($input === null) {
|
||||
Log::error('AnthropicPredictionProvider: no tool_use block in context submit response');
|
||||
return $input === null
|
||||
? null
|
||||
: $this->buildPrediction($input, PredictionSource::LlmWithContext);
|
||||
} catch (Throwable $e) {
|
||||
Log::error(self::class.': predictWithWebContext failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-turn prediction using a forced submit_prediction tool call.
|
||||
* Guarantees structured output — no JSON parsing needed.
|
||||
*/
|
||||
private function predictBasic(Collection $prices): ?PricePrediction
|
||||
{
|
||||
$chronological = $prices->sortBy('date');
|
||||
$ewma3 = $this->computeEwma($chronological->take(-3)->pluck('price_usd')->values()->all());
|
||||
$ewma7 = $this->computeEwma($chronological->take(-7)->pluck('price_usd')->values()->all());
|
||||
$ewma14 = $this->computeEwma($chronological->pluck('price_usd')->values()->all());
|
||||
|
||||
$url = 'https://api.anthropic.com/v1/messages';
|
||||
|
||||
try {
|
||||
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
|
||||
->withHeaders($this->headers())
|
||||
->post($url, [
|
||||
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||||
'max_tokens' => 256,
|
||||
'tools' => [$this->submitPredictionTool()],
|
||||
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
|
||||
'messages' => [[
|
||||
'role' => 'user',
|
||||
'content' => $this->basicPrompt($this->buildPriceList($prices), $ewma3, $ewma7, $ewma14),
|
||||
]],
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error(self::class.': basic request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildPrediction($input, PredictionSource::LlmWithContext);
|
||||
$input = $this->extractToolInput($response->json('content') ?? []);
|
||||
|
||||
return $input === null ? null : $this->buildPrediction($input);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('AnthropicPredictionProvider: predictWithWebContext failed', ['error' => $e->getMessage()]);
|
||||
Log::error(self::class.': predictBasic failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -126,18 +167,29 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
private function buildPriceList(Collection $prices): string
|
||||
private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string
|
||||
{
|
||||
return $prices->sortBy('date')
|
||||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
||||
->implode("\n");
|
||||
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}
|
||||
|
||||
Pre-computed indicators:
|
||||
- 3-day EWMA: \${$ewma3}
|
||||
- 7-day EWMA: \${$ewma7}
|
||||
- 14-day EWMA: \${$ewma14}
|
||||
|
||||
Use the submit_prediction tool to submit your answer.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function headers(): array
|
||||
{
|
||||
return [
|
||||
'x-api-key' => config('services.anthropic.api_key'),
|
||||
'x-api-key' => $this->apiKey(),
|
||||
'anthropic-version' => '2023-06-01',
|
||||
];
|
||||
}
|
||||
@@ -178,81 +230,7 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
return $block['input'] ?? null;
|
||||
}
|
||||
|
||||
/** @param array{direction: string, confidence: int, reasoning: string} $input */
|
||||
private function buildPrediction(array $input, PredictionSource $source): ?PricePrediction
|
||||
{
|
||||
$direction = TrendDirection::tryFrom($input['direction'] ?? '');
|
||||
|
||||
if ($direction === null) {
|
||||
Log::error('AnthropicPredictionProvider: invalid direction in tool input', ['input' => $input]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PricePrediction([
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => $source,
|
||||
'direction' => $direction,
|
||||
'confidence' => min((int) $input['confidence'], self::LLM_MAX_CONFIDENCE),
|
||||
'reasoning' => $input['reasoning'],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-turn prediction using a forced submit_prediction tool call.
|
||||
* Guarantees structured output — no JSON parsing needed.
|
||||
*/
|
||||
private function predictBasic(Collection $prices): ?PricePrediction
|
||||
{
|
||||
$chronological = $prices->sortBy('date');
|
||||
$ewma3 = $this->computeEwma($chronological->take(-3)->pluck('price_usd')->values()->all());
|
||||
$ewma7 = $this->computeEwma($chronological->take(-7)->pluck('price_usd')->values()->all());
|
||||
$ewma14 = $this->computeEwma($chronological->pluck('price_usd')->values()->all());
|
||||
|
||||
$priceList = $this->buildPriceList($prices);
|
||||
|
||||
$url = 'https://api.anthropic.com/v1/messages';
|
||||
|
||||
try {
|
||||
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
|
||||
->withHeaders($this->headers())
|
||||
->post($url, [
|
||||
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||||
'max_tokens' => 256,
|
||||
'tools' => [$this->submitPredictionTool()],
|
||||
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
|
||||
'messages' => [[
|
||||
'role' => 'user',
|
||||
'content' => $this->basicPrompt($priceList, $ewma3, $ewma7, $ewma14),
|
||||
]],
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('AnthropicPredictionProvider: basic request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$input = $this->extractToolInput($response->json('content') ?? []);
|
||||
|
||||
if ($input === null) {
|
||||
Log::error('AnthropicPredictionProvider: no tool_use block in basic response');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildPrediction($input, PredictionSource::Llm);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('AnthropicPredictionProvider: predictBasic failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param float[] $prices Chronological order (oldest first)
|
||||
*/
|
||||
/** @param float[] $prices Chronological order (oldest first) */
|
||||
private function computeEwma(array $prices): float
|
||||
{
|
||||
$ema = $prices[0];
|
||||
@@ -263,22 +241,4 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
|
||||
return round($ema, 4);
|
||||
}
|
||||
|
||||
private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): 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}
|
||||
|
||||
Pre-computed indicators:
|
||||
- 3-day EWMA: \${$ewma3}
|
||||
- 7-day EWMA: \${$ewma7}
|
||||
- 14-day EWMA: \${$ewma14}
|
||||
|
||||
Use the submit_prediction tool to submit your answer.
|
||||
PROMPT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,43 +2,26 @@
|
||||
|
||||
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
|
||||
class GeminiPredictionProvider extends AbstractLlmPredictionProvider
|
||||
{
|
||||
private const int LLM_MAX_CONFIDENCE = 85;
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiLogger $apiLogger,
|
||||
) {}
|
||||
|
||||
public function predict(Collection $prices): ?PricePrediction
|
||||
protected function apiKey(): ?string
|
||||
{
|
||||
if (! config('services.gemini.api_key')) {
|
||||
return null;
|
||||
return config('services.gemini.api_key');
|
||||
}
|
||||
|
||||
$priceList = $prices->sortBy('date')
|
||||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
||||
->implode("\n");
|
||||
|
||||
protected function callProvider(string $apiKey, string $priceList): ?array
|
||||
{
|
||||
$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')])
|
||||
->withQueryParameters(['key' => $apiKey])
|
||||
->post($url, [
|
||||
'contents' => [[
|
||||
'parts' => [['text' => $this->prompt($priceList)]],
|
||||
'parts' => [['text' => $this->defaultPrompt($priceList)]],
|
||||
]],
|
||||
'generationConfig' => [
|
||||
'responseMimeType' => 'application/json',
|
||||
@@ -58,7 +41,7 @@ class GeminiPredictionProvider implements OilPredictionProvider
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('GeminiPredictionProvider: request failed', ['status' => $response->status()]);
|
||||
Log::error(self::class.': request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -67,45 +50,11 @@ class GeminiPredictionProvider implements OilPredictionProvider
|
||||
$data = json_decode($text, true);
|
||||
|
||||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||||
Log::error('GeminiPredictionProvider: unexpected response format', ['text' => $text]);
|
||||
Log::error(self::class.': 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 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;
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,39 +2,22 @@
|
||||
|
||||
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
|
||||
class OpenAiPredictionProvider extends AbstractLlmPredictionProvider
|
||||
{
|
||||
private const int LLM_MAX_CONFIDENCE = 85;
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiLogger $apiLogger,
|
||||
) {}
|
||||
|
||||
public function predict(Collection $prices): ?PricePrediction
|
||||
protected function apiKey(): ?string
|
||||
{
|
||||
if (! config('services.openai.api_key')) {
|
||||
return null;
|
||||
return config('services.openai.api_key');
|
||||
}
|
||||
|
||||
$priceList = $prices->sortBy('date')
|
||||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
||||
->implode("\n");
|
||||
|
||||
protected function callProvider(string $apiKey, string $priceList): ?array
|
||||
{
|
||||
$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'))
|
||||
->withToken($apiKey)
|
||||
->post($url, [
|
||||
'model' => config('services.openai.model', 'gpt-4o-mini'),
|
||||
'response_format' => [
|
||||
@@ -56,12 +39,12 @@ class OpenAiPredictionProvider implements OilPredictionProvider
|
||||
],
|
||||
'messages' => [[
|
||||
'role' => 'user',
|
||||
'content' => $this->prompt($priceList),
|
||||
'content' => $this->defaultPrompt($priceList),
|
||||
]],
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('OpenAiPredictionProvider: request failed', ['status' => $response->status()]);
|
||||
Log::error(self::class.': request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -69,45 +52,11 @@ class OpenAiPredictionProvider implements OilPredictionProvider
|
||||
$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]);
|
||||
Log::error(self::class.': 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;
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user