End-to-end flow from user input through ContentView → AIService → external APIs, for both an initial query and a follow-up turn.
sequenceDiagram
participant U as User
participant CV as ContentView
participant AS as AIService.liveStream()
participant QP as QueryPlanner
participant LLM as OpenAI
participant MB as Mapbox APIs
Note over U,MB: ── Initial meet-in-middle query ──
U->>CV: "Find a coffee spot halfway between us"
CV->>CV: mimContext = last AI mapResponse?.meetInMiddleContext → nil
CV->>AS: stream(userMessage, meetInMiddleContext: nil)
AS->>LLM: intent classification pass
LLM-->>AS: queryIntent = .meetInMiddle
AS->>QP: dynamicPlan(userMessage) or meetInMiddlePlan(userMessage)
QP-->>AS: { locationA, locationB, profile, minutes }
AS->>MB: isochrone_tool(locationA, minutes, profile)
MB-->>AS: polygonA []CLLocationCoordinate2D
AS->>MB: isochrone_tool(locationB, minutes, profile)
MB-->>AS: polygonB []CLLocationCoordinate2D
AS->>AS: compute intersection(polygonA ∩ polygonB) → intersectionPolygons
AS->>AS: build intersectionBbox { min_lng, min_lat, max_lng, max_lat }
AS->>AS: capture pendingMeetInMiddleCtx (coords, polygons, bbox, profile, minutes)
AS->>MB: category_search_tool(bbox: intersectionBbox, category: "coffee")
MB-->>AS: pins[] (clipped to intersection)
AS-->>CV: yield MapResponse<br/>(pins, isochronePolygons[A,B,∩], meetInMiddleContext)
alt fewer than threshold pins
AS->>MB: expandMeetInMiddle — widens bbox, retries search
MB-->>AS: additional pins
end
AS->>LLM: meetInMiddleWave2 — gap-fill missing categories async
LLM->>MB: category_search_tool (supplemental categories)
MB-->>LLM: delta pins
LLM-->>AS: delta pins
AS-->>CV: yield MapResponse (wave-2 delta, meetInMiddleContext carried)
CV->>CV: recomputeMapResponse() → cachedMapResponse updated
CV->>CV: render: isochrone rings A+B+∩, pins in carousel
Note over U,MB: ── Follow-up query (intersection context cached) ──
U->>CV: "How about bars instead?"
CV->>CV: mimContext = last AI mapResponse.meetInMiddleContext → non-nil
CV->>AS: stream(userMessage, meetInMiddleContext: mimContext)
AS->>AS: meetInMiddleContext != nil → force queryIntent = .meetInMiddle
AS->>AS: append SystemPrompt.buildMeetInMiddleContext(mimCtx)<br/>(instructs LLM: skip isochrone_tool, use provided bbox)
AS->>AS: pre-populate isochroneIntersection, isochroneBbox,<br/>isochroneCallArgs, collectedIsochronePolygons from context
Note over AS: Orchestrator / isochrone pass SKIPPED — context already resolved
loop Each LLM tool-call round
AS->>AS: restore polygonA + polygonB + ∩ to pendingMapResponse<br/>(prevents tool-result overwrites from losing layers)
AS->>LLM: runTurn(tools: category_search, place_details, …)
LLM->>MB: category_search_tool(bbox: intersectionBbox, category: "bars")
MB-->>LLM: pins[]
LLM-->>AS: tool results + streaming narrative text
end
AS->>AS: pendingMeetInMiddleCtx = mimCtx (passed through unchanged)
AS-->>CV: yield MapResponse(new pins, preserved polygons, meetInMiddleContext)
CV->>CV: recomputeMapResponse() skips empty responses (hasMapContent guard)
CV->>CV: render: same isochrone rings, updated pins
| Struct | Where | Purpose |
|---|---|---|
MeetInMiddleContext |
MapResponse.swift |
Snapshot of the resolved intersection: coords A/B, polygon rings, bbox, profile, minutes |
pendingMeetInMiddleCtx |
AIService local var |
Accumulated during tool-call loop; attached to every yielded MapResponse |
isochroneCallArgs |
AIService local var |
Seeded from context on follow-up so LLM skips isochrone_tool entirely |
isochroneIntersection |
AIService local var |
Polygon ring(s) used to clip category-search pins |
wave2Snapshot |
AIService local var |
Copy of wave-1 MapResponse handed to the async gap-fill pass |
if classifierIntent == .meetInMiddle → keep
else if meetInMiddleContext != nil → force .meetInMiddle
else → use classifier result
The context presence check acts as the authoritative signal — no re-classification needed on follow-up turns.