Compare commits
14 Commits
a0e74f2363
...
c2c16c928b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2c16c928b | ||
|
|
fb4c413926 | ||
|
|
1d2eb12e83 | ||
|
|
a3288955d8 | ||
|
|
52dc225b3d | ||
|
|
b2cc3ee0ff | ||
|
|
d936175090 | ||
|
|
d602c8bde4 | ||
|
|
9998a3e1c8 | ||
|
|
a8fb275793 | ||
|
|
99b0ce480b | ||
|
|
cf187f9721 | ||
|
|
efba3cbfd6 | ||
|
|
cde3a27cff |
@@ -6,15 +6,20 @@ Produces a "fill up now or wait?" recommendation per user based on their local
|
||||
station history. Output is one of: `fill_up`, `wait`, `no_signal`.
|
||||
Never guess — stay silent (no_signal) when signals conflict or data is insufficient.
|
||||
|
||||
## The 4 signals (in priority order)
|
||||
## The 5 signals (in priority order)
|
||||
|
||||
### Signal 1 — Local price trend (HIGHEST WEIGHT)
|
||||
- Query `station_prices` for user's nearest 5 stations (within 5km of user lat/lng)
|
||||
- Use last 14 days of history for `e10` (or user's preferred fuel type)
|
||||
- Calculate 3-day rolling average vs 7-day rolling average
|
||||
- **Falling**: 3-day avg < 7-day avg by ≥ 0.5p → positive wait signal
|
||||
- **Rising**: 3-day avg > 7-day avg by ≥ 0.5p → fill_up signal
|
||||
- **Flat**: difference < 0.5p → neutral, no signal
|
||||
- **Use linear regression, not rolling averages:**
|
||||
- Run least-squares regression on `(recorded_at, price_pence)` pairs
|
||||
- Calculate slope (pence/day) and R² (goodness of fit, 0–1)
|
||||
- Only use the regression result if R² ≥ 0.5 — below that, data is too noisy
|
||||
- Use adaptive lookback: try 5 days first (best signal on sharp moves), fall back to 14 days if R² < 0.5
|
||||
- **Falling**: slope ≤ -0.3p/day AND R² ≥ 0.5 → wait signal, points scale with slope magnitude
|
||||
- **Rising**: slope ≥ +0.3p/day AND R² ≥ 0.5 → fill_up signal
|
||||
- **Flat / noisy**: |slope| < 0.3 OR R² < 0.5 → no signal from this source
|
||||
- Store slope, R², lookback_days, and data_points in signal output
|
||||
- Weight: 40 points max
|
||||
|
||||
### Signal 2 — Supermarket anchor effect (HIGH WEIGHT)
|
||||
@@ -22,6 +27,7 @@ Never guess — stay silent (no_signal) when signals conflict or data is insuffi
|
||||
- Check if supermarket cut price in last 48 hours (> 1p drop)
|
||||
- Check if nearest non-supermarket stations have NOT yet followed
|
||||
- If supermarket cut AND independents haven't moved → strong wait signal
|
||||
- Also check the inverse: if supermarket RAISED and independents haven't → mild fill_up
|
||||
- Weight: 35 points max
|
||||
|
||||
### Signal 3 — Day-of-week pattern (MEDIUM WEIGHT — needs 8+ weeks data)
|
||||
@@ -39,6 +45,16 @@ Never guess — stay silent (no_signal) when signals conflict or data is insuffi
|
||||
- Points awarded proportionally to confidence: `(confidence / 100) * 10`
|
||||
- Weight: 10 points max
|
||||
|
||||
### Signal 5 — Price stickiness (CONFIDENCE MODIFIER)
|
||||
- Per station: calculate average hold duration (days between price changes) from history
|
||||
- Requires 30+ days of history to activate
|
||||
- Use as a confidence modifier, not a directional signal:
|
||||
- avg hold < 2 days → reduce overall confidence by 5 points (volatile, hard to predict)
|
||||
- avg hold 2–4 days → neutral, no adjustment
|
||||
- avg hold > 5 days → increase overall confidence by 5 points (predictable, sticky)
|
||||
- Store avg_hold_days and data_points in signal output
|
||||
- Applied after all other signals are summed (±5 points)
|
||||
|
||||
## Confidence thresholds
|
||||
|
||||
- Score 70–100: strong signal → fire recommendation + notification
|
||||
@@ -51,29 +67,50 @@ Never guess — stay silent (no_signal) when signals conflict or data is insuffi
|
||||
|
||||
```php
|
||||
[
|
||||
'recommendation' => 'wait', // fill_up | wait | no_signal
|
||||
'confidence' => 78, // 0-100
|
||||
'signals' => [
|
||||
'trend' => ['direction' => 'falling', 'points' => 32],
|
||||
'recommendation' => 'wait',
|
||||
'confidence' => 78,
|
||||
'signals' => [
|
||||
'trend' => [
|
||||
'direction' => 'falling',
|
||||
'slope' => -1.07, // pence per day
|
||||
'r_squared' => 0.96,
|
||||
'lookback_days' => 5,
|
||||
'data_points' => 5,
|
||||
'points' => 32,
|
||||
],
|
||||
'supermarket' => ['triggered' => true, 'points' => 35],
|
||||
'day_pattern' => ['triggered' => false, 'points' => 0],
|
||||
'brent' => ['direction' => 'flat', 'points' => 0],
|
||||
'stickiness' => ['avg_hold_days' => 2.8, 'modifier' => 0],
|
||||
],
|
||||
'local_avg_pence' => 14380, // 143.80p
|
||||
'trend_delta' => -2.3, // pence change over 7 days
|
||||
'local_avg_pence' => 14380, // 143.80p
|
||||
'trend_delta' => -2.3, // pence change over lookback period
|
||||
]
|
||||
```
|
||||
|
||||
## Human-readable reason strings
|
||||
|
||||
Always generate a plain-English reason for the recommendation:
|
||||
- "Prices near you have been falling for 6 days. Tesco {station} cut 3p yesterday — independents usually follow within 48 hours."
|
||||
- "Prices are rising in your area — filling up today avoids paying more later."
|
||||
- "Prices near you have been falling at 1.1p/day for 5 days. Tesco {station} cut 3p yesterday — independents usually follow within 48 hours."
|
||||
- "Prices are rising sharply in your area (+7.5p expected this week) — filling up today avoids paying more later."
|
||||
- "No clear pattern this week — fill up at the cheapest station near you now."
|
||||
|
||||
Reason strings are stored in `scoring_results.signals` JSON and shown in the UI and notifications.
|
||||
|
||||
## Data quality — anomaly rejection
|
||||
|
||||
The Fuel Finder API contains dirty data (live example: 1369.0p/litre in national index).
|
||||
Reject a price record before storing or scoring if:
|
||||
- `price_pence > 25000` (over 250p/litre — physically implausible for UK pump prices)
|
||||
- `price_pence < 10000` (under 100p/litre — almost certainly a decimal entry error)
|
||||
- Price changed by more than 20p in a single update from the same station
|
||||
(flag for review, do not use in scoring)
|
||||
|
||||
Log rejected records to an `anomalous_prices` table for monitoring.
|
||||
Never let a dirty data point skew the regression slope or collapse R².
|
||||
|
||||
## Accuracy self-tracking
|
||||
|
||||
After 3 days, check if `wait` recommendation was correct (prices did fall further).
|
||||
Store outcome in `scoring_results` for future display: "This signal has been right X% of the time in your area."
|
||||
Store outcome in `scoring_results` for future display:
|
||||
"This signal has been right X% of the time in your area."
|
||||
35
app/Filament/NavigationGroup.php
Normal file
35
app/Filament/NavigationGroup.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Support\Contracts\HasIcon;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
|
||||
enum NavigationGroup implements HasIcon, HasLabel
|
||||
{
|
||||
case Users;
|
||||
|
||||
case Data;
|
||||
|
||||
case System;
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Users => 'Users',
|
||||
self::Data => 'Data',
|
||||
self::System => 'System',
|
||||
};
|
||||
}
|
||||
|
||||
public function getIcon(): string|BackedEnum|Htmlable|null
|
||||
{
|
||||
return match ($this) {
|
||||
self::Users => 'heroicon-o-users',
|
||||
self::Data => 'heroicon-o-circle-stack',
|
||||
self::System => 'heroicon-o-cog-6-tooth',
|
||||
};
|
||||
}
|
||||
}
|
||||
133
app/Filament/Resources/ApiLogResource.php
Normal file
133
app/Filament/Resources/ApiLogResource.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\ApiLogResource\Pages\ListApiLogs;
|
||||
use App\Filament\Resources\ApiLogResource\Pages\ViewApiLog;
|
||||
use App\Models\ApiLog;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ApiLogResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ApiLog::class;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::System;
|
||||
|
||||
protected static ?string $navigationLabel = 'API Logs';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('service')
|
||||
->badge()
|
||||
->color(fn (string $state) => match ($state) {
|
||||
'fuel_finder' => 'success',
|
||||
'fred' => 'info',
|
||||
'anthropic' => 'warning',
|
||||
default => 'gray',
|
||||
})
|
||||
->sortable(),
|
||||
TextColumn::make('method')
|
||||
->badge()
|
||||
->color('gray'),
|
||||
TextColumn::make('url')
|
||||
->limit(60)
|
||||
->tooltip(fn (ApiLog $record) => $record->url),
|
||||
TextColumn::make('status_code')
|
||||
->badge()
|
||||
->color(fn (?int $state) => match (true) {
|
||||
$state === null => 'danger',
|
||||
$state >= 500 => 'danger',
|
||||
$state >= 400 => 'warning',
|
||||
default => 'success',
|
||||
}),
|
||||
TextColumn::make('duration_ms')
|
||||
->label('Duration (ms)')
|
||||
->sortable(),
|
||||
TextColumn::make('error')
|
||||
->limit(40)
|
||||
->placeholder('—'),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime('d M Y H:i')
|
||||
->sortable(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
SelectFilter::make('service')
|
||||
->options([
|
||||
'fuel_finder' => 'Fuel Finder',
|
||||
'fred' => 'FRED',
|
||||
'anthropic' => 'Anthropic',
|
||||
'postcodes_io' => 'Postcodes.io',
|
||||
]),
|
||||
Filter::make('errors_only')
|
||||
->label('Errors only')
|
||||
->query(fn (Builder $query) => $query->where(
|
||||
fn (Builder $q) => $q->where('status_code', '>=', 400)
|
||||
->orWhereNull('status_code')
|
||||
->orWhereNotNull('error')
|
||||
)),
|
||||
Filter::make('created_at')
|
||||
->schema([
|
||||
DatePicker::make('from')->label('From'),
|
||||
DatePicker::make('until')->label('Until'),
|
||||
])
|
||||
->query(function (Builder $query, array $data) {
|
||||
$query
|
||||
->when($data['from'], fn ($q, $d) => $q->whereDate('created_at', '>=', $d))
|
||||
->when($data['until'], fn ($q, $d) => $q->whereDate('created_at', '<=', $d));
|
||||
}),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Section::make('Request')->schema([
|
||||
TextEntry::make('service')->badge(),
|
||||
TextEntry::make('method'),
|
||||
TextEntry::make('url')->columnSpanFull(),
|
||||
TextEntry::make('status_code')
|
||||
->badge()
|
||||
->color(fn (?int $state) => match (true) {
|
||||
$state === null => 'danger',
|
||||
$state >= 500 => 'danger',
|
||||
$state >= 400 => 'warning',
|
||||
default => 'success',
|
||||
}),
|
||||
TextEntry::make('duration_ms')->label('Duration (ms)'),
|
||||
TextEntry::make('created_at')->dateTime('d M Y H:i:s'),
|
||||
])->columns(3),
|
||||
Section::make('Error')->schema([
|
||||
TextEntry::make('error')
|
||||
->columnSpanFull()
|
||||
->placeholder('No error recorded'),
|
||||
])->collapsed(fn (ApiLog $record) => $record->error === null),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListApiLogs::route('/'),
|
||||
'view' => ViewApiLog::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
16
app/Filament/Resources/ApiLogResource/Pages/ListApiLogs.php
Normal file
16
app/Filament/Resources/ApiLogResource/Pages/ListApiLogs.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ApiLogResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ApiLogResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListApiLogs extends ListRecords
|
||||
{
|
||||
protected static string $resource = ApiLogResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
16
app/Filament/Resources/ApiLogResource/Pages/ViewApiLog.php
Normal file
16
app/Filament/Resources/ApiLogResource/Pages/ViewApiLog.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ApiLogResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ApiLogResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewApiLog extends ViewRecord
|
||||
{
|
||||
protected static string $resource = ApiLogResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
45
app/Filament/Resources/BrentPriceResource.php
Normal file
45
app/Filament/Resources/BrentPriceResource.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\BrentPriceResource\Pages\ListBrentPrices;
|
||||
use App\Models\BrentPrice;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class BrentPriceResource extends Resource
|
||||
{
|
||||
protected static ?string $model = BrentPrice::class;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data;
|
||||
|
||||
protected static ?string $navigationLabel = 'Brent Prices';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('date')
|
||||
->date('d M Y')
|
||||
->sortable(),
|
||||
TextColumn::make('price_usd')
|
||||
->label('Price (USD/barrel)')
|
||||
->numeric(2)
|
||||
->sortable(),
|
||||
])
|
||||
->defaultSort('date', 'desc')
|
||||
->recordActions([])
|
||||
->filters([]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListBrentPrices::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BrentPriceResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BrentPriceResource;
|
||||
use App\Filament\Widgets\BrentPriceChartWidget;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBrentPrices extends ListRecords
|
||||
{
|
||||
protected static string $resource = BrentPriceResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
BrentPriceChartWidget::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
130
app/Filament/Resources/OilPredictionResource.php
Normal file
130
app/Filament/Resources/OilPredictionResource.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Enums\PredictionSource;
|
||||
use App\Enums\TrendDirection;
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
|
||||
use App\Filament\Resources\OilPredictionResource\Pages\ViewOilPrediction;
|
||||
use App\Models\PricePrediction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class OilPredictionResource extends Resource
|
||||
{
|
||||
protected static ?string $model = PricePrediction::class;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data;
|
||||
|
||||
protected static ?string $navigationLabel = 'Oil Predictions';
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('predicted_for')
|
||||
->date('d M Y')
|
||||
->sortable(),
|
||||
TextColumn::make('source')
|
||||
->badge()
|
||||
->formatStateUsing(fn (PredictionSource $state) => strtoupper($state->value))
|
||||
->color(fn (PredictionSource $state) => match ($state) {
|
||||
PredictionSource::Llm => 'success',
|
||||
PredictionSource::Ewma => 'info',
|
||||
}),
|
||||
TextColumn::make('direction')
|
||||
->badge()
|
||||
->color(fn (TrendDirection $state) => match ($state) {
|
||||
TrendDirection::Rising => 'danger',
|
||||
TrendDirection::Falling => 'success',
|
||||
TrendDirection::Flat => 'gray',
|
||||
}),
|
||||
TextColumn::make('confidence')
|
||||
->suffix('%')
|
||||
->sortable(),
|
||||
TextColumn::make('reasoning')
|
||||
->limit(60)
|
||||
->placeholder('—'),
|
||||
TextColumn::make('generated_at')
|
||||
->dateTime('d M Y H:i')
|
||||
->sortable(),
|
||||
])
|
||||
->defaultSort('predicted_for', 'desc')
|
||||
->filters([
|
||||
SelectFilter::make('source')
|
||||
->options([
|
||||
PredictionSource::Llm->value => 'LLM',
|
||||
PredictionSource::Ewma->value => 'EWMA',
|
||||
]),
|
||||
SelectFilter::make('direction')
|
||||
->options([
|
||||
TrendDirection::Rising->value => 'Rising',
|
||||
TrendDirection::Falling->value => 'Falling',
|
||||
TrendDirection::Flat->value => 'Flat',
|
||||
]),
|
||||
Filter::make('predicted_for')
|
||||
->schema([
|
||||
DatePicker::make('from')->label('From'),
|
||||
DatePicker::make('until')->label('Until'),
|
||||
])
|
||||
->query(function (Builder $query, array $data) {
|
||||
$query
|
||||
->when($data['from'], fn ($q, $d) => $q->whereDate('predicted_for', '>=', $d))
|
||||
->when($data['until'], fn ($q, $d) => $q->whereDate('predicted_for', '<=', $d));
|
||||
}),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Section::make('Prediction')->schema([
|
||||
TextEntry::make('predicted_for')->date('d M Y'),
|
||||
TextEntry::make('source')
|
||||
->badge()
|
||||
->formatStateUsing(fn (PredictionSource $state) => strtoupper($state->value))
|
||||
->color(fn (PredictionSource $state) => match ($state) {
|
||||
PredictionSource::Llm => 'success',
|
||||
PredictionSource::Ewma => 'info',
|
||||
}),
|
||||
TextEntry::make('direction')
|
||||
->badge()
|
||||
->color(fn (TrendDirection $state) => match ($state) {
|
||||
TrendDirection::Rising => 'danger',
|
||||
TrendDirection::Falling => 'success',
|
||||
TrendDirection::Flat => 'gray',
|
||||
}),
|
||||
TextEntry::make('confidence')->suffix('%'),
|
||||
TextEntry::make('generated_at')->dateTime('d M Y H:i:s'),
|
||||
])->columns(3),
|
||||
Section::make('Reasoning')->schema([
|
||||
TextEntry::make('reasoning')
|
||||
->columnSpanFull()
|
||||
->placeholder('No reasoning recorded'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListOilPredictions::route('/'),
|
||||
'view' => ViewOilPrediction::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\OilPredictionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OilPredictionResource;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class ListOilPredictions extends ListRecords
|
||||
{
|
||||
protected static string $resource = OilPredictionResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('runPrediction')
|
||||
->label('Run Prediction Now')
|
||||
->icon('heroicon-o-cpu-chip')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Run oil price prediction?')
|
||||
->modalDescription('This will fetch the latest FRED prices and generate a new prediction. May take a few seconds.')
|
||||
->action(function () {
|
||||
$result = Artisan::call('oil:predict', ['--fetch' => true]);
|
||||
|
||||
if ($result === 0) {
|
||||
Notification::make()
|
||||
->title('Prediction generated successfully')
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('Prediction failed')
|
||||
->body('Check API Logs for details.')
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\OilPredictionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OilPredictionResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewOilPrediction extends ViewRecord
|
||||
{
|
||||
protected static string $resource = OilPredictionResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
105
app/Filament/Resources/StationResource.php
Normal file
105
app/Filament/Resources/StationResource.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\StationResource\Pages\ListStations;
|
||||
use App\Filament\Resources\StationResource\Pages\ViewStation;
|
||||
use App\Models\Station;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Infolists\Components\IconEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class StationResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Station::class;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data;
|
||||
|
||||
protected static ?string $navigationLabel = 'Stations';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('trading_name')->searchable()->sortable()
|
||||
->limit(25)
|
||||
->tooltip(fn (Station $record) => strlen($record->trading_name) > 45 ? $record->trading_name : null),
|
||||
TextColumn::make('brand_name')->searchable()->placeholder('—'),
|
||||
TextColumn::make('city')
|
||||
->description(fn (Station $record) => $record->postcode)
|
||||
->searchable(['city', 'postcode']),
|
||||
IconColumn::make('is_supermarket')->label('Supermarket')->boolean(),
|
||||
IconColumn::make('is_motorway_service_station')->label('Motorway')->boolean(),
|
||||
IconColumn::make('temporary_closure')
|
||||
->label('Temp closed')
|
||||
->boolean()
|
||||
->trueColor('warning')
|
||||
->falseColor('success'),
|
||||
TextColumn::make('last_seen_at')->dateTime('d M Y H:i')->sortable(),
|
||||
])
|
||||
->searchPlaceholder('Search name, brand, or postcode...')
|
||||
->defaultSort('last_seen_at', 'desc')
|
||||
->filters([
|
||||
TernaryFilter::make('is_supermarket')->label('Supermarket'),
|
||||
TernaryFilter::make('is_motorway_service_station')->label('Motorway'),
|
||||
TernaryFilter::make('temporary_closure')->label('Temporarily closed'),
|
||||
TernaryFilter::make('permanent_closure')->label('Permanently closed'),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Section::make('Location')->schema([
|
||||
TextEntry::make('trading_name'),
|
||||
TextEntry::make('brand_name')->placeholder('—'),
|
||||
TextEntry::make('address_line_1'),
|
||||
TextEntry::make('address_line_2')->placeholder('—'),
|
||||
TextEntry::make('city'),
|
||||
TextEntry::make('county')->placeholder('—'),
|
||||
TextEntry::make('postcode'),
|
||||
TextEntry::make('country'),
|
||||
])->columns(3),
|
||||
Section::make('Status')->schema([
|
||||
IconEntry::make('is_supermarket')->boolean(),
|
||||
IconEntry::make('is_motorway_service_station')->boolean(),
|
||||
IconEntry::make('temporary_closure')->boolean()->trueColor('warning'),
|
||||
IconEntry::make('permanent_closure')->boolean()->trueColor('danger'),
|
||||
TextEntry::make('permanent_closure_date')->date()->placeholder('—'),
|
||||
TextEntry::make('last_seen_at')->dateTime('d M Y H:i'),
|
||||
])->columns(3),
|
||||
Section::make('Fuel Types')->schema([
|
||||
TextEntry::make('fuel_types')
|
||||
->listWithLineBreaks()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Section::make('Amenities')->schema([
|
||||
TextEntry::make('amenities')
|
||||
->listWithLineBreaks()
|
||||
->placeholder('None recorded')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListStations::route('/'),
|
||||
'view' => ViewStation::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\StationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\StationResource;
|
||||
use App\Jobs\PollFuelPricesJob;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListStations extends ListRecords
|
||||
{
|
||||
protected static string $resource = StationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('triggerFullPoll')
|
||||
->label('Trigger Full Poll')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Trigger full station refresh?')
|
||||
->modalDescription('This dispatches a background job to refresh all ~14,500 stations from the Fuel Finder API. Results will appear in API Logs once complete.')
|
||||
->action(function () {
|
||||
PollFuelPricesJob::dispatch();
|
||||
|
||||
Notification::make()
|
||||
->title('Poll dispatched to queue')
|
||||
->body('Check API Logs once the job completes.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
16
app/Filament/Resources/StationResource/Pages/ViewStation.php
Normal file
16
app/Filament/Resources/StationResource/Pages/ViewStation.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\StationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\StationResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewStation extends ViewRecord
|
||||
{
|
||||
protected static string $resource = StationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
72
app/Filament/Resources/UserResource.php
Normal file
72
app/Filament/Resources/UserResource.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\UserResource\Pages\EditUser;
|
||||
use App\Filament\Resources\UserResource\Pages\ListUsers;
|
||||
use App\Models\User;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class UserResource extends Resource
|
||||
{
|
||||
protected static ?string $model = User::class;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Users;
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Toggle::make('is_admin')
|
||||
->label('Admin')
|
||||
->helperText('Grants access to this admin panel.'),
|
||||
TextInput::make('postcode')
|
||||
->label('Postcode')
|
||||
->maxLength(8),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')->searchable()->sortable(),
|
||||
TextColumn::make('email')->searchable()->sortable(),
|
||||
TextColumn::make('postcode')->placeholder('—'),
|
||||
IconColumn::make('is_admin')
|
||||
->label('Admin')
|
||||
->boolean(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime('d M Y')
|
||||
->sortable(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
TernaryFilter::make('is_admin')
|
||||
->label('Admins only'),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListUsers::route('/'),
|
||||
'edit' => EditUser::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
16
app/Filament/Resources/UserResource/Pages/EditUser.php
Normal file
16
app/Filament/Resources/UserResource/Pages/EditUser.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditUser extends EditRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
16
app/Filament/Resources/UserResource/Pages/ListUsers.php
Normal file
16
app/Filament/Resources/UserResource/Pages/ListUsers.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListUsers extends ListRecords
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
40
app/Filament/Widgets/BrentPriceChartWidget.php
Normal file
40
app/Filament/Widgets/BrentPriceChartWidget.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\BrentPrice;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
|
||||
class BrentPriceChartWidget extends ChartWidget
|
||||
{
|
||||
protected ?string $heading = 'Brent Crude — Last 30 Days (USD/barrel)';
|
||||
|
||||
protected ?string $pollingInterval = null;
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
$prices = BrentPrice::orderBy('date')
|
||||
->where('date', '>=', now()->subDays(30)->toDateString())
|
||||
->get();
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => 'USD/barrel',
|
||||
'data' => $prices->pluck('price_usd')->map(fn ($p) => (float) $p)->toArray(),
|
||||
'borderColor' => '#f59e0b',
|
||||
'fill' => false,
|
||||
'tension' => 0.3,
|
||||
],
|
||||
],
|
||||
'labels' => $prices->pluck('date')
|
||||
->map(fn ($d) => $d->format('d M'))
|
||||
->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getType(): string
|
||||
{
|
||||
return 'line';
|
||||
}
|
||||
}
|
||||
84
app/Filament/Widgets/StatsOverviewWidget.php
Normal file
84
app/Filament/Widgets/StatsOverviewWidget.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\ApiLog;
|
||||
use App\Models\PricePrediction;
|
||||
use App\Models\Station;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class StatsOverviewWidget extends BaseWidget
|
||||
{
|
||||
protected ?string $pollingInterval = '30s';
|
||||
|
||||
protected function getStats(): array
|
||||
{
|
||||
return [
|
||||
$this->usersStat(),
|
||||
$this->stationsStat(),
|
||||
$this->oilPredictionStat(),
|
||||
$this->apiErrorsStat(),
|
||||
];
|
||||
}
|
||||
|
||||
private function usersStat(): Stat
|
||||
{
|
||||
return Stat::make('Total users', User::count())
|
||||
->icon('heroicon-o-users')
|
||||
->color('primary');
|
||||
}
|
||||
|
||||
private function stationsStat(): Stat
|
||||
{
|
||||
$count = Station::count();
|
||||
$lastSeen = Station::max('last_seen_at');
|
||||
$description = $lastSeen
|
||||
? 'Last seen '.Carbon::parse($lastSeen)->diffForHumans()
|
||||
: 'No stations yet';
|
||||
|
||||
return Stat::make('Stations in DB', number_format($count))
|
||||
->description($description)
|
||||
->icon('heroicon-o-map-pin')
|
||||
->color('success');
|
||||
}
|
||||
|
||||
private function oilPredictionStat(): Stat
|
||||
{
|
||||
$prediction = PricePrediction::latest('generated_at')->first();
|
||||
|
||||
if ($prediction === null) {
|
||||
return Stat::make('Latest oil prediction', 'None')
|
||||
->icon('heroicon-o-beaker')
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
$ageHours = $prediction->generated_at->diffInHours(now());
|
||||
$color = $ageHours > 24 ? 'warning' : 'success';
|
||||
$value = ucfirst($prediction->direction->value)
|
||||
.' · '.$prediction->confidence.'%'
|
||||
.' · '.strtoupper($prediction->source->value);
|
||||
|
||||
return Stat::make('Latest oil prediction', $value)
|
||||
->description('Generated '.$prediction->generated_at->diffForHumans())
|
||||
->icon('heroicon-o-beaker')
|
||||
->color($color);
|
||||
}
|
||||
|
||||
private function apiErrorsStat(): Stat
|
||||
{
|
||||
$errors = ApiLog::where('created_at', '>=', now()->subDay())
|
||||
->where(fn ($q) => $q->where('status_code', '>=', 400)
|
||||
->orWhereNull('status_code')
|
||||
->orWhereNotNull('error'))
|
||||
->count();
|
||||
|
||||
$color = $errors > 0 ? 'danger' : 'success';
|
||||
|
||||
return Stat::make('API errors (24h)', $errors)
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->color($color);
|
||||
}
|
||||
}
|
||||
17
app/Jobs/PollFuelPricesJob.php
Normal file
17
app/Jobs/PollFuelPricesJob.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class PollFuelPricesJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
Artisan::call('fuel:poll', ['--full' => true]);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ApiLogFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['service', 'method', 'url', 'status_code', 'duration_ms', 'error'])]
|
||||
class ApiLog extends Model
|
||||
{
|
||||
/** @use HasFactory<ApiLogFactory> */
|
||||
use HasFactory;
|
||||
|
||||
const null UPDATED_AT = null;
|
||||
|
||||
protected function casts(): array
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\BrentPriceFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
@@ -13,6 +15,9 @@ use Illuminate\Support\Carbon;
|
||||
#[Fillable(['date', 'price_usd'])]
|
||||
class BrentPrice extends Model
|
||||
{
|
||||
/** @use HasFactory<BrentPriceFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $primaryKey = 'date';
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace App\Models;
|
||||
|
||||
use App\Enums\PredictionSource;
|
||||
use App\Enums\TrendDirection;
|
||||
use Database\Factories\PricePredictionFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
@@ -20,6 +22,9 @@ use Illuminate\Support\Carbon;
|
||||
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
|
||||
class PricePrediction extends Model
|
||||
{
|
||||
/** @use HasFactory<PricePredictionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
|
||||
@@ -14,7 +14,7 @@ use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Fillable(['name', 'email', 'password', 'is_admin', 'postcode'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
class User extends Authenticatable implements FilamentUser
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use App\Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@@ -11,7 +12,6 @@ use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Widgets\AccountWidget;
|
||||
use Filament\Widgets\FilamentInfoWidget;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||
@@ -24,20 +24,23 @@ class AdminPanelProvider extends PanelProvider
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->login()
|
||||
->authGuard('web')
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->discoverResources(in: app_path('Filament/Admin/Resources'), for: 'App\Filament\Admin\Resources')
|
||||
->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\Filament\Admin\Pages')
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
Dashboard::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Admin/Widgets'), for: 'App\Filament\Admin\Widgets')
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
||||
->widgets([
|
||||
AccountWidget::class,
|
||||
FilamentInfoWidget::class,
|
||||
StatsOverviewWidget::class,
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
|
||||
@@ -104,7 +104,8 @@ class OilPriceService
|
||||
$prediction = null;
|
||||
|
||||
if (config('services.anthropic.api_key')) {
|
||||
$prediction = $this->generateLlmPrediction($prices);
|
||||
$prediction = $this->generateLlmPredictionWithContext($prices);
|
||||
$prediction ??= $this->generateLlmPrediction($prices);
|
||||
}
|
||||
|
||||
$prediction ??= $this->generateEwmaPrediction($prices);
|
||||
@@ -204,6 +205,104 @@ class OilPriceService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 3–5 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]];
|
||||
$response = null;
|
||||
|
||||
try {
|
||||
for ($i = 0; $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_20260209', 'name' => 'web_search']],
|
||||
'messages' => $messages,
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('OilPriceService: Anthropic context request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($response->json('stop_reason') !== 'pause_turn') {
|
||||
break;
|
||||
}
|
||||
|
||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||
}
|
||||
|
||||
$text = collect($response->json('content') ?? [])
|
||||
->firstWhere('type', 'text')['text'] ?? '';
|
||||
|
||||
$text = preg_replace('/^```(?:json)?\s*/m', '', trim($text));
|
||||
$text = preg_replace('/```\s*$/m', '', $text);
|
||||
$data = json_decode(trim($text), true);
|
||||
|
||||
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::Llm,
|
||||
'direction' => $direction,
|
||||
'confidence' => $confidence,
|
||||
'reasoning' => $data['reasoning'],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('OilPriceService: generateLlmPredictionWithContext failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Option A — EWMA-based trend extrapolation. Used as fallback when LLM is unavailable.
|
||||
* Compares the 3-day EWMA against the 7-day EWMA to detect direction.
|
||||
|
||||
@@ -8,23 +8,25 @@ class StationTaggingService
|
||||
{
|
||||
/** @var array<string, string> brand keyword → normalised brand name */
|
||||
private const SUPERMARKET_BRANDS = [
|
||||
'tesco' => 'Tesco',
|
||||
'asda' => 'Asda',
|
||||
'tesco' => 'Tesco',
|
||||
'asda' => 'Asda',
|
||||
'morrisons' => 'Morrisons',
|
||||
'sainsbury' => 'Sainsbury\'s',
|
||||
'aldi' => 'Aldi',
|
||||
'lidl' => 'Lidl',
|
||||
'costco' => 'Costco',
|
||||
'aldi' => 'Aldi',
|
||||
'lidl' => 'Lidl',
|
||||
'costco' => 'Costco',
|
||||
];
|
||||
|
||||
public function tag(Station $station): void
|
||||
{
|
||||
$name = strtolower($station->trading_name);
|
||||
$tradingName = strtolower($station->trading_name);
|
||||
$brandName = strtolower($station->brand_name ?? '');
|
||||
|
||||
foreach (self::SUPERMARKET_BRANDS as $keyword => $normalisedBrand) {
|
||||
if (str_contains($name, $keyword)) {
|
||||
if (str_contains($tradingName, $keyword) || str_contains($brandName, $keyword)) {
|
||||
$station->is_supermarket = true;
|
||||
$station->brand_name = $normalisedBrand;
|
||||
$station->brand_name = $normalisedBrand;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ return [
|
||||
|
||||
'anthropic' => [
|
||||
'api_key' => env('ANTHROPIC_API_KEY'),
|
||||
'model' => env('ANTHROPIC_MODEL', 'claude-haiku-4-5-20251001'),
|
||||
'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-6'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
31
database/factories/ApiLogFactory.php
Normal file
31
database/factories/ApiLogFactory.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ApiLog;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<ApiLog> */
|
||||
class ApiLogFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'service' => fake()->randomElement(['fuel_finder', 'fred', 'anthropic', 'postcodes_io']),
|
||||
'method' => fake()->randomElement(['GET', 'POST']),
|
||||
'url' => fake()->url(),
|
||||
'status_code' => fake()->randomElement([200, 200, 200, 401, 429, 500]),
|
||||
'duration_ms' => fake()->numberBetween(50, 2000),
|
||||
'error' => null,
|
||||
'created_at' => fake()->dateTimeBetween('-7 days'),
|
||||
];
|
||||
}
|
||||
|
||||
public function failed(): static
|
||||
{
|
||||
return $this->state([
|
||||
'status_code' => fake()->randomElement([400, 401, 403, 429, 500, 503]),
|
||||
'error' => fake()->sentence(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
27
database/factories/BrentPriceFactory.php
Normal file
27
database/factories/BrentPriceFactory.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\BrentPrice;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<BrentPrice> */
|
||||
class BrentPriceFactory extends Factory
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
private static array $usedDates = [];
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
do {
|
||||
$date = fake()->dateTimeBetween('-60 days')->format('Y-m-d');
|
||||
} while (in_array($date, self::$usedDates, true));
|
||||
|
||||
self::$usedDates[] = $date;
|
||||
|
||||
return [
|
||||
'date' => $date,
|
||||
'price_usd' => fake()->randomFloat(2, 65, 95),
|
||||
];
|
||||
}
|
||||
}
|
||||
34
database/factories/PricePredictionFactory.php
Normal file
34
database/factories/PricePredictionFactory.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\PredictionSource;
|
||||
use App\Enums\TrendDirection;
|
||||
use App\Models\PricePrediction;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<PricePrediction> */
|
||||
class PricePredictionFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'predicted_for' => fake()->dateTimeBetween('-30 days')->format('Y-m-d'),
|
||||
'source' => fake()->randomElement(PredictionSource::cases()),
|
||||
'direction' => fake()->randomElement(TrendDirection::cases()),
|
||||
'confidence' => fake()->numberBetween(40, 85),
|
||||
'reasoning' => fake()->sentence(12),
|
||||
'generated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
public function llm(): static
|
||||
{
|
||||
return $this->state(['source' => PredictionSource::Llm]);
|
||||
}
|
||||
|
||||
public function ewma(): static
|
||||
{
|
||||
return $this->state(['source' => PredictionSource::Ewma]);
|
||||
}
|
||||
}
|
||||
@@ -57,4 +57,12 @@ class UserFactory extends Factory
|
||||
'two_factor_confirmed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the user is an admin.
|
||||
*/
|
||||
public function admin(): static
|
||||
{
|
||||
return $this->state(['is_admin' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('postcode', 8)->nullable()->after('is_admin');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('postcode');
|
||||
});
|
||||
}
|
||||
};
|
||||
22
database/seeders/AdminSeeder.php
Normal file
22
database/seeders/AdminSeeder.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class AdminSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
User::updateOrCreate(
|
||||
['email' => 'uovidiu@sent.com'],
|
||||
[
|
||||
'name' => 'Ovidiu U',
|
||||
'password' => Hash::make('changeme'),
|
||||
'is_admin' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class DatabaseSeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
$this->call(AdminSeeder::class);
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
|
||||
2044
docs/superpowers/plans/2026-04-04-filament-admin-panel.md
Normal file
2044
docs/superpowers/plans/2026-04-04-filament-admin-panel.md
Normal file
File diff suppressed because it is too large
Load Diff
20
tests/Feature/Admin/AdminAccessTest.php
Normal file
20
tests/Feature/Admin/AdminAccessTest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('denies non-admin users access to admin panel', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get('/admin')->assertForbidden();
|
||||
});
|
||||
|
||||
it('allows admin users to access admin panel', function () {
|
||||
$user = User::factory()->admin()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get('/admin')->assertOk();
|
||||
});
|
||||
51
tests/Feature/Admin/ApiLogResourceTest.php
Normal file
51
tests/Feature/Admin/ApiLogResourceTest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\ApiLogResource\Pages\ListApiLogs;
|
||||
use App\Filament\Resources\ApiLogResource\Pages\ViewApiLog;
|
||||
use App\Models\ApiLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->admin = User::factory()->admin()->create();
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
it('renders the api log list page', function () {
|
||||
$logs = ApiLog::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListApiLogs::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($logs);
|
||||
});
|
||||
|
||||
it('filters to errors only', function () {
|
||||
$ok = ApiLog::factory()->create(['status_code' => 200, 'error' => null]);
|
||||
$err = ApiLog::factory()->failed()->create();
|
||||
|
||||
Livewire::test(ListApiLogs::class)
|
||||
->filterTable('errors_only')
|
||||
->assertCanSeeTableRecords([$err])
|
||||
->assertCanNotSeeTableRecords([$ok]);
|
||||
});
|
||||
|
||||
it('renders the view page for a log', function () {
|
||||
$log = ApiLog::factory()->create();
|
||||
|
||||
Livewire::test(ViewApiLog::class, ['record' => $log->id])
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('filters to errors only including null status codes', function () {
|
||||
$ok = ApiLog::factory()->create(['status_code' => 200, 'error' => null]);
|
||||
$err4xx = ApiLog::factory()->failed()->create(['status_code' => 429, 'error' => 'Too many requests']);
|
||||
$errNull = ApiLog::factory()->create(['status_code' => null, 'error' => 'Connection timeout']);
|
||||
|
||||
Livewire::test(ListApiLogs::class)
|
||||
->filterTable('errors_only')
|
||||
->assertCanSeeTableRecords([$err4xx, $errNull])
|
||||
->assertCanNotSeeTableRecords([$ok]);
|
||||
});
|
||||
22
tests/Feature/Admin/BrentPriceResourceTest.php
Normal file
22
tests/Feature/Admin/BrentPriceResourceTest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\BrentPriceResource\Pages\ListBrentPrices;
|
||||
use App\Models\BrentPrice;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->admin = User::factory()->admin()->create();
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
it('renders the brent price list', function () {
|
||||
$prices = BrentPrice::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListBrentPrices::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($prices);
|
||||
});
|
||||
27
tests/Feature/Admin/OilPredictionResourceTest.php
Normal file
27
tests/Feature/Admin/OilPredictionResourceTest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
|
||||
use App\Models\PricePrediction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->admin = User::factory()->admin()->create();
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
it('renders the oil prediction list', function () {
|
||||
$predictions = PricePrediction::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListOilPredictions::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($predictions);
|
||||
});
|
||||
|
||||
it('has a run prediction header action', function () {
|
||||
Livewire::test(ListOilPredictions::class)
|
||||
->assertActionExists('runPrediction');
|
||||
});
|
||||
37
tests/Feature/Admin/StationResourceTest.php
Normal file
37
tests/Feature/Admin/StationResourceTest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\StationResource\Pages\ListStations;
|
||||
use App\Models\Station;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->admin = User::factory()->admin()->create();
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
it('renders the station list', function () {
|
||||
$stations = Station::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListStations::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($stations);
|
||||
});
|
||||
|
||||
it('filters to supermarkets only', function () {
|
||||
$regular = Station::factory()->create(['is_supermarket' => false]);
|
||||
$super = Station::factory()->supermarket()->create();
|
||||
|
||||
Livewire::test(ListStations::class)
|
||||
->filterTable('is_supermarket', true)
|
||||
->assertCanSeeTableRecords([$super])
|
||||
->assertCanNotSeeTableRecords([$regular]);
|
||||
});
|
||||
|
||||
it('has a trigger full poll header action', function () {
|
||||
Livewire::test(ListStations::class)
|
||||
->assertActionExists('triggerFullPoll');
|
||||
});
|
||||
26
tests/Feature/Admin/StatsOverviewWidgetTest.php
Normal file
26
tests/Feature/Admin/StatsOverviewWidgetTest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Widgets\StatsOverviewWidget;
|
||||
use App\Models\ApiLog;
|
||||
use App\Models\PricePrediction;
|
||||
use App\Models\Station;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->admin = User::factory()->admin()->create();
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
it('renders the stats overview widget', function () {
|
||||
User::factory()->count(3)->create();
|
||||
Station::factory()->count(2)->create();
|
||||
PricePrediction::factory()->create(['generated_at' => now()->subHours(2)]);
|
||||
ApiLog::factory()->count(2)->create(['status_code' => 200, 'error' => null, 'created_at' => now()->subMinutes(30)]);
|
||||
|
||||
Livewire::test(StatsOverviewWidget::class)
|
||||
->assertOk();
|
||||
});
|
||||
44
tests/Feature/Admin/UserResourceTest.php
Normal file
44
tests/Feature/Admin/UserResourceTest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\UserResource\Pages\EditUser;
|
||||
use App\Filament\Resources\UserResource\Pages\ListUsers;
|
||||
use App\Models\User;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->admin = User::factory()->admin()->create();
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
it('renders the user list', function () {
|
||||
$users = User::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListUsers::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($users);
|
||||
});
|
||||
|
||||
it('can toggle is_admin on edit', function () {
|
||||
$user = User::factory()->create(['is_admin' => false]);
|
||||
|
||||
Livewire::test(EditUser::class, ['record' => $user->id])
|
||||
->fillForm(['is_admin' => true])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
expect($user->fresh()->is_admin)->toBeTrue();
|
||||
});
|
||||
|
||||
it('can delete a user', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Livewire::test(ListUsers::class)
|
||||
->callTableAction(DeleteAction::class, $user)
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
$this->assertDatabaseMissing('users', ['id' => $user->id]);
|
||||
});
|
||||
12
tests/Unit/Jobs/PollFuelPricesJobTest.php
Normal file
12
tests/Unit/Jobs/PollFuelPricesJobTest.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\PollFuelPricesJob;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
it('dispatches to the default queue', function () {
|
||||
Queue::fake();
|
||||
|
||||
PollFuelPricesJob::dispatch();
|
||||
|
||||
Queue::assertPushed(PollFuelPricesJob::class);
|
||||
});
|
||||
@@ -227,3 +227,131 @@ it('returns null when there is insufficient price data', function (): void {
|
||||
expect($this->service->generatePrediction())->toBeNull()
|
||||
->and(PricePrediction::count())->toBe(0);
|
||||
});
|
||||
|
||||
// --- generateLlmPredictionWithContext ---
|
||||
|
||||
it('generates llm prediction with context using web search and raw prices', function () {
|
||||
config(['services.anthropic.api_key' => 'test-key']);
|
||||
|
||||
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
||||
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
||||
'price_usd' => 80.0 + $s->index * 0.5,
|
||||
])->create();
|
||||
|
||||
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',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
||||
$prediction = app(OilPriceService::class)->generateLlmPredictionWithContext($prices);
|
||||
|
||||
expect($prediction)->not->toBeNull()
|
||||
->and($prediction->direction)->toBe(TrendDirection::Rising)
|
||||
->and($prediction->confidence)->toBe(72)
|
||||
->and($prediction->source)->toBe(PredictionSource::Llm)
|
||||
->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 () {
|
||||
config(['services.anthropic.api_key' => 'test-key']);
|
||||
|
||||
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
||||
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
||||
'price_usd' => 80.0,
|
||||
])->create();
|
||||
|
||||
Http::fake([
|
||||
'https://api.anthropic.com/*' => Http::response([
|
||||
'content' => [['type' => 'text', 'text' => '{"direction":"flat","confidence":50,"reasoning":"No clear trend."}']],
|
||||
'stop_reason' => 'end_turn',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
||||
app(OilPriceService::class)->generateLlmPredictionWithContext($prices);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$tools = $request->data()['tools'] ?? [];
|
||||
|
||||
return collect($tools)->contains(fn ($t) => $t['type'] === 'web_search_20260209');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not include ewma indicators in the context prediction request', function () {
|
||||
config(['services.anthropic.api_key' => 'test-key']);
|
||||
|
||||
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
||||
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
||||
'price_usd' => 80.0,
|
||||
])->create();
|
||||
|
||||
Http::fake([
|
||||
'https://api.anthropic.com/*' => Http::response([
|
||||
'content' => [['type' => 'text', 'text' => '{"direction":"flat","confidence":50,"reasoning":"No clear trend."}']],
|
||||
'stop_reason' => 'end_turn',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
||||
app(OilPriceService::class)->generateLlmPredictionWithContext($prices);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$content = $request->data()['messages'][0]['content'] ?? '';
|
||||
|
||||
return ! str_contains($content, 'EWMA') && ! str_contains($content, 'Pre-computed');
|
||||
});
|
||||
});
|
||||
|
||||
it('context prediction continues on pause_turn and returns final answer', function () {
|
||||
config(['services.anthropic.api_key' => 'test-key']);
|
||||
|
||||
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
||||
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
||||
'price_usd' => 80.0,
|
||||
])->create();
|
||||
|
||||
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',
|
||||
], 200)
|
||||
->push([
|
||||
'content' => [['type' => 'text', 'text' => '{"direction":"falling","confidence":60,"reasoning":"Demand fears weigh on prices."}']],
|
||||
'stop_reason' => 'end_turn',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
||||
$prediction = app(OilPriceService::class)->generateLlmPredictionWithContext($prices);
|
||||
|
||||
expect($prediction)->not->toBeNull()
|
||||
->and($prediction->direction)->toBe(TrendDirection::Falling);
|
||||
|
||||
Http::assertSentCount(2);
|
||||
});
|
||||
|
||||
it('generatePrediction falls through to ewma when both llm methods fail', function () {
|
||||
config(['services.anthropic.api_key' => 'test-key']);
|
||||
|
||||
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
||||
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
||||
'price_usd' => 80.0,
|
||||
])->create();
|
||||
|
||||
Http::fake([
|
||||
'https://api.anthropic.com/*' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
$prediction = app(OilPriceService::class)->generatePrediction();
|
||||
|
||||
expect($prediction)->not->toBeNull()
|
||||
->and($prediction->source)->toBe(PredictionSource::Ewma);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ use App\Models\Station;
|
||||
use App\Services\StationTaggingService;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->service = new StationTaggingService();
|
||||
$this->service = new StationTaggingService;
|
||||
});
|
||||
|
||||
it('marks tesco station as supermarket and normalises brand', function (): void {
|
||||
@@ -54,10 +54,23 @@ it('handles case insensitive matching', function (): void {
|
||||
->and($station->brand_name)->toBe('Morrisons');
|
||||
});
|
||||
|
||||
it('marks station as supermarket when brand_name matches even if trading_name does not', function (): void {
|
||||
$station = new Station([
|
||||
'trading_name' => 'PETERBOROUGH EXTRA - PETROL FILLING STATION',
|
||||
'brand_name' => 'TESCO',
|
||||
'is_supermarket' => false,
|
||||
]);
|
||||
|
||||
$this->service->tag($station);
|
||||
|
||||
expect($station->is_supermarket)->toBeTrue()
|
||||
->and($station->brand_name)->toBe('Tesco');
|
||||
});
|
||||
|
||||
it('does not overwrite brand_name for non-supermarket stations', function (): void {
|
||||
$station = new Station([
|
||||
'trading_name' => 'Shell Garage',
|
||||
'brand_name' => 'Shell',
|
||||
'trading_name' => 'Shell Garage',
|
||||
'brand_name' => 'Shell',
|
||||
'is_supermarket' => false,
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user