feat: add LLM prediction providers with structured output support
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ovidiu U
2026-04-07 14:42:44 +01:00
parent e9612666e3
commit 6a80c11f38
18 changed files with 1101 additions and 484 deletions

View File

@@ -10,7 +10,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
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')
->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--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::B7Standard,
'fuel_type' => FuelType::E10,
'price_pence' => 14000 + ((6 - $daysAgo) * 100),
'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')
->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--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::B7Standard,
'fuel_type' => FuelType::E10,
'price_pence' => 16000 - ((6 - $daysAgo) * 100),
'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')
->and($result['predicted_direction'])->toBe('down')
@@ -61,29 +61,70 @@ it('returns current_avg from station_prices_current', function () {
$station = Station::factory()->create();
StationPriceCurrent::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::B7Standard,
'fuel_type' => FuelType::E10,
'price_pence' => 14750,
]);
$result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard);
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['current_avg'])->toBe(147.5);
});
it('includes all required keys in response', function () {
$result = app(NationalFuelPredictionService::class)->predict(FuelType::B7Standard);
$result = app(NationalFuelPredictionService::class)->predict();
expect($result)->toHaveKeys([
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
'confidence_score', 'confidence_label', 'action', 'reasoning',
'prediction_horizon_days', 'region_key', 'methodology',
'signals',
]);
expect($result)
->toHaveKeys([
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
'confidence_score', 'confidence_label', 'action', 'reasoning',
'prediction_horizon_days', 'region_key', 'methodology',
'signals',
])
->and($result['signals'])->toHaveKeys([
'trend', 'day_of_week', 'brand_behaviour',
'national_momentum', 'regional_momentum', 'price_stickiness',
]);
});
expect($result['signals'])->toHaveKeys([
'trend', 'day_of_week', 'brand_behaviour',
'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 () {
@@ -94,13 +135,13 @@ it('disables trend signal when r_squared is below 0.5', function () {
foreach ($prices as $daysAgo => $price) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::B7Standard,
'fuel_type' => FuelType::E10,
'price_pence' => $price,
'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
expect($result['signals']['trend']['data_points'])->toBeInt();