feat(ui): mobile-first redesign — compact hero, inline submit button, map-first with collapsible list
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

- Hero: remove full-width mobile submit, add inline "Go" button next to locate
- Prediction cards: tighter mobile padding (px-3 py-3)
- Search filters: right-aligned toolbar, remove "X stations found" count and map toggle
- Map: initialize view immediately to avoid tile wiggle, skip recenter on fresh init
- Station list: hidden by default, toggled via "Stations {count}" pill above map
- Typography: hide desktop h1 on mobile, scale down section headings and spacing
- Footer: remove uppercase styling from headings and copyright line
- Filter popover: auto-close on fuel/radius/sort/brand selection

fix(llm): retry submit_overlay when events_cited is missing, extend Fuel Finder timeout with retries

- LlmOverlayService: add `minItems: 1` to events_cited schema, detect missing citations
  in submit response, inject tool_result error and retry once with explicit prompt
- Log full raw_result context when no verified citations, capturing direction/confidence/reasoning
- FuelPriceService: add 3×1s retry with 60s timeout to batch price requests (was 30s no retry)
- Tests: cover successful retry recovery and rejection when retry also omits citations
This commit is contained in:
Ovidiu U
2026-05-14 13:23:52 +01:00
parent 11a3b433ff
commit 97e27fc057
10 changed files with 302 additions and 87 deletions

View File

@@ -189,6 +189,91 @@ it('persists an overlay row with verified citations and capped confidence', func
->and($row->events_json)->toHaveCount(1);
});
it('retries the submit when the model omits events_cited', function (): void {
Http::fake([
'*api.anthropic.com/*' => Http::sequence()
->push([
'stop_reason' => 'end_turn',
'content' => [['type' => 'text', 'text' => 'Search done.']],
])
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'id' => 'toolu_first',
'name' => 'submit_overlay',
'input' => [
'direction' => 'rising',
'confidence' => 70,
'reasoning_short' => 'Forgot citations.',
// events_cited omitted entirely — the bug we are guarding against
],
]],
])
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'id' => 'toolu_retry',
'name' => 'submit_overlay',
'input' => [
'direction' => 'rising',
'confidence' => 70,
'reasoning_short' => 'With citations now.',
'events_cited' => [
['headline' => 'OPEC', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'],
],
'agrees_with_ridge' => true,
'major_impact_event' => false,
],
]],
]),
'*' => Http::response('', 200),
]);
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
$row = $service->run();
expect($row)->not->toBeNull()
->and($row->events_json)->toHaveCount(1)
->and(LlmOverlay::query()->count())->toBe(1);
});
it('rejects when the retry also omits events_cited', function (): void {
Http::fake([
'*api.anthropic.com/*' => Http::sequence()
->push([
'stop_reason' => 'end_turn',
'content' => [['type' => 'text', 'text' => 'Search done.']],
])
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'id' => 'toolu_first',
'name' => 'submit_overlay',
'input' => ['direction' => 'rising', 'confidence' => 70, 'reasoning_short' => 'No cites.'],
]],
])
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'id' => 'toolu_retry',
'name' => 'submit_overlay',
'input' => ['direction' => 'rising', 'confidence' => 70, 'reasoning_short' => 'Still none.'],
]],
]),
'*' => Http::response('', 200),
]);
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
expect($service->run())->toBeNull()
->and(LlmOverlay::query()->count())->toBe(0);
});
it('honors the 4-hour cooldown for event-driven calls', function (): void {
Carbon::setTestNow('2026-05-01 10:00:00');
DB::table('llm_overlays')->insert([

View File

@@ -342,6 +342,38 @@ it('does not cache the poll timestamp when a batch errors', function (): void {
expect(Cache::has('fuel_finder_last_price_poll_at'))->toBeFalse();
});
it('retries a failing batch and recovers when the API responds successfully', function (): void {
Cache::put('fuel_finder_access_token', 'tok', 3540);
Station::factory()->create(['node_id' => 'sta1']);
Http::fake([
'*/pfs/fuel-prices*' => Http::sequence()
->push([], 500)
->push([], 500)
->push([
[
'node_id' => 'sta1',
'fuel_prices' => [
[
'fuel_type' => 'E10',
'price' => 142.9,
'price_last_updated' => '2026-04-04T10:00:00.000Z',
'price_change_effective_timestamp' => '2026-04-04T10:00:00.000Z',
],
],
],
])
->push([]),
]);
$inserted = $this->service->pollPrices();
expect($inserted)->toBe(1)
->and(StationPrice::count())->toBe(1);
Http::assertSentCount(4);
});
it('skips price rows for stations not present in the stations table', function (): void {
Cache::put('fuel_finder_access_token', 'tok', 3540);