mikeboone Claude Opus 4.8 commited on
Commit
a15663c
·
1 Parent(s): 26a1772

liveboard: auto-build a geo map from model lat/lon (Path B)

Browse files

When the model has latitude/longitude columns and the board has no geo viz,
synthesize a GEO_BUBBLE so the layout places it full-width under the KPIs
("geo on almost anything, when the data supports it").

golden_layout.py:
- detect_geo_columns(): find lat/lon (word-boundary match, so "Platform"
isn't a false hit) + a geo attribute + a bubble measure from model columns
- _build_geo_viz(): synthesize a GEO_BUBBLE answer (clone a sibling's model
ref; search_query [Lat] [Lon] sum [Measure] [GeoDim]; minimal mapview
client_state that auto-fits)
- _maybe_build_geo_viz(): only fires when lat/lon exist and no geo viz present
- build_dashboard_plan gains model_columns; apply_golden_two_tab_layout adds
the synthesized viz so it lands in the Geographic View group

liveboard_creator.py: _fetch_model_columns() resolves the board's model
(export w/ fqn -> model TML) and passes its columns to the plan; best-effort
(returns [] on failure so liveboard creation never breaks).

Verified live: stripped Tixr's geo viz, fed the model columns, and the engine
rebuilt a GEO_BUBBLE that renders as a venue map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

demoprep_app/liveboards/golden_layout.py CHANGED
@@ -23,6 +23,7 @@ def build_dashboard_plan(
23
  logo_url: str | None = None,
24
  company_url: str | None = None,
25
  keep_overview_note: bool = False,
 
26
  ) -> Dict[str, Any]:
27
  """Return the first-pass dashboard architecture for lab-created liveboards.
28
 
@@ -38,6 +39,7 @@ def build_dashboard_plan(
38
  "logo_url": logo_url,
39
  "company_url": company_url,
40
  "keep_overview_note": keep_overview_note,
 
41
  "executive_tab": executive_tab,
42
  "manager_tab": manager_tab,
43
  "target_viz_count": target_viz_count,
@@ -155,6 +157,15 @@ def apply_golden_two_tab_layout(
155
  visualizations = [viz for viz in visualizations if "note_tile" not in viz]
156
  liveboard["visualizations"] = visualizations
157
 
 
 
 
 
 
 
 
 
 
158
  kpi_changes = _convert_periodic_metric_charts(data_visualizations)
159
  chart_changes = _reduce_heatmaps_and_apply_palettes(data_visualizations)
160
  mode_changes = _force_chart_mode(data_visualizations)
@@ -173,7 +184,7 @@ def apply_golden_two_tab_layout(
173
  f"Applied golden two-tab layout ({len(groups)} groups)",
174
  f"Tabs: {_executive_tab_name(plan)}, {_manager_tab_name(plan)}",
175
  ]
176
- for change in (kpi_changes, chart_changes, mode_changes, color_changes, title_changes):
177
  if change:
178
  changes.append(change)
179
  return updated, changes
@@ -581,6 +592,104 @@ def _clean_viz_titles(visualizations: List[Dict[str, Any]]) -> str:
581
  return f"Cleaned {cleaned} viz title(s)" if cleaned else ""
582
 
583
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  def _apply_color_by_dimension(visualizations: List[Dict[str, Any]]) -> str:
585
  """Color ADVANCED_* charts by an attribute via custom_chart_config's
586
  'slice-with-color' slot — the new chart engine's real color mechanism
 
23
  logo_url: str | None = None,
24
  company_url: str | None = None,
25
  keep_overview_note: bool = False,
26
+ model_columns: List[Dict[str, Any]] | None = None,
27
  ) -> Dict[str, Any]:
28
  """Return the first-pass dashboard architecture for lab-created liveboards.
29
 
 
39
  "logo_url": logo_url,
40
  "company_url": company_url,
41
  "keep_overview_note": keep_overview_note,
42
+ "model_columns": model_columns,
43
  "executive_tab": executive_tab,
44
  "manager_tab": manager_tab,
45
  "target_viz_count": target_viz_count,
 
157
  visualizations = [viz for viz in visualizations if "note_tile" not in viz]
158
  liveboard["visualizations"] = visualizations
159
 
160
+ # Auto-build a geo map when the model has coordinates and the board has none.
161
+ geo_viz = _maybe_build_geo_viz(data_visualizations, plan.get("model_columns"))
162
+ geo_change = ""
163
+ if geo_viz is not None:
164
+ data_visualizations.append(geo_viz)
165
+ visualizations.append(geo_viz)
166
+ liveboard["visualizations"] = visualizations
167
+ geo_change = f"Built geo map: {geo_viz['answer']['name']}"
168
+
169
  kpi_changes = _convert_periodic_metric_charts(data_visualizations)
170
  chart_changes = _reduce_heatmaps_and_apply_palettes(data_visualizations)
171
  mode_changes = _force_chart_mode(data_visualizations)
 
184
  f"Applied golden two-tab layout ({len(groups)} groups)",
185
  f"Tabs: {_executive_tab_name(plan)}, {_manager_tab_name(plan)}",
186
  ]
187
+ for change in (geo_change, kpi_changes, chart_changes, mode_changes, color_changes, title_changes):
188
  if change:
189
  changes.append(change)
190
  return updated, changes
 
592
  return f"Cleaned {cleaned} viz title(s)" if cleaned else ""
593
 
594
 
595
+ _LAT_RE = re.compile(r"\blat(itude)?\b", re.IGNORECASE)
596
+ _LON_RE = re.compile(r"\b(lon(gitude)?|lng)\b", re.IGNORECASE)
597
+ _GEO_DIM_HINTS = (
598
+ "city", "venue", "location", "region", "country", "state", "province",
599
+ "market", "branch", "store", "site", "territory", "metro", "county",
600
+ )
601
+
602
+
603
+ def detect_geo_columns(model_columns: List[Dict[str, Any]]) -> Dict[str, str] | None:
604
+ """From a model's columns [{name, type}], find lat/lon + a geo attribute + a
605
+ measure. Returns {lat, lon, geodim, measure} or None if not geographic."""
606
+ names = [(c.get("name", ""), str(c.get("type", "")).upper()) for c in (model_columns or []) if c.get("name")]
607
+ lat = next((n for n, _ in names if _LAT_RE.search(n)), None)
608
+ lon = next((n for n, _ in names if _LON_RE.search(n)), None)
609
+ if not (lat and lon):
610
+ return None
611
+ attrs = [n for n, t in names if t == "ATTRIBUTE"]
612
+ lat_prefix = _LAT_RE.sub("", lat).strip() # e.g. "Venue Lat" -> "Venue"
613
+ geodim = None
614
+ if lat_prefix:
615
+ geodim = next((n for n in attrs if n.lower().startswith(lat_prefix.lower())), None)
616
+ if not geodim:
617
+ geodim = next((n for n in attrs if any(h in n.lower() for h in _GEO_DIM_HINTS)), None)
618
+ if not geodim:
619
+ geodim = attrs[0] if attrs else None
620
+ measures = [n for n, t in names if t == "MEASURE" and not _LAT_RE.search(n) and not _LON_RE.search(n)]
621
+ measure = next(
622
+ (n for n in measures if any(k in n.lower() for k in ("revenue", "sales", "amount", "value", "gmv", "spend"))),
623
+ measures[0] if measures else None,
624
+ )
625
+ if not (geodim and measure):
626
+ return None
627
+ return {"lat": lat, "lon": lon, "geodim": geodim, "measure": measure}
628
+
629
+
630
+ def _geo_client_state(lat_col: str, lon_col: str, geodim: str, measure_col: str) -> str:
631
+ state = {
632
+ "version": "V4DOT2",
633
+ "chartProperties": {
634
+ "gridLines": {},
635
+ "responsiveLayoutPreference": "USER_PREFERRED_ON",
636
+ "chartSpecific": {"dataFieldArea": "column"},
637
+ },
638
+ "columnProperties": [{"columnId": c, "columnProperty": {}} for c in (geodim, lat_col, lon_col, measure_col)],
639
+ "axisProperties": [
640
+ {"id": "geo-y", "properties": {"axisType": "Y", "linkedColumns": [lat_col], "isOpposite": False}},
641
+ {"id": "geo-x", "properties": {"axisType": "X", "linkedColumns": [geodim]}},
642
+ ],
643
+ "systemMultiColorSeriesColors": [
644
+ {"serieName": lat_col, "colorMap": [{"serieName": geodim,
645
+ "color": ["#ffffb2", "#fddd87", "#fba35d", "#f75534", "#f9140a", "#d70315", "#b10026"]}]}
646
+ ],
647
+ }
648
+ return json_dumps_compact(state)
649
+
650
+
651
+ def _build_geo_viz(sibling_answer: Dict[str, Any], geo: Dict[str, str], viz_id: str = "Viz_Geo") -> Dict[str, Any]:
652
+ lat, lon, geodim, measure = geo["lat"], geo["lon"], geo["geodim"], geo["measure"]
653
+ t_lat, t_lon, t_meas = f"Total {lat}", f"Total {lon}", f"Total {measure}"
654
+ return {
655
+ "id": viz_id,
656
+ "answer": {
657
+ "name": f"{measure} by {geodim}",
658
+ "tables": deepcopy(sibling_answer.get("tables", [])),
659
+ "search_query": f"[{lat}] [{lon}] sum [{measure}] [{geodim}]",
660
+ "answer_columns": [{"name": t_meas}, {"name": t_lat}, {"name": t_lon}, {"name": geodim}],
661
+ "chart": {
662
+ "type": "GEO_BUBBLE",
663
+ "chart_columns": [{"column_id": t_meas}, {"column_id": t_lat}, {"column_id": t_lon}, {"column_id": geodim}],
664
+ "axis_configs": [{"x": [geodim], "y": [t_lat]}],
665
+ "client_state": "",
666
+ "client_state_v2": _geo_client_state(t_lat, t_lon, geodim, t_meas),
667
+ },
668
+ "display_mode": "CHART_MODE",
669
+ },
670
+ }
671
+
672
+
673
+ def _maybe_build_geo_viz(
674
+ visualizations: List[Dict[str, Any]],
675
+ model_columns: List[Dict[str, Any]] | None,
676
+ ) -> Dict[str, Any] | None:
677
+ """Synthesize a GEO_BUBBLE when the model has lat/lon and the board has none."""
678
+ if not model_columns:
679
+ return None
680
+ if any(str(v.get("answer", {}).get("chart", {}).get("type", "")).startswith("GEO")
681
+ for v in visualizations if "note_tile" not in v):
682
+ return None
683
+ geo = detect_geo_columns(model_columns)
684
+ if not geo:
685
+ return None
686
+ sibling = next((v.get("answer", {}) for v in visualizations
687
+ if "note_tile" not in v and v.get("answer", {}).get("tables")), None)
688
+ if not sibling:
689
+ return None
690
+ return _build_geo_viz(sibling, geo)
691
+
692
+
693
  def _apply_color_by_dimension(visualizations: List[Dict[str, Any]]) -> str:
694
  """Color ADVANCED_* charts by an attribute via custom_chart_config's
695
  'slice-with-color' slot — the new chart engine's real color mechanism
liveboard_creator.py CHANGED
@@ -4080,6 +4080,50 @@ def create_liveboard_from_model_mcp(
4080
  return {'success': False, 'error': err}
4081
 
4082
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4083
  def enhance_mcp_liveboard(
4084
  liveboard_guid: str,
4085
  company_data: Dict,
@@ -4550,12 +4594,14 @@ def enhance_mcp_liveboard(
4550
  build_dashboard_plan,
4551
  )
4552
 
 
4553
  plan = build_dashboard_plan(
4554
  company_name=company_data.get('name', 'Company'),
4555
  use_case=company_data.get('use_case') or company_data.get('use_case_name') or 'Analytics',
4556
  target_viz_count=len(visualizations),
4557
  logo_url=company_data.get('logo_url'),
4558
  company_url=company_data.get('url') or company_data.get('company_url') or company_data.get('website'),
 
4559
  )
4560
  liveboard_tml, golden_changes = apply_golden_two_tab_layout(liveboard_tml, plan)
4561
  enhancements_applied.extend(golden_changes)
 
4080
  return {'success': False, 'error': err}
4081
 
4082
 
4083
+ def _fetch_model_columns(ts_client, liveboard_guid: str) -> List[Dict]:
4084
+ """Export the board (with fqn) -> resolve its model -> return the model's
4085
+ columns as [{name, type}]. Best-effort: returns [] on any failure, so the
4086
+ geo auto-build simply doesn't fire rather than breaking liveboard creation.
4087
+ """
4088
+ try:
4089
+ yaml.SafeLoader.add_constructor(
4090
+ "tag:yaml.org,2002:value", lambda loader, node: loader.construct_scalar(node)
4091
+ )
4092
+ export = ts_client.session.post(
4093
+ f"{ts_client.base_url}/api/rest/2.0/metadata/tml/export",
4094
+ json={"metadata": [{"identifier": liveboard_guid}], "export_fqn": True, "export_associated": False},
4095
+ timeout=60,
4096
+ )
4097
+ if export.status_code != 200:
4098
+ return []
4099
+ board = yaml.safe_load(export.json()[0].get("edoc", "")) or {}
4100
+ model_fqn = None
4101
+ for viz in board.get("liveboard", {}).get("visualizations", []):
4102
+ for tbl in (viz.get("answer", {}).get("tables") or []):
4103
+ if tbl.get("fqn"):
4104
+ model_fqn = tbl["fqn"]
4105
+ break
4106
+ if model_fqn:
4107
+ break
4108
+ if not model_fqn:
4109
+ return []
4110
+ m_export = ts_client.session.post(
4111
+ f"{ts_client.base_url}/api/rest/2.0/metadata/tml/export",
4112
+ json={"metadata": [{"identifier": model_fqn}], "export_associated": False},
4113
+ timeout=60,
4114
+ )
4115
+ if m_export.status_code != 200:
4116
+ return []
4117
+ model_root = (yaml.safe_load(m_export.json()[0].get("edoc", "")) or {}).get("model") or {}
4118
+ return [
4119
+ {"name": c.get("name"), "type": (c.get("properties") or {}).get("column_type")}
4120
+ for c in model_root.get("columns", []) if c.get("name")
4121
+ ]
4122
+ except Exception as exc:
4123
+ print(f" [geo] model-column fetch failed; skipping geo auto-build: {exc}", flush=True)
4124
+ return []
4125
+
4126
+
4127
  def enhance_mcp_liveboard(
4128
  liveboard_guid: str,
4129
  company_data: Dict,
 
4594
  build_dashboard_plan,
4595
  )
4596
 
4597
+ model_columns = _fetch_model_columns(ts_client, liveboard_guid)
4598
  plan = build_dashboard_plan(
4599
  company_name=company_data.get('name', 'Company'),
4600
  use_case=company_data.get('use_case') or company_data.get('use_case_name') or 'Analytics',
4601
  target_viz_count=len(visualizations),
4602
  logo_url=company_data.get('logo_url'),
4603
  company_url=company_data.get('url') or company_data.get('company_url') or company_data.get('website'),
4604
+ model_columns=model_columns,
4605
  )
4606
  liveboard_tml, golden_changes = apply_golden_two_tab_layout(liveboard_tml, plan)
4607
  enhancements_applied.extend(golden_changes)