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

@@ -10,7 +10,7 @@
use App\Livewire\Public\StationSearch; use App\Livewire\Public\StationSearch;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::view('/', 'welcome')->name('home'); Route::view('/', 'homepage')->name('home');
Route::get('/stations', StationSearch::class)->name('stations.search'); Route::get('/stations', StationSearch::class)->name('stations.search');

View File

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

View File

@@ -2,9 +2,7 @@
namespace App\Http\Requests\Api; namespace App\Http\Requests\Api;
use App\Enums\FuelType;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class PredictionRequest extends FormRequest class PredictionRequest extends FormRequest
{ {
@@ -16,18 +14,8 @@ class PredictionRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'fuel_type' => ['required', 'string'],
'lat' => ['nullable', 'numeric', 'between:-90,90'], 'lat' => ['nullable', 'numeric', 'between:-90,90'],
'lng' => ['nullable', 'numeric', 'between:-180,180'], '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; 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 Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -15,7 +20,15 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void 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 * 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); $trend = $this->computeTrendSignal($fuelType);
$dayOfWeek = $this->computeDayOfWeekSignal($fuelType); $dayOfWeek = $this->computeDayOfWeekSignal($fuelType);
$brandBehaviour = $this->computeBrandBehaviourSignal($fuelType); $brandBehaviour = $this->computeBrandBehaviourSignal($fuelType);
$stickiness = $this->computeStickinessSignal($fuelType); $stickiness = $this->computeStickinessSignal($fuelType);
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions'); $nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
$regionalMomentum = $lat !== null && $lng !== null $regionalMomentum = $hasCoordinates
? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng) ? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng)
: $this->disabledSignal('No coordinates provided for regional momentum analysis'); : $this->disabledSignal('No coordinates provided for regional momentum analysis');
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness'); $signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness');
[$direction, $confidenceScore] = $this->aggregateSignals($signals); [$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates);
$slope = $trend['slope'] ?? 0.0; $slope = $trend['slope'] ?? 0.0;
$predictedChangePence = round($slope * self::PREDICTION_HORIZON_DAYS, 1); $predictedChangePence = round($slope * self::PREDICTION_HORIZON_DAYS, 1);
@@ -72,7 +75,7 @@ class NationalFuelPredictionService
'action' => $action, 'action' => $action,
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour), 'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour),
'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS, 'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS,
'region_key' => 'national', 'region_key' => $hasCoordinates ? 'regional' : 'national',
'methodology' => 'multi_signal_live_fallback', 'methodology' => 'multi_signal_live_fallback',
'signals' => [ 'signals' => [
'trend' => $trend, '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'); $avg = StationPriceCurrent::where('fuel_type', $fuelType->value)->avg('price_pence');
return $avg !== null ? round((float) $avg / 100, 1) : 0.0; 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 * @param array<string, array{score: float, confidence: float, enabled: bool}> $signals
* @return array{0: string, 1: float} * @return array{0: string, 1: float}
*/ */
private function aggregateSignals(array $signals): array private function aggregateSignals(array $signals, bool $hasCoordinates = false): array
{ {
$weights = [ $weights = $hasCoordinates
'trend' => 0.45, ? [
'dayOfWeek' => 0.20, 'regionalMomentum' => 0.50,
'brandBehaviour' => 0.25, 'trend' => 0.20,
'stickiness' => 0.10, 'dayOfWeek' => 0.15,
]; 'brandBehaviour' => 0.10,
'stickiness' => 0.05,
]
: [
'trend' => 0.45,
'dayOfWeek' => 0.20,
'brandBehaviour' => 0.25,
'stickiness' => 0.10,
];
$weightedSum = 0.0; $weightedSum = 0.0;
$totalWeight = 0.0; $totalWeight = 0.0;

View File

@@ -6,6 +6,7 @@ use App\Enums\PredictionSource;
use App\Enums\TrendDirection; use App\Enums\TrendDirection;
use App\Models\BrentPrice; use App\Models\BrentPrice;
use App\Models\PricePrediction; use App\Models\PricePrediction;
use App\Services\LlmPrediction\OilPredictionProvider;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -28,11 +29,6 @@ class OilPriceService
*/ */
private const int EWMA_MAX_CONFIDENCE = 65; 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. * Minimum price rows needed before EWMA is meaningful.
*/ */
@@ -40,6 +36,7 @@ class OilPriceService
public function __construct( public function __construct(
private readonly ApiLogger $apiLogger, private readonly ApiLogger $apiLogger,
private readonly OilPredictionProvider $provider,
) {} ) {}
/** /**
@@ -87,7 +84,7 @@ class OilPriceService
/** /**
* Generate predictions from all available sources and store each one. * 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). * Returns the highest-confidence prediction (LLM preferred over EWMA).
*/ */
public function generatePrediction(): ?PricePrediction public function generatePrediction(): ?PricePrediction
@@ -108,207 +105,15 @@ class OilPriceService
PricePrediction::create($ewma->toArray()); PricePrediction::create($ewma->toArray());
} }
$llm = null; $llm = $this->provider->predict($prices);
if (config('services.anthropic.api_key')) { if ($llm !== null) {
$llm = $this->generateLlmPredictionWithContext($prices); PricePrediction::create($llm->toArray());
$llm ??= $this->generateLlmPrediction($prices);
if ($llm !== null) {
PricePrediction::create($llm->toArray());personal_access_tokens
}
} }
return $llm ?? $ewma; 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. * 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. * Compares the 3-day EWMA against the 7-day EWMA to detect direction.
@@ -372,23 +177,6 @@ class OilPriceService
return round($ema, 4); 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. * Map a % change magnitude to a 0EWMA_MAX_CONFIDENCE confidence score.
* 1.5% ~30, 3% ~50, 5%+ 65. * 1.5% ~30, 3% ~50, 5%+ 65.

View File

@@ -50,6 +50,20 @@ return [
'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-6'), 'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-6'),
], ],
'openai' => [
'api_key' => env('OPENAI_API_KEY'),
'model' => env('OPENAI_MODEL', 'gpt-4o-mini'),
],
'gemini' => [
'api_key' => env('GEMINI_API_KEY'),
'model' => env('GEMINI_MODEL', 'gemini-2.0-flash'),
],
'llm' => [
'provider' => env('LLM_PREDICTION_PROVIDER', 'anthropic'),
],
'fuelalert' => [ 'fuelalert' => [
'api_key' => env('FUELALERT_API_KEY'), 'api_key' => env('FUELALERT_API_KEY'),
], ],

View File

@@ -152,18 +152,17 @@ GET /api/stats/searches?period=month
### GET `/api/prediction` ### GET `/api/prediction`
National (or regional) fuel price direction forecast for the next 7 days, based on live price data signals. National or regional E10 fuel price direction forecast for the next 7 days, based on live price data signals. Always analyses E10 — the most widely available fuel and the one with the most price history.
| Parameter | Type | Description | | Parameter | Type | Description |
|---|---|---| |---|---|---|
| `fuel_type` | string | **Required.** Same aliases as `/api/stations` | | `lat` | float | Optional. Decimal latitude. Enables regional prediction (50km radius). |
| `lat` | float | Optional. Enables regional momentum signal | | `lng` | float | Optional. Decimal longitude. Required if `lat` is provided. |
| `lng` | float | Optional. Enables regional momentum signal |
**Example request:** **Example request:**
``` ```
GET /api/prediction?fuel_type=diesel GET /api/prediction
GET /api/prediction?fuel_type=petrol&lat=51.5074&lng=-0.1278 GET /api/prediction?lat=51.5074&lng=-0.1278
``` ```
**Response:** **Response:**
@@ -235,6 +234,13 @@ GET /api/prediction?fuel_type=petrol&lat=51.5074&lng=-0.1278
} }
``` ```
**`region_key` values:**
| Value | Meaning |
|---|---|
| `"national"` | No coordinates provided. `current_avg` and signals use national data. `regional_momentum` is disabled. |
| `"regional"` | Coordinates provided. `current_avg` uses stations within 50km. `regional_momentum` is the primary signal (50% weight). Falls back to national average if no stations found in radius. |
**Key fields:** **Key fields:**
| Field | Values | Meaning | | Field | Values | Meaning |
@@ -243,7 +249,21 @@ GET /api/prediction?fuel_type=petrol&lat=51.5074&lng=-0.1278
| `action` | `"fill_now"`, `"wait"`, `"no_signal"` | Consumer-facing recommendation | | `action` | `"fill_now"`, `"wait"`, `"no_signal"` | Consumer-facing recommendation |
| `confidence_label` | `"high"` (≥70), `"medium"` (≥40), `"low"` (<40) | Signal strength | | `confidence_label` | `"high"` (≥70), `"medium"` (≥40), `"low"` (<40) | Signal strength |
| `predicted_change_pence` | float | Expected p/litre change over 7 days | | `predicted_change_pence` | float | Expected p/litre change over 7 days |
| `current_avg` | float | Current national average in pence (e.g. `143.9` = 143.9p) | | `current_avg` | float | Average price in pence (e.g. `143.9` = 143.9p). Regional if lat/lng given, else national. |
**Signal weights:**
| Scope | Signal | Weight |
|---|---|---|
| National | trend | 45% |
| National | brand_behaviour | 25% |
| National | day_of_week | 20% |
| National | price_stickiness | 10% |
| Regional | regional_momentum | 50% |
| Regional | trend | 20% |
| Regional | day_of_week | 15% |
| Regional | brand_behaviour | 10% |
| Regional | price_stickiness | 5% |
**Signal structure** (each signal in `signals`): **Signal structure** (each signal in `signals`):
@@ -254,12 +274,9 @@ GET /api/prediction?fuel_type=petrol&lat=51.5074&lng=-0.1278
| `direction` | `"up"` / `"down"` / `"stable"` | Signal direction | | `direction` | `"up"` / `"down"` / `"stable"` | Signal direction |
| `detail` | string | Human-readable explanation | | `detail` | string | Human-readable explanation |
| `data_points` | int | Number of price records used | | `data_points` | int | Number of price records used |
| `enabled` | bool | False if signal was skipped (missing data/coords) | | `enabled` | bool | False if signal was skipped (missing data or coordinates) |
**Error — unknown fuel type:** **LLM-backed prediction** — separately, the nightly `oil:predict` command generates an oil price direction from Brent crude data and stores it in `price_predictions`. This feeds into `AlertScoringService` (Signal 4) but is not exposed directly through this endpoint. See [LLM Prediction Providers](llm-prediction-providers.md).
```json
{ "errors": { "fuel_type": ["Unknown fuel type. Use: diesel, petrol, e10, e5, hvo, b10."] } }
```
--- ---

View File

@@ -0,0 +1,145 @@
# LLM Prediction Providers
The oil price direction prediction supports multiple LLM backends behind a shared interface. The active provider is selected via environment variable. All providers return the same response shape and fall back to EWMA if not configured or if the API call fails.
## Selecting a Provider
Set `LLM_PREDICTION_PROVIDER` in `.env`:
```
LLM_PREDICTION_PROVIDER=anthropic # default
LLM_PREDICTION_PROVIDER=openai
LLM_PREDICTION_PROVIDER=gemini
```
Each provider needs its own API key. If the key is missing or empty the provider returns `null` and EWMA is used instead.
---
## Providers
### Anthropic (default)
**Key:** `ANTHROPIC_API_KEY`
**Model:** `ANTHROPIC_MODEL` (default: `claude-sonnet-4-6`)
Uses **tool use** with a forced `submit_prediction` tool call — no JSON parsing, guaranteed schema. Structured output is enforced at the API level via `tool_choice: { type: "tool", name: "submit_prediction" }`.
Two-phase prediction flow:
1. **Context phase** — multi-turn web search (`web_search_20250305` tool) for recent oil/geopolitical news (up to 5 iterations, `pause_turn` loop)
2. **Submission phase** — once searches are complete, forces a `submit_prediction` tool call with the full conversation context
If the context phase fails, falls back to a single-turn basic prediction (tool use only, no web search).
```php
// Structured output schema (enforced by Anthropic)
'input_schema' => [
'type' => 'object',
'properties' => [
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
'confidence' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 85],
'reasoning' => ['type' => 'string'],
],
'required' => ['direction', 'confidence', 'reasoning'],
],
```
`PredictionSource`: `llm_with_context` (web search succeeded) or `llm` (basic fallback).
---
### OpenAI
**Key:** `OPENAI_API_KEY`
**Model:** `OPENAI_MODEL` (default: `gpt-4o-mini`)
Uses `response_format: json_schema` with `strict: true`. The schema is sent to the API and the response is guaranteed to match it.
```php
'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,
],
],
],
```
Response is extracted from `choices.0.message.content` (a JSON string) and decoded.
`PredictionSource`: `llm`
---
### Gemini
**Key:** `GEMINI_API_KEY`
**Model:** `GEMINI_MODEL` (default: `gemini-2.0-flash`)
Uses `responseMimeType: application/json` and `responseSchema` in `generationConfig`. The API key is passed as a query parameter.
```php
'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'],
],
],
```
Response is extracted from `candidates.0.content.parts.0.text` (a JSON string) and decoded.
`PredictionSource`: `llm`
---
## Confidence Caps
All providers cap confidence at **85** regardless of what the model returns. EWMA is capped at **65**.
---
## EWMA Fallback
`OilPriceService::generatePrediction()` always runs EWMA first and stores its result. The LLM provider runs after; its result is stored and returned if non-null. If the provider returns null (key missing, API error, malformed response), EWMA is returned instead.
```
generatePrediction()
├── generateEwmaPrediction() → always stored
└── provider->predict()
├── on success → stored and returned (LLM wins)
└── on null → EWMA returned
```
---
## Adding a New Provider
1. Create `app/Services/LlmPrediction/YourProvider.php` implementing `OilPredictionProvider`
2. Add a case to the `match` in `AppServiceProvider::register()`
3. Add key/model config to `config/services.php` and document the `.env` vars
The interface requires one method:
```php
public function predict(Collection $prices): ?PricePrediction;
```
Return `null` on any failure — the orchestrator handles the fallback.

View File

@@ -171,7 +171,6 @@ Response shape: `{ data: Station[], meta: { count, lowest_pence, avg_pence } }`
### `/api/prediction` ### `/api/prediction`
| Param | Type | Notes | | Param | Type | Notes |
|---|---|---| |---|---|---|
| `fuel_type` | string | e.g. `petrol`, `diesel` |
| `lat` | float? | Optional — falls back to national | | `lat` | float? | Optional — falls back to national |
| `lng` | float? | Optional | | `lng` | float? | Optional |

View File

@@ -11,8 +11,8 @@ beforeEach(function () {
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]); $this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
}); });
it('returns a prediction response for diesel', function () { it('returns a prediction response', function () {
$this->getJson('/api/prediction?fuel_type=diesel') $this->getJson('/api/prediction')
->assertOk() ->assertOk()
->assertJsonStructure([ ->assertJsonStructure([
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence', 'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
@@ -27,7 +27,7 @@ it('returns a prediction response for diesel', function () {
'price_stickiness' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'], 'price_stickiness' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
], ],
]) ])
->assertJsonPath('fuel_type', 'b7_standard') ->assertJsonPath('fuel_type', 'e10')
->assertJsonPath('region_key', 'national'); ->assertJsonPath('region_key', 'national');
}); });
@@ -35,29 +35,36 @@ it('includes current average from live prices', function () {
$station = Station::factory()->create(); $station = Station::factory()->create();
StationPriceCurrent::factory()->create([ StationPriceCurrent::factory()->create([
'station_id' => $station->node_id, 'station_id' => $station->node_id,
'fuel_type' => FuelType::B7Standard, 'fuel_type' => FuelType::E10,
'price_pence' => 14750, 'price_pence' => 14750,
]); ]);
$response = $this->getJson('/api/prediction?fuel_type=diesel')->assertOk(); $response = $this->getJson('/api/prediction')->assertOk();
expect($response->json('current_avg'))->toBe(147.5); expect($response->json('current_avg'))->toBe(147.5);
}); });
it('accepts optional lat and lng for regional context', function () { it('returns regional prediction when lat and lng are provided', function () {
$this->getJson('/api/prediction?fuel_type=diesel&lat=52.5&lng=-0.2') $this->getJson('/api/prediction?lat=52.5&lng=-0.2')
->assertOk() ->assertOk()
->assertJsonPath('region_key', 'national'); // still national, regional_momentum signal updated internally ->assertJsonPath('region_key', 'regional')
->assertJsonPath('fuel_type', 'e10');
}); });
it('returns 422 when fuel_type is missing', function () { it('returns national prediction without coordinates', function () {
$this->getJson('/api/prediction') $this->getJson('/api/prediction')
->assertUnprocessable() ->assertOk()
->assertJsonValidationErrors(['fuel_type']); ->assertJsonPath('region_key', 'national');
}); });
it('returns 422 for unknown fuel_type alias', function () { it('returns 422 for invalid lat', function () {
$this->getJson('/api/prediction?fuel_type=rocket_fuel') $this->getJson('/api/prediction?lat=999&lng=0')
->assertUnprocessable() ->assertUnprocessable()
->assertJsonValidationErrors(['fuel_type']); ->assertJsonValidationErrors(['lat']);
});
it('returns 422 for invalid lng', function () {
$this->getJson('/api/prediction?lat=51.5&lng=999')
->assertUnprocessable()
->assertJsonValidationErrors(['lng']);
}); });

View File

@@ -0,0 +1,219 @@
<?php
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Services\ApiLogger;
use App\Services\LlmPrediction\AnthropicPredictionProvider;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
beforeEach(function (): void {
Http::preventStrayRequests();
config(['services.anthropic.api_key' => 'test-key']);
$this->provider = new AnthropicPredictionProvider(new ApiLogger);
});
it('returns null when api key is not configured', function (): void {
config(['services.anthropic.api_key' => null]);
$prices = fakePrices(14);
expect($this->provider->predict($prices))->toBeNull();
});
it('uses submit_prediction tool in the basic request', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::response([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'rising', 'confidence' => 72, 'reasoning' => 'Prices rising.'],
]],
]),
]);
// context request fails, falls back to basic
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([], 500)
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'rising', 'confidence' => 72, 'reasoning' => 'Prices rising.'],
]],
]),
]);
$this->provider->predict(fakePrices(14));
Http::assertSent(function ($request) {
$tools = $request->data()['tools'] ?? [];
return collect($tools)->contains(fn ($t) => $t['name'] === 'submit_prediction');
});
});
it('returns a prediction with Llm source from basic tool use', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([], 500) // context fails
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'rising', 'confidence' => 72, 'reasoning' => 'Consistent upward trend.'],
]],
]),
]);
$prediction = $this->provider->predict(fakePrices(14));
expect($prediction->direction)->toBe(TrendDirection::Rising)
->and($prediction->source)->toBe(PredictionSource::Llm)
->and($prediction->confidence)->toBe(72)
->and($prediction->reasoning)->toBe('Consistent upward trend.');
});
it('caps confidence at 85', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([], 500)
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'falling', 'confidence' => 99, 'reasoning' => 'Very confident.'],
]],
]),
]);
$prediction = $this->provider->predict(fakePrices(14));
expect($prediction->confidence)->toBe(85);
});
it('returns null when tool_use block is missing from response', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([], 500)
->push([
'stop_reason' => 'end_turn',
'content' => [['type' => 'text', 'text' => 'Sorry, I cannot help.']],
]),
]);
expect($this->provider->predict(fakePrices(14)))->toBeNull();
});
it('sends web_search tool during context prediction phase', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([
'stop_reason' => 'end_turn',
'content' => [['type' => 'text', 'text' => 'Searched and analysed.']],
])
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'flat', 'confidence' => 50, 'reasoning' => 'No clear trend.'],
]],
]),
]);
$this->provider->predict(fakePrices(20));
Http::assertSent(function ($request) {
$tools = $request->data()['tools'] ?? [];
return collect($tools)->contains(fn ($t) => ($t['type'] ?? '') === 'web_search_20250305');
});
});
it('returns LlmWithContext source when context prediction succeeds', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([
'stop_reason' => 'end_turn',
'content' => [['type' => 'text', 'text' => 'Analysed news.']],
])
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'rising', 'confidence' => 70, 'reasoning' => 'OPEC+ cuts support prices.'],
]],
]),
]);
$prediction = $this->provider->predict(fakePrices(20));
expect($prediction->source)->toBe(PredictionSource::LlmWithContext)
->and($prediction->direction)->toBe(TrendDirection::Rising);
});
it('continues on pause_turn during web search phase', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([
'stop_reason' => 'pause_turn',
'content' => [['type' => 'server_tool_use', 'name' => 'web_search', 'input' => ['query' => 'Brent crude']]],
])
->push([
'stop_reason' => 'end_turn',
'content' => [['type' => 'text', 'text' => 'Done searching.']],
])
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'falling', 'confidence' => 60, 'reasoning' => 'Demand fears.'],
]],
]),
]);
$prediction = $this->provider->predict(fakePrices(20));
expect($prediction)->not->toBeNull()
->and($prediction->direction)->toBe(TrendDirection::Falling);
Http::assertSentCount(3);
});
it('falls back to basic prediction when context phase fails', function (): void {
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([], 500) // context search fails
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_prediction',
'input' => ['direction' => 'rising', 'confidence' => 65, 'reasoning' => 'Rising trend.'],
]],
]),
]);
$prediction = $this->provider->predict(fakePrices(14));
expect($prediction->source)->toBe(PredictionSource::Llm);
});
// --- helpers ---
function fakePrices(int $count): Collection
{
return collect(range(1, $count))->map(fn (int $i) => new BrentPrice([
'date' => now()->subDays($count - $i)->toDateString(),
'price_usd' => 75.0 + $i,
]));
}

View File

@@ -10,7 +10,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('returns no_signal when there is insufficient price history', function () { it('returns no_signal when there is insufficient price history', function () {
$result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard); $result = app(NationalFuelPredictionService::class)->predict();
expect($result['predicted_direction'])->toBe('stable') expect($result['predicted_direction'])->toBe('stable')
->and($result['signals']['trend']['enabled'])->toBeFalse() ->and($result['signals']['trend']['enabled'])->toBeFalse()
@@ -24,13 +24,13 @@ it('detects rising trend from consistently increasing daily averages', function
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) { for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([ StationPrice::factory()->create([
'station_id' => $station->node_id, 'station_id' => $station->node_id,
'fuel_type' => FuelType::B7Standard, 'fuel_type' => FuelType::E10,
'price_pence' => 14000 + ((6 - $daysAgo) * 100), 'price_pence' => 14000 + ((6 - $daysAgo) * 100),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0), 'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]); ]);
} }
$result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard); $result = app(NationalFuelPredictionService::class)->predict();
expect($result['signals']['trend']['direction'])->toBe('up') expect($result['signals']['trend']['direction'])->toBe('up')
->and($result['signals']['trend']['enabled'])->toBeTrue() ->and($result['signals']['trend']['enabled'])->toBeTrue()
@@ -44,13 +44,13 @@ it('detects falling trend from consistently decreasing daily averages', function
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) { for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([ StationPrice::factory()->create([
'station_id' => $station->node_id, 'station_id' => $station->node_id,
'fuel_type' => FuelType::B7Standard, 'fuel_type' => FuelType::E10,
'price_pence' => 16000 - ((6 - $daysAgo) * 100), 'price_pence' => 16000 - ((6 - $daysAgo) * 100),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0), 'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]); ]);
} }
$result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard); $result = app(NationalFuelPredictionService::class)->predict();
expect($result['signals']['trend']['direction'])->toBe('down') expect($result['signals']['trend']['direction'])->toBe('down')
->and($result['predicted_direction'])->toBe('down') ->and($result['predicted_direction'])->toBe('down')
@@ -61,29 +61,70 @@ it('returns current_avg from station_prices_current', function () {
$station = Station::factory()->create(); $station = Station::factory()->create();
StationPriceCurrent::factory()->create([ StationPriceCurrent::factory()->create([
'station_id' => $station->node_id, 'station_id' => $station->node_id,
'fuel_type' => FuelType::B7Standard, 'fuel_type' => FuelType::E10,
'price_pence' => 14750, 'price_pence' => 14750,
]); ]);
$result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard); $result = app(NationalFuelPredictionService::class)->predict();
expect($result['current_avg'])->toBe(147.5); expect($result['current_avg'])->toBe(147.5);
}); });
it('includes all required keys in response', function () { it('includes all required keys in response', function () {
$result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard); $result = app(NationalFuelPredictionService::class)->predict();
expect($result)->toHaveKeys([ expect($result)
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence', ->toHaveKeys([
'confidence_score', 'confidence_label', 'action', 'reasoning', 'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
'prediction_horizon_days', 'region_key', 'methodology', 'confidence_score', 'confidence_label', 'action', 'reasoning',
'signals', 'prediction_horizon_days', 'region_key', 'methodology',
]); 'signals',
])
->and($result['signals'])->toHaveKeys([
'trend', 'day_of_week', 'brand_behaviour',
'national_momentum', 'regional_momentum', 'price_stickiness',
]);
});
expect($result['signals'])->toHaveKeys([ it('always returns e10 as fuel_type', function () {
'trend', 'day_of_week', 'brand_behaviour', $result = app(NationalFuelPredictionService::class)->predict();
'national_momentum', 'regional_momentum', 'price_stickiness',
]); expect($result['fuel_type'])->toBe('e10');
});
it('returns national region_key without coordinates', function () {
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['region_key'])->toBe('national');
});
it('returns regional region_key when coordinates are provided', function () {
$result = app(NationalFuelPredictionService::class)->predict(51.5074, -0.1278);
expect($result['region_key'])->toBe('regional');
});
it('enables regional_momentum signal when coordinates are provided', function () {
$station = Station::factory()->create(['lat' => 51.5, 'lng' => -0.1]);
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000 + ((6 - $daysAgo) * 100),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict(51.5074, -0.1278);
expect($result['signals']['regional_momentum']['enabled'])->toBeTrue();
});
it('disables regional_momentum signal without coordinates', function () {
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['signals']['regional_momentum']['enabled'])->toBeFalse();
}); });
it('disables trend signal when r_squared is below 0.5', function () { it('disables trend signal when r_squared is below 0.5', function () {
@@ -94,13 +135,13 @@ it('disables trend signal when r_squared is below 0.5', function () {
foreach ($prices as $daysAgo => $price) { foreach ($prices as $daysAgo => $price) {
StationPrice::factory()->create([ StationPrice::factory()->create([
'station_id' => $station->node_id, 'station_id' => $station->node_id,
'fuel_type' => FuelType::B7Standard, 'fuel_type' => FuelType::E10,
'price_pence' => $price, 'price_pence' => $price,
'price_effective_at' => now()->subDays(count($prices) - 1 - $daysAgo)->setTime(12, 0), 'price_effective_at' => now()->subDays(count($prices) - 1 - $daysAgo)->setTime(12, 0),
]); ]);
} }
$result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard); $result = app(NationalFuelPredictionService::class)->predict();
// Trend signal may be disabled if both 5-day and 14-day lookbacks fail R² threshold // Trend signal may be disabled if both 5-day and 14-day lookbacks fail R² threshold
expect($result['signals']['trend']['data_points'])->toBeInt(); expect($result['signals']['trend']['data_points'])->toBeInt();

View File

@@ -5,6 +5,7 @@ use App\Enums\TrendDirection;
use App\Models\BrentPrice; use App\Models\BrentPrice;
use App\Models\PricePrediction; use App\Models\PricePrediction;
use App\Services\ApiLogger; use App\Services\ApiLogger;
use App\Services\LlmPrediction\OilPredictionProvider;
use App\Services\OilPriceService; use App\Services\OilPriceService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@@ -13,7 +14,8 @@ uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
Http::preventStrayRequests(); Http::preventStrayRequests();
$this->service = new OilPriceService(new ApiLogger); $this->provider = Mockery::mock(OilPredictionProvider::class);
$this->service = new OilPriceService(new ApiLogger, $this->provider);
}); });
// --- fetchBrentPrices --- // --- fetchBrentPrices ---
@@ -115,173 +117,9 @@ it('returns null when fewer than 14 prices are available for EWMA', function ():
expect($this->service->generateEwmaPrediction($prices))->toBeNull(); expect($this->service->generateEwmaPrediction($prices))->toBeNull();
}); });
// --- generateLlmPrediction ---
it('generates an LLM prediction and stores it', function (): void {
$prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([
'date' => now()->subDays(14 - $i)->toDateString(),
'price_usd' => 75.0 + $i,
]));
Http::fake([
'https://api.anthropic.com/*' => Http::response([
'content' => [
['text' => '{"direction":"rising","confidence":72,"reasoning":"Consistent upward trend over 14 days."}'],
],
]),
]);
$prediction = $this->service->generateLlmPrediction($prices);
expect($prediction->direction)->toBe(TrendDirection::Rising)
->and($prediction->source)->toBe(PredictionSource::Llm)
->and($prediction->confidence)->toBe(72)
->and($prediction->reasoning)->toBe('Consistent upward trend over 14 days.');
});
it('caps LLM confidence at 85', function (): void {
$prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([
'date' => now()->subDays(14 - $i)->toDateString(),
'price_usd' => 75.0,
]));
Http::fake([
'https://api.anthropic.com/*' => Http::response([
'content' => [
['text' => '{"direction":"falling","confidence":99,"reasoning":"Very confident."}'],
],
]),
]);
$prediction = $this->service->generateLlmPrediction($prices);
expect($prediction->confidence)->toBe(85);
});
it('returns null when LLM returns malformed JSON', function (): void {
$prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([
'date' => now()->subDays(14 - $i)->toDateString(),
'price_usd' => 75.0,
]));
Http::fake([
'https://api.anthropic.com/*' => Http::response([
'content' => [['text' => 'Sorry, I cannot help with that.']],
]),
]);
expect($this->service->generateLlmPrediction($prices))->toBeNull();
});
// --- generateLlmPredictionWithContext ---
it('generates LLM prediction with context and returns LlmWithContext source', function (): void {
config(['services.anthropic.api_key' => 'test-key']);
$prices = collect(range(1, 20))->map(fn (int $i) => new BrentPrice([
'date' => now()->subDays(20 - $i)->toDateString(),
'price_usd' => 80.0 + $i * 0.5,
]));
Http::fake([
'https://api.anthropic.com/*' => Http::response([
'content' => [['type' => 'text', 'text' => '{"direction":"rising","confidence":72,"reasoning":"OPEC+ extended cuts while prices trend upward."}']],
'stop_reason' => 'end_turn',
]),
]);
$prediction = $this->service->generateLlmPredictionWithContext($prices);
expect($prediction)->not->toBeNull()
->and($prediction->direction)->toBe(TrendDirection::Rising)
->and($prediction->confidence)->toBe(72)
->and($prediction->source)->toBe(PredictionSource::LlmWithContext)
->and($prediction->reasoning)->toBe('OPEC+ extended cuts while prices trend upward.');
Http::assertSentCount(1);
});
it('sends web_search tool in the context prediction request', function (): void {
config(['services.anthropic.api_key' => 'test-key']);
$prices = collect(range(1, 20))->map(fn (int $i) => new BrentPrice([
'date' => now()->subDays(20 - $i)->toDateString(),
'price_usd' => 80.0,
]));
Http::fake([
'https://api.anthropic.com/*' => Http::response([
'content' => [['type' => 'text', 'text' => '{"direction":"flat","confidence":50,"reasoning":"No clear trend."}']],
'stop_reason' => 'end_turn',
]),
]);
$this->service->generateLlmPredictionWithContext($prices);
Http::assertSent(function ($request) {
$tools = $request->data()['tools'] ?? [];
return collect($tools)->contains(fn ($t) => $t['type'] === 'web_search_20250305');
});
});
it('does not include EWMA indicators in the context prediction prompt', function (): void {
config(['services.anthropic.api_key' => 'test-key']);
$prices = collect(range(1, 20))->map(fn (int $i) => new BrentPrice([
'date' => now()->subDays(20 - $i)->toDateString(),
'price_usd' => 80.0,
]));
Http::fake([
'https://api.anthropic.com/*' => Http::response([
'content' => [['type' => 'text', 'text' => '{"direction":"flat","confidence":50,"reasoning":"No clear trend."}']],
'stop_reason' => 'end_turn',
]),
]);
$this->service->generateLlmPredictionWithContext($prices);
Http::assertSent(function ($request) {
$content = $request->data()['messages'][0]['content'] ?? '';
return ! str_contains($content, 'EWMA') && ! str_contains($content, 'Pre-computed');
});
});
it('continues on pause_turn and returns final answer', function (): void {
config(['services.anthropic.api_key' => 'test-key']);
$prices = collect(range(1, 20))->map(fn (int $i) => new BrentPrice([
'date' => now()->subDays(20 - $i)->toDateString(),
'price_usd' => 80.0,
]));
Http::fake([
'https://api.anthropic.com/*' => Http::sequence()
->push([
'content' => [['type' => 'server_tool_use', 'id' => 'sttool_1', 'name' => 'web_search', 'input' => ['query' => 'Brent crude news']]],
'stop_reason' => 'pause_turn',
])
->push([
'content' => [['type' => 'text', 'text' => '{"direction":"falling","confidence":60,"reasoning":"Demand fears weigh on prices."}']],
'stop_reason' => 'end_turn',
]),
]);
$prediction = $this->service->generateLlmPredictionWithContext($prices);
expect($prediction)->not->toBeNull()
->and($prediction->direction)->toBe(TrendDirection::Falling);
Http::assertSentCount(2);
});
// --- generatePrediction (orchestrator) --- // --- generatePrediction (orchestrator) ---
it('uses LLM with context when API key is configured', function (): void { it('stores both EWMA and LLM predictions when provider succeeds', function (): void {
config(['services.anthropic.api_key' => 'test-key']);
BrentPrice::insert( BrentPrice::insert(
collect(range(1, 20))->map(fn (int $i) => [ collect(range(1, 20))->map(fn (int $i) => [
'date' => now()->subDays(20 - $i)->toDateString(), 'date' => now()->subDays(20 - $i)->toDateString(),
@@ -289,12 +127,14 @@ it('uses LLM with context when API key is configured', function (): void {
])->all() ])->all()
); );
Http::fake([ $this->provider->shouldReceive('predict')->once()->andReturn(new PricePrediction([
'https://api.anthropic.com/*' => Http::response([ 'predicted_for' => now()->toDateString(),
'content' => [['type' => 'text', 'text' => '{"direction":"rising","confidence":70,"reasoning":"Trend is up."}']], 'source' => PredictionSource::LlmWithContext,
'stop_reason' => 'end_turn', 'direction' => TrendDirection::Rising,
]), 'confidence' => 70,
]); 'reasoning' => 'Trend is up.',
'generated_at' => now(),
]));
$prediction = $this->service->generatePrediction(); $prediction = $this->service->generatePrediction();
@@ -302,33 +142,31 @@ it('uses LLM with context when API key is configured', function (): void {
->and(PricePrediction::count())->toBe(2); ->and(PricePrediction::count())->toBe(2);
}); });
it('falls back to plain LLM when context method fails', function (): void { it('returns LLM prediction when provider succeeds', function (): void {
config(['services.anthropic.api_key' => 'test-key']);
BrentPrice::insert( BrentPrice::insert(
collect(range(1, 20))->map(fn (int $i) => [ collect(range(1, 20))->map(fn (int $i) => [
'date' => now()->subDays(20 - $i)->toDateString(), 'date' => now()->subDays(20 - $i)->toDateString(),
'price_usd' => 75.0 + ($i * 0.8), 'price_usd' => 75.0 + $i,
])->all() ])->all()
); );
Http::fake([ $llmPrediction = new PricePrediction([
'https://api.anthropic.com/*' => Http::sequence() 'predicted_for' => now()->toDateString(),
->push([], 500) 'source' => PredictionSource::Llm,
->push([ 'direction' => TrendDirection::Rising,
'content' => [['text' => '{"direction":"rising","confidence":70,"reasoning":"Trend up."}']], 'confidence' => 65,
]), 'reasoning' => 'Rising trend.',
'generated_at' => now(),
]); ]);
$this->provider->shouldReceive('predict')->once()->andReturn($llmPrediction);
$prediction = $this->service->generatePrediction(); $prediction = $this->service->generatePrediction();
expect($prediction->source)->toBe(PredictionSource::Llm) expect($prediction->source)->toBe(PredictionSource::Llm);
->and(PricePrediction::count())->toBe(2);
}); });
it('falls back to EWMA when both LLM methods fail', function (): void { it('falls back to EWMA when provider returns null', function (): void {
config(['services.anthropic.api_key' => 'test-key']);
BrentPrice::insert( BrentPrice::insert(
collect(range(1, 20))->map(fn (int $i) => [ collect(range(1, 20))->map(fn (int $i) => [
'date' => now()->subDays(20 - $i)->toDateString(), 'date' => now()->subDays(20 - $i)->toDateString(),
@@ -336,9 +174,7 @@ it('falls back to EWMA when both LLM methods fail', function (): void {
])->all() ])->all()
); );
Http::fake([ $this->provider->shouldReceive('predict')->once()->andReturn(null);
'https://api.anthropic.com/*' => Http::response([], 500),
]);
$prediction = $this->service->generatePrediction(); $prediction = $this->service->generatePrediction();
@@ -352,6 +188,8 @@ it('returns null when there is insufficient price data', function (): void {
['date' => now()->subDay()->toDateString(), 'price_usd' => 76.0], ['date' => now()->subDay()->toDateString(), 'price_usd' => 76.0],
]); ]);
$this->provider->shouldNotReceive('predict');
expect($this->service->generatePrediction())->toBeNull() expect($this->service->generatePrediction())->toBeNull()
->and(PricePrediction::count())->toBe(0); ->and(PricePrediction::count())->toBe(0);
}); });