Skip to content

Instantly share code, notes, and snippets.

@mattpodwysocki
Created May 13, 2026 01:23
Show Gist options
  • Select an option

  • Save mattpodwysocki/ffd6a11ca2759ab80a97b4fff1c74bca to your computer and use it in GitHub Desktop.

Select an option

Save mattpodwysocki/ffd6a11ca2759ab80a97b4fff1c74bca to your computer and use it in GitHub Desktop.
MapGPT: Meet-in-the-Middle message flow diagram

Meet-in-the-Middle: Message Flow Diagram

End-to-end flow from user input through ContentViewAIService → 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
Loading

Key data structures

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

Follow-up intent override logic (AIService.swift)

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment