Spaces:
Running
Running
mike boone commited on
Commit ·
ac5b642
1
Parent(s): b25f47f
feat: add liveboard blueprint planning
Browse files- demoprep_app/liveboards/__init__.py +2 -0
- demoprep_app/liveboards/blueprint.py +313 -0
- demoprep_app/liveboards/golden_layout.py +695 -0
- docs/liveboard_lab.md +71 -0
- liveboard_creator.py +296 -99
- tests/test_golden_layout.py +186 -0
- tests/test_liveboard_blueprint.py +84 -0
- tools/liveboard_lab.py +241 -0
demoprep_app/liveboards/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Liveboard planning and TML enhancement helpers."""
|
| 2 |
+
|
demoprep_app/liveboards/blueprint.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Liveboard blueprint helpers for filters and demo-runner notes.
|
| 2 |
+
|
| 3 |
+
The blueprint layer is intentionally conservative. It adds a small number of
|
| 4 |
+
business-facing filters and a demo guide tile after MCP has created answers,
|
| 5 |
+
without trying to replace the answer-generation flow.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
from copy import deepcopy
|
| 12 |
+
from typing import Any, Dict, Iterable, List, Tuple
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
DEFAULT_APP_NAME = "DemoPrep"
|
| 16 |
+
DEMO_GUIDE_VIZ_ID = "Viz_99"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def build_liveboard_blueprint(
|
| 20 |
+
company_data: Dict[str, Any],
|
| 21 |
+
use_case: str,
|
| 22 |
+
model_columns: List[Dict[str, Any]] | None = None,
|
| 23 |
+
target_filter_count: int = 3,
|
| 24 |
+
) -> Dict[str, Any]:
|
| 25 |
+
"""Build a small liveboard blueprint from model metadata and story context."""
|
| 26 |
+
model_columns = model_columns or []
|
| 27 |
+
filters = choose_global_filters(model_columns, target_filter_count=target_filter_count)
|
| 28 |
+
return {
|
| 29 |
+
"company_name": company_data.get("name", "Company"),
|
| 30 |
+
"use_case": use_case or company_data.get("use_case", "Analytics"),
|
| 31 |
+
"app_name": company_data.get("app_name", DEFAULT_APP_NAME),
|
| 32 |
+
"filters": filters,
|
| 33 |
+
"demo_notes_tile": build_demo_notes_spec(company_data, use_case, filters),
|
| 34 |
+
"validation_rules": {
|
| 35 |
+
"filter_count_min": 2,
|
| 36 |
+
"filter_count_max": 6,
|
| 37 |
+
"filters_start_unfiltered": True,
|
| 38 |
+
"avoid_technical_filter_fields": True,
|
| 39 |
+
"demo_notes_tile_required": True,
|
| 40 |
+
},
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def choose_global_filters(
|
| 45 |
+
model_columns: List[Dict[str, Any]],
|
| 46 |
+
target_filter_count: int = 3,
|
| 47 |
+
min_filter_count: int = 2,
|
| 48 |
+
max_filter_count: int = 6,
|
| 49 |
+
) -> List[Dict[str, Any]]:
|
| 50 |
+
"""Choose business-friendly, unfiltered global filter chips from columns."""
|
| 51 |
+
target = max(min_filter_count, min(int(target_filter_count or 3), max_filter_count))
|
| 52 |
+
candidates: List[Tuple[int, int, Dict[str, Any]]] = []
|
| 53 |
+
|
| 54 |
+
for index, column in enumerate(model_columns or []):
|
| 55 |
+
raw_name = str(column.get("name") or "").strip()
|
| 56 |
+
if not raw_name or _is_bad_filter_column(raw_name, column):
|
| 57 |
+
continue
|
| 58 |
+
display_name = _filter_display_name(raw_name)
|
| 59 |
+
if not display_name or _is_bad_display_name(display_name):
|
| 60 |
+
continue
|
| 61 |
+
score = _filter_score(raw_name, display_name, column)
|
| 62 |
+
if score <= 0:
|
| 63 |
+
continue
|
| 64 |
+
candidates.append((score, -index, {"column": [raw_name], "is_mandatory": False, "is_single_value": False, "display_name": display_name}))
|
| 65 |
+
|
| 66 |
+
selected: List[Dict[str, Any]] = []
|
| 67 |
+
seen_display: set[str] = set()
|
| 68 |
+
seen_concepts: set[str] = set()
|
| 69 |
+
|
| 70 |
+
for _score, _negative_index, filter_spec in sorted(candidates, reverse=True):
|
| 71 |
+
display = str(filter_spec["display_name"])
|
| 72 |
+
concept = _filter_concept(display)
|
| 73 |
+
if display.lower() in seen_display or concept in seen_concepts:
|
| 74 |
+
continue
|
| 75 |
+
selected.append(filter_spec)
|
| 76 |
+
seen_display.add(display.lower())
|
| 77 |
+
seen_concepts.add(concept)
|
| 78 |
+
if len(selected) >= target:
|
| 79 |
+
break
|
| 80 |
+
|
| 81 |
+
return selected
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def apply_blueprint_to_liveboard_tml(
|
| 85 |
+
liveboard_tml: Dict[str, Any],
|
| 86 |
+
blueprint: Dict[str, Any],
|
| 87 |
+
) -> Tuple[Dict[str, Any], List[str]]:
|
| 88 |
+
"""Apply global filters and a demo guide tile to a liveboard TML dict."""
|
| 89 |
+
updated = deepcopy(liveboard_tml)
|
| 90 |
+
liveboard = updated.setdefault("liveboard", {})
|
| 91 |
+
changes: List[str] = []
|
| 92 |
+
|
| 93 |
+
filters = blueprint.get("filters") or []
|
| 94 |
+
if filters:
|
| 95 |
+
liveboard["filters"] = _merge_filters(liveboard.get("filters", []), filters)
|
| 96 |
+
changes.append(f"Added {len(filters)} global filter(s)")
|
| 97 |
+
|
| 98 |
+
demo_notes = blueprint.get("demo_notes_tile") or {}
|
| 99 |
+
if demo_notes:
|
| 100 |
+
_upsert_demo_guide_tile(liveboard, demo_notes)
|
| 101 |
+
changes.append("Added demo guide tile")
|
| 102 |
+
|
| 103 |
+
return updated, changes
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def build_demo_notes_spec(
|
| 107 |
+
company_data: Dict[str, Any],
|
| 108 |
+
use_case: str,
|
| 109 |
+
filters: List[Dict[str, Any]],
|
| 110 |
+
) -> Dict[str, Any]:
|
| 111 |
+
"""Return content for the demo guide tile."""
|
| 112 |
+
persona = (
|
| 113 |
+
company_data.get("target_persona")
|
| 114 |
+
or company_data.get("persona")
|
| 115 |
+
or "Business leader"
|
| 116 |
+
)
|
| 117 |
+
business_problem = (
|
| 118 |
+
company_data.get("business_problem")
|
| 119 |
+
or company_data.get("research")
|
| 120 |
+
or company_data.get("additional_context")
|
| 121 |
+
or "Use the liveboard to identify changes, drivers, and follow-up actions."
|
| 122 |
+
)
|
| 123 |
+
business_problem = _compact_sentence(str(business_problem), max_chars=220)
|
| 124 |
+
use_case_display = use_case or company_data.get("use_case", "Analytics")
|
| 125 |
+
filter_names = [str(f.get("display_name")) for f in filters if f.get("display_name")]
|
| 126 |
+
|
| 127 |
+
return {
|
| 128 |
+
"id": DEMO_GUIDE_VIZ_ID,
|
| 129 |
+
"title": "Demo Guide",
|
| 130 |
+
"placement": "bottom",
|
| 131 |
+
"html": _demo_notes_html(
|
| 132 |
+
company_name=company_data.get("name", "Company"),
|
| 133 |
+
use_case=use_case_display,
|
| 134 |
+
app_name=company_data.get("app_name", DEFAULT_APP_NAME),
|
| 135 |
+
persona=persona,
|
| 136 |
+
business_problem=business_problem,
|
| 137 |
+
filters=filter_names,
|
| 138 |
+
),
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _demo_notes_html(
|
| 143 |
+
company_name: str,
|
| 144 |
+
use_case: str,
|
| 145 |
+
app_name: str,
|
| 146 |
+
persona: str,
|
| 147 |
+
business_problem: str,
|
| 148 |
+
filters: List[str],
|
| 149 |
+
) -> str:
|
| 150 |
+
filters_text = ", ".join(filters) if filters else "Use dashboard filters only when the story needs narrowing."
|
| 151 |
+
spotter_one = f"Where should {company_name} focus first for {use_case.lower()}?"
|
| 152 |
+
spotter_two = "Which segment, location, or category is driving the biggest change?"
|
| 153 |
+
return f"""<div style="background:#101820; color:#ffffff; border-radius:12px; padding:18px 20px; font-family:Inter, Arial, sans-serif;">
|
| 154 |
+
<div style="font-size:12px; letter-spacing:1.8px; text-transform:uppercase; color:#40c1c0; font-weight:700; margin-bottom:8px;">Demo Guide</div>
|
| 155 |
+
<h3 class="theme-module__editor-h3" style="margin:0 0 10px 0;"><span style="color:#ffffff; white-space:pre-wrap;">{_escape(company_name)}: {_escape(use_case)}</span></h3>
|
| 156 |
+
<p class="theme-module__editor-paragraph" style="margin:0 0 10px 0;"><b><strong style="color:#ffffff;">Audience:</strong></b><span style="color:#d6dde8; white-space:pre-wrap;"> {_escape(str(persona))}</span></p>
|
| 157 |
+
<p class="theme-module__editor-paragraph" style="margin:0 0 10px 0;"><b><strong style="color:#ffffff;">Demo path:</strong></b><span style="color:#d6dde8; white-space:pre-wrap;"> Start with the executive KPIs, explain the primary trend, then use the breakdowns to identify where action is needed.</span></p>
|
| 158 |
+
<p class="theme-module__editor-paragraph" style="margin:0 0 10px 0;"><b><strong style="color:#ffffff;">Filters to try:</strong></b><span style="color:#d6dde8; white-space:pre-wrap;"> {_escape(filters_text)}</span></p>
|
| 159 |
+
<p class="theme-module__editor-paragraph" style="margin:0 0 10px 0;"><b><strong style="color:#ffffff;">Story prompt:</strong></b><span style="color:#d6dde8; white-space:pre-wrap;"> {_escape(business_problem)}</span></p>
|
| 160 |
+
<ul style="margin:8px 0 0 20px; padding:0; color:#d6dde8;">
|
| 161 |
+
<li>{_escape(spotter_one)}</li>
|
| 162 |
+
<li>{_escape(spotter_two)}</li>
|
| 163 |
+
</ul>
|
| 164 |
+
<div style="margin-top:14px; font-size:12px; color:#40c1c0;">Built with {_escape(app_name)}</div>
|
| 165 |
+
</div>"""
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _merge_filters(existing_filters: List[Dict[str, Any]], new_filters: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 169 |
+
merged = list(existing_filters or [])
|
| 170 |
+
seen = {tuple(f.get("column", [])) for f in merged if isinstance(f, dict)}
|
| 171 |
+
for filter_spec in new_filters:
|
| 172 |
+
key = tuple(filter_spec.get("column", []))
|
| 173 |
+
if key in seen:
|
| 174 |
+
continue
|
| 175 |
+
merged.append(filter_spec)
|
| 176 |
+
seen.add(key)
|
| 177 |
+
return merged
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _upsert_demo_guide_tile(liveboard: Dict[str, Any], demo_notes: Dict[str, Any]) -> None:
|
| 181 |
+
visualizations = liveboard.setdefault("visualizations", [])
|
| 182 |
+
demo_id = _resolve_demo_guide_id(visualizations, str(demo_notes.get("id") or DEMO_GUIDE_VIZ_ID))
|
| 183 |
+
html = demo_notes.get("html", "")
|
| 184 |
+
existing = next((viz for viz in visualizations if viz.get("id") == demo_id), None)
|
| 185 |
+
if existing:
|
| 186 |
+
existing["note_tile"] = {"html_parsed_string": html}
|
| 187 |
+
else:
|
| 188 |
+
visualizations.append({"id": demo_id, "note_tile": {"html_parsed_string": html}})
|
| 189 |
+
|
| 190 |
+
_place_demo_tile_at_bottom(liveboard, str(demo_id))
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _resolve_demo_guide_id(visualizations: List[Dict[str, Any]], requested_id: str) -> str:
|
| 194 |
+
for viz in visualizations:
|
| 195 |
+
if viz.get("id") == requested_id:
|
| 196 |
+
return requested_id
|
| 197 |
+
for viz in visualizations:
|
| 198 |
+
note_tile = viz.get("note_tile")
|
| 199 |
+
html = ""
|
| 200 |
+
if isinstance(note_tile, dict):
|
| 201 |
+
html = str(note_tile.get("html_parsed_string") or "")
|
| 202 |
+
elif note_tile:
|
| 203 |
+
html = str(note_tile)
|
| 204 |
+
if "Demo Guide" in html and "Built with DemoPrep" in html:
|
| 205 |
+
return str(viz.get("id"))
|
| 206 |
+
used_ids = {str(viz.get("id")) for viz in visualizations}
|
| 207 |
+
if requested_id.startswith("Viz_") and requested_id not in used_ids and requested_id[4:].isdigit():
|
| 208 |
+
return requested_id
|
| 209 |
+
numeric_ids = []
|
| 210 |
+
for viz_id in used_ids:
|
| 211 |
+
match = re.fullmatch(r"Viz_(\d+)", viz_id)
|
| 212 |
+
if match:
|
| 213 |
+
numeric_ids.append(int(match.group(1)))
|
| 214 |
+
next_id = max(numeric_ids or [0]) + 1
|
| 215 |
+
while f"Viz_{next_id}" in used_ids:
|
| 216 |
+
next_id += 1
|
| 217 |
+
return f"Viz_{next_id}"
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _place_demo_tile_at_bottom(liveboard: Dict[str, Any], demo_id: str) -> None:
|
| 221 |
+
tabs = liveboard.setdefault("layout", {}).setdefault("tabs", [])
|
| 222 |
+
if not tabs:
|
| 223 |
+
tabs.append({"name": "Executive Overview", "tiles": []})
|
| 224 |
+
target_tab = tabs[-1]
|
| 225 |
+
tiles = target_tab.setdefault("tiles", [])
|
| 226 |
+
tiles[:] = [tile for tile in tiles if tile.get("visualization_id") != demo_id]
|
| 227 |
+
y = _next_tile_y(tiles)
|
| 228 |
+
tiles.append({"visualization_id": demo_id, "x": 0, "y": y, "height": 7, "width": 12})
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _next_tile_y(tiles: Iterable[Dict[str, Any]]) -> int:
|
| 232 |
+
max_y = 0
|
| 233 |
+
for tile in tiles:
|
| 234 |
+
try:
|
| 235 |
+
max_y = max(max_y, int(tile.get("y", 0)) + int(tile.get("height", 0)))
|
| 236 |
+
except (TypeError, ValueError):
|
| 237 |
+
continue
|
| 238 |
+
return max_y
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _filter_score(raw_name: str, display_name: str, column: Dict[str, Any]) -> int:
|
| 242 |
+
low = raw_name.lower()
|
| 243 |
+
display_low = display_name.lower()
|
| 244 |
+
score = 0
|
| 245 |
+
if any(token in low for token in ("program", "campus", "segment", "category", "scenario", "region", "state", "channel", "term")):
|
| 246 |
+
score += 50
|
| 247 |
+
if any(token in low for token in ("product", "customer", "store", "supplier", "provider", "department", "market")):
|
| 248 |
+
score += 35
|
| 249 |
+
if "name" in low:
|
| 250 |
+
score += 10
|
| 251 |
+
if display_low in {"program", "campus", "student segment", "scenario", "term", "region", "category", "channel"}:
|
| 252 |
+
score += 20
|
| 253 |
+
if any(token in low for token in ("date", "month start", "full date")):
|
| 254 |
+
score += 15
|
| 255 |
+
return score
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _is_bad_filter_column(raw_name: str, column: Dict[str, Any]) -> bool:
|
| 259 |
+
low = raw_name.lower()
|
| 260 |
+
column_type = str(column.get("type") or column.get("column_type") or "").upper()
|
| 261 |
+
if any(token in low for token in (" key", "_key", " id", "_id", "guid", "uuid", "number")):
|
| 262 |
+
return True
|
| 263 |
+
if any(token in low for token in ("pct", "rate", "amount", "revenue", "sales", "units", "count", "volume", "value", "students", "applications", "admissions", "enrollments")):
|
| 264 |
+
return True
|
| 265 |
+
if column_type in {"MEASURE", "NUMBER", "INT", "INTEGER", "DOUBLE", "FLOAT", "DECIMAL"}:
|
| 266 |
+
return True
|
| 267 |
+
return False
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _is_bad_display_name(display_name: str) -> bool:
|
| 271 |
+
low = display_name.lower()
|
| 272 |
+
return low in {"name", "date key", "month key"} or len(display_name) > 32
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _filter_display_name(raw_name: str) -> str:
|
| 276 |
+
name = re.sub(r"^(Prog|Camp|Stud|Term|Enro|Regi|Prod|Supp|Ware)\s+", "", raw_name).strip()
|
| 277 |
+
name = re.sub(r"\s+Name$", "", name).strip()
|
| 278 |
+
name = re.sub(r"\s+Category$", " Category", name).strip()
|
| 279 |
+
replacements = {
|
| 280 |
+
"Student Segment": "Student Segment",
|
| 281 |
+
"Scenario Category": "Scenario",
|
| 282 |
+
"Month Start Date": "Month",
|
| 283 |
+
"Full Date": "Date",
|
| 284 |
+
"Program": "Program",
|
| 285 |
+
"Campus": "Campus",
|
| 286 |
+
"Term": "Term",
|
| 287 |
+
}
|
| 288 |
+
return replacements.get(name, name)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def _filter_concept(display_name: str) -> str:
|
| 292 |
+
low = display_name.lower()
|
| 293 |
+
for concept in ("program", "campus", "student segment", "segment", "scenario", "term", "region", "category", "channel", "date", "month"):
|
| 294 |
+
if concept in low:
|
| 295 |
+
return concept
|
| 296 |
+
return low
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def _compact_sentence(value: str, max_chars: int = 220) -> str:
|
| 300 |
+
text = " ".join(value.split())
|
| 301 |
+
if len(text) <= max_chars:
|
| 302 |
+
return text
|
| 303 |
+
return text[: max_chars - 1].rsplit(" ", 1)[0] + "."
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def _escape(value: str) -> str:
|
| 307 |
+
return (
|
| 308 |
+
str(value)
|
| 309 |
+
.replace("&", "&")
|
| 310 |
+
.replace("<", "<")
|
| 311 |
+
.replace(">", ">")
|
| 312 |
+
.replace('"', """)
|
| 313 |
+
)
|
demoprep_app/liveboards/golden_layout.py
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Golden-demo-inspired liveboard planning and TML layout helpers.
|
| 2 |
+
|
| 3 |
+
These helpers are intentionally pure: they operate on TML dictionaries and do
|
| 4 |
+
not call ThoughtSpot. That keeps the liveboard lab fast to test while the
|
| 5 |
+
existing MCP-first creation path remains the source of liveboard content.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from copy import deepcopy
|
| 11 |
+
from typing import Any, Dict, Iterable, List, Tuple
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
EXECUTIVE_TAB = "Executive Overview"
|
| 15 |
+
OPERATIONS_TAB = "Manager Drilldown"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def build_dashboard_plan(
|
| 19 |
+
company_name: str,
|
| 20 |
+
use_case: str,
|
| 21 |
+
target_viz_count: int = 16,
|
| 22 |
+
) -> Dict[str, Any]:
|
| 23 |
+
"""Return the first-pass dashboard architecture for lab-created liveboards."""
|
| 24 |
+
target_viz_count = max(8, min(int(target_viz_count or 16), 24))
|
| 25 |
+
kpi_count = 4 if target_viz_count >= 14 else 3
|
| 26 |
+
executive_tab, manager_tab = _tab_names_for_use_case(use_case)
|
| 27 |
+
return {
|
| 28 |
+
"company_name": company_name,
|
| 29 |
+
"use_case": use_case,
|
| 30 |
+
"executive_tab": executive_tab,
|
| 31 |
+
"manager_tab": manager_tab,
|
| 32 |
+
"target_viz_count": target_viz_count,
|
| 33 |
+
"audiences": [
|
| 34 |
+
{
|
| 35 |
+
"name": "Executive",
|
| 36 |
+
"tab": executive_tab,
|
| 37 |
+
"intent": "CEO/CRO/CFO-level summary of performance, changes, and risks.",
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"name": "Manager",
|
| 41 |
+
"tab": manager_tab,
|
| 42 |
+
"intent": "Operator-level breakdowns for action, ownership, and follow-up.",
|
| 43 |
+
},
|
| 44 |
+
],
|
| 45 |
+
"sections": [
|
| 46 |
+
{
|
| 47 |
+
"name": "Key Performance Metrics",
|
| 48 |
+
"tab": executive_tab,
|
| 49 |
+
"role": "kpi",
|
| 50 |
+
"target_count": kpi_count,
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"name": "Performance Trends",
|
| 54 |
+
"tab": executive_tab,
|
| 55 |
+
"role": "trend",
|
| 56 |
+
"target_count": 2,
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"name": "Performance Drivers",
|
| 60 |
+
"tab": executive_tab,
|
| 61 |
+
"role": "driver",
|
| 62 |
+
"target_count": 2,
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"name": _manager_breakdown_name(use_case),
|
| 66 |
+
"tab": manager_tab,
|
| 67 |
+
"role": "breakdown",
|
| 68 |
+
"target_count": 4,
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"name": "Top Opportunities",
|
| 72 |
+
"tab": manager_tab,
|
| 73 |
+
"role": "ranking",
|
| 74 |
+
"target_count": 3,
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"name": "Detail Review",
|
| 78 |
+
"tab": manager_tab,
|
| 79 |
+
"role": "detail",
|
| 80 |
+
"target_count": 2,
|
| 81 |
+
},
|
| 82 |
+
],
|
| 83 |
+
"style": {
|
| 84 |
+
"border": "CURVED",
|
| 85 |
+
"brand_color": "LBC_A",
|
| 86 |
+
"group_colors": ["GBC_C", "GBC_A", "GBC_C", "GBC_A", "GBC_C", "GBC_A"],
|
| 87 |
+
"highlight_kpis": True,
|
| 88 |
+
},
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def summarize_liveboard_tml(liveboard_tml: Dict[str, Any]) -> Dict[str, Any]:
|
| 93 |
+
"""Produce a compact, test-friendly summary of liveboard structure."""
|
| 94 |
+
liveboard = liveboard_tml.get("liveboard", {})
|
| 95 |
+
visualizations = liveboard.get("visualizations", [])
|
| 96 |
+
groups = liveboard.get("groups", [])
|
| 97 |
+
tabs = liveboard.get("layout", {}).get("tabs", [])
|
| 98 |
+
chart_types: Dict[str, int] = {}
|
| 99 |
+
for viz in visualizations:
|
| 100 |
+
chart_type = _chart_type(viz)
|
| 101 |
+
chart_types[chart_type] = chart_types.get(chart_type, 0) + 1
|
| 102 |
+
return {
|
| 103 |
+
"name": liveboard.get("name", ""),
|
| 104 |
+
"visualization_count": len(visualizations),
|
| 105 |
+
"group_count": len(groups),
|
| 106 |
+
"tab_names": [tab.get("name", "") for tab in tabs],
|
| 107 |
+
"chart_types": chart_types,
|
| 108 |
+
"group_names": [group.get("name", "") for group in groups],
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def apply_golden_two_tab_layout(
|
| 113 |
+
liveboard_tml: Dict[str, Any],
|
| 114 |
+
plan: Dict[str, Any] | None = None,
|
| 115 |
+
) -> Tuple[Dict[str, Any], List[str]]:
|
| 116 |
+
"""Return a copy of TML with two tabs, thematic groups, and golden styling."""
|
| 117 |
+
updated = deepcopy(liveboard_tml)
|
| 118 |
+
liveboard = updated.setdefault("liveboard", {})
|
| 119 |
+
plan = plan or build_dashboard_plan(liveboard.get("name", "Company"), "Analytics")
|
| 120 |
+
|
| 121 |
+
visualizations = list(liveboard.get("visualizations", []))
|
| 122 |
+
note_viz = next((viz for viz in visualizations if "note_tile" in viz), None)
|
| 123 |
+
data_visualizations = [viz for viz in visualizations if "note_tile" not in viz]
|
| 124 |
+
liveboard["visualizations"] = visualizations
|
| 125 |
+
|
| 126 |
+
kpi_changes = _convert_periodic_metric_charts(data_visualizations)
|
| 127 |
+
chart_changes = _reduce_heatmaps_and_apply_palettes(data_visualizations)
|
| 128 |
+
groups = _build_groups(data_visualizations, plan)
|
| 129 |
+
if note_viz:
|
| 130 |
+
_apply_overview_note(note_viz, plan, groups)
|
| 131 |
+
liveboard["groups"] = groups
|
| 132 |
+
note_viz_id = note_viz.get("id") if note_viz else None
|
| 133 |
+
liveboard["layout"] = {"tabs": _build_tabs(groups, note_viz_id=note_viz_id, plan=plan)}
|
| 134 |
+
_apply_style(liveboard, groups, plan, note_viz_id=note_viz_id)
|
| 135 |
+
|
| 136 |
+
changes = [
|
| 137 |
+
f"Applied golden two-tab layout ({len(groups)} groups)",
|
| 138 |
+
f"Tabs: {_executive_tab_name(plan)}, {_manager_tab_name(plan)}",
|
| 139 |
+
]
|
| 140 |
+
for change in (kpi_changes, chart_changes):
|
| 141 |
+
if change:
|
| 142 |
+
changes.append(change)
|
| 143 |
+
return updated, changes
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _build_groups(visualizations: List[Dict[str, Any]], plan: Dict[str, Any]) -> List[Dict[str, Any]]:
|
| 147 |
+
buckets = {
|
| 148 |
+
"kpi": [],
|
| 149 |
+
"trend": [],
|
| 150 |
+
"driver": [],
|
| 151 |
+
"breakdown": [],
|
| 152 |
+
"ranking": [],
|
| 153 |
+
"detail": [],
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
for viz in visualizations:
|
| 157 |
+
buckets[_role_for_viz(viz)].append(viz.get("id", ""))
|
| 158 |
+
|
| 159 |
+
# Keep non-empty ids only, preserving exported viz order.
|
| 160 |
+
for role, ids in buckets.items():
|
| 161 |
+
buckets[role] = [viz_id for viz_id in ids if viz_id]
|
| 162 |
+
|
| 163 |
+
overflow = []
|
| 164 |
+
assigned = set()
|
| 165 |
+
for role in ("kpi", "trend", "driver", "breakdown", "ranking", "detail"):
|
| 166 |
+
assigned.update(buckets[role])
|
| 167 |
+
for viz in visualizations:
|
| 168 |
+
viz_id = viz.get("id", "")
|
| 169 |
+
if viz_id and viz_id not in assigned:
|
| 170 |
+
overflow.append(viz_id)
|
| 171 |
+
buckets["breakdown"].extend(overflow)
|
| 172 |
+
|
| 173 |
+
groups = []
|
| 174 |
+
for section in plan.get("sections", []):
|
| 175 |
+
role = section.get("role", "")
|
| 176 |
+
ids = buckets.get(role, [])
|
| 177 |
+
if not ids:
|
| 178 |
+
continue
|
| 179 |
+
target_count = max(1, int(section.get("target_count") or 4))
|
| 180 |
+
for chunk_index, chunk in enumerate(_chunks(ids, target_count)):
|
| 181 |
+
section_name = section.get("name", role.title())
|
| 182 |
+
groups.append(
|
| 183 |
+
{
|
| 184 |
+
"id": f"Group_{len(groups) + 1}",
|
| 185 |
+
"name": _chunked_group_name(section_name, chunk_index),
|
| 186 |
+
"description": _section_description(section_name, section.get("tab", "")),
|
| 187 |
+
"tab": section.get("tab", ""),
|
| 188 |
+
"visualizations": chunk,
|
| 189 |
+
}
|
| 190 |
+
)
|
| 191 |
+
return groups
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _build_tabs(
|
| 195 |
+
groups: List[Dict[str, Any]],
|
| 196 |
+
note_viz_id: str | None = None,
|
| 197 |
+
plan: Dict[str, Any] | None = None,
|
| 198 |
+
) -> List[Dict[str, Any]]:
|
| 199 |
+
plan = plan or {}
|
| 200 |
+
executive_tab = _executive_tab_name(plan)
|
| 201 |
+
manager_tab = _manager_tab_name(plan)
|
| 202 |
+
executive_groups = [group for group in groups if group.get("tab") == executive_tab]
|
| 203 |
+
operations_groups = [group for group in groups if group.get("tab") == manager_tab]
|
| 204 |
+
|
| 205 |
+
if not executive_groups:
|
| 206 |
+
executive_groups = groups[:1]
|
| 207 |
+
operations_groups = groups[1:]
|
| 208 |
+
|
| 209 |
+
tabs = []
|
| 210 |
+
tabs.append(
|
| 211 |
+
{
|
| 212 |
+
"name": executive_tab,
|
| 213 |
+
"description": "Executive-ready performance summary and key drivers",
|
| 214 |
+
"tiles": _top_level_tiles(executive_groups, note_viz_id=note_viz_id),
|
| 215 |
+
"group_layouts": [_group_layout(group) for group in executive_groups],
|
| 216 |
+
}
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
if operations_groups:
|
| 220 |
+
tabs.append(
|
| 221 |
+
{
|
| 222 |
+
"name": manager_tab,
|
| 223 |
+
"description": "Manager-level breakdowns, rankings, and detail",
|
| 224 |
+
"tiles": _top_level_tiles(operations_groups),
|
| 225 |
+
"group_layouts": [_group_layout(group) for group in operations_groups],
|
| 226 |
+
}
|
| 227 |
+
)
|
| 228 |
+
return tabs
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _top_level_tiles(
|
| 232 |
+
groups: List[Dict[str, Any]],
|
| 233 |
+
note_viz_id: str | None = None,
|
| 234 |
+
) -> List[Dict[str, int | str]]:
|
| 235 |
+
tiles: List[Dict[str, int | str]] = []
|
| 236 |
+
y = 0
|
| 237 |
+
for index, group in enumerate(groups):
|
| 238 |
+
viz_count = len(group.get("visualizations", []))
|
| 239 |
+
is_kpi = index == 0 and "Metric" in group.get("name", "")
|
| 240 |
+
if is_kpi:
|
| 241 |
+
height = 7
|
| 242 |
+
width = 9 if note_viz_id else 12
|
| 243 |
+
x = 3 if note_viz_id else 0
|
| 244 |
+
if note_viz_id:
|
| 245 |
+
tiles.append(
|
| 246 |
+
{
|
| 247 |
+
"visualization_id": note_viz_id,
|
| 248 |
+
"x": 0,
|
| 249 |
+
"y": 0,
|
| 250 |
+
"height": 7,
|
| 251 |
+
"width": 3,
|
| 252 |
+
}
|
| 253 |
+
)
|
| 254 |
+
else:
|
| 255 |
+
height = _group_tile_height(viz_count)
|
| 256 |
+
width = 6 if len(groups) > 2 and index > 0 else 12
|
| 257 |
+
x = 0 if index % 2 else 6
|
| 258 |
+
if width == 12:
|
| 259 |
+
x = 0
|
| 260 |
+
if index == 1:
|
| 261 |
+
y = 7
|
| 262 |
+
elif index > 1 and index % 2 == 1:
|
| 263 |
+
y += max(8, height)
|
| 264 |
+
tiles.append(
|
| 265 |
+
{
|
| 266 |
+
"visualization_id": group.get("id", ""),
|
| 267 |
+
"x": x,
|
| 268 |
+
"y": y,
|
| 269 |
+
"height": height,
|
| 270 |
+
"width": width,
|
| 271 |
+
}
|
| 272 |
+
)
|
| 273 |
+
if width == 12:
|
| 274 |
+
y += height
|
| 275 |
+
return tiles
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _group_layout(group: Dict[str, Any]) -> Dict[str, Any]:
|
| 279 |
+
viz_ids = group.get("visualizations", [])
|
| 280 |
+
is_kpi = "Metric" in group.get("name", "")
|
| 281 |
+
tiles = []
|
| 282 |
+
for index, viz_id in enumerate(viz_ids):
|
| 283 |
+
if is_kpi:
|
| 284 |
+
if len(viz_ids) <= 2:
|
| 285 |
+
width = 6
|
| 286 |
+
elif len(viz_ids) == 3:
|
| 287 |
+
width = 4
|
| 288 |
+
else:
|
| 289 |
+
width = 3
|
| 290 |
+
columns = max(1, 12 // width)
|
| 291 |
+
tiles.append(
|
| 292 |
+
{
|
| 293 |
+
"visualization_id": viz_id,
|
| 294 |
+
"x": (index % columns) * width,
|
| 295 |
+
"y": (index // columns) * 7,
|
| 296 |
+
"height": 7,
|
| 297 |
+
"width": width,
|
| 298 |
+
}
|
| 299 |
+
)
|
| 300 |
+
else:
|
| 301 |
+
tiles.append(
|
| 302 |
+
{
|
| 303 |
+
"visualization_id": viz_id,
|
| 304 |
+
"x": 0 if index % 2 == 0 else 6,
|
| 305 |
+
"y": (index // 2) * 8,
|
| 306 |
+
"height": 8,
|
| 307 |
+
"width": 6,
|
| 308 |
+
}
|
| 309 |
+
)
|
| 310 |
+
return {"id": group.get("id", ""), "tiles": tiles}
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def _group_tile_height(viz_count: int) -> int:
|
| 314 |
+
if viz_count <= 1:
|
| 315 |
+
return 8
|
| 316 |
+
rows = (viz_count + 1) // 2
|
| 317 |
+
return max(8, rows * 8)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def _apply_style(
|
| 321 |
+
liveboard: Dict[str, Any],
|
| 322 |
+
groups: List[Dict[str, Any]],
|
| 323 |
+
plan: Dict[str, Any],
|
| 324 |
+
note_viz_id: str | None = None,
|
| 325 |
+
) -> None:
|
| 326 |
+
style_config = plan.get("style", {})
|
| 327 |
+
style = liveboard.setdefault("style", {})
|
| 328 |
+
style["style_properties"] = _merge_style_properties(
|
| 329 |
+
style.get("style_properties", []),
|
| 330 |
+
{
|
| 331 |
+
"lb_border_type": style_config.get("border", "CURVED"),
|
| 332 |
+
"lb_brand_color": style_config.get("brand_color", "LBC_A"),
|
| 333 |
+
"hide_group_title": "false",
|
| 334 |
+
"hide_group_description": "false",
|
| 335 |
+
"hide_group_tile_description": "false",
|
| 336 |
+
"hide_tile_description": "false",
|
| 337 |
+
"kpi_hero_font_size": "M",
|
| 338 |
+
},
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
overrides = list(style.get("overrides", []))
|
| 342 |
+
current_group_ids = {group.get("id") for group in groups if group.get("id")}
|
| 343 |
+
overrides = [
|
| 344 |
+
override for override in overrides
|
| 345 |
+
if not str(override.get("object_id", "")).startswith("Group_")
|
| 346 |
+
or override.get("object_id") in current_group_ids
|
| 347 |
+
]
|
| 348 |
+
if note_viz_id:
|
| 349 |
+
overrides = [
|
| 350 |
+
override for override in overrides
|
| 351 |
+
if override.get("object_id") != note_viz_id
|
| 352 |
+
]
|
| 353 |
+
group_colors = style_config.get("group_colors", ["GBC_C", "GBC_A"])
|
| 354 |
+
for index, group in enumerate(groups):
|
| 355 |
+
overrides = _upsert_override(
|
| 356 |
+
overrides,
|
| 357 |
+
group.get("id", ""),
|
| 358 |
+
{"group_brand_color": group_colors[index % len(group_colors)]},
|
| 359 |
+
)
|
| 360 |
+
if index == 0 and style_config.get("highlight_kpis", True):
|
| 361 |
+
for viz_id in group.get("visualizations", []):
|
| 362 |
+
overrides = _upsert_override(overrides, viz_id, {"is_highlighted": "true"})
|
| 363 |
+
style["overrides"] = overrides
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def _apply_overview_note(
|
| 367 |
+
note_viz: Dict[str, Any],
|
| 368 |
+
plan: Dict[str, Any],
|
| 369 |
+
groups: List[Dict[str, Any]],
|
| 370 |
+
) -> None:
|
| 371 |
+
company_name = plan.get("company_name") or "Company"
|
| 372 |
+
use_case = plan.get("use_case") or "Analytics"
|
| 373 |
+
initials = "".join(part[0] for part in company_name.replace("-", " ").split()[:2]).upper()
|
| 374 |
+
bullets = [
|
| 375 |
+
"Executive KPIs with trend context",
|
| 376 |
+
f"{len(groups)} guided analysis sections",
|
| 377 |
+
"Operational drivers for manager follow-up",
|
| 378 |
+
]
|
| 379 |
+
html = f"""<h1 class="theme-module__editor-h1"><span style="color: rgb(46, 117, 240); white-space: pre-wrap;">{initials}</span></h1>
|
| 380 |
+
<p class="theme-module__editor-paragraph"><br></p>
|
| 381 |
+
<h2 class="theme-module__editor-h2"><span style="color: rgb(240, 65, 82); white-space: pre-wrap;">INSIGHT OVERVIEW</span></h2>
|
| 382 |
+
<p class="theme-module__editor-paragraph"><span style="white-space: pre-wrap;">{company_name} {use_case} review for executive and manager audiences.</span></p>
|
| 383 |
+
<ul class="theme-module__editor-ul">
|
| 384 |
+
<li class="theme-module__editor-list-item"><span style="white-space: pre-wrap;">{bullets[0]}</span></li>
|
| 385 |
+
<li class="theme-module__editor-list-item"><span style="white-space: pre-wrap;">{bullets[1]}</span></li>
|
| 386 |
+
<li class="theme-module__editor-list-item"><span style="white-space: pre-wrap;">{bullets[2]}</span></li>
|
| 387 |
+
</ul>"""
|
| 388 |
+
note_tile = note_viz.setdefault("note_tile", {})
|
| 389 |
+
if isinstance(note_tile, dict):
|
| 390 |
+
note_tile["html_parsed_string"] = html
|
| 391 |
+
else:
|
| 392 |
+
note_viz["note_tile"] = {"html_parsed_string": html}
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def _reduce_heatmaps_and_apply_palettes(visualizations: List[Dict[str, Any]]) -> str:
|
| 396 |
+
"""Keep heatmaps rare and add varied chart colors to avoid all-blue boards."""
|
| 397 |
+
heatmaps_seen = 0
|
| 398 |
+
heatmaps_converted = 0
|
| 399 |
+
palettes_applied = 0
|
| 400 |
+
|
| 401 |
+
for index, viz in enumerate(visualizations):
|
| 402 |
+
answer = viz.get("answer", {})
|
| 403 |
+
chart = answer.get("chart", {})
|
| 404 |
+
chart_type = _chart_type(viz)
|
| 405 |
+
|
| 406 |
+
if chart_type == "HEATMAP":
|
| 407 |
+
heatmaps_seen += 1
|
| 408 |
+
if heatmaps_seen > 2:
|
| 409 |
+
chart["type"] = _replacement_chart_type(answer)
|
| 410 |
+
chart_type = chart["type"]
|
| 411 |
+
heatmaps_converted += 1
|
| 412 |
+
|
| 413 |
+
if chart_type not in {"KPI", "NOTE", "UNKNOWN"}:
|
| 414 |
+
_apply_chart_palette(chart, index)
|
| 415 |
+
palettes_applied += 1
|
| 416 |
+
|
| 417 |
+
parts = []
|
| 418 |
+
if heatmaps_converted:
|
| 419 |
+
parts.append(f"Converted {heatmaps_converted} extra heatmap(s)")
|
| 420 |
+
if palettes_applied:
|
| 421 |
+
parts.append(f"Applied varied palettes to {palettes_applied} chart(s)")
|
| 422 |
+
return "; ".join(parts)
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def _convert_periodic_metric_charts(visualizations: List[Dict[str, Any]]) -> str:
|
| 426 |
+
"""Turn simple time-series metric answers into real ThoughtSpot KPI charts."""
|
| 427 |
+
converted = 0
|
| 428 |
+
for viz in visualizations:
|
| 429 |
+
if _role_for_viz(viz) != "kpi" or _chart_type(viz) == "KPI":
|
| 430 |
+
continue
|
| 431 |
+
|
| 432 |
+
answer = viz.get("answer", {})
|
| 433 |
+
chart = answer.get("chart", {})
|
| 434 |
+
date_column, measure_column = _kpi_axis_columns(answer)
|
| 435 |
+
if not date_column or not measure_column:
|
| 436 |
+
continue
|
| 437 |
+
|
| 438 |
+
answer["display_mode"] = "CHART_MODE"
|
| 439 |
+
chart["type"] = "KPI"
|
| 440 |
+
chart["axis_configs"] = [{"x": [date_column], "y": [measure_column]}]
|
| 441 |
+
chart["chart_columns"] = [
|
| 442 |
+
{"column_id": date_column},
|
| 443 |
+
{"column_id": measure_column},
|
| 444 |
+
]
|
| 445 |
+
chart.pop("client_state_v2", None)
|
| 446 |
+
converted += 1
|
| 447 |
+
|
| 448 |
+
if converted:
|
| 449 |
+
return f"Converted {converted} periodic metric chart(s) to KPI"
|
| 450 |
+
return ""
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def _kpi_axis_columns(answer: Dict[str, Any]) -> Tuple[str | None, str | None]:
|
| 454 |
+
columns = _answer_column_names(answer)
|
| 455 |
+
if len(columns) < 2:
|
| 456 |
+
columns = _chart_column_names(answer.get("chart", {}))
|
| 457 |
+
|
| 458 |
+
date_column = next((column for column in columns if _looks_like_date_column(column)), None)
|
| 459 |
+
measure_column = next((column for column in columns if column != date_column), None)
|
| 460 |
+
return date_column, measure_column
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def _answer_column_names(answer: Dict[str, Any]) -> List[str]:
|
| 464 |
+
names = []
|
| 465 |
+
for column in answer.get("answer_columns", []) or []:
|
| 466 |
+
name = _column_name(column)
|
| 467 |
+
if name:
|
| 468 |
+
names.append(name)
|
| 469 |
+
return names
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def _chart_column_names(chart: Dict[str, Any]) -> List[str]:
|
| 473 |
+
names = []
|
| 474 |
+
for column in chart.get("chart_columns", []) or []:
|
| 475 |
+
name = _column_name(column)
|
| 476 |
+
if name:
|
| 477 |
+
names.append(name)
|
| 478 |
+
return names
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def _column_name(column: Any) -> str | None:
|
| 482 |
+
if isinstance(column, str):
|
| 483 |
+
return column
|
| 484 |
+
if isinstance(column, dict):
|
| 485 |
+
for key in ("column_id", "name", "id"):
|
| 486 |
+
value = column.get(key)
|
| 487 |
+
if value:
|
| 488 |
+
return str(value)
|
| 489 |
+
return None
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def _looks_like_date_column(column: str) -> bool:
|
| 493 |
+
lowered = column.lower()
|
| 494 |
+
return any(
|
| 495 |
+
token in lowered
|
| 496 |
+
for token in (
|
| 497 |
+
"date",
|
| 498 |
+
"week(",
|
| 499 |
+
"month(",
|
| 500 |
+
"quarter(",
|
| 501 |
+
"year(",
|
| 502 |
+
"day(",
|
| 503 |
+
"week start",
|
| 504 |
+
"month start",
|
| 505 |
+
)
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def _replacement_chart_type(answer: Dict[str, Any]) -> str:
|
| 510 |
+
name = answer.get("name", "").lower()
|
| 511 |
+
query = answer.get("search_query", "").lower()
|
| 512 |
+
text = f"{name} {query}"
|
| 513 |
+
if any(word in text for word in ("rate", "ratio", "retention", "win rate", "attainment")):
|
| 514 |
+
return "ADVANCED_COLUMN"
|
| 515 |
+
if any(word in text for word in ("top ", "client", "account", "rep", "engagement")):
|
| 516 |
+
return "ADVANCED_BAR"
|
| 517 |
+
return "ADVANCED_COLUMN"
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def _apply_chart_palette(chart: Dict[str, Any], index: int) -> None:
|
| 521 |
+
palettes = [
|
| 522 |
+
["#06BF7F", "#48D1E0", "#2E75F0"],
|
| 523 |
+
["#8C62F5", "#D66EFA", "#F45BA8"],
|
| 524 |
+
["#FF8142", "#FCC838", "#F7A11A"],
|
| 525 |
+
["#40C1C0", "#2458C9", "#6AA6FF"],
|
| 526 |
+
["#F45BA8", "#8C62F5", "#D66EFA"],
|
| 527 |
+
["#7B61FF", "#00A3A3", "#FFB84D"],
|
| 528 |
+
["#2359B6", "#06BF7F", "#FCC838"],
|
| 529 |
+
["#B06C00", "#FF8142", "#F7A11A"],
|
| 530 |
+
]
|
| 531 |
+
palette = palettes[index % len(palettes)]
|
| 532 |
+
client_state = _load_chart_client_state(chart)
|
| 533 |
+
chart_properties = client_state.setdefault("chartProperties", {})
|
| 534 |
+
chart_properties["chartLevelColorConfig"] = {
|
| 535 |
+
"colorPalette": {"colors": palette}
|
| 536 |
+
}
|
| 537 |
+
series_colors = _series_colors_for_chart(chart, palette)
|
| 538 |
+
if series_colors:
|
| 539 |
+
client_state["seriesColors"] = series_colors
|
| 540 |
+
client_state["systemSeriesColors"] = series_colors
|
| 541 |
+
chart["client_state_v2"] = json_dumps_compact(client_state)
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
def _series_colors_for_chart(chart: Dict[str, Any], palette: List[str]) -> List[Dict[str, str]]:
|
| 545 |
+
measure_columns = [
|
| 546 |
+
column
|
| 547 |
+
for column in _chart_column_names(chart)
|
| 548 |
+
if not _looks_like_date_column(column) and column.lower() not in {"measure names", "measure values"}
|
| 549 |
+
]
|
| 550 |
+
if not measure_columns:
|
| 551 |
+
return []
|
| 552 |
+
return [
|
| 553 |
+
{"serieName": column, "color": palette[index % len(palette)]}
|
| 554 |
+
for index, column in enumerate(measure_columns)
|
| 555 |
+
]
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def _load_chart_client_state(chart: Dict[str, Any]) -> Dict[str, Any]:
|
| 559 |
+
raw = chart.get("client_state_v2")
|
| 560 |
+
if not raw:
|
| 561 |
+
return {"version": "V4DOT2"}
|
| 562 |
+
try:
|
| 563 |
+
import json
|
| 564 |
+
|
| 565 |
+
value = json.loads(raw)
|
| 566 |
+
if isinstance(value, dict):
|
| 567 |
+
return value
|
| 568 |
+
except Exception:
|
| 569 |
+
pass
|
| 570 |
+
return {"version": "V4DOT2"}
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def json_dumps_compact(value: Dict[str, Any]) -> str:
|
| 574 |
+
import json
|
| 575 |
+
|
| 576 |
+
return json.dumps(value, separators=(",", ":"))
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def _tab_names_for_use_case(use_case: str) -> Tuple[str, str]:
|
| 580 |
+
text = (use_case or "").lower()
|
| 581 |
+
if any(token in text for token in ("professional service", "consulting", "advisory")):
|
| 582 |
+
return EXECUTIVE_TAB, "Practice Management"
|
| 583 |
+
if any(token in text for token in ("sales", "saas", "pipeline", "arr")):
|
| 584 |
+
return EXECUTIVE_TAB, "Sales Management"
|
| 585 |
+
if any(token in text for token in ("trucking", "transport", "fleet", "logistics")):
|
| 586 |
+
return EXECUTIVE_TAB, "Fleet Management"
|
| 587 |
+
if any(token in text for token in ("finance", "financial", "margin")):
|
| 588 |
+
return EXECUTIVE_TAB, "Finance Detail"
|
| 589 |
+
return EXECUTIVE_TAB, OPERATIONS_TAB
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
def _manager_breakdown_name(use_case: str) -> str:
|
| 593 |
+
text = (use_case or "").lower()
|
| 594 |
+
if any(token in text for token in ("professional service", "consulting", "advisory")):
|
| 595 |
+
return "Practice Breakdowns"
|
| 596 |
+
if any(token in text for token in ("sales", "saas", "pipeline", "arr")):
|
| 597 |
+
return "Sales Breakdowns"
|
| 598 |
+
if any(token in text for token in ("trucking", "transport", "fleet", "logistics")):
|
| 599 |
+
return "Fleet Breakdowns"
|
| 600 |
+
if any(token in text for token in ("finance", "financial", "margin")):
|
| 601 |
+
return "Financial Breakdowns"
|
| 602 |
+
return "Manager Breakdowns"
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
def _executive_tab_name(plan: Dict[str, Any]) -> str:
|
| 606 |
+
return plan.get("executive_tab") or EXECUTIVE_TAB
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
def _manager_tab_name(plan: Dict[str, Any]) -> str:
|
| 610 |
+
return plan.get("manager_tab") or OPERATIONS_TAB
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def _merge_style_properties(
|
| 614 |
+
existing: Iterable[Dict[str, str]],
|
| 615 |
+
updates: Dict[str, str],
|
| 616 |
+
) -> List[Dict[str, str]]:
|
| 617 |
+
by_name = {item.get("name"): dict(item) for item in existing if item.get("name")}
|
| 618 |
+
for name, value in updates.items():
|
| 619 |
+
by_name[name] = {"name": name, "value": value}
|
| 620 |
+
return list(by_name.values())
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
def _upsert_override(
|
| 624 |
+
overrides: List[Dict[str, Any]],
|
| 625 |
+
object_id: str,
|
| 626 |
+
updates: Dict[str, str],
|
| 627 |
+
) -> List[Dict[str, Any]]:
|
| 628 |
+
if not object_id:
|
| 629 |
+
return overrides
|
| 630 |
+
for override in overrides:
|
| 631 |
+
if override.get("object_id") == object_id:
|
| 632 |
+
override["style_properties"] = _merge_style_properties(
|
| 633 |
+
override.get("style_properties", []),
|
| 634 |
+
updates,
|
| 635 |
+
)
|
| 636 |
+
return overrides
|
| 637 |
+
overrides.append(
|
| 638 |
+
{
|
| 639 |
+
"object_id": object_id,
|
| 640 |
+
"style_properties": [{"name": name, "value": value} for name, value in updates.items()],
|
| 641 |
+
}
|
| 642 |
+
)
|
| 643 |
+
return overrides
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
def _role_for_viz(viz: Dict[str, Any]) -> str:
|
| 647 |
+
chart_type = _chart_type(viz)
|
| 648 |
+
name = viz.get("answer", {}).get("name", "").lower()
|
| 649 |
+
query = viz.get("answer", {}).get("search_query", "").lower()
|
| 650 |
+
text = f"{name} {query}"
|
| 651 |
+
|
| 652 |
+
if chart_type == "KPI":
|
| 653 |
+
return "kpi"
|
| 654 |
+
if "TABLE" in chart_type:
|
| 655 |
+
return "detail"
|
| 656 |
+
if (
|
| 657 |
+
chart_type in {"LINE", "AREA", "ADVANCED_LINE", "ADVANCED_AREA"}
|
| 658 |
+
and any(period in text for period in ("weekly", "monthly", "quarterly", "daily", "yearly"))
|
| 659 |
+
and " by " not in text
|
| 660 |
+
):
|
| 661 |
+
return "kpi"
|
| 662 |
+
if any(word in text for word in (" by ", "breakdown", "segment", "region", "channel", "product", "store")):
|
| 663 |
+
return "breakdown"
|
| 664 |
+
if chart_type in {"LINE", "AREA", "STACKED_AREA", "ADVANCED_LINE", "ADVANCED_STACKED_AREA"}:
|
| 665 |
+
return "trend"
|
| 666 |
+
if any(word in text for word in ("top ", "bottom ", "rank", "best ", "worst ")):
|
| 667 |
+
return "ranking"
|
| 668 |
+
return "driver"
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
def _chunks(values: List[str], size: int) -> Iterable[List[str]]:
|
| 672 |
+
for index in range(0, len(values), size):
|
| 673 |
+
yield values[index:index + size]
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def _chunked_group_name(name: str, index: int) -> str:
|
| 677 |
+
if index == 0:
|
| 678 |
+
return name
|
| 679 |
+
if name == "Operational Breakdowns":
|
| 680 |
+
return "Additional Breakdowns"
|
| 681 |
+
if name == "Top Opportunities":
|
| 682 |
+
return "Additional Opportunities"
|
| 683 |
+
return f"{name} {index + 1}"
|
| 684 |
+
|
| 685 |
+
|
| 686 |
+
def _chart_type(viz: Dict[str, Any]) -> str:
|
| 687 |
+
if "note_tile" in viz:
|
| 688 |
+
return "NOTE"
|
| 689 |
+
return str(viz.get("answer", {}).get("chart", {}).get("type", "UNKNOWN")).upper()
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
def _section_description(name: str, tab: str) -> str:
|
| 693 |
+
if tab == EXECUTIVE_TAB:
|
| 694 |
+
return f"{name} for executive review"
|
| 695 |
+
return f"{name} for manager follow-up"
|
docs/liveboard_lab.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Liveboard Lab
|
| 2 |
+
|
| 3 |
+
The Liveboard Lab is an internal workflow for iterating on liveboard quality
|
| 4 |
+
from existing ThoughtSpot model IDs. It preserves the supported architecture:
|
| 5 |
+
|
| 6 |
+
1. MCP creates answers and the initial liveboard.
|
| 7 |
+
2. TML post-processing polishes the same liveboard.
|
| 8 |
+
3. The lab opts into the experimental `golden_two_tab` layout strategy.
|
| 9 |
+
|
| 10 |
+
The normal app path is not changed by the lab. Production enhancement still
|
| 11 |
+
defaults to the existing single-tab layout unless `layout_strategy` is passed.
|
| 12 |
+
|
| 13 |
+
## Plan Only
|
| 14 |
+
|
| 15 |
+
```bash
|
| 16 |
+
python3 tools/liveboard_lab.py plan \
|
| 17 |
+
--company Nike \
|
| 18 |
+
--use-case "Retail Sales" \
|
| 19 |
+
--target-viz-count 16
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
This prints the dashboard plan without importing Snowflake or connecting to
|
| 23 |
+
ThoughtSpot.
|
| 24 |
+
|
| 25 |
+
## Create From Existing Model
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
python3 tools/liveboard_lab.py create \
|
| 29 |
+
--model-id "<thoughtspot-model-guid>" \
|
| 30 |
+
--model-name "<thoughtspot-model-name>" \
|
| 31 |
+
--company Nike \
|
| 32 |
+
--use-case "Retail Sales" \
|
| 33 |
+
--target-viz-count 16
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
Required ThoughtSpot settings can be passed as CLI flags or environment
|
| 37 |
+
variables:
|
| 38 |
+
|
| 39 |
+
- `THOUGHTSPOT_URL`
|
| 40 |
+
- `THOUGHTSPOT_USERNAME`
|
| 41 |
+
- `THOUGHTSPOT_TRUSTED_AUTH_KEY`
|
| 42 |
+
|
| 43 |
+
For older DemoPrep `.env` files that use `TS_ENV_1_URL`, `TS_ENV_1_KEY_VAR`,
|
| 44 |
+
and `TEST_USER`, pass:
|
| 45 |
+
|
| 46 |
+
```bash
|
| 47 |
+
python3 tools/liveboard_lab.py --env-file /path/to/.env --ts-env-index 1 create ...
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
## Re-Enhance Existing Liveboard
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
python3 tools/liveboard_lab.py enhance-existing \
|
| 54 |
+
--liveboard-guid "<thoughtspot-liveboard-guid>" \
|
| 55 |
+
--company Nike \
|
| 56 |
+
--use-case "Retail Sales"
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
Use this when MCP already created a liveboard and the iteration is only on TML
|
| 60 |
+
layout/style.
|
| 61 |
+
|
| 62 |
+
## Current Golden Target
|
| 63 |
+
|
| 64 |
+
The reference is `goldendemo/Vizio.com.liveboard.tml`.
|
| 65 |
+
|
| 66 |
+
The lab target is smaller than the reference:
|
| 67 |
+
|
| 68 |
+
- 14-20 visualizations instead of 40+
|
| 69 |
+
- 2 tabs: `Executive Overview`, `Operational Detail`
|
| 70 |
+
- Thematic groups inspired by the reference
|
| 71 |
+
- Curved borders, group colors, highlighted KPI tiles
|
liveboard_creator.py
CHANGED
|
@@ -19,6 +19,10 @@ import re
|
|
| 19 |
import requests
|
| 20 |
from typing import Dict, List, Optional
|
| 21 |
from llm_config import DEFAULT_LLM_MODEL, map_llm_display_to_provider
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
from dotenv import load_dotenv
|
| 24 |
|
|
@@ -636,13 +640,126 @@ def create_branded_note_tile(company_data: Dict, use_case: str, answers: List[Di
|
|
| 636 |
company_name = company_data.get('name', 'Company')
|
| 637 |
use_case_display = use_case if use_case else company_data.get('use_case', 'Analytics')
|
| 638 |
viz_count = len(answers)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
|
| 640 |
-
|
| 641 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
|
| 643 |
return note_tile
|
| 644 |
|
| 645 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
class QueryTranslator:
|
| 647 |
"""Translate natural language queries to ThoughtSpot search syntax"""
|
| 648 |
|
|
@@ -3324,6 +3441,21 @@ def create_liveboard_from_model_mcp(
|
|
| 3324 |
'success': False,
|
| 3325 |
'error': 'Failed to get answers for any questions. Model may not contain data.'
|
| 3326 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3327 |
|
| 3328 |
print(f"✅ Successfully retrieved {len(answers)} answers")
|
| 3329 |
|
|
@@ -3590,12 +3722,17 @@ def create_liveboard_from_model_mcp(
|
|
| 3590 |
})
|
| 3591 |
print(f" ✓ Added dark theme style to Viz_1")
|
| 3592 |
|
| 3593 |
-
# Convert time-series visualizations
|
|
|
|
|
|
|
| 3594 |
print(f" 🔄 Converting time-series charts to KPIs...")
|
| 3595 |
kpi_count = 0
|
|
|
|
| 3596 |
for viz in visualizations:
|
| 3597 |
if viz.get('id') == 'Viz_1':
|
| 3598 |
continue # Skip note tile
|
|
|
|
|
|
|
| 3599 |
|
| 3600 |
answer = viz.get('answer', {})
|
| 3601 |
viz_name = answer.get('name', '').lower()
|
|
@@ -3604,8 +3741,14 @@ def create_liveboard_from_model_mcp(
|
|
| 3604 |
# Check if this is a time-series viz (weekly, monthly, daily patterns)
|
| 3605 |
time_patterns = ['weekly', 'monthly', 'daily', 'quarterly', 'yearly', '.week', '.month', '.day', '.quarter', '.year']
|
| 3606 |
is_time_series = any(p in viz_name or p in search_query for p in time_patterns)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3607 |
|
| 3608 |
-
if is_time_series and 'chart' in answer:
|
| 3609 |
# Convert to KPI
|
| 3610 |
answer['chart']['type'] = 'KPI'
|
| 3611 |
|
|
@@ -3640,6 +3783,20 @@ def create_liveboard_from_model_mcp(
|
|
| 3640 |
|
| 3641 |
if kpi_count > 0:
|
| 3642 |
print(f" ✅ Converted {kpi_count} visualizations to KPIs with sparklines")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3643 |
|
| 3644 |
# Re-import fixed TML using authenticated session
|
| 3645 |
import_response = ts_client.session.post(
|
|
@@ -3778,7 +3935,8 @@ def enhance_mcp_liveboard(
|
|
| 3778 |
fix_kpis: bool = True,
|
| 3779 |
apply_brand_colors: bool = True,
|
| 3780 |
add_layout: bool = True,
|
| 3781 |
-
llm_model: str = None
|
|
|
|
| 3782 |
) -> Dict:
|
| 3783 |
"""
|
| 3784 |
Enhance an MCP-created liveboard with TML post-processing (Golden Demo style).
|
|
@@ -3803,6 +3961,8 @@ def enhance_mcp_liveboard(
|
|
| 3803 |
fix_kpis: Whether to fix KPI visualizations for sparklines
|
| 3804 |
apply_brand_colors: Whether to apply brand color styling
|
| 3805 |
add_layout: Whether to add proper tab/tile layout
|
|
|
|
|
|
|
| 3806 |
|
| 3807 |
Returns:
|
| 3808 |
Dict with success, message, enhancements list
|
|
@@ -3901,30 +4061,35 @@ def enhance_mcp_liveboard(
|
|
| 3901 |
other_vizs.append(viz_id)
|
| 3902 |
|
| 3903 |
print(f" Classification: {len(kpi_vizs)} KPIs, {len(trend_vizs)} trends, {len(bar_vizs)} bars, {len(table_vizs)} tables, {len(note_vizs)} notes", flush=True)
|
|
|
|
|
|
|
| 3904 |
|
| 3905 |
# Step 2.5: Remove note tiles (MCP requires them but we don't want them in final liveboard)
|
| 3906 |
# Safety: only remove note tiles if there are other vizzes to show — never leave liveboard empty
|
| 3907 |
non_note_count = len(visualizations) - len(note_vizs)
|
| 3908 |
-
if note_vizs and non_note_count > 0:
|
| 3909 |
print(f" Removing {len(note_vizs)} note tile(s)...", flush=True)
|
| 3910 |
original_count = len(visualizations)
|
|
|
|
| 3911 |
liveboard_tml['liveboard']['visualizations'] = [
|
| 3912 |
v for v in visualizations if v.get('id') not in note_vizs
|
| 3913 |
]
|
| 3914 |
visualizations = liveboard_tml['liveboard']['visualizations']
|
|
|
|
| 3915 |
print(f" [OK] Removed note tiles ({original_count} -> {len(visualizations)} visualizations)", flush=True)
|
| 3916 |
-
enhancements_applied.append(f"Removed {
|
| 3917 |
elif note_vizs and non_note_count == 0:
|
| 3918 |
print(f" ⚠️ Only note tiles found ({len(note_vizs)}) — MCP may have returned no chart answers. Keeping note tiles to preserve liveboard content.", flush=True)
|
| 3919 |
-
|
| 3920 |
# Step 3: Add Groups - simplified: just KPI section, rest ungrouped
|
| 3921 |
if add_groups:
|
| 3922 |
-
print(f" Adding Groups (simplified)...", flush=True)
|
| 3923 |
|
| 3924 |
groups = []
|
| 3925 |
|
| 3926 |
-
# Create ONE group: Key Metrics with ALL KPIs (always at top)
|
| 3927 |
-
|
|
|
|
| 3928 |
groups.append({
|
| 3929 |
'id': 'Group_1',
|
| 3930 |
'name': 'Key Metrics',
|
|
@@ -3981,7 +4146,7 @@ def enhance_mcp_liveboard(
|
|
| 3981 |
print(f" [OK] Converted {converted_count} LINE→KPI", flush=True)
|
| 3982 |
|
| 3983 |
# Update the groups if we just created KPIs
|
| 3984 |
-
if add_groups and 'groups' in liveboard_tml.get('liveboard', {}):
|
| 3985 |
groups = liveboard_tml['liveboard']['groups']
|
| 3986 |
if not groups: # No groups yet
|
| 3987 |
groups.append({
|
|
@@ -4120,6 +4285,11 @@ def enhance_mcp_liveboard(
|
|
| 4120 |
if stacked_converted > 0:
|
| 4121 |
enhancements_applied.append(f"Converted {stacked_converted} charts to stacked column")
|
| 4122 |
print(f" [OK] Converted {stacked_converted} charts to stacked column", flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4123 |
|
| 4124 |
# Step 5: Apply brand colors and styling (Golden Demo style)
|
| 4125 |
if apply_brand_colors:
|
|
@@ -4177,16 +4347,17 @@ def enhance_mcp_liveboard(
|
|
| 4177 |
colors_applied += 1
|
| 4178 |
|
| 4179 |
# Style note tiles
|
| 4180 |
-
|
| 4181 |
-
|
| 4182 |
-
|
| 4183 |
-
|
| 4184 |
-
'
|
| 4185 |
-
|
| 4186 |
-
|
| 4187 |
-
|
| 4188 |
-
|
| 4189 |
-
|
|
|
|
| 4190 |
|
| 4191 |
# Hide descriptions on analysis charts
|
| 4192 |
for viz_id in (bar_vizs + table_vizs + other_vizs):
|
|
@@ -4206,85 +4377,99 @@ def enhance_mcp_liveboard(
|
|
| 4206 |
# Step 6: Add proper layout with tabs and group_layouts (Golden Demo style)
|
| 4207 |
if add_layout:
|
| 4208 |
print(f" Adding layout structure (tab-based with group_layouts)...", flush=True)
|
| 4209 |
-
|
| 4210 |
-
|
| 4211 |
-
|
| 4212 |
-
|
| 4213 |
-
|
| 4214 |
-
|
| 4215 |
-
|
| 4216 |
-
|
| 4217 |
-
|
| 4218 |
-
|
| 4219 |
-
|
| 4220 |
-
|
| 4221 |
-
|
| 4222 |
-
|
| 4223 |
-
|
| 4224 |
-
|
| 4225 |
-
|
| 4226 |
-
|
| 4227 |
-
|
| 4228 |
-
|
| 4229 |
-
|
| 4230 |
-
|
| 4231 |
-
|
| 4232 |
-
|
| 4233 |
-
|
| 4234 |
-
|
| 4235 |
-
|
| 4236 |
-
|
| 4237 |
-
|
| 4238 |
-
|
| 4239 |
-
|
| 4240 |
-
|
| 4241 |
-
|
| 4242 |
-
|
| 4243 |
-
|
| 4244 |
-
|
| 4245 |
-
|
| 4246 |
-
|
| 4247 |
-
|
| 4248 |
-
|
| 4249 |
-
|
| 4250 |
-
|
| 4251 |
-
|
| 4252 |
-
|
| 4253 |
-
|
| 4254 |
-
|
| 4255 |
-
|
| 4256 |
-
|
| 4257 |
-
|
| 4258 |
-
|
| 4259 |
-
|
| 4260 |
-
'
|
| 4261 |
-
'x': 0 if j % 2 == 0 else 6,
|
| 4262 |
-
'y': (j // 2) * 3,
|
| 4263 |
-
'height': 3,
|
| 4264 |
'width': 6
|
| 4265 |
})
|
| 4266 |
-
|
| 4267 |
-
group_layouts
|
| 4268 |
-
|
| 4269 |
-
|
| 4270 |
-
|
| 4271 |
-
|
| 4272 |
-
|
| 4273 |
-
|
| 4274 |
-
|
| 4275 |
-
|
| 4276 |
-
|
| 4277 |
-
|
| 4278 |
-
|
| 4279 |
-
|
| 4280 |
-
|
| 4281 |
-
|
| 4282 |
-
|
| 4283 |
-
|
| 4284 |
-
|
| 4285 |
-
|
| 4286 |
-
|
| 4287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4288 |
|
| 4289 |
# Step 6.5: Humanize viz titles via LLM
|
| 4290 |
print(f" Humanizing viz titles...", flush=True)
|
|
@@ -4319,6 +4504,18 @@ def enhance_mcp_liveboard(
|
|
| 4319 |
enhancements_applied.append(f"Humanized {_titles_updated} viz titles")
|
| 4320 |
print(f" [OK] Applied humanized titles to {_titles_updated} vizzes", flush=True)
|
| 4321 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4322 |
# Step 7: Re-import the enhanced TML
|
| 4323 |
if enhancements_applied:
|
| 4324 |
print(f" Re-importing enhanced TML...", flush=True)
|
|
|
|
| 19 |
import requests
|
| 20 |
from typing import Dict, List, Optional
|
| 21 |
from llm_config import DEFAULT_LLM_MODEL, map_llm_display_to_provider
|
| 22 |
+
from demoprep_app.liveboards.blueprint import (
|
| 23 |
+
apply_blueprint_to_liveboard_tml,
|
| 24 |
+
build_liveboard_blueprint,
|
| 25 |
+
)
|
| 26 |
|
| 27 |
from dotenv import load_dotenv
|
| 28 |
|
|
|
|
| 640 |
company_name = company_data.get('name', 'Company')
|
| 641 |
use_case_display = use_case if use_case else company_data.get('use_case', 'Analytics')
|
| 642 |
viz_count = len(answers)
|
| 643 |
+
logo_url = company_data.get('logo_url', '').strip()
|
| 644 |
+
app_name = company_data.get('app_name', 'DemoPrep')
|
| 645 |
+
|
| 646 |
+
logo_block = ""
|
| 647 |
+
if logo_url:
|
| 648 |
+
logo_block = (
|
| 649 |
+
f'<div style="background:#ffffff; border-radius:10px; padding:14px 16px; '
|
| 650 |
+
f'margin-bottom:18px; text-align:left;">'
|
| 651 |
+
f'<img src="{logo_url}" alt="{company_name}" '
|
| 652 |
+
f'style="display:block; max-width:100%; width:310px; height:auto;" />'
|
| 653 |
+
f'</div>'
|
| 654 |
+
)
|
| 655 |
|
| 656 |
+
note_tile = f"""<div style="background:#0f1115; color:#ffffff; border-radius:14px; padding:22px 24px; font-family:Inter, Arial, sans-serif; min-height:250px; box-sizing:border-box;">
|
| 657 |
+
{logo_block}
|
| 658 |
+
<div style="font-size:12px; line-height:1; letter-spacing:2px; text-transform:uppercase; color:#40c1c0; font-weight:700; margin-bottom:10px;">{use_case_display}</div>
|
| 659 |
+
<h2 class="theme-module__editor-h2" dir="ltr" style="margin:0 0 12px 0;"><span style="color:#ffffff; white-space:pre-wrap;">{company_name}</span></h2>
|
| 660 |
+
<p class="theme-module__editor-paragraph" dir="ltr" style="margin:0 0 16px 0;"><span style="color:#d6dde8; white-space:pre-wrap;">Executive-ready analytics built from the live ThoughtSpot model, with KPI context and drilldowns for follow-up action.</span></p>
|
| 661 |
+
<div style="display:flex; gap:10px; flex-wrap:wrap; margin-top:16px;">
|
| 662 |
+
<span style="display:inline-block; background:#40c1c0; color:#071013; border-radius:999px; padding:7px 10px; font-size:12px; font-weight:700;">{viz_count} visualizations</span>
|
| 663 |
+
<span style="display:inline-block; background:#202833; color:#ffffff; border:1px solid #40c1c0; border-radius:999px; padding:7px 10px; font-size:12px; font-weight:700;">ThoughtSpot AI</span>
|
| 664 |
+
<span style="display:inline-block; background:#202833; color:#ffffff; border:1px solid #40c1c0; border-radius:999px; padding:7px 10px; font-size:12px; font-weight:700;">{app_name}</span>
|
| 665 |
+
</div>
|
| 666 |
+
</div>"""
|
| 667 |
|
| 668 |
return note_tile
|
| 669 |
|
| 670 |
|
| 671 |
+
def _apply_varied_chart_series_colors(visualizations: List[Dict]) -> int:
|
| 672 |
+
"""Apply explicit series colors so MCP-created boards do not default to all blue."""
|
| 673 |
+
palettes = [
|
| 674 |
+
['#A67F31', '#231F20', '#D6C28D', '#70551F'],
|
| 675 |
+
['#06BF7F', '#48D1E0', '#2E75F0', '#2359B6'],
|
| 676 |
+
['#8C62F5', '#D66EFA', '#F45BA8', '#7B61FF'],
|
| 677 |
+
['#FF8142', '#FCC838', '#F7A11A', '#B06C00'],
|
| 678 |
+
['#40C1C0', '#2458C9', '#6AA6FF', '#0B8FA3'],
|
| 679 |
+
['#E05252', '#FFB84D', '#8C62F5', '#06BF7F'],
|
| 680 |
+
['#2E75F0', '#F45BA8', '#FCC838', '#40C1C0'],
|
| 681 |
+
['#6B7280', '#A67F31', '#111827', '#D1A84B'],
|
| 682 |
+
['#00A3A3', '#7B61FF', '#FF8142', '#06BF7F'],
|
| 683 |
+
['#B83280', '#2E75F0', '#F7A11A', '#48D1E0'],
|
| 684 |
+
['#4B5563', '#10B981', '#F59E0B', '#6366F1'],
|
| 685 |
+
]
|
| 686 |
+
updated_count = 0
|
| 687 |
+
|
| 688 |
+
for viz in visualizations:
|
| 689 |
+
answer = viz.get('answer', {})
|
| 690 |
+
chart = answer.get('chart', {})
|
| 691 |
+
chart_type = str(chart.get('type', '')).upper()
|
| 692 |
+
if not chart or chart_type in {'KPI', 'NOTE', ''}:
|
| 693 |
+
continue
|
| 694 |
+
|
| 695 |
+
palette = palettes[updated_count % len(palettes)]
|
| 696 |
+
client_state = _load_client_state(chart)
|
| 697 |
+
chart_properties = client_state.setdefault('chartProperties', {})
|
| 698 |
+
chart_properties['chartLevelColorConfig'] = {'colorPalette': {'colors': palette}}
|
| 699 |
+
chart_properties['seriesColorConfig'] = {'colors': palette}
|
| 700 |
+
chart_properties.setdefault('chartSpecific', {}).setdefault('dataFieldArea', 'column')
|
| 701 |
+
|
| 702 |
+
series_columns = _client_state_measure_columns(client_state)
|
| 703 |
+
if not series_columns:
|
| 704 |
+
series_columns = _chart_measure_columns(chart)
|
| 705 |
+
if not series_columns:
|
| 706 |
+
series_columns = ['Measure Values', answer.get('name', 'Value')]
|
| 707 |
+
|
| 708 |
+
series_colors = [
|
| 709 |
+
{'serieName': column, 'color': palette[index % len(palette)]}
|
| 710 |
+
for index, column in enumerate(series_columns)
|
| 711 |
+
]
|
| 712 |
+
client_state['seriesColors'] = series_colors
|
| 713 |
+
client_state['systemSeriesColors'] = series_colors
|
| 714 |
+
|
| 715 |
+
if chart_type == 'HEATMAP':
|
| 716 |
+
chart_properties['heatmapColorConfig'] = {
|
| 717 |
+
'colorMap': [{'color': palette}],
|
| 718 |
+
'colorPalette': {'colors': palette},
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
chart['client_state_v2'] = json.dumps(client_state, separators=(',', ':'))
|
| 722 |
+
updated_count += 1
|
| 723 |
+
|
| 724 |
+
return updated_count
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
def _load_client_state(chart: Dict) -> Dict:
|
| 728 |
+
try:
|
| 729 |
+
return json.loads(chart.get('client_state_v2') or '{}')
|
| 730 |
+
except Exception:
|
| 731 |
+
return {}
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def _client_state_measure_columns(client_state: Dict) -> List[str]:
|
| 735 |
+
columns = []
|
| 736 |
+
for axis in client_state.get('axisProperties', []) or []:
|
| 737 |
+
properties = axis.get('properties', {}) if isinstance(axis, dict) else {}
|
| 738 |
+
if properties.get('axisType') != 'Y':
|
| 739 |
+
continue
|
| 740 |
+
for column in properties.get('linkedColumns', []) or []:
|
| 741 |
+
if column not in columns:
|
| 742 |
+
columns.append(column)
|
| 743 |
+
return columns
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
def _chart_measure_columns(chart: Dict) -> List[str]:
|
| 747 |
+
columns = []
|
| 748 |
+
for column in chart.get('chart_columns', []) or []:
|
| 749 |
+
if isinstance(column, dict):
|
| 750 |
+
name = column.get('column_id') or column.get('name') or column.get('id')
|
| 751 |
+
else:
|
| 752 |
+
name = str(column)
|
| 753 |
+
if not name:
|
| 754 |
+
continue
|
| 755 |
+
lowered = str(name).lower()
|
| 756 |
+
if any(token in lowered for token in ('date', 'month(', 'week(', 'quarter(', 'year(')):
|
| 757 |
+
continue
|
| 758 |
+
if name not in columns:
|
| 759 |
+
columns.append(str(name))
|
| 760 |
+
return columns
|
| 761 |
+
|
| 762 |
+
|
| 763 |
class QueryTranslator:
|
| 764 |
"""Translate natural language queries to ThoughtSpot search syntax"""
|
| 765 |
|
|
|
|
| 3441 |
'success': False,
|
| 3442 |
'error': 'Failed to get answers for any questions. Model may not contain data.'
|
| 3443 |
}
|
| 3444 |
+
|
| 3445 |
+
valid_answers = [
|
| 3446 |
+
ans for ans in answers
|
| 3447 |
+
if ans.get('session_identifier') and ans.get('tokens')
|
| 3448 |
+
]
|
| 3449 |
+
dropped_answers = len(answers) - len(valid_answers)
|
| 3450 |
+
if dropped_answers:
|
| 3451 |
+
print(f"⚠️ Dropping {dropped_answers} answer(s) without session/tokens before createLiveboard", flush=True)
|
| 3452 |
+
answers = valid_answers
|
| 3453 |
+
|
| 3454 |
+
if not answers:
|
| 3455 |
+
return {
|
| 3456 |
+
'success': False,
|
| 3457 |
+
'error': 'No valid answers with ThoughtSpot session/tokens were retrieved.'
|
| 3458 |
+
}
|
| 3459 |
|
| 3460 |
print(f"✅ Successfully retrieved {len(answers)} answers")
|
| 3461 |
|
|
|
|
| 3722 |
})
|
| 3723 |
print(f" ✓ Added dark theme style to Viz_1")
|
| 3724 |
|
| 3725 |
+
# Convert only simple single-metric time-series visualizations
|
| 3726 |
+
# to KPI cards. Multi-metric trends should remain charts, and
|
| 3727 |
+
# executive pages should not become a wall of KPI tiles.
|
| 3728 |
print(f" 🔄 Converting time-series charts to KPIs...")
|
| 3729 |
kpi_count = 0
|
| 3730 |
+
max_kpi_cards = 3
|
| 3731 |
for viz in visualizations:
|
| 3732 |
if viz.get('id') == 'Viz_1':
|
| 3733 |
continue # Skip note tile
|
| 3734 |
+
if kpi_count >= max_kpi_cards:
|
| 3735 |
+
break
|
| 3736 |
|
| 3737 |
answer = viz.get('answer', {})
|
| 3738 |
viz_name = answer.get('name', '').lower()
|
|
|
|
| 3741 |
# Check if this is a time-series viz (weekly, monthly, daily patterns)
|
| 3742 |
time_patterns = ['weekly', 'monthly', 'daily', 'quarterly', 'yearly', '.week', '.month', '.day', '.quarter', '.year']
|
| 3743 |
is_time_series = any(p in viz_name or p in search_query for p in time_patterns)
|
| 3744 |
+
is_multi_metric = (
|
| 3745 |
+
' and ' in viz_name
|
| 3746 |
+
or ' vs ' in viz_name
|
| 3747 |
+
or ' and ' in search_query
|
| 3748 |
+
or ' vs ' in search_query
|
| 3749 |
+
)
|
| 3750 |
|
| 3751 |
+
if is_time_series and not is_multi_metric and 'chart' in answer:
|
| 3752 |
# Convert to KPI
|
| 3753 |
answer['chart']['type'] = 'KPI'
|
| 3754 |
|
|
|
|
| 3783 |
|
| 3784 |
if kpi_count > 0:
|
| 3785 |
print(f" ✅ Converted {kpi_count} visualizations to KPIs with sparklines")
|
| 3786 |
+
|
| 3787 |
+
palette_count = _apply_varied_chart_series_colors(visualizations)
|
| 3788 |
+
if palette_count > 0:
|
| 3789 |
+
print(f" 🎨 Applied varied colors to {palette_count} chart visualizations")
|
| 3790 |
+
|
| 3791 |
+
blueprint = build_liveboard_blueprint(
|
| 3792 |
+
company_data=company_data,
|
| 3793 |
+
use_case=use_case or company_data.get('use_case', 'Analytics'),
|
| 3794 |
+
model_columns=model_columns or [],
|
| 3795 |
+
target_filter_count=3,
|
| 3796 |
+
)
|
| 3797 |
+
liveboard_tml, blueprint_changes = apply_blueprint_to_liveboard_tml(liveboard_tml, blueprint)
|
| 3798 |
+
for change in blueprint_changes:
|
| 3799 |
+
print(f" 🧭 {change}")
|
| 3800 |
|
| 3801 |
# Re-import fixed TML using authenticated session
|
| 3802 |
import_response = ts_client.session.post(
|
|
|
|
| 3935 |
fix_kpis: bool = True,
|
| 3936 |
apply_brand_colors: bool = True,
|
| 3937 |
add_layout: bool = True,
|
| 3938 |
+
llm_model: str = None,
|
| 3939 |
+
layout_strategy: str = "single_tab",
|
| 3940 |
) -> Dict:
|
| 3941 |
"""
|
| 3942 |
Enhance an MCP-created liveboard with TML post-processing (Golden Demo style).
|
|
|
|
| 3961 |
fix_kpis: Whether to fix KPI visualizations for sparklines
|
| 3962 |
apply_brand_colors: Whether to apply brand color styling
|
| 3963 |
add_layout: Whether to add proper tab/tile layout
|
| 3964 |
+
layout_strategy: "single_tab" preserves existing app behavior;
|
| 3965 |
+
"golden_two_tab" applies the lab's golden-demo-inspired layout.
|
| 3966 |
|
| 3967 |
Returns:
|
| 3968 |
Dict with success, message, enhancements list
|
|
|
|
| 4061 |
other_vizs.append(viz_id)
|
| 4062 |
|
| 4063 |
print(f" Classification: {len(kpi_vizs)} KPIs, {len(trend_vizs)} trends, {len(bar_vizs)} bars, {len(table_vizs)} tables, {len(note_vizs)} notes", flush=True)
|
| 4064 |
+
|
| 4065 |
+
use_golden_two_tab = layout_strategy == "golden_two_tab"
|
| 4066 |
|
| 4067 |
# Step 2.5: Remove note tiles (MCP requires them but we don't want them in final liveboard)
|
| 4068 |
# Safety: only remove note tiles if there are other vizzes to show — never leave liveboard empty
|
| 4069 |
non_note_count = len(visualizations) - len(note_vizs)
|
| 4070 |
+
if note_vizs and non_note_count > 0 and not use_golden_two_tab:
|
| 4071 |
print(f" Removing {len(note_vizs)} note tile(s)...", flush=True)
|
| 4072 |
original_count = len(visualizations)
|
| 4073 |
+
removed_note_count = len(note_vizs)
|
| 4074 |
liveboard_tml['liveboard']['visualizations'] = [
|
| 4075 |
v for v in visualizations if v.get('id') not in note_vizs
|
| 4076 |
]
|
| 4077 |
visualizations = liveboard_tml['liveboard']['visualizations']
|
| 4078 |
+
note_vizs = []
|
| 4079 |
print(f" [OK] Removed note tiles ({original_count} -> {len(visualizations)} visualizations)", flush=True)
|
| 4080 |
+
enhancements_applied.append(f"Removed {removed_note_count} note tile(s)")
|
| 4081 |
elif note_vizs and non_note_count == 0:
|
| 4082 |
print(f" ⚠️ Only note tiles found ({len(note_vizs)}) — MCP may have returned no chart answers. Keeping note tiles to preserve liveboard content.", flush=True)
|
| 4083 |
+
|
| 4084 |
# Step 3: Add Groups - simplified: just KPI section, rest ungrouped
|
| 4085 |
if add_groups:
|
| 4086 |
+
print(f" Adding Groups ({'golden two-tab later' if use_golden_two_tab else 'simplified'})...", flush=True)
|
| 4087 |
|
| 4088 |
groups = []
|
| 4089 |
|
| 4090 |
+
# Create ONE group: Key Metrics with ALL KPIs (always at top).
|
| 4091 |
+
# Golden two-tab mode replaces this with thematic groups during layout.
|
| 4092 |
+
if kpi_vizs and not use_golden_two_tab:
|
| 4093 |
groups.append({
|
| 4094 |
'id': 'Group_1',
|
| 4095 |
'name': 'Key Metrics',
|
|
|
|
| 4146 |
print(f" [OK] Converted {converted_count} LINE→KPI", flush=True)
|
| 4147 |
|
| 4148 |
# Update the groups if we just created KPIs
|
| 4149 |
+
if add_groups and not use_golden_two_tab and 'groups' in liveboard_tml.get('liveboard', {}):
|
| 4150 |
groups = liveboard_tml['liveboard']['groups']
|
| 4151 |
if not groups: # No groups yet
|
| 4152 |
groups.append({
|
|
|
|
| 4285 |
if stacked_converted > 0:
|
| 4286 |
enhancements_applied.append(f"Converted {stacked_converted} charts to stacked column")
|
| 4287 |
print(f" [OK] Converted {stacked_converted} charts to stacked column", flush=True)
|
| 4288 |
+
|
| 4289 |
+
palette_count = _apply_varied_chart_series_colors(visualizations)
|
| 4290 |
+
if palette_count > 0:
|
| 4291 |
+
enhancements_applied.append(f"Applied varied palettes to {palette_count} chart(s)")
|
| 4292 |
+
print(f" [OK] Applied varied palettes to {palette_count} chart(s)", flush=True)
|
| 4293 |
|
| 4294 |
# Step 5: Apply brand colors and styling (Golden Demo style)
|
| 4295 |
if apply_brand_colors:
|
|
|
|
| 4347 |
colors_applied += 1
|
| 4348 |
|
| 4349 |
# Style note tiles
|
| 4350 |
+
if not use_golden_two_tab:
|
| 4351 |
+
for note_id in note_vizs:
|
| 4352 |
+
existing = next((o for o in style['overrides'] if o.get('object_id') == note_id), None)
|
| 4353 |
+
if not existing:
|
| 4354 |
+
style['overrides'].append({
|
| 4355 |
+
'object_id': note_id,
|
| 4356 |
+
'style_properties': [
|
| 4357 |
+
{'name': 'tile_brand_color', 'value': 'TBC_I'}
|
| 4358 |
+
]
|
| 4359 |
+
})
|
| 4360 |
+
colors_applied += 1
|
| 4361 |
|
| 4362 |
# Hide descriptions on analysis charts
|
| 4363 |
for viz_id in (bar_vizs + table_vizs + other_vizs):
|
|
|
|
| 4377 |
# Step 6: Add proper layout with tabs and group_layouts (Golden Demo style)
|
| 4378 |
if add_layout:
|
| 4379 |
print(f" Adding layout structure (tab-based with group_layouts)...", flush=True)
|
| 4380 |
+
if use_golden_two_tab:
|
| 4381 |
+
from demoprep_app.liveboards.golden_layout import (
|
| 4382 |
+
apply_golden_two_tab_layout,
|
| 4383 |
+
build_dashboard_plan,
|
| 4384 |
+
)
|
| 4385 |
+
|
| 4386 |
+
plan = build_dashboard_plan(
|
| 4387 |
+
company_name=company_data.get('name', 'Company'),
|
| 4388 |
+
use_case=company_data.get('use_case') or company_data.get('use_case_name') or 'Analytics',
|
| 4389 |
+
target_viz_count=len(visualizations),
|
| 4390 |
+
)
|
| 4391 |
+
liveboard_tml, golden_changes = apply_golden_two_tab_layout(liveboard_tml, plan)
|
| 4392 |
+
enhancements_applied.extend(golden_changes)
|
| 4393 |
+
print(f" [OK] Applied golden two-tab layout", flush=True)
|
| 4394 |
+
else:
|
| 4395 |
+
groups = liveboard_tml.get('liveboard', {}).get('groups', [])
|
| 4396 |
+
all_viz_ids = kpi_vizs + trend_vizs + bar_vizs + geo_vizs + table_vizs + other_vizs
|
| 4397 |
+
|
| 4398 |
+
# Collect grouped visualization IDs
|
| 4399 |
+
grouped_vizs = set()
|
| 4400 |
+
for group in groups:
|
| 4401 |
+
grouped_vizs.update(group.get('visualizations', []))
|
| 4402 |
+
|
| 4403 |
+
# Ungrouped visualizations (everything not in a group)
|
| 4404 |
+
ungrouped = [v for v in all_viz_ids if v not in grouped_vizs]
|
| 4405 |
+
|
| 4406 |
+
# === Build tab tiles: groups FIRST (KPIs at top), then ungrouped below ===
|
| 4407 |
+
tab_tiles = []
|
| 4408 |
+
row = 0
|
| 4409 |
+
|
| 4410 |
+
# Add KPI group at the very top spanning full width
|
| 4411 |
+
for group in groups:
|
| 4412 |
+
grp_id = group.get('id')
|
| 4413 |
+
grp_vizs = group.get('visualizations', [])
|
| 4414 |
+
# KPI group spans full width at top
|
| 4415 |
+
group_height = max(3, (len(grp_vizs) + 1) // 2 * 3) # 3 rows per pair
|
| 4416 |
+
tab_tiles.append({
|
| 4417 |
+
'visualization_id': grp_id,
|
| 4418 |
+
'x': 0,
|
| 4419 |
+
'y': row,
|
| 4420 |
+
'height': group_height,
|
| 4421 |
+
'width': 12 # Full width for KPI group
|
| 4422 |
+
})
|
| 4423 |
+
row += group_height
|
| 4424 |
+
|
| 4425 |
+
# Add ungrouped visualizations in 2-column grid below groups
|
| 4426 |
+
for i, viz_id in enumerate(ungrouped):
|
| 4427 |
+
tab_tiles.append({
|
| 4428 |
+
'visualization_id': viz_id,
|
| 4429 |
+
'x': 0 if i % 2 == 0 else 6,
|
| 4430 |
+
'y': row + (i // 2) * 5,
|
| 4431 |
+
'height': 5,
|
|
|
|
|
|
|
|
|
|
| 4432 |
'width': 6
|
| 4433 |
})
|
| 4434 |
+
|
| 4435 |
+
# === Build group_layouts: define tile positions INSIDE each group ===
|
| 4436 |
+
group_layouts = []
|
| 4437 |
+
for group in groups:
|
| 4438 |
+
grp_id = group.get('id')
|
| 4439 |
+
grp_vizs = group.get('visualizations', [])
|
| 4440 |
+
|
| 4441 |
+
grp_tiles = []
|
| 4442 |
+
for j, gviz_id in enumerate(grp_vizs):
|
| 4443 |
+
# KPIs: 2-column layout inside group, each 3 rows tall
|
| 4444 |
+
grp_tiles.append({
|
| 4445 |
+
'visualization_id': gviz_id,
|
| 4446 |
+
'x': 0 if j % 2 == 0 else 6,
|
| 4447 |
+
'y': (j // 2) * 3,
|
| 4448 |
+
'height': 3,
|
| 4449 |
+
'width': 6
|
| 4450 |
+
})
|
| 4451 |
+
|
| 4452 |
+
group_layouts.append({
|
| 4453 |
+
'id': grp_id,
|
| 4454 |
+
'tiles': grp_tiles
|
| 4455 |
+
})
|
| 4456 |
+
|
| 4457 |
+
# === Assemble the layout with tabs structure ===
|
| 4458 |
+
if tab_tiles:
|
| 4459 |
+
tab = {
|
| 4460 |
+
'name': 'Overview',
|
| 4461 |
+
'description': '',
|
| 4462 |
+
'tiles': tab_tiles
|
| 4463 |
+
}
|
| 4464 |
+
if group_layouts:
|
| 4465 |
+
tab['group_layouts'] = group_layouts
|
| 4466 |
+
|
| 4467 |
+
liveboard_tml['liveboard']['layout'] = {
|
| 4468 |
+
'tabs': [tab]
|
| 4469 |
+
}
|
| 4470 |
+
total_tiles = len(tab_tiles) + sum(len(gl.get('tiles', [])) for gl in group_layouts)
|
| 4471 |
+
enhancements_applied.append(f"Added tab layout ({total_tiles} tiles, {len(group_layouts)} group layouts)")
|
| 4472 |
+
print(f" [OK] Added tab layout: {len(tab_tiles)} top-level tiles, {len(group_layouts)} group layouts", flush=True)
|
| 4473 |
|
| 4474 |
# Step 6.5: Humanize viz titles via LLM
|
| 4475 |
print(f" Humanizing viz titles...", flush=True)
|
|
|
|
| 4504 |
enhancements_applied.append(f"Humanized {_titles_updated} viz titles")
|
| 4505 |
print(f" [OK] Applied humanized titles to {_titles_updated} vizzes", flush=True)
|
| 4506 |
|
| 4507 |
+
blueprint = build_liveboard_blueprint(
|
| 4508 |
+
company_data=company_data,
|
| 4509 |
+
use_case=company_data.get('use_case', 'Analytics'),
|
| 4510 |
+
model_columns=company_data.get('model_columns', []),
|
| 4511 |
+
target_filter_count=3,
|
| 4512 |
+
)
|
| 4513 |
+
liveboard_tml, blueprint_changes = apply_blueprint_to_liveboard_tml(liveboard_tml, blueprint)
|
| 4514 |
+
if blueprint_changes:
|
| 4515 |
+
enhancements_applied.extend(blueprint_changes)
|
| 4516 |
+
for change in blueprint_changes:
|
| 4517 |
+
print(f" [OK] {change}", flush=True)
|
| 4518 |
+
|
| 4519 |
# Step 7: Re-import the enhanced TML
|
| 4520 |
if enhancements_applied:
|
| 4521 |
print(f" Re-importing enhanced TML...", flush=True)
|
tests/test_golden_layout.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import yaml
|
| 2 |
+
|
| 3 |
+
from demoprep_app.liveboards.golden_layout import (
|
| 4 |
+
EXECUTIVE_TAB,
|
| 5 |
+
OPERATIONS_TAB,
|
| 6 |
+
apply_golden_two_tab_layout,
|
| 7 |
+
build_dashboard_plan,
|
| 8 |
+
summarize_liveboard_tml,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_dashboard_plan_has_two_audiences_and_bounded_count():
|
| 13 |
+
plan = build_dashboard_plan("Nike", "Retail Sales", target_viz_count=99)
|
| 14 |
+
|
| 15 |
+
assert plan["target_viz_count"] == 24
|
| 16 |
+
assert [audience["tab"] for audience in plan["audiences"]] == [
|
| 17 |
+
EXECUTIVE_TAB,
|
| 18 |
+
"Sales Management",
|
| 19 |
+
]
|
| 20 |
+
assert plan["sections"][0]["name"] == "Key Performance Metrics"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_apply_golden_two_tab_layout_groups_and_styles_synthetic_tml():
|
| 24 |
+
tml = {
|
| 25 |
+
"liveboard": {
|
| 26 |
+
"name": "Lab Liveboard",
|
| 27 |
+
"visualizations": [
|
| 28 |
+
_viz("Viz_1", "Total Revenue", "KPI", "sum [Revenue] [Date].monthly"),
|
| 29 |
+
_viz("Viz_2", "Total Orders", "KPI", "sum [Orders] [Date].monthly"),
|
| 30 |
+
_viz("Viz_3", "Revenue Trend", "LINE", "sum [Revenue] by [Date].monthly"),
|
| 31 |
+
_viz("Viz_4", "Revenue by Region", "COLUMN", "sum [Revenue] by [Region]"),
|
| 32 |
+
_viz("Viz_5", "Top Stores by Revenue", "BAR", "top 10 [Store] by [Revenue]"),
|
| 33 |
+
_viz("Viz_6", "Store Detail", "TABLE", "[Store] [Revenue] [Orders]"),
|
| 34 |
+
{"id": "Viz_7", "note_tile": {"text": "remove me"}},
|
| 35 |
+
],
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
updated, changes = apply_golden_two_tab_layout(tml, build_dashboard_plan("Nike", "Retail Sales", 16))
|
| 40 |
+
summary = summarize_liveboard_tml(updated)
|
| 41 |
+
|
| 42 |
+
assert "Applied golden two-tab layout" in changes[0]
|
| 43 |
+
assert summary["visualization_count"] == 7
|
| 44 |
+
assert summary["tab_names"] == [EXECUTIVE_TAB, "Sales Management"]
|
| 45 |
+
assert "Key Performance Metrics" in summary["group_names"]
|
| 46 |
+
assert updated["liveboard"]["visualizations"][-1]["note_tile"]["html_parsed_string"]
|
| 47 |
+
assert updated["liveboard"]["style"]["style_properties"]
|
| 48 |
+
assert any(
|
| 49 |
+
override["object_id"] == "Viz_1"
|
| 50 |
+
for override in updated["liveboard"]["style"]["overrides"]
|
| 51 |
+
)
|
| 52 |
+
executive_tiles = updated["liveboard"]["layout"]["tabs"][0]["tiles"]
|
| 53 |
+
assert executive_tiles[0]["visualization_id"] == "Viz_7"
|
| 54 |
+
assert executive_tiles[0]["x"] == 0
|
| 55 |
+
assert executive_tiles[1]["visualization_id"] == "Group_1"
|
| 56 |
+
assert executive_tiles[1]["x"] == 3
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_dimensional_line_charts_move_to_manager_tab():
|
| 60 |
+
tml = {
|
| 61 |
+
"liveboard": {
|
| 62 |
+
"name": "Lab Liveboard",
|
| 63 |
+
"visualizations": [
|
| 64 |
+
_viz("Viz_1", "Total Revenue", "KPI", "sum [Revenue] [Date].monthly"),
|
| 65 |
+
_viz(
|
| 66 |
+
"Viz_2",
|
| 67 |
+
"Utilization by Region and Seniority",
|
| 68 |
+
"ADVANCED_LINE",
|
| 69 |
+
"sum [Utilization] by [Region] [Seniority]",
|
| 70 |
+
),
|
| 71 |
+
_viz("Viz_3", "Revenue by Region", "COLUMN", "sum [Revenue] by [Region]"),
|
| 72 |
+
],
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
updated, _changes = apply_golden_two_tab_layout(
|
| 77 |
+
tml,
|
| 78 |
+
build_dashboard_plan("EY", "Professional Services", 16),
|
| 79 |
+
)
|
| 80 |
+
tabs = updated["liveboard"]["layout"]["tabs"]
|
| 81 |
+
|
| 82 |
+
assert tabs[0]["name"] == EXECUTIVE_TAB
|
| 83 |
+
assert tabs[1]["name"] == "Practice Management"
|
| 84 |
+
assert "Viz_2" not in _tab_viz_ids(tabs[0])
|
| 85 |
+
assert "Viz_2" in _tab_viz_ids(tabs[1])
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_chart_palettes_include_explicit_series_colors():
|
| 89 |
+
tml = {
|
| 90 |
+
"liveboard": {
|
| 91 |
+
"name": "Lab Liveboard",
|
| 92 |
+
"visualizations": [
|
| 93 |
+
_viz(
|
| 94 |
+
"Viz_1",
|
| 95 |
+
"Revenue by Region",
|
| 96 |
+
"COLUMN",
|
| 97 |
+
"sum [Revenue] by [Region]",
|
| 98 |
+
chart_columns=[
|
| 99 |
+
{"column_id": "Region"},
|
| 100 |
+
{"column_id": "Total Revenue"},
|
| 101 |
+
],
|
| 102 |
+
),
|
| 103 |
+
],
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
updated, _changes = apply_golden_two_tab_layout(tml, build_dashboard_plan("Nike", "Retail Sales", 16))
|
| 108 |
+
client_state = updated["liveboard"]["visualizations"][0]["answer"]["chart"]["client_state_v2"]
|
| 109 |
+
|
| 110 |
+
assert "seriesColors" in client_state
|
| 111 |
+
assert "Total Revenue" in client_state
|
| 112 |
+
assert "#06BF7F" in client_state
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_periodic_metric_lines_become_real_kpi_charts():
|
| 116 |
+
tml = {
|
| 117 |
+
"liveboard": {
|
| 118 |
+
"name": "Lab Liveboard",
|
| 119 |
+
"visualizations": [
|
| 120 |
+
_viz(
|
| 121 |
+
"Viz_1",
|
| 122 |
+
"Total Weekly Revenue",
|
| 123 |
+
"ADVANCED_LINE",
|
| 124 |
+
"sum [Revenue] [Date].weekly",
|
| 125 |
+
answer_columns=[
|
| 126 |
+
{"name": "Total Revenue"},
|
| 127 |
+
{"name": "Week(Date)"},
|
| 128 |
+
],
|
| 129 |
+
chart_columns=[
|
| 130 |
+
{"column_id": "Total Revenue"},
|
| 131 |
+
{"column_id": "Week(Date)"},
|
| 132 |
+
],
|
| 133 |
+
),
|
| 134 |
+
_viz("Viz_2", "Revenue by Region", "COLUMN", "sum [Revenue] by [Region]"),
|
| 135 |
+
],
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
updated, changes = apply_golden_two_tab_layout(tml, build_dashboard_plan("Nike", "Retail Sales", 16))
|
| 140 |
+
chart = updated["liveboard"]["visualizations"][0]["answer"]["chart"]
|
| 141 |
+
|
| 142 |
+
assert "Converted 1 periodic metric chart(s) to KPI" in changes
|
| 143 |
+
assert chart["type"] == "KPI"
|
| 144 |
+
assert chart["axis_configs"] == [{"x": ["Week(Date)"], "y": ["Total Revenue"]}]
|
| 145 |
+
assert chart["chart_columns"] == [
|
| 146 |
+
{"column_id": "Week(Date)"},
|
| 147 |
+
{"column_id": "Total Revenue"},
|
| 148 |
+
]
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_golden_demo_summary_captures_reference_shape():
|
| 152 |
+
with open("goldendemo/Vizio.com.liveboard.tml", "r", encoding="utf-8") as fh:
|
| 153 |
+
# ThoughtSpot exports unquoted YAML tokens such as `oper: =`.
|
| 154 |
+
# BaseLoader keeps this test focused on exported structure.
|
| 155 |
+
tml = yaml.load(fh, Loader=yaml.BaseLoader)
|
| 156 |
+
|
| 157 |
+
summary = summarize_liveboard_tml(tml)
|
| 158 |
+
|
| 159 |
+
assert summary["visualization_count"] >= 40
|
| 160 |
+
assert summary["group_count"] == 18
|
| 161 |
+
assert summary["tab_names"][0] == "Overview"
|
| 162 |
+
assert "Forecasting" in summary["tab_names"]
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _viz(viz_id, name, chart_type, query, answer_columns=None, chart_columns=None):
|
| 166 |
+
return {
|
| 167 |
+
"id": viz_id,
|
| 168 |
+
"answer": {
|
| 169 |
+
"name": name,
|
| 170 |
+
"search_query": query,
|
| 171 |
+
"answer_columns": answer_columns or [],
|
| 172 |
+
"chart": {"type": chart_type, "chart_columns": chart_columns or []},
|
| 173 |
+
},
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _tab_viz_ids(tab):
|
| 178 |
+
group_ids = {layout["id"]: layout for layout in tab.get("group_layouts", [])}
|
| 179 |
+
ids = set()
|
| 180 |
+
for tile in tab.get("tiles", []):
|
| 181 |
+
tile_id = tile.get("visualization_id")
|
| 182 |
+
if tile_id in group_ids:
|
| 183 |
+
ids.update(inner.get("visualization_id") for inner in group_ids[tile_id].get("tiles", []))
|
| 184 |
+
else:
|
| 185 |
+
ids.add(tile_id)
|
| 186 |
+
return ids
|
tests/test_liveboard_blueprint.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from demoprep_app.liveboards.blueprint import (
|
| 2 |
+
DEMO_GUIDE_VIZ_ID,
|
| 3 |
+
apply_blueprint_to_liveboard_tml,
|
| 4 |
+
build_liveboard_blueprint,
|
| 5 |
+
choose_global_filters,
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_choose_global_filters_prefers_business_fields_and_clean_names():
|
| 10 |
+
columns = [
|
| 11 |
+
{"name": "Prog Program Key", "type": "ATTRIBUTE"},
|
| 12 |
+
{"name": "Program Name", "type": "ATTRIBUTE"},
|
| 13 |
+
{"name": "Campus Name", "type": "ATTRIBUTE"},
|
| 14 |
+
{"name": "Student Segment Name", "type": "ATTRIBUTE"},
|
| 15 |
+
{"name": "Scenario Category", "type": "ATTRIBUTE"},
|
| 16 |
+
{"name": "Applications", "type": "MEASURE"},
|
| 17 |
+
{"name": "Yield Rate Pct", "type": "MEASURE"},
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
filters = choose_global_filters(columns, target_filter_count=3)
|
| 21 |
+
|
| 22 |
+
assert [flt["display_name"] for flt in filters] == [
|
| 23 |
+
"Program",
|
| 24 |
+
"Campus",
|
| 25 |
+
"Student Segment",
|
| 26 |
+
]
|
| 27 |
+
assert all(flt["is_mandatory"] is False for flt in filters)
|
| 28 |
+
assert all("filter" not in flt for flt in filters)
|
| 29 |
+
assert all("date_filter" not in flt for flt in filters)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_blueprint_demo_notes_use_demoprep_and_no_stale_copy():
|
| 33 |
+
blueprint = build_liveboard_blueprint(
|
| 34 |
+
{"name": "Wake Forest University", "research": "Enrollment leaders need a concise view."},
|
| 35 |
+
"Enrollment and Student Success Analytics",
|
| 36 |
+
[{"name": "Program Name", "type": "ATTRIBUTE"}, {"name": "Campus Name", "type": "ATTRIBUTE"}],
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
html = blueprint["demo_notes_tile"]["html"]
|
| 40 |
+
|
| 41 |
+
assert "DemoPrep" in html
|
| 42 |
+
assert "Demo Wire" not in html
|
| 43 |
+
assert "Wake Forest University" in html
|
| 44 |
+
assert "Program" in html
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_apply_blueprint_adds_filters_and_bottom_demo_tile():
|
| 48 |
+
tml = {
|
| 49 |
+
"liveboard": {
|
| 50 |
+
"name": "Test Board",
|
| 51 |
+
"visualizations": [
|
| 52 |
+
{"id": "Viz_1", "answer": {"name": "Applications", "chart": {"type": "KPI"}}},
|
| 53 |
+
{"id": "Viz_2", "answer": {"name": "Trend", "chart": {"type": "LINE"}}},
|
| 54 |
+
],
|
| 55 |
+
"layout": {
|
| 56 |
+
"tabs": [
|
| 57 |
+
{
|
| 58 |
+
"name": "Executive Overview",
|
| 59 |
+
"tiles": [
|
| 60 |
+
{"visualization_id": "Viz_1", "x": 0, "y": 0, "height": 5, "width": 6},
|
| 61 |
+
{"visualization_id": "Viz_2", "x": 6, "y": 0, "height": 5, "width": 6},
|
| 62 |
+
],
|
| 63 |
+
}
|
| 64 |
+
]
|
| 65 |
+
},
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
blueprint = {
|
| 69 |
+
"filters": [
|
| 70 |
+
{"column": ["Program Name"], "is_mandatory": False, "is_single_value": False, "display_name": "Program"}
|
| 71 |
+
],
|
| 72 |
+
"demo_notes_tile": {"id": DEMO_GUIDE_VIZ_ID, "html": "<div>DemoPrep guide</div>"},
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
updated, changes = apply_blueprint_to_liveboard_tml(tml, blueprint)
|
| 76 |
+
liveboard = updated["liveboard"]
|
| 77 |
+
|
| 78 |
+
assert changes == ["Added 1 global filter(s)", "Added demo guide tile"]
|
| 79 |
+
assert liveboard["filters"][0]["display_name"] == "Program"
|
| 80 |
+
assert any(viz["id"] == DEMO_GUIDE_VIZ_ID for viz in liveboard["visualizations"])
|
| 81 |
+
demo_tile = liveboard["layout"]["tabs"][0]["tiles"][-1]
|
| 82 |
+
assert demo_tile["visualization_id"] == DEMO_GUIDE_VIZ_ID
|
| 83 |
+
assert demo_tile["y"] == 5
|
| 84 |
+
assert demo_tile["width"] == 12
|
tools/liveboard_lab.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Internal Liveboard Lab for faster MCP-first/TML-polish iteration.
|
| 3 |
+
|
| 4 |
+
Examples:
|
| 5 |
+
python tools/liveboard_lab.py plan --company Nike --use-case "Retail Sales"
|
| 6 |
+
|
| 7 |
+
python tools/liveboard_lab.py create \
|
| 8 |
+
--model-id 00000000-0000-0000-0000-000000000000 \
|
| 9 |
+
--model-name NIKE_RETAIL_mdl \
|
| 10 |
+
--company Nike \
|
| 11 |
+
--use-case "Retail Sales"
|
| 12 |
+
|
| 13 |
+
python tools/liveboard_lab.py enhance-existing \
|
| 14 |
+
--liveboard-guid 00000000-0000-0000-0000-000000000000 \
|
| 15 |
+
--company Nike \
|
| 16 |
+
--use-case "Retail Sales"
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import sys
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import Any, Dict
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 30 |
+
if str(REPO_ROOT) not in sys.path:
|
| 31 |
+
sys.path.insert(0, str(REPO_ROOT))
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
from dotenv import load_dotenv
|
| 35 |
+
except ImportError: # pragma: no cover - dotenv is present in app envs
|
| 36 |
+
load_dotenv = None
|
| 37 |
+
|
| 38 |
+
from demoprep_app.liveboards.golden_layout import build_dashboard_plan
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def main() -> int:
|
| 42 |
+
parser = argparse.ArgumentParser(
|
| 43 |
+
description="Create or re-enhance golden-demo-style liveboards from existing ThoughtSpot models."
|
| 44 |
+
)
|
| 45 |
+
parser.add_argument(
|
| 46 |
+
"--env-file",
|
| 47 |
+
default="",
|
| 48 |
+
help="Optional .env file to load before reading ThoughtSpot and LLM settings.",
|
| 49 |
+
)
|
| 50 |
+
parser.add_argument(
|
| 51 |
+
"--ts-env-index",
|
| 52 |
+
default="1",
|
| 53 |
+
help="Fallback TS_ENV_<n> environment index used by older DemoPrep .env files.",
|
| 54 |
+
)
|
| 55 |
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
| 56 |
+
|
| 57 |
+
plan_parser = subparsers.add_parser("plan", help="Print the dashboard plan only.")
|
| 58 |
+
_add_context_args(plan_parser)
|
| 59 |
+
plan_parser.add_argument("--target-viz-count", type=int, default=16)
|
| 60 |
+
|
| 61 |
+
create_parser = subparsers.add_parser("create", help="Create a liveboard from an existing model ID.")
|
| 62 |
+
_add_context_args(create_parser)
|
| 63 |
+
_add_auth_args(create_parser)
|
| 64 |
+
create_parser.add_argument("--model-id", required=True)
|
| 65 |
+
create_parser.add_argument("--model-name", required=True)
|
| 66 |
+
create_parser.add_argument("--target-viz-count", type=int, default=16)
|
| 67 |
+
create_parser.add_argument("--liveboard-name", default="")
|
| 68 |
+
create_parser.add_argument("--llm-model", default="")
|
| 69 |
+
|
| 70 |
+
enhance_parser = subparsers.add_parser(
|
| 71 |
+
"enhance-existing",
|
| 72 |
+
help="Apply the lab golden two-tab TML enhancement to an existing liveboard.",
|
| 73 |
+
)
|
| 74 |
+
_add_context_args(enhance_parser)
|
| 75 |
+
_add_auth_args(enhance_parser)
|
| 76 |
+
enhance_parser.add_argument("--liveboard-guid", required=True)
|
| 77 |
+
enhance_parser.add_argument("--llm-model", default="")
|
| 78 |
+
|
| 79 |
+
args = parser.parse_args()
|
| 80 |
+
|
| 81 |
+
if load_dotenv:
|
| 82 |
+
if args.env_file:
|
| 83 |
+
load_dotenv(args.env_file, override=False)
|
| 84 |
+
load_dotenv(REPO_ROOT / ".env", override=False)
|
| 85 |
+
_apply_legacy_ts_env(args.ts_env_index)
|
| 86 |
+
|
| 87 |
+
company_data = {
|
| 88 |
+
"name": args.company,
|
| 89 |
+
"website": args.website,
|
| 90 |
+
"research": args.research,
|
| 91 |
+
"additional_context": args.additional_context,
|
| 92 |
+
"use_case": args.use_case,
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
if args.command == "plan":
|
| 96 |
+
plan = build_dashboard_plan(args.company, args.use_case, args.target_viz_count)
|
| 97 |
+
print(json.dumps(plan, indent=2))
|
| 98 |
+
return 0
|
| 99 |
+
|
| 100 |
+
from liveboard_creator import create_liveboard_from_model_mcp, enhance_mcp_liveboard
|
| 101 |
+
|
| 102 |
+
ts_client = _build_ts_client(args)
|
| 103 |
+
if not ts_client.authenticate():
|
| 104 |
+
print("ThoughtSpot authentication failed.", file=sys.stderr)
|
| 105 |
+
return 2
|
| 106 |
+
|
| 107 |
+
if args.command == "enhance-existing":
|
| 108 |
+
result = enhance_mcp_liveboard(
|
| 109 |
+
liveboard_guid=args.liveboard_guid,
|
| 110 |
+
company_data=company_data,
|
| 111 |
+
ts_client=ts_client,
|
| 112 |
+
add_groups=True,
|
| 113 |
+
fix_kpis=True,
|
| 114 |
+
apply_brand_colors=True,
|
| 115 |
+
add_layout=True,
|
| 116 |
+
llm_model=args.llm_model or None,
|
| 117 |
+
layout_strategy="golden_two_tab",
|
| 118 |
+
)
|
| 119 |
+
print(json.dumps(result, indent=2))
|
| 120 |
+
return 0 if result.get("success") else 1
|
| 121 |
+
|
| 122 |
+
liveboard_name = args.liveboard_name.strip() or f"{args.company} - {args.use_case} Lab"
|
| 123 |
+
result = create_liveboard_from_model_mcp(
|
| 124 |
+
ts_client=ts_client,
|
| 125 |
+
model_id=args.model_id,
|
| 126 |
+
model_name=args.model_name,
|
| 127 |
+
company_data=company_data,
|
| 128 |
+
use_case=args.use_case,
|
| 129 |
+
num_visualizations=args.target_viz_count,
|
| 130 |
+
liveboard_name=liveboard_name,
|
| 131 |
+
llm_model=args.llm_model or None,
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
if not result.get("success"):
|
| 135 |
+
print(json.dumps(result, indent=2))
|
| 136 |
+
return 1
|
| 137 |
+
|
| 138 |
+
liveboard_guid = result.get("liveboard_guid") or result.get("liveboard_id")
|
| 139 |
+
if liveboard_guid:
|
| 140 |
+
enhance_result = enhance_mcp_liveboard(
|
| 141 |
+
liveboard_guid=liveboard_guid,
|
| 142 |
+
company_data=company_data,
|
| 143 |
+
ts_client=ts_client,
|
| 144 |
+
add_groups=True,
|
| 145 |
+
fix_kpis=True,
|
| 146 |
+
apply_brand_colors=True,
|
| 147 |
+
add_layout=True,
|
| 148 |
+
llm_model=args.llm_model or None,
|
| 149 |
+
layout_strategy="golden_two_tab",
|
| 150 |
+
)
|
| 151 |
+
result["lab_enhancement"] = enhance_result
|
| 152 |
+
|
| 153 |
+
print(json.dumps(result, indent=2))
|
| 154 |
+
return 0 if result.get("success") else 1
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _add_context_args(parser: argparse.ArgumentParser) -> None:
|
| 158 |
+
parser.add_argument("--company", required=True)
|
| 159 |
+
parser.add_argument("--use-case", required=True)
|
| 160 |
+
parser.add_argument("--website", default="")
|
| 161 |
+
parser.add_argument("--research", default="")
|
| 162 |
+
parser.add_argument("--additional-context", default="")
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _add_auth_args(parser: argparse.ArgumentParser) -> None:
|
| 166 |
+
parser.add_argument("--ts-url", default="")
|
| 167 |
+
parser.add_argument("--ts-username", default="")
|
| 168 |
+
parser.add_argument("--ts-secret-key", default="")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _apply_legacy_ts_env(index: str) -> None:
|
| 172 |
+
"""Map older TS_ENV_<n> settings to the names the lab expects."""
|
| 173 |
+
if not os.getenv("THOUGHTSPOT_URL"):
|
| 174 |
+
os.environ["THOUGHTSPOT_URL"] = os.getenv(f"TS_ENV_{index}_URL", "")
|
| 175 |
+
if not os.getenv("THOUGHTSPOT_USERNAME"):
|
| 176 |
+
os.environ["THOUGHTSPOT_USERNAME"] = os.getenv("TEST_USER", "")
|
| 177 |
+
if not os.getenv("THOUGHTSPOT_TRUSTED_AUTH_KEY"):
|
| 178 |
+
key_value = os.getenv(f"TS_ENV_{index}_KEY_VAR", "")
|
| 179 |
+
if key_value:
|
| 180 |
+
os.environ["THOUGHTSPOT_TRUSTED_AUTH_KEY"] = os.getenv(key_value, key_value)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _build_ts_client(args: argparse.Namespace) -> Any:
|
| 184 |
+
missing = [
|
| 185 |
+
name
|
| 186 |
+
for name, value in {
|
| 187 |
+
"--ts-url or THOUGHTSPOT_URL": args.ts_url or os.getenv("THOUGHTSPOT_URL", ""),
|
| 188 |
+
"--ts-username or THOUGHTSPOT_USERNAME": args.ts_username or os.getenv("THOUGHTSPOT_USERNAME", ""),
|
| 189 |
+
"--ts-secret-key or THOUGHTSPOT_TRUSTED_AUTH_KEY": args.ts_secret_key or os.getenv("THOUGHTSPOT_TRUSTED_AUTH_KEY", ""),
|
| 190 |
+
}.items()
|
| 191 |
+
if not value
|
| 192 |
+
]
|
| 193 |
+
if missing:
|
| 194 |
+
raise SystemExit(f"Missing ThoughtSpot settings: {', '.join(missing)}")
|
| 195 |
+
return LabThoughtSpotClient(
|
| 196 |
+
base_url=args.ts_url or os.getenv("THOUGHTSPOT_URL", ""),
|
| 197 |
+
username=args.ts_username or os.getenv("THOUGHTSPOT_USERNAME", ""),
|
| 198 |
+
secret_key=args.ts_secret_key or os.getenv("THOUGHTSPOT_TRUSTED_AUTH_KEY", ""),
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
class LabThoughtSpotClient:
|
| 203 |
+
"""Minimal ThoughtSpot client for liveboard lab flows only."""
|
| 204 |
+
|
| 205 |
+
def __init__(self, base_url: str, username: str, secret_key: str):
|
| 206 |
+
import requests
|
| 207 |
+
|
| 208 |
+
self.base_url = base_url.rstrip("/")
|
| 209 |
+
self.username = username
|
| 210 |
+
self.secret_key = secret_key
|
| 211 |
+
self.session = requests.Session()
|
| 212 |
+
self.session.headers.update(
|
| 213 |
+
{
|
| 214 |
+
"Content-Type": "application/json",
|
| 215 |
+
"X-Requested-By": "ThoughtSpot",
|
| 216 |
+
}
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
def authenticate(self) -> bool:
|
| 220 |
+
response = self.session.post(
|
| 221 |
+
f"{self.base_url}/api/rest/2.0/auth/token/full",
|
| 222 |
+
json={
|
| 223 |
+
"username": self.username,
|
| 224 |
+
"secret_key": self.secret_key,
|
| 225 |
+
"validity_time_in_sec": 3600,
|
| 226 |
+
},
|
| 227 |
+
)
|
| 228 |
+
if response.status_code == 200:
|
| 229 |
+
payload = response.json()
|
| 230 |
+
token = payload.get("token")
|
| 231 |
+
if token:
|
| 232 |
+
self.session.headers["Authorization"] = f"Bearer {token}"
|
| 233 |
+
return True
|
| 234 |
+
if response.status_code == 204:
|
| 235 |
+
return True
|
| 236 |
+
print(f"ThoughtSpot auth failed: HTTP {response.status_code} {response.text[:300]}", file=sys.stderr)
|
| 237 |
+
return False
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
if __name__ == "__main__":
|
| 241 |
+
raise SystemExit(main())
|