feat: add postcode resolution to /api/stations and Filament SearchResource
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:
16
app/Filament/Resources/Searches/Pages/ListSearches.php
Normal file
16
app/Filament/Resources/Searches/Pages/ListSearches.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
81
app/Filament/Resources/Searches/SearchResource.php
Normal file
81
app/Filament/Resources/Searches/SearchResource.php
Normal 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('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
60
app/Http/Controllers/Api/AuthController.php
Normal file
60
app/Http/Controllers/Api/AuthController.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 0–EWMA_MAX_CONFIDENCE confidence score.
|
||||
* 1.5% → ~30, 3% → ~50, 5%+ → 65.
|
||||
|
||||
Reference in New Issue
Block a user