Compare commits

..

8 Commits

Author SHA1 Message Date
Ovidiu U
6a80c11f38 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>
2026-04-07 14:42:44 +01:00
Ovidiu U
e9612666e3 feat: wire FuelFinder to home route, replacing static homepage 2026-04-07 14:38:03 +01:00
Ovidiu U
41a2cc5f43 feat: build fuel-finder view with mobile layout 2026-04-07 14:36:21 +01:00
Ovidiu U
8a8dc15c0d feat: add x-mobile-header and x-mobile-footer components 2026-04-07 14:35:27 +01:00
Ovidiu U
b21c99411d feat: add x-fuel.* Blade components 2026-04-07 14:34:44 +01:00
Ovidiu U
6d6def18f1 feat: add FuelFinder Livewire component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:32:45 +01:00
Ovidiu U
80ae25d98a test: add failing FuelFinder component tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:31:00 +01:00
Ovidiu U
3d552e8fcb docs: add FuelFinder mobile landing page design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:11:04 +01:00
33 changed files with 1977 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

@@ -0,0 +1,120 @@
<?php
namespace App\Livewire\Public;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\View\View;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.guest')]
class FuelFinder extends Component
{
#[Validate('required|string', message: 'Please enter a postcode, town or city.')]
public string $search = '';
#[Validate('required|string', message: 'Please select a fuel type.')]
public string $fuelType = 'petrol';
#[Validate('required|integer|min:1|max:20')]
public int $radius = 5;
#[Validate('nullable|string|in:price,distance,updated,brand,reliable')]
public string $sort = 'reliable';
public array $results = [];
public array $meta = [];
public ?array $prediction = null;
public ?string $apiError = null;
public bool $hasSearched = false;
public function updatedFuelType(): void
{
if ($this->hasSearched) {
$this->findStations();
}
}
public function updatedRadius(): void
{
if ($this->hasSearched) {
$this->findStations();
}
}
public function updatedSort(): void
{
if ($this->hasSearched) {
$this->findStations();
}
}
public function findStations(): void
{
$this->validate();
$this->results = [];
$this->meta = [];
$this->prediction = null;
$this->apiError = null;
$this->hasSearched = false;
$radiusKm = round($this->radius * 1.60934, 2);
try {
$response = Http::timeout(10)
->withHeaders(['X-Api-Key' => config('app.api_secret_key')])
->get(url('/api/stations'), [
'postcode' => $this->search,
'fuel_type' => $this->fuelType,
'radius' => $radiusKm,
'sort' => $this->sort,
]);
} catch (ConnectionException) {
$this->apiError = 'Unable to fetch stations. Please try again.';
return;
}
if ($response->status() === 422) {
$errors = $response->json('errors', []);
$this->apiError = collect($errors)->flatten()->first()
?? $response->json('message', 'Validation error.');
return;
}
if (! $response->successful()) {
$this->apiError = 'Unable to fetch stations. Please try again.';
return;
}
$this->results = $response->json('data', []);
$this->meta = $response->json('meta', []);
$this->hasSearched = true;
try {
$predictionResponse = Http::timeout(10)
->withHeaders(['X-Api-Key' => config('app.api_secret_key')])
->get(url('/api/prediction'));
if ($predictionResponse->successful()) {
$this->prediction = $predictionResponse->json();
}
} catch (ConnectionException) {
// Prediction failure is silent — stations are more important
}
}
public function render(): View
{
return view('livewire.public.fuel-finder');
}
}

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,9 +406,17 @@ 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
? [
'regionalMomentum' => 0.50,
'trend' => 0.20,
'dayOfWeek' => 0.15,
'brandBehaviour' => 0.10,
'stickiness' => 0.05,
]
: [
'trend' => 0.45, 'trend' => 0.45,
'dayOfWeek' => 0.20, 'dayOfWeek' => 0.20,
'brandBehaviour' => 0.25, 'brandBehaviour' => 0.25,

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')) {
$llm = $this->generateLlmPredictionWithContext($prices);
$llm ??= $this->generateLlmPrediction($prices);
if ($llm !== null) { if ($llm !== null) {
PricePrediction::create($llm->toArray());personal_access_tokens PricePrediction::create($llm->toArray());
}
} }
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

@@ -0,0 +1,202 @@
# FuelFinder Mobile Landing Page — Design Spec
**Date:** 2026-04-07
**Scope:** Replace static mobile homepage with a fully functional Livewire-powered landing page, backed by reusable Blade components shared with the desktop search.
---
## Goals
- Mobile landing page (`/`) becomes the working app — search, recommendation, map, stations list, forecast
- All interactive sections are driven by a single new Livewire component (`FuelFinder`)
- Presentation elements extracted into reusable Blade components consumed by both `FuelFinder` and the existing `StationSearch`
- As lean as possible — no duplicated logic, one station repeater, one map component
---
## Route Change
```php
// routes/web.php
Route::get('/', FuelFinder::class)->name('home');
```
`/stations` (StationSearch) is kept as-is for now. Migration to shared components happens separately.
---
## Livewire Component: `FuelFinder`
**File:** `app/Livewire/Public/FuelFinder.php`
**View:** `resources/views/livewire/public/fuel-finder.blade.php`
### Properties
| Property | Type | Default | Notes |
|---|---|---|---|
| `$search` | `string` | `''` | Validated: required |
| `$fuelType` | `string` | `'petrol'` | Validated: required |
| `$radius` | `int` | `5` | Validated: 120 |
| `$sort` | `string` | `'reliable'` | Validated: in allowed list |
| `$results` | `array` | `[]` | Populated from `/api/stations` |
| `$meta` | `array` | `[]` | Count, lowest/avg price |
| `$prediction` | `?array` | `null` | Populated from `/api/prediction` |
| `$apiError` | `?string` | `null` | Surface API/connection errors |
| `$hasSearched` | `bool` | `false` | Controls section visibility |
### Methods
**`findStations()`**
1. Validate all properties
2. Reset results, meta, prediction, apiError, hasSearched
3. Call `GET /api/stations` with postcode, fuel_type, radius (km), sort
4. On success: populate `$results`, `$meta`, set `$hasSearched = true`
5. Call `GET /api/prediction` with `fuel_type` (no lat/lng — national fallback)
6. On success: populate `$prediction`
7. On any failure: set `$apiError`
**`updatedFuelType()` / `updatedRadius()` / `updatedSort()`**
Re-run `findStations()` if `$hasSearched` is true (live filter refresh).
---
## Blade Components
All components live under `resources/views/components/fuel/` and are namespaced as `x-fuel.*`.
### `x-fuel.type-select`
Fuel type `<select>`. Accepts `wire:model` passthrough via `$attributes->whereStartsWith('wire:')`.
Options: Petrol (E10), Super Unleaded (E5), Diesel, Premium Diesel, B10 Biodiesel, HVO.
Reused in: `FuelFinder`, `StationSearch`.
### `x-fuel.radius-select`
Radius `<select>`. Options: 1, 2, 5, 10, 20 miles.
Accepts `wire:model` passthrough.
### `x-fuel.sort-select`
Sort `<select>`. Options: Best price (reliable), Cheapest first, Nearest first, Recently updated, Brand AZ.
Accepts `wire:model` passthrough.
### `x-fuel.station-card :station`
Single station row. Props from `/api/stations` response:
- Name, address, postcode, distance (km → miles)
- Price in pence (formatted to 1dp)
- Price colour: `current` → green, `recent` → slate, `stale` → amber, `outdated` → red
- `is_supermarket` → shows "Supermarket" badge
### `x-fuel.station-map :results`
Leaflet map wrapper using Alpine `x-data="stationMap(@entangle('results'))"` pattern.
- Default centre: UK (`54.0, -2.0`), zoom 6 — shown before search
- After search: re-centres to fit result bounds automatically (handled in `station-map.js`)
- Height: `h-56` mobile / `h-96` desktop
### `x-fuel.recommendation :prediction`
Recommendation card driven by `/api/prediction` response.
- Shows `action` as headline: `fill_now` → "Fill up now", `wait` → "Wait", else "No signal"
- Confidence ring (SVG) from `confidence_score` (0100)
- `reasoning` text below
- Confidence label badge (`confidence_label`: low / medium / high)
- Hidden when `$prediction` is null
### `x-fuel.forecast`
Static 14-day forecast Pro upsell card.
- SVG squiggle line chart (decorative)
- Blurred/locked overlay with "Unlock Forecast" CTA button
- "Pro" badge
### `x-mobile-header`
Mobile app header: FuelAlert logo + user icon button (links to login/dashboard based on auth state).
`pt-14` for safe area, fixed positioning.
### `x-mobile-footer`
Mobile tab bar: Prices, Alerts, Forecourts, Trends. Sticky bottom, `pb-8` for safe area.
Active tab highlight for current route.
---
## View: `fuel-finder.blade.php`
Mobile layout only (desktop layout handled by existing homepage / StationSearch).
Structure:
```
<x-mobile-header />
<main> {{-- scrollable --}}
{{-- #search --}}
Search input + x-fuel.type-select + x-fuel.radius-select + x-fuel.sort-select
Displayed as pill-style filter buttons (scroll horizontally on mobile)
Submit triggers findStations()
{{-- #recommendation --}}
<x-fuel.recommendation :prediction="$prediction" />
Hidden (@if $prediction) until searched
{{-- #map --}}
<x-fuel.station-map :results="$results" />
Always visible — UK centre until search
{{-- #stations --}}
@if $hasSearched
@forelse $results as $station
<x-fuel.station-card :station="$station" />
@empty
"No stations found" message
@endforelse
@endif
{{-- #forecast --}}
<x-fuel.forecast />
</main>
<x-mobile-footer />
```
---
## API Contracts
### `/api/stations`
| Param | Type | Notes |
|---|---|---|
| `postcode` | string | Full postcode, outcode, or place name |
| `fuel_type` | string | e.g. `petrol`, `diesel`, `e5` |
| `radius` | float | km (convert from miles: × 1.60934) |
| `sort` | string | `reliable`, `price`, `distance`, `updated`, `brand` |
Response shape: `{ data: Station[], meta: { count, lowest_pence, avg_pence } }`
### `/api/prediction`
| Param | Type | Notes |
|---|---|---|
| `lat` | float? | Optional — falls back to national |
| `lng` | float? | Optional |
Key response fields used: `action`, `confidence_score`, `confidence_label`, `reasoning`, `predicted_direction`, `predicted_change_pence`.
---
## Data Flow
```
User types postcode → submits → findStations()
→ GET /api/stations → $results, $meta, $hasSearched=true
→ GET /api/prediction → $prediction
→ blade re-renders:
#recommendation shows with prediction data
#map re-centres to results
#stations lists results
```
Filter change (fuel type / radius / sort) → `updated*()``findStations()` if `$hasSearched`
---
## Not in scope
- Desktop layout changes (StationSearch at `/stations` unchanged)
- Migrating StationSearch to use shared Blade components (follow-up)
- lat/lng resolution for `/api/prediction` (uses national fallback for now)
- Tab bar navigation routes (Alerts, Forecourts, Trends pages don't exist yet)

View File

@@ -0,0 +1,29 @@
<div class="relative overflow-hidden rounded-2xl border border-[#e5ded7] bg-[#faf6f3] p-5 shadow-sm">
{{-- Pro badge --}}
<div class="mb-3 flex items-center justify-between">
<div>
<p class="text-[10px] font-bold uppercase tracking-widest text-[#89726c]">14-Day Forecast</p>
<h3 class="text-lg font-black text-[#4a3f3b]">Price Trend</h3>
</div>
<span class="rounded-full bg-[#bb5b3e] px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-white">
Pro
</span>
</div>
{{-- Decorative squiggle chart --}}
<svg viewBox="0 0 200 60" class="mb-4 h-16 w-full opacity-40" fill="none" stroke="#bb5b3e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="0,45 20,38 40,42 60,30 80,35 100,20 120,25 140,15 160,22 180,12 200,18" />
</svg>
{{-- Blurred overlay --}}
<div class="absolute inset-0 flex flex-col items-center justify-center rounded-2xl bg-[#faf6f3]/80 backdrop-blur-sm">
<iconify-icon icon="lucide:lock" class="mb-2 text-3xl text-[#bb5b3e]"></iconify-icon>
<p class="mb-3 text-sm font-bold text-[#4a3f3b]">14-day forecast is a Pro feature</p>
<a
href="{{ route('register') }}"
class="rounded-xl bg-[#bb5b3e] px-5 py-2.5 text-sm font-bold text-white shadow-md"
>
Unlock Forecast
</a>
</div>
</div>

View File

@@ -0,0 +1,10 @@
<select
{{ $attributes->whereStartsWith('wire:') }}
class="h-11 w-full rounded-xl border border-[#e5ded7] bg-[#faf6f3] px-3 text-sm font-semibold text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
>
<option value="1">1 mile</option>
<option value="2">2 miles</option>
<option value="5">5 miles</option>
<option value="10">10 miles</option>
<option value="20">20 miles</option>
</select>

View File

@@ -0,0 +1,63 @@
@props(['prediction'])
@if ($prediction)
@php
$action = $prediction['action'] ?? 'no_signal';
$headline = match ($action) {
'fill_now' => 'Fill up now',
'wait' => 'Wait',
default => 'No signal',
};
$score = (float) ($prediction['confidence_score'] ?? 0);
$circumference = 125.6; // 2π × 20
$offset = round($circumference * (1 - $score / 100), 1);
$label = $prediction['confidence_label'] ?? 'low';
$labelColour = match ($label) {
'high' => 'bg-green-100 text-green-700',
'medium' => 'bg-amber-100 text-amber-700',
default => 'bg-zinc-100 text-zinc-500',
};
@endphp
<div class="rounded-2xl border border-[#e5ded7] bg-[#faf6f3] p-5 shadow-sm">
<div class="mb-3 flex items-start justify-between">
<div>
<p class="mb-1 text-[10px] font-bold uppercase tracking-widest text-[#89726c]">
Recommendation
</p>
<h2 class="text-3xl font-black leading-tight text-[#8B4860]">
{{ $headline }}
</h2>
</div>
<div class="flex flex-col items-center gap-1">
<div class="relative h-12 w-12">
<svg class="h-full w-full -rotate-90" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="20" stroke="#eeeae5" stroke-width="4" fill="transparent" />
<circle
cx="24" cy="24" r="20"
stroke="#8B4860" stroke-width="4" fill="transparent"
stroke-dasharray="{{ $circumference }}"
stroke-dashoffset="{{ $offset }}"
stroke-linecap="round"
/>
</svg>
<span class="absolute inset-0 flex items-center justify-center text-[11px] font-black text-[#4a3f3b]">
{{ (int) $score }}%
</span>
</div>
<span class="text-[9px] font-bold uppercase tracking-wider text-[#89726c]">Confidence</span>
</div>
</div>
<p class="text-sm font-medium leading-relaxed text-[#6b5a55]">
{{ $prediction['reasoning'] ?? '' }}
</p>
<div class="mt-3">
<span class="rounded-full px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide {{ $labelColour }}">
{{ ucfirst($label) }} confidence
</span>
</div>
</div>
@endif

View File

@@ -0,0 +1,10 @@
<select
{{ $attributes->whereStartsWith('wire:') }}
class="h-11 w-full rounded-xl border border-[#e5ded7] bg-[#faf6f3] px-3 text-sm font-semibold text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
>
<option value="reliable">Best price (reliable)</option>
<option value="price">Cheapest first</option>
<option value="distance">Nearest first</option>
<option value="updated">Recently updated</option>
<option value="brand">Brand AZ</option>
</select>

View File

@@ -0,0 +1,42 @@
@props(['station'])
@php
$colourClass = match($station['price_classification'] ?? '') {
'current' => 'text-green-500',
'recent' => 'text-slate-500',
'stale' => 'text-amber-500',
'outdated' => 'text-red-500',
default => 'text-zinc-400',
};
$miles = number_format(($station['distance_km'] ?? 0) * 0.621371, 1);
$price = number_format($station['price'] ?? 0, 1);
@endphp
<div class="flex items-center justify-between rounded-xl border border-[#e5ded7] bg-[#faf6f3] px-4 py-3.5">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate text-sm font-bold text-[#4a3f3b]">
{{ $station['name'] ?? '' }}
</p>
@if (! empty($station['is_supermarket']))
<span class="shrink-0 rounded bg-lime-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-lime-700">
Supermarket
</span>
@endif
</div>
<p class="truncate text-xs text-[#89726c]">
{{ $station['address'] ?? '' }}, {{ $station['postcode'] ?? '' }}
</p>
<p class="text-xs text-[#b0a09a]">{{ $miles }} miles away</p>
</div>
<div class="ml-4 shrink-0 text-right">
<p class="text-xl font-black text-[#4a3f3b]">{{ $price }}p</p>
<p class="text-[11px] {{ $colourClass }}">
{{ $station['price_classification_label'] ?? '' }}
@if (! empty($station['price_updated_at']))
&middot; {{ \Carbon\Carbon::parse($station['price_updated_at'])->diffForHumans() }}
@endif
</p>
</div>
</div>

View File

@@ -0,0 +1,6 @@
@props(['results' => []])
<div
x-data="stationMap(@entangle('results'))"
class="h-56 w-full overflow-hidden border-y border-[#e5ded7] md:h-96"
></div>

View File

@@ -0,0 +1,11 @@
<select
{{ $attributes->whereStartsWith('wire:') }}
class="h-11 w-full rounded-xl border border-[#e5ded7] bg-[#faf6f3] px-3 text-sm font-semibold text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
>
<option value="petrol">Petrol (E10)</option>
<option value="e5">Super Unleaded (E5)</option>
<option value="diesel">Diesel</option>
<option value="b7_premium">Premium Diesel</option>
<option value="b10">B10 Biodiesel</option>
<option value="hvo">HVO</option>
</select>

View File

@@ -0,0 +1,26 @@
@php
$tabs = [
['label' => 'Prices', 'icon' => 'lucide:fuel', 'route' => 'home'],
['label' => 'Alerts', 'icon' => 'lucide:bell', 'route' => null],
['label' => 'Forecourts', 'icon' => 'lucide:map-pin', 'route' => null],
['label' => 'Trends', 'icon' => 'lucide:trending-up','route' => null],
];
$currentRoute = request()->routeIs('home') ? 'home' : null;
@endphp
<nav class="fixed inset-x-0 bottom-0 z-50 border-t border-[#e5ded7] bg-[#faf6f3] pb-8">
<div class="flex">
@foreach ($tabs as $tab)
@php
$isActive = $tab['route'] && $currentRoute === $tab['route'];
$colour = $isActive ? 'text-[#bb5b3e]' : 'text-[#89726c]';
@endphp
<div class="flex flex-1 flex-col items-center gap-1 pt-3">
<iconify-icon icon="{{ $tab['icon'] }}" class="text-xl {{ $colour }}"></iconify-icon>
<span class="text-[10px] font-bold uppercase tracking-wide {{ $colour }}">
{{ $tab['label'] }}
</span>
</div>
@endforeach
</div>
</nav>

View File

@@ -0,0 +1,17 @@
<header class="fixed inset-x-0 top-0 z-50 shrink-0 border-b border-[#e5ded7] bg-[#faf6f3] px-5 pb-4 pt-14 shadow-sm flex items-center justify-between">
<div class="flex items-center gap-2.5">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-[#bb5b3e] shadow-md">
<iconify-icon icon="lucide:fuel" class="text-xl text-white"></iconify-icon>
</div>
<span class="text-2xl font-black tracking-tighter text-[#bb5b3e]">FuelAlert</span>
</div>
@auth
<a href="{{ route('dashboard') }}" class="flex h-10 w-10 items-center justify-center rounded-full border border-[#e5ded7] bg-[#f5ede5]">
<iconify-icon icon="lucide:user" class="text-lg text-[#89726c]"></iconify-icon>
</a>
@else
<a href="{{ route('login') }}" class="flex h-10 w-10 items-center justify-center rounded-full border border-[#e5ded7] bg-[#f5ede5]">
<iconify-icon icon="lucide:user" class="text-lg text-[#89726c]"></iconify-icon>
</a>
@endauth
</header>

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
</head>
<body class="font-sans antialiased">
{{ $slot }}
</body>
</html>

View File

@@ -0,0 +1,106 @@
<div class="flex h-dvh flex-col bg-[#f5ede5]">
<x-mobile-header />
{{-- Scrollable main content, offset for fixed header (~80px) and footer (~80px) --}}
<main
class="flex-1 overflow-y-auto pt-[80px] pb-[80px]"
style="-ms-overflow-style:none;scrollbar-width:none;"
>
{{-- #search --}}
<section class="space-y-3 px-5 pt-5 pb-4">
<form wire:submit="findStations">
<div class="relative mb-3">
<iconify-icon
icon="lucide:map-pin"
class="absolute left-4 top-1/2 -translate-y-1/2 text-xl text-[#89726c] pointer-events-none"
></iconify-icon>
<input
wire:model="search"
type="text"
name="search"
placeholder="Postcode, town or city"
class="h-14 w-full rounded-xl border border-[#e5ded7] bg-[#faf6f3] pl-12 pr-4 text-base font-semibold text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e] focus:border-transparent"
/>
</div>
@error('search')
<p class="mb-2 text-sm text-red-600">{{ $message }}</p>
@enderror
{{-- Filter pills (scrollable row) --}}
<div class="flex gap-2 overflow-x-auto pb-1" style="-ms-overflow-style:none;scrollbar-width:none;">
<div class="shrink-0">
<x-fuel.type-select wire:model.live="fuelType" />
</div>
<div class="shrink-0">
<x-fuel.radius-select wire:model.live="radius" />
</div>
<div class="shrink-0">
<x-fuel.sort-select wire:model.live="sort" />
</div>
</div>
<button
type="submit"
wire:loading.attr="disabled"
class="mt-3 w-full rounded-xl bg-[#bb5b3e] py-3.5 text-sm font-bold text-white shadow-md disabled:opacity-60"
>
<span wire:loading.remove wire:target="findStations">Find Stations</span>
<span wire:loading wire:target="findStations">Searching…</span>
</button>
</form>
@if ($apiError)
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ $apiError }}
</div>
@endif
</section>
{{-- #recommendation --}}
@if ($prediction)
<section class="px-5 pb-5">
<x-fuel.recommendation :prediction="$prediction" />
</section>
@endif
{{-- #map --}}
<section class="mb-4">
<x-fuel.station-map :results="$results" />
</section>
{{-- #stations --}}
@if ($hasSearched)
<section class="px-5 pb-5">
@if (! empty($meta))
<div class="mb-3 flex items-center justify-between">
<h3 class="text-base font-bold text-[#4a3f3b]">Stations Nearby</h3>
<span class="text-[10px] font-bold uppercase tracking-widest text-[#89726c]">
{{ $meta['count'] ?? 0 }} {{ str('Result')->plural($meta['count'] ?? 0) }}
</span>
</div>
@endif
@forelse ($results as $station)
<div class="mb-2">
<x-fuel.station-card :station="$station" />
</div>
@empty
<p class="text-sm text-[#89726c]">
No stations found within {{ $radius }} {{ str('mile')->plural($radius) }} of "{{ $search }}".
</p>
@endforelse
</section>
@endif
{{-- #forecast --}}
<section class="px-5 pb-8">
<x-fuel.forecast />
</section>
</main>
<x-mobile-footer />
</div>

View File

@@ -1,9 +1,10 @@
<?php <?php
use App\Livewire\Public\FuelFinder;
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::get('/', FuelFinder::class)->name('home');
Route::get('/stations', StationSearch::class)->name('stations.search'); Route::get('/stations', StationSearch::class)->name('stations.search');

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,223 @@
<?php
use App\Livewire\Public\FuelFinder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders the fuel finder page', function () {
Livewire::test(FuelFinder::class)
->assertStatus(200)
->assertSeeHtml('name="search"');
});
it('has default property values', function () {
Livewire::test(FuelFinder::class)
->assertSet('search', '')
->assertSet('fuelType', 'petrol')
->assertSet('radius', 5)
->assertSet('sort', 'reliable')
->assertSet('results', [])
->assertSet('meta', [])
->assertSet('prediction', null)
->assertSet('apiError', null)
->assertSet('hasSearched', false);
});
it('validates search is required', function () {
Livewire::test(FuelFinder::class)
->call('findStations')
->assertHasErrors(['search' => 'required']);
});
it('validates fuelType is required', function () {
Livewire::test(FuelFinder::class)
->set('search', 'SW1A 1AA')
->set('fuelType', '')
->call('findStations')
->assertHasErrors(['fuelType' => 'required']);
});
it('populates results, meta, and prediction on successful search', function () {
Http::fake([
'*/api/stations*' => Http::response([
'data' => [
[
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street',
'postcode' => 'SW1A 1AA',
'lat' => 51.5074,
'lng' => -0.1278,
'distance_km' => 1.5,
'fuel_type' => 'e10',
'price_pence' => 14390,
'price' => 143.9,
'price_updated_at' => '2026-04-05T08:00:00.000Z',
'price_classification' => 'current',
'price_classification_label' => 'Current',
],
],
'meta' => [
'count' => 1,
'lowest_pence' => 14390,
'avg_pence' => 14390.0,
],
], 200),
'*/api/prediction*' => Http::response([
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices rising.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
], 200),
]);
Livewire::test(FuelFinder::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('hasSearched', true)
->assertSet('apiError', null)
->assertSet('results', fn (array $r) => count($r) === 1 && $r[0]['name'] === 'BP Garage')
->assertSet('meta', fn (array $m) => $m['count'] === 1)
->assertSet('prediction', fn (?array $p) => $p !== null && $p['action'] === 'fill_now');
});
it('sets apiError from 422 station response and leaves prediction null', function () {
Http::fake([
'*/api/stations*' => Http::response([
'errors' => ['postcode' => ['Postcode not found.']],
], 422),
]);
Livewire::test(FuelFinder::class)
->set('search', 'ZZ99 9ZZ')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('results', [])
->assertSet('meta', [])
->assertSet('prediction', null)
->assertSet('hasSearched', false)
->assertSet('apiError', 'Postcode not found.');
});
it('sets generic apiError on server error', function () {
Http::fake([
'*/api/stations*' => Http::response([], 500),
]);
Livewire::test(FuelFinder::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('apiError', 'Unable to fetch stations. Please try again.');
});
it('converts radius from miles to km in the outgoing stations request', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(FuelFinder::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->set('radius', 5)
->call('findStations');
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'api/stations')) {
return false;
}
$data = $request->data();
return isset($data['radius']) && abs((float) $data['radius'] - 8.05) < 0.01;
});
});
it('resets state before each new search', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(FuelFinder::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->set('results', [['name' => 'Old Result']])
->set('apiError', 'Old error')
->call('findStations')
->assertSet('apiError', null)
->assertSet('results', []);
});
it('does not call findStations on updatedFuelType if not yet searched', function () {
Http::fake();
Livewire::test(FuelFinder::class)
->set('fuelType', 'diesel');
Http::assertNothingSent();
});
it('re-runs findStations on updatedFuelType when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(FuelFinder::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('fuelType', 'diesel');
Http::assertSentCount(2); // stations + prediction
});
it('re-runs findStations on updatedRadius when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(FuelFinder::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('radius', 10);
Http::assertSentCount(2);
});
it('re-runs findStations on updatedSort when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(FuelFinder::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('sort', 'price');
Http::assertSentCount(2);
});
it('prediction remains null when prediction api fails', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response([], 500),
]);
Livewire::test(FuelFinder::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('hasSearched', true)
->assertSet('prediction', null);
});

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,31 +61,72 @@ 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)
->toHaveKeys([
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence', 'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
'confidence_score', 'confidence_label', 'action', 'reasoning', 'confidence_score', 'confidence_label', 'action', 'reasoning',
'prediction_horizon_days', 'region_key', 'methodology', 'prediction_horizon_days', 'region_key', 'methodology',
'signals', 'signals',
]); ])
->and($result['signals'])->toHaveKeys([
expect($result['signals'])->toHaveKeys([
'trend', 'day_of_week', 'brand_behaviour', 'trend', 'day_of_week', 'brand_behaviour',
'national_momentum', 'regional_momentum', 'price_stickiness', 'national_momentum', 'regional_momentum', 'price_stickiness',
]); ]);
}); });
it('always returns e10 as fuel_type', function () {
$result = app(NationalFuelPredictionService::class)->predict();
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 () {
$station = Station::factory()->create(); $station = Station::factory()->create();
@@ -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);
}); });