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('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(); });