Spaces:
Running
feat(mcp): opt-in header tiles — logo_tile and insight_tile
Browse filesbuild_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>
|
@@ -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
|
|
@@ -1337,10 +1337,12 @@ def validate_spec(spec: dict) -> list[str]:
|
|
| 1337 |
|
| 1338 |
# ---------------------------------------------------------------- build steps
|
| 1339 |
|
| 1340 |
-
def
|
|
|
|
|
|
|
| 1341 |
label = company.upper()[:26]
|
| 1342 |
width = 40 + len(label) * 11
|
| 1343 |
-
|
| 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:"
|
|
@@ -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.
|
| 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 |
|
| 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 |
|
|
@@ -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 = ""
|
|
|
|
| 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 |
-
|
| 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
|
|
@@ -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 "<sales>" in body and "Exec & Co" in body
|
| 329 |
+
assert 'href="https://x.example/?a=1&b="2""' 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"
|
|
@@ -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())
|
|
@@ -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())
|