mike boone commited on
Commit
ac5b642
·
1 Parent(s): b25f47f

feat: add liveboard blueprint planning

Browse files
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("&", "&amp;")
310
+ .replace("<", "&lt;")
311
+ .replace(">", "&gt;")
312
+ .replace('"', "&quot;")
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
- # Cleaner note tile - simpler format like golden demo
641
- note_tile = f"""<h2 class="theme-module__editor-h2" dir="ltr"><span style="color: rgb(255, 255, 255); white-space: pre-wrap;">{company_name} {use_case_display}</span></h2><hr><p class="theme-module__editor-paragraph" dir="ltr"><span style="color: rgb(255, 255, 255); white-space: pre-wrap;">Featuring </span><b><strong class="theme-module__editor-text-bold" style="color: rgb(64, 193, 192); white-space: pre-wrap;">{viz_count} visualizations</strong></b><span style="color: rgb(255, 255, 255); white-space: pre-wrap;"> powered by ThoughtSpot AI</span></p><p class="theme-module__editor-paragraph"><br></p><p class="theme-module__editor-paragraph" dir="ltr"><span style="color: rgb(255, 255, 255); white-space: pre-wrap;">Built with </span><b><strong class="theme-module__editor-text-bold" style="color: rgb(64, 193, 192); white-space: pre-wrap;">Demo Wire</strong></b></p>"""
 
 
 
 
 
 
 
 
 
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 to KPIs with sparklines
 
 
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 {len(note_vizs)} note tile(s)")
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
- if kpi_vizs:
 
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
- for note_id in note_vizs:
4181
- existing = next((o for o in style['overrides'] if o.get('object_id') == note_id), None)
4182
- if not existing:
4183
- style['overrides'].append({
4184
- 'object_id': note_id,
4185
- 'style_properties': [
4186
- {'name': 'tile_brand_color', 'value': 'TBC_I'}
4187
- ]
4188
- })
4189
- colors_applied += 1
 
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
- groups = liveboard_tml.get('liveboard', {}).get('groups', [])
4211
- all_viz_ids = kpi_vizs + trend_vizs + bar_vizs + geo_vizs + table_vizs + other_vizs
4212
-
4213
- # Collect grouped visualization IDs
4214
- grouped_vizs = set()
4215
- for group in groups:
4216
- grouped_vizs.update(group.get('visualizations', []))
4217
-
4218
- # Ungrouped visualizations (everything not in a group)
4219
- ungrouped = [v for v in all_viz_ids if v not in grouped_vizs]
4220
-
4221
- # === Build tab tiles: groups FIRST (KPIs at top), then ungrouped below ===
4222
- tab_tiles = []
4223
- row = 0
4224
-
4225
- # Add KPI group at the very top spanning full width
4226
- for group in groups:
4227
- grp_id = group.get('id')
4228
- grp_vizs = group.get('visualizations', [])
4229
- # KPI group spans full width at top
4230
- group_height = max(3, (len(grp_vizs) + 1) // 2 * 3) # 3 rows per pair
4231
- tab_tiles.append({
4232
- 'visualization_id': grp_id,
4233
- 'x': 0,
4234
- 'y': row,
4235
- 'height': group_height,
4236
- 'width': 12 # Full width for KPI group
4237
- })
4238
- row += group_height
4239
-
4240
- # Add ungrouped visualizations in 2-column grid below groups
4241
- for i, viz_id in enumerate(ungrouped):
4242
- tab_tiles.append({
4243
- 'visualization_id': viz_id,
4244
- 'x': 0 if i % 2 == 0 else 6,
4245
- 'y': row + (i // 2) * 5,
4246
- 'height': 5,
4247
- 'width': 6
4248
- })
4249
-
4250
- # === Build group_layouts: define tile positions INSIDE each group ===
4251
- group_layouts = []
4252
- for group in groups:
4253
- grp_id = group.get('id')
4254
- grp_vizs = group.get('visualizations', [])
4255
-
4256
- grp_tiles = []
4257
- for j, gviz_id in enumerate(grp_vizs):
4258
- # KPIs: 2-column layout inside group, each 3 rows tall
4259
- grp_tiles.append({
4260
- 'visualization_id': gviz_id,
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.append({
4268
- 'id': grp_id,
4269
- 'tiles': grp_tiles
4270
- })
4271
-
4272
- # === Assemble the layout with tabs structure ===
4273
- if tab_tiles:
4274
- tab = {
4275
- 'name': 'Overview',
4276
- 'description': '',
4277
- 'tiles': tab_tiles
4278
- }
4279
- if group_layouts:
4280
- tab['group_layouts'] = group_layouts
4281
-
4282
- liveboard_tml['liveboard']['layout'] = {
4283
- 'tabs': [tab]
4284
- }
4285
- total_tiles = len(tab_tiles) + sum(len(gl.get('tiles', [])) for gl in group_layouts)
4286
- enhancements_applied.append(f"Added tab layout ({total_tiles} tiles, {len(group_layouts)} group layouts)")
4287
- print(f" [OK] Added tab layout: {len(tab_tiles)} top-level tiles, {len(group_layouts)} group layouts", flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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())