apiKey() === null) { Log::info('LlmOverlayService: no ANTHROPIC_API_KEY, skipping'); return null; } if ($eventDriven && $this->onCooldown()) { return null; } $forecast = $this->weeklyForecast->currentForecast(); $context = $this->buildContext($forecast); $rawResult = $this->callAnthropic($context); if ($rawResult === null) { return null; } $verifiedEvents = $this->verifyCitedUrls($rawResult['events_cited'] ?? []); if ($verifiedEvents === []) { Log::warning('LlmOverlayService: no verified citations, rejecting overlay'); return null; } $confidence = max(0, min(self::CONFIDENCE_CAP, (int) ($rawResult['confidence'] ?? 0))); $direction = $rawResult['direction'] ?? 'flat'; $agreesWithRidge = $direction === $this->ridgeDirection($forecast['predicted_direction']); return LlmOverlay::query()->create([ 'ran_at' => now(), 'forecast_for_week' => $this->upcomingMondayDateString(), 'direction' => $direction, 'confidence' => $confidence, 'reasoning' => (string) ($rawResult['reasoning_short'] ?? ''), 'events_json' => $verifiedEvents, 'agrees_with_ridge' => $agreesWithRidge, 'major_impact_event' => (bool) ($rawResult['major_impact_event'] ?? false), 'volatility_flag_on' => VolatilityRegime::currentlyActive() !== null, 'search_used' => true, ]); } private function onCooldown(): bool { $latest = LlmOverlay::query()->orderByDesc('ran_at')->first(); return $latest !== null && $latest->ran_at->greaterThanOrEqualTo(now()->subHours(self::COOLDOWN_HOURS)); } /** @return array */ private function buildContext(array $forecast): array { $ulspWeekly = DB::table('weekly_pump_prices') ->orderByDesc('date') ->limit(8) ->get(['date', 'ulsp_pence']) ->reverse() ->map(fn ($r): array => ['date' => (string) $r->date, 'ulsp_pence' => round((int) $r->ulsp_pence / 100, 1)]) ->values() ->all(); $brentRecent = BrentPrice::query() ->orderByDesc('date') ->limit(14) ->get(['date', 'price_usd']) ->reverse() ->map(fn (BrentPrice $r): array => ['date' => (string) $r->date->toDateString(), 'price_usd' => (float) $r->price_usd]) ->values() ->all(); return [ 'ulsp_recent_8_weeks' => $ulspWeekly, 'brent_recent_14_days' => $brentRecent, 'ridge_model_says' => [ 'direction' => $forecast['predicted_direction'] ?? 'stable', 'confidence' => $forecast['confidence_score'] ?? 0, 'magnitude_pence' => $forecast['predicted_change_pence'] ?? 0, ], ]; } /** @return array|null */ private function callAnthropic(array $context): ?array { $messages = [['role' => 'user', 'content' => $this->prompt($context)]]; try { // Phase 1: web search loop for ($i = 0, $response = null; $i < 5; $i++) { $response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(45) ->withHeaders($this->headers()) ->post(self::URL, [ 'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'), 'max_tokens' => 1024, 'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']], 'messages' => $messages, ])); if (! $response->successful()) { Log::error('LlmOverlayService: search request failed', ['status' => $response->status()]); return null; } if ($response->json('stop_reason') !== 'pause_turn') { break; } $messages[] = ['role' => 'assistant', 'content' => $response->json('content')]; } $messages[] = ['role' => 'assistant', 'content' => $response->json('content')]; $messages[] = ['role' => 'user', 'content' => 'Now submit your overlay using the submit_overlay tool. Cite at least one event with a URL.']; // Phase 2: forced structured output $submitResponse = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(20) ->withHeaders($this->headers()) ->post(self::URL, [ 'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'), 'max_tokens' => 512, 'tools' => [$this->submitOverlayTool()], 'tool_choice' => ['type' => 'tool', 'name' => 'submit_overlay'], 'messages' => $messages, ])); if (! $submitResponse->successful()) { Log::error('LlmOverlayService: submit request failed', ['status' => $submitResponse->status()]); return null; } return $this->extractToolInput($submitResponse->json('content') ?? []); } catch (Throwable $e) { Log::error('LlmOverlayService: callAnthropic failed', ['error' => $e->getMessage()]); return null; } } private const string VERIFICATION_USER_AGENT = 'Mozilla/5.0 (compatible; FuelPriceBot/1.0; +https://fuel-price.test/bot)'; /** * Verify each cited URL is reachable. Major news sites (Reuters, FT, * Bloomberg, BBC...) often reject HEAD with 403 / 405 even though * GET works fine. So: try HEAD first, then fall back to a 1-byte * GET (Range header) when HEAD fails. Both must include a * browser-shaped User-Agent or Cloudflare etc. block us as a bot. * * Every URL — verified or rejected — is logged at INFO/WARNING so * operators can debug rejections from `storage/logs/laravel.log` * without needing to capture the Anthropic response body. * * @param array> $events * @return array> */ private function verifyCitedUrls(array $events): array { $verified = []; foreach ($events as $event) { $url = (string) ($event['url'] ?? ''); if ($url === '') { Log::warning('LlmOverlayService: dropping cited event with empty URL', [ 'headline' => $event['headline'] ?? null, 'source' => $event['source'] ?? null, ]); continue; } [$reachable, $diagnosis] = $this->urlReachable($url); if ($reachable) { Log::info('LlmOverlayService: URL verified', [ 'url' => $url, 'via' => $diagnosis, ]); $verified[] = $event; } else { Log::warning('LlmOverlayService: URL rejected', [ 'url' => $url, 'reason' => $diagnosis, 'headline' => $event['headline'] ?? null, 'source' => $event['source'] ?? null, ]); } } return $verified; } /** @return array{0: bool, 1: string} [reachable, diagnostic_string] */ private function urlReachable(string $url): array { $headers = ['User-Agent' => self::VERIFICATION_USER_AGENT]; $headStatus = 'no-attempt'; try { $head = Http::timeout(5) ->withHeaders($headers) ->head($url); $headStatus = 'HEAD='.$head->status(); if ($head->successful() || $head->redirect()) { return [true, $headStatus]; } } catch (Throwable $e) { $headStatus = 'HEAD=exception('.class_basename($e).')'; } try { $get = Http::timeout(8) ->withHeaders($headers + ['Range' => 'bytes=0-0']) ->get($url); $getStatus = 'GET='.$get->status(); if ($get->successful() || $get->redirect()) { return [true, $headStatus.' → '.$getStatus.' (fallback)']; } return [false, $headStatus.' → '.$getStatus]; } catch (Throwable $e) { return [false, $headStatus.' → GET=exception('.class_basename($e).')']; } } private function ridgeDirection(string $publicDirection): string { return match ($publicDirection) { 'up' => 'rising', 'down' => 'falling', default => 'flat', }; } private function upcomingMondayDateString(): string { $today = now()->startOfDay(); $monday = $today->isMonday() ? $today : $today->copy()->next(CarbonInterface::MONDAY); return $monday->toDateString(); } /** @return array */ private function headers(): array { return [ 'x-api-key' => $this->apiKey(), 'anthropic-version' => '2023-06-01', ]; } private function apiKey(): ?string { return config('services.anthropic.api_key'); } private function prompt(array $context): string { $json = json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); return <<confidenceCap), short reasoning, cited events with URLs, agrees_with_ridge, and major_impact_event. Citing events with REAL URLs is mandatory. An empty citation array will be rejected and the overlay discarded. PROMPT; } private string $confidenceCap = '75'; /** @return array */ private function submitOverlayTool(): array { return [ 'name' => 'submit_overlay', 'description' => 'Submit the news-aware overlay for the upcoming weekly forecast.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']], 'confidence' => ['type' => 'integer', 'minimum' => 0, 'maximum' => self::CONFIDENCE_CAP], 'reasoning_short' => ['type' => 'string', 'description' => '1–2 sentences.'], 'events_cited' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'headline' => ['type' => 'string'], 'source' => ['type' => 'string'], 'url' => ['type' => 'string'], 'impact' => ['type' => 'string', 'enum' => ['rising', 'falling', 'neutral']], ], 'required' => ['headline', 'source', 'url', 'impact'], ], ], 'agrees_with_ridge' => ['type' => 'boolean'], 'major_impact_event' => ['type' => 'boolean'], ], 'required' => ['direction', 'confidence', 'reasoning_short', 'events_cited', 'agrees_with_ridge', 'major_impact_event'], ], ]; } /** * @param array $content * @return array|null */ private function extractToolInput(array $content): ?array { $block = collect($content)->firstWhere('type', 'tool_use'); return $block['input'] ?? null; } }