- 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
334 lines
12 KiB
PHP
334 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Models\LlmOverlay;
|
|
use App\Services\ApiLogger;
|
|
use App\Services\Forecasting\LlmOverlayService;
|
|
use App\Services\Forecasting\WeeklyForecastService;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function (): void {
|
|
Cache::flush();
|
|
Config::set('services.anthropic.api_key', 'test-key');
|
|
});
|
|
|
|
function fakeAnthropicWithOverlay(string $direction, int $confidence, array $events, bool $major = false): void
|
|
{
|
|
Http::fake([
|
|
'*api.anthropic.com/*' => Http::sequence()
|
|
->push([
|
|
'stop_reason' => 'end_turn',
|
|
'content' => [['type' => 'text', 'text' => 'Search summary.']],
|
|
])
|
|
->push([
|
|
'stop_reason' => 'tool_use',
|
|
'content' => [[
|
|
'type' => 'tool_use',
|
|
'name' => 'submit_overlay',
|
|
'input' => [
|
|
'direction' => $direction,
|
|
'confidence' => $confidence,
|
|
'reasoning_short' => 'Test reasoning.',
|
|
'events_cited' => $events,
|
|
'agrees_with_ridge' => true,
|
|
'major_impact_event' => $major,
|
|
],
|
|
]],
|
|
]),
|
|
// URL HEAD verification probes — accept everything by default
|
|
'*' => Http::response('', 200),
|
|
]);
|
|
}
|
|
|
|
it('skips when ANTHROPIC_API_KEY is not set', function (): void {
|
|
Config::set('services.anthropic.api_key', null);
|
|
|
|
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
|
|
|
expect($service->run())->toBeNull();
|
|
});
|
|
|
|
it('rejects the overlay when no events are cited', function (): void {
|
|
fakeAnthropicWithOverlay('rising', 60, []);
|
|
|
|
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
|
|
|
expect($service->run())->toBeNull()
|
|
->and(LlmOverlay::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('verifies a URL via GET fallback when HEAD returns 405', function (): void {
|
|
Http::fake([
|
|
'*api.anthropic.com/*' => Http::sequence()
|
|
->push([
|
|
'stop_reason' => 'end_turn',
|
|
'content' => [['type' => 'text', 'text' => 'ok']],
|
|
])
|
|
->push([
|
|
'stop_reason' => 'tool_use',
|
|
'content' => [[
|
|
'type' => 'tool_use',
|
|
'name' => 'submit_overlay',
|
|
'input' => [
|
|
'direction' => 'rising',
|
|
'confidence' => 60,
|
|
'reasoning_short' => 'Hostile-to-HEAD source.',
|
|
'events_cited' => [
|
|
['headline' => 'OPEC', 'source' => 'Reuters', 'url' => 'https://reuters.com/x', 'impact' => 'rising'],
|
|
],
|
|
'agrees_with_ridge' => true,
|
|
'major_impact_event' => false,
|
|
],
|
|
]],
|
|
]),
|
|
'reuters.com/*' => Http::sequence()
|
|
->push('', 405) // HEAD → 405 Method Not Allowed
|
|
->push('partial-body', 200), // GET fallback succeeds
|
|
]);
|
|
|
|
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
|
$row = $service->run();
|
|
|
|
expect($row)->not->toBeNull()
|
|
->and($row->events_json)->toHaveCount(1);
|
|
});
|
|
|
|
it('rejects the overlay when both HEAD and GET fail', function (): void {
|
|
Http::fake([
|
|
'*api.anthropic.com/*' => Http::sequence()
|
|
->push([
|
|
'stop_reason' => 'end_turn',
|
|
'content' => [['type' => 'text', 'text' => 'ok']],
|
|
])
|
|
->push([
|
|
'stop_reason' => 'tool_use',
|
|
'content' => [[
|
|
'type' => 'tool_use',
|
|
'name' => 'submit_overlay',
|
|
'input' => [
|
|
'direction' => 'rising',
|
|
'confidence' => 60,
|
|
'reasoning_short' => 'Truly dead URL.',
|
|
'events_cited' => [
|
|
['headline' => 'X', 'source' => 'Reuters', 'url' => 'https://example.com/dead', 'impact' => 'rising'],
|
|
],
|
|
'agrees_with_ridge' => true,
|
|
'major_impact_event' => false,
|
|
],
|
|
]],
|
|
]),
|
|
'example.com/*' => Http::sequence()
|
|
->push('', 404) // HEAD → 404
|
|
->push('', 404), // GET → still 404
|
|
]);
|
|
|
|
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
|
|
|
expect($service->run())->toBeNull()
|
|
->and(LlmOverlay::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('rejects the overlay when every cited URL is unreachable', function (): void {
|
|
Http::fake([
|
|
'*api.anthropic.com/*' => Http::sequence()
|
|
->push([
|
|
'stop_reason' => 'end_turn',
|
|
'content' => [['type' => 'text', 'text' => 'ok']],
|
|
])
|
|
->push([
|
|
'stop_reason' => 'tool_use',
|
|
'content' => [[
|
|
'type' => 'tool_use',
|
|
'name' => 'submit_overlay',
|
|
'input' => [
|
|
'direction' => 'rising',
|
|
'confidence' => 60,
|
|
'reasoning_short' => 'Test.',
|
|
'events_cited' => [
|
|
['headline' => 'X', 'source' => 'Reuters', 'url' => 'https://example.com/dead', 'impact' => 'rising'],
|
|
],
|
|
'agrees_with_ridge' => true,
|
|
'major_impact_event' => false,
|
|
],
|
|
]],
|
|
]),
|
|
'example.com/*' => Http::response('', 404),
|
|
]);
|
|
|
|
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
|
|
|
expect($service->run())->toBeNull()
|
|
->and(LlmOverlay::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('persists an overlay row with verified citations and capped confidence', function (): void {
|
|
fakeAnthropicWithOverlay(
|
|
direction: 'rising',
|
|
confidence: 95, // above cap → expect capped to 75
|
|
events: [
|
|
['headline' => 'OPEC cuts output', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'],
|
|
],
|
|
major: true,
|
|
);
|
|
|
|
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
|
|
|
$row = $service->run();
|
|
|
|
expect($row)->not->toBeNull()
|
|
->and($row->direction)->toBe('rising')
|
|
->and($row->confidence)->toBe(75) // capped
|
|
->and($row->major_impact_event)->toBeTrue()
|
|
->and($row->search_used)->toBeTrue()
|
|
->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([
|
|
'ran_at' => Carbon::parse('2026-05-01 08:00:00'),
|
|
'forecast_for_week' => '2026-05-04',
|
|
'direction' => 'rising',
|
|
'confidence' => 60,
|
|
'reasoning' => 'prior',
|
|
'events_json' => json_encode([['headline' => 'x', 'url' => 'https://reuters.com/x']]),
|
|
'agrees_with_ridge' => true,
|
|
'major_impact_event' => false,
|
|
'volatility_flag_on' => false,
|
|
'search_used' => true,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
fakeAnthropicWithOverlay('falling', 40, [
|
|
['headline' => 'A', 'source' => 'X', 'url' => 'https://reuters.com/a', 'impact' => 'falling'],
|
|
]);
|
|
|
|
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
|
|
|
expect($service->run(eventDriven: true))->toBeNull() // <4h since prior
|
|
->and(LlmOverlay::query()->count())->toBe(1); // no new row inserted
|
|
|
|
Carbon::setTestNow();
|
|
});
|
|
|
|
it('always runs (ignores cooldown) when not event-driven', function (): void {
|
|
Carbon::setTestNow('2026-05-01 10:00:00');
|
|
DB::table('llm_overlays')->insert([
|
|
'ran_at' => Carbon::parse('2026-05-01 08:00:00'),
|
|
'forecast_for_week' => '2026-05-04',
|
|
'direction' => 'rising',
|
|
'confidence' => 60,
|
|
'reasoning' => 'prior',
|
|
'events_json' => json_encode([['headline' => 'x', 'url' => 'https://reuters.com/x']]),
|
|
'agrees_with_ridge' => true,
|
|
'major_impact_event' => false,
|
|
'volatility_flag_on' => false,
|
|
'search_used' => true,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
fakeAnthropicWithOverlay('falling', 40, [
|
|
['headline' => 'A', 'source' => 'X', 'url' => 'https://reuters.com/a', 'impact' => 'falling'],
|
|
]);
|
|
|
|
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
|
|
|
expect($service->run())->not->toBeNull()
|
|
->and(LlmOverlay::query()->count())->toBe(2);
|
|
|
|
Carbon::setTestNow();
|
|
});
|