Maps & Local Rank Check (LRC)
The FCR-built maps subsystem: the rank-check grid, the local-insights map, and how the three map flows (LRC, Dashboard Discovery, Prospect) differ — plus which external service supplies each data point. All maps are served by the Worker as self-contained HTML (Leaflet) and shared via
/r/:id. This is FCR’s own LRC — separate from InSites/SayMore’s LRC.For the client-facing “how to read the report” explainer see
lrc-methodology.md. Grid-sizing maths:LRC_Grid_Sizing_Brief.md. Verified 2026-05-20.
1. FCR LRC vs InSites LRC
Section titled “1. FCR LRC vs InSites LRC”There are two “LRC”s — don’t conflate them:
- FCR LRC (this doc) — our own rank check, computed by querying SerpAPI
at a grid of points. Canonical. Lives in the Worker (
serp-grid,grid-map-build). Predictable credit cost, real per-point coordinates. - InSites LRC — SayMore’s third-party rank check (submitted via InSites). Used for the home anchor grid historically; being sunset in favour of SerpAPI as the canonical source.
2. How the LRC grid computes
Section titled “2. How the LRC grid computes”dashboard-serp-grid is the engine:
- Take a centre
lat/lng, asize(e.g. 5 → 5×5 = 25 points), and aspacing_km. Generate asize × sizegrid of real coordinates. - For each point × each keyword, query SerpAPI
engine=google_mapsand read the top-20 local pack results. - Return the per-point results in an InSites-LRC-compatible shape, so the map builder treats SerpAPI and InSites grids uniformly.
- The prospect’s rank at each point = its position in that point’s local pack (or “not in top 20”). Across the grid that’s the heatmap of where they rank well vs poorly.
Key compute details:
- Zoom is auto-derived from spacing (not hardcoded). A wide zoom let city-centre giants (e.g. “Temple Bar Pub”) rank #1 at every point and buried the actual prospect — tighter zoom = genuinely local results. (Fixed 2026-04-26.)
- Irish-leak guard: each SerpAPI response is validated for Irish signals (county names, eircode) and retried once with a tighter location string if it drifts to US results.
- Geometry (
lib/grid-geometry.js):coverage_kmper grid + satellite overlap analysis, so multi-grid layouts don’t double-cover the same area (thecheck_satellite_overlapadvisor tool uses this before burning SerpAPI). - Catchment radius by place type (metro vs regional, Place vs SAB) is set in
grid-map-build.js— seereferencenotes; 12/20/25/50 km tiers.
grid-map-build then assembles the shareable map from the grid + the other
layers (below) and renders the /r/:id HTML with four sidebar tabs:
Rankings · Demographics · Competitors · Web Results.
Web Results population. The “Web Results” tab is fetched (SerpAPI
- screenshots) when the build has a Discovery overlay, passes
include_insights(the default for prospect / shared LRC publishes), or setsinclude_web_results. It is decoupled from the insights narrative — a published map still gets Web Results even if the AI narrative failed to generate. Lean “quick internal” maps (include_insights=false, no flag) stay lean and skip the SerpAPI + screenshot cost. Caveat: this is the worker JS renderer only. The/prospectskill’s Python renderer (build_lrc_grid_map.py) does not render Web Results (or posture preset / regenerate). Closing that gap is the planned consolidation: route/prospectthroughdashboard-grid-map-buildand retire the Python renderer (verify the worker handles the multi-grid home+satellite shape first).
3. The three map flows
Section titled “3. The three map flows”All three produce a Leaflet map; they differ in who drives it and how much is automated.
| Flow | What it is | Entry | Driver |
|---|---|---|---|
| LRC | The rank-check grid map itself — pick business + keywords, get the heatmap + layers, share /r/:id | dashboard-grid-map-build / dashboard-serp-grid | the primitive everything else builds on |
| Dashboard Discovery (Alpha) | A stepped, AI-guided build of an LRC report inside the dashboard: suggest keywords/area → run grid → AI insights → DF competitor compare, all baked into one shareable map | dashboard-discovery-suggest → -insights → -df-compare | the AM, interactively in the dashboard |
| Prospect | Meeting-prep, multi-grid LRC: a home grid + satellite grids (SAB centre override, shared + geo keywords), overlap-checked, as a customer-facing pitch artefact | the /prospect skill (Roam / CLI) | the AM prepping for a meeting |
In short: LRC is the map primitive; Discovery is the guided in-dashboard builder around it; Prospect is the multi-grid meeting-prep flow.
4. Which service supplies which data point
Section titled “4. Which service supplies which data point”The maps call several services — one per data point:
| Data point | Service | Worker route / source |
|---|---|---|
| Local rankings (the grid heatmap) | SerpAPI google_maps (local pack) | dashboard-serp-grid |
| Web SERP results (the “Web Results” tab — mobile google.ie organic) | SerpAPI google + screenshot | dashboard-serp-local, dashboard-serp-screenshot |
| Place search / lat-lng / NAP | Google Maps via SerpAPI | dashboard-prospect-search |
| GBP profile (reviews, categories, hours, photos) | Pleper (+ SerpAPI gbp-live) | dashboard-gbp-live, used in grid-map-build + discovery-insights |
| Demographics + Purchasing Power | CSO (cso_small_areas_v2, census) | dashboard-circle-demographics, bbox-demographics |
| Online reach | FB / IG (Meta) ad-audience estimate | dashboard-meta-reach (+ -batch) |
| Keywords (what to rank-check, volumes, intent) | FCR Keyword Knowledge Base | KEYWORD_INTELLIGENCE, dashboard-category-keywords |
| Map AI commentary | Claude | dashboard-map-advisor |
So a single LRC/Discovery map can fan out to SerpAPI (ranks + web), Pleper (GBP), Google Maps (places), CSO (demographics/PP), Meta (reach), and the Keyword KB (keywords) — each layer from its own source.
5. Map features
Section titled “5. Map features”- Local insights map (Area Intelligence) —
dashboard-area-map: the layers map of an area (purchasing power, population density, housing tenure, commercial properties, deprivation, Meta reach). Sourced from CSO + Meta. Standalone, or as the Demographics tab on an LRC map. - “Quick — who ranks locally?” — the light competitor path (~3-4s, NOT a
full grid):
search_google_places(category + locality) →enrich_competitors. Returns competitors with reviews/hours/category breadth + threat level, without burning a SerpAPI grid. Used in the HubSpot card / Roam for a fast answer; escalate torun_serp_gridonly when the AM asks for actual rank/SERP. - Identify key competitors —
dashboard-competitor-enrich/enrich_competitors: for a set of place_ids, adds services, photo count, response rate, hours, all GBP categories, andfcr_link(whether each is already on FCR’s books) — surfacing a natural competitor to benchmark against and cross-sell from.
6. Where it’s rendered & shared
Section titled “6. Where it’s rendered & shared”- All maps are Worker-served HTML (Leaflet from CDN, data inlined) — no
separate front-end build. The dashboard UI embeds them; clients open them via
the public
/r/:idshare URL. - The published map message + the four-tab explainer is what the advisor surfaces
when it
publish_grid_maps (see the advisor tooling). - Client-facing methodology (how ranks are measured, how to read each layer):
lrc-methodology.md— served atdashboard-lrc-methodology.
7. Known issue — duplicate publish_grid_map on fresh LRC intents (open, 2026-05-27)
Section titled “7. Known issue — duplicate publish_grid_map on fresh LRC intents (open, 2026-05-27)”Symptom. A single-grid LRC (“who ranks for florists in clontarf”) through the Roam advisor took 4m 02s end-to-end vs ~2m 30s for an equivalent single-grid LRC the week prior (Fergal Colbert / Dungarvan, same shape: 5×5 grid, 5 keywords, 4 advertised tool calls).
Diagnosis. Worker-side advisor_usage_log for the slow run (n8n exec 37234) shows the tool sequence was:
run_serp_grid, enrich_competitors, publish_grid_map, publish_grid_mappublish_grid_map ran twice. Comparing to the 2m 30s Fergal baseline (n8n exec 36494) — same intent, same grid shape, more competitors enriched (13 vs 7) — that run published once. The extra publish + the extra Claude round-trip needed to reach it accounts for ~75–85 s of wall clock, which matches the 85 s gap almost exactly. Output tokens were also ~85% higher on the slow run, consistent with the model regenerating its response after the second tool call.
Root cause. Two paragraphs in worker/src/handlers/ai-advisor.js give the model contradictory defaults for include_insights:
- L4690 (GRID MAP EXPORT block): “DEFAULT TO LRC (include_insights OFF) … plain ‘LRC’ / ‘run a map’ / ‘/prospect’ stays tables-only.”
- L4700 (WHEN TO AUTO-FIRE, 10 lines below): “After enrich_competitors returns … call publish_grid_map immediately with grid_labels …, competitors_enrichment …, AND include_insights=true.”
- L4701: “If enrich was skipped (explicit spot-check), still auto-publish with include_insights=true unless the AM said ‘just chat’ / ‘no map’.”
So the same block tells the model both “LRC default = insights OFF” and “auto-fire = insights ON”. On the florist run the model resolved this by calling publish_grid_map once for each interpretation. Only the first URL was surfaced to the AM (the closing footer in the response — “Say ‘add insights’ to republish with the AI analysis” — is the LRC-default footer copy), so the second publish was effectively waste.
Mid-flow runs (e.g. when the AM replies "a" to a /prospect proposal) don’t re-derive include_insights from scratch and don’t trip this — which is why the Fergal baseline was clean.
Fix options (not yet applied — flag for prompt owner):
- Reconcile the two paragraphs. Pick a single default for the auto-fire path. Either drop the
AND include_insights=trueclauses at L4700–L4701 (LRC-default wins, matches L4690 + the user-facing “say ‘add insights’ to republish” footer), or revert L4690 (insights-default wins) and update the footer copy elsewhere. The first option matches current product behaviour; whichever is chosen, the two paragraphs must agree. - Idempotent publish in the handler. In
publish_grid_map’s branch (ai-advisor.js, ~L3504), short-circuit a second call with the same(sessionKey, business_name, grid_labels, include_insights)within e.g. 60 s by returning the cached result without re-invokingdashboard-grid-map-build. This is the cheap safety net and defends against any future prompt drift.
(1) addresses the root cause; (2) caps the worst case at ~10 s instead of ~80 s if the model still double-calls for any other reason. Worth doing both.
Repro evidence.
| Run | Date | User msg | Tool sequence | Wall clock | Worker latency | Output tokens |
|---|---|---|---|---|---|---|
| Slow (today) | 2026-05-27 | ”who ranks for florists in clontarf” | run_serp_grid, enrich_competitors, publish_grid_map, publish_grid_map | 4m 02s | 237 s | 5,565 |
| Baseline | 2026-05-25 | ”a” (mid /prospect flow) — Fergal Colbert LRC | search_google_places, run_serp_grid, enrich_competitors, publish_grid_map | 2m 36s | 152 s | 3,009 |
| Heavier (3-grid) | 2026-05-25 | ”3 seperate” — DS Electrical home + 2 satellites | search_google_places, check_satellite_overlap, run_serp_grid×3, enrich_competitors, publish_grid_map | 4m 14s | 249 s | 3,719 |
The 3-grid run did 3× the SERP work for the same wall time as today’s single-grid run — confirming the regression is in the orchestration layer (duplicate publish + longer model response), not in SerpAPI / Places / Claude API latency.