mikeboone Claude Opus 5.5 commited on
Commit
619d0b1
·
1 Parent(s): e429fd8

feat(mcp): opt-in header tiles — logo_tile and insight_tile

Browse files

build_demo_from_brief gains two flags, both OFF unless Agent Q sends them
(bool | str = False, read with the portal's _as_bool). Omitted, false or blank:
no header-tile work at all and the liveboard TML is byte-for-byte today's
(pinned against the real enhance_mcp_liveboard run offline).

Insight tile: verified on secloud 2026-10-20 that it IS TML. An exported board
carries it as a visualization with an ai_tile block (target_agent
insight_code_runner, template_id text_long_form, analysis_code = gzip+base64
pandas reading each source viz as <viz_guid>.csv). The narrative is written by
ThoughtSpot at view time, not stored. A TML import creates it (ZZ throwaway
board). DemoPrep generates the analysis from the first tab's KPIs and driver
charts; the code was run against the board's real CSVs.

Logo tile: a note tile in ThoughtSpot's own editor markup — the portal's logo
(its brand-logo.svg, else the builder's wordmark via the new wordmark_svg) as a
data: URI linked to the TSE portal, the tab name in bold, "<use case> at a
glance." and the board's KPI measures in bold. No portal -> company website +
warning; neither -> tile left out + warning.

Placement, first tab, left to right: logo, insight, KPI group. Logo only:
3 | 9. With the insight tile: 2 | 4 | 6 and the KPIs two per row (same width
each as today; the row doubles in height).

Runs in _run_build AFTER the chained portal (the link needs its URL), as one
export -> edit -> import of the finished board. A tile that cannot be built or
that ThoughtSpot rejects is left out with a warning; nothing fails the build.

Suite: 2 failed (pre-existing) / 1400 passed -> 2 failed / 1448 passed.

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

demoprep_app/liveboards/header_tiles.py ADDED
@@ -0,0 +1,823 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Opt-in header tiles for the first tab of a DemoPrep liveboard (boone, 2026-10-20).
2
+
3
+ Two tiles, each OFF unless Agent Q asks for it on `build_demo_from_brief`:
4
+
5
+ * ``logo_tile`` — a note tile: the company logo (linked to the TSE portal),
6
+ a bold heading (the tab's name, "Executive Overview"), an italic tagline and a
7
+ sentence naming the board's headline measures in bold.
8
+ * ``insight_tile`` — ThoughtSpot's AI insight tile. Verified against secloud on
9
+ 2026-10-20: a liveboard TML carries it as a visualization with an ``ai_tile``
10
+ block (``target_agent: insight_code_runner``, ``template_id: text_long_form``,
11
+ ``analysis_code``: gzip+base64 pandas code). The code reads each source
12
+ visualization's data as ``<viz_guid>.csv`` and prints an analysis; ThoughtSpot
13
+ runs it and writes the narrative card AT VIEW TIME. The narrative text is not
14
+ stored in the TML, so what we write is the analysis, never the prose. A TML
15
+ import creates the tile (scratch/header_tiles evidence; ZZ throwaway board).
16
+
17
+ Order on the first tab, left to right: logo tile, insight tile, KPI group.
18
+
19
+ They are added AFTER the board is enhanced and after the chained portal build,
20
+ in one export -> edit -> import pass, because:
21
+
22
+ * the logo links to the portal, whose URL exists only once the portal is built;
23
+ * the insight code must name the board's real viz GUIDs, which only an export
24
+ of the live board guarantees.
25
+
26
+ A failure here never fails the build: it comes back as a warning and the tile
27
+ is left out. With both flags off this module is never called.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import base64
33
+ import gzip
34
+ import html
35
+ import json
36
+ import re
37
+ from copy import deepcopy
38
+ from typing import Any, Dict, List, Optional, Tuple
39
+ from urllib.parse import urlparse
40
+
41
+ LOGO_TILE_ID = "Viz_header_logo"
42
+ INSIGHT_TILE_ID = "Viz_header_insight"
43
+
44
+ # What ThoughtSpot itself writes for an insight tile (exported from boone's
45
+ # example board d062e894, 2026-10-20). Not documented; read off a real export.
46
+ INSIGHT_TARGET_AGENT = "insight_code_runner"
47
+ INSIGHT_TEMPLATE_ID = "text_long_form"
48
+
49
+ # Sources the insight code reads: the headline KPIs plus a few driver charts.
50
+ MAX_TREND_SOURCES = 4
51
+ MAX_BREAKDOWN_SOURCES = 3
52
+
53
+ # Widths on the 12-column grid. With the insight tile on, the KPI group keeps
54
+ # six columns and its tiles go two per row, so every KPI is exactly as wide as
55
+ # on today's board (four across twelve columns); the row doubles in height to
56
+ # give the narrative room.
57
+ KPI_GROUP_WIDTH_WITH_INSIGHT = 6
58
+ LOGO_WIDTH_WITH_INSIGHT = 2
59
+ LOGO_WIDTH_ALONE = 3
60
+
61
+ _AGG_PREFIX_RE = re.compile(
62
+ r"^(total|sum of|sum|average|avg|count of|count|unique count of|unique count|"
63
+ r"min|max|median)\s+", re.I)
64
+ _TARGET_WORDS = ("target", "goal", "budget", "plan", "quota", "forecast")
65
+
66
+
67
+ # --------------------------------------------------------------------- flags
68
+
69
+ def resolve_header_tile_flags(logo_tile: Any, insight_tile: Any) -> Tuple[bool, bool, List[str]]:
70
+ """Read the two flags the way the portal flags are read (``_as_bool``).
71
+
72
+ Omitted, False, None or blank -> False. An unreadable value is reported
73
+ and read as False — never as a yes.
74
+ """
75
+ from demoprep_app.portal.builder import _as_bool
76
+
77
+ notes: List[str] = []
78
+ logo = _as_bool(logo_tile, "logo_tile", False, notes)
79
+ insight = _as_bool(insight_tile, "insight_tile", False, notes)
80
+ return logo, insight, notes
81
+
82
+
83
+ # ------------------------------------------------------------ TML inspection
84
+
85
+ def _column_name(column: Any) -> str:
86
+ if isinstance(column, str):
87
+ return column
88
+ if isinstance(column, dict):
89
+ for key in ("name", "column_id", "id"):
90
+ if column.get(key):
91
+ return str(column[key])
92
+ return ""
93
+
94
+
95
+ def _answer_columns(answer: Dict[str, Any]) -> List[str]:
96
+ return [n for n in (_column_name(c) for c in answer.get("answer_columns") or []) if n]
97
+
98
+
99
+ def _is_measure(name: str) -> bool:
100
+ from demoprep_app.liveboards.golden_layout import _is_measure_name
101
+
102
+ return _is_measure_name(name)
103
+
104
+
105
+ def _is_date(name: str) -> bool:
106
+ from demoprep_app.liveboards.golden_layout import _looks_like_date_column
107
+
108
+ return _looks_like_date_column(name)
109
+
110
+
111
+ def measure_phrase(name: str) -> str:
112
+ """'Total Units Produced' -> 'units produced'. Acronyms keep their case."""
113
+ bare = _AGG_PREFIX_RE.sub("", (name or "").strip())
114
+ words = []
115
+ for word in bare.split():
116
+ words.append(word if (len(word) > 1 and word.isupper()) else word.lower())
117
+ return " ".join(words)
118
+
119
+
120
+ def dimension_phrase(name: str) -> str:
121
+ """'Maintenance Type Name' -> 'maintenance type'; 'Region' -> 'region'."""
122
+ words = measure_phrase(name).split()
123
+ if len(words) > 1 and words[-1] == "name":
124
+ words = words[:-1]
125
+ return " ".join(words)
126
+
127
+
128
+ def _sentence_case(text: str) -> str:
129
+ words = (text or "").split()
130
+ out = []
131
+ for i, word in enumerate(words):
132
+ if i and not (len(word) > 1 and word.isupper()):
133
+ word = word.lower()
134
+ out.append(word)
135
+ phrase = " ".join(out)
136
+ return phrase[:1].upper() + phrase[1:]
137
+
138
+
139
+ def _aggregation(measure: str) -> str:
140
+ """'mean' for averages and rates, 'sum' for additive measures."""
141
+ from demoprep_app.dataset.measure_names import is_intensive_measure
142
+
143
+ lowered = measure.lower().strip()
144
+ if lowered.startswith(("average ", "avg ")):
145
+ return "mean"
146
+ return "mean" if is_intensive_measure(_AGG_PREFIX_RE.sub("", measure)) else "sum"
147
+
148
+
149
+ def first_tab(liveboard: Dict[str, Any]) -> Optional[Dict[str, Any]]:
150
+ tabs = (liveboard.get("layout") or {}).get("tabs") or []
151
+ return tabs[0] if tabs and isinstance(tabs[0], dict) else None
152
+
153
+
154
+ def _kpi_group(liveboard: Dict[str, Any], tab: Dict[str, Any]) -> Optional[Dict[str, Any]]:
155
+ """The KPI group on the tab: a group named '...Metric...' with a tile on it."""
156
+ on_tab = {t.get("visualization_id") for t in tab.get("tiles") or []}
157
+ for group in liveboard.get("groups") or []:
158
+ if "Metric" in (group.get("name") or "") and group.get("id") in on_tab:
159
+ return group
160
+ return None
161
+
162
+
163
+ def _ordered_group_vizzes(tab: Dict[str, Any], group_id: str, fallback: List[str]) -> List[str]:
164
+ for gl in tab.get("group_layouts") or []:
165
+ if gl.get("id") == group_id:
166
+ tiles = sorted(gl.get("tiles") or [], key=lambda t: (t.get("y", 0), t.get("x", 0)))
167
+ return [t.get("visualization_id") for t in tiles if t.get("visualization_id")]
168
+ return list(fallback)
169
+
170
+
171
+ def _tab_viz_ids_in_order(liveboard: Dict[str, Any], tab: Dict[str, Any]) -> List[str]:
172
+ """Every viz on the tab, groups expanded, top-to-bottom / left-to-right."""
173
+ groups = {g.get("id"): g for g in liveboard.get("groups") or []}
174
+ out: List[str] = []
175
+ for tile in sorted(tab.get("tiles") or [], key=lambda t: (t.get("y", 0), t.get("x", 0))):
176
+ vid = tile.get("visualization_id")
177
+ if vid in groups:
178
+ out.extend(_ordered_group_vizzes(tab, vid, groups[vid].get("visualizations") or []))
179
+ elif vid:
180
+ out.append(vid)
181
+ return out
182
+
183
+
184
+ def board_story(liveboard: Dict[str, Any]) -> Dict[str, Any]:
185
+ """What the first tab says: its name, KPI vizzes, driver charts, measures, dimensions."""
186
+ tab = first_tab(liveboard)
187
+ if tab is None:
188
+ return {"tab_name": "", "kpis": [], "drivers": [], "measures": [], "dimensions": []}
189
+ by_id = {v.get("id"): v for v in liveboard.get("visualizations") or []}
190
+ kpi_group = _kpi_group(liveboard, tab)
191
+ kpi_ids = set(_ordered_group_vizzes(tab, kpi_group["id"], kpi_group.get("visualizations") or [])
192
+ if kpi_group else [])
193
+ kpis, drivers, measures, dimensions = [], [], [], []
194
+ for vid in _tab_viz_ids_in_order(liveboard, tab):
195
+ viz = by_id.get(vid) or {}
196
+ answer = viz.get("answer")
197
+ if not isinstance(answer, dict):
198
+ continue # note / ai tiles carry no data
199
+ cols = _answer_columns(answer)
200
+ dates = [c for c in cols if _is_date(c)]
201
+ ms = [c for c in cols if c not in dates and _is_measure(c)]
202
+ attrs = [c for c in cols if c not in dates and c not in ms]
203
+ chart_type = ((answer.get("chart") or {}).get("type") or "").upper()
204
+ entry = {"id": vid, "viz_guid": viz.get("viz_guid") or "", "title": answer.get("name") or vid,
205
+ "date": dates[0] if dates else "", "measures": ms, "attributes": attrs}
206
+ if vid in kpi_ids or chart_type == "KPI":
207
+ if not ms and len(cols) >= 2:
208
+ # A KPI's measure may lack an aggregation prefix; it is the non-date column.
209
+ ms = [c for c in cols if c not in dates][:1]
210
+ entry["measures"] = ms
211
+ attrs = [] # a KPI has no breakdown dimension
212
+ kpis.append(entry)
213
+ elif ms and attrs:
214
+ drivers.append(entry)
215
+ for m in ms:
216
+ if m not in measures:
217
+ measures.append(m)
218
+ for a in attrs:
219
+ if a not in dimensions:
220
+ dimensions.append(a)
221
+ kpi_measures = [k["measures"][0] for k in kpis if k["measures"]]
222
+ return {"tab_name": tab.get("name") or "", "kpis": kpis, "drivers": drivers,
223
+ "measures": kpi_measures or measures, "dimensions": dimensions}
224
+
225
+
226
+ # ----------------------------------------------------------------- logo tile
227
+
228
+ def normalize_link(url: str) -> str:
229
+ """An http(s) URL with a host, or "" when it cannot be one."""
230
+ text = (url or "").strip()
231
+ if not text:
232
+ return ""
233
+ if "://" not in text:
234
+ text = "https://" + text
235
+ parsed = urlparse(text)
236
+ if parsed.scheme not in ("http", "https") or not parsed.netloc or " " in parsed.netloc:
237
+ return ""
238
+ return text
239
+
240
+
241
+ def logo_tile_html(*, logo_svg: str, link: str, company: str, heading: str,
242
+ tagline: str, measures: List[str], dimensions: List[str]) -> str:
243
+ """The note tile, in the markup ThoughtSpot's own note editor writes for a
244
+ linked image + bold heading + italic line + paragraph (read off the Viz_23
245
+ logo tile on boone's example board)."""
246
+ if not logo_svg.strip():
247
+ raise ValueError("logo SVG is empty")
248
+ if not link:
249
+ raise ValueError("logo tile needs a link")
250
+ src = "data:image/svg+xml;base64," + base64.b64encode(logo_svg.encode("utf-8")).decode("ascii")
251
+ esc = lambda s: html.escape(str(s), quote=True) # noqa: E731
252
+ span = lambda s: f'<span style="white-space: pre-wrap;">{esc(s)}</span>' # noqa: E731
253
+ bold = lambda s: (f'<b><strong class="theme-module__editor-text-bold" ' # noqa: E731
254
+ f'style="white-space: pre-wrap;">{esc(s)}</strong></b>')
255
+
256
+ phrases = [measure_phrase(m) for m in measures[:3] if measure_phrase(m)]
257
+ parts: List[str] = []
258
+ if phrases:
259
+ parts.append(span("Tracks "))
260
+ for i, phrase in enumerate(phrases):
261
+ if i:
262
+ parts.append(span(", and " if i == len(phrases) - 1 and len(phrases) > 2
263
+ else " and " if i == len(phrases) - 1 else ", "))
264
+ parts.append(bold(phrase))
265
+ parts.append(span(". "))
266
+ dims = [dimension_phrase(d) for d in dimensions[:3] if dimension_phrase(d)]
267
+ if dims:
268
+ listed = dims[0] if len(dims) == 1 else ", ".join(dims[:-1]) + " and " + dims[-1]
269
+ parts.append(span(f"Drill into {listed} to see what drives each one."))
270
+ else:
271
+ parts.append(span("Drill into the charts below to see what drives each one."))
272
+
273
+ return (
274
+ '<p class="theme-module__editor-paragraph">'
275
+ f'<a href="{esc(link)}" target="_blank" rel="noopener noreferrer" data-image-link="true" '
276
+ 'style="width: 100%; max-width: 100%;">'
277
+ f'<img src="{src}" alt="{esc(company)} logo" '
278
+ 'style="width: 100%; height: inherit; max-width: 100%;">'
279
+ f'<span data-link-overlay="true">{esc(link)}</span></a></p>'
280
+ '<p class="theme-module__editor-paragraph"><br></p>'
281
+ f'<p class="theme-module__editor-paragraph">{bold(heading)}</p>'
282
+ '<p class="theme-module__editor-paragraph"><i><em class="theme-module__editor-text-italic" '
283
+ f'style="white-space: pre-wrap;">{esc(tagline)}</em></i></p>'
284
+ f'<p class="theme-module__editor-paragraph">{"".join(parts)}</p>'
285
+ )
286
+
287
+
288
+ def portal_logo_svg(company: str, portal: Optional[Dict[str, Any]]) -> Tuple[str, str]:
289
+ """The logo the TSE portal shows, as SVG text, plus where it came from.
290
+
291
+ A built portal leaves the exact file it shipped in its work dir
292
+ (builder.build_portal writes ``brand-logo.svg`` there: the caller's logo
293
+ when one was given, else the generated wordmark). Without a portal, the
294
+ wordmark the portal builder would have drawn, with its default primary.
295
+ """
296
+ from pathlib import Path
297
+
298
+ from demoprep_app.portal.builder import DEFAULT_PRIMARY, wordmark_svg
299
+
300
+ work_dir = (portal or {}).get("work_dir") or ""
301
+ if work_dir:
302
+ path = Path(work_dir) / "brand-logo.svg"
303
+ if path.is_file():
304
+ return path.read_text(encoding="utf-8"), "the TSE portal's logo"
305
+ return wordmark_svg(company, DEFAULT_PRIMARY), "the portal builder's wordmark"
306
+
307
+
308
+ # -------------------------------------------------------------- insight tile
309
+
310
+ def _slug(text: str, used: set) -> str:
311
+ base = re.sub(r"[^a-z0-9]+", "_", (text or "").lower()).strip("_")[:40] or "viz"
312
+ key, n = base, 2
313
+ while key in used:
314
+ key, n = f"{base}_{n}", n + 1
315
+ used.add(key)
316
+ return key
317
+
318
+
319
+ def insight_sources(story: Dict[str, Any]) -> List[Dict[str, Any]]:
320
+ """The vizzes the insight reads: KPI trends first, then driver charts.
321
+
322
+ Only vizzes with a viz_guid can be read — the code names its data files
323
+ by it — and the GUIDs come from an export of the live board.
324
+ """
325
+ used: set = set()
326
+ out: List[Dict[str, Any]] = []
327
+ for k in [k for k in story["kpis"] if k["viz_guid"] and k["measures"]][:MAX_TREND_SOURCES]:
328
+ m = k["measures"][0]
329
+ out.append({"key": _slug(k["title"], used), "file": f"{k['viz_guid']}.csv",
330
+ "title": k["title"], "kind": "trend", "date": k["date"],
331
+ "measures": [m], "aggs": [_aggregation(m)], "attributes": []})
332
+ for d in [d for d in story["drivers"] if d["viz_guid"]][:MAX_BREAKDOWN_SOURCES]:
333
+ ms = d["measures"][:3]
334
+ out.append({"key": _slug(d["title"], used), "file": f"{d['viz_guid']}.csv",
335
+ "title": d["title"], "kind": "breakdown", "date": d["date"],
336
+ "measures": ms, "aggs": [_aggregation(m) for m in ms],
337
+ "attributes": d["attributes"][:2]})
338
+ return out
339
+
340
+
341
+ _INSIGHT_BODY = r'''
342
+ _NUMERIC = r"[-+]?[$\u20ac\u00a3(]?[-+]?[\d,]*\.?\d+(?:[eE][-+]?\d+)?[%)]?"
343
+
344
+
345
+ def _num(series):
346
+ if series.dtype != object:
347
+ return pd.to_numeric(series, errors="coerce")
348
+ return pd.to_numeric(series.astype(str).str.replace(r"[^\d.\-eE]", "", regex=True), errors="coerce")
349
+
350
+
351
+ def _is_numeric(series):
352
+ if series.dtype != object:
353
+ return True
354
+ s = series.dropna().astype(str).str.strip()
355
+ return bool(len(s)) and s.str.fullmatch(_NUMERIC).mean() > 0.8
356
+
357
+
358
+ def _fmt(v):
359
+ if v is None or (isinstance(v, float) and np.isnan(v)):
360
+ return "n/a"
361
+ a = abs(v)
362
+ if a >= 100:
363
+ return f"{v:,.0f}"
364
+ if a >= 1:
365
+ return f"{v:,.2f}"
366
+ return f"{v:.4f}".rstrip("0").rstrip(".")
367
+
368
+
369
+ def _pct(new, old):
370
+ if old is None or new is None or pd.isna(old) or pd.isna(new) or old == 0:
371
+ return None
372
+ return (new - old) / abs(old) * 100
373
+
374
+
375
+ def _load(src):
376
+ """The viz's rows. Tolerates a report-style preamble ("Data extract produced
377
+ by ...", applied filters) above the header row."""
378
+ import csv
379
+ path = CSV_FILES[src["key"]]
380
+ with open(path, encoding="utf-8-sig", errors="replace") as fh:
381
+ head = list(csv.reader(fh.read().splitlines()[:20]))
382
+ wanted = set(src["measures"] + src["attributes"])
383
+ widest = max((len(r) for r in head), default=0)
384
+ start = 0
385
+ for i, row in enumerate(head):
386
+ if wanted & {c.strip() for c in row}:
387
+ start = i
388
+ break
389
+ else:
390
+ start = next((i for i, r in enumerate(head) if len(r) == widest and widest > 1), 0)
391
+ return pd.read_csv(path, skiprows=start)
392
+
393
+
394
+ def _col(df, name, role):
395
+ """The named column, else the closest by role (CSV headers can differ
396
+ from answer column names, e.g. 'Monthly Order Date' for 'Month(Order Date)')."""
397
+ if name in df.columns:
398
+ return name
399
+ low = {c.lower(): c for c in df.columns}
400
+ if name.lower() in low:
401
+ return low[name.lower()]
402
+ inner = re.sub(r"^.*\((.*)\)$", r"\1", name).lower()
403
+ for c in df.columns:
404
+ if inner and inner in c.lower():
405
+ return c
406
+ numeric = [c for c in df.columns if _is_numeric(df[c])]
407
+ if role == "measure":
408
+ return numeric[-1] if numeric else None
409
+ others = [c for c in df.columns if c not in numeric]
410
+ return others[0] if others else None
411
+
412
+
413
+ signals = []
414
+
415
+ for src in SOURCES:
416
+ title = src["title"]
417
+ try:
418
+ df = _load(src)
419
+ if src["kind"] == "trend":
420
+ print(f"=== Trend: {title} ===")
421
+ mcol = _col(df, src["measures"][0], "measure")
422
+ dcol = _col(df, src["date"], "date") if src["date"] else None
423
+ vals = _num(df[mcol])
424
+ frame = pd.DataFrame({"label": df[dcol].astype(str) if dcol else df.index.astype(str), "v": vals})
425
+ if dcol:
426
+ frame["d"] = pd.to_datetime(frame["label"], errors="coerce")
427
+ if frame["d"].notna().mean() > 0.6:
428
+ frame = frame.sort_values("d")
429
+ frame = frame.dropna(subset=["v"])
430
+ if len(frame) < 2:
431
+ print(f"{mcol}: not enough periods to describe a trend")
432
+ print()
433
+ continue
434
+ n = len(frame)
435
+ last, prev = frame.iloc[-1], frame.iloc[-2]
436
+ chg = _pct(last["v"], prev["v"])
437
+ print(f"{mcol}: latest {_fmt(last['v'])} ({last['label']}), previous {_fmt(prev['v'])} ({prev['label']})"
438
+ + (f", change {chg:+.1f}%" if chg is not None else ""))
439
+ if chg is not None:
440
+ signals.append((min(abs(chg), 60), f"{mcol} moved {chg:+.1f}% in the latest period ({last['label']} vs {prev['label']})."))
441
+ if n >= 6:
442
+ recent, prior = frame["v"].iloc[-3:].mean(), frame["v"].iloc[-6:-3].mean()
443
+ q = _pct(recent, prior)
444
+ if q is not None:
445
+ print(f"Last 3 periods average {_fmt(recent)} vs the 3 before {_fmt(prior)}: {q:+.1f}%")
446
+ signals.append((min(abs(q), 60) * 1.2, f"{mcol} averaged {_fmt(recent)} over the last 3 periods, {q:+.1f}% versus the 3 before."))
447
+ if n >= 15:
448
+ year_ago = frame["v"].iloc[-15:-12].mean()
449
+ y = _pct(frame["v"].iloc[-3:].mean(), year_ago)
450
+ if y is not None:
451
+ print(f"Last 3 periods vs the same 3 periods a year earlier ({_fmt(year_ago)}): {y:+.1f}%")
452
+ signals.append((min(abs(y), 60) * 1.1, f"{mcol} is {y:+.1f}% year over year (last 3 periods)."))
453
+ # The first and latest periods are often partial; leave them out of peak/low.
454
+ body = frame.iloc[1:-1] if n > 4 else frame
455
+ peak, low = body.loc[body["v"].idxmax()], body.loc[body["v"].idxmin()]
456
+ print(f"{n} periods from {frame.iloc[0]['label']} to {last['label']}; "
457
+ f"peak {_fmt(peak['v'])} in {peak['label']}, low {_fmt(low['v'])} in {low['label']}")
458
+ print("Note: the first and latest periods may be partial.")
459
+ print()
460
+ else:
461
+ print(f"=== Breakdown: {title} ===")
462
+ mcols = [_col(df, m, "measure") for m in src["measures"]]
463
+ pairs = [(c, a) for c, a in zip(mcols, src["aggs"]) if c]
464
+ for c, _ in pairs:
465
+ df[c] = _num(df[c])
466
+ acols = []
467
+ for name in src["attributes"]:
468
+ c = _col(df, name, "attribute")
469
+ if c and c not in acols and c not in mcols:
470
+ acols.append(c)
471
+ for acol in acols:
472
+ grouped = df.groupby(acol, dropna=False).agg({c: a for c, a in pairs})
473
+ print(f"By {acol} ({len(grouped)} values):")
474
+ for c, agg in pairs:
475
+ s = grouped[c].dropna().sort_values(ascending=False)
476
+ if s.empty:
477
+ continue
478
+ top_name, top_val = s.index[0], s.iloc[0]
479
+ bot_name, bot_val = s.index[-1], s.iloc[-1]
480
+ line = f"{c}: highest {top_name} ({_fmt(top_val)}), lowest {bot_name} ({_fmt(bot_val)})"
481
+ if agg == "sum" and s.sum():
482
+ share = top_val / s.sum() * 100
483
+ line += f"; total {_fmt(s.sum())}; {top_name} is {share:.1f}% of it"
484
+ signals.append((share / 2, f"{top_name} accounts for {share:.1f}% of {c} across {acol}."))
485
+ else:
486
+ line += f"; average across {acol} {_fmt(s.mean())}"
487
+ if bot_val and bot_val > 0 and len(s) > 1:
488
+ ratio = top_val / bot_val
489
+ line += f"; spread {ratio:.1f}x"
490
+ signals.append((min(ratio * 5, 60), f"{c} ranges {ratio:.1f}x across {acol}, from {bot_name} to {top_name}."))
491
+ print(line)
492
+ print(s.head(5).apply(_fmt).to_string())
493
+ targets = [c for c, _ in pairs if any(w in c.lower() for w in TARGET_WORDS)]
494
+ actuals = [c for c, _ in pairs if c not in targets]
495
+ if targets and actuals:
496
+ t, a = targets[0], actuals[0]
497
+ att = (grouped[a] / grouped[t].replace(0, np.nan) * 100).dropna().sort_values(ascending=False)
498
+ if not att.empty:
499
+ overall = grouped[a].sum() / grouped[t].sum() * 100 if grouped[t].sum() else None
500
+ below = int((att < 100).sum())
501
+ print(f"Attainment ({a} / {t}): " + (f"overall {overall:.1f}%, " if overall is not None else "")
502
+ + f"best {att.index[0]} {att.iloc[0]:.1f}%, worst {att.index[-1]} {att.iloc[-1]:.1f}%, "
503
+ + f"{below} of {len(att)} below 100%")
504
+ signals.append((80, f"{below} of {len(att)} {acol} values are below target on {a}."))
505
+ print()
506
+ except Exception as e:
507
+ print(f"[SKIPPED {title}] {type(e).__name__}: {e}")
508
+ print()
509
+
510
+ print("=== Key Signals ===")
511
+ seen = set()
512
+ rank = 0
513
+ for _, text in sorted(signals, key=lambda s: -s[0]):
514
+ if text in seen:
515
+ continue
516
+ seen.add(text)
517
+ rank += 1
518
+ print(f"{rank}. {text}")
519
+ if rank == 5:
520
+ break
521
+ if not rank:
522
+ print("No signal could be computed from the source visualizations.")
523
+ '''
524
+
525
+
526
+ def insight_analysis_code(company: str, use_case: str, sources: List[Dict[str, Any]]) -> str:
527
+ """The pandas program the insight tile runs. Same shape as ThoughtSpot's own:
528
+ a user query, a summary, a CSV_FILES map of '<viz_guid>.csv', then sections."""
529
+ if not sources:
530
+ raise ValueError("no source visualizations for the insight tile")
531
+ query = (f"Write an executive insight for {company}'s {use_case} liveboard: lead with the "
532
+ "most important change in the headline metrics, quantify it, name the segments "
533
+ "driving it, and say where to act.")
534
+ summary = (f"{company} {use_case}: " + ", ".join(
535
+ s["title"] for s in sources) + ".")
536
+ csv_lines = "\n".join(f" {json.dumps(s['key'])}: {json.dumps(s['file'])}," for s in sources)
537
+ spec = [{k: s[k] for k in ("key", "title", "kind", "date", "measures", "aggs", "attributes")}
538
+ for s in sources]
539
+ return (
540
+ "\nimport re\nimport warnings\nimport pandas as pd\nimport numpy as np\n\n"
541
+ 'warnings.filterwarnings("ignore")\n\n'
542
+ 'print("=== User Query ===")\n'
543
+ f"print({json.dumps(query)})\n"
544
+ "print()\n"
545
+ 'print("=== Analysis Summary ===")\n'
546
+ f"print({json.dumps(summary)})\n"
547
+ "print()\n\n"
548
+ "# --- CSV declarations ---\n"
549
+ f"CSV_FILES = {{\n{csv_lines}\n}}\n\n"
550
+ f"SOURCES = {json.dumps(spec, indent=4)}\n\n"
551
+ f"TARGET_WORDS = {json.dumps(list(_TARGET_WORDS))}\n"
552
+ + _INSIGHT_BODY
553
+ )
554
+
555
+
556
+ def encode_analysis_code(code: str) -> str:
557
+ """gzip + base64, as ThoughtSpot stores ``ai_tile.analysis_code``. mtime=0
558
+ so the same code always encodes to the same text."""
559
+ return base64.b64encode(gzip.compress(code.encode("utf-8"), mtime=0)).decode("ascii")
560
+
561
+
562
+ def decode_analysis_code(encoded: str) -> str:
563
+ return gzip.decompress(base64.b64decode(encoded)).decode("utf-8")
564
+
565
+
566
+ # -------------------------------------------------------------------- layout
567
+
568
+ def place_header_tiles(liveboard: Dict[str, Any], logo_id: Optional[str],
569
+ insight_id: Optional[str]) -> str:
570
+ """Put the tiles on the first tab: logo, insight, then the KPI group.
571
+
572
+ Beside a KPI group that has the top band to itself (every golden-layout
573
+ board): logo only -> logo 3 cols, KPIs 9 (the layout of boone's example
574
+ board). With the insight tile -> logo 2, insight 4, KPIs 6 with the KPI
575
+ tiles two per row, so each KPI keeps today's width and the row doubles in
576
+ height. Anything else (no KPI group, or other tiles beside it) -> the
577
+ header tiles take a new row above everything. Returns a one-line summary.
578
+ """
579
+ from demoprep_app.liveboards.golden_layout import KPI_ROW_H
580
+
581
+ tab = first_tab(liveboard)
582
+ if tab is None:
583
+ raise ValueError("the liveboard has no tabs")
584
+ tiles = tab.setdefault("tiles", [])
585
+ header = [i for i in (logo_id, insight_id) if i]
586
+ if not header:
587
+ return ""
588
+ group = _kpi_group(liveboard, tab)
589
+ kpi_tile = next((t for t in tiles if group and t.get("visualization_id") == group["id"]), None)
590
+ band_mates = []
591
+ if kpi_tile is not None:
592
+ k_y, k_h = kpi_tile.get("y", 0), kpi_tile.get("height", KPI_ROW_H)
593
+ band_mates = [t for t in tiles if t is not kpi_tile
594
+ and t.get("y", 0) < k_y + k_h and t.get("y", 0) + t.get("height", 0) > k_y]
595
+
596
+ if kpi_tile is None or band_mates:
597
+ top = min((t.get("y", 0) for t in tiles), default=0)
598
+ for t in tiles:
599
+ t["y"] = t.get("y", 0) + KPI_ROW_H
600
+ x = 0
601
+ new = []
602
+ if logo_id:
603
+ new.append({"visualization_id": logo_id, "x": 0, "y": top, "height": KPI_ROW_H,
604
+ "width": LOGO_WIDTH_ALONE})
605
+ x = LOGO_WIDTH_ALONE
606
+ if insight_id:
607
+ new.append({"visualization_id": insight_id, "x": x, "y": top, "height": KPI_ROW_H,
608
+ "width": 12 - x})
609
+ tab["tiles"] = new + tiles
610
+ return "header tiles placed in a new top row (no KPI row to sit beside)"
611
+
612
+ k_y, k_h = kpi_tile.get("y", 0), kpi_tile.get("height", KPI_ROW_H)
613
+ new_h = k_h
614
+ if insight_id:
615
+ layout = next((gl for gl in tab.get("group_layouts") or [] if gl.get("id") == group["id"]), None)
616
+ kpi_ids = _ordered_group_vizzes(tab, group["id"], group.get("visualizations") or [])
617
+ tile_h = max([t.get("height", KPI_ROW_H) for t in (layout or {}).get("tiles") or []] or [KPI_ROW_H])
618
+ per_row = 1 if len(kpi_ids) == 1 else 2
619
+ width = 12 // per_row
620
+ reflowed = [{"visualization_id": vid, "x": (i % per_row) * width, "y": (i // per_row) * tile_h,
621
+ "height": tile_h, "width": width} for i, vid in enumerate(kpi_ids)]
622
+ if layout is None:
623
+ tab.setdefault("group_layouts", []).append({"id": group["id"], "tiles": reflowed})
624
+ else:
625
+ layout["tiles"] = reflowed
626
+ rows = -(-len(kpi_ids) // per_row)
627
+ new_h = max(k_h, rows * tile_h)
628
+ logo_w = LOGO_WIDTH_WITH_INSIGHT if logo_id else 0
629
+ kpi_x = 12 - KPI_GROUP_WIDTH_WITH_INSIGHT
630
+ placed = []
631
+ if logo_id:
632
+ placed.append({"visualization_id": logo_id, "x": 0, "y": k_y, "height": new_h, "width": logo_w})
633
+ placed.append({"visualization_id": insight_id, "x": logo_w, "y": k_y, "height": new_h,
634
+ "width": kpi_x - logo_w})
635
+ else:
636
+ kpi_x = LOGO_WIDTH_ALONE
637
+ placed = [{"visualization_id": logo_id, "x": 0, "y": k_y, "height": k_h, "width": LOGO_WIDTH_ALONE}]
638
+
639
+ delta = new_h - k_h
640
+ for t in tiles:
641
+ if t is not kpi_tile and t.get("y", 0) >= k_y + k_h:
642
+ t["y"] = t.get("y", 0) + delta
643
+ kpi_tile.update({"x": kpi_x, "width": 12 - kpi_x, "height": new_h})
644
+ index = tiles.index(kpi_tile)
645
+ tab["tiles"] = tiles[:index] + placed + tiles[index:]
646
+ order = ", ".join(p["visualization_id"] for p in placed) + f", {group['id']}"
647
+ return f"header tiles beside the KPI group (left to right: {order})"
648
+
649
+
650
+ # --------------------------------------------------------------- the pass
651
+
652
+ def apply_header_tiles(liveboard_tml: Dict[str, Any], *, company: str, use_case: str,
653
+ logo: bool, insight: bool, link: str = "",
654
+ logo_svg: str = "") -> Tuple[Dict[str, Any], List[str], List[str]]:
655
+ """Return (new TML, changes, warnings). Pure: no ThoughtSpot calls.
656
+
657
+ With logo and insight both False the input comes back unchanged (same
658
+ object, untouched). A tile that cannot be built is left out with a warning.
659
+ """
660
+ if not (logo or insight):
661
+ return liveboard_tml, [], []
662
+ updated = deepcopy(liveboard_tml)
663
+ lb = updated.get("liveboard") or {}
664
+ changes: List[str] = []
665
+ warnings: List[str] = []
666
+ story = board_story(lb)
667
+ vizzes = lb.setdefault("visualizations", [])
668
+ logo_id = insight_id = None
669
+
670
+ if logo:
671
+ try:
672
+ body = logo_tile_html(
673
+ logo_svg=logo_svg, link=link, company=company,
674
+ heading=story["tab_name"] or "Executive Overview",
675
+ tagline=f"{_sentence_case(use_case or 'Analytics')} at a glance.",
676
+ measures=story["measures"], dimensions=story["dimensions"])
677
+ vizzes.append({"id": LOGO_TILE_ID, "note_tile": {"html_parsed_string": body}})
678
+ logo_id = LOGO_TILE_ID
679
+ changes.append(f"logo tile added (links to {link})")
680
+ except Exception as e: # noqa: BLE001 - a tile never fails the build
681
+ warnings.append(f"logo tile left out: {e}")
682
+
683
+ if insight:
684
+ try:
685
+ sources = insight_sources(story)
686
+ code = insight_analysis_code(company, use_case or "Analytics", sources)
687
+ vizzes.append({"id": INSIGHT_TILE_ID, "ai_tile": {
688
+ "target_agent": INSIGHT_TARGET_AGENT,
689
+ "analysis_code": encode_analysis_code(code),
690
+ "template_id": INSIGHT_TEMPLATE_ID,
691
+ }})
692
+ insight_id = INSIGHT_TILE_ID
693
+ changes.append("insight tile added (reads: " + ", ".join(s["title"] for s in sources) + ")")
694
+ except Exception as e: # noqa: BLE001
695
+ warnings.append(f"insight tile left out: {e}")
696
+
697
+ if not (logo_id or insight_id):
698
+ return liveboard_tml, [], warnings
699
+ try:
700
+ changes.append(place_header_tiles(lb, logo_id, insight_id))
701
+ except Exception as e: # noqa: BLE001
702
+ return liveboard_tml, [], warnings + [f"header tiles left out: could not place them ({e})"]
703
+ return updated, changes, warnings
704
+
705
+
706
+ def logo_link(portal: Optional[Dict[str, Any]], company_url: str,
707
+ with_portal: bool) -> Tuple[str, List[str]]:
708
+ """Where the logo links: the TSE portal, else the company website (with a
709
+ warning saying why), else "" (the tile is left out, with a warning)."""
710
+ portal_url = normalize_link((portal or {}).get("url") or "")
711
+ if portal_url:
712
+ return portal_url, []
713
+ why = ("the build was run with with_portal=False" if not with_portal
714
+ else "the portal build did not produce a site")
715
+ site = normalize_link(company_url)
716
+ if site:
717
+ return site, [f"logo tile links to the company website ({site}) instead of a TSE portal: {why}"]
718
+ return "", [f"logo tile left out: no TSE portal ({why}) and no company website to link to"]
719
+
720
+
721
+ def _import_tml(ts_client, liveboard_tml: Dict[str, Any]) -> Dict[str, Any]:
722
+ """One TML import; {'ok', 'error'}. PARTIAL, as enhance_mcp_liveboard imports."""
723
+ import yaml
724
+
725
+ from liveboard_creator import _import_status_from_response
726
+
727
+ response = ts_client.session.post(
728
+ f"{ts_client.base_url}/api/rest/2.0/metadata/tml/import",
729
+ json={"metadata_tmls": [yaml.dump(liveboard_tml, default_flow_style=False, sort_keys=False)],
730
+ "import_policy": "PARTIAL", "create_new": False},
731
+ timeout=300,
732
+ )
733
+ if response.status_code != 200:
734
+ return {"ok": False, "error": f"HTTP {response.status_code}: {response.text[:500]}"}
735
+ status = _import_status_from_response(response.json())
736
+ if status["status_code"] in ("OK", "WARNING"):
737
+ return {"ok": True, "error": ""}
738
+ return {"ok": False, "error": status["error_message"] or "unknown TML import error"}
739
+
740
+
741
+ def add_header_tiles_to_board(ts_client, liveboard_guid: str, *, company: str, use_case: str,
742
+ company_url: str, logo: bool, insight: bool,
743
+ with_portal: bool, portal: Optional[Dict[str, Any]]) -> Dict[str, Any]:
744
+ """Export the live board, add the requested tiles, import it back.
745
+
746
+ Returns {'changes': [...], 'warnings': [...]} and never raises: a tile that
747
+ cannot be built or that ThoughtSpot rejects is left out with a warning, and
748
+ the board stays exactly as the build left it.
749
+ """
750
+ out: Dict[str, Any] = {"changes": [], "warnings": []}
751
+ if not (logo or insight):
752
+ return out
753
+ try:
754
+ link, logo_svg, logo_source = "", "", ""
755
+ if logo:
756
+ link, notes = logo_link(portal, company_url, with_portal)
757
+ out["warnings"].extend(notes)
758
+ if link:
759
+ try:
760
+ logo_svg, logo_source = portal_logo_svg(company, portal)
761
+ except Exception as e: # noqa: BLE001
762
+ out["warnings"].append(f"logo tile left out: the logo could not be read ({e})")
763
+ link = ""
764
+ logo = bool(link and logo_svg)
765
+ if not (logo or insight):
766
+ return out
767
+
768
+ from demoprep_app.liveboards.tml_yaml import load_tml
769
+
770
+ response = ts_client.session.post(
771
+ f"{ts_client.base_url}/api/rest/2.0/metadata/tml/export",
772
+ json={"metadata": [{"identifier": liveboard_guid}], "export_associated": False},
773
+ timeout=120,
774
+ )
775
+ if response.status_code != 200:
776
+ out["warnings"].append(f"header tiles not added: liveboard export failed "
777
+ f"(HTTP {response.status_code})")
778
+ return out
779
+ tml = load_tml((response.json() or [{}])[0].get("edoc") or "")
780
+ if "liveboard" not in tml:
781
+ out["warnings"].append("header tiles not added: the liveboard export had no TML")
782
+ return out
783
+
784
+ new_tml, changes, warnings = apply_header_tiles(
785
+ tml, company=company, use_case=use_case, logo=logo, insight=insight,
786
+ link=link, logo_svg=logo_svg)
787
+ out["warnings"].extend(warnings)
788
+ if not changes:
789
+ return out
790
+
791
+ result = _import_tml(ts_client, new_tml)
792
+ if not result["ok"]:
793
+ # Retry once without whichever of OUR tiles ThoughtSpot named. Any
794
+ # other rejection leaves the board exactly as the build left it.
795
+ from liveboard_creator import parse_failed_visualization_ids
796
+
797
+ named = set(parse_failed_visualization_ids(result["error"]))
798
+ ours = named & {LOGO_TILE_ID, INSIGHT_TILE_ID}
799
+ if not ours or named - ours:
800
+ out["warnings"].append(f"header tiles not added: ThoughtSpot rejected the import "
801
+ f"({result['error'][:300]})")
802
+ return out
803
+ keep_logo = logo and LOGO_TILE_ID not in ours
804
+ keep_insight = insight and INSIGHT_TILE_ID not in ours
805
+ for tile in sorted(ours):
806
+ out["warnings"].append(f"{'logo' if tile == LOGO_TILE_ID else 'insight'} tile left out: "
807
+ f"ThoughtSpot rejected it ({result['error'][:300]})")
808
+ if not (keep_logo or keep_insight):
809
+ return out
810
+ new_tml, changes, warnings = apply_header_tiles(
811
+ tml, company=company, use_case=use_case, logo=keep_logo, insight=keep_insight,
812
+ link=link, logo_svg=logo_svg)
813
+ result = _import_tml(ts_client, new_tml)
814
+ if not result["ok"]:
815
+ out["warnings"].append(f"header tiles not added: ThoughtSpot rejected the import "
816
+ f"({result['error'][:300]})")
817
+ return out
818
+ out["changes"].extend(c.replace("logo tile added (", f"logo tile added ({logo_source}; ", 1)
819
+ for c in changes)
820
+ return out
821
+ except Exception as e: # noqa: BLE001 - header tiles never fail a build
822
+ out["warnings"].append(f"header tiles not added: {type(e).__name__}: {e}")
823
+ return out
demoprep_app/portal/builder.py CHANGED
@@ -1337,10 +1337,12 @@ def validate_spec(spec: dict) -> list[str]:
1337
 
1338
  # ---------------------------------------------------------------- build steps
1339
 
1340
- def wordmark(company: str, primary: str, path: Path) -> None:
 
 
1341
  label = company.upper()[:26]
1342
  width = 40 + len(label) * 11
1343
- path.write_text(
1344
  f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} 40" '
1345
  f'width="{width}" height="40"><text x="4" y="27" font-family="Inter, '
1346
  f'system-ui, sans-serif" font-size="18" font-weight="700" letter-spacing="0.5" '
@@ -1348,6 +1350,10 @@ def wordmark(company: str, primary: str, path: Path) -> None:
1348
  )
1349
 
1350
 
 
 
 
 
1351
  def _tool(name: str) -> str:
1352
  """Absolute path to node/npm. Containers often lack them on the default PATH."""
1353
  found = shutil.which(name) or shutil.which(name, path="/usr/bin:/usr/local/bin:"
 
1337
 
1338
  # ---------------------------------------------------------------- build steps
1339
 
1340
+ def wordmark_svg(company: str, primary: str) -> str:
1341
+ """The generated wordmark logo, as SVG text. Also the liveboard logo tile's
1342
+ image when there is no portal (demoprep_app/liveboards/header_tiles.py)."""
1343
  label = company.upper()[:26]
1344
  width = 40 + len(label) * 11
1345
+ return (
1346
  f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} 40" '
1347
  f'width="{width}" height="40"><text x="4" y="27" font-family="Inter, '
1348
  f'system-ui, sans-serif" font-size="18" font-weight="700" letter-spacing="0.5" '
 
1350
  )
1351
 
1352
 
1353
+ def wordmark(company: str, primary: str, path: Path) -> None:
1354
+ path.write_text(wordmark_svg(company, primary))
1355
+
1356
+
1357
  def _tool(name: str) -> str:
1358
  """Absolute path to node/npm. Containers often lack them on the default PATH."""
1359
  found = shutil.which(name) or shutil.which(name, path="/usr/bin:/usr/local/bin:"
docs/thoughtspot_mcp_api_review_2026_09.md CHANGED
@@ -7,7 +7,7 @@
7
  ## Summary
8
 
9
  1. **Our MCP endpoint is deprecated.** `liveboard_creator.py` calls `https://agent.thoughtspot.app/bearer/mcp`. ThoughtSpot now documents it as deprecated (still served, no sunset date). The replacement is `https://agent.thoughtspot.app/token/mcp?api-version=2025-01-01`, which serves the identical five tools we use today (**verified**: `ping`, `getRelevantQuestions`, `getAnswer` all work). Do **not** switch to the unversioned `/token/mcp`: it resolves to the Spotter 3 toolset, which has no `getAnswer` or `createLiveboard`, and the pipeline would break.
10
- 2. **There is no Spotter Viz API.** SpotterViz is GA as a UI feature inside Liveboard edit mode; the Visual Embed SDK only lets you brand its panel and supply starter prompts. Insight tiles (26.8, early access) and Spotter Analysts (26.8, early access) are UI-only too. The programmatic route ThoughtSpot actually offers is Spotter answer sessions → TML, which is what both MCP `createLiveboard` and the Spotter 3 `create_dashboard` do server-side.
11
  3. **We can assemble liveboards without MCP.** `metadata/tml/export` accepts `{type: ANSWER, session_identifier, generation_number}` and returns the TML of an *unsaved* Spotter answer in about 0.1 s (**verified**). A liveboard TML built from that answer passed `import_policy: VALIDATE_ONLY` (**verified**). This turns the sprint's "backup TML-only liveboard" into a first-class path with full control over groups, tabs, and styles in a single import.
12
  4. **New per-model Spotter coaching APIs fit our mission directly.** `ai/instructions/set` (10.15+) writes natural-language rules onto a model (**verified** set/get/restore as the model owner). `ai/memory/export|import` (26.8) moves Spotter's learned rules and recipes between models. Encoding the demo narrative into the model itself makes Spotter's live answers agree with the liveboard story.
13
  5. **Spotter 3 works on our clusters via REST and MCP.** One question takes 26–27 s and returns a reasoning narrative plus answer sessions usable for liveboards. It is rate-limited (10 conversations/min, 30 messages/min), so it is a spike for one or two "reasoning" tiles, not a replacement for `ai/answer/create` (3 s).
@@ -142,7 +142,7 @@ We hand-roll `requests` calls, which is fine. If we ever consolidate, use the of
142
  | Capability | Status | Programmatic access |
143
  |---|---|---|
144
  | **SpotterViz** builds a Liveboard from a prompt: picks data models, plans the story, creates answers with Spotter, lays out tabs, KPIs, charts, filters, and styling; iterative refinement with checkpoints. Requires Liveboard edit mode, Spotter enabled on the model, TML edit permission, admin enablement; documented as working best with Claude Opus 4.5. | GA in the UI (early access when introduced in 26.7) | **None.** No REST endpoint, no MCP tool. Visual Embed SDK 1.50 `spotterViz` config only customizes branding and `customStarterPrompts`; it cannot submit a prompt. |
145
- | **Insight tiles**: AI text that re-runs its instructions against live data each time the Liveboard loads. | Early access, 26.8 | None documented; no TML tile type published. Check whether an exported Liveboard with one shows a new visualization type before planning on it. |
146
  | **Spotter Analysts**: named, governed Spotter workspaces scoped to up to 5 models with up to 5,000 characters of instructions, connector allow/deny, and sharing. | Early access, 26.8 | UI only. |
147
  | **Memory from Liveboards**: Spotter reads up to 3 Liveboards and extracts metric definitions and business rules as memory (10–20 min, async). | GA, needs "Spotter 3 capabilities" enabled by an admin | UI only; the resulting memory can then be exported and imported via `ai/memory/*`. |
148
 
 
7
  ## Summary
8
 
9
  1. **Our MCP endpoint is deprecated.** `liveboard_creator.py` calls `https://agent.thoughtspot.app/bearer/mcp`. ThoughtSpot now documents it as deprecated (still served, no sunset date). The replacement is `https://agent.thoughtspot.app/token/mcp?api-version=2025-01-01`, which serves the identical five tools we use today (**verified**: `ping`, `getRelevantQuestions`, `getAnswer` all work). Do **not** switch to the unversioned `/token/mcp`: it resolves to the Spotter 3 toolset, which has no `getAnswer` or `createLiveboard`, and the pipeline would break.
10
+ 2. **There is no Spotter Viz API.** SpotterViz is GA as a UI feature inside Liveboard edit mode; the Visual Embed SDK only lets you brand its panel and supply starter prompts. Spotter Analysts (26.8, early access) are UI-only too. Insight tiles (26.8, early access) have no API, but a TML import creates them (verified 2026-10-20; see the table below). The programmatic route ThoughtSpot actually offers is Spotter answer sessions → TML, which is what both MCP `createLiveboard` and the Spotter 3 `create_dashboard` do server-side.
11
  3. **We can assemble liveboards without MCP.** `metadata/tml/export` accepts `{type: ANSWER, session_identifier, generation_number}` and returns the TML of an *unsaved* Spotter answer in about 0.1 s (**verified**). A liveboard TML built from that answer passed `import_policy: VALIDATE_ONLY` (**verified**). This turns the sprint's "backup TML-only liveboard" into a first-class path with full control over groups, tabs, and styles in a single import.
12
  4. **New per-model Spotter coaching APIs fit our mission directly.** `ai/instructions/set` (10.15+) writes natural-language rules onto a model (**verified** set/get/restore as the model owner). `ai/memory/export|import` (26.8) moves Spotter's learned rules and recipes between models. Encoding the demo narrative into the model itself makes Spotter's live answers agree with the liveboard story.
13
  5. **Spotter 3 works on our clusters via REST and MCP.** One question takes 26–27 s and returns a reasoning narrative plus answer sessions usable for liveboards. It is rate-limited (10 conversations/min, 30 messages/min), so it is a spike for one or two "reasoning" tiles, not a replacement for `ai/answer/create` (3 s).
 
142
  | Capability | Status | Programmatic access |
143
  |---|---|---|
144
  | **SpotterViz** builds a Liveboard from a prompt: picks data models, plans the story, creates answers with Spotter, lays out tabs, KPIs, charts, filters, and styling; iterative refinement with checkpoints. Requires Liveboard edit mode, Spotter enabled on the model, TML edit permission, admin enablement; documented as working best with Claude Opus 4.5. | GA in the UI (early access when introduced in 26.7) | **None.** No REST endpoint, no MCP tool. Visual Embed SDK 1.50 `spotterViz` config only customizes branding and `customStarterPrompts`; it cannot submit a prompt. |
145
+ | **Insight tiles**: AI text that re-runs its instructions against live data each time the Liveboard loads. | Early access, 26.8 | **TML, verified 2026-10-20** (no REST endpoint, not in the published TML docs). An exported Liveboard carries the tile as a visualization with an `ai_tile` block: `target_agent: insight_code_runner`, `template_id: text_long_form`, `analysis_code` = gzip+base64 pandas that reads each source viz as `<viz_guid>.csv` and prints an analysis. The narrative is NOT stored: ThoughtSpot writes it at view time. A TML import creates the tile (VALIDATE_ONLY and ALL_OR_NONE both OK on secloud; re-export shows it intact) and does not rewrite the viz GUIDs inside the code. DemoPrep builds one behind `insight_tile` (`demoprep_app/liveboards/header_tiles.py`). |
146
  | **Spotter Analysts**: named, governed Spotter workspaces scoped to up to 5 models with up to 5,000 characters of instructions, connector allow/deny, and sharing. | Early access, 26.8 | UI only. |
147
  | **Memory from Liveboards**: Spotter reads up to 3 Liveboards and extracts metric definitions and business rules as memory (10–20 min, async). | GA, needs "Spotter 3 capabilities" enabled by an admin | UI only; the resulting memory can then be exported and imported via `ai/memory/*`. |
148
 
mcp_server.py CHANGED
@@ -676,7 +676,8 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
676
  portal_options: dict | None = None,
677
  salesforce_opportunity_id: str = "",
678
  existing_schema: str = "", existing_ddl: str = "",
679
- environment: str = "") -> dict:
 
680
  """Drive the controller headlessly (runs in a BACKGROUND THREAD; the caller has
681
  already registered the build in _active_builds and holds a _build_sem slot).
682
  With `existing_schema` + `existing_ddl` (deploy_existing_schema) the dataset,
@@ -1031,6 +1032,37 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
1031
  except Exception as e:
1032
  portal = {"_warnings": [f"portal build failed ({type(e).__name__}): {e}"]}
1033
  _log_hit(f"portal FAILED for {run_id}: {type(e).__name__}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1034
  # A run that shipped its model but no board (liveboard_creation_path ==
1035
  # 'none', handoff 2026-09-22 item C) is PARTIAL to the caller, not a
1036
  # success: the demo is usable, but the deliverable they asked for is
@@ -1104,6 +1136,10 @@ def build_demo_from_brief(
1104
  *,
1105
  salesforce_opportunity_id: str,
1106
  environment: str,
 
 
 
 
1107
  ) -> dict:
1108
  """Start a full ThoughtSpot demo build from a prospect brief.
1109
 
@@ -1151,6 +1187,22 @@ def build_demo_from_brief(
1151
  whichever path produced it.
1152
  tiers: The Premium/Basic tier switcher. ON by default, same reason.
1153
  pinning: Add the "Add Report" panel.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1154
 
1155
  Not settable here: askMode (it breaks the upstream tab prune) and the home
1156
  page (its copy is written from a brand screenshot). For those, call
@@ -1239,18 +1291,27 @@ def build_demo_from_brief(
1239
  portal_options = _chained_portal_options(
1240
  theme_mode=theme_mode, auth=auth, nav_layout=nav_layout, inline_tab=inline_tab,
1241
  monetize=monetize, tiers=tiers, pinning=pinning)
 
 
 
 
 
 
 
 
1242
 
1243
  def _worker() -> None:
1244
  try:
1245
  _run_build(run_id, b, c, u, url, ts_target_url, ts_auth_key, owner, with_portal, _payload,
1246
  portal_options=portal_options,
1247
  salesforce_opportunity_id=salesforce_opportunity_id,
1248
- environment=environment)
 
1249
  finally:
1250
  _build_sem.release()
1251
 
1252
  threading.Thread(target=_worker, daemon=True, name=f"build-{run_id}").start()
1253
- return {
1254
  "status": "started",
1255
  "run_id": run_id,
1256
  "message": ("Build started in the background (~15-20 min). Poll status() for "
@@ -1258,6 +1319,9 @@ def build_demo_from_brief(
1258
  "portal_url — the branded customer-facing site built from the "
1259
  "liveboard — and download_url, a zip of its editable source."),
1260
  }
 
 
 
1261
 
1262
 
1263
  # ------------------------------------------------------- redeploy a schema
 
676
  portal_options: dict | None = None,
677
  salesforce_opportunity_id: str = "",
678
  existing_schema: str = "", existing_ddl: str = "",
679
+ environment: str = "",
680
+ header_tiles: dict | None = None) -> dict:
681
  """Drive the controller headlessly (runs in a BACKGROUND THREAD; the caller has
682
  already registered the build in _active_builds and holds a _build_sem slot).
683
  With `existing_schema` + `existing_ddl` (deploy_existing_schema) the dataset,
 
1032
  except Exception as e:
1033
  portal = {"_warnings": [f"portal build failed ({type(e).__name__}): {e}"]}
1034
  _log_hit(f"portal FAILED for {run_id}: {type(e).__name__}: {e}")
1035
+ # ---- header tiles (logo_tile / insight_tile; boone 2026-10-20) ----
1036
+ # Only when the caller asked. After the portal, so the logo can link to
1037
+ # it; one export -> edit -> import of the finished board. Never fails
1038
+ # the build: a tile that cannot be added is left out and warned about.
1039
+ if header_tiles:
1040
+ _tile_warnings = list(header_tiles.get("warnings") or [])
1041
+ if (header_tiles.get("logo") or header_tiles.get("insight")) and not dc.get("liveboard_guid"):
1042
+ _tile_warnings.append("header tiles not added: the build produced no liveboard")
1043
+ elif header_tiles.get("logo") or header_tiles.get("insight"):
1044
+ _set_progress(run_id, phase="adding header tiles", detail="")
1045
+ try:
1046
+ from thoughtspot_deployer import ThoughtSpotDeployer
1047
+ from demoprep_app.liveboards.header_tiles import add_header_tiles_to_board
1048
+ _ts = ThoughtSpotDeployer(base_url=ts_target_url, username=owner,
1049
+ secret_key=ts_auth_key)
1050
+ if not _ts.authenticate_trusted():
1051
+ raise RuntimeError(f"could not authenticate to {ts_target_url} as {owner}")
1052
+ _tiles = add_header_tiles_to_board(
1053
+ _ts, dc["liveboard_guid"], company=company_name, use_case=use_case,
1054
+ company_url=company_url, logo=bool(header_tiles.get("logo")),
1055
+ insight=bool(header_tiles.get("insight")), with_portal=with_portal,
1056
+ portal=portal)
1057
+ except Exception as e:
1058
+ _tiles = {"changes": [],
1059
+ "warnings": [f"header tiles not added: {type(e).__name__}: {e}"]}
1060
+ _tile_warnings.extend(_tiles["warnings"])
1061
+ for _change in _tiles["changes"]:
1062
+ _log_hit(f"header tiles {run_id}: {_change}")
1063
+ if _tile_warnings:
1064
+ dc["warnings"] = list(dc.get("warnings") or []) + _tile_warnings
1065
+ # ---- end header tiles ----
1066
  # A run that shipped its model but no board (liveboard_creation_path ==
1067
  # 'none', handoff 2026-09-22 item C) is PARTIAL to the caller, not a
1068
  # success: the demo is usable, but the deliverable they asked for is
 
1136
  *,
1137
  salesforce_opportunity_id: str,
1138
  environment: str,
1139
+ # ---- header tiles (boone 2026-10-20): OFF unless Agent Q sends them ----
1140
+ logo_tile: bool | str = False,
1141
+ insight_tile: bool | str = False,
1142
+ # ---- end header tiles ----
1143
  ) -> dict:
1144
  """Start a full ThoughtSpot demo build from a prospect brief.
1145
 
 
1187
  whichever path produced it.
1188
  tiers: The Premium/Basic tier switcher. ON by default, same reason.
1189
  pinning: Add the "Add Report" panel.
1190
+ logo_tile: OFF by default; omitted, false or blank builds exactly what it
1191
+ builds without this argument. True adds a logo tile at the far
1192
+ left of the first tab's top row: the company logo (the TSE
1193
+ portal's own logo) linked to the TSE portal, the tab's name as a
1194
+ heading, a tagline and a line naming the board's headline
1195
+ measures. With no portal (with_portal=False, or the portal build
1196
+ failed) the logo links to company_url and a warning says so; with
1197
+ neither, the tile is left out with a warning.
1198
+ insight_tile: OFF by default; omitted, false or blank builds exactly what
1199
+ it builds without this argument. True adds ThoughtSpot's AI insight
1200
+ tile left of the KPIs (right of the logo tile when both are on):
1201
+ a narrative card ThoughtSpot writes when the board is opened, from
1202
+ an analysis of the board's own KPIs and driver charts. With it on,
1203
+ the KPIs sit two per row so the row has room for the narrative.
1204
+ A tile that cannot be added never fails the build; it is left out
1205
+ and the reason is in the result's warnings.
1206
 
1207
  Not settable here: askMode (it breaks the upstream tab prune) and the home
1208
  page (its copy is written from a brand screenshot). For those, call
 
1291
  portal_options = _chained_portal_options(
1292
  theme_mode=theme_mode, auth=auth, nav_layout=nav_layout, inline_tab=inline_tab,
1293
  monetize=monetize, tiers=tiers, pinning=pinning)
1294
+ # ---- header tiles (boone 2026-10-20) ----
1295
+ # Read like the portal flags (_as_bool). None when both are off and nothing
1296
+ # needs reporting, so a default build carries no header-tile work at all.
1297
+ from demoprep_app.liveboards.header_tiles import resolve_header_tile_flags
1298
+ _logo_on, _insight_on, _tile_notes = resolve_header_tile_flags(logo_tile, insight_tile)
1299
+ header_tiles = ({"logo": _logo_on, "insight": _insight_on, "warnings": _tile_notes}
1300
+ if (_logo_on or _insight_on or _tile_notes) else None)
1301
+ # ---- end header tiles ----
1302
 
1303
  def _worker() -> None:
1304
  try:
1305
  _run_build(run_id, b, c, u, url, ts_target_url, ts_auth_key, owner, with_portal, _payload,
1306
  portal_options=portal_options,
1307
  salesforce_opportunity_id=salesforce_opportunity_id,
1308
+ environment=environment,
1309
+ header_tiles=header_tiles)
1310
  finally:
1311
  _build_sem.release()
1312
 
1313
  threading.Thread(target=_worker, daemon=True, name=f"build-{run_id}").start()
1314
+ started = {
1315
  "status": "started",
1316
  "run_id": run_id,
1317
  "message": ("Build started in the background (~15-20 min). Poll status() for "
 
1319
  "portal_url — the branded customer-facing site built from the "
1320
  "liveboard — and download_url, a zip of its editable source."),
1321
  }
1322
+ if _tile_notes: # header tiles: an unreadable flag value, read as off
1323
+ started["warnings"] = list(_tile_notes)
1324
+ return started
1325
 
1326
 
1327
  # ------------------------------------------------------- redeploy a schema
tests/test_header_tiles.py ADDED
@@ -0,0 +1,601 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Opt-in header tiles on the first tab: logo_tile and insight_tile (boone 2026-10-20).
2
+
3
+ boone's rule for every flag: OFF unless Agent Q sends it. Omitted, false or blank
4
+ means the build is byte-for-byte today's — same liveboard TML, no extra work.
5
+ That is pinned first, against the REAL enhance_mcp_liveboard run offline.
6
+
7
+ What an insight tile is, read off a real export (boone's example board on
8
+ secloud, d062e894, 2026-10-20): a visualization with an `ai_tile` block —
9
+ target_agent insight_code_runner, template_id text_long_form, analysis_code =
10
+ gzip+base64 pandas that reads each source viz as `<viz_guid>.csv`. ThoughtSpot
11
+ writes the narrative at view time; a TML import creates the tile (verified on a
12
+ throwaway ZZ board — see demoprep_app/liveboards/header_tiles.py).
13
+
14
+ Run: pytest tests/test_header_tiles.py -v
15
+ """
16
+ import base64
17
+ import inspect
18
+ import json
19
+ import re
20
+ import subprocess
21
+ import sys
22
+ import threading
23
+
24
+ import pytest
25
+ import yaml
26
+
27
+ from demoprep_app.liveboards import header_tiles as HT
28
+ from demoprep_app.liveboards.header_tiles import (
29
+ INSIGHT_TILE_ID, LOGO_TILE_ID, add_header_tiles_to_board, apply_header_tiles,
30
+ decode_analysis_code, logo_link, portal_logo_svg, resolve_header_tile_flags,
31
+ )
32
+
33
+ PORTAL_URL = "https://thoughtspot-demoprep-portals.static.hf.space/acme-ab12cd34/index.html"
34
+ SVG = '<svg xmlns="http://www.w3.org/2000/svg"><text>ACME</text></svg>'
35
+ OFF_VALUES = [None, False, "", " ", "false", "no", "off", "0"]
36
+
37
+
38
+ # --------------------------------------------------------------- the board
39
+
40
+ def _viz(vid, name, ctype, query, cols, guid=None):
41
+ viz = {"id": vid, "answer": {"name": name, "search_query": query,
42
+ "answer_columns": [{"name": c} for c in cols],
43
+ "chart": {"type": ctype, "chart_columns": []}}}
44
+ if guid:
45
+ viz["viz_guid"] = guid
46
+ return viz
47
+
48
+
49
+ def _mcp_board():
50
+ """An exported MCP board: every viz carries the viz_guid ThoughtSpot gave it."""
51
+ return {"guid": "lb-1", "liveboard": {"name": "Acme Board", "visualizations": [
52
+ _viz("Viz_1", "Revenue Weekly", "LINE", "sum [Revenue] [Date].weekly",
53
+ ["Week(Date)", "Total Revenue"], "g-rev"),
54
+ _viz("Viz_2", "Orders Weekly", "LINE", "sum [Orders] [Date].weekly",
55
+ ["Week(Date)", "Total Orders"], "g-ord"),
56
+ _viz("Viz_3", "Margin Rate Weekly", "LINE", "average [Margin Rate] [Date].weekly",
57
+ ["Week(Date)", "Average Margin Rate"], "g-mar"),
58
+ _viz("Viz_4", "Customers Weekly", "LINE", "sum [Customers] [Date].weekly",
59
+ ["Week(Date)", "Total Customers"], "g-cus"),
60
+ _viz("Viz_5", "Revenue by Region", "COLUMN", "sum [Revenue] [Region]",
61
+ ["Region", "Total Revenue"], "g-reg"),
62
+ _viz("Viz_6", "Revenue vs Target by Channel", "COLUMN",
63
+ "sum [Revenue] sum [Revenue Target] [Channel]",
64
+ ["Channel", "Total Revenue", "Total Revenue Target"], "g-chn"),
65
+ _viz("Viz_7", "Orders by Store Name", "BAR", "sum [Orders] [Store Name]",
66
+ ["Store Name", "Total Orders"], "g-sto"),
67
+ _viz("Viz_8", "Revenue Trend", "LINE", "sum [Revenue] [Date].monthly",
68
+ ["Month(Date)", "Total Revenue"], "g-trd"),
69
+ ]}}
70
+
71
+
72
+ class _Resp:
73
+ def __init__(self, payload, status_code=200):
74
+ self._payload, self.status_code = payload, status_code
75
+ self.text = json.dumps(payload)
76
+
77
+ def json(self):
78
+ return self._payload
79
+
80
+
81
+ def _enhance(monkeypatch):
82
+ """Run the REAL enhance_mcp_liveboard (golden layout) offline; return the TML it imports."""
83
+ import demoprep_app.liveboards.muze_engine as me
84
+ import liveboard_creator as lc
85
+
86
+ board = _mcp_board()
87
+ captured = {}
88
+
89
+ class _Session:
90
+ def post(self, url, **kwargs):
91
+ return _Resp([{"edoc": yaml.safe_dump(board)}])
92
+
93
+ class _Client:
94
+ base_url = "https://example"
95
+ session = _Session()
96
+
97
+ def fake_import(client, liveboard_tml):
98
+ captured["tml"] = yaml.safe_load(yaml.safe_dump(liveboard_tml))
99
+ return {"success": True}
100
+
101
+ monkeypatch.setattr(lc, "_humanize_viz_titles", lambda **kw: {})
102
+ monkeypatch.setattr(lc, "_fetch_model_columns", lambda client, guid: [])
103
+ monkeypatch.setattr(lc, "import_liveboard_tml_with_recovery", fake_import)
104
+ monkeypatch.setattr(me, "make_cardinality_probe", lambda client, guid: (lambda viz: {}))
105
+ result = lc.enhance_mcp_liveboard(liveboard_guid="lb-1",
106
+ company_data={"name": "Acme", "use_case": "Retail Sales"},
107
+ ts_client=_Client(), layout_strategy="golden_two_tab")
108
+ assert result["success"], result
109
+ return captured["tml"]
110
+
111
+
112
+ @pytest.fixture
113
+ def enhanced(monkeypatch):
114
+ return _enhance(monkeypatch)
115
+
116
+
117
+ def _tab(tml):
118
+ return tml["liveboard"]["layout"]["tabs"][0]
119
+
120
+
121
+ def _tile(tab, vid):
122
+ return next(t for t in tab["tiles"] if t["visualization_id"] == vid)
123
+
124
+
125
+ def _kpi_group_id(tml):
126
+ return next(g["id"] for g in tml["liveboard"]["groups"] if "Metric" in g["name"])
127
+
128
+
129
+ def _apply(tml, **kw):
130
+ args = {"company": "Acme", "use_case": "Retail Sales", "logo": False, "insight": False,
131
+ "link": PORTAL_URL, "logo_svg": SVG}
132
+ args.update(kw)
133
+ return apply_header_tiles(tml, **args)
134
+
135
+
136
+ # ------------------------------------------------------------ default OFF
137
+
138
+ @pytest.mark.parametrize("value", OFF_VALUES)
139
+ def test_omitted_false_or_blank_flags_are_off_and_silent(value):
140
+ assert resolve_header_tile_flags(value, value) == (False, False, [])
141
+
142
+
143
+ @pytest.mark.parametrize("value", [True, "true", "yes", "1", 1, "ON"])
144
+ def test_true_values_turn_a_tile_on(value):
145
+ assert resolve_header_tile_flags(value, False) == (True, False, [])
146
+ assert resolve_header_tile_flags(False, value) == (False, True, [])
147
+
148
+
149
+ def test_an_unreadable_flag_is_off_and_reported():
150
+ logo, insight, notes = resolve_header_tile_flags("maybe", None)
151
+ assert (logo, insight) == (False, False)
152
+ assert notes and "logo_tile" in notes[0] and "maybe" in notes[0]
153
+
154
+
155
+ def test_enhanced_board_is_byte_identical_with_the_tiles_off(monkeypatch):
156
+ """The real enhanced TML is untouched — same object, same bytes — when off."""
157
+ base = _enhance(monkeypatch)
158
+ before = yaml.safe_dump(base, sort_keys=True)
159
+ out, changes, warnings = _apply(base)
160
+ assert out is base and changes == [] and warnings == []
161
+ assert yaml.safe_dump(out, sort_keys=True) == before
162
+ # And the enhance pass itself is the same run to run (nothing reads the flags).
163
+ assert yaml.safe_dump(_enhance(monkeypatch), sort_keys=True) == before
164
+ assert LOGO_TILE_ID not in before and INSIGHT_TILE_ID not in before
165
+
166
+
167
+ def test_tool_flags_are_real_parameters_defaulting_off():
168
+ import mcp_server
169
+
170
+ sig = inspect.signature(mcp_server.build_demo_from_brief).parameters
171
+ for name in ("logo_tile", "insight_tile"):
172
+ assert name in sig, name
173
+ assert sig[name].default is False
174
+ assert sig[name].annotation in ("bool | str", bool | str)
175
+ doc = inspect.getdoc(mcp_server.build_demo_from_brief)
176
+ assert "logo_tile: OFF by default" in doc and "insight_tile: OFF by default" in doc
177
+
178
+
179
+ def _start(monkeypatch, **kwargs):
180
+ """build_demo_from_brief with _run_build stubbed; returns (reply, _run_build kwargs)."""
181
+ import mcp_server
182
+
183
+ seen, done = {}, threading.Event()
184
+
185
+ def fake_run_build(*args, **kw):
186
+ seen["args"] = args
187
+ seen.update(kw)
188
+ done.set()
189
+ return {}
190
+
191
+ monkeypatch.setattr(mcp_server, "_run_build", fake_run_build)
192
+ monkeypatch.setattr(mcp_server, "_resolve_ts_target", lambda url: ("https://ts.example", "k"))
193
+ monkeypatch.setattr(mcp_server, "_resolve_owner", lambda e: "se@thoughtspot.com")
194
+ monkeypatch.setattr(mcp_server, "_login_precheck",
195
+ lambda url, key, owner: {"outcome": "ok", "blocking": False})
196
+ out = mcp_server.build_demo_from_brief(
197
+ brief="A brief.", company_name="Acme", owner_email="se@thoughtspot.com",
198
+ salesforce_opportunity_id="0065f00000AbCdEfGHI", environment="prod", **kwargs)
199
+ assert out["status"] == "started", out
200
+ assert done.wait(5), "worker never called _run_build"
201
+ return out, seen
202
+
203
+
204
+ @pytest.mark.parametrize("kwargs", [
205
+ {}, {"logo_tile": False, "insight_tile": False}, {"logo_tile": "", "insight_tile": ""},
206
+ {"logo_tile": "false", "insight_tile": "no"}, {"logo_tile": None},
207
+ ])
208
+ def test_a_build_without_the_flags_does_no_header_tile_work(monkeypatch, kwargs):
209
+ out, seen = _start(monkeypatch, **kwargs)
210
+ assert "warnings" not in out, "nothing to report on a default build"
211
+ assert seen["header_tiles"] is None
212
+
213
+
214
+ def test_the_flags_reach_the_build_and_the_payload(monkeypatch):
215
+ out, seen = _start(monkeypatch, logo_tile="true", insight_tile=True)
216
+ assert seen["header_tiles"] == {"logo": True, "insight": True, "warnings": []}
217
+ payload = seen["args"][-1]
218
+ assert payload["logo_tile"] == "true" and payload["insight_tile"] is True
219
+
220
+
221
+ def test_an_unreadable_flag_is_reported_on_the_started_reply(monkeypatch):
222
+ out, seen = _start(monkeypatch, insight_tile="perhaps")
223
+ assert out["warnings"] and "insight_tile" in out["warnings"][0]
224
+ assert seen["header_tiles"]["insight"] is False
225
+ assert seen["header_tiles"]["warnings"] == out["warnings"]
226
+
227
+
228
+ # ------------------------------------------------------------- placement
229
+
230
+ def test_logo_only_sits_left_of_the_kpis(enhanced):
231
+ gid = _kpi_group_id(enhanced)
232
+ before = _tab(enhanced)
233
+ kpi_before = dict(_tile(before, gid))
234
+ others_before = {t["visualization_id"]: dict(t) for t in before["tiles"]}
235
+ out, changes, warnings = _apply(enhanced, logo=True)
236
+ assert warnings == []
237
+ tab = _tab(out)
238
+ assert _tile(tab, LOGO_TILE_ID) == {"visualization_id": LOGO_TILE_ID, "x": 0, "y": kpi_before["y"],
239
+ "height": kpi_before["height"], "width": 3}
240
+ kpi = _tile(tab, gid)
241
+ assert (kpi["x"], kpi["width"], kpi["height"]) == (3, 9, kpi_before["height"])
242
+ # Nothing else moved, and the KPIs keep their group layout.
243
+ for vid, t in others_before.items():
244
+ if vid != gid:
245
+ assert _tile(tab, vid) == t
246
+ assert tab["group_layouts"] == before["group_layouts"]
247
+
248
+
249
+ def test_insight_only_sits_left_of_the_kpis_with_kpis_two_per_row(enhanced):
250
+ gid = _kpi_group_id(enhanced)
251
+ before = _tab(enhanced)
252
+ k = dict(_tile(before, gid))
253
+ out, _, warnings = _apply(enhanced, insight=True)
254
+ assert warnings == []
255
+ tab = _tab(out)
256
+ ins, kpi = _tile(tab, INSIGHT_TILE_ID), _tile(tab, gid)
257
+ assert (ins["x"], ins["width"], ins["y"]) == (0, 6, k["y"])
258
+ assert (kpi["x"], kpi["width"]) == (6, 6)
259
+ assert ins["height"] == kpi["height"] == 2 * k["height"]
260
+ layout = next(gl for gl in tab["group_layouts"] if gl["id"] == gid)
261
+ assert sorted((t["x"], t["y"], t["width"]) for t in layout["tiles"]) == [
262
+ (0, 0, 6), (0, k["height"], 6), (6, 0, 6), (6, k["height"], 6)]
263
+ # Everything below the KPI row moved down by exactly the extra height.
264
+ for t in before["tiles"]:
265
+ if t["visualization_id"] != gid:
266
+ assert _tile(tab, t["visualization_id"])["y"] == t["y"] + k["height"]
267
+
268
+
269
+ def test_both_tiles_order_is_logo_then_insight_then_kpis(enhanced):
270
+ gid = _kpi_group_id(enhanced)
271
+ out, changes, warnings = _apply(enhanced, logo=True, insight=True)
272
+ assert warnings == []
273
+ tab = _tab(out)
274
+ row = sorted((t for t in tab["tiles"] if t["y"] == 0), key=lambda t: t["x"])
275
+ assert [(t["visualization_id"], t["x"], t["width"]) for t in row] == [
276
+ (LOGO_TILE_ID, 0, 2), (INSIGHT_TILE_ID, 2, 4), (gid, 6, 6)]
277
+ assert len({t["height"] for t in row}) == 1
278
+ assert any("left to right" in c for c in changes)
279
+ # Every tile on the tab sits inside the 12-column grid and none overlap.
280
+ cells = set()
281
+ for t in tab["tiles"]:
282
+ assert 0 <= t["x"] and t["x"] + t["width"] <= 12
283
+ for x in range(t["x"], t["x"] + t["width"]):
284
+ for y in range(t["y"], t["y"] + t["height"]):
285
+ assert (x, y) not in cells, f"overlap at {(x, y)}"
286
+ cells.add((x, y))
287
+
288
+
289
+ def test_no_kpi_row_puts_the_tiles_in_a_new_top_row():
290
+ lb = {"liveboard": {"name": "B", "visualizations": [
291
+ _viz("Viz_1", "Revenue by Region", "COLUMN", "", ["Region", "Total Revenue"], "g1")],
292
+ "layout": {"tabs": [{"name": "Overview", "tiles": [
293
+ {"visualization_id": "Viz_1", "x": 0, "y": 0, "height": 8, "width": 12}]}]}}}
294
+ out, changes, warnings = _apply(lb, logo=True, insight=True)
295
+ tiles = {t["visualization_id"]: t for t in _tab(out)["tiles"]}
296
+ assert (tiles[LOGO_TILE_ID]["x"], tiles[LOGO_TILE_ID]["width"]) == (0, 3)
297
+ assert (tiles[INSIGHT_TILE_ID]["x"], tiles[INSIGHT_TILE_ID]["width"]) == (3, 9)
298
+ assert tiles["Viz_1"]["y"] == tiles[LOGO_TILE_ID]["height"]
299
+ assert "new top row" in changes[-1]
300
+
301
+
302
+ # ---------------------------------------------------------- tile content
303
+
304
+ def test_logo_tile_content_comes_from_the_board_and_links_to_the_portal(enhanced):
305
+ out, changes, _ = _apply(enhanced, logo=True)
306
+ viz = next(v for v in out["liveboard"]["visualizations"] if v["id"] == LOGO_TILE_ID)
307
+ body = viz["note_tile"]["html_parsed_string"]
308
+ assert f'<a href="{PORTAL_URL}"' in body and 'data-image-link="true"' in body
309
+ src = re.search(r'<img src="data:image/svg\+xml;base64,([^"]+)"', body).group(1)
310
+ assert base64.b64decode(src).decode() == SVG
311
+ heading = _tab(out)["name"]
312
+ assert f">{heading}</strong>" in body
313
+ assert "Retail sales at a glance." in body
314
+ # The board's real KPI measures, in bold, without the aggregation prefix.
315
+ bolds = re.findall(r'text-bold" style="white-space: pre-wrap;">([^<]+)</strong>', body)
316
+ assert bolds[0] == heading
317
+ kpi_phrases = bolds[1:]
318
+ assert kpi_phrases and set(kpi_phrases) <= {"revenue", "orders", "margin rate", "customers"}
319
+ assert "Drill into" in body and "store" in body.lower()
320
+ assert "Okta" not in body and "Identity" not in body
321
+ assert any(PORTAL_URL in c for c in changes)
322
+
323
+
324
+ def test_logo_text_is_escaped():
325
+ body = HT.logo_tile_html(logo_svg=SVG, link='https://x.example/?a=1&b="2"', company="A<B>",
326
+ heading="Exec & Co", tagline="t", measures=["Total <Sales>"],
327
+ dimensions=[])
328
+ assert "<B>" not in body and "&lt;sales&gt;" in body and "Exec &amp; Co" in body
329
+ assert 'href="https://x.example/?a=1&amp;b=&quot;2&quot;"' in body
330
+
331
+
332
+ def _insight_code(tml):
333
+ viz = next(v for v in tml["liveboard"]["visualizations"] if v["id"] == INSIGHT_TILE_ID)
334
+ ai = viz["ai_tile"]
335
+ assert ai["target_agent"] == "insight_code_runner"
336
+ assert ai["template_id"] == "text_long_form"
337
+ return decode_analysis_code(ai["analysis_code"])
338
+
339
+
340
+ def test_insight_tile_reads_the_boards_own_kpis_and_drivers(enhanced):
341
+ out, changes, warnings = _apply(enhanced, insight=True)
342
+ assert warnings == []
343
+ code = _insight_code(out)
344
+ files = re.findall(r'"([^"]+)\.csv"', code)
345
+ # The first tab's four KPIs in row order, then its driver charts in layout
346
+ # order — each by the viz_guid of the viz on the board. Region went to the
347
+ # manager tab, so it is not read.
348
+ assert files == ["g-rev", "g-ord", "g-mar", "g-cus", "g-sto", "g-chn"]
349
+ assert "Acme" in code and "Retail Sales" in code
350
+ compile(code, "analysis_code", "exec")
351
+
352
+
353
+ def test_the_insight_code_is_deterministic(enhanced):
354
+ a = _apply(enhanced, insight=True)[0]
355
+ b = _apply(enhanced, insight=True)[0]
356
+ assert yaml.safe_dump(a) == yaml.safe_dump(b)
357
+
358
+
359
+ def _write_csvs(tmp_path, code, preamble):
360
+ """Fake CSVs for every file the code declares, shaped like ThoughtSpot's."""
361
+ lead = ('"Data extract produced by x"\n"Filters applied on data :"\n""\n' if preamble else "")
362
+ sources = json.loads(re.search(r"SOURCES = (\[.*?\n\])", code, re.S).group(1))
363
+ files = dict(re.findall(r'"([a-z0-9_]+)": "([^"]+\.csv)"', code))
364
+ for s in sources:
365
+ path = tmp_path / files[s["key"]]
366
+ if s["kind"] == "trend":
367
+ months = ["Jan 2026", "Feb 2026", "Mar 2026", "Apr 2026", "May 2026", "Jun 2026",
368
+ "Jul 2026", "Aug 2026"]
369
+ rows = "\n".join(f'"{m}","{100 + i * 7}"' for i, m in enumerate(reversed(months)))
370
+ path.write_text(lead + f'"Monthly Date","{s["measures"][0]}"\n' + rows + "\n")
371
+ else:
372
+ cols = s["attributes"] + s["measures"]
373
+ body = "\n".join(",".join(f'"{n}{i}"' if c in s["attributes"] else f'"{(i + 1) * (j + 3) * 10}"'
374
+ for j, c in enumerate(cols)) for i, n in enumerate("ABCD"))
375
+ path.write_text(lead + ",".join(f'"{c}"' for c in cols) + "\n" + body + "\n")
376
+
377
+
378
+ @pytest.mark.parametrize("preamble", [False, True])
379
+ def test_the_insight_code_runs_on_the_boards_csvs(enhanced, tmp_path, preamble):
380
+ """The program ThoughtSpot will run: valid, and it prints an analysis
381
+ from CSVs shaped like ThoughtSpot's (with or without a report preamble)."""
382
+ code = _insight_code(_apply(enhanced, insight=True)[0])
383
+ _write_csvs(tmp_path, code, preamble)
384
+ (tmp_path / "analysis.py").write_text(code)
385
+ p = subprocess.run([sys.executable, "-I", "analysis.py"], cwd=tmp_path,
386
+ capture_output=True, text=True, timeout=60)
387
+ assert p.returncode == 0, p.stderr
388
+ assert "=== User Query ===" in p.stdout and "=== Key Signals ===" in p.stdout
389
+ assert "[SKIPPED" not in p.stdout, p.stdout
390
+ assert "=== Trend:" in p.stdout and "=== Breakdown:" in p.stdout
391
+ assert "Attainment (Total Revenue / Total Revenue Target)" in p.stdout
392
+ assert re.search(r"^1\. ", p.stdout, re.M)
393
+
394
+
395
+ # --------------------------------------------------------- failures warn
396
+
397
+ def test_no_portal_and_no_website_leaves_the_logo_out_with_a_warning():
398
+ link, notes = logo_link(None, "", with_portal=False)
399
+ assert link == "" and "left out" in notes[0] and "with_portal=False" in notes[0]
400
+
401
+
402
+ def test_no_portal_links_the_company_website_with_a_warning():
403
+ link, notes = logo_link(None, "acme.com", with_portal=False)
404
+ assert link == "https://acme.com" and "company website" in notes[0]
405
+ link, notes = logo_link({"_warnings": ["portal build failed"]}, "https://acme.com", True)
406
+ assert link == "https://acme.com" and "portal build did not produce a site" in notes[0]
407
+ assert logo_link({"url": PORTAL_URL}, "acme.com", True) == (PORTAL_URL, [])
408
+
409
+
410
+ def test_logo_is_the_portals_own_file_else_its_wordmark(tmp_path):
411
+ from demoprep_app.portal.builder import DEFAULT_PRIMARY, wordmark_svg
412
+
413
+ (tmp_path / "brand-logo.svg").write_text(SVG)
414
+ assert portal_logo_svg("Acme", {"work_dir": str(tmp_path)}) == (SVG, "the TSE portal's logo")
415
+ svg, source = portal_logo_svg("Acme", None)
416
+ assert svg == wordmark_svg("Acme", DEFAULT_PRIMARY) and "wordmark" in source
417
+
418
+
419
+ def test_the_portal_wordmark_is_unchanged(tmp_path):
420
+ """wordmark() now delegates to wordmark_svg(); the portal's file is byte-identical."""
421
+ from demoprep_app.portal.builder import wordmark
422
+
423
+ wordmark("Acme Corp", "#123a5c", tmp_path / "w.svg")
424
+ label = "ACME CORP"
425
+ width = 40 + len(label) * 11
426
+ assert (tmp_path / "w.svg").read_text() == (
427
+ f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} 40" '
428
+ f'width="{width}" height="40"><text x="4" y="27" font-family="Inter, '
429
+ f'system-ui, sans-serif" font-size="18" font-weight="700" letter-spacing="0.5" '
430
+ f'style="fill:#123a5c">{label}</text></svg>\n')
431
+
432
+
433
+ def test_a_tile_that_cannot_be_built_is_left_out_with_a_warning(enhanced):
434
+ out, changes, warnings = _apply(enhanced, logo=True, link="")
435
+ assert out is enhanced and changes == []
436
+ assert warnings and warnings[0].startswith("logo tile left out")
437
+ no_guids = yaml.safe_load(yaml.safe_dump(enhanced))
438
+ for v in no_guids["liveboard"]["visualizations"]:
439
+ v.pop("viz_guid", None)
440
+ out, changes, warnings = _apply(no_guids, logo=True, insight=True)
441
+ assert any(w.startswith("insight tile left out") for w in warnings)
442
+ assert LOGO_TILE_ID in yaml.safe_dump(out) and INSIGHT_TILE_ID not in yaml.safe_dump(out)
443
+
444
+
445
+ class _FakeTS:
446
+ """Records export/import calls; import answers come from a list."""
447
+
448
+ def __init__(self, tml, import_statuses=(), export_status=200):
449
+ self.base_url = "https://ts.example"
450
+ self.tml, self.imports, self.statuses = tml, [], list(import_statuses)
451
+ self.export_status = export_status
452
+ outer = self
453
+
454
+ class _S:
455
+ def post(self, url, json=None, **kw):
456
+ if url.endswith("/tml/export"):
457
+ return _Resp([{"edoc": yaml.safe_dump(outer.tml)}], outer.export_status)
458
+ outer.imports.append(yaml.safe_load(json["metadata_tmls"][0]))
459
+ status = outer.statuses.pop(0) if outer.statuses else {"status_code": "OK"}
460
+ return _Resp([{"response": {"status": status}}])
461
+
462
+ self.session = _S()
463
+
464
+
465
+ def _run_pass(ts, **kw):
466
+ args = {"company": "Acme", "use_case": "Retail Sales", "company_url": "acme.com",
467
+ "logo": True, "insight": True, "with_portal": True, "portal": {"url": PORTAL_URL}}
468
+ args.update(kw)
469
+ return add_header_tiles_to_board(ts, "lb-1", **args)
470
+
471
+
472
+ def test_the_pass_exports_adds_both_tiles_and_imports_once(enhanced):
473
+ ts = _FakeTS(enhanced)
474
+ out = _run_pass(ts)
475
+ assert out["warnings"] == []
476
+ assert len(ts.imports) == 1
477
+ ids = {v["id"] for v in ts.imports[0]["liveboard"]["visualizations"]}
478
+ assert {LOGO_TILE_ID, INSIGHT_TILE_ID} <= ids
479
+ assert any("logo tile added" in c and PORTAL_URL in c for c in out["changes"])
480
+
481
+
482
+ def test_the_pass_does_nothing_at_all_when_off(enhanced):
483
+ ts = _FakeTS(enhanced)
484
+ assert _run_pass(ts, logo=False, insight=False) == {"changes": [], "warnings": []}
485
+ assert ts.imports == []
486
+
487
+
488
+ def test_a_rejected_tile_is_dropped_and_the_other_kept(enhanced):
489
+ rejected = {"status_code": "ERROR",
490
+ "error_message": f"Visualization <b> {INSIGHT_TILE_ID} </b> has following errors:<br/>bad"}
491
+ ts = _FakeTS(enhanced, [rejected])
492
+ out = _run_pass(ts)
493
+ assert len(ts.imports) == 2
494
+ final_ids = {v["id"] for v in ts.imports[1]["liveboard"]["visualizations"]}
495
+ assert LOGO_TILE_ID in final_ids and INSIGHT_TILE_ID not in final_ids
496
+ assert any(w.startswith("insight tile left out") for w in out["warnings"])
497
+
498
+
499
+ def test_a_rejection_of_anything_else_changes_nothing(enhanced):
500
+ rejected = {"status_code": "ERROR",
501
+ "error_message": "Visualization <b> Viz_5 </b> has following errors:<br/>x"}
502
+ ts = _FakeTS(enhanced, [rejected])
503
+ out = _run_pass(ts)
504
+ assert len(ts.imports) == 1 and out["changes"] == []
505
+ assert out["warnings"][0].startswith("header tiles not added: ThoughtSpot rejected")
506
+
507
+
508
+ def test_an_export_failure_warns_and_imports_nothing(enhanced):
509
+ ts = _FakeTS(enhanced, export_status=500)
510
+ out = _run_pass(ts)
511
+ assert ts.imports == [] and "export failed" in out["warnings"][0]
512
+
513
+
514
+ def test_the_pass_never_raises():
515
+ class Broken:
516
+ base_url = "https://ts.example"
517
+
518
+ @property
519
+ def session(self):
520
+ raise ConnectionError("cluster unreachable")
521
+
522
+ out = _run_pass(Broken())
523
+ assert out["changes"] == [] and "cluster unreachable" in out["warnings"][0]
524
+
525
+
526
+ # ---------------------------------------------- the build runs the pass
527
+
528
+ def _offline_build(monkeypatch, header_tiles, tiles_result=None, tiles_raises=None):
529
+ """_run_build on an existing schema with a fake controller; with_portal=False."""
530
+ import collections
531
+
532
+ import mcp_server
533
+ import thoughtspot_deployer
534
+ from demoprep_app.liveboards import header_tiles as ht
535
+
536
+ calls = {"deployer": 0, "tiles": []}
537
+
538
+ class FakeController:
539
+ def __init__(self, **kw):
540
+ self.settings, self.deployment_completion = {}, None
541
+
542
+ def _create_run_loggers(self, force=False):
543
+ pass
544
+
545
+ def _run_thoughtspot_deployment(self, schema, company, use_case, on_progress=None):
546
+ self.deployment_completion = {"success": True, "liveboard_guid": "lb-1",
547
+ "model_guid": "m-1", "warnings": ["from the build"]}
548
+ return iter(())
549
+
550
+ class FakeDeployer:
551
+ def __init__(self, **kw):
552
+ calls["deployer"] += 1
553
+
554
+ def authenticate_trusted(self):
555
+ return True
556
+
557
+ def fake_tiles(ts, guid, **kw):
558
+ calls["tiles"].append(kw)
559
+ if tiles_raises:
560
+ raise tiles_raises
561
+ return tiles_result or {"changes": [], "warnings": []}
562
+
563
+ monkeypatch.setattr(mcp_server, "ChatDemoInterface", FakeController)
564
+ monkeypatch.setattr(mcp_server, "DemoBuilder", lambda **kw: type("DB", (), {})())
565
+ monkeypatch.setattr(mcp_server, "load_gradio_settings", lambda owner: {})
566
+ monkeypatch.setattr(mcp_server, "log_mcp_payload", lambda p: True)
567
+ monkeypatch.setattr(mcp_server, "log_mcp_result", lambda p: True)
568
+ monkeypatch.setattr(mcp_server, "_recent_builds", collections.OrderedDict())
569
+ monkeypatch.setattr(thoughtspot_deployer, "ThoughtSpotDeployer", FakeDeployer)
570
+ monkeypatch.setattr(ht, "add_header_tiles_to_board", fake_tiles)
571
+ out = mcp_server._run_build("r1", "brief", "Acme", "Retail Sales", "acme.com",
572
+ "https://ts.example", "k", "se@thoughtspot.com", False,
573
+ {"brief": "brief"}, existing_schema="S",
574
+ existing_ddl="CREATE TABLE T (A INT)", header_tiles=header_tiles)
575
+ return out, calls
576
+
577
+
578
+ def test_a_default_build_never_touches_the_board_again(monkeypatch):
579
+ out, calls = _offline_build(monkeypatch, None)
580
+ assert out["status"] == "success", out
581
+ assert calls == {"deployer": 0, "tiles": []}
582
+ assert out["warnings"] == ["from the build"]
583
+
584
+
585
+ def test_a_build_with_the_flags_runs_the_pass_after_the_portal(monkeypatch):
586
+ out, calls = _offline_build(
587
+ monkeypatch, {"logo": True, "insight": False, "warnings": []},
588
+ tiles_result={"changes": ["logo tile added"], "warnings": ["logo tile links to the company website"]})
589
+ assert out["status"] == "success"
590
+ assert calls["deployer"] == 1 and len(calls["tiles"]) == 1
591
+ kw = calls["tiles"][0]
592
+ assert kw["logo"] is True and kw["insight"] is False
593
+ assert kw["with_portal"] is False and kw["portal"] is None and kw["company_url"] == "acme.com"
594
+ assert out["warnings"] == ["from the build", "logo tile links to the company website"]
595
+
596
+
597
+ def test_a_failing_pass_never_fails_the_build(monkeypatch):
598
+ out, calls = _offline_build(monkeypatch, {"logo": False, "insight": True, "warnings": []},
599
+ tiles_raises=RuntimeError("boom"))
600
+ assert out["status"] == "success"
601
+ assert out["warnings"][-1] == "header tiles not added: RuntimeError: boom"
tests/test_payload_capture_is_complete.py CHANGED
@@ -36,7 +36,8 @@ def test_capture_includes_every_parameter_of_the_tool():
36
  ts_url="t", owner_email="o", with_portal=True,
37
  theme_mode="light", auth="none", nav_layout="top", inline_tab=False,
38
  monetize=True, tiers=True, pinning=False, salesforce_opportunity_id="",
39
- environment=""):
 
40
  return M._call_payload(inspect.currentframe())
41
 
42
  got = set(sample())
 
36
  ts_url="t", owner_email="o", with_portal=True,
37
  theme_mode="light", auth="none", nav_layout="top", inline_tab=False,
38
  monetize=True, tiers=True, pinning=False, salesforce_opportunity_id="",
39
+ environment="",
40
+ logo_tile=False, insight_tile=False): # header tiles, 2026-10-20
41
  return M._call_payload(inspect.currentframe())
42
 
43
  got = set(sample())
tests/test_payload_reaches_the_database.py CHANGED
@@ -123,7 +123,8 @@ def test_tool_signature_survives_the_entire_path():
123
  ts_url="t", owner_email="o", with_portal=True,
124
  theme_mode="light", auth="none", nav_layout="top", inline_tab=False,
125
  monetize=True, tiers=True, pinning=False, salesforce_opportunity_id="",
126
- environment=""):
 
127
  return M._call_payload(inspect.currentframe())
128
 
129
  results = captured_row(sample())
 
123
  ts_url="t", owner_email="o", with_portal=True,
124
  theme_mode="light", auth="none", nav_layout="top", inline_tab=False,
125
  monetize=True, tiers=True, pinning=False, salesforce_opportunity_id="",
126
+ environment="",
127
+ logo_tile=False, insight_tile=False): # header tiles, 2026-10-20
128
  return M._call_payload(inspect.currentframe())
129
 
130
  results = captured_row(sample())