diff --git a/app/Services/Forecasting/LlmOverlayService.php b/app/Services/Forecasting/LlmOverlayService.php index 1c776f7..99a21a7 100644 --- a/app/Services/Forecasting/LlmOverlayService.php +++ b/app/Services/Forecasting/LlmOverlayService.php @@ -62,7 +62,13 @@ final class LlmOverlayService $verifiedEvents = $this->verifyCitedUrls($rawResult['events_cited'] ?? []); if ($verifiedEvents === []) { - Log::warning('LlmOverlayService: no verified citations, rejecting overlay'); + 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; } @@ -131,7 +137,10 @@ final class LlmOverlayService $messages = [['role' => 'user', 'content' => $this->prompt($context)]]; try { - // Phase 1: web search loop + // 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()) @@ -148,14 +157,13 @@ final class LlmOverlayService return null; } + $messages[] = ['role' => 'assistant', 'content' => $response->json('content')]; + 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 @@ -175,7 +183,18 @@ final class LlmOverlayService return null; } - return $this->extractToolInput($submitResponse->json('content') ?? []); + $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()]); @@ -183,7 +202,12 @@ final class LlmOverlayService } } - private const string VERIFICATION_USER_AGENT = 'Mozilla/5.0 (compatible; FuelPriceBot/1.0; +https://fuel-price.test/bot)'; + 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, @@ -235,7 +259,7 @@ final class LlmOverlayService /** @return array{0: bool, 1: string} [reachable, diagnostic_string] */ private function urlReachable(string $url): array { - $headers = ['User-Agent' => self::VERIFICATION_USER_AGENT]; + $headers = ['User-Agent' => $this->verificationUserAgent()]; $headStatus = 'no-attempt'; try { @@ -342,6 +366,7 @@ final class LlmOverlayService 'reasoning_short' => ['type' => 'string', 'description' => '1–2 sentences.'], 'events_cited' => [ 'type' => 'array', + 'minItems' => 1, 'items' => [ 'type' => 'object', 'properties' => [ @@ -371,4 +396,57 @@ final class LlmOverlayService 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') ?? []); + } } diff --git a/app/Services/FuelPriceService.php b/app/Services/FuelPriceService.php index de5ec99..917ac6a 100644 --- a/app/Services/FuelPriceService.php +++ b/app/Services/FuelPriceService.php @@ -124,7 +124,8 @@ class FuelPriceService } $logUrl = $baseUrl.'?'.http_build_query($params); - $response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30) + $response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::retry(3, 1000) + ->timeout(60) ->withToken($token) ->get($baseUrl, $params)); diff --git a/resources/js/components/LeafletMap.vue b/resources/js/components/LeafletMap.vue index cd6330b..98768ef 100644 --- a/resources/js/components/LeafletMap.vue +++ b/resources/js/components/LeafletMap.vue @@ -204,6 +204,20 @@ function initMap() { // map-polish:7 — replace default attribution control with custom ⓘ button mapInstance = L.map(mapContainer.value, {zoomControl: false, attributionControl: false}) + // Set the initial view immediately so tiles load at the correct spot from + // frame 1 — avoids the "wiggle" caused by setView running after the + // container is already laid out and tiles are mid-load. + const initialZoom = getZoomForRadius(props.radiusMiles) + const initialCenter = props.origin?.lat != null && props.origin?.lng != null + ? [props.origin.lat, props.origin.lng] + : props.stations.length + ? [props.stations[0].lat, props.stations[0].lng] + : null + if (initialCenter) { + mapInstance.setView(initialCenter, initialZoom) + hasInitialView = true + } + // map-polish:5 — Carto Positron tile (cleaner than raw OSM) L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { subdomains: 'abcd', @@ -230,7 +244,7 @@ function initMap() { locateUser() } -function renderMarkers() { +function renderMarkers({skipRecenter = false} = {}) { if (!mapInstance || !markersLayer) return markersLayer.clearLayers() @@ -262,6 +276,8 @@ function renderMarkers() { bounds.push([station.lat, station.lng]) }) + if (skipRecenter) return + const zoom = getZoomForRadius(props.radiusMiles) const center = props.origin?.lat != null && props.origin?.lng != null ? [props.origin.lat, props.origin.lng] @@ -290,9 +306,10 @@ function destroyMap() { async function openMap() { await nextTick() + const wasFreshInit = !mapInstance initMap() mapInstance?.invalidateSize() - renderMarkers() + renderMarkers({skipRecenter: wasFreshInit}) } onMounted(() => { diff --git a/resources/js/components/PostSearchFilters.vue b/resources/js/components/PostSearchFilters.vue index 152ad37..9c837f9 100644 --- a/resources/js/components/PostSearchFilters.vue +++ b/resources/js/components/PostSearchFilters.vue @@ -1,12 +1,12 @@