Spaces:
Running
liveboard: auto-build a geo map from model lat/lon (Path B)
Browse filesWhen 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 +110 -1
- liveboard_creator.py +46 -0
|
@@ -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
|
|
@@ -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)
|