mikeboone Claude Fable 5.1 commited on
Commit
2093856
·
1 Parent(s): 8528a7c

feat(migrator): DP Migrator beta — rebuild a prospect's Tableau dashboard (or a picture of it) as the demo

Browse files

Ketan's "built-in legacy migrator" and Nick's ask (2026-10-28): the SE sends the
prospect's website plus a .twb/.twbx or a dashboard screenshot; the demo's data
speaks the dashboard's measure and dimension names and the liveboard copies its
sheets (chart types, titles, arrangement; a dual axis as a combo chart) instead
of the golden layout. DP Web unchanged.

- demoprep_app/migration/: MirrorSpec contract, source-grammar -> ThoughtSpot
chart-type mapping, deterministic Tableau .twb parser (defusedxml; paths,
hosts and users stripped; blends, relationships, parameters, table calcs and
LOD noted), one-vision-call picture reader, the blueprint statement that
binds the vocabulary, one liveboard question per sheet in the model's column
names, and the argument loader (.twbx -> its .twb only; the payload log keeps
kind, size and hash, never content).
- liveboards/mirror_layout.py + enhance_mcp_liveboard(layout_strategy="mirror"):
retype/retitle from the source before classification; no groups, KPI/donut
conversions, Muze, title rewriting or guide tile; one tab named after the
dashboard, tiles from the source zones or a KPI-first grid.
- create_liveboard_from_model_mcp(explicit_questions), deploy_all(mirror),
ChatDemoInterface.mirror_spec, deployment_completion["mirror"].
- mcp_server: source_dashboard / source_dashboard_image on build_demo_from_brief
(workbook parsed before a slot is taken; picture read on the build thread),
the inspect_dashboard tool, mirror block on the result and recent_builds.
- docs: agent_q_flags.md (ISS-31), agentspot_dp_migrator_agent.md (the
dedicated agent's instructions), sprint doc ISS-31.
- tests: tableau parser (26, on Nick's workbook with the user path scrubbed),
mirror layout (incl. enhance end to end with a fake cluster), questions /
statement / loader / picture JSON; signature-mirroring tests updated.

Verified live on secloud 2026-10-29: board 395e7f14 (Sales as columns, Target
and Sales V Target as lines, monthly; data API 200). Run 1 found the column
matcher taking "Total Sales V Target" for "Target" (fixed, regression test).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

chat_interface.py CHANGED
@@ -447,6 +447,10 @@ class ChatDemoInterface:
447
  # The prospect's brand colours (ISS-19) as BrandTheme.to_dict(). Set only by
448
  # the MCP build when the caller asked for branding; None = default palette.
449
  self.brand_theme = None
 
 
 
 
450
  # Custom MCP demo (GitHub issues → Spotter) Demo Pack section: OFF unless a
451
  # caller turns it on for a run, because it costs an extra LLM call. Only the
452
  # MCP path sets it (build_demo_from_brief custom_mcp_demo=true); the App and
@@ -582,6 +586,9 @@ class ChatDemoInterface:
582
  "ts_username": results.get("ts_username") or "",
583
  "liveboard_creation_path": results.get("liveboard_creation_path") or ("spotter_tml_backup" if backup else "mcp" if liveboard_url else "none"),
584
  "fallback_reason": results.get("fallback_reason") or "",
 
 
 
585
  "model_url": model_url,
586
  "liveboard_url": liveboard_url,
587
  "model_guid": model_guid,
@@ -3375,6 +3382,13 @@ To change settings, use:
3375
 
3376
  row_count_guidance = int(self.settings.get("fact_table_size", 5000) or 5000)
3377
  company_name = self.demo_builder.extract_company_name()
 
 
 
 
 
 
 
3378
  build = build_demo(
3379
  company_name=company_name,
3380
  company_url=self.demo_builder.company_url,
@@ -3389,7 +3403,7 @@ To change settings, use:
3389
  ]
3390
  if part
3391
  ),
3392
- user_request=getattr(self, "generic_use_case_context", "") or self.demo_builder.use_case,
3393
  llm_model=self.settings.get("model", DEFAULT_LLM_MODEL),
3394
  progress_callback=self.log_feedback,
3395
  prompt_logger=self._prompt_logger,
@@ -4809,6 +4823,7 @@ This chat will update when complete."""
4809
  progress_callback=progress_callback,
4810
  session_logger=_slog,
4811
  brand_theme=self.brand_theme,
 
4812
  )
4813
  except Exception as e:
4814
  ts_result["error"] = str(e)
 
447
  # The prospect's brand colours (ISS-19) as BrandTheme.to_dict(). Set only by
448
  # the MCP build when the caller asked for branding; None = default palette.
449
  self.brand_theme = None
450
+ # DP Migrator (boone 2026-10-28): the prospect's dashboard as a MirrorSpec
451
+ # dict, set by the MCP build only. It binds the blueprint's vocabulary and
452
+ # switches the liveboard to the mirror layout. None = an ordinary demo.
453
+ self.mirror_spec = None
454
  # Custom MCP demo (GitHub issues → Spotter) Demo Pack section: OFF unless a
455
  # caller turns it on for a run, because it costs an extra LLM call. Only the
456
  # MCP path sets it (build_demo_from_brief custom_mcp_demo=true); the App and
 
586
  "ts_username": results.get("ts_username") or "",
587
  "liveboard_creation_path": results.get("liveboard_creation_path") or ("spotter_tml_backup" if backup else "mcp" if liveboard_url else "none"),
588
  "fallback_reason": results.get("fallback_reason") or "",
589
+ # DP Migrator: the tiles asked from the source dashboard and the sheets
590
+ # dropped, when the build mirrored one (thoughtspot_deployer.deploy_all).
591
+ "mirror": results.get("mirror"),
592
  "model_url": model_url,
593
  "liveboard_url": liveboard_url,
594
  "model_guid": model_guid,
 
3382
 
3383
  row_count_guidance = int(self.settings.get("fact_table_size", 5000) or 5000)
3384
  company_name = self.demo_builder.extract_company_name()
3385
+ _user_request = getattr(self, "generic_use_case_context", "") or self.demo_builder.use_case
3386
+ if getattr(self, "mirror_spec", None):
3387
+ # DP Migrator: the prospect's dashboard vocabulary, as a request the
3388
+ # blueprint author honours (measure/dimension names, date, charts).
3389
+ from demoprep_app.migration.spec import MirrorSpec
3390
+ from demoprep_app.migration.statement import statement_from_spec
3391
+ _user_request = f"{_user_request}\n\n{statement_from_spec(MirrorSpec.from_dict(self.mirror_spec))}"
3392
  build = build_demo(
3393
  company_name=company_name,
3394
  company_url=self.demo_builder.company_url,
 
3403
  ]
3404
  if part
3405
  ),
3406
+ user_request=_user_request,
3407
  llm_model=self.settings.get("model", DEFAULT_LLM_MODEL),
3408
  progress_callback=self.log_feedback,
3409
  prompt_logger=self._prompt_logger,
 
4823
  progress_callback=progress_callback,
4824
  session_logger=_slog,
4825
  brand_theme=self.brand_theme,
4826
+ mirror=getattr(self, 'mirror_spec', None),
4827
  )
4828
  except Exception as e:
4829
  ts_result["error"] = str(e)
demoprep_app/liveboards/mirror_layout.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mirror layout: the board looks like the prospect's dashboard, not like ours.
2
+
3
+ Used by enhance_mcp_liveboard(layout_strategy="mirror", mirror=...). The
4
+ golden layout regroups a board into executive/manager/showcase tabs, promotes
5
+ KPIs, lets the Muze engine retype charts and rewrites titles. None of that is
6
+ wanted when the SE sent the prospect's own dashboard: the tiles keep the
7
+ source sheets' chart types, titles and arrangement (boone, 2026-10-28:
8
+ "recreate the liveboard as much like theirs as we can").
9
+
10
+ Two passes, called at different points of the enhance step:
11
+ retype_for_mirror right after the MCP board is exported, before any
12
+ classification: match each viz to its source tile,
13
+ set the source chart type (the combo config for a
14
+ dual axis), the source title.
15
+ apply_mirror_layout at layout time: one tab named after the dashboard,
16
+ tiles placed from the source zones (or a plain grid
17
+ when the source had no dashboard), no groups.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from typing import Any, Dict, List, Tuple
23
+
24
+ from demoprep_app.liveboards.golden_layout import (
25
+ CHART_ROW_H, KPI_ROW_H, _advanced_chart_config, _answer_column_names,
26
+ _chart_column_names, _combo_chart_config, _load_chart_client_state,
27
+ json_dumps_compact,
28
+ )
29
+
30
+ COMBO = "ADVANCED_LINE_COLUMN"
31
+ TABLE_TYPES = {"TABLE", "PIVOT_TABLE"}
32
+ #: Rows per column of width on a ThoughtSpot board: a 6-wide, 8-tall chart tile
33
+ #: reads roughly square, so one row unit is about 0.6 of one column unit.
34
+ ROW_UNITS_PER_COLUMN_UNIT = 1.6
35
+ GRID_COLUMNS = 12
36
+ MIN_TILE_W = 2
37
+ MIN_TILE_H = 3
38
+
39
+
40
+ # --------------------------------------------------------------------------
41
+ # pass 1: chart types and titles
42
+ # --------------------------------------------------------------------------
43
+ def retype_for_mirror(visualizations: List[Dict[str, Any]], tiles: List[Dict[str, Any]]
44
+ ) -> Tuple[List[str], Dict[str, Dict[str, Any]]]:
45
+ """Set each matched viz's chart type and title from its source tile.
46
+ Returns (changes, mapping viz_id -> tile)."""
47
+ changes: List[str] = []
48
+ data_vizs = [v for v in visualizations if "note_tile" not in v and v.get("id")]
49
+ mapping = match_tiles(data_vizs, tiles)
50
+ retyped = 0
51
+ titled = 0
52
+ seen_titles: Dict[str, int] = {}
53
+ for viz in data_vizs:
54
+ tile = mapping.get(viz["id"])
55
+ if not tile:
56
+ continue
57
+ answer = viz.setdefault("answer", {})
58
+ title = _unique_title(tile.get("title") or answer.get("name") or viz["id"], seen_titles)
59
+ if answer.get("name") != title:
60
+ answer["name"] = title
61
+ titled += 1
62
+ if _set_chart_type(answer, tile):
63
+ retyped += 1
64
+ unmatched = [v["id"] for v in data_vizs if v["id"] not in mapping]
65
+ if mapping:
66
+ changes.append(f"Mirror: matched {len(mapping)} of {len(tiles)} source tile(s) to board answers")
67
+ if retyped:
68
+ changes.append(f"Mirror: set {retyped} chart type(s) from the source sheets")
69
+ if titled:
70
+ changes.append(f"Mirror: kept {titled} source title(s)")
71
+ if unmatched:
72
+ changes.append(f"Mirror: {len(unmatched)} answer(s) had no source tile and keep their own type: "
73
+ + ", ".join(unmatched[:6]))
74
+ missing = [t.get("title") for t in tiles if not any(m is t for m in mapping.values())]
75
+ if missing:
76
+ changes.append("Mirror: no answer came back for " + ", ".join(repr(m) for m in missing[:6]))
77
+ return changes, mapping
78
+
79
+
80
+ def match_tiles(vizs: List[Dict[str, Any]], tiles: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
81
+ """viz_id -> tile. Each answer takes the unused tile whose measures,
82
+ dimensions and date its name, query and columns mention best; position
83
+ (the MCP board keeps the answers in the order the questions were asked)
84
+ only breaks ties. A tile is used once; an answer that mentions none of a
85
+ tile's words stays unmatched."""
86
+ mapping: Dict[str, Dict[str, Any]] = {}
87
+ used: set = set()
88
+ for idx, viz in enumerate(vizs):
89
+ best, best_key = None, (0.0, 0.0)
90
+ for j, tile in enumerate(tiles):
91
+ if id(tile) in used:
92
+ continue
93
+ key = (_overlap(viz, tile), -abs(j - idx))
94
+ if key > best_key:
95
+ best, best_key = tile, key
96
+ if best is not None:
97
+ mapping[viz["id"]] = best
98
+ used.add(id(best))
99
+ return mapping
100
+
101
+
102
+ #: Words that say how a column is aggregated or bucketed, not which column.
103
+ _FILLER = {"total", "sum", "average", "avg", "count", "unique", "min", "max", "median",
104
+ "monthly", "weekly", "daily", "quarterly", "yearly", "by", "and", "of", "the"}
105
+
106
+
107
+ def _overlap(viz: Dict[str, Any], tile: Dict[str, Any]) -> float:
108
+ """How well an answer fits a tile: mostly whether the tile's fields are
109
+ all mentioned (recall), partly whether the answer's columns are only those
110
+ fields (precision), so a KPI answer does not take a combo tile that
111
+ wanted one more measure."""
112
+ answer = viz.get("answer") or {}
113
+ columns = _answer_column_names(answer) or []
114
+ have_cols = _words(" ".join(columns)) - _FILLER
115
+ have = (_words(" ".join([str(answer.get("name") or ""), str(answer.get("search_query") or "")]))
116
+ | have_cols) - _FILLER
117
+ want = set()
118
+ for key in ("measures", "dimensions"):
119
+ for name in tile.get(key) or []:
120
+ want |= _words(name)
121
+ if tile.get("date"):
122
+ want |= _words(tile["date"])
123
+ want -= _FILLER
124
+ if not want:
125
+ return 0.0
126
+ recall = len(have & want) / len(want)
127
+ if recall == 0:
128
+ return 0.0
129
+ precision = (len(have_cols & want) / len(have_cols)) if have_cols else recall
130
+ return 0.7 * recall + 0.3 * precision
131
+
132
+
133
+ def _words(text: str) -> set:
134
+ return {w for w in re.split(r"[^a-z0-9]+", (text or "").lower()) if len(w) > 1}
135
+
136
+
137
+ def _unique_title(title: str, seen: Dict[str, int]) -> str:
138
+ n = seen.get(title, 0) + 1
139
+ seen[title] = n
140
+ return title if n == 1 else f"{title} ({n})"
141
+
142
+
143
+ def _set_chart_type(answer: Dict[str, Any], tile: Dict[str, Any]) -> bool:
144
+ """The source chart type on the answer, with axes that match it. Returns
145
+ True when the answer changed."""
146
+ want = str(tile.get("chart_type") or "").upper()
147
+ if not want:
148
+ return False
149
+ chart = answer.get("chart") or {}
150
+ current = str(chart.get("type") or "").upper()
151
+ if want in TABLE_TYPES:
152
+ changed = answer.get("display_mode") != "TABLE_MODE" or bool(answer.get("chart"))
153
+ answer["display_mode"] = "TABLE_MODE"
154
+ answer.pop("chart", None)
155
+ return changed
156
+ columns = _answer_column_names(answer) or _chart_column_names(chart)
157
+ measures = _ordered(columns, tile.get("measures") or [])
158
+ dims = _ordered(columns, tile.get("dimensions") or [])
159
+ date = _first(columns, tile.get("date"))
160
+ x = [date] if date else dims[:1]
161
+ color = [d for d in dims if d not in x][:1]
162
+ answer["display_mode"] = "CHART_MODE"
163
+ chart = answer.setdefault("chart", {})
164
+ chart["type"] = want
165
+ chart.pop("axis_configs", None)
166
+ chart.pop("custom_chart_config", None)
167
+ if want == "KPI" or not x or not measures:
168
+ # A KPI declares nothing; a chart whose columns we cannot see keeps
169
+ # whatever ThoughtSpot infers (the enhance step's output-column pass
170
+ # will declare them from the answer later).
171
+ return current != want
172
+ if want == COMBO:
173
+ chart["custom_chart_config"] = _combo_chart_config(x, measures)
174
+ elif want.startswith("ADVANCED_"):
175
+ chart["custom_chart_config"] = _advanced_chart_config(x, measures, color, want)
176
+ else:
177
+ axis = {"x": x, "y": measures}
178
+ if color:
179
+ axis["color"] = color
180
+ chart["axis_configs"] = [axis]
181
+ state = _load_chart_client_state(chart)
182
+ state["axisProperties"] = [
183
+ {"id": "y-measures", "properties": {"axisType": "Y", "linkedColumns": measures, "isOpposite": False}},
184
+ {"id": "x-dim", "properties": {"axisType": "X", "linkedColumns": x}},
185
+ ]
186
+ chart["client_state_v2"] = json_dumps_compact(state)
187
+ return True
188
+
189
+
190
+ def _ordered(columns: List[str], wanted: List[str]) -> List[str]:
191
+ """The answer's columns that correspond to `wanted`, in `wanted` order."""
192
+ out: List[str] = []
193
+ for name in wanted:
194
+ hit = _first(columns, name)
195
+ if hit and hit not in out:
196
+ out.append(hit)
197
+ return out
198
+
199
+
200
+ def _first(columns: List[str], name: str | None) -> str | None:
201
+ """The answer column for a tile field. Exact once normalised, else the
202
+ column whose words (aggregation words aside) cover the field's with the
203
+ FEWEST extra words: "Target" takes "Total Target", not "Total Sales V
204
+ Target" (the live board of 2026-10-29 lost its target line to that)."""
205
+ if not name:
206
+ return None
207
+ key = _norm(name)
208
+ for c in columns:
209
+ if _norm(c) == key:
210
+ return c
211
+ want = _words(name) - _FILLER
212
+ if not want:
213
+ return None
214
+ best, best_extra = None, None
215
+ for c in columns:
216
+ have = _words(c) - _FILLER
217
+ if want <= have:
218
+ extra = len(have - want)
219
+ if best is None or extra < best_extra:
220
+ best, best_extra = c, extra
221
+ if best is not None:
222
+ return best
223
+ for c in columns:
224
+ have = _words(c) - _FILLER
225
+ if have and len(want & have) / len(want | have) >= 0.5:
226
+ return c
227
+ return None
228
+
229
+
230
+ def _norm(name: str) -> str:
231
+ return re.sub(r"[^a-z0-9]", "", (name or "").lower())
232
+
233
+
234
+ # --------------------------------------------------------------------------
235
+ # pass 2: one tab, tiles where the source put them
236
+ # --------------------------------------------------------------------------
237
+ def apply_mirror_layout(liveboard_tml: Dict[str, Any], mirror: Dict[str, Any],
238
+ tiles: List[Dict[str, Any]], mapping: Dict[str, Dict[str, Any]]
239
+ ) -> Tuple[Dict[str, Any], List[str]]:
240
+ """Lay the board out like the source dashboard. Mutates and returns the
241
+ same TML tree (callers keep their reference), plus the change notes."""
242
+ liveboard = liveboard_tml.setdefault("liveboard", {})
243
+ vizs = [v for v in liveboard.get("visualizations", []) if "note_tile" not in v and v.get("id")]
244
+ liveboard["visualizations"] = [v for v in liveboard.get("visualizations", []) if "note_tile" not in v]
245
+ liveboard.pop("groups", None)
246
+
247
+ zones = _zones_by_sheet(mirror)
248
+ size = _dashboard_size(mirror)
249
+ placed: List[Dict[str, Any]] = []
250
+ from_zones = 0
251
+ for viz in vizs:
252
+ tile = mapping.get(viz["id"])
253
+ zone = zones.get(tile.get("sheet")) if tile else None
254
+ if zone:
255
+ placed.append({"viz": viz, "tile": tile, **_zone_to_grid(zone, size)})
256
+ from_zones += 1
257
+ if from_zones != len(vizs):
258
+ # No usable dashboard: a grid in tile order, KPIs first in one row.
259
+ placed = _grid(vizs, mapping)
260
+ source = "grid (no source dashboard layout)"
261
+ else:
262
+ placed.sort(key=lambda p: (p["y"], p["x"]))
263
+ _resolve_overlaps(placed)
264
+ source = "source dashboard zones"
265
+
266
+ tab_name = _tab_name(mirror)
267
+ liveboard["layout"] = {"tabs": [{
268
+ "name": tab_name,
269
+ "description": f"Rebuilt from the prospect's dashboard ({mirror.get('source_kind', 'source')})",
270
+ "tiles": [{"visualization_id": p["viz"]["id"], "x": p["x"], "y": p["y"],
271
+ "height": p["h"], "width": p["w"]} for p in placed],
272
+ }]}
273
+ return liveboard_tml, [f"Mirror layout: 1 tab '{tab_name}', {len(placed)} tile(s) from {source}"]
274
+
275
+
276
+ def _tab_name(mirror: Dict[str, Any]) -> str:
277
+ name = str(mirror.get("mirrored_dashboard") or mirror.get("name") or "Dashboard").strip()
278
+ return name[:60] or "Dashboard"
279
+
280
+
281
+ def _zones_by_sheet(mirror: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
282
+ out: Dict[str, Dict[str, Any]] = {}
283
+ for dash in mirror.get("dashboards") or []:
284
+ sheet_zones = [z for z in (dash.get("zones") or []) if z.get("kind", "sheet") == "sheet"]
285
+ if not sheet_zones:
286
+ continue
287
+ for z in sheet_zones:
288
+ out.setdefault(z["sheet"], z)
289
+ break # the first dashboard with sheets is the one mirrored
290
+ return out
291
+
292
+
293
+ def _dashboard_size(mirror: Dict[str, Any]) -> Tuple[int, int]:
294
+ for dash in mirror.get("dashboards") or []:
295
+ if any(z.get("kind", "sheet") == "sheet" for z in (dash.get("zones") or [])):
296
+ return int(dash.get("width") or 0), int(dash.get("height") or 0)
297
+ return 0, 0
298
+
299
+
300
+ def _zone_to_grid(zone: Dict[str, Any], size: Tuple[int, int]) -> Dict[str, int]:
301
+ width, height = size
302
+ aspect = (height / width) if width and height else 9 / 16
303
+ rows = max(GRID_COLUMNS, round(GRID_COLUMNS * ROW_UNITS_PER_COLUMN_UNIT * aspect))
304
+ x = max(0, min(GRID_COLUMNS - MIN_TILE_W, round(float(zone["x"]) * GRID_COLUMNS)))
305
+ w = max(MIN_TILE_W, round(float(zone["w"]) * GRID_COLUMNS))
306
+ w = min(w, GRID_COLUMNS - x)
307
+ y = max(0, round(float(zone["y"]) * rows))
308
+ h = max(MIN_TILE_H, round(float(zone["h"]) * rows))
309
+ return {"x": x, "y": y, "w": w, "h": h}
310
+
311
+
312
+ def _resolve_overlaps(placed: List[Dict[str, Any]]) -> None:
313
+ """Push a tile down until it overlaps nothing placed before it."""
314
+ done: List[Dict[str, Any]] = []
315
+ for p in placed:
316
+ while any(_overlaps(p, q) for q in done):
317
+ p["y"] += 1
318
+ done.append(p)
319
+
320
+
321
+ def _overlaps(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
322
+ return not (a["x"] + a["w"] <= b["x"] or b["x"] + b["w"] <= a["x"]
323
+ or a["y"] + a["h"] <= b["y"] or b["y"] + b["h"] <= a["y"])
324
+
325
+
326
+ def _grid(vizs: List[Dict[str, Any]], mapping: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]:
327
+ kpis = [v for v in vizs if _type_of(v, mapping) == "KPI"]
328
+ rest = [v for v in vizs if v not in kpis]
329
+ placed: List[Dict[str, Any]] = []
330
+ y = 0
331
+ if kpis:
332
+ w = max(2, min(6, GRID_COLUMNS // max(1, min(len(kpis), 6))))
333
+ per_row = GRID_COLUMNS // w
334
+ for i, v in enumerate(kpis):
335
+ placed.append({"viz": v, "tile": mapping.get(v["id"]), "x": (i % per_row) * w,
336
+ "y": y + (i // per_row) * KPI_ROW_H, "w": w, "h": KPI_ROW_H})
337
+ y += ((len(kpis) - 1) // per_row + 1) * KPI_ROW_H
338
+ x = 0
339
+ for v in rest:
340
+ full = _type_of(v, mapping) in TABLE_TYPES or len(rest) == 1
341
+ w = GRID_COLUMNS if full else GRID_COLUMNS // 2
342
+ if x + w > GRID_COLUMNS:
343
+ x, y = 0, y + CHART_ROW_H
344
+ placed.append({"viz": v, "tile": mapping.get(v["id"]), "x": x, "y": y, "w": w, "h": CHART_ROW_H})
345
+ x += w
346
+ if x >= GRID_COLUMNS:
347
+ x, y = 0, y + CHART_ROW_H
348
+ return placed
349
+
350
+
351
+ def _type_of(viz: Dict[str, Any], mapping: Dict[str, Dict[str, Any]]) -> str:
352
+ tile = mapping.get(viz.get("id"))
353
+ if tile and tile.get("chart_type"):
354
+ return str(tile["chart_type"]).upper()
355
+ answer = viz.get("answer") or {}
356
+ if answer.get("display_mode") == "TABLE_MODE":
357
+ return "TABLE"
358
+ return str((answer.get("chart") or {}).get("type") or "").upper()
demoprep_app/migration/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DP Migrator: rebuild a prospect's existing dashboard as a DemoPrep demo.
2
+
3
+ The SE hands Agent Q the prospect's Tableau workbook (.twb, or a .twbx whose
4
+ .twb is read and nothing else) or a screenshot of the dashboard. Either becomes
5
+ one MirrorSpec (spec.py): the fields, tables, joins and sheets the dashboard is
6
+ made of. The spec then does two things in the normal build:
7
+
8
+ 1. It seeds the blueprint: a custom statement (statement.py) that binds the
9
+ measure and dimension names so the synthetic data speaks the prospect's
10
+ vocabulary. The story (planted insights, Spotter questions) is still
11
+ authored as for any demo.
12
+ 2. It drives the liveboard: one question per source sheet (questions.py) and
13
+ a mirror layout (demoprep_app/liveboards/mirror_layout.py) that keeps the
14
+ source chart types, titles and arrangement instead of the golden layout.
15
+
16
+ No customer data is ever read: a .twbx's extracts are ignored, and connection
17
+ details (server, file path, user) are stripped by the parser.
18
+ """
demoprep_app/migration/image_spec.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A MirrorSpec read off a picture of the dashboard.
2
+
3
+ One vision call: the image plus a prompt that asks for the same facts the
4
+ Tableau parser reads from a workbook, as JSON. The result is lower confidence
5
+ by construction (a picture carries titles, chart types, axis labels and legend
6
+ values, but no data types, joins or formulas) and the spec says so: every
7
+ sheet's confidence comes from the model, the spec's confidence is their mean,
8
+ and the summary the SE sees lists what was inferred.
9
+
10
+ The call goes through MultiLLMResearcher (main_research.py), the same client
11
+ every other LLM stage uses, on an Anthropic model: its messages are passed to
12
+ the API as given, so an image block works.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import json
18
+ import re
19
+ from statistics import fmean
20
+ from typing import Any
21
+
22
+ from demoprep_app.migration.mapping import chart_type_for
23
+ from demoprep_app.migration.spec import (
24
+ CHART_TYPES, FIDELITY_APPROXIMATED, GRAINS, MirrorError, MirrorSpec,
25
+ ROLE_DATE, ROLE_DIMENSION, ROLE_MEASURE, SOURCE_IMAGE, ShelfField,
26
+ SourceDashboard, SourceField, SourceSheet, Zone, source_fingerprint,
27
+ )
28
+
29
+ IMAGE_MAX_TOKENS = 6000
30
+ _TEMPERATURE = 0.1
31
+
32
+ PROMPT = """You are reading a screenshot of a business intelligence dashboard (Tableau, Power BI, Looker or similar) so it can be rebuilt chart by chart on another platform.
33
+
34
+ Return ONLY a JSON object (no markdown fences, no commentary) with this shape:
35
+ {
36
+ "dashboard_name": "<the dashboard's title as shown, or a short descriptive name>",
37
+ "measures": [{"name": "<as labelled>", "aggregation": "sum|avg|count|count_distinct|min|max|median", "format": "currency|pct|int|float"}],
38
+ "dimensions": [{"name": "<as labelled>", "sample_values": ["<up to 6 values visible>"]}],
39
+ "dates": [{"name": "<date field as labelled, e.g. Order Date>", "grain": "day|week|month|quarter|year|"}],
40
+ "filters": ["<filter controls visible, by field name>"],
41
+ "sheets": [
42
+ {
43
+ "title": "<chart title as shown>",
44
+ "marks": ["<one or more of: Bar, Line, Area, Pie, Text, Circle, Square, Map>"],
45
+ "measures": [{"name": "<measure>", "aggregation": "sum|avg|count|count_distinct|min|max|median"}],
46
+ "x": "<the field on the horizontal axis, or empty>",
47
+ "y_dimension": "<a dimension on the vertical axis (horizontal bars), or empty>",
48
+ "color": "<field the marks are coloured/split by, or empty>",
49
+ "date": {"name": "<date field on an axis, or empty>", "grain": "day|week|month|quarter|year|"},
50
+ "dual_axis": true/false,
51
+ "position": {"x": 0.0, "y": 0.0, "w": 0.5, "h": 0.5},
52
+ "confidence": 0.0
53
+ }
54
+ ],
55
+ "confidence": 0.0,
56
+ "notes": ["<anything you could not read or had to assume>"]
57
+ }
58
+
59
+ Rules:
60
+ - One entry in "sheets" per chart, KPI card or table on the dashboard, in reading order (top-left first).
61
+ - A single big number with a label is a sheet with marks ["Text"], one measure, no x and no date.
62
+ - "position" is the chart's bounding box as fractions of the whole dashboard image (0..1).
63
+ - Use the labels exactly as printed. Do not invent fields that are not visible.
64
+ - "confidence" is 0..1: how sure you are of that sheet's fields and type (1 = clearly labelled)."""
65
+
66
+
67
+ def image_to_spec(image_arg: str, *, llm_model: str | None = None) -> MirrorSpec:
68
+ from demoprep_app.portal.login_clone.sources import ScreenshotError, load_screenshot
69
+
70
+ try:
71
+ data = load_screenshot(image_arg)
72
+ except ScreenshotError as exc:
73
+ raise MirrorError(f"source_dashboard_image: {exc}") from exc
74
+ media = _media_type(data)
75
+ if not media:
76
+ raise MirrorError("source_dashboard_image must be a png, jpeg or webp image")
77
+
78
+ raw = _ask_vision(data, media, llm_model)
79
+ parsed = _extract_json(raw)
80
+ if not isinstance(parsed, dict) or not parsed.get("sheets"):
81
+ raise MirrorError("could not read any chart from the image; send a clearer, larger screenshot "
82
+ "of the dashboard (or the workbook file)")
83
+ spec = _spec_from_json(parsed)
84
+ spec.source_meta = {"format": "image", "media_type": media, **source_fingerprint(data)}
85
+ return spec
86
+
87
+
88
+ def _ask_vision(data: bytes, media: str, llm_model: str | None) -> str:
89
+ from llm_config import DEFAULT_LLM_MODEL, map_llm_display_to_provider
90
+ from main_research import MultiLLMResearcher
91
+
92
+ provider, model = map_llm_display_to_provider(llm_model or DEFAULT_LLM_MODEL)
93
+ if provider != "anthropic":
94
+ # The image block below is the Anthropic shape; other providers would
95
+ # need their own. Use the default (Anthropic) model for this one call.
96
+ provider, model = map_llm_display_to_provider(DEFAULT_LLM_MODEL)
97
+ if provider != "anthropic":
98
+ raise MirrorError("reading a dashboard picture needs an Anthropic model; none is configured")
99
+ researcher = MultiLLMResearcher(provider=provider, model=model)
100
+ messages = [{"role": "user", "content": [
101
+ {"type": "image", "source": {"type": "base64", "media_type": media,
102
+ "data": base64.b64encode(data).decode("ascii")}},
103
+ {"type": "text", "text": PROMPT},
104
+ ]}]
105
+ text = researcher.make_request(messages, _TEMPERATURE, IMAGE_MAX_TOKENS, False)
106
+ if not text:
107
+ raise MirrorError("the vision model returned nothing for the dashboard picture")
108
+ return str(text)
109
+
110
+
111
+ def _spec_from_json(d: dict[str, Any]) -> MirrorSpec:
112
+ fields: list[SourceField] = []
113
+ seen: set[str] = set()
114
+
115
+ def add(name: str, role: str, **kw) -> None:
116
+ name = (name or "").strip()
117
+ key = name.lower()
118
+ if name and key not in seen:
119
+ seen.add(key)
120
+ fields.append(SourceField(name=name, role=role, **kw))
121
+
122
+ for m in d.get("measures") or []:
123
+ if isinstance(m, dict):
124
+ add(m.get("name", ""), ROLE_MEASURE, datatype="real",
125
+ aggregation=_agg(m.get("aggregation")), notes=([f"format {m['format']}"] if m.get("format") else []))
126
+ for x in d.get("dimensions") or []:
127
+ if isinstance(x, dict):
128
+ add(x.get("name", ""), ROLE_DIMENSION,
129
+ notes=([f"values seen: {', '.join(str(v) for v in x['sample_values'][:6])}"]
130
+ if x.get("sample_values") else []))
131
+ for dt in d.get("dates") or []:
132
+ if isinstance(dt, dict):
133
+ add(dt.get("name", ""), ROLE_DATE, datatype="date")
134
+
135
+ sheets: list[SourceSheet] = []
136
+ zones: list[Zone] = []
137
+ confidences: list[float] = []
138
+ for i, s in enumerate(d.get("sheets") or [], 1):
139
+ if not isinstance(s, dict):
140
+ continue
141
+ title = str(s.get("title") or f"Chart {i}").strip()
142
+ name = f"Sheet {i}"
143
+ measures = [ShelfField(str(m.get("name")).strip(), ROLE_MEASURE, _agg(m.get("aggregation")))
144
+ for m in (s.get("measures") or []) if isinstance(m, dict) and m.get("name")]
145
+ for m in measures:
146
+ add(m.field, ROLE_MEASURE, datatype="real", aggregation=m.aggregation)
147
+ rows: list[ShelfField] = list(measures)
148
+ cols: list[ShelfField] = []
149
+ y_dim = str(s.get("y_dimension") or "").strip()
150
+ x_field = str(s.get("x") or "").strip()
151
+ date = s.get("date") if isinstance(s.get("date"), dict) else {}
152
+ date_name = str(date.get("name") or "").strip()
153
+ grain = str(date.get("grain") or "").strip().lower()
154
+ grain = grain if grain in GRAINS else ""
155
+ if date_name:
156
+ add(date_name, ROLE_DATE, datatype="date")
157
+ cols.append(ShelfField(date_name, ROLE_DATE, grain=grain))
158
+ if x_field.lower() == date_name.lower():
159
+ x_field = ""
160
+ if x_field:
161
+ add(x_field, ROLE_DIMENSION)
162
+ cols.append(ShelfField(x_field, ROLE_DIMENSION))
163
+ if y_dim:
164
+ # Horizontal bars: the dimension runs down the rows, measures across.
165
+ add(y_dim, ROLE_DIMENSION)
166
+ rows, cols = [ShelfField(y_dim, ROLE_DIMENSION)], cols + measures
167
+ color_name = str(s.get("color") or "").strip()
168
+ color = None
169
+ if color_name and color_name.lower() not in {f.field.lower() for f in rows + cols}:
170
+ add(color_name, ROLE_DIMENSION)
171
+ color = ShelfField(color_name, ROLE_DIMENSION)
172
+ marks = [str(m) for m in (s.get("marks") or []) if m]
173
+ dual = bool(s.get("dual_axis"))
174
+ chart_type, fidelity, notes = chart_type_for(marks, rows, cols, color, None, dual)
175
+ conf = _clamp(s.get("confidence"), 0.6)
176
+ confidences.append(conf)
177
+ sheet_notes = ["read from a picture: fields and types are inferred"] + notes
178
+ sheets.append(SourceSheet(
179
+ name=name, title=title, marks=marks, rows=rows, cols=cols, color=color,
180
+ dual_axis=dual, chart_type=chart_type, fidelity=FIDELITY_APPROXIMATED,
181
+ notes=sheet_notes, confidence=conf,
182
+ ))
183
+ pos = s.get("position") if isinstance(s.get("position"), dict) else None
184
+ if pos:
185
+ zones.append(Zone(sheet=name, x=_clamp(pos.get("x"), 0.0), y=_clamp(pos.get("y"), 0.0),
186
+ w=_clamp(pos.get("w"), 0.5), h=_clamp(pos.get("h"), 0.3)))
187
+
188
+ dash_name = str(d.get("dashboard_name") or "Dashboard").strip()
189
+ dashboards = [SourceDashboard(name=dash_name, zones=zones)] if zones else []
190
+ notes = [str(n) for n in (d.get("notes") or []) if n]
191
+ filters = [str(f) for f in (d.get("filters") or []) if f]
192
+ for sh in sheets:
193
+ sh.filters = list(filters)
194
+ spec = MirrorSpec(
195
+ source_kind=SOURCE_IMAGE, name=dash_name, fields=fields, sheets=sheets,
196
+ dashboards=dashboards,
197
+ warnings=["read from a picture: no joins, data types or formulas; names as printed"] + notes,
198
+ confidence=round(fmean(confidences), 2) if confidences else _clamp(d.get("confidence"), 0.5),
199
+ )
200
+ return spec
201
+
202
+
203
+ def _agg(value: Any) -> str:
204
+ v = str(value or "").strip().lower()
205
+ return v if v in {"sum", "avg", "count", "count_distinct", "min", "max", "median"} else "sum"
206
+
207
+
208
+ def _clamp(value: Any, default: float) -> float:
209
+ try:
210
+ f = float(value)
211
+ except (TypeError, ValueError):
212
+ return default
213
+ return max(0.0, min(1.0, f))
214
+
215
+
216
+ def _media_type(data: bytes) -> str:
217
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
218
+ return "image/png"
219
+ if data[:3] == b"\xff\xd8\xff":
220
+ return "image/jpeg"
221
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
222
+ return "image/webp"
223
+ return ""
224
+
225
+
226
+ def _extract_json(text: str) -> Any:
227
+ t = text.strip()
228
+ t = re.sub(r"\A```(?:json)?\s*|\s*```\Z", "", t, flags=re.I)
229
+ try:
230
+ return json.loads(t)
231
+ except json.JSONDecodeError:
232
+ start, end = t.find("{"), t.rfind("}")
233
+ if start >= 0 and end > start:
234
+ try:
235
+ return json.loads(t[start:end + 1])
236
+ except json.JSONDecodeError:
237
+ return None
238
+ return None
demoprep_app/migration/mapping.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source chart grammar -> ThoughtSpot chart type.
2
+
3
+ One function, used by both producers (the Tableau parser and the vision path),
4
+ so a sheet is typed the same way whichever way it came in. The rule set is
5
+ deliberately small and ranked: the first rule that fits wins.
6
+
7
+ A dual axis is the case this started from (Nick's example, 2026-10-28): two
8
+ measures on one shelf with different mark types (bar + area). ThoughtSpot draws
9
+ that as its combo chart (ADVANCED_LINE_COLUMN: first measure as columns, the
10
+ rest as lines), which golden_layout already knows how to configure so it
11
+ renders. It is an approximation and the notes say so.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from demoprep_app.migration.spec import (
16
+ FIDELITY_APPROXIMATED, FIDELITY_DROPPED, FIDELITY_EXACT,
17
+ ROLE_DATE, ROLE_DIMENSION, ROLE_MEASURE, ShelfField,
18
+ )
19
+
20
+ #: Tableau mark classes, lower-cased, grouped by what they draw.
21
+ _BAR_MARKS = {"bar", "automatic"}
22
+ _LINE_MARKS = {"line"}
23
+ _AREA_MARKS = {"area"}
24
+ _POINT_MARKS = {"circle", "shape", "square"}
25
+ _TEXT_MARKS = {"text"}
26
+ _PIE_MARKS = {"pie"}
27
+ _MAP_MARKS = {"map", "filled map", "filledmap"}
28
+ _UNSUPPORTED_MARKS = {"gantt", "polygon", "density", "multipolygon", "line-polygon"}
29
+
30
+ _GEO_WORDS = ("country", "state", "province", "city", "zip", "postal", "county", "region", "latitude", "longitude")
31
+
32
+
33
+ def chart_type_for(
34
+ marks: list[str],
35
+ rows: list[ShelfField],
36
+ cols: list[ShelfField],
37
+ color: ShelfField | None = None,
38
+ size: ShelfField | None = None,
39
+ dual_axis: bool = False,
40
+ ) -> tuple[str, str, list[str]]:
41
+ """(chart_type, fidelity, notes) for one sheet."""
42
+ notes: list[str] = []
43
+ mark_set = {m.strip().lower() for m in marks if m}
44
+ shelf = list(rows) + list(cols)
45
+ measures = _unique([f for f in shelf if f.role == ROLE_MEASURE])
46
+ dims = _unique([f for f in shelf if f.role == ROLE_DIMENSION])
47
+ date = next((f for f in shelf if f.role == ROLE_DATE), None)
48
+ color_dim = color if (color and color.role == ROLE_DIMENSION) else None
49
+ color_measure = color if (color and color.role == ROLE_MEASURE) else None
50
+ row_measures = [f for f in rows if f.role == ROLE_MEASURE]
51
+ col_measures = [f for f in cols if f.role == ROLE_MEASURE]
52
+ row_dims = [f for f in rows if f.role in (ROLE_DIMENSION, ROLE_DATE)]
53
+ col_dims = [f for f in cols if f.role in (ROLE_DIMENSION, ROLE_DATE)]
54
+
55
+ if mark_set & _UNSUPPORTED_MARKS:
56
+ kind = ", ".join(sorted(mark_set & _UNSUPPORTED_MARKS))
57
+ notes.append(f"{kind} marks have no ThoughtSpot equivalent; shown as a table")
58
+ return "TABLE", FIDELITY_APPROXIMATED, notes
59
+
60
+ if not measures:
61
+ if not dims and not date:
62
+ notes.append("no fields on the sheet")
63
+ return "TABLE", FIDELITY_DROPPED, notes
64
+ notes.append("no measure on the sheet; shown as a table of its dimensions")
65
+ return "TABLE", FIDELITY_APPROXIMATED, notes
66
+
67
+ # A lone number: text mark or a bar with nothing to break it down by.
68
+ if not dims and not date and len(measures) == 1 and (mark_set <= (_TEXT_MARKS | _BAR_MARKS) or not mark_set):
69
+ return "KPI", FIDELITY_EXACT, notes
70
+
71
+ if mark_set & _TEXT_MARKS:
72
+ if row_dims and col_dims:
73
+ return "PIVOT_TABLE", FIDELITY_EXACT, notes
74
+ return "TABLE", FIDELITY_EXACT, notes
75
+
76
+ if mark_set & _PIE_MARKS:
77
+ if len(measures) > 1:
78
+ notes.append("pie shows the first measure only")
79
+ return "PIE", FIDELITY_APPROXIMATED, notes
80
+ return "PIE", FIDELITY_EXACT, notes
81
+
82
+ if mark_set & _MAP_MARKS or (not mark_set and _is_geo(dims)):
83
+ notes.append("map drawn on the model's geo columns; blank if the model has none tagged")
84
+ return ("GEO_BUBBLE" if size or "circle" in mark_set else "GEO_AREA"), FIDELITY_APPROXIMATED, notes
85
+
86
+ # Two measures on opposite shelves and point marks: a scatter.
87
+ if row_measures and col_measures and (mark_set & _POINT_MARKS or not mark_set) and not date:
88
+ if len(measures) > 2:
89
+ notes.append("scatter keeps the first two measures")
90
+ return "SCATTER", FIDELITY_APPROXIMATED, notes
91
+ return "SCATTER", FIDELITY_EXACT, notes
92
+
93
+ # Square marks coloured by a measure over two dimensions: a heatmap.
94
+ if "square" in mark_set and color_measure and len(dims) >= 2:
95
+ return "HEATMAP", FIDELITY_EXACT, notes
96
+
97
+ # Size-encoded squares with no axes: a treemap.
98
+ if size and size.role == ROLE_MEASURE and not row_dims and not col_dims and dims:
99
+ return "TREEMAP", FIDELITY_EXACT, notes
100
+
101
+ # Dual axis / mixed marks over several measures: the combo chart.
102
+ mixed = bool(mark_set & _BAR_MARKS) and bool(mark_set & (_LINE_MARKS | _AREA_MARKS))
103
+ if len(measures) >= 2 and (dual_axis or mixed):
104
+ notes.append("dual axis drawn as a combo chart: first measure as columns, the rest as lines")
105
+ if color_dim:
106
+ notes.append(f"colour by {color_dim.field} is not kept on a combo chart")
107
+ return "ADVANCED_LINE_COLUMN", FIDELITY_APPROXIMATED, notes
108
+
109
+ if mark_set & _LINE_MARKS or (date and not mark_set):
110
+ return "LINE", FIDELITY_EXACT, notes
111
+
112
+ if mark_set & _AREA_MARKS:
113
+ return ("STACKED_AREA" if color_dim else "AREA"), FIDELITY_EXACT, notes
114
+
115
+ # Bars: horizontal when the measure runs along the columns shelf and a
116
+ # dimension sits on rows; stacked when coloured by another dimension.
117
+ horizontal = bool(col_measures) and bool(row_dims) and not row_measures
118
+ stacked = color_dim is not None and color_dim.field not in {f.field for f in row_dims + col_dims}
119
+ if horizontal:
120
+ return ("STACKED_BAR" if stacked else "BAR"), FIDELITY_EXACT, notes
121
+ return ("STACKED_COLUMN" if stacked else "COLUMN"), FIDELITY_EXACT, notes
122
+
123
+
124
+ def _unique(fields: list[ShelfField]) -> list[ShelfField]:
125
+ seen: set[str] = set()
126
+ out = []
127
+ for f in fields:
128
+ if f.field not in seen:
129
+ seen.add(f.field)
130
+ out.append(f)
131
+ return out
132
+
133
+
134
+ def _is_geo(dims: list[ShelfField]) -> bool:
135
+ return any(any(w in f.field.lower() for w in _GEO_WORDS) for f in dims)
demoprep_app/migration/questions.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One liveboard question per source sheet.
2
+
3
+ The MCP liveboard path (liveboard_creator.create_liveboard_from_model_mcp)
4
+ asks ThoughtSpot a natural-language question per tile and keeps the answer.
5
+ In mirror mode the questions are not authored by an LLM from the use case:
6
+ each comes from a source sheet, phrased with the MODEL's column names, which
7
+ is why this needs the model columns. A sheet whose measures the model does not
8
+ have cannot render and is dropped, by name, so the SE sees it.
9
+
10
+ The chart type is NOT in the question: Spotter picks a first type and
11
+ liveboards/mirror_layout.py sets the source type afterwards.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import Any
17
+
18
+ from demoprep_app.migration.spec import (
19
+ AGG_WORDS, KPI_TYPES, MirrorSpec, ROLE_DATE, ShelfField, SourceSheet,
20
+ )
21
+
22
+ GRAIN_WORD = {"day": "daily", "week": "weekly", "month": "monthly",
23
+ "quarter": "quarterly", "year": "yearly"}
24
+ _DATE_TYPES = {"DATE", "DATETIME", "DATE_TIME", "TIMESTAMP", "TIME"}
25
+ _DATE_WORDS = ("date", "_dt", "day", "month", "timestamp", "time")
26
+
27
+ #: Chart types on which a colour dimension is a real split. On the others
28
+ #: (a plain column chart, a combo, a KPI, a scatter) adding it to the question
29
+ #: would change the shape of the answer, so the colour field is left off.
30
+ #: On a combo chart the first measure is drawn as columns and the rest as
31
+ #: lines. A target-like measure reads as a line over the bars (Nick's example:
32
+ #: Sales as bars, Target as the line), so it goes last whatever shelf order
33
+ #: the source used.
34
+ TARGET_WORDS = ("target", "goal", "budget", "plan", "quota", "forecast", "benchmark")
35
+ #: A variance against a target ("Sales V Target", "Gap to Budget") is not a target.
36
+ _COMPARISON_WORDS = frozenset({"v", "vs", "versus", "variance", "gap", "delta", "diff", "difference",
37
+ "minus", "over", "under", "attainment", "pct", "percent", "ratio"})
38
+
39
+ COLOUR_KEEPING_TYPES = frozenset({
40
+ "STACKED_COLUMN", "STACKED_BAR", "STACKED_AREA", "LINE", "AREA", "PIE",
41
+ "DONUT", "HEATMAP", "TREEMAP", "TABLE", "PIVOT_TABLE",
42
+ })
43
+
44
+
45
+ def questions_from_spec(spec: MirrorSpec, model_columns: list[dict[str, Any]]
46
+ ) -> tuple[list[dict[str, Any]], list[str], list[str]]:
47
+ """(questions, dropped_sheets, warnings).
48
+
49
+ questions: [{question, source: "mirror", sheet, title, chart_type, fidelity,
50
+ notes, measures, dimensions, date, grain}] in tile order.
51
+ model_columns: [{name, type, ...}] as the deployer has them."""
52
+ names = [str(c.get("name") or "") for c in (model_columns or []) if c.get("name")]
53
+ date_names = [n for n, c in zip(names, (model_columns or [])) if _is_date_column(c)]
54
+ questions: list[dict[str, Any]] = []
55
+ dropped: list[str] = []
56
+ warnings: list[str] = []
57
+
58
+ for sheet in spec.tiles():
59
+ measures = []
60
+ for f in sheet.measures:
61
+ hit = resolve_column(f.field, names)
62
+ if hit:
63
+ measures.append((hit, f))
64
+ else:
65
+ warnings.append(f'"{sheet.title}": the model has no column for measure {f.field!r}; left off that tile')
66
+ if not measures:
67
+ dropped.append(sheet.title)
68
+ warnings.append(f'"{sheet.title}" dropped: none of its measures are in the model')
69
+ continue
70
+ if sheet.chart_type == "ADVANCED_LINE_COLUMN":
71
+ measures = _columns_first(measures)
72
+ dims = []
73
+ for f in _question_dimensions(sheet):
74
+ hit = resolve_column(f.field, names)
75
+ if hit:
76
+ dims.append(hit)
77
+ else:
78
+ warnings.append(f'"{sheet.title}": the model has no column for {f.field!r}; left off that tile')
79
+ date_col, grain = _resolve_date(sheet, names, date_names)
80
+ if sheet.chart_type in KPI_TYPES and not date_col:
81
+ warnings.append(f'"{sheet.title}": no date column for the KPI trend; shown as a plain number')
82
+ questions.append({
83
+ "question": _phrase(sheet, measures, dims, date_col, grain),
84
+ "source": "mirror",
85
+ "sheet": sheet.name,
86
+ "title": sheet.title,
87
+ "chart_type": sheet.chart_type,
88
+ "fidelity": sheet.fidelity,
89
+ "notes": list(sheet.notes),
90
+ "measures": [m for m, _ in measures],
91
+ "dimensions": dims,
92
+ "date": date_col,
93
+ "grain": grain,
94
+ })
95
+ return questions, dropped, warnings
96
+
97
+
98
+ def _columns_first(measures: list[tuple[str, ShelfField]]) -> list[tuple[str, ShelfField]]:
99
+ """Combo order: the measures that are not target-like first (columns),
100
+ target-like ones last (lines); otherwise the source order."""
101
+ plain = [m for m in measures if not _is_target(m[0])]
102
+ targets = [m for m in measures if _is_target(m[0])]
103
+ return plain + targets if plain else measures
104
+
105
+
106
+ def _is_target(name: str) -> bool:
107
+ words = _tokens(name)
108
+ return any(w in words for w in TARGET_WORDS) and not (words & _COMPARISON_WORDS)
109
+
110
+
111
+ def _question_dimensions(sheet: SourceSheet) -> list[ShelfField]:
112
+ dims = list(sheet.axis_dimensions)
113
+ c = sheet.color
114
+ if c and c.role != ROLE_DATE and sheet.chart_type in COLOUR_KEEPING_TYPES \
115
+ and c.field not in {f.field for f in dims}:
116
+ dims.append(c)
117
+ return dims
118
+
119
+
120
+ def resolve_column(source_name: str, model_names: list[str]) -> str | None:
121
+ """The model column a source field maps to: an exact match once both are
122
+ normalised, else the column whose words best cover the source name (F1 over
123
+ word tokens, at least 0.5). "Sub-Category" finds "Product Sub Category"
124
+ over "Category"; "Net Sales" finds nothing in a model that only has
125
+ "Revenue", and the caller says so."""
126
+ key = _norm(source_name)
127
+ if not key:
128
+ return None
129
+ for n in model_names:
130
+ if _norm(n) == key:
131
+ return n
132
+ src = _tokens(source_name)
133
+ if not src:
134
+ return None
135
+ best, best_score = None, 0.0
136
+ for n in model_names:
137
+ t = _tokens(n)
138
+ if not t:
139
+ continue
140
+ hit = len(src & t)
141
+ if not hit:
142
+ continue
143
+ recall, precision = hit / len(src), hit / len(t)
144
+ score = 2 * recall * precision / (recall + precision)
145
+ if score > best_score:
146
+ best, best_score = n, score
147
+ return best if best_score >= 0.5 else None
148
+
149
+
150
+ def _resolve_date(sheet: SourceSheet, names: list[str], date_names: list[str]) -> tuple[str | None, str]:
151
+ d = sheet.date
152
+ grain = GRAIN_WORD.get(d.grain, "") if d else ""
153
+ if d:
154
+ hit = resolve_column(d.field, names)
155
+ if hit:
156
+ return hit, grain or ("monthly" if sheet.chart_type in KPI_TYPES else "")
157
+ if sheet.chart_type in KPI_TYPES or (d and not d.field):
158
+ # A KPI wants a trend; any date on the model will do.
159
+ if date_names:
160
+ return date_names[0], grain or "monthly"
161
+ if d and date_names:
162
+ return date_names[0], grain
163
+ return None, ""
164
+
165
+
166
+ def _phrase(sheet: SourceSheet, measures: list[tuple[str, ShelfField]], dims: list[str],
167
+ date_col: str | None, grain: str) -> str:
168
+ parts = [f"{AGG_WORDS.get(f.aggregation or 'sum', 'total')} {name}" for name, f in measures]
169
+ text = " and ".join(parts)
170
+ by: list[str] = list(dims)
171
+ if date_col:
172
+ by.append(f"{date_col} {grain}".strip())
173
+ if sheet.chart_type in KPI_TYPES:
174
+ # "total revenue monthly": the KPI tile's sparkline needs the grain.
175
+ return f"{text} {grain}".strip() if date_col else text
176
+ if by:
177
+ text += " by " + " and ".join(by)
178
+ return text
179
+
180
+
181
+ def _is_date_column(col: dict[str, Any]) -> bool:
182
+ t = str(col.get("data_type") or col.get("type") or "").upper()
183
+ if t in _DATE_TYPES:
184
+ return True
185
+ n = str(col.get("name") or "").lower()
186
+ return any(w in n for w in _DATE_WORDS) and t not in {"MEASURE"}
187
+
188
+
189
+ def _norm(name: str) -> str:
190
+ return re.sub(r"[^a-z0-9]", "", (name or "").lower())
191
+
192
+
193
+ def _tokens(name: str) -> set[str]:
194
+ return {t for t in re.split(r"[^a-z0-9]+", (name or "").lower()) if t and t not in {"of", "the", "by"}}
demoprep_app/migration/sources.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reading the SE's source-dashboard arguments into a MirrorSpec.
2
+
3
+ Two arguments reach the server from Agent Q:
4
+ source_dashboard the workbook: the .twb's XML text as is, or a .twbx
5
+ (a zip) as a base64 data URI / bare base64. From a
6
+ .twbx only the .twb member is read; extracts (.hyper,
7
+ .tde, customer data) are never opened.
8
+ source_dashboard_image a picture of the dashboard: an https image URL or a
9
+ base64 data URI, exactly like homepage_screenshot.
10
+
11
+ When both are sent the workbook wins (it is exact) and the image is ignored
12
+ with a note. Neither sent: no mirror, the build is an ordinary demo.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import binascii
18
+ import io
19
+ import re
20
+ import zipfile
21
+ from typing import Any
22
+
23
+ from demoprep_app.migration.spec import MirrorError, MirrorSpec, source_fingerprint
24
+
25
+ TWB_MAX_BYTES = 25_000_000
26
+ TWBX_MAX_BYTES = 200_000_000 # the zip as sent; only its .twb member is read
27
+ _DATA_URI = re.compile(r"\Adata:([\w.+-]+/[\w.+-]+)?(?:;[^,]*)?;base64,(.*)\Z", re.I | re.S)
28
+ _XML_START = re.compile(r"\A\s*(<\?xml|<workbook\b)", re.I)
29
+
30
+
31
+ def has_source(file_arg: str, image_arg: str) -> bool:
32
+ return bool((file_arg or "").strip()) or bool((image_arg or "").strip())
33
+
34
+
35
+ def describe_source_arg(value: str) -> str:
36
+ """What the payload log keeps about `source_dashboard`: kind and size,
37
+ never the content (a workbook names the prospect's tables and fields)."""
38
+ v = (value or "").strip()
39
+ if not v:
40
+ return ""
41
+ if _XML_START.match(v):
42
+ fp = source_fingerprint(v.encode("utf-8", "replace"))
43
+ return f"<twb xml bytes={fp['bytes']} sha={fp['sha256_12']}>"
44
+ m = _DATA_URI.match(v)
45
+ if m:
46
+ return f"<data uri type={m.group(1) or 'unknown'} chars={len(v)}>"
47
+ return f"<text chars={len(v)}>"
48
+
49
+
50
+ def load_source_dashboard(file_arg: str, image_arg: str, *, llm_model: str | None = None,
51
+ ) -> tuple[MirrorSpec | None, list[str]]:
52
+ """(spec or None, notes). Raises MirrorError when something WAS sent and
53
+ could not be read: the build must not silently become a generic demo."""
54
+ notes: list[str] = []
55
+ file_arg = (file_arg or "").strip()
56
+ image_arg = (image_arg or "").strip()
57
+ if not file_arg and not image_arg:
58
+ return None, notes
59
+ if file_arg:
60
+ spec = load_workbook_arg(file_arg)
61
+ if image_arg:
62
+ notes.append("source_dashboard_image ignored: the workbook was sent too and is exact")
63
+ return spec, notes
64
+ from demoprep_app.migration.image_spec import image_to_spec
65
+ return image_to_spec(image_arg, llm_model=llm_model), notes
66
+
67
+
68
+ def load_workbook_arg(value: str) -> MirrorSpec:
69
+ """A .twb's XML text, or a .twbx/.twb as a base64 data URI or bare base64."""
70
+ from demoprep_app.migration.tableau_twb import parse_twb
71
+
72
+ v = value.strip()
73
+ if _XML_START.match(v):
74
+ if len(v.encode("utf-8", "replace")) > TWB_MAX_BYTES:
75
+ raise MirrorError(f"the workbook is over {TWB_MAX_BYTES // 1_000_000} MB; send the .twb, not a packaged workbook")
76
+ return parse_twb(v)
77
+
78
+ raw = _decode_base64(v)
79
+ if raw is None:
80
+ raise MirrorError("source_dashboard must be the .twb file's XML text, or a .twbx/.twb as a base64 data URI")
81
+ if raw[:2] == b"PK":
82
+ return parse_twb(_twb_from_twbx(raw))
83
+ text = raw.decode("utf-8", "replace")
84
+ if _XML_START.match(text):
85
+ return parse_twb(text)
86
+ raise MirrorError("the decoded file is neither a Tableau workbook (.twb XML) nor a packaged workbook (.twbx zip)")
87
+
88
+
89
+ def _decode_base64(v: str) -> bytes | None:
90
+ m = _DATA_URI.match(v)
91
+ payload = m.group(2) if m else v
92
+ payload = re.sub(r"\s+", "", payload)
93
+ if not payload or len(payload) > TWBX_MAX_BYTES * 4 // 3 + 4:
94
+ return None
95
+ try:
96
+ return base64.b64decode(payload, validate=True)
97
+ except (binascii.Error, ValueError):
98
+ return None
99
+
100
+
101
+ def _twb_from_twbx(raw: bytes) -> str:
102
+ """The one .twb inside a .twbx. Nothing else in the archive is opened."""
103
+ try:
104
+ zf = zipfile.ZipFile(io.BytesIO(raw))
105
+ except zipfile.BadZipFile as exc:
106
+ raise MirrorError("the packaged workbook (.twbx) could not be opened as a zip") from exc
107
+ members = [i for i in zf.infolist() if i.filename.lower().endswith(".twb") and "/" not in i.filename.strip("/")]
108
+ if not members:
109
+ members = [i for i in zf.infolist() if i.filename.lower().endswith(".twb")]
110
+ if not members:
111
+ raise MirrorError("the packaged workbook (.twbx) has no .twb inside it")
112
+ info = members[0]
113
+ if info.file_size > TWB_MAX_BYTES:
114
+ raise MirrorError(f"the workbook inside the .twbx is over {TWB_MAX_BYTES // 1_000_000} MB")
115
+ with zf.open(info) as fh:
116
+ return fh.read(TWB_MAX_BYTES + 1).decode("utf-8", "replace")
117
+
118
+
119
+ def spec_result(spec: MirrorSpec, *, tiles: list[dict[str, Any]] | None = None,
120
+ dropped: list[str] | None = None, extra_warnings: list[str] | None = None) -> dict[str, Any]:
121
+ """The `mirror` block of a build result / the inspect tool's answer."""
122
+ counts = spec.carried_counts()
123
+ out = spec.summary()
124
+ out["carried"] = counts
125
+ if tiles is not None:
126
+ out["tiles_built"] = [{"title": t["title"], "chart_type": t["chart_type"], "question": t["question"]}
127
+ for t in tiles]
128
+ if dropped:
129
+ out["tiles_dropped"] = list(dropped)
130
+ if extra_warnings:
131
+ out["warnings"] = list(out.get("warnings") or []) + list(extra_warnings)
132
+ return out
demoprep_app/migration/spec.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The mirror spec: one shape for what a legacy dashboard says about its schema
2
+ and its charts, whether it was read from a Tableau workbook or from a picture.
3
+
4
+ Producers: migration/tableau_twb.py (deterministic, from the .twb XML) and
5
+ migration/image_spec.py (a vision model, lower confidence).
6
+ Consumers: migration/statement.py (blueprint seed), migration/questions.py
7
+ (liveboard questions), liveboards/mirror_layout.py (chart types + layout), and
8
+ the inspect tool (summary()).
9
+
10
+ Fidelity vocabulary, per sheet:
11
+ exact - the chart carries over as is (type, fields, grain)
12
+ approximated - it carries over with a substitution the notes name
13
+ (dual axis drawn as a combo, a table calc left out, ...)
14
+ dropped - it cannot carry over; the notes say why
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ from dataclasses import dataclass, field, asdict
20
+ from typing import Any
21
+
22
+ SOURCE_TABLEAU = "tableau_twb"
23
+ SOURCE_IMAGE = "image"
24
+
25
+ FIDELITY_EXACT = "exact"
26
+ FIDELITY_APPROXIMATED = "approximated"
27
+ FIDELITY_DROPPED = "dropped"
28
+
29
+ #: ThoughtSpot chart types a mirrored tile may take. Everything the parser or
30
+ #: the vision model emits is checked against this set; anything else is a bug.
31
+ CHART_TYPES = frozenset({
32
+ "KPI", "COLUMN", "BAR", "STACKED_COLUMN", "STACKED_BAR", "LINE", "AREA",
33
+ "STACKED_AREA", "PIE", "DONUT", "SCATTER", "HEATMAP", "TREEMAP", "TABLE",
34
+ "PIVOT_TABLE", "ADVANCED_LINE_COLUMN", "GEO_AREA", "GEO_BUBBLE",
35
+ })
36
+
37
+ #: Chart types whose tile is a single number: the question needs a time grain
38
+ #: (KPI sparkline/comparison) and the tile goes in the KPI row of a grid.
39
+ KPI_TYPES = frozenset({"KPI"})
40
+ TABLE_TYPES = frozenset({"TABLE", "PIVOT_TABLE"})
41
+
42
+ ROLE_MEASURE = "measure"
43
+ ROLE_DIMENSION = "dimension"
44
+ ROLE_DATE = "date"
45
+
46
+ GRAINS = ("day", "week", "month", "quarter", "year")
47
+
48
+ #: Aggregation words as ThoughtSpot search understands them.
49
+ AGG_WORDS = {
50
+ "sum": "total", "avg": "average", "count": "count of",
51
+ "count_distinct": "unique count of", "min": "min", "max": "max",
52
+ "median": "median",
53
+ }
54
+
55
+
56
+ class MirrorError(ValueError):
57
+ """The source dashboard could not be read. The message is for the SE."""
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class SourceField:
62
+ name: str # as the dashboard's user sees it (caption)
63
+ role: str = ROLE_DIMENSION # measure | dimension | date
64
+ datatype: str = "string" # string | integer | real | date | datetime | boolean
65
+ table: str = "" # source table, "" when unknown (image path)
66
+ aggregation: str = "" # measures: sum | avg | count | count_distinct | min | max | median
67
+ formula: str = "" # calculated fields: the source formula, verbatim
68
+ hidden: bool = False
69
+ notes: list[str] = field(default_factory=list)
70
+
71
+ @property
72
+ def is_calculated(self) -> bool:
73
+ return bool(self.formula)
74
+
75
+
76
+ @dataclass(slots=True)
77
+ class SourceTable:
78
+ name: str
79
+ columns: list[str] = field(default_factory=list)
80
+ kind: str = "table" # table | custom_sql | union
81
+ connection: str = "" # connection CLASS only (excel-direct, snowflake...), never a host or path
82
+
83
+
84
+ @dataclass(slots=True)
85
+ class SourceJoin:
86
+ left_table: str
87
+ right_table: str
88
+ left_field: str
89
+ right_field: str
90
+ kind: str = "inner" # inner | left | right | full | blend
91
+
92
+
93
+ @dataclass(slots=True)
94
+ class ShelfField:
95
+ """A field as placed on a shelf or an encoding: with the aggregation or the
96
+ date grain the sheet applies to it."""
97
+ field: str
98
+ role: str = ROLE_DIMENSION
99
+ aggregation: str = "" # measures only
100
+ grain: str = "" # dates only: day | week | month | quarter | year
101
+
102
+
103
+ @dataclass(slots=True)
104
+ class SourceSheet:
105
+ name: str
106
+ title: str = "" # shown title; falls back to name
107
+ marks: list[str] = field(default_factory=list) # source mark types seen, e.g. ["Bar", "Area"]
108
+ rows: list[ShelfField] = field(default_factory=list)
109
+ cols: list[ShelfField] = field(default_factory=list)
110
+ color: ShelfField | None = None
111
+ size: ShelfField | None = None
112
+ detail: list[ShelfField] = field(default_factory=list)
113
+ filters: list[str] = field(default_factory=list) # field names filtered on the sheet
114
+ dual_axis: bool = False
115
+ chart_type: str = "TABLE" # one of CHART_TYPES
116
+ fidelity: str = FIDELITY_EXACT
117
+ notes: list[str] = field(default_factory=list)
118
+ confidence: float = 1.0
119
+
120
+ def __post_init__(self) -> None:
121
+ if self.chart_type not in CHART_TYPES:
122
+ raise ValueError(f"sheet {self.name!r}: unknown chart type {self.chart_type!r}")
123
+ if self.fidelity not in (FIDELITY_EXACT, FIDELITY_APPROXIMATED, FIDELITY_DROPPED):
124
+ raise ValueError(f"sheet {self.name!r}: unknown fidelity {self.fidelity!r}")
125
+ if not self.title:
126
+ self.title = self.name
127
+
128
+ @property
129
+ def shelf_fields(self) -> list[ShelfField]:
130
+ out = list(self.rows) + list(self.cols)
131
+ if self.color:
132
+ out.append(self.color)
133
+ if self.size:
134
+ out.append(self.size)
135
+ out.extend(self.detail)
136
+ return out
137
+
138
+ @property
139
+ def measures(self) -> list[ShelfField]:
140
+ seen: set[str] = set()
141
+ out = []
142
+ for f in self.shelf_fields:
143
+ if f.role == ROLE_MEASURE and f.field not in seen:
144
+ seen.add(f.field)
145
+ out.append(f)
146
+ return out
147
+
148
+ @property
149
+ def dimensions(self) -> list[ShelfField]:
150
+ seen: set[str] = set()
151
+ out = []
152
+ for f in self.shelf_fields:
153
+ if f.role == ROLE_DIMENSION and f.field not in seen:
154
+ seen.add(f.field)
155
+ out.append(f)
156
+ return out
157
+
158
+ @property
159
+ def axis_dimensions(self) -> list[ShelfField]:
160
+ """Dimensions on the rows/cols shelves only: what the chart breaks down
161
+ by, before colour, size or detail."""
162
+ seen: set[str] = set()
163
+ out = []
164
+ for f in list(self.rows) + list(self.cols):
165
+ if f.role == ROLE_DIMENSION and f.field not in seen:
166
+ seen.add(f.field)
167
+ out.append(f)
168
+ return out
169
+
170
+ @property
171
+ def date(self) -> ShelfField | None:
172
+ for f in self.shelf_fields:
173
+ if f.role == ROLE_DATE:
174
+ return f
175
+ return None
176
+
177
+
178
+ @dataclass(slots=True)
179
+ class Zone:
180
+ """Where a sheet sits on a dashboard, as fractions of the dashboard."""
181
+ sheet: str
182
+ x: float
183
+ y: float
184
+ w: float
185
+ h: float
186
+ kind: str = "sheet" # sheet | text | filter | other
187
+
188
+
189
+ @dataclass(slots=True)
190
+ class SourceDashboard:
191
+ name: str
192
+ width: int = 0 # px, 0 when unknown
193
+ height: int = 0
194
+ zones: list[Zone] = field(default_factory=list)
195
+
196
+ @property
197
+ def sheet_names(self) -> list[str]:
198
+ """Sheets on this dashboard, top-to-bottom then left-to-right."""
199
+ ordered = sorted((z for z in self.zones if z.kind == "sheet"), key=lambda z: (round(z.y, 2), z.x))
200
+ out: list[str] = []
201
+ for z in ordered:
202
+ if z.sheet not in out:
203
+ out.append(z.sheet)
204
+ return out
205
+
206
+
207
+ @dataclass(slots=True)
208
+ class MirrorSpec:
209
+ source_kind: str # tableau_twb | image
210
+ name: str # workbook / dashboard title
211
+ tables: list[SourceTable] = field(default_factory=list)
212
+ joins: list[SourceJoin] = field(default_factory=list)
213
+ fields: list[SourceField] = field(default_factory=list)
214
+ sheets: list[SourceSheet] = field(default_factory=list)
215
+ dashboards: list[SourceDashboard] = field(default_factory=list)
216
+ parameters: list[str] = field(default_factory=list) # names of parameters/sets not carried
217
+ warnings: list[str] = field(default_factory=list)
218
+ confidence: float = 1.0
219
+ source_meta: dict[str, Any] = field(default_factory=dict) # version, bytes, sha (no content)
220
+
221
+ # ---- fields by role -------------------------------------------------
222
+ def measures(self) -> list[SourceField]:
223
+ return [f for f in self.fields if f.role == ROLE_MEASURE and not f.hidden]
224
+
225
+ def dimensions(self) -> list[SourceField]:
226
+ return [f for f in self.fields if f.role == ROLE_DIMENSION and not f.hidden]
227
+
228
+ def date_fields(self) -> list[SourceField]:
229
+ return [f for f in self.fields if f.role == ROLE_DATE and not f.hidden]
230
+
231
+ def field_named(self, name: str) -> SourceField | None:
232
+ key = _norm(name)
233
+ for f in self.fields:
234
+ if _norm(f.name) == key:
235
+ return f
236
+ return None
237
+
238
+ # ---- what becomes a tile ----------------------------------------------
239
+ def primary_dashboard(self) -> SourceDashboard | None:
240
+ """The dashboard that is mirrored: the first one with sheets on it."""
241
+ for d in self.dashboards:
242
+ if d.sheet_names:
243
+ return d
244
+ return None
245
+
246
+ def tiles(self) -> list[SourceSheet]:
247
+ """Sheets that become tiles, in reading order. The first dashboard's
248
+ sheets when there is one, else every sheet in workbook order. Dropped
249
+ sheets are left out (the summary still lists them)."""
250
+ by_name = {s.name: s for s in self.sheets}
251
+ dash = self.primary_dashboard()
252
+ names = dash.sheet_names if dash else [s.name for s in self.sheets]
253
+ out = []
254
+ for n in names:
255
+ s = by_name.get(n)
256
+ if s is not None and s.fidelity != FIDELITY_DROPPED:
257
+ out.append(s)
258
+ return out
259
+
260
+ def zone_for(self, sheet_name: str) -> Zone | None:
261
+ dash = self.primary_dashboard()
262
+ if not dash:
263
+ return None
264
+ for z in dash.zones:
265
+ if z.kind == "sheet" and z.sheet == sheet_name:
266
+ return z
267
+ return None
268
+
269
+ # ---- serialisation ------------------------------------------------------
270
+ def to_dict(self) -> dict[str, Any]:
271
+ return asdict(self)
272
+
273
+ @classmethod
274
+ def from_dict(cls, data: dict[str, Any]) -> "MirrorSpec":
275
+ def shelf(d):
276
+ return ShelfField(**d) if d else None
277
+
278
+ sheets = []
279
+ for s in data.get("sheets") or []:
280
+ s = dict(s)
281
+ s["rows"] = [ShelfField(**x) for x in s.get("rows") or []]
282
+ s["cols"] = [ShelfField(**x) for x in s.get("cols") or []]
283
+ s["detail"] = [ShelfField(**x) for x in s.get("detail") or []]
284
+ s["color"] = shelf(s.get("color"))
285
+ s["size"] = shelf(s.get("size"))
286
+ sheets.append(SourceSheet(**s))
287
+ dashboards = []
288
+ for d in data.get("dashboards") or []:
289
+ d = dict(d)
290
+ d["zones"] = [Zone(**z) for z in d.get("zones") or []]
291
+ dashboards.append(SourceDashboard(**d))
292
+ return cls(
293
+ source_kind=data["source_kind"],
294
+ name=data.get("name") or "",
295
+ tables=[SourceTable(**t) for t in data.get("tables") or []],
296
+ joins=[SourceJoin(**j) for j in data.get("joins") or []],
297
+ fields=[SourceField(**f) for f in data.get("fields") or []],
298
+ sheets=sheets,
299
+ dashboards=dashboards,
300
+ parameters=list(data.get("parameters") or []),
301
+ warnings=list(data.get("warnings") or []),
302
+ confidence=float(data.get("confidence", 1.0)),
303
+ source_meta=dict(data.get("source_meta") or {}),
304
+ )
305
+
306
+ def summary(self) -> dict[str, Any]:
307
+ """What the inspect tool returns and the build result carries: enough
308
+ for the SE to see what carries over, nothing that could leak data."""
309
+ dash = self.primary_dashboard()
310
+ return {
311
+ "source_kind": self.source_kind,
312
+ "name": self.name,
313
+ "confidence": round(self.confidence, 2),
314
+ "tables": [{"name": t.name, "columns": len(t.columns), "kind": t.kind,
315
+ "connection": t.connection} for t in self.tables],
316
+ "joins": [f"{j.left_table}.{j.left_field} {j.kind} {j.right_table}.{j.right_field}"
317
+ for j in self.joins],
318
+ "measures": [{"name": f.name, "aggregation": f.aggregation or "sum",
319
+ **({"formula": f.formula} if f.formula else {})} for f in self.measures()],
320
+ "dimensions": [f.name for f in self.dimensions()],
321
+ "dates": [f.name for f in self.date_fields()],
322
+ "dashboards": [d.name for d in self.dashboards],
323
+ "mirrored_dashboard": dash.name if dash else "",
324
+ "sheets": [{
325
+ "name": s.name, "title": s.title, "chart_type": s.chart_type,
326
+ "measures": [f.field for f in s.measures],
327
+ "dimensions": [f.field for f in s.dimensions],
328
+ "date": (s.date.field + (f" ({s.date.grain})" if s.date.grain else "")) if s.date else "",
329
+ "fidelity": s.fidelity, "notes": list(s.notes),
330
+ } for s in self.sheets],
331
+ "tiles": [s.name for s in self.tiles()],
332
+ "parameters_not_carried": list(self.parameters),
333
+ "warnings": list(self.warnings),
334
+ "source": dict(self.source_meta),
335
+ }
336
+
337
+ def carried_counts(self) -> dict[str, int]:
338
+ counts = {FIDELITY_EXACT: 0, FIDELITY_APPROXIMATED: 0, FIDELITY_DROPPED: 0}
339
+ for s in self.sheets:
340
+ counts[s.fidelity] += 1
341
+ return counts
342
+
343
+
344
+ def _norm(name: str) -> str:
345
+ return "".join(ch for ch in (name or "").lower() if ch.isalnum())
346
+
347
+
348
+ def source_fingerprint(data: bytes) -> dict[str, Any]:
349
+ """What the payload log and the summary keep about the file: size and a
350
+ short hash. Never the content."""
351
+ return {"bytes": len(data), "sha256_12": hashlib.sha256(data).hexdigest()[:12]}
demoprep_app/migration/statement.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The mirror spec as a custom request for the blueprint author.
2
+
3
+ The blueprint LLM reads the SE's request verbatim (blueprint_author.py puts it
4
+ under <user_request>). This renders the spec as that request: the measure and
5
+ dimension names the prospect's users know, the date and its grain, the
6
+ calculated fields to re-express, and the charts the board will recreate, so
7
+ the synthetic data speaks the dashboard's vocabulary. The story (planted
8
+ insights, Spotter questions) is still the author's job, as for any demo.
9
+
10
+ Wording matters: directives.py reads "do not create/include/use/build/generate/
11
+ add X" in a request as a binding prohibition, so this text never says it.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ from demoprep_app.migration.spec import (
18
+ MirrorSpec, SOURCE_TABLEAU, SourceField, SourceSheet, AGG_WORDS,
19
+ )
20
+
21
+ HEADING = "## Source dashboard to mirror (DP Migrator)"
22
+
23
+ #: Tableau bookkeeping fields that are not business columns.
24
+ _SKIP_NAMES = {"number of records", "measure names", "measure values"}
25
+
26
+
27
+ def statement_from_spec(spec: MirrorSpec) -> str:
28
+ """The request text appended to the SE's use case for the blueprint stage."""
29
+ origin = ("read from their Tableau workbook" if spec.source_kind == SOURCE_TABLEAU
30
+ else "read from a screenshot of it")
31
+ lines = [HEADING, (
32
+ f'This demo recreates the prospect\'s existing dashboard "{spec.name}" ({origin}). '
33
+ "Use ITS vocabulary: the measure and dimension names below are the names the "
34
+ "prospect's users already know. Keep each one as a column (same words; spaces "
35
+ "become underscores) so the rebuilt charts read like theirs. Add only what the "
36
+ "star schema needs around them (keys, a few descriptive attributes)."
37
+ )]
38
+
39
+ base = [f for f in spec.measures() if not f.is_calculated and _keep(f)]
40
+ calcs = [f for f in spec.measures() if f.is_calculated and _keep(f)]
41
+ dims = _business_dimensions(spec)
42
+ dates = [f for f in spec.date_fields() if _keep(f)]
43
+
44
+ if base:
45
+ lines.append("Measures, each a measure on the fact with this aggregation: "
46
+ + "; ".join(f"{f.name} ({f.aggregation or 'sum'})" for f in base) + ".")
47
+ if calcs:
48
+ lines.append("Calculated measures: re-express each as a derived measure with an arithmetic "
49
+ "formula over the base measures, as close as the data allows: "
50
+ + "; ".join(f"{f.name} = {clean_formula(f.formula)}" for f in calcs) + ".")
51
+ if dims:
52
+ lines.append("Dimensions, each a dimension table or an attribute column of one: "
53
+ + ", ".join(f.name for f in dims) + ". Group attributes that describe the same "
54
+ "entity into one dimension (a product's category and sub-category; a customer's "
55
+ "segment; a location's region, country, state and city).")
56
+ if dates:
57
+ grain = _dominant_grain(spec)
58
+ primary = dates[0].name
59
+ lines.append(f"Date: {primary} is the date on the charts"
60
+ + (f" ({grain} grain)" if grain else "")
61
+ + f"; the fact's date column is named {primary}. At least 24 months of history.")
62
+ if spec.tables:
63
+ lines.append("Source tables, for context only (model a clean star schema from them): "
64
+ + "; ".join(_table_line(spec, t.name) for t in spec.tables) + ".")
65
+ tiles = spec.tiles()
66
+ if tiles:
67
+ lines.append("Charts the liveboard recreates (keep every measure and dimension they use): "
68
+ + " ".join(f"{i}. {_sheet_line(s)}" for i, s in enumerate(tiles, 1)))
69
+ return "\n".join(lines)
70
+
71
+
72
+ _SOURCE_ID = re.compile(r"\[federated\.[^\]]+\]\.")
73
+ _TABLE_SUFFIX = re.compile(r"\s*\([^()]*\)\s*$")
74
+
75
+
76
+ def clean_formula(formula: str) -> str:
77
+ """A source formula without Tableau's internal datasource ids:
78
+ SUM([federated.1lm8...].[Target]) -> SUM([Target])."""
79
+ return _SOURCE_ID.sub("", formula or "").strip()
80
+
81
+
82
+ def _business_dimensions(spec: MirrorSpec) -> list[SourceField]:
83
+ """Dimensions worth naming: not join keys (the blueprint adds its own),
84
+ and not a table-qualified duplicate of another field ("Order ID
85
+ (OrderBreakdown)")."""
86
+ keys = {j.left_field.lower() for j in spec.joins} | {j.right_field.lower() for j in spec.joins}
87
+ names = {f.name.lower() for f in spec.fields}
88
+ out = []
89
+ for f in spec.dimensions():
90
+ if not _keep(f) or f.name.lower() in keys:
91
+ continue
92
+ base = _TABLE_SUFFIX.sub("", f.name)
93
+ if base != f.name and base.lower() in names:
94
+ continue
95
+ if base.lower() in keys:
96
+ continue
97
+ out.append(f)
98
+ return out
99
+
100
+
101
+ def _keep(f: SourceField) -> bool:
102
+ if f.hidden:
103
+ return False
104
+ if f.name.strip().lower() in _SKIP_NAMES:
105
+ return False
106
+ if f.is_calculated and f.formula.strip() == "1":
107
+ return False
108
+ return True
109
+
110
+
111
+ def _dominant_grain(spec: MirrorSpec) -> str:
112
+ """The grain most sheets use on their date, as a word ("monthly")."""
113
+ counts: dict[str, int] = {}
114
+ for sheet in spec.sheets:
115
+ d = sheet.date
116
+ if d and d.grain:
117
+ counts[d.grain] = counts.get(d.grain, 0) + 1
118
+ if not counts:
119
+ return ""
120
+ return _grain_word(max(counts.items(), key=lambda kv: kv[1])[0])
121
+
122
+
123
+ def _grain_word(grain: str) -> str:
124
+ return {"day": "daily", "week": "weekly", "month": "monthly",
125
+ "quarter": "quarterly", "year": "yearly"}.get(grain, grain)
126
+
127
+
128
+ def _table_line(spec: MirrorSpec, table: str) -> str:
129
+ t = next((x for x in spec.tables if x.name == table), None)
130
+ cols = f" ({len(t.columns)} columns)" if t and t.columns else ""
131
+ joins = [j for j in spec.joins if table in (j.left_table, j.right_table)]
132
+ if not joins:
133
+ return f"{table}{cols}"
134
+ parts = []
135
+ for j in joins:
136
+ other = j.right_table if j.left_table == table else j.left_table
137
+ how = "blended with" if j.kind == "blend" else f"{j.kind} join to"
138
+ on = f" on {j.left_field}" if j.left_field else ""
139
+ parts.append(f"{how} {other}{on}")
140
+ return f"{table}{cols}, " + ", ".join(parts)
141
+
142
+
143
+ def _sheet_line(s: SourceSheet) -> str:
144
+ measures = ", ".join(f.field for f in s.measures) or "no measure"
145
+ by = [f.field for f in s.axis_dimensions]
146
+ if s.date:
147
+ by.append(s.date.field + (f" ({s.date.grain})" if s.date.grain else ""))
148
+ text = f'"{s.title}": {measures}'
149
+ if by:
150
+ text += " by " + ", ".join(by)
151
+ if s.color and s.color.field not in {f.field for f in s.axis_dimensions}:
152
+ text += f", colour by {s.color.field}"
153
+ return text + f" [{chart_words(s.chart_type)}]."
154
+
155
+
156
+ _CHART_WORDS = {
157
+ "ADVANCED_LINE_COLUMN": "combo chart: columns and lines",
158
+ "KPI": "KPI card", "TABLE": "table", "PIVOT_TABLE": "pivot table",
159
+ "GEO_AREA": "filled map", "GEO_BUBBLE": "bubble map",
160
+ }
161
+
162
+
163
+ def chart_words(chart_type: str) -> str:
164
+ return _CHART_WORDS.get(chart_type, chart_type.lower().replace("_", " ") + " chart")
demoprep_app/migration/tableau_twb.py ADDED
@@ -0,0 +1,860 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tableau workbook (.twb XML) -> MirrorSpec, deterministically.
2
+
3
+ The grammar this reads (verified on tests/fixtures/tableau_dual_axis_example.twb):
4
+
5
+ <workbook version source-build>
6
+ <datasources><datasource caption name> one per data source; name='Parameters' holds parameters
7
+ <connection class='federated'>
8
+ <named-connections><named-connection><connection class=.../> the class is all we keep
9
+ <relation type='join' join='inner'><clause><expression op='='> legacy joins, nested relations
10
+ <relation type='table' name><columns><column datatype name/> physical tables (also under
11
+ 2020.2+ <object-graph>/<object>)
12
+ <relationships><relationship><expression op='='>... 2020.2+ logical layer
13
+ <metadata-records><metadata-record class='column'> local-name, parent-name, local-type, aggregation
14
+ <column caption name role datatype hidden><calculation formula/> display names, roles, calculated fields
15
+ <datasource-relationships> blends: which data sources share columns
16
+ <worksheets><worksheet name><table><view> datasource-dependencies, <filter column>
17
+ <panes><pane y-axis-name><mark class/><encodings><color column/>
18
+ <rows>/<cols> shelf text: [<datasource id>].[<prefix>:<Field>:<suffix>] tokens joined by ' + '
19
+ <dashboards><dashboard name><size/><zones><zone name type x y w h> 1/100000 units; <devicelayouts> skipped
20
+
21
+ Shelf prefixes: sum/avg/cnt/ctd/min/max/mdn/usr are aggregations, yr/qr/mn/wk/dy (and the t* truncations)
22
+ are date grains, none is a plain dimension. Nothing that identifies the file, host or user is kept:
23
+ connection details are reduced to the class and Tableau's internal data-source ids never reach a name.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from dataclasses import dataclass, field
29
+ from typing import Any, Iterator
30
+ from xml.etree.ElementTree import Element
31
+
32
+ from defusedxml import DefusedXmlException
33
+ from defusedxml.ElementTree import ParseError, fromstring
34
+
35
+ from demoprep_app.migration.mapping import chart_type_for
36
+ from demoprep_app.migration.spec import (
37
+ FIDELITY_APPROXIMATED, FIDELITY_EXACT, MirrorError, MirrorSpec,
38
+ ROLE_DATE, ROLE_DIMENSION, ROLE_MEASURE, SOURCE_TABLEAU, ShelfField,
39
+ SourceDashboard, SourceField, SourceJoin, SourceSheet, SourceTable, Zone,
40
+ source_fingerprint,
41
+ )
42
+
43
+ MAX_BYTES = 25 * 1024 * 1024
44
+ PARAMETERS_DATASOURCE = "Parameters"
45
+ ZONE_UNITS = 100_000.0
46
+
47
+ #: Shelf-token prefixes that aggregate a field -> the spec's aggregation word.
48
+ _AGG_PREFIX = {
49
+ "sum": "sum", "avg": "avg", "cnt": "count", "ctd": "count_distinct",
50
+ "min": "min", "max": "max", "mdn": "median", "usr": "sum",
51
+ }
52
+ #: Shelf-token prefixes that derive a date part or truncation -> grain.
53
+ _DATE_PREFIX = {
54
+ "yr": "year", "tyr": "year", "qr": "quarter", "tqr": "quarter",
55
+ "mn": "month", "tmn": "month", "my": "month", "tmy": "month",
56
+ "wk": "week", "twk": "week", "dy": "day", "tdy": "day", "mdy": "day", "tmdy": "day",
57
+ "hr": "", "thr": "", "mi": "", "tmi": "", "sc": "", "tsc": "",
58
+ }
59
+ #: metadata-record <aggregation> words -> the spec's aggregation word.
60
+ _META_AGG = {
61
+ "sum": "sum", "average": "avg", "avg": "avg", "count": "count", "countd": "count_distinct",
62
+ "min": "min", "max": "max", "median": "median",
63
+ }
64
+ _JOIN_KINDS = {"inner", "left", "right", "full"}
65
+ _DATE_TYPES = {"date", "datetime"}
66
+ _NUMERIC_TYPES = {"integer", "real"}
67
+ _OMIT_FIELDS = {"[number of records]", "[measure values]", "[multiple values]"}
68
+ _LOD_MARKERS = ("{FIXED", "{INCLUDE", "{EXCLUDE")
69
+ _TEXT_ZONES = {"text", "title"}
70
+ _FILTER_ZONES = {"filter", "paramctrl", "legend", "color"}
71
+ _CONTAINER_ZONES = {"layout-basic", "layout-flow"}
72
+
73
+ _CALC_NAME = re.compile(r"^Calculation_\d+$")
74
+ #: [<datasource id>].[<token>] or a bare [<token>]; ']]' escapes a ']' inside a name.
75
+ _TOKEN = re.compile(r"(?:\[((?:[^\]]|\]\])*)\]\.)?\[((?:[^\]]|\]\])*)\]")
76
+ _GROUP = re.compile(r"\(([^()]*)\)")
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # public API
81
+ # ---------------------------------------------------------------------------
82
+
83
+ def parse_twb(xml_text: str) -> MirrorSpec:
84
+ """Read a Tableau workbook's XML into a MirrorSpec. Raises MirrorError when
85
+ the text is empty, too large, not XML, or not a workbook."""
86
+ root, data = _parse_root(xml_text)
87
+ warnings: list[str] = []
88
+
89
+ datasources, parameters = _read_datasources(root, warnings)
90
+ by_id = {ds.id: ds for ds in datasources}
91
+ if parameters:
92
+ _warn(warnings, f"{len(parameters)} parameter(s) are not carried: {', '.join(parameters)}")
93
+
94
+ fields = _merge_fields(datasources)
95
+ tables = _merge_tables(datasources)
96
+ joins = _merge_joins(datasources)
97
+
98
+ sheets: list[SourceSheet] = []
99
+ blends: list[tuple[str, ...]] = []
100
+ for ws in _worksheets(root):
101
+ sheet, dep_ids = _read_worksheet(ws, by_id, warnings)
102
+ if sheet is None:
103
+ continue
104
+ sheets.append(sheet)
105
+ if len(dep_ids) >= 2:
106
+ blends.append(tuple(dep_ids))
107
+ joins.extend(_read_blends(root, blends, by_id, warnings))
108
+
109
+ dashboards = _read_dashboards(root, {s.name for s in sheets}, warnings)
110
+
111
+ if dashboards:
112
+ name = dashboards[0].name
113
+ elif sheets:
114
+ name = sheets[0].name
115
+ else:
116
+ name = "Tableau workbook"
117
+
118
+ source_build = root.get("source-build", "")
119
+ confidence = 1.0
120
+ if not source_build and not fields:
121
+ confidence = 0.5
122
+ _warn(warnings, "No source build and no fields were found; this may not be a complete Tableau workbook")
123
+
124
+ meta: dict[str, Any] = {
125
+ "format": "twb",
126
+ "tableau_version": root.get("version", ""),
127
+ "source_build": source_build,
128
+ "worksheets": len(sheets),
129
+ "dashboards": len(dashboards),
130
+ }
131
+ meta.update(source_fingerprint(data))
132
+
133
+ return MirrorSpec(
134
+ source_kind=SOURCE_TABLEAU, name=name, tables=tables, joins=joins, fields=fields,
135
+ sheets=sheets, dashboards=dashboards, parameters=parameters, warnings=warnings,
136
+ confidence=confidence, source_meta=meta,
137
+ )
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # input
142
+ # ---------------------------------------------------------------------------
143
+
144
+ def _parse_root(xml_text: str) -> tuple[Element, bytes]:
145
+ if not isinstance(xml_text, str) or not xml_text.strip():
146
+ raise MirrorError("The workbook is empty. Send the .twb file's contents (a .twbx must be unpacked first).")
147
+ text = xml_text.lstrip("")
148
+ data = text.encode("utf-8")
149
+ if len(data) > MAX_BYTES:
150
+ raise MirrorError(
151
+ f"The workbook is {len(data) / (1024 * 1024):.0f} MB; DP Migrator reads .twb files up to 25 MB. "
152
+ "A .twb that large usually carries embedded data: save it without extracts and send it again."
153
+ )
154
+ try:
155
+ root = fromstring(text)
156
+ except (ParseError, DefusedXmlException):
157
+ raise MirrorError(
158
+ "This file is not a Tableau workbook: it could not be read as XML. "
159
+ "Send the .twb itself (unpack a .twbx first), saved from Tableau Desktop."
160
+ ) from None
161
+ if root.tag != "workbook":
162
+ raise MirrorError(
163
+ f"This XML is not a Tableau workbook: its top element is <{root.tag}>, a .twb starts with <workbook>."
164
+ )
165
+ return root, data
166
+
167
+
168
+ def _warn(warnings: list[str], message: str) -> None:
169
+ if message not in warnings:
170
+ warnings.append(message)
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # data sources
175
+ # ---------------------------------------------------------------------------
176
+
177
+ @dataclass
178
+ class _Datasource:
179
+ id: str
180
+ caption: str
181
+ fields: dict[str, SourceField] = field(default_factory=dict) # by bracketed local name, in order
182
+ tables: list[SourceTable] = field(default_factory=list)
183
+ joins: list[SourceJoin] = field(default_factory=list)
184
+
185
+
186
+ def _read_datasources(root: Element, warnings: list[str]) -> tuple[list[_Datasource], list[str]]:
187
+ container = root.find("datasources")
188
+ datasources: list[_Datasource] = []
189
+ parameters: list[str] = []
190
+ calc_counter = [0]
191
+ if container is None:
192
+ return datasources, parameters
193
+ for n, ds_el in enumerate(container.findall("datasource"), 1):
194
+ if ds_el.get("name") == PARAMETERS_DATASOURCE:
195
+ parameters.extend(_read_parameters(ds_el))
196
+ continue
197
+ ds = _Datasource(id=ds_el.get("name", ""), caption=ds_el.get("caption") or f"Data source {n}")
198
+ classes = _connection_classes(ds_el)
199
+ _read_tables(ds_el, ds, classes, warnings)
200
+ _read_legacy_joins(ds_el, ds, warnings)
201
+ _read_relationships(ds_el, ds, warnings)
202
+ _read_metadata_fields(ds_el, ds, warnings)
203
+ _read_column_fields(ds_el, ds, calc_counter, warnings)
204
+ datasources.append(ds)
205
+ return datasources, parameters
206
+
207
+
208
+ def _read_parameters(ds_el: Element) -> list[str]:
209
+ out: list[str] = []
210
+ for col in ds_el.findall("column"):
211
+ name = col.get("caption") or _unbracket(col.get("name", ""))
212
+ if name and name not in out:
213
+ out.append(name)
214
+ return out
215
+
216
+
217
+ def _connection_classes(ds_el: Element) -> dict[str, str]:
218
+ """named-connection name -> connection class; '' -> the class to use when a
219
+ relation names no connection. Only the class is read, never a host or path."""
220
+ classes: dict[str, str] = {}
221
+ conn = ds_el.find("connection")
222
+ if conn is None:
223
+ return classes
224
+ for named in conn.iter("named-connection"):
225
+ inner = named.find("connection")
226
+ if inner is not None and inner.get("class"):
227
+ classes[named.get("name", "")] = inner.get("class", "")
228
+ top = conn.get("class", "")
229
+ if top and top != "federated":
230
+ classes[""] = top
231
+ elif len(classes) == 1:
232
+ classes[""] = next(iter(classes.values()))
233
+ return classes
234
+
235
+
236
+ def _read_tables(ds_el: Element, ds: _Datasource, classes: dict[str, str], warnings: list[str]) -> None:
237
+ for rel in ds_el.iter("relation"):
238
+ kind = rel.get("type", "")
239
+ if kind not in ("table", "text", "union"):
240
+ continue
241
+ name = rel.get("name") or ""
242
+ if not name:
243
+ _warn(warnings, f"A {kind} relation in {ds.caption} has no name; skipped")
244
+ continue
245
+ connection = classes.get(rel.get("connection", ""), classes.get("", ""))
246
+ columns: list[str] = []
247
+ if kind == "text":
248
+ _warn(warnings, f"Custom SQL ({name}) in {ds.caption} was not read; its columns are not listed")
249
+ table_kind = "custom_sql"
250
+ else:
251
+ table_kind = "union" if kind == "union" else "table"
252
+ cols = rel.find("columns")
253
+ if cols is not None:
254
+ columns = [c.get("name", "") for c in cols.findall("column") if c.get("name")]
255
+ existing = next((t for t in ds.tables if t.name == name), None)
256
+ if existing is None:
257
+ ds.tables.append(SourceTable(name=name, columns=columns, kind=table_kind, connection=connection))
258
+ elif columns and not existing.columns:
259
+ existing.columns = columns
260
+
261
+
262
+ def _read_legacy_joins(ds_el: Element, ds: _Datasource, warnings: list[str]) -> None:
263
+ for rel in ds_el.iter("relation"):
264
+ if rel.get("type") != "join":
265
+ continue
266
+ kind = (rel.get("join") or "inner").lower()
267
+ if kind not in _JOIN_KINDS:
268
+ _warn(warnings, f"Join type {kind!r} in {ds.caption} is not understood; read as inner")
269
+ kind = "inner"
270
+ sides = [c.get("name", "") for c in rel.findall("relation") if c.get("type") == "table"]
271
+ for clause in rel.findall("clause"):
272
+ for expr in clause.findall("expression"):
273
+ join = _join_from_expression(expr, sides, kind, {}, ds.caption, warnings)
274
+ if join is not None:
275
+ ds.joins.append(join)
276
+
277
+
278
+ def _read_relationships(ds_el: Element, ds: _Datasource, warnings: list[str]) -> None:
279
+ objects = _object_names(ds_el)
280
+ count = 0
281
+ for rel in ds_el.iter("relationship"):
282
+ count += 1
283
+ ends = [e.get("object-id", "") for e in (rel.find("first-end-point"), rel.find("second-end-point"))
284
+ if e is not None]
285
+ sides = [objects.get(e, e) for e in ends]
286
+ found = False
287
+ for expr in rel.iter("expression"):
288
+ if expr.get("op") == "=" and len(expr.findall("expression")) == 2:
289
+ join = _join_from_expression(expr, sides, "inner", objects, ds.caption, warnings)
290
+ if join is not None:
291
+ ds.joins.append(join)
292
+ found = True
293
+ if not found:
294
+ _warn(warnings, f"A relationship in {ds.caption} has no readable equality condition; skipped")
295
+ if count:
296
+ _warn(warnings, f"{count} relationship(s) read as inner joins")
297
+
298
+
299
+ def _object_names(ds_el: Element) -> dict[str, str]:
300
+ """2020.2+ object-model ids -> the physical table name (what the tables list
301
+ uses), else the object's caption."""
302
+ out: dict[str, str] = {}
303
+ for obj in ds_el.iter("object"):
304
+ obj_id = obj.get("id", "")
305
+ if not obj_id:
306
+ continue
307
+ table = next((r.get("name", "") for r in obj.iter("relation") if r.get("type") == "table"), "")
308
+ out[obj_id] = table or obj.get("caption") or obj_id
309
+ return out
310
+
311
+
312
+ def _join_from_expression(expr: Element, sides: list[str], kind: str, objects: dict[str, str],
313
+ ds_caption: str, warnings: list[str]) -> SourceJoin | None:
314
+ op = expr.get("op", "")
315
+ if op != "=":
316
+ _warn(warnings, f"A join in {ds_caption} uses the operator {op!r}; only equality joins are carried")
317
+ return None
318
+ refs = [e.get("op", "") for e in expr.findall("expression")]
319
+ if len(refs) != 2:
320
+ _warn(warnings, f"A join condition in {ds_caption} does not name two fields; skipped")
321
+ return None
322
+ (lt, lf), (rt, rf) = _split_ref(refs[0]), _split_ref(refs[1])
323
+ lt = objects.get(lt, lt) or (sides[0] if len(sides) > 0 else "")
324
+ rt = objects.get(rt, rt) or (sides[1] if len(sides) > 1 else "")
325
+ return SourceJoin(left_table=lt, right_table=rt, left_field=lf, right_field=rf, kind=kind)
326
+
327
+
328
+ def _read_metadata_fields(ds_el: Element, ds: _Datasource, warnings: list[str]) -> None:
329
+ for rec in ds_el.iter("metadata-record"):
330
+ if rec.get("class") != "column":
331
+ continue
332
+ local = (rec.findtext("local-name") or "").strip()
333
+ if not local:
334
+ _warn(warnings, f"A column metadata-record in {ds.caption} has no local-name; skipped")
335
+ continue
336
+ if _omitted(local) or local in ds.fields:
337
+ continue
338
+ datatype = (rec.findtext("local-type") or "string").strip().lower()
339
+ agg = _META_AGG.get((rec.findtext("aggregation") or "").strip().lower(), "")
340
+ if datatype in _DATE_TYPES:
341
+ role, aggregation = ROLE_DATE, ""
342
+ elif datatype in _NUMERIC_TYPES:
343
+ role, aggregation = ROLE_MEASURE, agg or "sum"
344
+ else:
345
+ role, aggregation = ROLE_DIMENSION, ""
346
+ ds.fields[local] = SourceField(
347
+ name=_unbracket(local), role=role, datatype=datatype,
348
+ table=_unbracket((rec.findtext("parent-name") or "").strip()), aggregation=aggregation,
349
+ )
350
+
351
+
352
+ def _read_column_fields(ds_el: Element, ds: _Datasource, calc_counter: list[int], warnings: list[str]) -> None:
353
+ for col in ds_el.findall("column"):
354
+ local = col.get("name", "")
355
+ if not local:
356
+ _warn(warnings, f"A column in {ds.caption} has no name; skipped")
357
+ continue
358
+ if _omitted(local):
359
+ continue
360
+ calc = col.find("calculation")
361
+ formula = calc.get("formula", "") if calc is not None else ""
362
+ existing = ds.fields.get(local)
363
+ datatype = (col.get("datatype") or (existing.datatype if existing else "string")).lower()
364
+ explicit_role = (col.get("role") or "").lower()
365
+ if datatype in _DATE_TYPES:
366
+ role = ROLE_DATE
367
+ elif explicit_role == "measure":
368
+ role = ROLE_MEASURE
369
+ elif explicit_role == "dimension":
370
+ role = ROLE_DIMENSION
371
+ else:
372
+ role = existing.role if existing else ROLE_DIMENSION
373
+ name = col.get("caption") or ""
374
+ if not name:
375
+ bare = _unbracket(local)
376
+ if _CALC_NAME.match(bare):
377
+ calc_counter[0] += 1
378
+ name = f"Calculated field {calc_counter[0]}"
379
+ _warn(warnings, f"A calculated field in {ds.caption} has no caption; shown as {name!r}")
380
+ else:
381
+ name = bare
382
+ aggregation = ""
383
+ if role == ROLE_MEASURE:
384
+ aggregation = (existing.aggregation if existing and existing.aggregation else "sum")
385
+ notes: list[str] = list(existing.notes) if existing else []
386
+ if formula and any(m in formula.upper() for m in _LOD_MARKERS):
387
+ notes.append("level-of-detail expression; re-expressed loosely")
388
+ ds.fields[local] = SourceField(
389
+ name=name, role=role, datatype=datatype,
390
+ table=existing.table if existing else "",
391
+ aggregation=aggregation, formula=formula,
392
+ hidden=(col.get("hidden", "").lower() == "true"), notes=notes,
393
+ )
394
+
395
+
396
+ def _merge_fields(datasources: list[_Datasource]) -> list[SourceField]:
397
+ seen: set[str] = set()
398
+ out: list[SourceField] = []
399
+ for ds in datasources:
400
+ for f in ds.fields.values():
401
+ key = f.name.strip().lower()
402
+ if key in seen:
403
+ continue
404
+ seen.add(key)
405
+ out.append(f)
406
+ return out
407
+
408
+
409
+ def _merge_tables(datasources: list[_Datasource]) -> list[SourceTable]:
410
+ out: list[SourceTable] = []
411
+ for ds in datasources:
412
+ for t in ds.tables:
413
+ if not any(x.name == t.name for x in out):
414
+ out.append(t)
415
+ return out
416
+
417
+
418
+ def _merge_joins(datasources: list[_Datasource]) -> list[SourceJoin]:
419
+ out: list[SourceJoin] = []
420
+ for ds in datasources:
421
+ for j in ds.joins:
422
+ if j not in out:
423
+ out.append(j)
424
+ return out
425
+
426
+
427
+ # ---------------------------------------------------------------------------
428
+ # worksheets
429
+ # ---------------------------------------------------------------------------
430
+
431
+ @dataclass
432
+ class _Dependencies:
433
+ """What one worksheet declares it uses from one data source."""
434
+ captions: dict[str, str] = field(default_factory=dict) # bracketed name -> caption
435
+ roles: dict[str, str] = field(default_factory=dict) # bracketed name -> measure|dimension
436
+ datatypes: dict[str, str] = field(default_factory=dict)
437
+ aggregations: dict[str, str] = field(default_factory=dict) # bracketed name -> aggregation on an instance
438
+ table_calcs: list[str] = field(default_factory=list) # bracketed names carrying a table calc
439
+
440
+
441
+ class _ShelfReader:
442
+ """Turns shelf/encoding text into ShelfFields for one worksheet."""
443
+
444
+ def __init__(self, by_id: dict[str, _Datasource], deps: dict[str, _Dependencies], notes: list[str]):
445
+ self.by_id = by_id
446
+ self.deps = deps
447
+ self.notes = notes
448
+
449
+ def fields_in(self, text: str | None) -> list[ShelfField]:
450
+ out: list[ShelfField] = []
451
+ for m in _TOKEN.finditer(text or ""):
452
+ for sf in self.resolve(_unescape(m.group(1) or ""), _unescape(m.group(2))):
453
+ if not any(x.field == sf.field and x.role == sf.role for x in out):
454
+ out.append(sf)
455
+ return out
456
+
457
+ def measure_token_count(self, text: str) -> int:
458
+ return sum(1 for sf in self.fields_in(text) if sf.role == ROLE_MEASURE)
459
+
460
+ def resolve(self, ds_id: str, token: str) -> list[ShelfField]:
461
+ if token == "Multiple Values":
462
+ return self._measure_values(ds_id)
463
+ if token.startswith(":"):
464
+ return []
465
+ prefix, name = _split_token(token)
466
+ local = f"[{name}]"
467
+ if _omitted(local):
468
+ return []
469
+ src = self._source_field(ds_id, local)
470
+ display = self._display(ds_id, local, src)
471
+ if prefix in _DATE_PREFIX:
472
+ return [ShelfField(field=display, role=ROLE_DATE, grain=_DATE_PREFIX[prefix])]
473
+ if prefix in _AGG_PREFIX:
474
+ return [ShelfField(field=display, role=ROLE_MEASURE, aggregation=_AGG_PREFIX[prefix])]
475
+ is_date = (src is not None and src.role == ROLE_DATE) or self._dep_datatype(ds_id, local) in _DATE_TYPES
476
+ if prefix in ("none", ""):
477
+ if is_date:
478
+ return [ShelfField(field=display, role=ROLE_DATE)]
479
+ return [ShelfField(field=display, role=ROLE_DIMENSION)]
480
+ if prefix != "attr":
481
+ self._note(f"shelf prefix {prefix!r} on {display} is not understood; carried by the field's own role")
482
+ if is_date:
483
+ return [ShelfField(field=display, role=ROLE_DATE)]
484
+ if src is not None and src.role == ROLE_MEASURE:
485
+ return [ShelfField(field=display, role=ROLE_MEASURE, aggregation=src.aggregation or "sum")]
486
+ return [ShelfField(field=display, role=ROLE_DIMENSION)]
487
+
488
+ def _measure_values(self, ds_id: str) -> list[ShelfField]:
489
+ ids = [ds_id] if ds_id else list(self.deps)
490
+ out: list[ShelfField] = []
491
+ for d in ids:
492
+ dep = self.deps.get(d)
493
+ if dep is None:
494
+ continue
495
+ for local, role in dep.roles.items():
496
+ if role != "measure" or _omitted(local):
497
+ continue
498
+ src = self._source_field(d, local)
499
+ agg = dep.aggregations.get(local) or (src.aggregation if src else "") or "sum"
500
+ sf = ShelfField(field=self._display(d, local, src), role=ROLE_MEASURE, aggregation=agg)
501
+ if not any(x.field == sf.field for x in out):
502
+ out.append(sf)
503
+ if out:
504
+ self._note("Measure Values expanded")
505
+ else:
506
+ self._note("Measure Values could not be expanded: the sheet lists no measures")
507
+ return out
508
+
509
+ def display_name(self, local: str) -> str:
510
+ """The caption for a bracketed field name, from any data source the sheet uses."""
511
+ return self._display("", local, self._source_field("", local))
512
+
513
+ def _source_field(self, ds_id: str, local: str) -> SourceField | None:
514
+ ds = self.by_id.get(ds_id)
515
+ if ds is not None and local in ds.fields:
516
+ return ds.fields[local]
517
+ for other in self.by_id.values():
518
+ if local in other.fields:
519
+ return other.fields[local]
520
+ return None
521
+
522
+ def _display(self, ds_id: str, local: str, src: SourceField | None) -> str:
523
+ if src is not None:
524
+ return src.name
525
+ dep = self.deps.get(ds_id)
526
+ if dep is not None and dep.captions.get(local):
527
+ return dep.captions[local]
528
+ for dep in self.deps.values():
529
+ if dep.captions.get(local):
530
+ return dep.captions[local]
531
+ bare = _unbracket(local)
532
+ if _CALC_NAME.match(bare):
533
+ return "Calculated field"
534
+ return bare
535
+
536
+ def _dep_datatype(self, ds_id: str, local: str) -> str:
537
+ dep = self.deps.get(ds_id)
538
+ return dep.datatypes.get(local, "") if dep is not None else ""
539
+
540
+ def _note(self, message: str) -> None:
541
+ if message not in self.notes:
542
+ self.notes.append(message)
543
+
544
+
545
+ def _worksheets(root: Element) -> Iterator[Element]:
546
+ container = root.find("worksheets")
547
+ if container is None:
548
+ return
549
+ yield from container.findall("worksheet")
550
+
551
+
552
+ def _read_worksheet(ws: Element, by_id: dict[str, _Datasource], warnings: list[str]
553
+ ) -> tuple[SourceSheet | None, list[str]]:
554
+ name = ws.get("name") or ""
555
+ if not name:
556
+ _warn(warnings, "A worksheet has no name; skipped")
557
+ return None, []
558
+ table = ws.find("table")
559
+ if table is None:
560
+ _warn(warnings, f"Worksheet {name!r} has no table element; skipped")
561
+ return None, []
562
+ view = table.find("view")
563
+ notes: list[str] = []
564
+ deps, dep_ids = _read_dependencies(view, warnings, name)
565
+ reader = _ShelfReader(by_id, deps, notes)
566
+
567
+ rows = reader.fields_in(table.findtext("rows"))
568
+ cols = reader.fields_in(table.findtext("cols"))
569
+
570
+ panes = table.find("panes")
571
+ pane_list = panes.findall("pane") if panes is not None else []
572
+ marks = _marks(pane_list)
573
+ color = size = None
574
+ detail: list[ShelfField] = []
575
+ text_fields: list[ShelfField] = []
576
+ for pane in pane_list:
577
+ enc = pane.find("encodings")
578
+ if enc is None:
579
+ continue
580
+ for el in enc:
581
+ fields = reader.fields_in(el.get("column"))
582
+ if el.tag == "color" and color is None and fields:
583
+ color = fields[0]
584
+ elif el.tag == "size" and size is None and fields:
585
+ size = fields[0]
586
+ elif el.tag == "lod":
587
+ _extend_unique(detail, fields)
588
+ elif el.tag == "text":
589
+ _extend_unique(text_fields, fields)
590
+
591
+ filters: list[str] = []
592
+ if view is not None:
593
+ for flt in view.findall("filter"):
594
+ for sf in reader.fields_in(flt.get("column")):
595
+ if sf.field not in filters:
596
+ filters.append(sf.field)
597
+
598
+ dual_axis = _dual_axis(table, pane_list, reader)
599
+
600
+ on_shelves = {f.field for f in rows + cols} | {f.field for f in (color, size) if f}
601
+ extra_text = [f for f in text_fields if f.field not in on_shelves]
602
+ _extend_unique(detail, extra_text)
603
+ if any(f.role == ROLE_MEASURE for f in extra_text) and not any(f.role == ROLE_MEASURE for f in rows + cols):
604
+ notes.append("measure read from the text (label) encoding")
605
+
606
+ chart_type, fidelity, mapping_notes = chart_type_for(
607
+ marks, rows + extra_text, cols, color, size, dual_axis)
608
+ for local in _table_calcs(deps):
609
+ notes.append(f"table calculation on {reader.display_name(local)} is not carried")
610
+ if fidelity == FIDELITY_EXACT:
611
+ fidelity = FIDELITY_APPROXIMATED
612
+
613
+ sheet = SourceSheet(
614
+ name=name, title=_title(ws, name), marks=marks, rows=rows, cols=cols, color=color, size=size,
615
+ detail=detail, filters=filters, dual_axis=dual_axis, chart_type=chart_type, fidelity=fidelity,
616
+ notes=mapping_notes + [n for n in notes if n not in mapping_notes],
617
+ )
618
+ return sheet, dep_ids
619
+
620
+
621
+ def _read_dependencies(view: Element | None, warnings: list[str], sheet_name: str
622
+ ) -> tuple[dict[str, _Dependencies], list[str]]:
623
+ deps: dict[str, _Dependencies] = {}
624
+ order: list[str] = []
625
+ if view is None:
626
+ _warn(warnings, f"Worksheet {sheet_name!r} has no view element; its fields could not be read")
627
+ return deps, order
628
+ for dd in view.findall("datasource-dependencies"):
629
+ ds_id = dd.get("datasource", "")
630
+ dep = deps.setdefault(ds_id, _Dependencies())
631
+ if ds_id not in order:
632
+ order.append(ds_id)
633
+ for col in dd.findall("column"):
634
+ local = col.get("name", "")
635
+ if not local:
636
+ continue
637
+ if col.get("caption"):
638
+ dep.captions[local] = col.get("caption", "")
639
+ dep.roles[local] = (col.get("role") or "").lower()
640
+ dep.datatypes[local] = (col.get("datatype") or "").lower()
641
+ for inst in dd.findall("column-instance"):
642
+ local = inst.get("column", "")
643
+ prefix, _ = _split_token(_unbracket(inst.get("name", "")))
644
+ if local and prefix in _AGG_PREFIX and local not in dep.aggregations:
645
+ dep.aggregations[local] = _AGG_PREFIX[prefix]
646
+ if local and inst.find("table-calc") is not None and local not in dep.table_calcs:
647
+ dep.table_calcs.append(local)
648
+ return deps, order
649
+
650
+
651
+ def _table_calcs(deps: dict[str, _Dependencies]) -> list[str]:
652
+ out: list[str] = []
653
+ for dep in deps.values():
654
+ for local in dep.table_calcs:
655
+ if local not in out:
656
+ out.append(local)
657
+ return out
658
+
659
+
660
+ def _marks(panes: list[Element]) -> list[str]:
661
+ named = [p for p in panes if p.get("y-axis-name") or p.get("x-axis-name")]
662
+ source = named if any(p.find("mark") is not None for p in named) else panes
663
+ out: list[str] = []
664
+ for pane in source:
665
+ mark = pane.find("mark")
666
+ cls = mark.get("class", "") if mark is not None else ""
667
+ if cls and cls not in out:
668
+ out.append(cls)
669
+ return out
670
+
671
+
672
+ def _dual_axis(table: Element, panes: list[Element], reader: _ShelfReader) -> bool:
673
+ style = table.find("style")
674
+ if style is not None:
675
+ for enc in style.iter("encoding"):
676
+ if enc.get("attr") == "space" and enc.get("fold", "").lower() == "true":
677
+ return True
678
+ for shelf in ("rows", "cols"):
679
+ for group in _GROUP.finditer(table.findtext(shelf) or ""):
680
+ if reader.measure_token_count(group.group(1)) >= 2:
681
+ return True
682
+ axes: dict[str, str] = {}
683
+ for pane in panes:
684
+ axis = pane.get("y-axis-name") or pane.get("x-axis-name")
685
+ mark = pane.find("mark")
686
+ if axis and mark is not None and mark.get("class"):
687
+ axes.setdefault(axis, mark.get("class", ""))
688
+ return len(axes) > 1 and len(set(axes.values())) > 1
689
+
690
+
691
+ def _title(ws: Element, name: str) -> str:
692
+ ft = ws.find("layout-options/title/formatted-text")
693
+ if ft is None:
694
+ return name
695
+ text = "".join((run.text or "") for run in ft.iter("run")).strip()
696
+ text = text.replace("<Sheet Name>", name).strip()
697
+ return text or name
698
+
699
+
700
+ def _extend_unique(target: list[ShelfField], items: list[ShelfField]) -> None:
701
+ for sf in items:
702
+ if not any(x.field == sf.field and x.role == sf.role for x in target):
703
+ target.append(sf)
704
+
705
+
706
+ # ---------------------------------------------------------------------------
707
+ # blends
708
+ # ---------------------------------------------------------------------------
709
+
710
+ def _read_blends(root: Element, blends: list[tuple[str, ...]], by_id: dict[str, _Datasource],
711
+ warnings: list[str]) -> list[SourceJoin]:
712
+ rel_el = root.find("datasource-relationships")
713
+ columns: dict[str, list[str]] = {}
714
+ if rel_el is not None:
715
+ for dd in rel_el.findall("datasource-dependencies"):
716
+ cols = columns.setdefault(dd.get("datasource", ""), [])
717
+ for inst in dd.findall("column-instance"):
718
+ if inst.get("column") and inst.get("column") not in cols:
719
+ cols.append(inst.get("column", ""))
720
+ for col in dd.findall("column"):
721
+ if col.get("name") and col.get("name") not in cols:
722
+ cols.append(col.get("name", ""))
723
+ out: list[SourceJoin] = []
724
+ seen: set[tuple[str, str]] = set()
725
+ for dep_ids in blends:
726
+ primary = dep_ids[0]
727
+ for secondary in dep_ids[1:]:
728
+ if (primary, secondary) in seen:
729
+ continue
730
+ seen.add((primary, secondary))
731
+ shared = next((c for c in columns.get(primary, []) if c in columns.get(secondary, [])), "")
732
+ shared = _unbracket(shared)
733
+ left, right = _caption(by_id, primary), _caption(by_id, secondary)
734
+ out.append(SourceJoin(left_table=left, right_table=right, left_field=shared,
735
+ right_field=shared, kind="blend"))
736
+ _warn(warnings, f"{right} is blended onto {left} on {shared or 'no shared field found'}; "
737
+ "mirrored as one model")
738
+ return out
739
+
740
+
741
+ def _caption(by_id: dict[str, _Datasource], ds_id: str) -> str:
742
+ ds = by_id.get(ds_id)
743
+ return ds.caption if ds is not None else "unnamed data source"
744
+
745
+
746
+ # ---------------------------------------------------------------------------
747
+ # dashboards
748
+ # ---------------------------------------------------------------------------
749
+
750
+ def _read_dashboards(root: Element, sheet_names: set[str], warnings: list[str]) -> list[SourceDashboard]:
751
+ container = root.find("dashboards")
752
+ out: list[SourceDashboard] = []
753
+ if container is None:
754
+ return out
755
+ for dash in container.findall("dashboard"):
756
+ name = dash.get("name") or ""
757
+ if not name:
758
+ _warn(warnings, "A dashboard has no name; skipped")
759
+ continue
760
+ width, height = _dashboard_size(dash, warnings)
761
+ zones: list[Zone] = []
762
+ for z in _zone_elements(dash):
763
+ zone = _zone(z, sheet_names, name, warnings)
764
+ if zone is not None:
765
+ zones.append(zone)
766
+ out.append(SourceDashboard(name=name, width=width, height=height, zones=zones))
767
+ return out
768
+
769
+
770
+ def _dashboard_size(dash: Element, warnings: list[str]) -> tuple[int, int]:
771
+ """(width, height) in px from <size>: the max values, else the min ones, else 0."""
772
+ size = dash.find("size")
773
+ if size is None:
774
+ return 0, 0
775
+ values: dict[str, int] = {}
776
+ for attr in ("maxwidth", "maxheight", "minwidth", "minheight"):
777
+ raw = size.get(attr)
778
+ if not raw:
779
+ continue
780
+ try:
781
+ values[attr] = int(raw)
782
+ except ValueError:
783
+ _warn(warnings, f"Dashboard {dash.get('name')!r} has a size ({attr}) that could not be read; "
784
+ "treated as unknown")
785
+ width = values.get("maxwidth") or values.get("minwidth") or 0
786
+ height = values.get("maxheight") or values.get("minheight") or 0
787
+ return width, height
788
+
789
+
790
+ def _zone_elements(el: Element) -> Iterator[Element]:
791
+ for child in el:
792
+ if child.tag == "devicelayouts":
793
+ continue
794
+ if child.tag == "zone":
795
+ yield child
796
+ yield from _zone_elements(child)
797
+
798
+
799
+ def _zone(z: Element, sheet_names: set[str], dashboard: str, warnings: list[str]) -> Zone | None:
800
+ ztype = (z.get("type") or "").lower()
801
+ name = z.get("name") or ""
802
+ if ztype in _CONTAINER_ZONES:
803
+ return None
804
+ if not ztype:
805
+ if name not in sheet_names:
806
+ return None
807
+ kind = "sheet"
808
+ elif ztype in _TEXT_ZONES:
809
+ kind = "text"
810
+ elif ztype in _FILTER_ZONES:
811
+ kind = "filter"
812
+ else:
813
+ kind = "other"
814
+ try:
815
+ x, y, w, h = (int(z.get(a, "0")) / ZONE_UNITS for a in ("x", "y", "w", "h"))
816
+ except ValueError:
817
+ _warn(warnings, f"A {kind} zone on dashboard {dashboard!r} has a position that could not be read; skipped")
818
+ return None
819
+ return Zone(sheet=name or ztype, x=x, y=y, w=w, h=h, kind=kind)
820
+
821
+
822
+ # ---------------------------------------------------------------------------
823
+ # small helpers
824
+ # ---------------------------------------------------------------------------
825
+
826
+ def _unbracket(name: str) -> str:
827
+ name = (name or "").strip()
828
+ if name.startswith("[") and name.endswith("]"):
829
+ name = name[1:-1]
830
+ return _unescape(name)
831
+
832
+
833
+ def _unescape(name: str) -> str:
834
+ return name.replace("]]", "]")
835
+
836
+
837
+ def _omitted(local: str) -> bool:
838
+ return local.lower() in _OMIT_FIELDS or local.startswith("[:")
839
+
840
+
841
+ def _split_token(token: str) -> tuple[str, str]:
842
+ """'sum:Sales:qk' -> ('sum', 'Sales'); 'Category' -> ('none', 'Category')."""
843
+ parts = token.split(":")
844
+ if len(parts) >= 3:
845
+ return parts[0].lower(), ":".join(parts[1:-1])
846
+ if len(parts) == 2:
847
+ return parts[0].lower(), parts[1]
848
+ return "none", token
849
+
850
+
851
+ def _split_ref(ref: str) -> tuple[str, str]:
852
+ """'[Table].[Field]' -> ('Table', 'Field'); '[Field]' -> ('', 'Field')."""
853
+ m = _TOKEN.fullmatch(ref.strip())
854
+ if m is None:
855
+ return "", _unbracket(ref)
856
+ return _unescape(m.group(1) or ""), _unescape(m.group(2))
857
+
858
+
859
+
860
+ __all__ = ["parse_twb", "MAX_BYTES"]
docs/agent_q_flags.md CHANGED
@@ -1,6 +1,6 @@
1
  # Agent Q → DemoPrep: what to send (flag registry)
2
 
3
- *Living document, owned by the release manager. Every change to the `build_demo_from_brief` arguments or the MCP tool list is recorded here first. Last updated 2026-10-22.*
4
 
5
  **Rule (boone, 2026-10-14 / 10-20):** every feature is **OFF unless Agent Q sends it**. If an argument is omitted, false or blank, the build is exactly what it was before the feature existed.
6
 
@@ -52,6 +52,7 @@ Status key:
52
  | `insight_tile=true` (boone: "tile=1") | no insight tile | ThoughtSpot insight (AI narrative) tile **to the left of the KPIs** on the first tab | BUILT (`619d0b1`); TML import works, the narrative render still needs a look | ISS-24 |
53
  | `logo_tile=true` (boone: "logo=1") | no logo tile | Card with the company logo (**linking to the TSE site**; the company website if there's no portal), the tab name, a tagline and a short description of the key measures; top-left of the first tab | BUILT (`619d0b1`) | ISS-25 |
54
  | Nick's homepage | — | details pending from boone | IDEA | ISS-21 |
 
55
 
56
  Flag names: boone said "tile=1" / "logo=1". The arguments are named `insight_tile` / `logo_tile` so the tool schema is unambiguous to Agent Q. Both accept `true`/`1`/`"yes"` like the portal flags.
57
 
@@ -65,11 +66,13 @@ Flag names: boone said "tile=1" / "logo=1". The arguments are named `insight_til
65
  | `list_board_charts`, `inspect_board_data`, `adjust_chart_data` (preview by default, `apply=true` writes), `undo_chart_adjustment` | find and fix flat charts on a built board | TEST |
66
  | `deploy_existing_schema` | redeploy a built schema to ThoughtSpot | TEST |
67
  | `check_homepage(url)` | call BEFORE `build_demo_from_brief`: can DemoPrep get the prospect's home page by itself (live, else an Internet Archive copy)? Returns `ok`, `source` (`live` / `wayback (YYYY-MM-DD)` / `none`), `needs_screenshot`, `reason`, `message_for_se`. When `needs_screenshot` is true, relay `message_for_se` to the SE and pass their image as `homepage_screenshot`. Up to ~45 s, no build slot, no writes; a usable capture is reused by a build of the same URL within 30 min | BUILT (`feature/check-homepage-tool`) |
 
68
 
69
  ## 5. After any prod push
70
  AgentSpot caches the tool list. **Refresh the tools in Agent Q** after every push that changes this page, or new arguments are invisible and newly required ones fail.
71
 
72
  ## Change log
 
73
  - 2026-10-22: `list_settings_fields` / `get_settings` / `set_settings` for the dpSettingsDB agent (settings in Supabase, read and written through the MCP server).
74
  - 2026-10-22: `settings` (one JSON document per SE, produced by the DP Settings agent) on `build_demo_from_brief`; replaces the SE's Supabase settings for that run. On TEST `0d02061`.
75
  - 2026-10-21: `check_homepage(url)` tool: the build's own live-then-Wayback home page capture, run on its own so Agent Q can ask the SE for a screenshot before building (`needs_screenshot`, `message_for_se`).
 
1
  # Agent Q → DemoPrep: what to send (flag registry)
2
 
3
+ *Living document, owned by the release manager. Every change to the `build_demo_from_brief` arguments or the MCP tool list is recorded here first. Last updated 2026-10-28.*
4
 
5
  **Rule (boone, 2026-10-14 / 10-20):** every feature is **OFF unless Agent Q sends it**. If an argument is omitted, false or blank, the build is exactly what it was before the feature existed.
6
 
 
52
  | `insight_tile=true` (boone: "tile=1") | no insight tile | ThoughtSpot insight (AI narrative) tile **to the left of the KPIs** on the first tab | BUILT (`619d0b1`); TML import works, the narrative render still needs a look | ISS-24 |
53
  | `logo_tile=true` (boone: "logo=1") | no logo tile | Card with the company logo (**linking to the TSE site**; the company website if there's no portal), the tab name, a tagline and a short description of the key measures; top-left of the first tab | BUILT (`619d0b1`) | ISS-25 |
54
  | Nick's homepage | — | details pending from boone | IDEA | ISS-21 |
55
+ | `source_dashboard` (the prospect's Tableau .twb as XML text, or a .twbx/.twb as a base64 data URI) or `source_dashboard_image` (a picture of the dashboard: https image URL or base64 data URI) | an ordinary demo | **DP Migrator** (Ketan + Nick, 2026-10-28): the demo REBUILDS the prospect's dashboard. The data speaks its measure/dimension names (the spec seeds the blueprint); the liveboard copies its sheets — chart types, titles, arrangement, a dual axis as a combo chart — on one tab, instead of the golden layout. DP Web unchanged. Only the .twb inside a .twbx is read; extracts are never opened. An unreadable file is refused at once; an unreadable picture fails the build within seconds. The result carries `mirror` (sheets with fidelity exact / approximated / dropped, tiles built, sheets dropped). Call `inspect_dashboard` first. | BUILT (`claude/dashboard-migration-tse-5669ab`, off test `8528a7c`) | ISS-31 |
56
 
57
  Flag names: boone said "tile=1" / "logo=1". The arguments are named `insight_tile` / `logo_tile` so the tool schema is unambiguous to Agent Q. Both accept `true`/`1`/`"yes"` like the portal flags.
58
 
 
66
  | `list_board_charts`, `inspect_board_data`, `adjust_chart_data` (preview by default, `apply=true` writes), `undo_chart_adjustment` | find and fix flat charts on a built board | TEST |
67
  | `deploy_existing_schema` | redeploy a built schema to ThoughtSpot | TEST |
68
  | `check_homepage(url)` | call BEFORE `build_demo_from_brief`: can DemoPrep get the prospect's home page by itself (live, else an Internet Archive copy)? Returns `ok`, `source` (`live` / `wayback (YYYY-MM-DD)` / `none`), `needs_screenshot`, `reason`, `message_for_se`. When `needs_screenshot` is true, relay `message_for_se` to the SE and pass their image as `homepage_screenshot`. Up to ~45 s, no build slot, no writes; a usable capture is reused by a build of the same URL within 30 min | BUILT (`feature/check-homepage-tool`) |
69
+ | `inspect_dashboard(source_dashboard, source_dashboard_image)` | DP Migrator: read the prospect's Tableau workbook or dashboard picture and say what a build would carry over — `sheets` (title, ThoughtSpot `chart_type`, measures, dimensions, date, `fidelity` exact / approximated / dropped, notes), `tables`, `joins`, `measures`, `dimensions`, `carried` counts, `warnings`, `message_for_se`. Workbook < 1 s; picture = one vision call. No slot, no writes. Then pass the SAME argument to `build_demo_from_brief` | BUILT (`claude/dashboard-migration-tse-5669ab`) |
70
 
71
  ## 5. After any prod push
72
  AgentSpot caches the tool list. **Refresh the tools in Agent Q** after every push that changes this page, or new arguments are invisible and newly required ones fail.
73
 
74
  ## Change log
75
+ - 2026-10-28: **DP Migrator** — `source_dashboard` / `source_dashboard_image` on `build_demo_from_brief` and the `inspect_dashboard` tool (Ketan's "built-in legacy migrator", Nick's ask). Beta: Tableau .twb/.twbx and pictures; one dashboard; zone layout or a grid; Power BI later.
76
  - 2026-10-22: `list_settings_fields` / `get_settings` / `set_settings` for the dpSettingsDB agent (settings in Supabase, read and written through the MCP server).
77
  - 2026-10-22: `settings` (one JSON document per SE, produced by the DP Settings agent) on `build_demo_from_brief`; replaces the SE's Supabase settings for that run. On TEST `0d02061`.
78
  - 2026-10-21: `check_homepage(url)` tool: the build's own live-then-Wayback home page capture, run on its own so Agent Q can ask the SE for a screenshot before building (`needs_screenshot`, `message_for_se`).
docs/agentspot_dp_migrator_agent.md ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AgentSpot agent instructions — DP Migrator
2
+
3
+ Paste into the agent's instructions. Keep it generic: AgentSpot agent
4
+ instructions are **org-wide** on shared SE clusters, so no customer names,
5
+ GUIDs or schemas belong here. The agent needs the DemoPrep MCP connector
6
+ (the same connector Agent Q uses); it calls three of its tools.
7
+
8
+ ---
9
+
10
+ ## What you do
11
+
12
+ You rebuild a prospect's existing dashboard as a ThoughtSpot Embedded demo.
13
+ The SE gives you the prospect's website and either their Tableau workbook
14
+ (a .twb or .twbx file) or a picture of the dashboard. DemoPrep reads it,
15
+ builds a demo whose data uses the dashboard's own measure and dimension
16
+ names, recreates the dashboard as a liveboard (same charts, same titles, same
17
+ arrangement, one tab), and embeds it in a DP Web site that looks like the
18
+ prospect's. Nothing from the prospect's data is used: the numbers are
19
+ synthetic, the vocabulary and the layout are theirs.
20
+
21
+ Be honest about what this is. It is a demo-grade rebuild, not a migration of
22
+ their data: charts carry over as "exact", "approximated" (the notes say what
23
+ changed, for example a dual axis drawn as a combo chart) or "dropped" (the
24
+ notes say why).
25
+
26
+ ## What you need before you build
27
+
28
+ 1. **The prospect's website**, e.g. https://www.acme.com. The DP Web site is
29
+ cloned from its home page. Derive the company name from it unless the SE
30
+ gives one.
31
+ 2. **The dashboard**, one of:
32
+ - the Tableau workbook: a `.twb` (XML text; send its contents as is) or a
33
+ `.twbx` (send the file as a base64 data URI). Only the `.twb` inside a
34
+ `.twbx` is read; its extracts are never opened.
35
+ - a picture of the dashboard: a png, jpeg or webp up to 10 MB, as an https
36
+ image URL or a base64 data URI. The workbook is exact; a picture is read
37
+ by a vision model and gets lower confidence, so prefer the file when the
38
+ SE has it. If they send both, the workbook is used.
39
+ 3. **The SE's email** (`owner_email`): the demo is built under their
40
+ ThoughtSpot account.
41
+ 4. **The Salesforce opportunity id** (`salesforce_opportunity_id`) and the
42
+ **environment** (`environment`: "prod" or "staging"). Both are required on
43
+ every build; nothing is read from Salesforce.
44
+
45
+ Ask for whatever is missing in one message; do not start without the website,
46
+ the dashboard and the SE's email.
47
+
48
+ ## Tools
49
+
50
+ **`inspect_dashboard(source_dashboard, source_dashboard_image)`** — reads the
51
+ workbook or the picture and says what a build would carry over. Read-only,
52
+ takes no build slot; a workbook returns in under a second, a picture takes a
53
+ vision call (10-30 s). Returns `ok`, `name`, `source_kind`, `confidence`, the
54
+ `measures`, `dimensions`, `dates`, `tables` and `joins` it read, `sheets`
55
+ (one per chart: `title`, `chart_type`, `measures`, `dimensions`, `date`,
56
+ `fidelity`, `notes`), `tiles` (the sheets that become tiles, in order),
57
+ `carried` (counts of exact / approximated / dropped), `warnings`, and
58
+ `message_for_se`. `ok` false means it could not be read; `error` says why.
59
+
60
+ **`build_demo_from_brief(...)`** — the full build (data, model, liveboard,
61
+ DP Web site). Returns at once with a `run_id`; the build takes 15-20 minutes
62
+ in the background. For a rebuild pass, besides the usual arguments:
63
+
64
+ - `source_dashboard` — the workbook, exactly the value you inspected, **or**
65
+ - `source_dashboard_image` — the picture, exactly the value you inspected.
66
+ - `brief` — two or three sentences you write: who the prospect is (from the
67
+ website), what the dashboard tracks (from the inspect result: its measures,
68
+ dimensions and date), and that they are evaluating ThoughtSpot Embedded.
69
+ - `company_name`, `company_url`, `use_case` (name it after the dashboard, for
70
+ example "Sales vs Target"), `owner_email`, `salesforce_opportunity_id`,
71
+ `environment`.
72
+
73
+ Leave every other argument at its default. An unreadable workbook is refused
74
+ immediately (`status` "failed", the reason in `errors`); an unreadable picture
75
+ fails the build within its first minute with the reason.
76
+
77
+ **`status()`** — progress of running builds (`active_builds`, each with a
78
+ `phase`) and the results of finished ones (`recent_builds`, by `run_id`).
79
+
80
+ Optional: **`check_homepage(url)`** before building. If it says
81
+ `needs_screenshot`, ask the SE for a screenshot of the home page and pass it
82
+ as `homepage_screenshot`; the build still runs without one, on a template
83
+ login page.
84
+
85
+ ## How to run a rebuild
86
+
87
+ 1. Collect the website, the dashboard and the SE's email (plus opportunity id
88
+ and environment).
89
+ 2. Call `inspect_dashboard`. Relay `message_for_se`, then show a short table of
90
+ the sheets: title, chart type it becomes, fidelity, notes. List `warnings`
91
+ (parameters and table calculations that do not carry over, blends read as
92
+ one model). If `ok` is false, relay `error` and ask for the `.twb` file or
93
+ a larger, clearer screenshot.
94
+ 3. Ask "Build it?" and wait for a yes.
95
+ 4. Call `build_demo_from_brief` with the same `source_dashboard` /
96
+ `source_dashboard_image` value and the arguments above. Tell the SE the
97
+ build is running and takes 15-20 minutes.
98
+ 5. Poll `status()` about once a minute. Report the phase when it changes
99
+ (dataset, tables, model, liveboard, portal). Do not call it in a tight loop.
100
+ 6. When the run leaves `active_builds`, read its entry in `recent_builds` and
101
+ report: `liveboard_url`, `portal_url` (the DP Web site), `model_url`, and
102
+ the `mirror` block — `tiles_built` (title, chart type, the question each
103
+ tile asks) and `tiles_dropped` — plus any `warnings`. A `status` of
104
+ "partial" means the data and model exist but the liveboard or site did
105
+ not; say which and relay the error.
106
+
107
+ ## Rules
108
+
109
+ - Never paste the workbook's contents or the picture into the conversation;
110
+ they travel only as tool arguments. Summarise what was read instead.
111
+ - One dashboard per build: the workbook's first dashboard with charts on it
112
+ (or every worksheet when it has no dashboard). Say so if the workbook has
113
+ several, and offer a second build for another.
114
+ - Never promise exact fidelity. Use the fidelity and notes from
115
+ `inspect_dashboard` when you describe what the demo will show.
116
+ - If `build_demo_from_brief` returns `status` "busy", wait a few minutes and
117
+ try again; do not start a second build for the same prospect meanwhile.
118
+ - The build creates objects under the SE's account on the default cluster.
119
+ Pass `ts_url` only when the SE names a different instance.
liveboard_creator.py CHANGED
@@ -3676,6 +3676,7 @@ def create_liveboard_from_model_mcp(
3676
  model_columns: List[Dict] = None,
3677
  prompt_logger=None,
3678
  session_logger=None,
 
3679
  ) -> Dict:
3680
  """
3681
  Create and deploy a Liveboard using ThoughtSpot's Model Context Protocol (MCP)
@@ -3697,6 +3698,9 @@ def create_liveboard_from_model_mcp(
3697
  outliers: Optional list of demo outliers to create targeted visualizations
3698
  llm_model: LLM model for AI question generation
3699
  model_columns: List of column dicts with 'name' and 'type' from DDL
 
 
 
3700
 
3701
  Returns:
3702
  Deployment result dictionary with structure:
@@ -3801,40 +3805,50 @@ def create_liveboard_from_model_mcp(
3801
  model_guid=model_id,
3802
  )
3803
 
3804
- ai_result, _q_last_error = _generate_questions_with_retries(_q_generate, on_failure=_q_failed)
3805
- if ai_result is None:
3806
- # Still no skeleton board — but the caller (deploy_all) now
3807
- # ships the model and schema with a loud no_liveboard
3808
- # warning instead of aborting the run.
3809
- return {
3810
- 'success': False,
3811
- 'error': (
3812
- f'AI question generation failed {QUESTION_GENERATION_ATTEMPTS} times; '
3813
- f'not shipping a skeleton board. Last error: {_q_last_error[:400]}'
3814
- ),
3815
- }
3816
-
3817
- questions_to_ask = []
3818
- for q in ai_result.get('kpis', []):
3819
- questions_to_ask.append({'question': q, 'source': 'kpi'})
3820
- print(f" 📊 KPI: {q}", flush=True)
3821
- for q in ai_result.get('visualizations', []):
3822
- questions_to_ask.append({'question': q, 'source': 'viz'})
3823
- print(f" 🧠 Viz: {q}", flush=True)
 
 
3824
 
3825
- # Fallback: if AI returned nothing, ask MCP directly
3826
- if not questions_to_ask:
3827
- print(f"⚠️ AI returned no questions, falling back to MCP getRelevantQuestions", flush=True)
3828
- questions_result = await session.call_tool("getRelevantQuestions", {
3829
- "query": use_case.lower(),
3830
- "datasourceIds": [model_id]
3831
- })
3832
- questions_data = json.loads(questions_result.content[0].text)
3833
- for q in questions_data.get('questions', [])[:num_visualizations + 4]:
3834
- questions_to_ask.append({
3835
- 'question': q['question'],
3836
- 'source': 'mcp'
 
 
3837
  })
 
 
 
 
 
 
3838
 
3839
  if not questions_to_ask:
3840
  print(f"❌ No questions generated!", flush=True)
@@ -5643,7 +5657,8 @@ def enhance_mcp_liveboard(
5643
  add_layout: bool = True,
5644
  llm_model: str = None,
5645
  layout_strategy: str = "single_tab",
5646
- model_id: str = None
 
5647
  ) -> Dict:
5648
  """
5649
  Enhance an MCP-created liveboard with TML post-processing (Golden Demo style).
@@ -5669,7 +5684,12 @@ def enhance_mcp_liveboard(
5669
  apply_brand_colors: Whether to apply brand color styling
5670
  add_layout: Whether to add proper tab/tile layout
5671
  layout_strategy: "single_tab" preserves existing app behavior;
5672
- "golden_two_tab" applies the lab's golden-demo-inspired layout.
 
 
 
 
 
5673
  model_id: GUID of the model the liveboard is built on — used to
5674
  validate search queries (cross-fact dates, granularity).
5675
  Validation is skipped if not provided.
@@ -5768,6 +5788,20 @@ def enhance_mcp_liveboard(
5768
  else:
5769
  print(f" [OK] All measure aggregations valid", flush=True)
5770
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5771
  # Step 2: Classify visualizations by type and purpose
5772
  kpi_vizs = [] # KPI charts (big numbers, sparklines)
5773
  trend_vizs = [] # Line/area charts (time series)
@@ -5833,7 +5867,7 @@ def enhance_mcp_liveboard(
5833
  print(f" ⚠️ Only note tiles found ({len(note_vizs)}) — MCP may have returned no chart answers. Keeping note tiles to preserve liveboard content.", flush=True)
5834
 
5835
  # Step 3: Add Groups - simplified: just KPI section, rest ungrouped
5836
- if add_groups:
5837
  print(f" Adding Groups ({'golden two-tab later' if use_golden_two_tab else 'simplified'})...", flush=True)
5838
 
5839
  groups = []
@@ -5861,7 +5895,7 @@ def enhance_mcp_liveboard(
5861
  # MCP/Spotter often returns LINE charts for questions like "revenue weekly",
5862
  # and sometimes returns COLUMN/BAR. Single-metric time-grain charts are KPI
5863
  # cards; dimensional breakdowns ("by X") stay as analysis charts.
5864
- if fix_kpis and len(kpi_vizs) == 0:
5865
  print(f" Converting KPI-style time-grain charts to KPIs...", flush=True)
5866
  converted_count = 0
5867
  kpi_candidates = []
@@ -5908,7 +5942,7 @@ def enhance_mcp_liveboard(
5908
 
5909
  # If KPI conversion happened after the first grouping pass, ensure the
5910
  # Key Metrics group exists before layout is built.
5911
- if add_groups and not use_golden_two_tab and kpi_vizs:
5912
  groups = liveboard_tml.get('liveboard', {}).setdefault('groups', [])
5913
  key_metrics_group = next((g for g in groups if g.get('name') == 'Key Metrics'), None)
5914
  if key_metrics_group:
@@ -5973,88 +6007,89 @@ def enhance_mcp_liveboard(
5973
 
5974
  # Step 4b: Convert single-dimension categorical breakdowns to donut charts with colors.
5975
  # Multi-dimension breakdowns ("by X and Y") are converted to STACKED_COLUMN for variety.
5976
- print(f" Converting categorical charts to donuts/stacked...", flush=True)
5977
- donuts_converted = 0
5978
- stacked_converted = 0
5979
-
5980
- # Define vibrant donut colors (from Golden Demo)
5981
- DONUT_COLORS = [
5982
- '#40C1C0', # Teal
5983
- '#9c6ade', # Purple
5984
- '#FF8142', # Orange
5985
- '#4CAF50', # Green
5986
- '#2196F3', # Blue
5987
- '#E91E63', # Pink
5988
- '#FFC107', # Amber
5989
- '#00BCD4', # Cyan
5990
- ]
 
5991
 
5992
- # Single-dimension breakdown keywords (no "and" following them)
5993
- _SINGLE_DIM_KEYWORDS = [
5994
- 'by channel', 'by category', 'by segment', 'by region', 'by type',
5995
- 'by audience', 'by campaign', 'by product', 'by brand', 'by store',
5996
- 'distribution', 'mix', 'split', 'breakdown',
5997
- ]
5998
- # Charts convertible to donut — include LINE because MCP often creates LINE for categorical breakdowns
5999
- _DONUT_CONVERTIBLE = {'PIE', 'BAR', 'COLUMN', 'STACKED_COLUMN', 'LINE', 'AREA'}
6000
 
6001
- for viz in visualizations:
6002
- viz_id = viz.get('id')
6003
- answer = viz.get('answer', {})
6004
- viz_name = answer.get('name', '').lower()
6005
- chart = answer.get('chart', {})
6006
- chart_type = chart.get('type', '').upper()
6007
-
6008
- # Multi-dimension breakdown: "by X and Y" → STACKED_COLUMN for variety
6009
- has_multi_dim = (' by ' in viz_name and ' and ' in viz_name.split(' by ', 1)[-1])
6010
- if has_multi_dim and chart_type in {'BAR', 'COLUMN', 'LINE', 'AREA'}:
6011
- chart['type'] = 'STACKED_COLUMN'
6012
- stacked_converted += 1
6013
- print(f" Converted '{answer.get('name', viz_id)}' to STACKED_COLUMN (multi-dim)", flush=True)
6014
- continue
6015
 
6016
- # Single-dimension breakdown → donut
6017
- is_single_breakdown = any(kw in viz_name for kw in _SINGLE_DIM_KEYWORDS)
6018
- # Exclude multi-dim cases (already handled above) and exclude pure time-series
6019
- has_time_suffix = viz_name.rstrip().endswith(('weekly', 'monthly', 'daily', 'quarterly', 'yearly'))
6020
- if is_single_breakdown and not has_multi_dim and not has_time_suffix and chart_type in _DONUT_CONVERTIBLE:
6021
- chart['type'] = 'PIE'
 
6022
 
6023
- client_state_str = chart.get('client_state_v2', '{}')
6024
- try:
6025
- client_state = json.loads(client_state_str)
6026
- except Exception:
6027
- client_state = {}
 
6028
 
6029
- client_state.setdefault('version', 'V4DOT2')
6030
- client_state.setdefault('chartProperties', {})
6031
- client_state['chartProperties']['chartSpecific'] = {
6032
- 'customProps': json.dumps({
6033
- 'pieStyle': 'RING',
6034
- 'showLabel': True,
6035
- 'showValue': True,
6036
- 'showPercent': True
6037
- })
6038
- }
6039
- client_state['chartProperties']['chartLevelColorConfig'] = {
6040
- 'colorPalette': {'colors': DONUT_COLORS}
6041
- }
6042
 
6043
- chart['client_state_v2'] = json.dumps(client_state)
6044
- donuts_converted += 1
6045
- print(f" Converted '{answer.get('name', viz_id)}' to donut chart", flush=True)
6046
-
6047
- if donuts_converted > 0:
6048
- enhancements_applied.append(f"Converted {donuts_converted} charts to donuts")
6049
- print(f" [OK] Converted {donuts_converted} charts to donuts", flush=True)
6050
- if stacked_converted > 0:
6051
- enhancements_applied.append(f"Converted {stacked_converted} charts to stacked column")
6052
- print(f" [OK] Converted {stacked_converted} charts to stacked column", flush=True)
6053
-
6054
- palette_count = _apply_varied_chart_series_colors(visualizations)
6055
- if palette_count > 0:
6056
- enhancements_applied.append(f"Applied varied palettes to {palette_count} chart(s)")
6057
- print(f" [OK] Applied varied palettes to {palette_count} chart(s)", flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6058
 
6059
  # Step 5: Apply brand colors and styling (Golden Demo style)
6060
  if apply_brand_colors:
@@ -6179,6 +6214,14 @@ def enhance_mcp_liveboard(
6179
  visualizations = liveboard_tml.get('liveboard', {}).get('visualizations', [])
6180
  enhancements_applied.extend(golden_changes)
6181
  print(f" [OK] Applied golden two-tab layout", flush=True)
 
 
 
 
 
 
 
 
6182
  else:
6183
  groups = liveboard_tml.get('liveboard', {}).get('groups', [])
6184
  all_viz_ids = kpi_vizs + trend_vizs + bar_vizs + geo_vizs + table_vizs + other_vizs
@@ -6270,7 +6313,7 @@ def enhance_mcp_liveboard(
6270
  if name:
6271
  _viz_name_to_ids.setdefault(name, []).append(viz.get('id'))
6272
 
6273
- if _viz_name_to_ids:
6274
  _unique_names = list(_viz_name_to_ids.keys())
6275
  _title_mapping = _humanize_viz_titles(
6276
  viz_names=_unique_names,
@@ -6299,7 +6342,7 @@ def enhance_mcp_liveboard(
6299
  target_filter_count=4,
6300
  # boone's ruling 2026-09-02 (closes ISS-5): the Demo Guide tile is
6301
  # MOVED to the third page, not removed and not gated off.
6302
- include_demo_notes_tile=True,
6303
  # Global filters must actually filter the board (ISS-13): a chip
6304
  # stays when it reaches MIN_FILTER_TILE_COVERAGE of the measure
6305
  # tiles. None when the model's join graph could not be read.
 
3676
  model_columns: List[Dict] = None,
3677
  prompt_logger=None,
3678
  session_logger=None,
3679
+ explicit_questions: Optional[List[Dict]] = None,
3680
  ) -> Dict:
3681
  """
3682
  Create and deploy a Liveboard using ThoughtSpot's Model Context Protocol (MCP)
 
3698
  outliers: Optional list of demo outliers to create targeted visualizations
3699
  llm_model: LLM model for AI question generation
3700
  model_columns: List of column dicts with 'name' and 'type' from DDL
3701
+ explicit_questions: Mirror mode (DP Migrator): [{question, source, ...}]
3702
+ asked AS GIVEN, one per source sheet, instead of the AI-generated
3703
+ set. None = generate as usual.
3704
 
3705
  Returns:
3706
  Deployment result dictionary with structure:
 
3805
  model_guid=model_id,
3806
  )
3807
 
3808
+ if explicit_questions:
3809
+ # Mirror mode (DP Migrator, boone 2026-10-28): one question per source
3810
+ # sheet, authored from the prospect's dashboard, asked as given.
3811
+ questions_to_ask = [{'question': q['question'], 'source': q.get('source', 'mirror')}
3812
+ for q in explicit_questions if q.get('question')]
3813
+ mode_label = "mirror"
3814
+ print(f"🪞 Mirror mode: {len(questions_to_ask)} question(s) from the source dashboard", flush=True)
3815
+ for q in questions_to_ask:
3816
+ print(f" 🪞 {q['question']}", flush=True)
3817
+ else:
3818
+ ai_result, _q_last_error = _generate_questions_with_retries(_q_generate, on_failure=_q_failed)
3819
+ if ai_result is None:
3820
+ # Still no skeleton board — but the caller (deploy_all) now
3821
+ # ships the model and schema with a loud no_liveboard
3822
+ # warning instead of aborting the run.
3823
+ return {
3824
+ 'success': False,
3825
+ 'error': (
3826
+ f'AI question generation failed {QUESTION_GENERATION_ATTEMPTS} times; '
3827
+ f'not shipping a skeleton board. Last error: {_q_last_error[:400]}'
3828
+ ),
3829
+ }
3830
 
3831
+ questions_to_ask = []
3832
+ for q in ai_result.get('kpis', []):
3833
+ questions_to_ask.append({'question': q, 'source': 'kpi'})
3834
+ print(f" 📊 KPI: {q}", flush=True)
3835
+ for q in ai_result.get('visualizations', []):
3836
+ questions_to_ask.append({'question': q, 'source': 'viz'})
3837
+ print(f" 🧠 Viz: {q}", flush=True)
3838
+
3839
+ # Fallback: if AI returned nothing, ask MCP directly
3840
+ if not questions_to_ask:
3841
+ print(f"⚠️ AI returned no questions, falling back to MCP getRelevantQuestions", flush=True)
3842
+ questions_result = await session.call_tool("getRelevantQuestions", {
3843
+ "query": use_case.lower(),
3844
+ "datasourceIds": [model_id]
3845
  })
3846
+ questions_data = json.loads(questions_result.content[0].text)
3847
+ for q in questions_data.get('questions', [])[:num_visualizations + 4]:
3848
+ questions_to_ask.append({
3849
+ 'question': q['question'],
3850
+ 'source': 'mcp'
3851
+ })
3852
 
3853
  if not questions_to_ask:
3854
  print(f"❌ No questions generated!", flush=True)
 
5657
  add_layout: bool = True,
5658
  llm_model: str = None,
5659
  layout_strategy: str = "single_tab",
5660
+ model_id: str = None,
5661
+ mirror: Optional[Dict] = None,
5662
  ) -> Dict:
5663
  """
5664
  Enhance an MCP-created liveboard with TML post-processing (Golden Demo style).
 
5684
  apply_brand_colors: Whether to apply brand color styling
5685
  add_layout: Whether to add proper tab/tile layout
5686
  layout_strategy: "single_tab" preserves existing app behavior;
5687
+ "golden_two_tab" applies the lab's golden-demo-inspired layout;
5688
+ "mirror" copies the prospect's dashboard (DP Migrator): chart
5689
+ types, titles and arrangement come from `mirror`, and the golden
5690
+ regroup, KPI/donut conversions, Muze and title rewriting are off.
5691
+ mirror: the MirrorSpec as a dict plus its 'tiles' (the questions asked,
5692
+ from demoprep_app.migration.questions). Required for "mirror".
5693
  model_id: GUID of the model the liveboard is built on — used to
5694
  validate search queries (cross-fact dates, granularity).
5695
  Validation is skipped if not provided.
 
5788
  else:
5789
  print(f" [OK] All measure aggregations valid", flush=True)
5790
 
5791
+ # Mirror mode (DP Migrator, boone 2026-10-28): the board copies the
5792
+ # prospect's dashboard. Chart types and titles come from the source
5793
+ # sheets and are set BEFORE classification, so every later pass (KPI
5794
+ # fixes, colours, layout) sees the final types.
5795
+ use_mirror = layout_strategy == "mirror" and bool(mirror)
5796
+ mirror_tiles = list((mirror or {}).get("tiles") or []) if use_mirror else []
5797
+ mirror_mapping: Dict = {}
5798
+ if use_mirror:
5799
+ from demoprep_app.liveboards.mirror_layout import retype_for_mirror
5800
+ _mirror_changes, mirror_mapping = retype_for_mirror(visualizations, mirror_tiles)
5801
+ for _c in _mirror_changes:
5802
+ print(f" [mirror] {_c}", flush=True)
5803
+ enhancements_applied.extend(_mirror_changes)
5804
+
5805
  # Step 2: Classify visualizations by type and purpose
5806
  kpi_vizs = [] # KPI charts (big numbers, sparklines)
5807
  trend_vizs = [] # Line/area charts (time series)
 
5867
  print(f" ⚠️ Only note tiles found ({len(note_vizs)}) — MCP may have returned no chart answers. Keeping note tiles to preserve liveboard content.", flush=True)
5868
 
5869
  # Step 3: Add Groups - simplified: just KPI section, rest ungrouped
5870
+ if add_groups and not use_mirror:
5871
  print(f" Adding Groups ({'golden two-tab later' if use_golden_two_tab else 'simplified'})...", flush=True)
5872
 
5873
  groups = []
 
5895
  # MCP/Spotter often returns LINE charts for questions like "revenue weekly",
5896
  # and sometimes returns COLUMN/BAR. Single-metric time-grain charts are KPI
5897
  # cards; dimensional breakdowns ("by X") stay as analysis charts.
5898
+ if fix_kpis and len(kpi_vizs) == 0 and not use_mirror:
5899
  print(f" Converting KPI-style time-grain charts to KPIs...", flush=True)
5900
  converted_count = 0
5901
  kpi_candidates = []
 
5942
 
5943
  # If KPI conversion happened after the first grouping pass, ensure the
5944
  # Key Metrics group exists before layout is built.
5945
+ if add_groups and not use_golden_two_tab and not use_mirror and kpi_vizs:
5946
  groups = liveboard_tml.get('liveboard', {}).setdefault('groups', [])
5947
  key_metrics_group = next((g for g in groups if g.get('name') == 'Key Metrics'), None)
5948
  if key_metrics_group:
 
6007
 
6008
  # Step 4b: Convert single-dimension categorical breakdowns to donut charts with colors.
6009
  # Multi-dimension breakdowns ("by X and Y") are converted to STACKED_COLUMN for variety.
6010
+ if not use_mirror: # mirror keeps the source chart types
6011
+ print(f" Converting categorical charts to donuts/stacked...", flush=True)
6012
+ donuts_converted = 0
6013
+ stacked_converted = 0
6014
+
6015
+ # Define vibrant donut colors (from Golden Demo)
6016
+ DONUT_COLORS = [
6017
+ '#40C1C0', # Teal
6018
+ '#9c6ade', # Purple
6019
+ '#FF8142', # Orange
6020
+ '#4CAF50', # Green
6021
+ '#2196F3', # Blue
6022
+ '#E91E63', # Pink
6023
+ '#FFC107', # Amber
6024
+ '#00BCD4', # Cyan
6025
+ ]
6026
 
6027
+ # Single-dimension breakdown keywords (no "and" following them)
6028
+ _SINGLE_DIM_KEYWORDS = [
6029
+ 'by channel', 'by category', 'by segment', 'by region', 'by type',
6030
+ 'by audience', 'by campaign', 'by product', 'by brand', 'by store',
6031
+ 'distribution', 'mix', 'split', 'breakdown',
6032
+ ]
6033
+ # Charts convertible to donut — include LINE because MCP often creates LINE for categorical breakdowns
6034
+ _DONUT_CONVERTIBLE = {'PIE', 'BAR', 'COLUMN', 'STACKED_COLUMN', 'LINE', 'AREA'}
6035
 
6036
+ for viz in visualizations:
6037
+ viz_id = viz.get('id')
6038
+ answer = viz.get('answer', {})
6039
+ viz_name = answer.get('name', '').lower()
6040
+ chart = answer.get('chart', {})
6041
+ chart_type = chart.get('type', '').upper()
 
 
 
 
 
 
 
 
6042
 
6043
+ # Multi-dimension breakdown: "by X and Y" → STACKED_COLUMN for variety
6044
+ has_multi_dim = (' by ' in viz_name and ' and ' in viz_name.split(' by ', 1)[-1])
6045
+ if has_multi_dim and chart_type in {'BAR', 'COLUMN', 'LINE', 'AREA'}:
6046
+ chart['type'] = 'STACKED_COLUMN'
6047
+ stacked_converted += 1
6048
+ print(f" Converted '{answer.get('name', viz_id)}' to STACKED_COLUMN (multi-dim)", flush=True)
6049
+ continue
6050
 
6051
+ # Single-dimension breakdown → donut
6052
+ is_single_breakdown = any(kw in viz_name for kw in _SINGLE_DIM_KEYWORDS)
6053
+ # Exclude multi-dim cases (already handled above) and exclude pure time-series
6054
+ has_time_suffix = viz_name.rstrip().endswith(('weekly', 'monthly', 'daily', 'quarterly', 'yearly'))
6055
+ if is_single_breakdown and not has_multi_dim and not has_time_suffix and chart_type in _DONUT_CONVERTIBLE:
6056
+ chart['type'] = 'PIE'
6057
 
6058
+ client_state_str = chart.get('client_state_v2', '{}')
6059
+ try:
6060
+ client_state = json.loads(client_state_str)
6061
+ except Exception:
6062
+ client_state = {}
 
 
 
 
 
 
 
 
6063
 
6064
+ client_state.setdefault('version', 'V4DOT2')
6065
+ client_state.setdefault('chartProperties', {})
6066
+ client_state['chartProperties']['chartSpecific'] = {
6067
+ 'customProps': json.dumps({
6068
+ 'pieStyle': 'RING',
6069
+ 'showLabel': True,
6070
+ 'showValue': True,
6071
+ 'showPercent': True
6072
+ })
6073
+ }
6074
+ client_state['chartProperties']['chartLevelColorConfig'] = {
6075
+ 'colorPalette': {'colors': DONUT_COLORS}
6076
+ }
6077
+
6078
+ chart['client_state_v2'] = json.dumps(client_state)
6079
+ donuts_converted += 1
6080
+ print(f" Converted '{answer.get('name', viz_id)}' to donut chart", flush=True)
6081
+
6082
+ if donuts_converted > 0:
6083
+ enhancements_applied.append(f"Converted {donuts_converted} charts to donuts")
6084
+ print(f" [OK] Converted {donuts_converted} charts to donuts", flush=True)
6085
+ if stacked_converted > 0:
6086
+ enhancements_applied.append(f"Converted {stacked_converted} charts to stacked column")
6087
+ print(f" [OK] Converted {stacked_converted} charts to stacked column", flush=True)
6088
+
6089
+ palette_count = _apply_varied_chart_series_colors(visualizations)
6090
+ if palette_count > 0:
6091
+ enhancements_applied.append(f"Applied varied palettes to {palette_count} chart(s)")
6092
+ print(f" [OK] Applied varied palettes to {palette_count} chart(s)", flush=True)
6093
 
6094
  # Step 5: Apply brand colors and styling (Golden Demo style)
6095
  if apply_brand_colors:
 
6214
  visualizations = liveboard_tml.get('liveboard', {}).get('visualizations', [])
6215
  enhancements_applied.extend(golden_changes)
6216
  print(f" [OK] Applied golden two-tab layout", flush=True)
6217
+ elif use_mirror:
6218
+ from demoprep_app.liveboards.mirror_layout import apply_mirror_layout
6219
+ liveboard_tml, _layout_changes = apply_mirror_layout(
6220
+ liveboard_tml, mirror, mirror_tiles, mirror_mapping)
6221
+ visualizations = liveboard_tml.get('liveboard', {}).get('visualizations', [])
6222
+ enhancements_applied.extend(_layout_changes)
6223
+ for _c in _layout_changes:
6224
+ print(f" [OK] {_c}", flush=True)
6225
  else:
6226
  groups = liveboard_tml.get('liveboard', {}).get('groups', [])
6227
  all_viz_ids = kpi_vizs + trend_vizs + bar_vizs + geo_vizs + table_vizs + other_vizs
 
6313
  if name:
6314
  _viz_name_to_ids.setdefault(name, []).append(viz.get('id'))
6315
 
6316
+ if _viz_name_to_ids and not use_mirror: # mirror keeps the source titles
6317
  _unique_names = list(_viz_name_to_ids.keys())
6318
  _title_mapping = _humanize_viz_titles(
6319
  viz_names=_unique_names,
 
6342
  target_filter_count=4,
6343
  # boone's ruling 2026-09-02 (closes ISS-5): the Demo Guide tile is
6344
  # MOVED to the third page, not removed and not gated off.
6345
+ include_demo_notes_tile=not use_mirror, # one tab in mirror mode; no guide tile
6346
  # Global filters must actually filter the board (ISS-13): a chip
6347
  # stays when it reaches MIN_FILTER_TILE_COVERAGE of the measure
6348
  # tiles. None when the model's join graph could not be read.
mcp_server.py CHANGED
@@ -830,6 +830,7 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
830
  brand: dict | None = None,
831
  custom_mcp_demo: bool | str = False,
832
  header_tiles: dict | None = None,
 
833
  *, schema_style: str, homepage_screenshot: str = "",
834
  build_settings: BuildSettings | None = None) -> dict:
835
  """Drive the controller headlessly (runs in a BACKGROUND THREAD; the caller has
@@ -847,6 +848,9 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
847
  # blank = not asked = OFF, and an unreadable value stays OFF with a warning saying so.
848
  _custom_mcp_notes: list[str] = []
849
  custom_mcp_on = _read_yes_no(custom_mcp_demo, "custom_mcp_demo", False, _custom_mcp_notes)
 
 
 
850
 
851
  # Full-brief capture + durable persist — ONCE per accepted build (this only runs for
852
  # builds that actually got a slot, so busy-retries of a queued build never duplicate
@@ -930,6 +934,9 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
930
  # custom_mcp_demo: the field exists only when the caller asked for it, so a
931
  # build that did not ask returns, reports and stores exactly what it did before.
932
  out["warnings"].extend(_custom_mcp_notes)
 
 
 
933
  # Which home page the portal opens on (cloned | template), when one was built.
934
  if (portal or {}).get("home_page"):
935
  out["home_page"] = portal["home_page"]
@@ -941,7 +948,7 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
941
  out["warnings"].append(
942
  "custom_mcp_demo: requested, but no section was generated because "
943
  "the build did not reach a successful ThoughtSpot deploy")
944
- _opt_in = {k: out[k] for k in ("custom_mcp_demo_section",) if k in out}
945
  # Mark the run's Slack root: check on full success, x otherwise (partial
946
  # or failed), and @-mention the alert users in-thread on failure. Reacts
947
  # once; the controller's own "Complete" post may have beaten us to it.
@@ -1034,6 +1041,21 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
1034
 
1035
  controller = None
1036
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1037
  # (i) controller — acting user = the per-build owner (per-instance; no
1038
  # process-global write, so concurrent builds never race on the owner).
1039
  # With a settings document the controller skips its Supabase settings
@@ -1104,6 +1126,9 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
1104
  controller.generic_use_case_context = ""
1105
  # The prospect's brand colours (ISS-19); None unless the caller asked.
1106
  controller.brand_theme = (brand or {}).get("theme")
 
 
 
1107
 
1108
  # (iii-b) Run-scoped session logging. MCP builds bypass process_chat_message,
1109
  # which is where App/Chat runs create their loggers — without this, MCP runs
@@ -1420,6 +1445,14 @@ def _call_payload(frame) -> dict:
1420
  # key (the same key a clone made from it carries), not the image.
1421
  from demoprep_app.portal.login_clone.sources import describe_screenshot_arg
1422
  out[name] = describe_screenshot_arg(v)
 
 
 
 
 
 
 
 
1423
  else:
1424
  out[name] = v
1425
  return out
@@ -1491,6 +1524,88 @@ async def check_homepage(url: str) -> dict:
1491
  return out
1492
 
1493
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1494
  @mcp.tool()
1495
  def build_demo_from_brief(
1496
  brief: str,
@@ -1527,6 +1642,10 @@ def build_demo_from_brief(
1527
  # An INPUT, not a feature flag: the SE's picture of the prospect's home
1528
  # page, used for the cloned login before the live site (2026-10-21).
1529
  homepage_screenshot: str = "",
 
 
 
 
1530
  # The SE's settings document (boone 2026-10-22). Optional: without it the
1531
  # build reads the SE's settings from Supabase as before.
1532
  settings: dict | None = None,
@@ -1661,6 +1780,23 @@ def build_demo_from_brief(
1661
  building whether a screenshot is needed, call
1662
  check_homepage(url) first and send one when it says
1663
  needs_screenshot.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1664
 
1665
  Not settable here: askMode (it breaks the upstream tab prune) and the home
1666
  page (its copy is written from a brand screenshot). For those, call
@@ -1678,6 +1814,9 @@ def build_demo_from_brief(
1678
  the cluster (no account there, or the cluster is in standby) the call
1679
  fails at once with the reason and nothing is built. A brand theme that
1680
  cannot be used is also returned at once as `warnings` on the "started" reply.
 
 
 
1681
  """
1682
  _log_hit(
1683
  f"CALL build_demo_from_brief company={company_name!r} use_case={use_case!r} "
@@ -1757,6 +1896,34 @@ def build_demo_from_brief(
1757
  # 2026-10-20) — brand["derive"] tells _run_build to do that. Bad input
1758
  # never fails the build — None plus warnings.
1759
  from demoprep_app.liveboards.brand_theme import BrandTheme, brand_request, portal_brand_options
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1760
  brand = brand_request(apply_brand_theme, brand_theme, company_url)
1761
  _portal_brand, _portal_brand_warnings = portal_brand_options(
1762
  BrandTheme.from_dict(brand["theme"]) if brand["theme"] else None)
@@ -1822,6 +1989,7 @@ def build_demo_from_brief(
1822
  header_tiles=header_tiles,
1823
  schema_style=schema_style,
1824
  homepage_screenshot=_shot,
 
1825
  build_settings=build_settings)
1826
  finally:
1827
  _build_sem.release()
@@ -1842,6 +2010,11 @@ def build_demo_from_brief(
1842
  started["warnings"] = list(_tile_notes)
1843
  if _shot_warning:
1844
  started.setdefault("warnings", []).append(_shot_warning)
 
 
 
 
 
1845
  return started
1846
 
1847
 
 
830
  brand: dict | None = None,
831
  custom_mcp_demo: bool | str = False,
832
  header_tiles: dict | None = None,
833
+ mirror: dict | None = None, mirror_image: str = "",
834
  *, schema_style: str, homepage_screenshot: str = "",
835
  build_settings: BuildSettings | None = None) -> dict:
836
  """Drive the controller headlessly (runs in a BACKGROUND THREAD; the caller has
 
848
  # blank = not asked = OFF, and an unreadable value stays OFF with a warning saying so.
849
  _custom_mcp_notes: list[str] = []
850
  custom_mcp_on = _read_yes_no(custom_mcp_demo, "custom_mcp_demo", False, _custom_mcp_notes)
851
+ # DP Migrator: what was read from the source dashboard (set below once the
852
+ # spec is known); result() merges the deployer's tiles-built/dropped into it.
853
+ _mirror_summary: dict = {}
854
 
855
  # Full-brief capture + durable persist — ONCE per accepted build (this only runs for
856
  # builds that actually got a slot, so busy-retries of a queued build never duplicate
 
934
  # custom_mcp_demo: the field exists only when the caller asked for it, so a
935
  # build that did not ask returns, reports and stores exactly what it did before.
936
  out["warnings"].extend(_custom_mcp_notes)
937
+ # DP Migrator: present only when a source dashboard was sent.
938
+ if _mirror_summary or dc.get("mirror"):
939
+ out["mirror"] = {**_mirror_summary, **(dc.get("mirror") or {})}
940
  # Which home page the portal opens on (cloned | template), when one was built.
941
  if (portal or {}).get("home_page"):
942
  out["home_page"] = portal["home_page"]
 
948
  out["warnings"].append(
949
  "custom_mcp_demo: requested, but no section was generated because "
950
  "the build did not reach a successful ThoughtSpot deploy")
951
+ _opt_in = {k: out[k] for k in ("custom_mcp_demo_section", "mirror") if k in out}
952
  # Mark the run's Slack root: check on full success, x otherwise (partial
953
  # or failed), and @-mention the alert users in-thread on failure. Reacts
954
  # once; the controller's own "Complete" post may have beaten us to it.
 
1041
 
1042
  controller = None
1043
  try:
1044
+ # DP Migrator (boone 2026-10-28): a picture of the prospect's dashboard is
1045
+ # read HERE, on the build thread (a vision call takes seconds), and an
1046
+ # unreadable one fails the build now — never a silent generic demo.
1047
+ if mirror_image and not mirror:
1048
+ from demoprep_app.migration.image_spec import image_to_spec
1049
+ _set_progress(run_id, phase="reading the source dashboard picture", detail="")
1050
+ mirror = image_to_spec(mirror_image).to_dict()
1051
+ if mirror:
1052
+ from demoprep_app.migration.sources import spec_result
1053
+ from demoprep_app.migration.spec import MirrorSpec
1054
+ _mirror_summary = spec_result(MirrorSpec.from_dict(mirror))
1055
+ _log_hit(f"MIRROR run_id={run_id} kind={_mirror_summary.get('source_kind')!r} "
1056
+ f"name={str(_mirror_summary.get('name'))[:80]!r} sheets={len(_mirror_summary.get('sheets') or [])} "
1057
+ f"tiles={len(_mirror_summary.get('tiles') or [])} carried={_mirror_summary.get('carried')}")
1058
+
1059
  # (i) controller — acting user = the per-build owner (per-instance; no
1060
  # process-global write, so concurrent builds never race on the owner).
1061
  # With a settings document the controller skips its Supabase settings
 
1126
  controller.generic_use_case_context = ""
1127
  # The prospect's brand colours (ISS-19); None unless the caller asked.
1128
  controller.brand_theme = (brand or {}).get("theme")
1129
+ # DP Migrator: binds the blueprint's vocabulary and switches the liveboard
1130
+ # to the mirror layout (chat_interface.run_ddl_creation / deploy_all).
1131
+ controller.mirror_spec = mirror
1132
 
1133
  # (iii-b) Run-scoped session logging. MCP builds bypass process_chat_message,
1134
  # which is where App/Chat runs create their loggers — without this, MCP runs
 
1445
  # key (the same key a clone made from it carries), not the image.
1446
  from demoprep_app.portal.login_clone.sources import describe_screenshot_arg
1447
  out[name] = describe_screenshot_arg(v)
1448
+ elif name == "source_dashboard" and isinstance(v, str):
1449
+ # A workbook names the prospect's tables and fields: log its kind,
1450
+ # size and hash, not the content.
1451
+ from demoprep_app.migration.sources import describe_source_arg
1452
+ out[name] = describe_source_arg(v)
1453
+ elif name == "source_dashboard_image" and isinstance(v, str):
1454
+ from demoprep_app.portal.login_clone.sources import describe_screenshot_arg
1455
+ out[name] = describe_screenshot_arg(v)
1456
  else:
1457
  out[name] = v
1458
  return out
 
1524
  return out
1525
 
1526
 
1527
+ @mcp.tool()
1528
+ async def inspect_dashboard(source_dashboard: str = "", source_dashboard_image: str = "") -> dict:
1529
+ """DP Migrator: read the prospect's existing dashboard and say what a build
1530
+ would carry over. Call BEFORE build_demo_from_brief when the SE has sent a
1531
+ Tableau workbook or a dashboard picture. Starts nothing, takes no build slot,
1532
+ changes nothing anywhere. A workbook takes under a second; a picture takes a
1533
+ vision call (~10-30 s).
1534
+
1535
+ Args:
1536
+ source_dashboard: the Tableau workbook: the .twb file's XML text as is, or
1537
+ a .twbx/.twb as a base64 data URI. Only the .twb inside a .twbx is read;
1538
+ extracts (customer data) are never opened.
1539
+ source_dashboard_image: a picture of the dashboard instead: an https image
1540
+ URL or a base64 data URI (png, jpeg or webp, up to 10 MB). When both are
1541
+ sent the workbook is used.
1542
+
1543
+ Returns a dict:
1544
+ ok: true when something was read.
1545
+ name: the dashboard (or workbook) title.
1546
+ source_kind: "tableau_twb" or "image".
1547
+ confidence: 0..1 (a workbook is 1.0; a picture is what the model is sure of).
1548
+ measures, dimensions, dates: the fields read, with aggregation and formulas.
1549
+ tables, joins: the source tables and how they join (workbook only).
1550
+ sheets: one per chart: title, chart_type (the ThoughtSpot type it becomes),
1551
+ measures, dimensions, date, fidelity ("exact" | "approximated" |
1552
+ "dropped") and notes saying what changed or why it was dropped.
1553
+ tiles: the sheets that will become tiles, in reading order (the first
1554
+ dashboard's sheets, else every sheet).
1555
+ carried: counts of exact / approximated / dropped.
1556
+ parameters_not_carried, warnings: what the build will not do.
1557
+ message_for_se: one sentence to relay, e.g. "Read 'Sales Overview': 6 of 7
1558
+ charts carry over (1 approximated: dual axis as a combo), 1 dropped."
1559
+ error: when ok is false, why (and nothing else is set).
1560
+
1561
+ WHAT TO DO NEXT: show the SE message_for_se and the sheets table; if they are
1562
+ happy, call build_demo_from_brief with the SAME source_dashboard /
1563
+ source_dashboard_image (plus the brief, company and the required ids).
1564
+ Never raises; an unreadable source is ok false with the reason.
1565
+ """
1566
+ import asyncio
1567
+ from demoprep_app.migration.sources import load_source_dashboard, spec_result
1568
+ from demoprep_app.migration.spec import MirrorError
1569
+
1570
+ _log_hit(f"CALL inspect_dashboard file={describe_source_arg_safe(source_dashboard)} "
1571
+ f"image={'yes' if (source_dashboard_image or '').strip() else 'no'}")
1572
+ try:
1573
+ spec, notes = await asyncio.to_thread(
1574
+ load_source_dashboard,
1575
+ source_dashboard if isinstance(source_dashboard, str) else "",
1576
+ source_dashboard_image if isinstance(source_dashboard_image, str) else "")
1577
+ except MirrorError as e:
1578
+ return {"ok": False, "error": str(e)}
1579
+ except Exception as e: # noqa: BLE001 - never raises to the caller
1580
+ return {"ok": False, "error": f"the dashboard could not be read: {type(e).__name__}: {str(e)[:200]}"}
1581
+ if spec is None:
1582
+ return {"ok": False, "error": "send source_dashboard (the .twb) or source_dashboard_image (a picture)"}
1583
+ out = spec_result(spec, extra_warnings=notes)
1584
+ counts = out["carried"]
1585
+ total = sum(counts.values())
1586
+ kept = counts["exact"] + counts["approximated"]
1587
+ parts = [f"Read \"{spec.name}\": {kept} of {total} chart(s) carry over"]
1588
+ if counts["approximated"]:
1589
+ parts.append(f"({counts['approximated']} approximated)")
1590
+ if counts["dropped"]:
1591
+ parts.append(f", {counts['dropped']} dropped")
1592
+ if spec.source_kind != "tableau_twb":
1593
+ parts.append(f"; read from a picture, confidence {out['confidence']}")
1594
+ out["message_for_se"] = " ".join(parts).replace(" ,", ",") + "."
1595
+ out["ok"] = True
1596
+ _log_hit(f"INSPECT-DASHBOARD kind={spec.source_kind} name={spec.name[:80]!r} sheets={total} "
1597
+ f"carried={counts} confidence={out['confidence']}")
1598
+ return out
1599
+
1600
+
1601
+ def describe_source_arg_safe(value) -> str:
1602
+ """For the call log: the source argument's kind and size, never its content."""
1603
+ if not isinstance(value, str) or not value.strip():
1604
+ return "none"
1605
+ from demoprep_app.migration.sources import describe_source_arg
1606
+ return describe_source_arg(value)
1607
+
1608
+
1609
  @mcp.tool()
1610
  def build_demo_from_brief(
1611
  brief: str,
 
1642
  # An INPUT, not a feature flag: the SE's picture of the prospect's home
1643
  # page, used for the cloned login before the live site (2026-10-21).
1644
  homepage_screenshot: str = "",
1645
+ # DP Migrator (boone 2026-10-28): the prospect's existing dashboard, rebuilt
1646
+ # as this demo's liveboard. The Tableau workbook, or a picture of it.
1647
+ source_dashboard: str = "",
1648
+ source_dashboard_image: str = "",
1649
  # The SE's settings document (boone 2026-10-22). Optional: without it the
1650
  # build reads the SE's settings from Supabase as before.
1651
  settings: dict | None = None,
 
1780
  building whether a screenshot is needed, call
1781
  check_homepage(url) first and send one when it says
1782
  needs_screenshot.
1783
+ source_dashboard: Optional (DP Migrator). The prospect's Tableau workbook:
1784
+ the .twb file's XML text as is, or a .twbx/.twb as a base64 data
1785
+ URI. The demo then REBUILDS that dashboard: the data speaks its
1786
+ measure and dimension names, and the liveboard copies its sheets
1787
+ (chart types, titles, arrangement; a dual axis becomes a combo
1788
+ chart) instead of the golden layout. The DP Web site is unchanged.
1789
+ Only the .twb inside a .twbx is read; extracts (customer data) are
1790
+ never opened. An unreadable file is refused at once (status
1791
+ "failed") so the build cannot silently become a generic demo.
1792
+ Call inspect_dashboard first to show the SE what will carry over.
1793
+ source_dashboard_image: Optional (DP Migrator). A picture of the dashboard
1794
+ instead of the file: an https image URL or a base64 data URI
1795
+ (png, jpeg or webp, up to 10 MB). Read by a vision model on the
1796
+ build thread, so lower fidelity: titles, chart types and labels
1797
+ as printed; no joins, types or formulas. If it cannot be read the
1798
+ build fails within seconds with the reason. When both are sent
1799
+ the workbook is used.
1800
 
1801
  Not settable here: askMode (it breaks the upstream tab prune) and the home
1802
  page (its copy is written from a brand screenshot). For those, call
 
1814
  the cluster (no account there, or the cluster is in standby) the call
1815
  fails at once with the reason and nothing is built. A brand theme that
1816
  cannot be used is also returned at once as `warnings` on the "started" reply.
1817
+ With a source dashboard the result also carries `mirror`: what was read
1818
+ (sheets with their chart type and fidelity: exact / approximated /
1819
+ dropped, with notes), the tiles built and the sheets dropped.
1820
  """
1821
  _log_hit(
1822
  f"CALL build_demo_from_brief company={company_name!r} use_case={use_case!r} "
 
1896
  # 2026-10-20) — brand["derive"] tells _run_build to do that. Bad input
1897
  # never fails the build — None plus warnings.
1898
  from demoprep_app.liveboards.brand_theme import BrandTheme, brand_request, portal_brand_options
1899
+ # DP Migrator (boone 2026-10-28): the prospect's dashboard. A workbook is
1900
+ # parsed NOW (milliseconds; refused before a slot is taken when unreadable);
1901
+ # a picture is checked for shape here and read by the vision model on the
1902
+ # build thread, where an unreadable one fails the build at once.
1903
+ from demoprep_app.migration.sources import load_workbook_arg
1904
+ from demoprep_app.migration.spec import MirrorError
1905
+ from demoprep_app.portal.login_clone.sources import screenshot_problem
1906
+ mirror: dict | None = None
1907
+ mirror_image = ""
1908
+ _mirror_notes: list[str] = []
1909
+ if (source_dashboard and not isinstance(source_dashboard, str)) or \
1910
+ (source_dashboard_image and not isinstance(source_dashboard_image, str)):
1911
+ return {"status": "failed", "errors": ["source_dashboard and source_dashboard_image must be strings"]}
1912
+ _src = (source_dashboard or "").strip()
1913
+ _src_img = (source_dashboard_image or "").strip()
1914
+ if _src:
1915
+ try:
1916
+ mirror = load_workbook_arg(_src).to_dict()
1917
+ except MirrorError as e:
1918
+ return {"status": "failed", "errors": [f"source_dashboard: {e}"]}
1919
+ if _src_img:
1920
+ _mirror_notes.append("source_dashboard_image ignored: the workbook was sent too and is exact")
1921
+ elif _src_img:
1922
+ _img_problem = screenshot_problem(_src_img)
1923
+ if _img_problem:
1924
+ return {"status": "failed", "errors": [f"source_dashboard_image: {_img_problem}"]}
1925
+ mirror_image = _src_img
1926
+
1927
  brand = brand_request(apply_brand_theme, brand_theme, company_url)
1928
  _portal_brand, _portal_brand_warnings = portal_brand_options(
1929
  BrandTheme.from_dict(brand["theme"]) if brand["theme"] else None)
 
1989
  header_tiles=header_tiles,
1990
  schema_style=schema_style,
1991
  homepage_screenshot=_shot,
1992
+ mirror=mirror, mirror_image=mirror_image,
1993
  build_settings=build_settings)
1994
  finally:
1995
  _build_sem.release()
 
2010
  started["warnings"] = list(_tile_notes)
2011
  if _shot_warning:
2012
  started.setdefault("warnings", []).append(_shot_warning)
2013
+ if mirror or mirror_image:
2014
+ started["mirror"] = ("workbook read: the liveboard will copy its sheets" if mirror
2015
+ else "picture sent: read by the vision model as the build starts")
2016
+ if _mirror_notes:
2017
+ started.setdefault("warnings", []).extend(_mirror_notes)
2018
  return started
2019
 
2020
 
sprint_2026_04.md CHANGED
@@ -269,6 +269,17 @@ should tell. KPI targets, growth trends, and anomaly patterns live in the matrix
269
  - 2026-10-22 (boone, after a Walmart demo): the browser's default link colour no longer counts as a brand colour. walmart.com's nav links are unstyled, so they read #0000EE and slipped in as a series colour. Measured on Chromium 149: an unstyled link is #0000EE, or #9E9EFF on a dark-scheme page; a visited link reports the same colour to scripts. Those two are skipped in TEXT readings only (button text, h1, h2, nav link); a background or brand variable in that blue still counts. Live walmart.com now reads #0053E2 / #004F9A / #001E60, all Walmart blues. Committed on `feature/brand-from-homepage`, not pushed. 4 new tests; brand suites 136; full suite 1911 pass, the same 3 pre-existing failures.
270
  - Ask (boone): "take a brand palette and build that off the home page, then change the colors on our charts to match that branding."
271
 
 
 
 
 
 
 
 
 
 
 
 
272
  ## ISS-24: Insight tile on the first tab, opt-in (`insight_tile`, boone: "tile=1")
273
  - Date: 2026-10-20 Severity: FEATURE
274
  - Status: BUILT 2026-10-20: `619d0b1` on `feature/header-tiles` (off test `e429fd8`), not on test. **An insight tile CAN be made through TML**, which corrects the September note. It is a viz with an `ai_tile` block (`target_agent: insight_code_runner`, `template_id: text_long_form`, gzip+base64 pandas `analysis_code` that reads the source vizzes as `<viz_guid>.csv`). The narrative is NOT stored; ThoughtSpot writes it on every view. A VALIDATE_ONLY and an ALL_OR_NONE import both worked. Ours analyses the first tab's KPI trends (up to 4) and driver charts (up to 3). Placement: logo 2 | insight 4 | KPIs 6 (KPIs two per row). Insight only: 6 | 6. **Unverified:** whether the narrative renders. boone to open the throwaway board `60f46445-fb45-463a-a29e-d7b994ffd594` on secloud ("ZZ insight tile test (copy of Premier Tech)"). EA 26.8: a cluster without it should reject the tile, and the build warns (untested).
@@ -701,6 +712,7 @@ implementation keeps:
701
 
702
  ### Sprint 4 (this sprint)
703
 
 
704
  - [x] **Findings triage, Waves 1–3 (Oct 20) — on `feature/findings-triage`, cut from the test line `hf-mcp-test/main` c0c09a2; NOT pushed.** Plan: `docs/PLAN_findings_triage_2026_10_15.md` (copy of the parent session's `dev_notes/` original, status section appended to both) (47 SE builds Oct 12–15: ~600 of 662 Slack warnings were `bound_not_inferable`; four deploy failures were unprovisioned users reported as "authentication failed"). Four commits: ISS-22 login precheck cherry-picked (6747616 → 30d7d85), Wave 1 c6a9e6a, Wave 2 1e97c45, Wave 3 (this entry's tip). Decisions (boone 2026-10-20): `bound_not_inferable` = `info` + one roll-up line; `_RATIO` keeps 0-1 with an open-ratio veto; never auto-create ThoughtSpot users.
705
  - **Oct 20, after the Turtle replay on test-mcp 4f0378f:** the two `insight_weak` lines it posted (1.20x vs promised 1.30x; 0.82x vs promised 0.78x) failed only because `_check_insight` took the pass bar from the BOOSTED magnitude (1.76x / 0.44x) while printing the authored promise. Fixed on `fix/insight-bar-authored-promise` (off test tip d02d82c): the bar follows whichever of authored/driven magnitude sits closer to 1.0 (boost never raises it; a band cap lowers it). Test `test_the_tuning_boost_does_not_raise_the_pass_bar`. Suite 1406 pass / 3 pre-existing fails.
706
  - **Wave 1 — stop the noise, show the real failures.** The post-load gate now reads the blueprint's `fmt`/`signed`/`kind` per measure (`DatasetColumn` carries them, `quality_gate.measure_formats`, threaded from `chat_interface`; the MCP path runs the same controller), so an int count or a currency amount is non-negative, not "unclassified". `infer_bound` returns a `signed` `MeasureBound` (unbounded by design, no gap) for NET_/MARGIN/VARIANCE/FLOW names, declared-signed measures and difference formulas instead of the same `None` as "no class matched". `quality.findings.dedupe`: one record per (code, table, measure) across stages, post-load wins (CSAT_SCORE_SUM was posted twice per build). `bound_not_inferable` is `info` at both emitters; Slack posts one roll-up line last and orders findings critical → send-blocking → data defect → insight_weak → rest. `insight_weak` states realized / needed / promised (as authored — `PlantedInsight.authored_magnitude`) / direction; the old text printed the pass bar as "it promised". `formula.validate` reports a copy (`X`, `X*1`, `1*X`, `X/1`, `X+0`) before generation; the gate never recomputes a mirror (straight to omit) and names the cause. `deploy_all`'s auth error is `thoughtspot_errors.trusted_auth_failure_message`: a missing account is the plain instruction naming user + cluster; the same text reaches the run errors, the MCP result and the Slack "Failed" post.
 
269
  - 2026-10-22 (boone, after a Walmart demo): the browser's default link colour no longer counts as a brand colour. walmart.com's nav links are unstyled, so they read #0000EE and slipped in as a series colour. Measured on Chromium 149: an unstyled link is #0000EE, or #9E9EFF on a dark-scheme page; a visited link reports the same colour to scripts. Those two are skipped in TEXT readings only (button text, h1, h2, nav link); a background or brand variable in that blue still counts. Live walmart.com now reads #0053E2 / #004F9A / #001E60, all Walmart blues. Committed on `feature/brand-from-homepage`, not pushed. 4 new tests; brand suites 136; full suite 1911 pass, the same 3 pre-existing failures.
270
  - Ask (boone): "take a brand palette and build that off the home page, then change the colors on our charts to match that branding."
271
 
272
+ ## ISS-31: DP Migrator — rebuild the prospect's existing dashboard (Tableau .twb or a picture) as the demo (Ketan + Nick, 10-28)
273
+ - Date: 2026-10-28 Severity: FEATURE
274
+ - Ask: Ketan closed the 10-28 meeting by challenging boone to "build a comprehensive ThoughtSpot Embedded demo builder equipped with a built-in legacy migrator"; Nick Moscaritolo asked for the same the same day: company URL + either the Tableau file or a screenshot of the dashboard, DemoPrep infers the schema, runs the normal pipeline, embeds it in DP Web. boone's reading (agreed): demo-grade, not a migration product — "your dashboard's shape and names, our data, plus a story"; the liveboard "as much like theirs as we can", the TSE site in golden TSE style. Beta to show 10-29.
275
+ - Status: BUILT 2026-10-28 on `claude/dashboard-migration-tse-5669ab` (cut from the test line `8528a7c`; the worktree had been on the August portal spike and was reset). NOT committed, NOT pushed. **Replayed live 2026-10-29 on secloud** (in-process through the MCP tool, Slack off, opp `TEST-NOT-A-DEAL`, env staging, owner boone): run 1 `51428cccbb02` → board `fd753f8e` (7.6 min, success) proved the path end to end — model columns Sales / Target / Sales V Target / Order Date, one tab "Sheet 1", one `ADVANCED_LINE_COLUMN` tile, data API 200 — and found one defect: the column matcher took "Total Sales V Target" for "Target", so the target line was missing (fixed in `mirror_layout._first`, fewest-extra-words wins; regression test). Run 2 `AMA_10290008_BKV` → board `395e7f14-f98f-4c20-a480-01e6d4fcba6e` (6.7 min): columns = Total Sales, lines = Total Sales V Target + Total Target, monthly, data API returns all four columns. Portal built on the template login (no company_url in the replay). Both test schemas are in DEMOBUILD_2026_10 (`AMA_10282358_GWD_sch`, `AMA_10290008_BKV_sch`) and can be cleaned up.
276
+ - New package `demoprep_app/migration/`: `spec.py` (MirrorSpec: tables, joins, fields, sheets with ThoughtSpot `chart_type` + fidelity exact/approximated/dropped, dashboards with zones), `mapping.py` (source marks/shelves -> chart type; dual axis -> `ADVANCED_LINE_COLUMN`), `tableau_twb.py` (deterministic .twb parser, defusedxml; strips paths/hosts/users; blends, relationships, parameters, table calcs, LOD noted), `image_spec.py` (one Anthropic vision call -> the same spec, lower confidence), `statement.py` (spec -> the blueprint's `user_request`: measure/dimension names bound, calcs re-expressed by the author, charts listed; never a wording `directives.py` reads as a prohibition), `questions.py` (one liveboard question per source sheet in the MODEL's column names; a sheet whose measures the model lacks is dropped by name), `sources.py` (.twb text / .twbx zip -> only its .twb member; the payload log records kind+size+hash, never content).
277
+ - Pipeline: `create_liveboard_from_model_mcp(explicit_questions=...)` asks the sheet questions as given; `enhance_mcp_liveboard(layout_strategy="mirror", mirror=...)` retypes and retitles from the source BEFORE classification (`demoprep_app/liveboards/mirror_layout.py`), skips groups, KPI/donut conversions, Muze, title humanising and the Demo Guide tile, keeps query validation, KPI sparklines, brand colours, axis repair; one tab named after the dashboard, tiles from the source zones (12-col grid, rows ≈ 1.6 × columns × aspect) or a KPI-first grid. `deploy_all(mirror=...)`, `ChatDemoInterface.mirror_spec`, `deployment_completion["mirror"]`.
278
+ - MCP: `build_demo_from_brief(source_dashboard=..., source_dashboard_image=...)` — a workbook is parsed before the slot is taken (unreadable = refused), a picture is read on the build thread (unreadable = the build fails within seconds); result `mirror` block. New tool `inspect_dashboard`. `docs/agent_q_flags.md` updated (ISS-31).
279
+ - Tests: `tests/test_migration_twb.py` (on `tests/fixtures/tableau_dual_axis_example.twb`, Nick's file with the user path scrubbed), `tests/test_mirror_layout.py` (incl. enhance end-to-end with a fake cluster), `tests/test_migration_questions.py`.
280
+ - Cut for the beta: zone-accurate layout beyond the first dashboard, source colours, several dashboards as tabs, Power BI (.pbix), Looker.
281
+ - Open: commit; push to test-mcp; Agent Q tool refresh after the test-mcp push; a DP name for the feature (placeholder "DP Migrator"); whether `inspect_dashboard` stays a separate beat or folds into the build. Polish: `mapping.chart_type_for` has no slot for Tableau's text/label encoding (where KPI and crosstab measures live); the parser passes those measures through its `rows` argument and notes it on the sheet — give the mapping a `text` parameter when there is time.
282
+
283
  ## ISS-24: Insight tile on the first tab, opt-in (`insight_tile`, boone: "tile=1")
284
  - Date: 2026-10-20 Severity: FEATURE
285
  - Status: BUILT 2026-10-20: `619d0b1` on `feature/header-tiles` (off test `e429fd8`), not on test. **An insight tile CAN be made through TML**, which corrects the September note. It is a viz with an `ai_tile` block (`target_agent: insight_code_runner`, `template_id: text_long_form`, gzip+base64 pandas `analysis_code` that reads the source vizzes as `<viz_guid>.csv`). The narrative is NOT stored; ThoughtSpot writes it on every view. A VALIDATE_ONLY and an ALL_OR_NONE import both worked. Ours analyses the first tab's KPI trends (up to 4) and driver charts (up to 3). Placement: logo 2 | insight 4 | KPIs 6 (KPIs two per row). Insight only: 6 | 6. **Unverified:** whether the narrative renders. boone to open the throwaway board `60f46445-fb45-463a-a29e-d7b994ffd594` on secloud ("ZZ insight tile test (copy of Premier Tech)"). EA 26.8: a cluster without it should reject the tile, and the build warns (untested).
 
712
 
713
  ### Sprint 4 (this sprint)
714
 
715
+ - [x] **DP Migrator beta (Oct 28) — ISS-31, on `claude/dashboard-migration-tse-5669ab` off test `8528a7c`; NOT pushed, NOT replayed live.** Tableau .twb/.twbx or a dashboard picture -> MirrorSpec -> blueprint vocabulary + one question per sheet + mirror layout; `inspect_dashboard` tool. Details under ISS-31.
716
  - [x] **Findings triage, Waves 1–3 (Oct 20) — on `feature/findings-triage`, cut from the test line `hf-mcp-test/main` c0c09a2; NOT pushed.** Plan: `docs/PLAN_findings_triage_2026_10_15.md` (copy of the parent session's `dev_notes/` original, status section appended to both) (47 SE builds Oct 12–15: ~600 of 662 Slack warnings were `bound_not_inferable`; four deploy failures were unprovisioned users reported as "authentication failed"). Four commits: ISS-22 login precheck cherry-picked (6747616 → 30d7d85), Wave 1 c6a9e6a, Wave 2 1e97c45, Wave 3 (this entry's tip). Decisions (boone 2026-10-20): `bound_not_inferable` = `info` + one roll-up line; `_RATIO` keeps 0-1 with an open-ratio veto; never auto-create ThoughtSpot users.
717
  - **Oct 20, after the Turtle replay on test-mcp 4f0378f:** the two `insight_weak` lines it posted (1.20x vs promised 1.30x; 0.82x vs promised 0.78x) failed only because `_check_insight` took the pass bar from the BOOSTED magnitude (1.76x / 0.44x) while printing the authored promise. Fixed on `fix/insight-bar-authored-promise` (off test tip d02d82c): the bar follows whichever of authored/driven magnitude sits closer to 1.0 (boost never raises it; a band cap lowers it). Test `test_the_tuning_boost_does_not_raise_the_pass_bar`. Suite 1406 pass / 3 pre-existing fails.
718
  - **Wave 1 — stop the noise, show the real failures.** The post-load gate now reads the blueprint's `fmt`/`signed`/`kind` per measure (`DatasetColumn` carries them, `quality_gate.measure_formats`, threaded from `chat_interface`; the MCP path runs the same controller), so an int count or a currency amount is non-negative, not "unclassified". `infer_bound` returns a `signed` `MeasureBound` (unbounded by design, no gap) for NET_/MARGIN/VARIANCE/FLOW names, declared-signed measures and difference formulas instead of the same `None` as "no class matched". `quality.findings.dedupe`: one record per (code, table, measure) across stages, post-load wins (CSAT_SCORE_SUM was posted twice per build). `bound_not_inferable` is `info` at both emitters; Slack posts one roll-up line last and orders findings critical → send-blocking → data defect → insight_weak → rest. `insight_weak` states realized / needed / promised (as authored — `PlantedInsight.authored_magnitude`) / direction; the old text printed the pass bar as "it promised". `formula.validate` reports a copy (`X`, `X*1`, `1*X`, `X/1`, `X+0`) before generation; the gate never recomputes a mirror (straight to omit) and names the cause. `deploy_all`'s auth error is `thoughtspot_errors.trusted_auth_failure_message`: a missing account is the plain instruction naming user + cluster; the same text reaches the run errors, the MCP result and the Slack "Failed" post.
tests/fixtures/tableau_dual_axis_example.twb ADDED
@@ -0,0 +1,950 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version='1.0' encoding='utf-8' ?>
2
+
3
+ <!-- build 20201.20.0219.0753 -->
4
+ <workbook original-version='18.1' source-build='2020.1.0 (20201.20.0219.0753)' source-platform='win' version='18.1' xmlns:user='http://www.tableausoftware.com/xml/user'>
5
+ <document-format-change-manifest>
6
+ <_.fcp.MarkAnimation.true...MarkAnimation />
7
+ <SheetIdentifierTracking />
8
+ <WindowsPersistSimpleIdentifiers />
9
+ </document-format-change-manifest>
10
+ <preferences>
11
+ <preference name='ui.encoding.shelf.height' value='24' />
12
+ <preference name='ui.shelf.height' value='26' />
13
+ </preferences>
14
+ <datasources>
15
+ <datasource caption='ListOfOrders+OrderBreakdown' inline='true' name='federated.0683hos176mghg18jfdcj01g7fgn' version='18.1'>
16
+ <connection class='federated'>
17
+ <named-connections>
18
+ <named-connection caption='P1-AmazingMartEU2' name='excel-direct.06y78xf1p244xs1c9h76a18luhv4'>
19
+ <connection class='excel-direct' cleaning='no' compat='no' dataRefreshTime='' filename='C:/Tableau/Datasources/P1-AmazingMartEU2.xlsx' interpretationMode='0' password='' server='' validate='no' />
20
+ </named-connection>
21
+ </named-connections>
22
+ <relation join='inner' type='join'>
23
+ <clause type='join'>
24
+ <expression op='='>
25
+ <expression op='[ListOfOrders].[Order ID]' />
26
+ <expression op='[OrderBreakdown].[Order ID]' />
27
+ </expression>
28
+ </clause>
29
+ <relation connection='excel-direct.06y78xf1p244xs1c9h76a18luhv4' name='ListOfOrders' table='[ListOfOrders$]' type='table'>
30
+ <columns gridOrigin='A1:J4118:no:A1:J4118:0' header='yes' outcome='6'>
31
+ <column datatype='string' name='Order ID' ordinal='0' />
32
+ <column datatype='date' name='Order Date' ordinal='1' />
33
+ <column datatype='string' name='Customer Name' ordinal='2' />
34
+ <column datatype='string' name='City' ordinal='3' />
35
+ <column datatype='string' name='Country' ordinal='4' />
36
+ <column datatype='string' name='Region' ordinal='5' />
37
+ <column datatype='string' name='Segment' ordinal='6' />
38
+ <column datatype='date' name='Ship Date' ordinal='7' />
39
+ <column datatype='string' name='Ship Mode' ordinal='8' />
40
+ <column datatype='string' name='State' ordinal='9' />
41
+ </columns>
42
+ </relation>
43
+ <relation connection='excel-direct.06y78xf1p244xs1c9h76a18luhv4' name='OrderBreakdown' table='[OrderBreakdown$]' type='table'>
44
+ <columns gridOrigin='A1:H8048:no:A1:H8048:0' header='yes' outcome='6'>
45
+ <column datatype='string' name='Order ID' ordinal='0' />
46
+ <column datatype='string' name='Product Name' ordinal='1' />
47
+ <column datatype='real' name='Discount' ordinal='2' />
48
+ <column datatype='integer' name='Sales' ordinal='3' />
49
+ <column datatype='integer' name='Profit' ordinal='4' />
50
+ <column datatype='integer' name='Quantity' ordinal='5' />
51
+ <column datatype='string' name='Category' ordinal='6' />
52
+ <column datatype='string' name='Sub-Category' ordinal='7' />
53
+ </columns>
54
+ </relation>
55
+ </relation>
56
+ <cols>
57
+ <map key='[Category]' value='[OrderBreakdown].[Category]' />
58
+ <map key='[City]' value='[ListOfOrders].[City]' />
59
+ <map key='[Country]' value='[ListOfOrders].[Country]' />
60
+ <map key='[Customer Name]' value='[ListOfOrders].[Customer Name]' />
61
+ <map key='[Discount]' value='[OrderBreakdown].[Discount]' />
62
+ <map key='[Order Date]' value='[ListOfOrders].[Order Date]' />
63
+ <map key='[Order ID (OrderBreakdown)]' value='[OrderBreakdown].[Order ID]' />
64
+ <map key='[Order ID]' value='[ListOfOrders].[Order ID]' />
65
+ <map key='[Product Name]' value='[OrderBreakdown].[Product Name]' />
66
+ <map key='[Profit]' value='[OrderBreakdown].[Profit]' />
67
+ <map key='[Quantity]' value='[OrderBreakdown].[Quantity]' />
68
+ <map key='[Region]' value='[ListOfOrders].[Region]' />
69
+ <map key='[Sales]' value='[OrderBreakdown].[Sales]' />
70
+ <map key='[Segment]' value='[ListOfOrders].[Segment]' />
71
+ <map key='[Ship Date]' value='[ListOfOrders].[Ship Date]' />
72
+ <map key='[Ship Mode]' value='[ListOfOrders].[Ship Mode]' />
73
+ <map key='[State]' value='[ListOfOrders].[State]' />
74
+ <map key='[Sub-Category]' value='[OrderBreakdown].[Sub-Category]' />
75
+ </cols>
76
+ <metadata-records>
77
+ <metadata-record class='column'>
78
+ <remote-name>Order ID</remote-name>
79
+ <remote-type>130</remote-type>
80
+ <local-name>[Order ID]</local-name>
81
+ <parent-name>[ListOfOrders]</parent-name>
82
+ <remote-alias>Order ID</remote-alias>
83
+ <ordinal>0</ordinal>
84
+ <local-type>string</local-type>
85
+ <aggregation>Count</aggregation>
86
+ <contains-null>true</contains-null>
87
+ <collation flag='1' name='LEN_RUS_S2' />
88
+ <attributes>
89
+ <attribute datatype='string' name='DebugRemoteType'>&quot;WSTR&quot;</attribute>
90
+ </attributes>
91
+ </metadata-record>
92
+ <metadata-record class='column'>
93
+ <remote-name>Order Date</remote-name>
94
+ <remote-type>7</remote-type>
95
+ <local-name>[Order Date]</local-name>
96
+ <parent-name>[ListOfOrders]</parent-name>
97
+ <remote-alias>Order Date</remote-alias>
98
+ <ordinal>1</ordinal>
99
+ <local-type>date</local-type>
100
+ <aggregation>Year</aggregation>
101
+ <contains-null>true</contains-null>
102
+ <attributes>
103
+ <attribute datatype='string' name='DebugRemoteType'>&quot;DATE&quot;</attribute>
104
+ </attributes>
105
+ </metadata-record>
106
+ <metadata-record class='column'>
107
+ <remote-name>Customer Name</remote-name>
108
+ <remote-type>130</remote-type>
109
+ <local-name>[Customer Name]</local-name>
110
+ <parent-name>[ListOfOrders]</parent-name>
111
+ <remote-alias>Customer Name</remote-alias>
112
+ <ordinal>2</ordinal>
113
+ <local-type>string</local-type>
114
+ <aggregation>Count</aggregation>
115
+ <contains-null>true</contains-null>
116
+ <collation flag='1' name='LEN_RUS_S2' />
117
+ <attributes>
118
+ <attribute datatype='string' name='DebugRemoteType'>&quot;WSTR&quot;</attribute>
119
+ </attributes>
120
+ </metadata-record>
121
+ <metadata-record class='column'>
122
+ <remote-name>City</remote-name>
123
+ <remote-type>130</remote-type>
124
+ <local-name>[City]</local-name>
125
+ <parent-name>[ListOfOrders]</parent-name>
126
+ <remote-alias>City</remote-alias>
127
+ <ordinal>3</ordinal>
128
+ <local-type>string</local-type>
129
+ <aggregation>Count</aggregation>
130
+ <contains-null>true</contains-null>
131
+ <collation flag='1' name='LEN_RUS_S2' />
132
+ <attributes>
133
+ <attribute datatype='string' name='DebugRemoteType'>&quot;WSTR&quot;</attribute>
134
+ </attributes>
135
+ </metadata-record>
136
+ <metadata-record class='column'>
137
+ <remote-name>Country</remote-name>
138
+ <remote-type>130</remote-type>
139
+ <local-name>[Country]</local-name>
140
+ <parent-name>[ListOfOrders]</parent-name>
141
+ <remote-alias>Country</remote-alias>
142
+ <ordinal>4</ordinal>
143
+ <local-type>string</local-type>
144
+ <aggregation>Count</aggregation>
145
+ <contains-null>true</contains-null>
146
+ <collation flag='1' name='LEN_RUS_S2' />
147
+ <attributes>
148
+ <attribute datatype='string' name='DebugRemoteType'>&quot;WSTR&quot;</attribute>
149
+ </attributes>
150
+ </metadata-record>
151
+ <metadata-record class='column'>
152
+ <remote-name>Region</remote-name>
153
+ <remote-type>130</remote-type>
154
+ <local-name>[Region]</local-name>
155
+ <parent-name>[ListOfOrders]</parent-name>
156
+ <remote-alias>Region</remote-alias>
157
+ <ordinal>5</ordinal>
158
+ <local-type>string</local-type>
159
+ <aggregation>Count</aggregation>
160
+ <contains-null>true</contains-null>
161
+ <collation flag='1' name='LEN_RUS_S2' />
162
+ <attributes>
163
+ <attribute datatype='string' name='DebugRemoteType'>&quot;WSTR&quot;</attribute>
164
+ </attributes>
165
+ </metadata-record>
166
+ <metadata-record class='column'>
167
+ <remote-name>Segment</remote-name>
168
+ <remote-type>130</remote-type>
169
+ <local-name>[Segment]</local-name>
170
+ <parent-name>[ListOfOrders]</parent-name>
171
+ <remote-alias>Segment</remote-alias>
172
+ <ordinal>6</ordinal>
173
+ <local-type>string</local-type>
174
+ <aggregation>Count</aggregation>
175
+ <contains-null>true</contains-null>
176
+ <collation flag='1' name='LEN_RUS_S2' />
177
+ <attributes>
178
+ <attribute datatype='string' name='DebugRemoteType'>&quot;WSTR&quot;</attribute>
179
+ </attributes>
180
+ </metadata-record>
181
+ <metadata-record class='column'>
182
+ <remote-name>Ship Date</remote-name>
183
+ <remote-type>7</remote-type>
184
+ <local-name>[Ship Date]</local-name>
185
+ <parent-name>[ListOfOrders]</parent-name>
186
+ <remote-alias>Ship Date</remote-alias>
187
+ <ordinal>7</ordinal>
188
+ <local-type>date</local-type>
189
+ <aggregation>Year</aggregation>
190
+ <contains-null>true</contains-null>
191
+ <attributes>
192
+ <attribute datatype='string' name='DebugRemoteType'>&quot;DATE&quot;</attribute>
193
+ </attributes>
194
+ </metadata-record>
195
+ <metadata-record class='column'>
196
+ <remote-name>Ship Mode</remote-name>
197
+ <remote-type>130</remote-type>
198
+ <local-name>[Ship Mode]</local-name>
199
+ <parent-name>[ListOfOrders]</parent-name>
200
+ <remote-alias>Ship Mode</remote-alias>
201
+ <ordinal>8</ordinal>
202
+ <local-type>string</local-type>
203
+ <aggregation>Count</aggregation>
204
+ <contains-null>true</contains-null>
205
+ <collation flag='1' name='LEN_RUS_S2' />
206
+ <attributes>
207
+ <attribute datatype='string' name='DebugRemoteType'>&quot;WSTR&quot;</attribute>
208
+ </attributes>
209
+ </metadata-record>
210
+ <metadata-record class='column'>
211
+ <remote-name>State</remote-name>
212
+ <remote-type>130</remote-type>
213
+ <local-name>[State]</local-name>
214
+ <parent-name>[ListOfOrders]</parent-name>
215
+ <remote-alias>State</remote-alias>
216
+ <ordinal>9</ordinal>
217
+ <local-type>string</local-type>
218
+ <aggregation>Count</aggregation>
219
+ <contains-null>true</contains-null>
220
+ <collation flag='1' name='LEN_RUS_S2' />
221
+ <attributes>
222
+ <attribute datatype='string' name='DebugRemoteType'>&quot;WSTR&quot;</attribute>
223
+ </attributes>
224
+ </metadata-record>
225
+ <metadata-record class='capability'>
226
+ <remote-name />
227
+ <remote-type>0</remote-type>
228
+ <parent-name>[ListOfOrders]</parent-name>
229
+ <remote-alias />
230
+ <aggregation>Count</aggregation>
231
+ <contains-null>true</contains-null>
232
+ <attributes>
233
+ <attribute datatype='integer' name='context'>0</attribute>
234
+ <attribute datatype='string' name='gridOrigin'>&quot;A1:J4118:no:A1:J4118:0&quot;</attribute>
235
+ <attribute datatype='boolean' name='header'>true</attribute>
236
+ <attribute datatype='integer' name='outcome'>6</attribute>
237
+ </attributes>
238
+ </metadata-record>
239
+ <metadata-record class='column'>
240
+ <remote-name>Order ID</remote-name>
241
+ <remote-type>130</remote-type>
242
+ <local-name>[Order ID (OrderBreakdown)]</local-name>
243
+ <parent-name>[OrderBreakdown]</parent-name>
244
+ <remote-alias>Order ID</remote-alias>
245
+ <ordinal>10</ordinal>
246
+ <local-type>string</local-type>
247
+ <aggregation>Count</aggregation>
248
+ <contains-null>true</contains-null>
249
+ <collation flag='1' name='LEN_RUS_S2' />
250
+ <attributes>
251
+ <attribute datatype='string' name='DebugRemoteType'>&quot;WSTR&quot;</attribute>
252
+ </attributes>
253
+ </metadata-record>
254
+ <metadata-record class='column'>
255
+ <remote-name>Product Name</remote-name>
256
+ <remote-type>130</remote-type>
257
+ <local-name>[Product Name]</local-name>
258
+ <parent-name>[OrderBreakdown]</parent-name>
259
+ <remote-alias>Product Name</remote-alias>
260
+ <ordinal>11</ordinal>
261
+ <local-type>string</local-type>
262
+ <aggregation>Count</aggregation>
263
+ <contains-null>true</contains-null>
264
+ <collation flag='1' name='LEN_RUS_S2' />
265
+ <attributes>
266
+ <attribute datatype='string' name='DebugRemoteType'>&quot;WSTR&quot;</attribute>
267
+ </attributes>
268
+ </metadata-record>
269
+ <metadata-record class='column'>
270
+ <remote-name>Discount</remote-name>
271
+ <remote-type>5</remote-type>
272
+ <local-name>[Discount]</local-name>
273
+ <parent-name>[OrderBreakdown]</parent-name>
274
+ <remote-alias>Discount</remote-alias>
275
+ <ordinal>12</ordinal>
276
+ <local-type>real</local-type>
277
+ <aggregation>Sum</aggregation>
278
+ <precision>15</precision>
279
+ <contains-null>true</contains-null>
280
+ <attributes>
281
+ <attribute datatype='string' name='DebugRemoteType'>&quot;R8&quot;</attribute>
282
+ </attributes>
283
+ </metadata-record>
284
+ <metadata-record class='column'>
285
+ <remote-name>Sales</remote-name>
286
+ <remote-type>20</remote-type>
287
+ <local-name>[Sales]</local-name>
288
+ <parent-name>[OrderBreakdown]</parent-name>
289
+ <remote-alias>Sales</remote-alias>
290
+ <ordinal>13</ordinal>
291
+ <local-type>integer</local-type>
292
+ <aggregation>Sum</aggregation>
293
+ <contains-null>true</contains-null>
294
+ <attributes>
295
+ <attribute datatype='string' name='DebugRemoteType'>&quot;I8&quot;</attribute>
296
+ </attributes>
297
+ </metadata-record>
298
+ <metadata-record class='column'>
299
+ <remote-name>Profit</remote-name>
300
+ <remote-type>20</remote-type>
301
+ <local-name>[Profit]</local-name>
302
+ <parent-name>[OrderBreakdown]</parent-name>
303
+ <remote-alias>Profit</remote-alias>
304
+ <ordinal>14</ordinal>
305
+ <local-type>integer</local-type>
306
+ <aggregation>Sum</aggregation>
307
+ <contains-null>true</contains-null>
308
+ <attributes>
309
+ <attribute datatype='string' name='DebugRemoteType'>&quot;I8&quot;</attribute>
310
+ </attributes>
311
+ </metadata-record>
312
+ <metadata-record class='column'>
313
+ <remote-name>Quantity</remote-name>
314
+ <remote-type>20</remote-type>
315
+ <local-name>[Quantity]</local-name>
316
+ <parent-name>[OrderBreakdown]</parent-name>
317
+ <remote-alias>Quantity</remote-alias>
318
+ <ordinal>15</ordinal>
319
+ <local-type>integer</local-type>
320
+ <aggregation>Sum</aggregation>
321
+ <contains-null>true</contains-null>
322
+ <attributes>
323
+ <attribute datatype='string' name='DebugRemoteType'>&quot;I8&quot;</attribute>
324
+ </attributes>
325
+ </metadata-record>
326
+ <metadata-record class='column'>
327
+ <remote-name>Category</remote-name>
328
+ <remote-type>130</remote-type>
329
+ <local-name>[Category]</local-name>
330
+ <parent-name>[OrderBreakdown]</parent-name>
331
+ <remote-alias>Category</remote-alias>
332
+ <ordinal>16</ordinal>
333
+ <local-type>string</local-type>
334
+ <aggregation>Count</aggregation>
335
+ <contains-null>true</contains-null>
336
+ <collation flag='1' name='LEN_RUS_S2' />
337
+ <attributes>
338
+ <attribute datatype='string' name='DebugRemoteType'>&quot;WSTR&quot;</attribute>
339
+ </attributes>
340
+ </metadata-record>
341
+ <metadata-record class='column'>
342
+ <remote-name>Sub-Category</remote-name>
343
+ <remote-type>130</remote-type>
344
+ <local-name>[Sub-Category]</local-name>
345
+ <parent-name>[OrderBreakdown]</parent-name>
346
+ <remote-alias>Sub-Category</remote-alias>
347
+ <ordinal>17</ordinal>
348
+ <local-type>string</local-type>
349
+ <aggregation>Count</aggregation>
350
+ <contains-null>true</contains-null>
351
+ <collation flag='1' name='LEN_RUS_S2' />
352
+ <attributes>
353
+ <attribute datatype='string' name='DebugRemoteType'>&quot;WSTR&quot;</attribute>
354
+ </attributes>
355
+ </metadata-record>
356
+ <metadata-record class='capability'>
357
+ <remote-name />
358
+ <remote-type>0</remote-type>
359
+ <parent-name>[OrderBreakdown]</parent-name>
360
+ <remote-alias />
361
+ <aggregation>Count</aggregation>
362
+ <contains-null>true</contains-null>
363
+ <attributes>
364
+ <attribute datatype='integer' name='context'>0</attribute>
365
+ <attribute datatype='string' name='gridOrigin'>&quot;A1:H8048:no:A1:H8048:0&quot;</attribute>
366
+ <attribute datatype='boolean' name='header'>true</attribute>
367
+ <attribute datatype='integer' name='outcome'>6</attribute>
368
+ </attributes>
369
+ </metadata-record>
370
+ </metadata-records>
371
+ </connection>
372
+ <aliases enabled='yes' />
373
+ <column caption='Sales V Target' datatype='integer' name='[Calculation_953355781789577216]' role='measure' type='quantitative'>
374
+ <calculation class='tableau' formula='SUM([Sales])-SUM([federated.1lm8rfp01pfb1y157heyi0tdr8s4].[Target])' />
375
+ </column>
376
+ <column datatype='string' name='[Category]' role='dimension' type='nominal' />
377
+ <column datatype='string' name='[City]' role='dimension' semantic-role='[City].[Name]' type='nominal' />
378
+ <column datatype='string' name='[Country]' role='dimension' semantic-role='[Country].[ISO3166_2]' type='nominal' />
379
+ <column datatype='integer' name='[Number of Records]' role='measure' type='quantitative' user:auto-column='numrec'>
380
+ <calculation class='tableau' formula='1' />
381
+ </column>
382
+ <column datatype='string' name='[State]' role='dimension' semantic-role='[State].[Name]' type='nominal' />
383
+ <column-instance column='[Category]' derivation='None' name='[none:Category:nk]' pivot='key' type='nominal' />
384
+ <drill-paths>
385
+ <drill-path name='Country, State, City'>
386
+ <field>[Country]</field>
387
+ <field>[State]</field>
388
+ <field>[City]</field>
389
+ </drill-path>
390
+ </drill-paths>
391
+ <layout dim-ordering='alphabetic' dim-percentage='0.850962' measure-ordering='alphabetic' measure-percentage='0.149038' show-structure='true' />
392
+ <style>
393
+ <style-rule element='mark'>
394
+ <encoding attr='color' field='[none:Category:nk]' type='palette'>
395
+ <map to='#4e79a7'>
396
+ <bucket>&quot;Furniture&quot;</bucket>
397
+ </map>
398
+ <map to='#e15759'>
399
+ <bucket>&quot;Technology&quot;</bucket>
400
+ </map>
401
+ <map to='#f28e2b'>
402
+ <bucket>&quot;Office Supplies&quot;</bucket>
403
+ </map>
404
+ </encoding>
405
+ </style-rule>
406
+ </style>
407
+ <semantic-values>
408
+ <semantic-value key='[Country].[Name]' value='&quot;United States&quot;' />
409
+ </semantic-values>
410
+ <datasource-dependencies datasource='federated.1lm8rfp01pfb1y157heyi0tdr8s4'>
411
+ <column datatype='integer' name='[Target]' role='measure' type='quantitative' />
412
+ </datasource-dependencies>
413
+ </datasource>
414
+ <datasource caption='SalesTargets (P1-AmazingMartEU2)' inline='true' name='federated.1lm8rfp01pfb1y157heyi0tdr8s4' version='18.1'>
415
+ <connection class='federated'>
416
+ <named-connections>
417
+ <named-connection caption='P1-AmazingMartEU2' name='excel-direct.1fmxg1r17lu72y124nsqq160byg5'>
418
+ <connection class='excel-direct' cleaning='no' compat='no' dataRefreshTime='' filename='C:/Tableau/Datasources/P1-AmazingMartEU2.xlsx' interpretationMode='0' password='' server='' validate='no' />
419
+ </named-connection>
420
+ </named-connections>
421
+ <relation connection='excel-direct.1fmxg1r17lu72y124nsqq160byg5' name='SalesTargets' table='[SalesTargets$]' type='table'>
422
+ <columns gridOrigin='A1:C145:no:A1:C145:0' header='yes' outcome='6'>
423
+ <column datatype='date' name='Month of Order Date' ordinal='0' />
424
+ <column datatype='string' name='Category' ordinal='1' />
425
+ <column datatype='integer' name='Target' ordinal='2' />
426
+ </columns>
427
+ </relation>
428
+ <metadata-records>
429
+ <metadata-record class='column'>
430
+ <remote-name>Month of Order Date</remote-name>
431
+ <remote-type>7</remote-type>
432
+ <local-name>[Month of Order Date]</local-name>
433
+ <parent-name>[SalesTargets]</parent-name>
434
+ <remote-alias>Month of Order Date</remote-alias>
435
+ <ordinal>0</ordinal>
436
+ <local-type>date</local-type>
437
+ <aggregation>Year</aggregation>
438
+ <contains-null>true</contains-null>
439
+ <attributes>
440
+ <attribute datatype='string' name='DebugRemoteType'>&quot;DATE&quot;</attribute>
441
+ </attributes>
442
+ </metadata-record>
443
+ <metadata-record class='column'>
444
+ <remote-name>Category</remote-name>
445
+ <remote-type>130</remote-type>
446
+ <local-name>[Category]</local-name>
447
+ <parent-name>[SalesTargets]</parent-name>
448
+ <remote-alias>Category</remote-alias>
449
+ <ordinal>1</ordinal>
450
+ <local-type>string</local-type>
451
+ <aggregation>Count</aggregation>
452
+ <contains-null>true</contains-null>
453
+ <collation flag='1' name='LEN_RUS_S2' />
454
+ <attributes>
455
+ <attribute datatype='string' name='DebugRemoteType'>&quot;WSTR&quot;</attribute>
456
+ </attributes>
457
+ </metadata-record>
458
+ <metadata-record class='column'>
459
+ <remote-name>Target</remote-name>
460
+ <remote-type>20</remote-type>
461
+ <local-name>[Target]</local-name>
462
+ <parent-name>[SalesTargets]</parent-name>
463
+ <remote-alias>Target</remote-alias>
464
+ <ordinal>2</ordinal>
465
+ <local-type>integer</local-type>
466
+ <aggregation>Sum</aggregation>
467
+ <contains-null>true</contains-null>
468
+ <attributes>
469
+ <attribute datatype='string' name='DebugRemoteType'>&quot;I8&quot;</attribute>
470
+ </attributes>
471
+ </metadata-record>
472
+ <metadata-record class='capability'>
473
+ <remote-name />
474
+ <remote-type>0</remote-type>
475
+ <parent-name>[SalesTargets]</parent-name>
476
+ <remote-alias />
477
+ <aggregation>Count</aggregation>
478
+ <contains-null>true</contains-null>
479
+ <attributes>
480
+ <attribute datatype='integer' name='context'>0</attribute>
481
+ <attribute datatype='string' name='gridOrigin'>&quot;A1:C145:no:A1:C145:0&quot;</attribute>
482
+ <attribute datatype='boolean' name='header'>true</attribute>
483
+ <attribute datatype='integer' name='outcome'>6</attribute>
484
+ </attributes>
485
+ </metadata-record>
486
+ </metadata-records>
487
+ </connection>
488
+ <column datatype='integer' name='[Number of Records]' role='measure' type='quantitative' user:auto-column='numrec'>
489
+ <calculation class='tableau' formula='1' />
490
+ </column>
491
+ <layout dim-ordering='alphabetic' dim-percentage='0.5' measure-ordering='alphabetic' measure-percentage='0.5' show-structure='true' />
492
+ <semantic-values>
493
+ <semantic-value key='[Country].[Name]' value='&quot;United States&quot;' />
494
+ </semantic-values>
495
+ </datasource>
496
+ </datasources>
497
+ <datasource-relationships>
498
+ <datasource-dependencies datasource='federated.0683hos176mghg18jfdcj01g7fgn'>
499
+ <column datatype='string' name='[Category]' role='dimension' type='nominal' />
500
+ <column datatype='date' name='[Order Date]' role='dimension' type='ordinal' />
501
+ <column-instance column='[Order Date]' derivation='Month' name='[mn:Order Date:ok]' pivot='key' type='ordinal' />
502
+ <column-instance column='[Category]' derivation='None' name='[none:Category:nk]' pivot='key' type='nominal' />
503
+ <column-instance column='[Order Date]' derivation='Month-Trunc' name='[tmn:Order Date:ok]' pivot='key' type='ordinal' />
504
+ <column-instance column='[Order Date]' derivation='Year-Trunc' name='[tyr:Order Date:ok]' pivot='key' type='ordinal' />
505
+ <column-instance column='[Order Date]' derivation='Year' name='[yr:Order Date:ok]' pivot='key' type='ordinal' />
506
+ </datasource-dependencies>
507
+ <datasource-dependencies datasource='federated.1lm8rfp01pfb1y157heyi0tdr8s4'>
508
+ <column datatype='string' name='[Category]' role='dimension' type='nominal' />
509
+ <column datatype='date' name='[Month of Order Date]' role='dimension' type='ordinal' />
510
+ <column-instance column='[Month of Order Date]' derivation='Month' name='[mn:Month of Order Date:ok]' pivot='key' type='ordinal' />
511
+ <column-instance column='[Category]' derivation='None' name='[none:Category:nk]' pivot='key' type='nominal' />
512
+ <column-instance column='[Month of Order Date]' derivation='Month-Trunc' name='[tmn:Month of Order Date:ok]' pivot='key' type='ordinal' />
513
+ <column-instance column='[Month of Order Date]' derivation='Year-Trunc' name='[tyr:Month of Order Date:ok]' pivot='key' type='ordinal' />
514
+ <column-instance column='[Month of Order Date]' derivation='Year' name='[yr:Month of Order Date:ok]' pivot='key' type='ordinal' />
515
+ </datasource-dependencies>
516
+ <datasource-relationship source='federated.0683hos176mghg18jfdcj01g7fgn' target='federated.1lm8rfp01pfb1y157heyi0tdr8s4'>
517
+ <column-mapping>
518
+ <map key='[federated.0683hos176mghg18jfdcj01g7fgn].[mn:Order Date:ok]' value='[federated.1lm8rfp01pfb1y157heyi0tdr8s4].[mn:Month of Order Date:ok]' />
519
+ <map key='[federated.0683hos176mghg18jfdcj01g7fgn].[none:Category:nk]' value='[federated.1lm8rfp01pfb1y157heyi0tdr8s4].[none:Category:nk]' />
520
+ <map key='[federated.0683hos176mghg18jfdcj01g7fgn].[tmn:Order Date:ok]' value='[federated.1lm8rfp01pfb1y157heyi0tdr8s4].[tmn:Month of Order Date:ok]' />
521
+ <map key='[federated.0683hos176mghg18jfdcj01g7fgn].[tyr:Order Date:ok]' value='[federated.1lm8rfp01pfb1y157heyi0tdr8s4].[tyr:Month of Order Date:ok]' />
522
+ <map key='[federated.0683hos176mghg18jfdcj01g7fgn].[yr:Order Date:ok]' value='[federated.1lm8rfp01pfb1y157heyi0tdr8s4].[yr:Month of Order Date:ok]' />
523
+ </column-mapping>
524
+ </datasource-relationship>
525
+ </datasource-relationships>
526
+ <worksheets>
527
+ <worksheet name='Sheet 1'>
528
+ <table>
529
+ <view>
530
+ <datasources>
531
+ <datasource caption='ListOfOrders+OrderBreakdown' name='federated.0683hos176mghg18jfdcj01g7fgn' />
532
+ <datasource caption='SalesTargets (P1-AmazingMartEU2)' name='federated.1lm8rfp01pfb1y157heyi0tdr8s4' />
533
+ </datasources>
534
+ <datasource-dependencies datasource='federated.0683hos176mghg18jfdcj01g7fgn'>
535
+ <column caption='Sales V Target' datatype='integer' name='[Calculation_953355781789577216]' role='measure' type='quantitative'>
536
+ <calculation class='tableau' formula='SUM([Sales])-SUM([federated.1lm8rfp01pfb1y157heyi0tdr8s4].[Target])' />
537
+ </column>
538
+ <column datatype='string' name='[Category]' role='dimension' type='nominal' />
539
+ <column datatype='date' name='[Order Date]' role='dimension' type='ordinal' />
540
+ <column datatype='integer' name='[Sales]' role='measure' type='quantitative' />
541
+ <column-instance column='[Category]' derivation='None' name='[none:Category:nk]' pivot='key' type='nominal' />
542
+ <column-instance column='[Sales]' derivation='Sum' name='[sum:Sales:qk]' pivot='key' type='quantitative' />
543
+ <column-instance column='[Order Date]' derivation='Month-Trunc' name='[tmn:Order Date:qk]' pivot='key' type='quantitative' />
544
+ <column-instance column='[Calculation_953355781789577216]' derivation='User' name='[usr:Calculation_953355781789577216:qk]' pivot='key' type='quantitative' />
545
+ </datasource-dependencies>
546
+ <datasource-dependencies datasource='federated.1lm8rfp01pfb1y157heyi0tdr8s4'>
547
+ <column datatype='integer' name='[Target]' role='measure' type='quantitative' />
548
+ <column-instance column='[Target]' derivation='Sum' name='[sum:Target:qk]' pivot='key' type='quantitative' />
549
+ </datasource-dependencies>
550
+ <filter class='categorical' column='[federated.0683hos176mghg18jfdcj01g7fgn].[none:Category:nk]'>
551
+ <groupfilter function='member' level='[none:Category:nk]' member='&quot;Furniture&quot;' user:ui-domain='relevant' user:ui-enumeration='inclusive' user:ui-marker='enumerate' />
552
+ </filter>
553
+ <slices>
554
+ <column>[federated.0683hos176mghg18jfdcj01g7fgn].[none:Category:nk]</column>
555
+ </slices>
556
+ <aggregation value='true' />
557
+ </view>
558
+ <style>
559
+ <style-rule element='axis'>
560
+ <encoding attr='space' class='0' field='[federated.0683hos176mghg18jfdcj01g7fgn].[sum:Sales:qk]' field-type='quantitative' fold='true' scope='rows' synchronized='true' type='space' />
561
+ <format attr='display' class='0' field='[federated.0683hos176mghg18jfdcj01g7fgn].[sum:Sales:qk]' scope='rows' value='false' />
562
+ <format attr='title' class='0' field='[federated.1lm8rfp01pfb1y157heyi0tdr8s4].[sum:Target:qk]' scope='rows' value='Sales &amp; Target' />
563
+ <format attr='title' class='0' field='[federated.0683hos176mghg18jfdcj01g7fgn].[usr:Calculation_953355781789577216:qk]' scope='rows' value='Performance' />
564
+ </style-rule>
565
+ <style-rule element='label'>
566
+ <format attr='text-format' field='[federated.0683hos176mghg18jfdcj01g7fgn].[tmn:Order Date:qk]' value='*dd-mmm-yy' />
567
+ <format attr='text-orientation' field='[federated.0683hos176mghg18jfdcj01g7fgn].[tmn:Order Date:qk]' value='-90' />
568
+ </style-rule>
569
+ <style-rule element='worksheet'>
570
+ <format attr='font-weight' value='bold' />
571
+ </style-rule>
572
+ <style-rule element='legend-title'>
573
+ <format attr='font-weight' value='normal' />
574
+ </style-rule>
575
+ </style>
576
+ <panes>
577
+ <pane selection-relaxation-option='selection-relaxation-allow'>
578
+ <view>
579
+ <breakdown value='auto' />
580
+ </view>
581
+ <mark class='Bar' />
582
+ <mark-sizing custom-mark-size-in-axis-units='1.0' mark-alignment='mark-alignment-left' mark-sizing-setting='marks-scaling-on' use-custom-mark-size='false' />
583
+ <style>
584
+ <style-rule element='mark'>
585
+ <format attr='mark-labels-cull' value='true' />
586
+ <format attr='mark-labels-show' value='false' />
587
+ </style-rule>
588
+ <style-rule element='pane'>
589
+ <format attr='minwidth' value='1139' />
590
+ <format attr='maxwidth' value='1139' />
591
+ <format attr='minheight' value='145' />
592
+ <format attr='maxheight' value='145' />
593
+ <format attr='aspect' value='0' />
594
+ </style-rule>
595
+ </style>
596
+ </pane>
597
+ <pane id='1' selection-relaxation-option='selection-relaxation-allow' y-axis-name='[federated.0683hos176mghg18jfdcj01g7fgn].[sum:Sales:qk]'>
598
+ <view>
599
+ <breakdown value='auto' />
600
+ </view>
601
+ <mark class='Bar' />
602
+ <mark-sizing mark-sizing-setting='marks-scaling-off' />
603
+ <encodings>
604
+ <color column='[federated.0683hos176mghg18jfdcj01g7fgn].[none:Category:nk]' />
605
+ </encodings>
606
+ <style>
607
+ <style-rule element='mark'>
608
+ <format attr='mark-labels-cull' value='true' />
609
+ <format attr='mark-labels-show' value='false' />
610
+ <format attr='size' value='1.6701657772064209' />
611
+ </style-rule>
612
+ <style-rule element='pane'>
613
+ <format attr='minwidth' value='1139' />
614
+ <format attr='maxwidth' value='1139' />
615
+ <format attr='minheight' value='224' />
616
+ <format attr='maxheight' value='224' />
617
+ <format attr='aspect' value='0' />
618
+ </style-rule>
619
+ </style>
620
+ </pane>
621
+ <pane id='2' selection-relaxation-option='selection-relaxation-allow' y-axis-name='[federated.1lm8rfp01pfb1y157heyi0tdr8s4].[sum:Target:qk]'>
622
+ <view>
623
+ <breakdown value='auto' />
624
+ </view>
625
+ <mark class='Area' />
626
+ <encodings>
627
+ <lod column='[federated.0683hos176mghg18jfdcj01g7fgn].[none:Category:nk]' />
628
+ <lod column='[federated.1lm8rfp01pfb1y157heyi0tdr8s4].[sum:Target:qk]' />
629
+ </encodings>
630
+ <style>
631
+ <style-rule element='mark'>
632
+ <format attr='mark-labels-cull' value='true' />
633
+ <format attr='mark-labels-show' value='false' />
634
+ <format attr='mark-color' value='#a0cbe8' />
635
+ </style-rule>
636
+ <style-rule element='pane'>
637
+ <format attr='minwidth' value='1139' />
638
+ <format attr='maxwidth' value='1139' />
639
+ <format attr='minheight' value='224' />
640
+ <format attr='maxheight' value='224' />
641
+ <format attr='aspect' value='0' />
642
+ </style-rule>
643
+ </style>
644
+ </pane>
645
+ <pane id='3' selection-relaxation-option='selection-relaxation-allow' y-axis-name='[federated.0683hos176mghg18jfdcj01g7fgn].[usr:Calculation_953355781789577216:qk]'>
646
+ <view>
647
+ <breakdown value='auto' />
648
+ </view>
649
+ <mark class='Bar' />
650
+ <mark-sizing mark-sizing-setting='marks-scaling-off' />
651
+ <encodings>
652
+ <color column='[federated.0683hos176mghg18jfdcj01g7fgn].[usr:Calculation_953355781789577216:qk]' />
653
+ <lod column='[federated.0683hos176mghg18jfdcj01g7fgn].[none:Category:nk]' />
654
+ </encodings>
655
+ <style>
656
+ <style-rule element='mark'>
657
+ <format attr='mark-labels-cull' value='true' />
658
+ <format attr='mark-labels-show' value='false' />
659
+ <format attr='size' value='1.087458610534668' />
660
+ </style-rule>
661
+ <style-rule element='pane'>
662
+ <format attr='minwidth' value='1139' />
663
+ <format attr='maxwidth' value='1139' />
664
+ <format attr='minheight' value='145' />
665
+ <format attr='maxheight' value='145' />
666
+ <format attr='aspect' value='0' />
667
+ </style-rule>
668
+ </style>
669
+ </pane>
670
+ </panes>
671
+ <rows>([federated.1lm8rfp01pfb1y157heyi0tdr8s4].[sum:Target:qk] + ([federated.0683hos176mghg18jfdcj01g7fgn].[sum:Sales:qk] + [federated.0683hos176mghg18jfdcj01g7fgn].[usr:Calculation_953355781789577216:qk]))</rows>
672
+ <cols>[federated.0683hos176mghg18jfdcj01g7fgn].[tmn:Order Date:qk]</cols>
673
+ </table>
674
+ <simple-id uuid='{C975713C-4086-4120-A4FC-F5C47D1D3741}' />
675
+ </worksheet>
676
+ </worksheets>
677
+ <windows saved-dpi-scale-factor='1.5' source-height='75'>
678
+ <window class='worksheet' maximized='true' name='Sheet 1'>
679
+ <cards>
680
+ <edge name='left'>
681
+ <strip size='146'>
682
+ <card type='pages' />
683
+ <card type='filters' />
684
+ <card type='marks' />
685
+ </strip>
686
+ </edge>
687
+ <edge name='top'>
688
+ <strip size='2147483647'>
689
+ <card type='columns' />
690
+ </strip>
691
+ <strip size='2147483647'>
692
+ <card type='rows' />
693
+ </strip>
694
+ <strip size='2147483647'>
695
+ <card type='title' />
696
+ </strip>
697
+ </edge>
698
+ <edge name='right'>
699
+ <strip size='100'>
700
+ <card mode='radiolist' param='[federated.0683hos176mghg18jfdcj01g7fgn].[none:Category:nk]' show-all='false' type='filter' />
701
+ <card pane-specification-id='1' param='[federated.0683hos176mghg18jfdcj01g7fgn].[none:Category:nk]' type='color' />
702
+ <card pane-specification-id='3' param='[federated.0683hos176mghg18jfdcj01g7fgn].[usr:Calculation_953355781789577216:qk]' type='color' />
703
+ </strip>
704
+ </edge>
705
+ </cards>
706
+ <viewpoint>
707
+ <highlight>
708
+ <color-one-way>
709
+ <field>[federated.0683hos176mghg18jfdcj01g7fgn].[none:Category:nk]</field>
710
+ <field>[federated.0683hos176mghg18jfdcj01g7fgn].[yr:Order Date:ok]</field>
711
+ </color-one-way>
712
+ </highlight>
713
+ </viewpoint>
714
+ <simple-id uuid='{0F0F4737-D6EB-403D-97C2-ED1040E6DF06}' />
715
+ </window>
716
+ </windows>
717
+ <thumbnails>
718
+ <thumbnail height='192' name='Sheet 1' width='192'>
719
+ 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
947
+ rmv+PxIhINcVIQaiAAAAAElFTkSuQmCC
948
+ </thumbnail>
949
+ </thumbnails>
950
+ </workbook>
tests/test_liveboard_creation_context.py CHANGED
@@ -137,7 +137,9 @@ def test_prepare_liveboard_creation_context_falls_back_to_ddl_columns(monkeypatc
137
  def test_normal_thoughtspot_deploy_path_uses_golden_two_tab_layout():
138
  source = inspect.getsource(thoughtspot_deployer.ThoughtSpotDeployer.deploy_all)
139
 
140
- assert 'layout_strategy="golden_two_tab"' in source
 
 
141
 
142
 
143
  def test_tml_export_parser_accepts_yaml_exports():
 
137
  def test_normal_thoughtspot_deploy_path_uses_golden_two_tab_layout():
138
  source = inspect.getsource(thoughtspot_deployer.ThoughtSpotDeployer.deploy_all)
139
 
140
+ # The golden layout is the default; a DP Migrator build (mirror_tiles from the
141
+ # prospect's dashboard, 2026-10-28) is the one case that takes the mirror layout.
142
+ assert 'layout_strategy="mirror" if mirror_tiles else "golden_two_tab"' in source
143
 
144
 
145
  def test_tml_export_parser_accepts_yaml_exports():
tests/test_migration_questions.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DP Migrator plumbing around the spec: questions per sheet, the blueprint
2
+ statement, the argument loader, and the picture reading's JSON -> spec.
3
+
4
+ Run: pytest tests/test_migration_questions.py -q --noconftest
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import io
10
+ import json
11
+ import zipfile
12
+
13
+ import pytest
14
+
15
+ from demoprep_app.migration import questions as q
16
+ from demoprep_app.migration.image_spec import _extract_json, _spec_from_json
17
+ from demoprep_app.migration.sources import (
18
+ describe_source_arg, load_source_dashboard, load_workbook_arg, spec_result,
19
+ )
20
+ from demoprep_app.migration.spec import (
21
+ MirrorError, MirrorSpec, ROLE_DATE, ROLE_DIMENSION, ROLE_MEASURE, SOURCE_TABLEAU,
22
+ ShelfField, SourceDashboard, SourceField, SourceJoin, SourceSheet, SourceTable, Zone,
23
+ )
24
+ from demoprep_app.migration.statement import HEADING, statement_from_spec
25
+
26
+
27
+ def _spec():
28
+ combo = SourceSheet(
29
+ name="Sheet 1", marks=["Bar", "Area"],
30
+ rows=[ShelfField("Target", ROLE_MEASURE, "sum"), ShelfField("Sales", ROLE_MEASURE, "sum"),
31
+ ShelfField("Sales V Target", ROLE_MEASURE, "sum")],
32
+ cols=[ShelfField("Order Date", ROLE_DATE, grain="month")],
33
+ color=ShelfField("Category", ROLE_DIMENSION), filters=["Category"], dual_axis=True,
34
+ chart_type="ADVANCED_LINE_COLUMN", fidelity="approximated", notes=["dual axis drawn as a combo chart"])
35
+ kpi = SourceSheet(name="Total Sales", marks=["Text"], cols=[ShelfField("Sales", ROLE_MEASURE, "sum")], chart_type="KPI")
36
+ stacked = SourceSheet(name="Mix", marks=["Bar"], rows=[ShelfField("Sales", ROLE_MEASURE, "sum")],
37
+ cols=[ShelfField("Region", ROLE_DIMENSION)], color=ShelfField("Category", ROLE_DIMENSION),
38
+ chart_type="STACKED_COLUMN")
39
+ gone = SourceSheet(name="Returns", marks=["Bar"], rows=[ShelfField("Returns", ROLE_MEASURE, "sum")],
40
+ cols=[ShelfField("Region", ROLE_DIMENSION)], chart_type="COLUMN")
41
+ return MirrorSpec(
42
+ source_kind=SOURCE_TABLEAU, name="Sales vs Target",
43
+ tables=[SourceTable("ListOfOrders", ["a"] * 10, "table", "excel-direct"),
44
+ SourceTable("OrderBreakdown", ["a"] * 8, "table", "excel-direct")],
45
+ joins=[SourceJoin("ListOfOrders", "OrderBreakdown", "Order ID", "Order ID", "inner")],
46
+ fields=[SourceField("Sales", ROLE_MEASURE, "integer", "OrderBreakdown", "sum"),
47
+ SourceField("Target", ROLE_MEASURE, "integer", "SalesTargets", "sum"),
48
+ SourceField("Returns", ROLE_MEASURE, "integer", "OrderBreakdown", "sum"),
49
+ SourceField("Sales V Target", ROLE_MEASURE, "integer", formula="SUM([Sales])-SUM([Target])"),
50
+ SourceField("Number of Records", ROLE_MEASURE, "integer", formula="1"),
51
+ SourceField("Category", ROLE_DIMENSION), SourceField("Region", ROLE_DIMENSION),
52
+ SourceField("Order Date", ROLE_DATE, "date")],
53
+ sheets=[combo, kpi, stacked, gone])
54
+
55
+
56
+ MODEL = [{"name": "Sales", "type": "MEASURE"}, {"name": "Target", "type": "MEASURE"},
57
+ {"name": "Sales Vs Target", "type": "MEASURE"}, {"name": "Category", "type": "ATTRIBUTE"},
58
+ {"name": "Region", "type": "ATTRIBUTE"}, {"name": "Order Date", "type": "ATTRIBUTE"}]
59
+
60
+
61
+ def test_one_question_per_sheet_in_the_models_words():
62
+ qs, dropped, warnings = q.questions_from_spec(_spec(), MODEL)
63
+ assert [x["title"] for x in qs] == ["Sheet 1", "Total Sales", "Mix"]
64
+ assert qs[0]["question"] == "total Sales and total Sales Vs Target and total Target by Order Date monthly", \
65
+ "on a combo the target-like measure goes last, so it is drawn as the line over the bars"
66
+ assert qs[0]["measures"] == ["Sales", "Sales Vs Target", "Target"]
67
+ assert qs[0]["chart_type"] == "ADVANCED_LINE_COLUMN" and "Category" not in qs[0]["question"], \
68
+ "colour is not kept on a combo, so it is not asked"
69
+ assert qs[1]["question"] == "total Sales monthly", "a KPI gets the model's date for its trend"
70
+ assert qs[2]["question"] == "total Sales by Region and Category", "a stacked column keeps its colour split"
71
+ assert dropped == ["Returns"]
72
+ assert any("Returns" in w and "dropped" in w for w in warnings)
73
+
74
+
75
+ def test_resolver_prefers_the_column_whose_words_cover_the_source_name():
76
+ names = ["Sales Vs Target", "Sales", "Product Sub Category", "Category", "ORDER_DATE"]
77
+ assert q.resolve_column("Sales V Target", names) == "Sales Vs Target"
78
+ assert q.resolve_column("Sub-Category", names) == "Product Sub Category"
79
+ assert q.resolve_column("Order Date", names) == "ORDER_DATE"
80
+ assert q.resolve_column("Net Revenue", names) is None
81
+
82
+
83
+ def test_statement_binds_the_vocabulary_without_prohibitions():
84
+ text = statement_from_spec(_spec())
85
+ assert text.startswith(HEADING)
86
+ assert "Sales (sum); Target (sum); Returns (sum)" in text
87
+ assert "Sales V Target = SUM([Sales])-SUM([Target])" in text
88
+ assert "Number of Records" not in text
89
+ assert "Order Date is the date on the charts (monthly grain)" in text
90
+ assert 'inner join to OrderBreakdown on Order ID' in text
91
+ assert '"Sheet 1": Target, Sales, Sales V Target by Order Date (month), colour by Category [combo chart' in text
92
+ assert '"Mix": Sales by Region, colour by Category [stacked column chart]' in text
93
+ assert "do not " not in text.lower() and "never " not in text.lower(), \
94
+ "directives.py reads those as binding prohibitions"
95
+ from demoprep_app.scenario.directives import extract_directives
96
+ assert extract_directives(text).is_empty()
97
+
98
+
99
+ def test_statement_leaves_out_join_keys_and_source_ids():
100
+ spec = _spec()
101
+ spec.fields += [SourceField("Order ID", ROLE_DIMENSION), SourceField("Order ID (OrderBreakdown)", ROLE_DIMENSION)]
102
+ spec.fields[3].formula = "SUM([Sales])-SUM([federated.1lm8rfp01pfb1y157heyi0tdr8s4].[Target])"
103
+ text = statement_from_spec(spec)
104
+ assert "Order ID" not in text.split("Source tables")[0].split("Dimensions")[1].split("Date:")[0]
105
+ assert "Sales V Target = SUM([Sales])-SUM([Target])" in text
106
+ assert "federated." not in text
107
+
108
+
109
+ def test_statement_from_a_picture_says_so():
110
+ spec = _spec()
111
+ spec.source_kind = "image"
112
+ assert "read from a screenshot" in statement_from_spec(spec)
113
+
114
+
115
+ # ---- argument loading ------------------------------------------------------
116
+ def test_workbook_text_and_twbx_zip_both_load(monkeypatch):
117
+ import demoprep_app.migration.sources as sources
118
+ seen = {}
119
+
120
+ def fake_parse(xml_text):
121
+ seen["xml"] = xml_text
122
+ return _spec()
123
+ import demoprep_app.migration.tableau_twb as twb
124
+ monkeypatch.setattr(twb, "parse_twb", fake_parse)
125
+
126
+ assert load_workbook_arg("<?xml version='1.0'?><workbook/>").name == "Sales vs Target"
127
+ assert seen["xml"].startswith("<?xml")
128
+
129
+ buf = io.BytesIO()
130
+ with zipfile.ZipFile(buf, "w") as zf:
131
+ zf.writestr("Data/extract.hyper", b"\x00customer data\x00")
132
+ zf.writestr("Book.twb", "<?xml version='1.0'?><workbook version='18.1'/>")
133
+ uri = "data:application/octet-stream;base64," + base64.b64encode(buf.getvalue()).decode()
134
+ assert load_workbook_arg(uri).name == "Sales vs Target"
135
+ assert "workbook version='18.1'" in seen["xml"]
136
+ assert "customer data" not in seen["xml"]
137
+
138
+ with pytest.raises(MirrorError):
139
+ load_workbook_arg("hello there")
140
+ with pytest.raises(MirrorError):
141
+ load_workbook_arg("data:application/zip;base64," + base64.b64encode(b"PK\x03\x04junk").decode())
142
+ empty = io.BytesIO()
143
+ with zipfile.ZipFile(empty, "w") as zf:
144
+ zf.writestr("Data/x.hyper", b"")
145
+ with pytest.raises(MirrorError, match="no .twb"):
146
+ load_workbook_arg(base64.b64encode(empty.getvalue()).decode())
147
+
148
+
149
+ def test_loader_prefers_the_workbook_and_describes_without_content(monkeypatch):
150
+ import demoprep_app.migration.tableau_twb as twb
151
+ monkeypatch.setattr(twb, "parse_twb", lambda xml: _spec())
152
+ spec, notes = load_source_dashboard("<workbook/>", "data:image/png;base64,AAAA")
153
+ assert spec is not None and notes and "ignored" in notes[0]
154
+ assert load_source_dashboard("", "") == (None, [])
155
+ d = describe_source_arg("<?xml version='1.0'?><workbook>ListOfOrders secret host</workbook>")
156
+ assert d.startswith("<twb xml bytes=") and "ListOfOrders" not in d and "secret" not in d
157
+ assert describe_source_arg("") == ""
158
+
159
+
160
+ def test_spec_result_carries_counts_and_tiles():
161
+ out = spec_result(_spec(), tiles=[{"title": "Sheet 1", "chart_type": "ADVANCED_LINE_COLUMN", "question": "x"}],
162
+ dropped=["Returns"], extra_warnings=["note"])
163
+ assert out["carried"] == {"exact": 3, "approximated": 1, "dropped": 0}
164
+ assert out["tiles_built"][0]["title"] == "Sheet 1" and out["tiles_dropped"] == ["Returns"]
165
+ assert out["warnings"][-1] == "note"
166
+ json.dumps(out)
167
+
168
+
169
+ # ---- picture reading -------------------------------------------------------
170
+ def test_vision_json_becomes_a_spec_with_zones_and_lower_fidelity():
171
+ d = {"dashboard_name": "Sales Overview",
172
+ "measures": [{"name": "Sales", "aggregation": "sum", "format": "currency"}],
173
+ "dimensions": [{"name": "Region", "sample_values": ["East", "West"]}],
174
+ "dates": [{"name": "Order Date", "grain": "month"}],
175
+ "sheets": [
176
+ {"title": "Total Sales", "marks": ["Text"], "measures": [{"name": "Sales"}],
177
+ "position": {"x": 0, "y": 0, "w": 0.25, "h": 0.15}, "confidence": 0.9},
178
+ {"title": "Sales by Region", "marks": ["Bar"], "measures": [{"name": "Sales"}], "y_dimension": "Region",
179
+ "position": {"x": 0, "y": 0.2, "w": 0.5, "h": 0.4}, "confidence": 0.8},
180
+ {"title": "Sales Trend", "marks": ["Line"], "measures": [{"name": "Sales"}], "x": "Order Date",
181
+ "color": "Region", "date": {"name": "Order Date", "grain": "month"},
182
+ "position": {"x": 0.5, "y": 0.2, "w": 0.5, "h": 0.4}, "confidence": 0.7},
183
+ ], "confidence": 0.8, "notes": ["axis labels small"]}
184
+ spec = _spec_from_json(d)
185
+ assert spec.source_kind == "image" and spec.name == "Sales Overview"
186
+ assert [s.chart_type for s in spec.sheets] == ["KPI", "BAR", "LINE"]
187
+ assert all(s.fidelity == "approximated" for s in spec.sheets)
188
+ assert spec.confidence == 0.8
189
+ assert spec.primary_dashboard().sheet_names == ["Sheet 1", "Sheet 2", "Sheet 3"]
190
+ assert spec.sheets[2].color.field == "Region" and spec.sheets[2].date.grain == "month"
191
+ assert {f.name for f in spec.fields} == {"Sales", "Region", "Order Date"}
192
+ assert "axis labels small" in spec.warnings
193
+ qs, dropped, _ = q.questions_from_spec(spec, MODEL)
194
+ assert [x["question"] for x in qs] == ["total Sales monthly", "total Sales by Region",
195
+ "total Sales by Region and Order Date monthly"]
196
+
197
+
198
+ def test_extract_json_tolerates_fences_and_prose():
199
+ assert _extract_json('```json\n{"a": 1}\n```') == {"a": 1}
200
+ assert _extract_json('Here you go: {"a": [1, 2]} thanks') == {"a": [1, 2]}
201
+ assert _extract_json("nothing") is None
tests/test_migration_twb.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """parse_twb: a Tableau workbook's XML -> MirrorSpec.
2
+
3
+ The real workbook in tests/fixtures/tableau_dual_axis_example.twb (Tableau
4
+ 2020.1, two Excel data sources blended, a legacy inner join, a dual-axis
5
+ bar + area sheet) is the ground truth; the small hand-written workbooks below
6
+ cover the grammar the fixture does not use (dashboards, 2020.2 relationships,
7
+ parameters, KPI/bar sheets, Measure Values, table calcs).
8
+
9
+ Run: source .venv/bin/activate && python -m pytest tests/test_migration_twb.py -q --noconftest
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+
18
+ from demoprep_app.migration.spec import (
19
+ FIDELITY_APPROXIMATED, FIDELITY_EXACT, MirrorError, MirrorSpec,
20
+ ROLE_DATE, ROLE_DIMENSION, ROLE_MEASURE, SOURCE_TABLEAU, SourceJoin,
21
+ )
22
+ from demoprep_app.migration.tableau_twb import parse_twb
23
+
24
+ FIXTURE = Path(__file__).parent / "fixtures" / "tableau_dual_axis_example.twb"
25
+
26
+ #: Strings that would leak where the prospect's file lives; none may reach the spec.
27
+ LEAKS = ("C:/", "Tableau/Datasources", "federated.", ".xlsx", "excel-direct.")
28
+
29
+
30
+ @pytest.fixture(scope="module")
31
+ def spec() -> MirrorSpec:
32
+ return parse_twb(FIXTURE.read_text(encoding="utf-8"))
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # the real workbook
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def test_fixture_identity_and_meta(spec):
40
+ assert spec.source_kind == SOURCE_TABLEAU
41
+ assert spec.name == "Sheet 1" # no dashboards: first worksheet
42
+ assert spec.confidence == 1.0
43
+ meta = spec.source_meta
44
+ assert meta["format"] == "twb"
45
+ assert meta["tableau_version"] == "18.1"
46
+ assert meta["source_build"].startswith("2020.1.0")
47
+ assert meta["worksheets"] == 1
48
+ assert meta["dashboards"] == 0
49
+ assert "sha256_12" in meta and meta["bytes"] == FIXTURE.stat().st_size
50
+ assert set(meta) == {"format", "tableau_version", "source_build", "worksheets", "dashboards", "bytes", "sha256_12"}
51
+
52
+
53
+ def test_fixture_tables(spec):
54
+ by_name = {t.name: t for t in spec.tables}
55
+ assert set(by_name) == {"ListOfOrders", "OrderBreakdown", "SalesTargets"}
56
+ assert len(by_name["ListOfOrders"].columns) == 10
57
+ assert {"Order Date", "Ship Mode"} <= set(by_name["ListOfOrders"].columns)
58
+ assert len(by_name["OrderBreakdown"].columns) == 8
59
+ assert "Sub-Category" in by_name["OrderBreakdown"].columns
60
+ assert len(by_name["SalesTargets"].columns) == 3
61
+ assert all(t.connection == "excel-direct" for t in spec.tables)
62
+ assert all(t.kind == "table" for t in spec.tables)
63
+
64
+
65
+ def test_fixture_joins(spec):
66
+ inner = [j for j in spec.joins if j.kind == "inner"]
67
+ blends = [j for j in spec.joins if j.kind == "blend"]
68
+ assert len(spec.joins) == 2
69
+ assert inner == [SourceJoin("ListOfOrders", "OrderBreakdown", "Order ID", "Order ID", "inner")]
70
+ assert blends == [SourceJoin("ListOfOrders+OrderBreakdown", "SalesTargets (P1-AmazingMartEU2)",
71
+ "Category", "Category", "blend")]
72
+ assert any("blended onto ListOfOrders+OrderBreakdown on Category" in w for w in spec.warnings)
73
+
74
+
75
+ def test_fixture_fields(spec):
76
+ measures = {f.name: f for f in spec.measures()}
77
+ assert {"Sales", "Profit", "Quantity", "Discount", "Target", "Sales V Target"} <= set(measures)
78
+ assert (measures["Sales"].aggregation, measures["Sales"].datatype) == ("sum", "integer")
79
+ assert measures["Discount"].datatype == "real"
80
+ assert measures["Target"].datatype == "integer"
81
+ calc = measures["Sales V Target"]
82
+ assert calc.is_calculated
83
+ assert calc.formula == "SUM([Sales])-SUM([federated.1lm8rfp01pfb1y157heyi0tdr8s4].[Target])"
84
+ assert spec.field_named("Number of Records") is None
85
+ assert spec.field_named("Measure Names") is None
86
+ assert not any(f.name.startswith("Calculation_") for f in spec.fields)
87
+ dims = {f.name for f in spec.dimensions()}
88
+ assert {"Category", "Sub-Category", "Region", "Segment", "Ship Mode", "Country", "State", "City",
89
+ "Customer Name", "Product Name"} <= dims
90
+ dates = {f.name for f in spec.date_fields()}
91
+ assert {"Order Date", "Ship Date", "Month of Order Date"} <= dates
92
+ assert measures["Sales"].table == "OrderBreakdown"
93
+ names = [f.name.lower() for f in spec.fields]
94
+ assert len(names) == len(set(names)) # deduped across data sources
95
+
96
+
97
+ def test_fixture_sheet(spec):
98
+ assert len(spec.sheets) == 1
99
+ s = spec.sheets[0]
100
+ assert s.name == "Sheet 1" and s.title == "Sheet 1"
101
+ assert s.marks == ["Bar", "Area"]
102
+ assert s.dual_axis is True
103
+ assert [(f.field, f.role, f.aggregation) for f in s.rows] == [
104
+ ("Target", ROLE_MEASURE, "sum"), ("Sales", ROLE_MEASURE, "sum"), ("Sales V Target", ROLE_MEASURE, "sum")]
105
+ assert [(f.field, f.role, f.grain) for f in s.cols] == [("Order Date", ROLE_DATE, "month")]
106
+ assert s.color is not None and (s.color.field, s.color.role) == ("Category", ROLE_DIMENSION)
107
+ assert s.size is None
108
+ assert s.filters == ["Category"]
109
+ assert s.chart_type == "ADVANCED_LINE_COLUMN"
110
+ assert s.fidelity == FIDELITY_APPROXIMATED
111
+ assert any("combo" in n for n in s.notes)
112
+
113
+
114
+ def test_fixture_tiles_and_summary(spec):
115
+ assert spec.tiles() == [spec.sheets[0]]
116
+ summary = spec.summary()
117
+ json.dumps(summary) # must serialise for the inspect tool
118
+ assert summary["sheets"][0]["date"] == "Order Date (month)"
119
+ assert summary["sheets"][0]["measures"] == ["Target", "Sales", "Sales V Target"]
120
+ assert summary["tiles"] == ["Sheet 1"]
121
+ assert MirrorSpec.from_dict(spec.to_dict()).summary() == summary
122
+
123
+
124
+ def test_fixture_leaks_nothing_about_the_file(spec):
125
+ strings = [spec.name]
126
+ for t in spec.tables:
127
+ strings += [t.name, t.kind, t.connection, *t.columns]
128
+ for f in spec.fields:
129
+ strings += [f.name, f.table, *f.notes] # formulas are kept verbatim and are not names
130
+ for j in spec.joins:
131
+ strings += [j.left_table, j.right_table, j.left_field, j.right_field]
132
+ for s in spec.sheets:
133
+ strings += [s.name, s.title, *s.notes, *s.filters, *(f.field for f in s.shelf_fields)]
134
+ strings += spec.warnings + spec.parameters
135
+ for leak in LEAKS:
136
+ assert not [x for x in strings if leak in x], leak
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # input validation
141
+ # ---------------------------------------------------------------------------
142
+
143
+ @pytest.mark.parametrize("text", ["not xml", "<foo/>", "", " \n", "<workbook>"])
144
+ def test_rejects_non_workbooks(text):
145
+ with pytest.raises(MirrorError) as err:
146
+ parse_twb(text)
147
+ assert "Traceback" not in str(err.value) and "Exception" not in str(err.value)
148
+
149
+
150
+ def test_rejects_oversized_text():
151
+ with pytest.raises(MirrorError, match="25 MB"):
152
+ parse_twb("<workbook>" + " " * (25 * 1024 * 1024 + 1))
153
+
154
+
155
+ def test_rejects_xml_entities():
156
+ with pytest.raises(MirrorError):
157
+ parse_twb("<!DOCTYPE x [<!ENTITY a 'b'>]><workbook>&a;</workbook>")
158
+
159
+
160
+ def test_bare_workbook_has_low_confidence():
161
+ spec = parse_twb("<workbook/>")
162
+ assert spec.name == "Tableau workbook"
163
+ assert spec.sheets == [] and spec.fields == [] and spec.tables == []
164
+ assert spec.confidence == 0.5
165
+ assert any("complete Tableau workbook" in w for w in spec.warnings)
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # hand-written workbooks
170
+ # ---------------------------------------------------------------------------
171
+
172
+ DS = "federated.0abc123def456ghi789"
173
+ DS2 = "federated.9zyx876wvu543tsr210"
174
+
175
+ ORDERS = """<relation connection='excel-direct.1' name='Orders' table='[Orders$]' type='table'>
176
+ <columns header='yes'>
177
+ <column datatype='string' name='Category' ordinal='0'/>
178
+ <column datatype='integer' name='Sales' ordinal='1'/>
179
+ <column datatype='integer' name='Profit' ordinal='2'/>
180
+ <column datatype='date' name='Order Date' ordinal='3'/>
181
+ </columns>
182
+ </relation>"""
183
+
184
+
185
+ def _meta(local: str, parent: str, kind: str, agg: str) -> str:
186
+ return (f"<metadata-record class='column'><remote-name>{local}</remote-name><local-name>[{local}]</local-name>"
187
+ f"<parent-name>[{parent}]</parent-name><local-type>{kind}</local-type>"
188
+ f"<aggregation>{agg}</aggregation></metadata-record>")
189
+
190
+
191
+ ORDERS_META = (_meta("Category", "Orders", "string", "Count") + _meta("Sales", "Orders", "integer", "Sum")
192
+ + _meta("Profit", "Orders", "integer", "Sum") + _meta("Order Date", "Orders", "date", "Year"))
193
+
194
+ DEPS = f"""<column datatype='string' name='[Category]' role='dimension' type='nominal'/>
195
+ <column datatype='date' name='[Order Date]' role='dimension' type='ordinal'/>
196
+ <column datatype='integer' name='[Sales]' role='measure' type='quantitative'/>
197
+ <column datatype='integer' name='[Profit]' role='measure' type='quantitative'/>
198
+ <column-instance column='[Category]' derivation='None' name='[none:Category:nk]' pivot='key' type='nominal'/>
199
+ <column-instance column='[Order Date]' derivation='Month-Trunc' name='[tmn:Order Date:qk]' pivot='key' type='quantitative'/>
200
+ <column-instance column='[Sales]' derivation='Sum' name='[sum:Sales:qk]' pivot='key' type='quantitative'/>
201
+ <column-instance column='[Profit]' derivation='Sum' name='[sum:Profit:qk]' pivot='key' type='quantitative'/>"""
202
+
203
+
204
+ def _datasource(relation: str, metadata: str = "", columns: str = "", caption: str = "Sales Data",
205
+ ds_id: str = DS) -> str:
206
+ return f"""<datasource caption='{caption}' inline='true' name='{ds_id}' version='18.1'>
207
+ <connection class='federated'>
208
+ <named-connections>
209
+ <named-connection caption='Book1' name='excel-direct.1'>
210
+ <connection class='excel-direct' filename='C:/Tableau/Datasources/Book1.xlsx' password='' server='' username='boone'/>
211
+ </named-connection>
212
+ </named-connections>
213
+ {relation}
214
+ <metadata-records>{metadata}</metadata-records>
215
+ </connection>
216
+ {columns}
217
+ </datasource>"""
218
+
219
+
220
+ def _worksheet(name: str, rows: str = "", cols: str = "", mark: str = "Bar", encodings: str = "",
221
+ deps: str = DEPS, filters: str = "", layout: str = "") -> str:
222
+ return f"""<worksheet name='{name}'>
223
+ {layout}
224
+ <table>
225
+ <view>
226
+ <datasources><datasource caption='Sales Data' name='{DS}'/></datasources>
227
+ <datasource-dependencies datasource='{DS}'>{deps}</datasource-dependencies>
228
+ {filters}
229
+ </view>
230
+ <panes><pane><mark class='{mark}'/><encodings>{encodings}</encodings></pane></panes>
231
+ <rows>{rows}</rows>
232
+ <cols>{cols}</cols>
233
+ </table>
234
+ </worksheet>"""
235
+
236
+
237
+ def _workbook(datasources: str = "", worksheets: str = "", dashboards: str = "", extra: str = "") -> str:
238
+ dash = f"<dashboards>{dashboards}</dashboards>" if dashboards else ""
239
+ return f"""<?xml version='1.0' encoding='utf-8' ?>
240
+ <workbook source-build='2020.1.0 (20201.20.0219.0753)' version='18.1' xmlns:user='http://www.tableausoftware.com/xml/user'>
241
+ <datasources>{datasources}</datasources>
242
+ {extra}
243
+ <worksheets>{worksheets}</worksheets>
244
+ {dash}
245
+ </workbook>"""
246
+
247
+
248
+ def test_dashboard_zones_sizes_and_order():
249
+ kpi = _worksheet("KPI", mark="Text", encodings=f"<text column='[{DS}].[sum:Sales:qk]'/>")
250
+ bars = _worksheet("Bars", rows=f"[{DS}].[none:Category:nk]", cols=f"[{DS}].[sum:Sales:qk]")
251
+ dash = """<dashboard name='Overview'>
252
+ <size maxheight='800' maxwidth='1000' minheight='800' minwidth='1000' sizing-mode='fixed'/>
253
+ <zones>
254
+ <zone h='100000' id='1' type='layout-basic' w='100000' x='0' y='0'>
255
+ <zone h='100000' id='2' name='Bars' w='50000' x='50000' y='0'/>
256
+ <zone h='100000' id='3' name='KPI' w='50000' x='0' y='0'/>
257
+ <zone h='10000' id='4' type='text' w='100000' x='0' y='90000'>
258
+ <formatted-text><run>Hello</run></formatted-text>
259
+ </zone>
260
+ </zone>
261
+ </zones>
262
+ <devicelayouts>
263
+ <devicelayout auto-generated='true' name='Phone'>
264
+ <zones><zone h='100000' id='9' name='KPI' w='100000' x='0' y='0'/></zones>
265
+ </devicelayout>
266
+ </devicelayouts>
267
+ </dashboard>"""
268
+ spec = parse_twb(_workbook(_datasource(ORDERS, ORDERS_META), kpi + bars, dash))
269
+
270
+ assert spec.name == "Overview"
271
+ assert spec.source_meta["dashboards"] == 1 and spec.source_meta["worksheets"] == 2
272
+ dash_spec = spec.primary_dashboard()
273
+ assert dash_spec is not None and (dash_spec.width, dash_spec.height) == (1000, 800)
274
+ assert [z.kind for z in dash_spec.zones] == ["sheet", "sheet", "text"] # container skipped, phone layout skipped
275
+ kpi_zone = spec.zone_for("KPI")
276
+ assert (kpi_zone.x, kpi_zone.y, kpi_zone.w, kpi_zone.h) == (0.0, 0.0, 0.5, 1.0)
277
+ bars_zone = spec.zone_for("Bars")
278
+ assert (bars_zone.x, bars_zone.y, bars_zone.w, bars_zone.h) == (0.5, 0.0, 0.5, 1.0)
279
+ text_zone = next(z for z in dash_spec.zones if z.kind == "text")
280
+ assert (text_zone.y, text_zone.h) == (0.9, 0.1)
281
+ assert dash_spec.sheet_names == ["KPI", "Bars"] # by position, not XML order
282
+ assert [s.name for s in spec.tiles()] == ["KPI", "Bars"]
283
+ assert spec.summary()["mirrored_dashboard"] == "Overview"
284
+
285
+
286
+ def test_relationships_read_as_inner_joins():
287
+ returns = """<relation connection='excel-direct.1' name='Returns' table='[Returns$]' type='table'>
288
+ <columns header='yes'><column datatype='string' name='Order ID' ordinal='0'/></columns>
289
+ </relation>"""
290
+ object_graph = f"""<_.fcp.ObjectModelEncapsulateLegacy.true...object-graph>
291
+ <objects>
292
+ <object caption='Orders' id='Orders_1A2B3C'><properties context=''>{ORDERS}</properties></object>
293
+ <object caption='Returns' id='Returns_4D5E6F'><properties context=''>{returns}</properties></object>
294
+ </objects>
295
+ <relationships>
296
+ <relationship>
297
+ <expression op='='>
298
+ <expression op='[Orders_1A2B3C].[Order ID]'/>
299
+ <expression op='[Returns_4D5E6F].[Order ID]'/>
300
+ </expression>
301
+ <first-end-point object-id='Orders_1A2B3C'/>
302
+ <second-end-point object-id='Returns_4D5E6F'/>
303
+ </relationship>
304
+ </relationships>
305
+ </_.fcp.ObjectModelEncapsulateLegacy.true...object-graph>"""
306
+ spec = parse_twb(_workbook(_datasource(object_graph, ORDERS_META)))
307
+
308
+ assert {t.name for t in spec.tables} == {"Orders", "Returns"}
309
+ assert spec.joins == [SourceJoin("Orders", "Returns", "Order ID", "Order ID", "inner")]
310
+ assert "1 relationship(s) read as inner joins" in spec.warnings
311
+
312
+
313
+ def test_legacy_left_join_and_custom_sql():
314
+ joined = f"""<relation join='left' type='join'>
315
+ <clause type='join'>
316
+ <expression op='='><expression op='[Orders].[Category]'/><expression op='[Targets].[Category]'/></expression>
317
+ </clause>
318
+ {ORDERS}
319
+ <relation connection='excel-direct.1' name='Targets' type='text'>SELECT category, target FROM secret_schema.targets</relation>
320
+ </relation>"""
321
+ spec = parse_twb(_workbook(_datasource(joined, ORDERS_META)))
322
+
323
+ assert spec.joins == [SourceJoin("Orders", "Targets", "Category", "Category", "left")]
324
+ targets = next(t for t in spec.tables if t.name == "Targets")
325
+ assert targets.kind == "custom_sql" and targets.columns == [] and targets.connection == "excel-direct"
326
+ assert any("Custom SQL" in w for w in spec.warnings)
327
+ assert not any("secret_schema" in w for w in spec.warnings)
328
+
329
+
330
+ def test_parameters_are_listed_not_carried():
331
+ params = """<datasource hasconnection='false' inline='true' name='Parameters' version='18.1'>
332
+ <aliases enabled='yes'/>
333
+ <column caption='Top N' datatype='integer' name='[Parameter 1]' param-domain-type='range' role='measure' type='quantitative' value='10'/>
334
+ <column caption='Region Pick' datatype='string' name='[Parameter 2]' param-domain-type='list' role='measure' type='nominal' value='&quot;East&quot;'/>
335
+ </datasource>"""
336
+ spec = parse_twb(_workbook(params + _datasource(ORDERS, ORDERS_META)))
337
+
338
+ assert spec.parameters == ["Top N", "Region Pick"]
339
+ assert "2 parameter(s) are not carried: Top N, Region Pick" in spec.warnings
340
+ assert spec.field_named("Top N") is None
341
+ assert {t.name for t in spec.tables} == {"Orders"}
342
+ assert spec.summary()["parameters_not_carried"] == ["Top N", "Region Pick"]
343
+
344
+
345
+ def test_text_mark_with_one_measure_is_a_kpi():
346
+ on_label = _worksheet("Total Sales", mark="Text", encodings=f"<text column='[{DS}].[sum:Sales:qk]'/>")
347
+ on_rows = _worksheet("Total Profit", mark="Text", rows=f"[{DS}].[sum:Profit:qk]")
348
+ spec = parse_twb(_workbook(_datasource(ORDERS, ORDERS_META), on_label + on_rows))
349
+
350
+ label, rows = spec.sheets
351
+ assert (label.chart_type, label.fidelity) == ("KPI", FIDELITY_EXACT)
352
+ assert [f.field for f in label.measures] == ["Sales"]
353
+ assert label.rows == [] and label.cols == []
354
+ assert (rows.chart_type, rows.fidelity) == ("KPI", FIDELITY_EXACT)
355
+ assert [f.field for f in rows.measures] == ["Profit"]
356
+
357
+
358
+ def test_bar_sheet_with_dimension_on_rows():
359
+ sheet = _worksheet("Sales by Category", rows=f"[{DS}].[none:Category:nk]", cols=f"[{DS}].[sum:Sales:qk]",
360
+ filters=f"<filter class='categorical' column='[{DS}].[none:Category:nk]'/>",
361
+ layout="<layout-options><title><formatted-text><run>Sales by </run><run>Category</run></formatted-text></title></layout-options>")
362
+ spec = parse_twb(_workbook(_datasource(ORDERS, ORDERS_META), sheet))
363
+
364
+ s = spec.sheets[0]
365
+ assert s.chart_type == "BAR" and s.fidelity == FIDELITY_EXACT
366
+ assert [(f.field, f.role) for f in s.rows] == [("Category", ROLE_DIMENSION)]
367
+ assert [(f.field, f.role, f.aggregation) for f in s.cols] == [("Sales", ROLE_MEASURE, "sum")]
368
+ assert s.filters == ["Category"]
369
+ assert s.title == "Sales by Category"
370
+ assert s.dual_axis is False
371
+
372
+
373
+ def test_measure_values_expands_to_dependent_measures():
374
+ sheet = _worksheet("Measures", rows="[Multiple Values]", cols=f"[{DS}].[none:Category:nk]",
375
+ encodings=f"<color column='[{DS}].[:Measure Names]'/>")
376
+ spec = parse_twb(_workbook(_datasource(ORDERS, ORDERS_META), sheet))
377
+
378
+ s = spec.sheets[0]
379
+ assert [(f.field, f.role, f.aggregation) for f in s.rows] == [("Sales", ROLE_MEASURE, "sum"),
380
+ ("Profit", ROLE_MEASURE, "sum")]
381
+ assert s.color is None # Measure Names is dropped
382
+ assert "Measure Values expanded" in s.notes
383
+ assert spec.field_named("Measure Names") is None
384
+
385
+
386
+ def test_table_calc_is_noted_and_approximated():
387
+ deps = DEPS.replace(
388
+ "<column-instance column='[Sales]' derivation='Sum' name='[sum:Sales:qk]' pivot='key' type='quantitative'/>",
389
+ "<column-instance column='[Sales]' derivation='Sum' name='[sum:Sales:qk]' pivot='key' type='quantitative'>"
390
+ "<table-calc ordering-type='Rows' type='MovingCalc'/></column-instance>")
391
+ sheet = _worksheet("Trend", mark="Line", rows=f"[{DS}].[sum:Sales:qk]", cols=f"[{DS}].[tmn:Order Date:qk]", deps=deps)
392
+ spec = parse_twb(_workbook(_datasource(ORDERS, ORDERS_META), sheet))
393
+
394
+ s = spec.sheets[0]
395
+ assert s.chart_type == "LINE"
396
+ assert s.fidelity == FIDELITY_APPROXIMATED
397
+ assert "table calculation on Sales is not carried" in s.notes
398
+ assert [(f.field, f.role, f.grain) for f in s.cols] == [("Order Date", ROLE_DATE, "month")]
399
+
400
+
401
+ def test_dual_axis_from_parenthesised_shelf_group():
402
+ sheet = _worksheet("Dual", rows=f"([{DS}].[sum:Sales:qk] + [{DS}].[sum:Profit:qk])",
403
+ cols=f"[{DS}].[tmn:Order Date:qk]")
404
+ spec = parse_twb(_workbook(_datasource(ORDERS, ORDERS_META), sheet))
405
+
406
+ s = spec.sheets[0]
407
+ assert s.dual_axis is True
408
+ assert s.chart_type == "ADVANCED_LINE_COLUMN" and s.fidelity == FIDELITY_APPROXIMATED
409
+
410
+
411
+ def test_uncaptioned_calculation_gets_a_readable_name():
412
+ columns = """<column datatype='real' name='[Calculation_123456789]' role='measure' type='quantitative'>
413
+ <calculation class='tableau' formula='{FIXED [Category] : SUM([Sales])}'/>
414
+ </column>
415
+ <column caption='Margin' datatype='real' name='[Calculation_987654321]' role='measure' type='quantitative'>
416
+ <calculation class='tableau' formula='SUM([Profit]) / SUM([Sales])'/>
417
+ </column>"""
418
+ spec = parse_twb(_workbook(_datasource(ORDERS, ORDERS_META, columns)))
419
+
420
+ unnamed = spec.field_named("Calculated field 1")
421
+ assert unnamed is not None and unnamed.is_calculated
422
+ assert "level-of-detail expression; re-expressed loosely" in unnamed.notes
423
+ assert any("Calculated field 1" in w for w in spec.warnings)
424
+ margin = spec.field_named("Margin")
425
+ assert margin is not None and margin.formula == "SUM([Profit]) / SUM([Sales])" and margin.role == ROLE_MEASURE
426
+ assert not any(f.name.startswith("Calculation_") for f in spec.fields)
427
+
428
+
429
+ def test_unreadable_dashboard_size_is_warned_not_guessed():
430
+ dash = """<dashboard name='Odd'>
431
+ <size maxheight='tall' maxwidth='1000' sizing-mode='fixed'/>
432
+ <zones><zone h='100000' id='1' name='Bars' w='100000' x='0' y='0'/></zones>
433
+ </dashboard>"""
434
+ bars = _worksheet("Bars", rows=f"[{DS}].[none:Category:nk]", cols=f"[{DS}].[sum:Sales:qk]")
435
+ spec = parse_twb(_workbook(_datasource(ORDERS, ORDERS_META), bars, dash))
436
+
437
+ assert (spec.dashboards[0].width, spec.dashboards[0].height) == (1000, 0)
438
+ assert any("'Odd'" in w and "maxheight" in w for w in spec.warnings)
tests/test_mirror_layout.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mirror layout (DP Migrator, boone 2026-10-28): the board copies the source
2
+ dashboard's chart types, titles and arrangement instead of the golden layout.
3
+
4
+ Run: pytest tests/test_mirror_layout.py -q --noconftest
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+
10
+ import pytest
11
+
12
+ from demoprep_app.liveboards.mirror_layout import (
13
+ apply_mirror_layout, match_tiles, retype_for_mirror, _zone_to_grid,
14
+ )
15
+
16
+
17
+ def _viz(viz_id, name, chart_type, query, columns):
18
+ return {
19
+ "id": viz_id,
20
+ "answer": {
21
+ "name": name,
22
+ "search_query": query,
23
+ "display_mode": "CHART_MODE",
24
+ "answer_columns": [{"name": c} for c in columns],
25
+ "chart": {"type": chart_type, "axis_configs": [{"x": [columns[-1]], "y": [columns[0]]}]},
26
+ },
27
+ }
28
+
29
+
30
+ def _tile(title, chart_type, measures, dims=(), date=None, sheet=None, grain="monthly"):
31
+ return {"question": "q", "source": "mirror", "sheet": sheet or title, "title": title,
32
+ "chart_type": chart_type, "fidelity": "exact", "notes": [],
33
+ "measures": list(measures), "dimensions": list(dims), "date": date, "grain": grain}
34
+
35
+
36
+ COMBO_TILE = _tile("Sales vs Target", "ADVANCED_LINE_COLUMN", ["Sales", "Target"], date="Order Date", sheet="Sheet 1")
37
+ KPI_TILE = _tile("Total Sales", "KPI", ["Sales"], date="Order Date", sheet="Sheet 2")
38
+ BAR_TILE = _tile("Sales by Region", "BAR", ["Sales"], dims=["Region"], sheet="Sheet 3")
39
+ TABLE_TILE = _tile("Order Detail", "TABLE", ["Sales", "Profit"], dims=["Customer Name"], sheet="Sheet 4")
40
+
41
+
42
+ def _board():
43
+ return {"liveboard": {"name": "Acme - Sales", "visualizations": [
44
+ _viz("Viz_1", "Total sales and total target by order date monthly", "LINE",
45
+ "[Sales] [Target] [Order Date].monthly", ["Total Sales", "Total Target", "Order Date"]),
46
+ _viz("Viz_2", "Total sales monthly", "LINE", "[Sales] [Order Date].monthly", ["Total Sales", "Order Date"]),
47
+ _viz("Viz_3", "Total sales by region", "COLUMN", "[Sales] [Region]", ["Total Sales", "Region"]),
48
+ _viz("Viz_4", "Sales and profit by customer name", "COLUMN", "[Sales] [Profit] [Customer Name]",
49
+ ["Total Sales", "Total Profit", "Customer Name"]),
50
+ {"id": "Viz_9", "note_tile": {"text": "scaffolding"}},
51
+ ]}}
52
+
53
+
54
+ def test_tiles_match_answers_in_order_and_by_words():
55
+ vizs = [v for v in _board()["liveboard"]["visualizations"] if "note_tile" not in v]
56
+ mapping = match_tiles(vizs, [COMBO_TILE, KPI_TILE, BAR_TILE, TABLE_TILE])
57
+ assert mapping["Viz_1"] is COMBO_TILE
58
+ assert mapping["Viz_3"] is BAR_TILE
59
+ assert mapping["Viz_4"] is TABLE_TILE
60
+ # Out of order: the answers came back shuffled, words still find the tile.
61
+ shuffled = [vizs[2], vizs[0], vizs[3], vizs[1]]
62
+ mapping = match_tiles(shuffled, [COMBO_TILE, KPI_TILE, BAR_TILE, TABLE_TILE])
63
+ assert mapping["Viz_3"] is BAR_TILE and mapping["Viz_1"] is COMBO_TILE
64
+
65
+
66
+ def test_retype_sets_source_types_titles_and_combo_config():
67
+ board = _board()
68
+ changes, mapping = retype_for_mirror(board["liveboard"]["visualizations"], [COMBO_TILE, KPI_TILE, BAR_TILE, TABLE_TILE])
69
+ by_id = {v["id"]: v for v in board["liveboard"]["visualizations"]}
70
+ combo = by_id["Viz_1"]["answer"]
71
+ assert combo["name"] == "Sales vs Target"
72
+ assert combo["chart"]["type"] == "ADVANCED_LINE_COLUMN"
73
+ assert "axis_configs" not in combo["chart"]
74
+ keys = {d["key"] for block in combo["chart"]["custom_chart_config"] for d in block["dimensions"]}
75
+ assert {"x-axis", "y-axis-column", "y-axis-line"} <= keys
76
+ cols = {d["key"]: d for block in combo["chart"]["custom_chart_config"] for d in block["dimensions"]}
77
+ assert cols["y-axis-column"]["axes"][0]["columns"] == ["Total Sales"]
78
+ assert cols["y-axis-line"]["axes"][0]["columns"] == ["Total Target"]
79
+ assert cols["x-axis"]["axes"][0]["column"] == "Order Date"
80
+ state = json.loads(combo["chart"]["client_state_v2"])
81
+ assert state["axisProperties"][0]["properties"]["linkedColumns"] == ["Total Sales", "Total Target"]
82
+
83
+ assert by_id["Viz_2"]["answer"]["chart"]["type"] == "KPI"
84
+ bar = by_id["Viz_3"]["answer"]
85
+ assert bar["chart"]["type"] == "BAR"
86
+ assert bar["chart"]["axis_configs"] == [{"x": ["Region"], "y": ["Total Sales"]}]
87
+ table = by_id["Viz_4"]["answer"]
88
+ assert table["display_mode"] == "TABLE_MODE" and "chart" not in table
89
+ assert any("matched 4 of 4" in c for c in changes)
90
+ assert any("set 4 chart type" in c for c in changes)
91
+ assert len(mapping) == 4
92
+
93
+
94
+ def test_combo_keeps_every_measure_when_names_overlap():
95
+ """Live board fd753f8e (2026-10-29): "Target" matched "Total Sales V Target"
96
+ first and the target line vanished from the combo chart."""
97
+ board = {"liveboard": {"visualizations": [
98
+ _viz("Viz_1", "total sales and total sales v target and total target by order date monthly",
99
+ "LINE", "sum [Sales] sum [Sales V Target] sum [Target] [Order Date].monthly",
100
+ ["Total Sales", "Total Sales V Target", "Total Target", "Month(Order Date)"]),
101
+ ]}}
102
+ tile = _tile("Sheet 1", "ADVANCED_LINE_COLUMN", ["Sales", "Sales V Target", "Target"], date="Order Date", sheet="Sheet 1")
103
+ retype_for_mirror(board["liveboard"]["visualizations"], [tile])
104
+ chart = board["liveboard"]["visualizations"][0]["answer"]["chart"]
105
+ cols = {d["key"]: d for block in chart["custom_chart_config"] for d in block["dimensions"]}
106
+ assert cols["y-axis-column"]["axes"][0]["columns"] == ["Total Sales"]
107
+ assert cols["y-axis-line"]["axes"][0]["columns"] == ["Total Sales V Target", "Total Target"]
108
+ assert cols["x-axis"]["axes"][0]["column"] == "Month(Order Date)"
109
+
110
+
111
+ def test_duplicate_source_titles_stay_distinct():
112
+ board = {"liveboard": {"visualizations": [
113
+ _viz("Viz_1", "a", "LINE", "[Sales] [Order Date].monthly", ["Total Sales", "Order Date"]),
114
+ _viz("Viz_2", "b", "LINE", "[Sales] [Ship Date].monthly", ["Total Sales", "Ship Date"]),
115
+ ]}}
116
+ tiles = [_tile("Trend", "LINE", ["Sales"], date="Order Date", sheet="s1"),
117
+ _tile("Trend", "LINE", ["Sales"], date="Ship Date", sheet="s2")]
118
+ retype_for_mirror(board["liveboard"]["visualizations"], tiles)
119
+ names = [v["answer"]["name"] for v in board["liveboard"]["visualizations"]]
120
+ assert names == ["Trend", "Trend (2)"]
121
+
122
+
123
+ def test_grid_layout_when_the_source_had_no_dashboard():
124
+ board = _board()
125
+ tiles = [COMBO_TILE, KPI_TILE, BAR_TILE, TABLE_TILE]
126
+ _, mapping = retype_for_mirror(board["liveboard"]["visualizations"], tiles)
127
+ mirror = {"name": "Sheet 1", "source_kind": "tableau_twb", "dashboards": []}
128
+ out, changes = apply_mirror_layout(board, mirror, tiles, mapping)
129
+ lb = out["liveboard"]
130
+ assert all("note_tile" not in v for v in lb["visualizations"]), "note tiles are dropped"
131
+ assert "groups" not in lb
132
+ tabs = lb["layout"]["tabs"]
133
+ assert len(tabs) == 1 and tabs[0]["name"] == "Sheet 1"
134
+ tiles_out = {t["visualization_id"]: t for t in tabs[0]["tiles"]}
135
+ assert set(tiles_out) == {"Viz_1", "Viz_2", "Viz_3", "Viz_4"}
136
+ assert tiles_out["Viz_2"]["y"] == 0, "the KPI leads"
137
+ assert tiles_out["Viz_4"]["width"] == 12, "a table spans the row"
138
+ assert all(t["x"] + t["width"] <= 12 for t in tiles_out.values())
139
+ assert "grid" in changes[0]
140
+
141
+
142
+ def test_zone_layout_follows_the_source_dashboard():
143
+ board = _board()
144
+ tiles = [COMBO_TILE, KPI_TILE, BAR_TILE, TABLE_TILE]
145
+ _, mapping = retype_for_mirror(board["liveboard"]["visualizations"], tiles)
146
+ mirror = {"name": "Sales Overview", "source_kind": "tableau_twb", "mirrored_dashboard": "Sales Overview",
147
+ "dashboards": [{"name": "Sales Overview", "width": 1200, "height": 800, "zones": [
148
+ {"sheet": "Sheet 2", "x": 0.0, "y": 0.0, "w": 0.25, "h": 0.2, "kind": "sheet"},
149
+ {"sheet": "Sheet 1", "x": 0.25, "y": 0.0, "w": 0.75, "h": 0.5, "kind": "sheet"},
150
+ {"sheet": "Sheet 3", "x": 0.0, "y": 0.5, "w": 0.5, "h": 0.5, "kind": "sheet"},
151
+ {"sheet": "Sheet 4", "x": 0.5, "y": 0.5, "w": 0.5, "h": 0.5, "kind": "sheet"},
152
+ {"sheet": "", "x": 0, "y": 0, "w": 1, "h": 0.05, "kind": "text"},
153
+ ]}]}
154
+ out, changes = apply_mirror_layout(board, mirror, tiles, mapping)
155
+ tab = out["liveboard"]["layout"]["tabs"][0]
156
+ assert tab["name"] == "Sales Overview"
157
+ t = {x["visualization_id"]: x for x in tab["tiles"]}
158
+ assert t["Viz_2"]["x"] == 0 and t["Viz_2"]["width"] == 3
159
+ assert t["Viz_1"]["x"] == 3 and t["Viz_1"]["width"] == 9
160
+ assert t["Viz_3"]["x"] == 0 and t["Viz_4"]["x"] == 6
161
+ assert t["Viz_3"]["y"] == t["Viz_4"]["y"] > t["Viz_1"]["y"]
162
+ # No two tiles overlap.
163
+ placed = list(t.values())
164
+ for a in placed:
165
+ for b in placed:
166
+ if a is b:
167
+ continue
168
+ assert (a["x"] + a["width"] <= b["x"] or b["x"] + b["width"] <= a["x"]
169
+ or a["y"] + a["height"] <= b["y"] or b["y"] + b["height"] <= a["y"])
170
+ assert "source dashboard zones" in changes[0]
171
+
172
+
173
+ def test_zone_to_grid_respects_the_grid_and_minimums():
174
+ g = _zone_to_grid({"x": 0.9, "y": 0.0, "w": 0.3, "h": 0.01}, (1000, 500))
175
+ assert g["x"] + g["w"] <= 12 and g["w"] >= 2 and g["h"] >= 3
176
+
177
+
178
+ def test_enhance_in_mirror_mode_keeps_titles_and_skips_the_golden_passes(monkeypatch):
179
+ """End to end through enhance_mcp_liveboard with a fake cluster: no groups,
180
+ no title rewrite, source chart types, one tab named after the dashboard."""
181
+ import yaml
182
+ import liveboard_creator as lc
183
+
184
+ board = _board()
185
+ raw = yaml.safe_dump({"guid": "lb-1", **board})
186
+ imported = {}
187
+
188
+ class _Resp:
189
+ def __init__(self, payload):
190
+ self.status_code = 200
191
+ self._payload = payload
192
+
193
+ def json(self):
194
+ return self._payload
195
+
196
+ class _Session:
197
+ def post(self, url, **kwargs):
198
+ if "tml/export" in url:
199
+ return _Resp([{"edoc": raw}])
200
+ imported["body"] = kwargs.get("json")
201
+ return _Resp([{"response": {"status": {"status_code": "OK"}}}])
202
+
203
+ class _Client:
204
+ base_url = "https://example"
205
+ session = _Session()
206
+
207
+ monkeypatch.setattr(lc, "fetch_search_column_map", lambda *a, **k: None)
208
+ monkeypatch.setattr(lc, "_fetch_model_columns", lambda *a, **k: [])
209
+ monkeypatch.setattr(lc, "_humanize_viz_titles",
210
+ lambda **k: pytest.fail("mirror mode must not rewrite the source titles"))
211
+ captured = {}
212
+
213
+ def _import(ts_client, tml):
214
+ captured["tml"] = tml
215
+ return {"success": True, "dropped": []}
216
+ monkeypatch.setattr(lc, "import_liveboard_tml_with_recovery", _import)
217
+
218
+ tiles = [COMBO_TILE, KPI_TILE, BAR_TILE, TABLE_TILE]
219
+ mirror = {"name": "Sales Overview", "source_kind": "tableau_twb", "dashboards": [], "tiles": tiles}
220
+ result = lc.enhance_mcp_liveboard(
221
+ liveboard_guid="lb-1", company_data={"name": "Acme", "use_case": "Sales"},
222
+ ts_client=_Client(), layout_strategy="mirror", mirror=mirror, llm_model=None,
223
+ )
224
+ assert result.get("success"), result
225
+ tml = captured["tml"]["liveboard"]
226
+ names = [v["answer"]["name"] for v in tml["visualizations"]]
227
+ assert names == ["Sales vs Target", "Total Sales", "Sales by Region", "Order Detail"]
228
+ assert "groups" not in tml or not tml["groups"]
229
+ assert tml["layout"]["tabs"][0]["name"] == "Sales Overview"
230
+ types = [v["answer"].get("chart", {}).get("type") for v in tml["visualizations"]]
231
+ assert types[:3] == ["ADVANCED_LINE_COLUMN", "KPI", "BAR"]
232
+ assert tml["visualizations"][3]["answer"]["display_mode"] == "TABLE_MODE"
233
+ assert any(e.startswith("Mirror") for e in result["enhancements"])
tests/test_payload_capture_is_complete.py CHANGED
@@ -39,7 +39,8 @@ def test_capture_includes_every_parameter_of_the_tool():
39
  environment="", schema_style="simple", apply_brand_theme=False,
40
  brand_theme=None, custom_mcp_demo=False,
41
  logo_tile=False, insight_tile=False, homepage_screenshot="",
42
- settings=None): # settings document, 2026-10-22
 
43
  return M._call_payload(inspect.currentframe())
44
 
45
  got = set(sample())
 
39
  environment="", schema_style="simple", apply_brand_theme=False,
40
  brand_theme=None, custom_mcp_demo=False,
41
  logo_tile=False, insight_tile=False, homepage_screenshot="",
42
+ settings=None, # settings document, 2026-10-22
43
+ source_dashboard="", source_dashboard_image=""): # DP Migrator, 2026-10-28
44
  return M._call_payload(inspect.currentframe())
45
 
46
  got = set(sample())
tests/test_payload_reaches_the_database.py CHANGED
@@ -126,7 +126,8 @@ def test_tool_signature_survives_the_entire_path():
126
  environment="", schema_style="simple", apply_brand_theme=False,
127
  brand_theme=None, custom_mcp_demo=False,
128
  logo_tile=False, insight_tile=False, homepage_screenshot="",
129
- settings=None): # settings document, 2026-10-22
 
130
  return M._call_payload(inspect.currentframe())
131
 
132
  results = captured_row(sample())
 
126
  environment="", schema_style="simple", apply_brand_theme=False,
127
  brand_theme=None, custom_mcp_demo=False,
128
  logo_tile=False, insight_tile=False, homepage_screenshot="",
129
+ settings=None, # settings document, 2026-10-22
130
+ source_dashboard="", source_dashboard_image=""): # DP Migrator, 2026-10-28
131
  return M._call_payload(inspect.currentframe())
132
 
133
  results = captured_row(sample())
thoughtspot_deployer.py CHANGED
@@ -3065,7 +3065,7 @@ class ThoughtSpotDeployer:
3065
  company_research: str = None, additional_context: str = None,
3066
  vertical: str = None, line: str = None, function: str = None,
3067
  progress_callback=None, session_logger=None,
3068
- brand_theme: Dict = None) -> Dict:
3069
  """
3070
  Deploy complete data model to ThoughtSpot
3071
 
@@ -3077,6 +3077,10 @@ class ThoughtSpotDeployer:
3077
  brand_theme: A resolved brand theme (BrandTheme.to_dict(), ISS-19) to
3078
  colour the liveboard in. None — every App/Chat build, and any MCP
3079
  build that did not ask — leaves the board in the default palette.
 
 
 
 
3080
 
3081
  Returns:
3082
  Dict with deployment results and names of created objects
@@ -4371,6 +4375,35 @@ class ThoughtSpotDeployer:
4371
  company_data['brand_theme'] = brand_theme
4372
  model_columns = liveboard_context['model_columns']
4373
  matrix_config = liveboard_context['matrix_config']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4374
  # Surface gate/context warnings in the build result, not just the log.
4375
  results['warnings'].extend(liveboard_context.get('warnings') or [])
4376
 
@@ -4441,7 +4474,10 @@ class ThoughtSpotDeployer:
4441
  model_name=model_name,
4442
  company_data=company_data,
4443
  use_case=use_case or 'General Analytics',
4444
- num_visualizations=16, # boone 2026-09-10/16: 4 KPIs + 5-6 exec + 3-4 manager + showcase (16 since the variety push routes 1-in-4 to showcase)
 
 
 
4445
  liveboard_name=liveboard_name,
4446
  matrix_config=matrix_config,
4447
  llm_model=llm_model,
@@ -4604,8 +4640,9 @@ class ThoughtSpotDeployer:
4604
  fix_kpis=True,
4605
  apply_brand_colors=True,
4606
  llm_model=llm_model,
4607
- layout_strategy="golden_two_tab",
4608
  model_id=model_guid,
 
4609
  )
4610
  # Tiles ThoughtSpot rejected were dropped so the rest of
4611
  # the enhancements could land. That gap is REPORTED, never
 
3065
  company_research: str = None, additional_context: str = None,
3066
  vertical: str = None, line: str = None, function: str = None,
3067
  progress_callback=None, session_logger=None,
3068
+ brand_theme: Dict = None, mirror: Dict = None) -> Dict:
3069
  """
3070
  Deploy complete data model to ThoughtSpot
3071
 
 
3077
  brand_theme: A resolved brand theme (BrandTheme.to_dict(), ISS-19) to
3078
  colour the liveboard in. None — every App/Chat build, and any MCP
3079
  build that did not ask — leaves the board in the default palette.
3080
+ mirror: DP Migrator (boone 2026-10-28): the prospect's dashboard as a
3081
+ MirrorSpec dict (demoprep_app.migration.spec). The liveboard then
3082
+ asks one question per source sheet and is laid out like the
3083
+ source (layout_strategy "mirror"). None = the golden board.
3084
 
3085
  Returns:
3086
  Dict with deployment results and names of created objects
 
4375
  company_data['brand_theme'] = brand_theme
4376
  model_columns = liveboard_context['model_columns']
4377
  matrix_config = liveboard_context['matrix_config']
4378
+
4379
+ # Mirror mode (DP Migrator): the liveboard copies the prospect's
4380
+ # dashboard, so its questions come from the source sheets, phrased
4381
+ # with THIS model's column names, not from the LLM. A sheet whose
4382
+ # measures the model lacks is dropped by name, never silently.
4383
+ mirror_tiles: List[Dict] = []
4384
+ if mirror:
4385
+ from demoprep_app.migration.questions import questions_from_spec
4386
+ from demoprep_app.migration.spec import MirrorSpec
4387
+ mirror_tiles, _mirror_dropped, _mirror_warnings = questions_from_spec(
4388
+ MirrorSpec.from_dict(mirror), model_columns)
4389
+ for _w in _mirror_warnings:
4390
+ results['warnings'].append(f"mirror: {_w}")
4391
+ log_progress(f" [WARN] mirror: {_w}")
4392
+ results['mirror'] = {
4393
+ 'tiles': [{'title': t['title'], 'chart_type': t['chart_type'],
4394
+ 'question': t['question'], 'fidelity': t['fidelity'],
4395
+ 'notes': t['notes']} for t in mirror_tiles],
4396
+ 'dropped': list(_mirror_dropped),
4397
+ }
4398
+ if mirror_tiles:
4399
+ log_progress(f" [mirror] {len(mirror_tiles)} question(s) from the source dashboard")
4400
+ else:
4401
+ _w = "mirror: none of the source sheets can be asked of this model; building the standard board"
4402
+ results['warnings'].append(_w)
4403
+ log_progress(f" [WARN] {_w}")
4404
+ if _slog:
4405
+ _slog.log("thoughtspot", "mirror questions", count=len(mirror_tiles),
4406
+ dropped=list(_mirror_dropped), warnings=list(_mirror_warnings))
4407
  # Surface gate/context warnings in the build result, not just the log.
4408
  results['warnings'].extend(liveboard_context.get('warnings') or [])
4409
 
 
4474
  model_name=model_name,
4475
  company_data=company_data,
4476
  use_case=use_case or 'General Analytics',
4477
+ # boone 2026-09-10/16: 4 KPIs + 5-6 exec + 3-4 manager + showcase (16 since
4478
+ # the variety push routes 1-in-4 to showcase). Mirror: one per source sheet.
4479
+ num_visualizations=len(mirror_tiles) if mirror_tiles else 16,
4480
+ explicit_questions=mirror_tiles or None,
4481
  liveboard_name=liveboard_name,
4482
  matrix_config=matrix_config,
4483
  llm_model=llm_model,
 
4640
  fix_kpis=True,
4641
  apply_brand_colors=True,
4642
  llm_model=llm_model,
4643
+ layout_strategy="mirror" if mirror_tiles else "golden_two_tab",
4644
  model_id=model_guid,
4645
+ mirror={**mirror, 'tiles': mirror_tiles} if mirror_tiles else None,
4646
  )
4647
  # Tiles ThoughtSpot rejected were dropped so the rest of
4648
  # the enhancements could land. That gap is REPORTED, never