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 private function oilPredictionStat(): Stat
{ {
$prediction = PricePrediction::latest('generated_at')->first(); $prediction = PricePrediction::bestFirst()->latest('generated_at')->first();
if ($prediction === null) { if ($prediction === null) {
return Stat::make('Latest oil prediction', 'None') 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\Http\Resources\Api\StationResource;
use App\Models\Search; use App\Models\Search;
use App\Models\Station; use App\Models\Station;
use App\Services\PostcodeService;
use Illuminate\Database\Query\JoinClause; use Illuminate\Database\Query\JoinClause;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
class StationController extends Controller class StationController extends Controller
{ {
public function __construct(private readonly PostcodeService $postcodeService) {}
public function index(NearbyStationsRequest $request): JsonResponse public function index(NearbyStationsRequest $request): JsonResponse
{ {
$lat = (float) $request->input('lat'); if ($request->filled('postcode')) {
$lng = (float) $request->input('lng'); $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(); $fuelType = $request->fuelType();
$radius = $request->radius(); $radius = $request->radius();
$sort = $request->sort(); $sort = $request->sort();
@@ -23,7 +38,7 @@ class StationController extends Controller
$all = Station::query() $all = Station::query()
->selectRaw( ->selectRaw(
'stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at, '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(?)) cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?))
+ sin(radians(?)) * sin(radians(lat)) + sin(radians(?)) * sin(radians(lat))
)))) AS distance_km', )))) AS distance_km',

View File

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

View File

@@ -6,6 +6,7 @@ use App\Enums\PredictionSource;
use App\Enums\TrendDirection; use App\Enums\TrendDirection;
use Database\Factories\PricePredictionFactory; use Database\Factories\PricePredictionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -18,6 +19,8 @@ use Illuminate\Support\Carbon;
* @property int $confidence * @property int $confidence
* @property string|null $reasoning * @property string|null $reasoning
* @property Carbon $generated_at * @property Carbon $generated_at
*
* @method static Builder<PricePrediction> bestFirst()
*/ */
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])] #[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
class PricePrediction extends Model class PricePrediction extends Model
@@ -37,4 +40,21 @@ class PricePrediction extends Model
'generated_at' => 'datetime', '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\Notifications\Notifiable;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['name', 'email', 'password', 'is_admin', 'postcode'])] #[Fillable(['name', 'email', 'password', 'is_admin', 'postcode'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable implements FilamentUser class User extends Authenticatable implements FilamentUser
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, TwoFactorAuthenticatable; use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
/** /**
* Get the attributes that should be cast. * Get the attributes that should be cast.

View File

@@ -37,6 +37,13 @@ class AppServiceProvider extends ServiceProvider
app()->isProduction(), 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::defaults(fn (): ?Password => app()->isProduction()
? Password::min(12) ? Password::min(12)
->mixedCase() ->mixedCase()

View File

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

View File

@@ -3,6 +3,7 @@
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
@@ -15,5 +16,5 @@ return Application::configure(basePath: dirname(__DIR__))
// //
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// $exceptions->shouldRenderJsonWhen(fn (Request $request) => $request->is('api/*'));
})->create(); })->create();

View File

@@ -1,6 +1,5 @@
<?php <?php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\PredictionController; use App\Http\Controllers\Api\PredictionController;
use App\Http\Controllers\Api\StationController; use App\Http\Controllers\Api\StationController;
use App\Http\Controllers\Api\StatsController; use App\Http\Controllers\Api\StatsController;
@@ -9,12 +8,3 @@ use Illuminate\Support\Facades\Route;
Route::get('/stations', [StationController::class, 'index']); Route::get('/stations', [StationController::class, 'index']);
Route::get('/stats/searches', [StatsController::class, 'searches']); Route::get('/stats/searches', [StatsController::class, 'searches']);
Route::get('/prediction', [PredictionController::class, 'index']); Route::get('/prediction', [PredictionController::class, 'index']);
Route::prefix('auth')->group(function (): void {
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function (): void {
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/me', [AuthController::class, 'me']);
});
});

View File

@@ -0,0 +1,81 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('registers a new user and returns a token', function () {
$this->postJson('/api/auth/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
])
->assertCreated()
->assertJsonStructure(['token', 'user' => ['id', 'name', 'email']]);
});
it('returns 422 when register fields are missing', function () {
$this->postJson('/api/auth/register')
->assertUnprocessable()
->assertJsonValidationErrors(['name', 'email', 'password']);
});
it('returns 422 when email is already taken', function () {
User::factory()->create(['email' => 'taken@example.com']);
$this->postJson('/api/auth/register', [
'name' => 'Another User',
'email' => 'taken@example.com',
'password' => 'password',
'password_confirmation' => 'password',
])
->assertUnprocessable()
->assertJsonValidationErrors(['email']);
});
it('logs in with valid credentials and returns a token', function () {
$user = User::factory()->create(['password' => bcrypt('secret123')]);
$this->postJson('/api/auth/login', [
'email' => $user->email,
'password' => 'secret123',
])
->assertOk()
->assertJsonStructure(['token', 'user']);
});
it('returns 401 for invalid credentials', function () {
User::factory()->create(['email' => 'user@example.com', 'password' => bcrypt('correct')]);
$this->postJson('/api/auth/login', [
'email' => 'user@example.com',
'password' => 'wrong',
])->assertUnauthorized();
});
it('returns the authenticated user on /me', function () {
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('email', $user->email);
});
it('logs out and revokes the token', function () {
$user = User::factory()->create();
$token = $user->createToken('api')->plainTextToken;
$this->withToken($token)
->postJson('/api/auth/logout')
->assertOk()
->assertJsonPath('message', 'Logged out.');
expect($user->tokens()->count())->toBe(0);
});
it('returns 401 on protected routes without a token', function () {
$this->getJson('/api/auth/me')->assertUnauthorized();
});

View File

@@ -4,6 +4,7 @@ use App\Enums\FuelType;
use App\Models\Station; use App\Models\Station;
use App\Models\StationPriceCurrent; use App\Models\StationPriceCurrent;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -102,3 +103,53 @@ it('returns 422 when required params are missing', function () {
$this->getJson('/api/stations?lat=52.5') $this->getJson('/api/stations?lat=52.5')
->assertUnprocessable(); ->assertUnprocessable();
}); });
it('resolves a full postcode to coordinates and returns nearby stations', function () {
$station = Station::factory()->create(['lat' => 51.5010, 'lng' => -0.1415]);
StationPriceCurrent::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14200,
]);
Http::fake([
'api.postcodes.io/postcodes/SW1A1AA' => Http::response([
'status' => 200,
'result' => ['postcode' => 'SW1A 1AA', 'latitude' => 51.5010, 'longitude' => -0.1415],
]),
]);
$this->getJson('/api/stations?postcode=SW1A+1AA&fuel_type=e10&radius=1')
->assertOk()
->assertJsonPath('meta.count', 1);
});
it('resolves an outcode to coordinates', function () {
$station = Station::factory()->create(['lat' => 51.5010, 'lng' => -0.1415]);
StationPriceCurrent::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14200,
]);
Http::fake([
'api.postcodes.io/outcodes/SW1A' => Http::response([
'status' => 200,
'result' => ['outcode' => 'SW1A', 'latitude' => 51.5010, 'longitude' => -0.1415],
]),
]);
$this->getJson('/api/stations?postcode=SW1A&fuel_type=e10&radius=1')
->assertOk()
->assertJsonPath('meta.count', 1);
});
it('returns 422 when postcode cannot be resolved', function () {
Http::fake([
'api.postcodes.io/*' => Http::response(['status' => 404, 'error' => 'Postcode not found'], 404),
]);
$this->getJson('/api/stations?postcode=ZZ99+9ZZ&fuel_type=e10')
->assertUnprocessable()
->assertJsonValidationErrors(['postcode']);
});

View File

@@ -221,7 +221,7 @@ it('sends web_search tool in the context prediction request', function (): void
Http::assertSent(function ($request) { Http::assertSent(function ($request) {
$tools = $request->data()['tools'] ?? []; $tools = $request->data()['tools'] ?? [];
return collect($tools)->contains(fn ($t) => $t['type'] === 'web_search_20260209'); return collect($tools)->contains(fn ($t) => $t['type'] === 'web_search_20250305');
}); });
}); });
@@ -299,7 +299,7 @@ it('uses LLM with context when API key is configured', function (): void {
$prediction = $this->service->generatePrediction(); $prediction = $this->service->generatePrediction();
expect($prediction->source)->toBe(PredictionSource::LlmWithContext) expect($prediction->source)->toBe(PredictionSource::LlmWithContext)
->and(PricePrediction::count())->toBe(1); ->and(PricePrediction::count())->toBe(2);
}); });
it('falls back to plain LLM when context method fails', function (): void { it('falls back to plain LLM when context method fails', function (): void {
@@ -323,7 +323,7 @@ it('falls back to plain LLM when context method fails', function (): void {
$prediction = $this->service->generatePrediction(); $prediction = $this->service->generatePrediction();
expect($prediction->source)->toBe(PredictionSource::Llm) expect($prediction->source)->toBe(PredictionSource::Llm)
->and(PricePrediction::count())->toBe(1); ->and(PricePrediction::count())->toBe(2);
}); });
it('falls back to EWMA when both LLM methods fail', function (): void { it('falls back to EWMA when both LLM methods fail', function (): void {