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

@@ -15,11 +15,10 @@ class PredictionController extends Controller
public function index(PredictionRequest $request): JsonResponse
{
$fuelType = $request->fuelType();
$lat = $request->filled('lat') ? (float) $request->input('lat') : null;
$lng = $request->filled('lng') ? (float) $request->input('lng') : null;
$result = $this->predictionService->predict($fuelType, $lat, $lng);
$result = $this->predictionService->predict($lat, $lng);
return response()->json($result);
}

View File

@@ -2,9 +2,7 @@
namespace App\Http\Requests\Api;
use App\Enums\FuelType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class PredictionRequest extends FormRequest
{
@@ -16,18 +14,8 @@ class PredictionRequest extends FormRequest
public function rules(): array
{
return [
'fuel_type' => ['required', 'string'],
'lat' => ['nullable', 'numeric', 'between:-90,90'],
'lng' => ['nullable', 'numeric', 'between:-180,180'],
];
}
public function fuelType(): FuelType
{
try {
return FuelType::fromAlias($this->string('fuel_type')->toString());
} catch (\ValueError) {
throw ValidationException::withMessages(['fuel_type' => 'Unknown fuel type. Use: diesel, petrol, e10, e5, hvo, b10.']);
}
}
}

View File

@@ -2,6 +2,11 @@
namespace App\Providers;
use App\Services\ApiLogger;
use App\Services\LlmPrediction\AnthropicPredictionProvider;
use App\Services\LlmPrediction\GeminiPredictionProvider;
use App\Services\LlmPrediction\OilPredictionProvider;
use App\Services\LlmPrediction\OpenAiPredictionProvider;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
@@ -15,7 +20,15 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->bind(OilPredictionProvider::class, function ($app) {
$logger = $app->make(ApiLogger::class);
return match (config('services.llm.provider')) {
'openai' => new OpenAiPredictionProvider($logger),
'gemini' => new GeminiPredictionProvider($logger),
default => new AnthropicPredictionProvider($logger),
};
});
}
/**

View File

@@ -0,0 +1,284 @@
<?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 AnthropicPredictionProvider implements OilPredictionProvider
{
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.
*/
public function predict(Collection $prices): ?PricePrediction
{
if (! config('services.anthropic.api_key')) {
return null;
}
$prediction = $this->predictWithWebContext($prices);
return $prediction ?? $this->predictBasic($prices);
}
/**
* 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.
*/
private function predictWithWebContext(Collection $prices): ?PricePrediction
{
$messages = [['role' => 'user', 'content' => $this->contextPrompt($this->buildPriceList($prices))]];
$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())
->post($url, [
'model' => config('services.anthropic.model', 'claude-sonnet-4-6'),
'max_tokens' => 1024,
'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']],
'messages' => $messages,
]));
if (! $response->successful()) {
Log::error('AnthropicPredictionProvider: context search request failed', ['status' => $response->status()]);
return null;
}
if ($response->json('stop_reason') !== 'pause_turn') {
break;
}
$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.'];
$submitResponse = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
->withHeaders($this->headers())
->post($url, [
'model' => config('services.anthropic.model', 'claude-sonnet-4-6'),
'max_tokens' => 256,
'tools' => [$this->submitPredictionTool()],
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
'messages' => $messages,
]));
if (! $submitResponse->successful()) {
Log::error('AnthropicPredictionProvider: 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 null;
}
return $this->buildPrediction($input, PredictionSource::LlmWithContext);
} catch (Throwable $e) {
Log::error('AnthropicPredictionProvider: predictWithWebContext failed', ['error' => $e->getMessage()]);
return null;
}
}
private function contextPrompt(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.
First, search for recent news (last 48 hours) about:
- Brent crude oil price movements
- OPEC+ production decisions or announcements
- Major geopolitical events affecting oil supply
- Global demand signals (China economic data, US inventory reports)
Recent Brent crude prices (USD/barrel):
{$priceList}
After searching, you will be asked to submit your prediction.
PROMPT;
}
private function buildPriceList(Collection $prices): string
{
return $prices->sortBy('date')
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
->implode("\n");
}
/** @return array<string, string> */
private function headers(): array
{
return [
'x-api-key' => config('services.anthropic.api_key'),
'anthropic-version' => '2023-06-01',
];
}
/** @return array{name: string, description: string, input_schema: array<string, mixed>} */
private function submitPredictionTool(): array
{
return [
'name' => 'submit_prediction',
'description' => 'Submit the final oil price direction prediction.',
'input_schema' => [
'type' => 'object',
'properties' => [
'direction' => [
'type' => 'string',
'enum' => ['rising', 'falling', 'flat'],
],
'confidence' => [
'type' => 'integer',
'minimum' => 0,
'maximum' => self::LLM_MAX_CONFIDENCE,
],
'reasoning' => [
'type' => 'string',
'description' => 'One sentence explaining the prediction.',
],
],
'required' => ['direction', 'confidence', 'reasoning'],
],
];
}
/** @param array<int, mixed> $content */
private function extractToolInput(array $content): ?array
{
$block = collect($content)->firstWhere('type', 'tool_use');
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)
*/
private function computeEwma(array $prices): float
{
$ema = $prices[0];
foreach (array_slice($prices, 1) as $price) {
$ema = self::EWMA_ALPHA * $price + (1 - self::EWMA_ALPHA) * $ema;
}
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 35 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;
}
}

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;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Services\LlmPrediction;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use Illuminate\Support\Collection;
interface OilPredictionProvider
{
/**
* Generate an oil price direction prediction from recent Brent crude prices.
* Returns null on failure, API key not configured, or insufficient data.
*
* @param Collection<int, BrentPrice> $prices Chronological Brent crude prices
*/
public function predict(Collection $prices): ?PricePrediction;
}

View File

@@ -0,0 +1,113 @@
<?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 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;
}
}

View File

@@ -30,22 +30,25 @@ class NationalFuelPredictionService
* signals: array
* }
*/
public function predict(FuelType $fuelType, ?float $lat = null, ?float $lng = null): array
public function predict(?float $lat = null, ?float $lng = null): array
{
$currentAvg = $this->getCurrentNationalAverage($fuelType);
$fuelType = FuelType::E10;
$hasCoordinates = $lat !== null && $lng !== null;
$currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng);
$trend = $this->computeTrendSignal($fuelType);
$dayOfWeek = $this->computeDayOfWeekSignal($fuelType);
$brandBehaviour = $this->computeBrandBehaviourSignal($fuelType);
$stickiness = $this->computeStickinessSignal($fuelType);
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
$regionalMomentum = $lat !== null && $lng !== null
$regionalMomentum = $hasCoordinates
? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng)
: $this->disabledSignal('No coordinates provided for regional momentum analysis');
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness');
[$direction, $confidenceScore] = $this->aggregateSignals($signals);
[$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates);
$slope = $trend['slope'] ?? 0.0;
$predictedChangePence = round($slope * self::PREDICTION_HORIZON_DAYS, 1);
@@ -72,7 +75,7 @@ class NationalFuelPredictionService
'action' => $action,
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour),
'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS,
'region_key' => 'national',
'region_key' => $hasCoordinates ? 'regional' : 'national',
'methodology' => 'multi_signal_live_fallback',
'signals' => [
'trend' => $trend,
@@ -85,8 +88,20 @@ class NationalFuelPredictionService
];
}
private function getCurrentNationalAverage(FuelType $fuelType): float
private function getCurrentAverage(FuelType $fuelType, ?float $lat, ?float $lng): float
{
if ($lat !== null && $lng !== null) {
$avg = DB::table('station_prices_current')
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
->where('station_prices_current.fuel_type', $fuelType->value)
->whereRaw('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat])
->avg('station_prices_current.price_pence');
if ($avg !== null) {
return round((float) $avg / 100, 1);
}
}
$avg = StationPriceCurrent::where('fuel_type', $fuelType->value)->avg('price_pence');
return $avg !== null ? round((float) $avg / 100, 1) : 0.0;
@@ -391,14 +406,22 @@ class NationalFuelPredictionService
* @param array<string, array{score: float, confidence: float, enabled: bool}> $signals
* @return array{0: string, 1: float}
*/
private function aggregateSignals(array $signals): array
private function aggregateSignals(array $signals, bool $hasCoordinates = false): array
{
$weights = [
'trend' => 0.45,
'dayOfWeek' => 0.20,
'brandBehaviour' => 0.25,
'stickiness' => 0.10,
];
$weights = $hasCoordinates
? [
'regionalMomentum' => 0.50,
'trend' => 0.20,
'dayOfWeek' => 0.15,
'brandBehaviour' => 0.10,
'stickiness' => 0.05,
]
: [
'trend' => 0.45,
'dayOfWeek' => 0.20,
'brandBehaviour' => 0.25,
'stickiness' => 0.10,
];
$weightedSum = 0.0;
$totalWeight = 0.0;

View File

@@ -6,6 +6,7 @@ use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\LlmPrediction\OilPredictionProvider;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -28,11 +29,6 @@ class OilPriceService
*/
private const int EWMA_MAX_CONFIDENCE = 65;
/**
* LLM confidence is capped no model should be certain about oil prices.
*/
private const int LLM_MAX_CONFIDENCE = 85;
/**
* Minimum price rows needed before EWMA is meaningful.
*/
@@ -40,6 +36,7 @@ class OilPriceService
public function __construct(
private readonly ApiLogger $apiLogger,
private readonly OilPredictionProvider $provider,
) {}
/**
@@ -87,7 +84,7 @@ class OilPriceService
/**
* Generate predictions from all available sources and store each one.
* EWMA always runs. LLM runs when an API key is configured.
* EWMA always runs. LLM provider runs and returns null if not configured.
* Returns the highest-confidence prediction (LLM preferred over EWMA).
*/
public function generatePrediction(): ?PricePrediction
@@ -108,207 +105,15 @@ class OilPriceService
PricePrediction::create($ewma->toArray());
}
$llm = null;
$llm = $this->provider->predict($prices);
if (config('services.anthropic.api_key')) {
$llm = $this->generateLlmPredictionWithContext($prices);
$llm ??= $this->generateLlmPrediction($prices);
if ($llm !== null) {
PricePrediction::create($llm->toArray());personal_access_tokens
}
if ($llm !== null) {
PricePrediction::create($llm->toArray());
}
return $llm ?? $ewma;
}
/**
* Option B LLM prediction via Anthropic API.
* Sends recent prices + pre-computed EWMA context and asks for direction + confidence.
*/
public function generateLlmPrediction(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 = $chronological
->map(fn (BrentPrice $p) => "{$p->date->toDateString()}: \${$p->price_usd}")
->implode("\n");
$prompt = <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Your goal is to predict the short-term direction over the next 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Pre-computed indicators:
- 3-day EWMA: \${$ewma3}
- 7-day EWMA: \${$ewma7}
- 14-day EWMA: \${$ewma14}
Respond with JSON only, no other text:
{"direction": "rising|falling|flat", "confidence": 0-85, "reasoning": "one sentence"}
PROMPT;
$url = 'https://api.anthropic.com/v1/messages';
try {
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
->withHeaders([
'x-api-key' => config('services.anthropic.api_key'),
'anthropic-version' => '2023-06-01',
])
->post($url, [
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
'max_tokens' => 256,
'messages' => [
['role' => 'user', 'content' => $prompt],
],
]));
if (! $response->successful()) {
Log::error('OilPriceService: Anthropic request failed', ['status' => $response->status()]);
return null;
}
$text = $response->json('content.0.text') ?? '';
$data = $this->extractJson($text);
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error('OilPriceService: unexpected LLM response format', ['text' => $text]);
return null;
}
$direction = TrendDirection::tryFrom($data['direction']);
$confidence = min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE);
if ($direction === null) {
Log::error('OilPriceService: invalid direction in LLM response', ['direction' => $data['direction']]);
return null;
}
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => PredictionSource::Llm,
'direction' => $direction,
'confidence' => $confidence,
'reasoning' => $data['reasoning'],
'generated_at' => now(),
]);
} catch (Throwable $e) {
Log::error('OilPriceService: generateLlmPrediction failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* LLM prediction with 48h geopolitical context via Anthropic web search.
* Claude searches for recent oil/geopolitical news before answering.
* Reasons from raw prices only no pre-computed indicators in prompt.
*/
public function generateLlmPredictionWithContext(Collection $prices): ?PricePrediction
{
$priceList = $prices->sortBy('date')
->map(fn (BrentPrice $p) => "{$p->date->toDateString()}: \${$p->price_usd}")
->implode("\n");
$prompt = <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Your goal is to predict the short-term direction over the next 35 days.
First, search for recent news (last 48 hours) about:
- Brent crude oil price movements
- OPEC+ production decisions or announcements
- Major geopolitical events affecting oil supply (Middle East, Russia, US sanctions)
- Global demand signals (China economic data, US inventory reports)
Then, combining the news context with the price history below, predict the direction.
Recent Brent crude prices (USD/barrel):
{$priceList}
Respond with JSON only, no other text:
{"direction": "rising|falling|flat", "confidence": 0-85, "reasoning": "one sentence combining price trend and key news factor"}
PROMPT;
$url = 'https://api.anthropic.com/v1/messages';
$messages = [['role' => 'user', 'content' => $prompt]];
try {
for ($i = 0, $response = null; $i < 5; $i++) {
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
->withHeaders([
'x-api-key' => config('services.anthropic.api_key'),
'anthropic-version' => '2023-06-01',
])
->post($url, [
'model' => config('services.anthropic.model', 'claude-sonnet-4-6'),
'max_tokens' => 1024,
'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']],
'messages' => $messages,
]));
if (! $response->successful()) {
Log::error('OilPriceService: Anthropic context request failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
return null;
}
if ($response->json('stop_reason') !== 'pause_turn') {
break;
}
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
}
$content = $response->json('content') ?? [];
$text = collect($content)
->filter(fn ($b) => ($b['type'] ?? '') === 'text')
->implode('text', '');
$data = $this->extractJson($text);
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error('OilPriceService: unexpected context LLM response format', ['text' => $text]);
return null;
}
$direction = TrendDirection::tryFrom($data['direction']);
$confidence = min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE);
if ($direction === null) {
Log::error('OilPriceService: invalid direction in context LLM response', ['direction' => $data['direction']]);
return null;
}
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => PredictionSource::LlmWithContext,
'direction' => $direction,
'confidence' => $confidence,
'reasoning' => $data['reasoning'],
'generated_at' => now(),
]);
} catch (Throwable $e) {
Log::error('OilPriceService: generateLlmPredictionWithContext failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Option A EWMA-based trend extrapolation. Used as fallback when LLM is unavailable.
* Compares the 3-day EWMA against the 7-day EWMA to detect direction.
@@ -372,23 +177,6 @@ class OilPriceService
return round($ema, 4);
}
/**
* Strip markdown code fences from a string and extract the first JSON object found.
* Handles prose preambles that Claude sometimes adds before the JSON.
*/
private function extractJson(string $text): ?array
{
$text = preg_replace('/^```(?:json)?\s*/m', '', trim($text));
$text = preg_replace('/```\s*$/m', '', $text);
$start = strpos($text, '{');
$end = strrpos($text, '}');
if ($start === false || $end === false) {
return null;
}
return json_decode(substr($text, $start, $end - $start + 1), true) ?: null;
}
/**
* Map a % change magnitude to a 0EWMA_MAX_CONFIDENCE confidence score.
* 1.5% ~30, 3% ~50, 5%+ 65.