feat: add postcode resolution to /api/stations and Filament SearchResource
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

Extends NearbyStationsRequest to accept `postcode` (full or outcode) as an alternative to lat/lng. PostcodeService resolves it via postcodes.io and falls through to coordinates. Also adds SearchResource to the Filament admin panel for viewing logged search activity with fuel type filter and price/distance stats columns. Includes SQLite GREATEST/LEAST function polyfills in AppServiceProvider for test compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ovidiu U
2026-04-05 19:10:25 +01:00
parent 3ccdc28763
commit 7101ed3550
15 changed files with 392 additions and 45 deletions

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Searches\Pages;
use App\Filament\Resources\Searches\SearchResource;
use Filament\Resources\Pages\ListRecords;
class ListSearches extends ListRecords
{
protected static string $resource = SearchResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Filament\Resources\Searches;
use App\Filament\NavigationGroup;
use App\Filament\Resources\Searches\Pages\ListSearches;
use App\Models\Search;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class SearchResource extends Resource
{
protected static ?string $model = Search::class;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data;
protected static ?string $navigationLabel = 'Searches';
protected static ?int $navigationSort = 4;
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('searched_at')
->label('Searched At')
->dateTime('d M Y H:i')
->sortable(),
TextColumn::make('fuel_type')
->label('Fuel Type')
->badge(),
TextColumn::make('results_count')
->label('Results')
->numeric()
->sortable(),
TextColumn::make('lowest_pence')
->label('Lowest')
->formatStateUsing(fn (int $state): string => number_format($state / 100, 1).'p')
->sortable(),
TextColumn::make('highest_pence')
->label('Highest')
->formatStateUsing(fn (int $state): string => number_format($state / 100, 1).'p')
->sortable(),
TextColumn::make('avg_pence')
->label('Average')
->formatStateUsing(fn (string $state): string => number_format((float) $state / 100, 1).'p')
->sortable(),
TextColumn::make('lat_bucket')
->label('Area (lat/lng)')
->formatStateUsing(fn (Search $record): string => $record->lat_bucket.', '.$record->lng_bucket)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('ip_hash')
->label('IP Hash')
->limit(16)
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('searched_at', 'desc')
->filters([
SelectFilter::make('fuel_type')
->options([
'E10' => 'E10',
'E5' => 'E5',
'B7_STANDARD' => 'B7 Standard',
'B7_PREMIUM' => 'B7 Premium',
'B10' => 'B10',
'HVO' => 'HVO',
]),
])
->recordActions([])
->toolbarActions([]);
}
public static function getPages(): array
{
return [
'index' => ListSearches::route('/'),
];
}
}

View File

@@ -47,7 +47,7 @@ class StatsOverviewWidget extends BaseWidget
private function oilPredictionStat(): Stat
{
$prediction = PricePrediction::latest('generated_at')->first();
$prediction = PricePrediction::bestFirst()->latest('generated_at')->first();
if ($prediction === null) {
return Stat::make('Latest oil prediction', 'None')

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
use Laravel\Sanctum\PersonalAccessToken;
class AuthController extends Controller
{
public function register(Request $request): JsonResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$user = User::create($data);
$token = $user->createToken('api')->plainTextToken;
return response()->json(['token' => $token, 'user' => $user], 201);
}
public function login(Request $request): JsonResponse
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
if (! Auth::attempt($credentials)) {
return response()->json(['message' => 'Invalid credentials.'], 401);
}
/** @var User $user */
$user = Auth::user();
$token = $user->createToken('api')->plainTextToken;
return response()->json(['token' => $token, 'user' => $user]);
}
public function logout(Request $request): JsonResponse
{
/** @var PersonalAccessToken $token */
$token = $request->user()->currentAccessToken();
$token->delete();
return response()->json(['message' => 'Logged out.']);
}
public function me(Request $request): JsonResponse
{
return response()->json($request->user());
}
}

View File

@@ -7,15 +7,30 @@ use App\Http\Requests\Api\NearbyStationsRequest;
use App\Http\Resources\Api\StationResource;
use App\Models\Search;
use App\Models\Station;
use App\Services\PostcodeService;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
class StationController extends Controller
{
public function __construct(private readonly PostcodeService $postcodeService) {}
public function index(NearbyStationsRequest $request): JsonResponse
{
$lat = (float) $request->input('lat');
$lng = (float) $request->input('lng');
if ($request->filled('postcode')) {
$location = $this->postcodeService->resolve($request->string('postcode')->toString());
if ($location === null) {
throw ValidationException::withMessages(['postcode' => 'Postcode not found.']);
}
$lat = $location->lat;
$lng = $location->lng;
} else {
$lat = (float) $request->input('lat');
$lng = (float) $request->input('lng');
}
$fuelType = $request->fuelType();
$radius = $request->radius();
$sort = $request->sort();
@@ -23,7 +38,7 @@ class StationController extends Controller
$all = Station::query()
->selectRaw(
'stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at,
(6371 * acos(MAX(-1.0, MIN(1.0,
(6371 * acos(GREATEST(-1.0, LEAST(1.0,
cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?))
+ sin(radians(?)) * sin(radians(lat))
)))) AS distance_km',

View File

@@ -15,8 +15,9 @@ class NearbyStationsRequest extends FormRequest
public function rules(): array
{
return [
'lat' => ['required', 'numeric', 'between:-90,90'],
'lng' => ['required', 'numeric', 'between:-180,180'],
'postcode' => ['nullable', 'string', 'max:10'],
'lat' => ['required_without:postcode', 'nullable', 'numeric', 'between:-90,90'],
'lng' => ['required_without:postcode', 'nullable', 'numeric', 'between:-180,180'],
'fuel_type' => ['required', 'string'],
'radius' => ['nullable', 'numeric', 'between:0.1,50'],
'sort' => ['nullable', 'string', 'in:price,distance'],

View File

@@ -6,6 +6,7 @@ use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use Database\Factories\PricePredictionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
@@ -18,6 +19,8 @@ use Illuminate\Support\Carbon;
* @property int $confidence
* @property string|null $reasoning
* @property Carbon $generated_at
*
* @method static Builder<PricePrediction> bestFirst()
*/
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
class PricePrediction extends Model
@@ -37,4 +40,21 @@ class PricePrediction extends Model
'generated_at' => 'datetime',
];
}
/**
* Order by source quality: llm_with_context llm ewma.
* Use this whenever reading the "best" prediction for a given date.
*
* @param Builder<PricePrediction> $query
* @return Builder<PricePrediction>
*/
public function scopeBestFirst(Builder $query): Builder
{
$priority = implode(', ', array_map(
fn (string $v) => "'$v'",
[PredictionSource::LlmWithContext->value, PredictionSource::Llm->value, PredictionSource::Ewma->value],
));
return $query->orderByRaw("FIELD(source, $priority)");
}
}

View File

@@ -13,13 +13,14 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['name', 'email', 'password', 'is_admin', 'postcode'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable implements FilamentUser
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, TwoFactorAuthenticatable;
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
/**
* Get the attributes that should be cast.

View File

@@ -37,6 +37,13 @@ class AppServiceProvider extends ServiceProvider
app()->isProduction(),
);
// SQLite lacks GREATEST/LEAST scalar functions — register them for tests.
if (DB::connection()->getDriverName() === 'sqlite') {
$pdo = DB::connection()->getPdo();
$pdo->sqliteCreateFunction('GREATEST', fn (...$args) => max($args), -1);
$pdo->sqliteCreateFunction('LEAST', fn (...$args) => min($args), -1);
}
Password::defaults(fn (): ?Password => app()->isProduction()
? Password::min(12)
->mixedCase()

View File

@@ -86,8 +86,9 @@ class OilPriceService
}
/**
* Generate a prediction using LLM first, falling back to EWMA.
* Stores the result in price_predictions and returns it.
* Generate predictions from all available sources and store each one.
* EWMA always runs. LLM runs when an API key is configured.
* Returns the highest-confidence prediction (LLM preferred over EWMA).
*/
public function generatePrediction(): ?PricePrediction
{
@@ -101,20 +102,24 @@ class OilPriceService
return null;
}
$prediction = null;
$ewma = $this->generateEwmaPrediction($prices);
if ($ewma !== null) {
PricePrediction::create($ewma->toArray());
}
$llm = null;
if (config('services.anthropic.api_key')) {
$prediction = $this->generateLlmPredictionWithContext($prices);
$prediction ??= $this->generateLlmPrediction($prices);
$llm = $this->generateLlmPredictionWithContext($prices);
$llm ??= $this->generateLlmPrediction($prices);
if ($llm !== null) {
PricePrediction::create($llm->toArray());personal_access_tokens
}
}
$prediction ??= $this->generateEwmaPrediction($prices);
if ($prediction !== null) {
PricePrediction::create($prediction->toArray());
}
return $prediction;
return $llm ?? $ewma;
}
/**
@@ -171,9 +176,7 @@ class OilPriceService
}
$text = $response->json('content.0.text') ?? '';
$text = preg_replace('/^```(?:json)?\s*/m', '', trim($text));
$text = preg_replace('/```\s*$/m', '', $text);
$data = json_decode(trim($text), true);
$data = $this->extractJson($text);
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error('OilPriceService: unexpected LLM response format', ['text' => $text]);
@@ -237,10 +240,9 @@ class OilPriceService
$url = 'https://api.anthropic.com/v1/messages';
$messages = [['role' => 'user', 'content' => $prompt]];
$response = null;
try {
for ($i = 0; $i < 5; $i++) {
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'),
@@ -249,12 +251,15 @@ class OilPriceService
->post($url, [
'model' => config('services.anthropic.model', 'claude-sonnet-4-6'),
'max_tokens' => 1024,
'tools' => [['type' => 'web_search_20260209', 'name' => 'web_search']],
'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']],
'messages' => $messages,
]));
if (! $response->successful()) {
Log::error('OilPriceService: Anthropic context request failed', ['status' => $response->status()]);
Log::error('OilPriceService: Anthropic context request failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
return null;
}
@@ -266,12 +271,13 @@ class OilPriceService
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
}
$text = collect($response->json('content') ?? [])
->firstWhere('type', 'text')['text'] ?? '';
$content = $response->json('content') ?? [];
$text = preg_replace('/^```(?:json)?\s*/m', '', trim($text));
$text = preg_replace('/```\s*$/m', '', $text);
$data = json_decode(trim($text), true);
$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]);
@@ -366,6 +372,23 @@ class OilPriceService
return round($ema, 4);
}
/**
* Strip markdown code fences from a string and extract the first JSON object found.
* Handles prose preambles that Claude sometimes adds before the JSON.
*/
private function extractJson(string $text): ?array
{
$text = preg_replace('/^```(?:json)?\s*/m', '', trim($text));
$text = preg_replace('/```\s*$/m', '', $text);
$start = strpos($text, '{');
$end = strrpos($text, '}');
if ($start === false || $end === false) {
return null;
}
return json_decode(substr($text, $start, $end - $start + 1), true) ?: null;
}
/**
* Map a % change magnitude to a 0EWMA_MAX_CONFIDENCE confidence score.
* 1.5% ~30, 3% ~50, 5%+ 65.