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', [ 'events_cited_count' => count($rawResult['events_cited'] ?? []), 'direction' => $rawResult['direction'] ?? null, 'confidence' => $rawResult['confidence'] ?? null, 'reasoning_short' => $rawResult['reasoning_short'] ?? null, 'raw_result' => $rawResult, ]); 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. Append the assistant turn after every // successful response, then decide whether to keep looping — // this guarantees the messages array stays well-formed regardless // of whether we exit via `break` or by exhausting iterations. 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; } $messages[] = ['role' => 'assistant', 'content' => $response->json('content')]; if ($response->json('stop_reason') !== 'pause_turn') { break; } } $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; } $submitContent = $submitResponse->json('content') ?? []; $rawResult = $this->extractToolInput($submitContent); // Haiku sometimes calls submit_overlay without `events_cited` even // though the schema marks it required. Confirmed in laravel.log on // 2026-05-12: tool_use input had only direction/confidence/reasoning. // Retry once with an explicit tool_result error. if ($this->citationsMissing($rawResult)) { $rawResult = $this->retrySubmitWithCitationError($messages, $submitContent) ?? $rawResult; } return $rawResult; } catch (Throwable $e) { Log::error('LlmOverlayService: callAnthropic failed', ['error' => $e->getMessage()]); return null; } } private function verificationUserAgent(): string { $appUrl = rtrim((string) config('app.url'), '/'); return "Mozilla/5.0 (compatible; FuelPriceBot/1.0; +{$appUrl}/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' => $this->verificationUserAgent()]; $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', 'minItems' => 1, '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; } /** @param array|null $rawResult */ private function citationsMissing(?array $rawResult): bool { return $rawResult === null || ! isset($rawResult['events_cited']) || ! is_array($rawResult['events_cited']) || $rawResult['events_cited'] === []; } /** * @param array $messages * @param array $failedSubmitContent * @return array|null */ private function retrySubmitWithCitationError(array $messages, array $failedSubmitContent): ?array { $toolUseId = collect($failedSubmitContent)->firstWhere('type', 'tool_use')['id'] ?? null; if ($toolUseId === null) { Log::warning('LlmOverlayService: cannot retry — no tool_use id in failed submit'); return null; } Log::info('LlmOverlayService: retrying submit with citation error', ['tool_use_id' => $toolUseId]); $messages[] = ['role' => 'assistant', 'content' => $failedSubmitContent]; $messages[] = ['role' => 'user', 'content' => [[ 'type' => 'tool_result', 'tool_use_id' => $toolUseId, 'content' => 'events_cited was missing or empty. Resubmit submit_overlay with at least one event from your earlier web search results, including its real URL, headline, source, and impact.', 'is_error' => true, ]]]; $retryResponse = $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 (! $retryResponse->successful()) { Log::error('LlmOverlayService: retry submit failed', ['status' => $retryResponse->status()]); return null; } return $this->extractToolInput($retryResponse->json('content') ?? []); } }