Spaces:
Running
feat(migrator): DP Migrator beta — rebuild a prospect's Tableau dashboard (or a picture of it) as the demo
Browse filesKetan'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 +16 -1
- demoprep_app/liveboards/mirror_layout.py +358 -0
- demoprep_app/migration/__init__.py +18 -0
- demoprep_app/migration/image_spec.py +238 -0
- demoprep_app/migration/mapping.py +135 -0
- demoprep_app/migration/questions.py +194 -0
- demoprep_app/migration/sources.py +132 -0
- demoprep_app/migration/spec.py +351 -0
- demoprep_app/migration/statement.py +164 -0
- demoprep_app/migration/tableau_twb.py +860 -0
- docs/agent_q_flags.md +4 -1
- docs/agentspot_dp_migrator_agent.md +119 -0
- liveboard_creator.py +158 -115
- mcp_server.py +174 -1
- sprint_2026_04.md +12 -0
- tests/fixtures/tableau_dual_axis_example.twb +950 -0
- tests/test_liveboard_creation_context.py +3 -1
- tests/test_migration_questions.py +201 -0
- tests/test_migration_twb.py +438 -0
- tests/test_mirror_layout.py +233 -0
- tests/test_payload_capture_is_complete.py +2 -1
- tests/test_payload_reaches_the_database.py +2 -1
- thoughtspot_deployer.py +40 -3
|
@@ -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=
|
| 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)
|
|
@@ -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()
|
|
@@ -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 |
+
"""
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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"}}
|
|
@@ -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
|
|
@@ -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]}
|
|
@@ -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")
|
|
@@ -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"]
|
|
@@ -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-
|
| 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`).
|
|
@@ -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.
|
|
@@ -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 |
-
|
| 3805 |
-
|
| 3806 |
-
#
|
| 3807 |
-
|
| 3808 |
-
|
| 3809 |
-
|
| 3810 |
-
|
| 3811 |
-
|
| 3812 |
-
|
| 3813 |
-
|
| 3814 |
-
|
| 3815 |
-
|
| 3816 |
-
|
| 3817 |
-
|
| 3818 |
-
|
| 3819 |
-
|
| 3820 |
-
|
| 3821 |
-
|
| 3822 |
-
|
| 3823 |
-
|
|
|
|
|
|
|
| 3824 |
|
| 3825 |
-
|
| 3826 |
-
|
| 3827 |
-
|
| 3828 |
-
|
| 3829 |
-
|
| 3830 |
-
|
| 3831 |
-
|
| 3832 |
-
|
| 3833 |
-
|
| 3834 |
-
|
| 3835 |
-
|
| 3836 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 5977 |
-
|
| 5978 |
-
|
| 5979 |
-
|
| 5980 |
-
|
| 5981 |
-
|
| 5982 |
-
|
| 5983 |
-
|
| 5984 |
-
|
| 5985 |
-
|
| 5986 |
-
|
| 5987 |
-
|
| 5988 |
-
|
| 5989 |
-
|
| 5990 |
-
|
|
|
|
| 5991 |
|
| 5992 |
-
|
| 5993 |
-
|
| 5994 |
-
|
| 5995 |
-
|
| 5996 |
-
|
| 5997 |
-
|
| 5998 |
-
|
| 5999 |
-
|
| 6000 |
|
| 6001 |
-
|
| 6002 |
-
|
| 6003 |
-
|
| 6004 |
-
|
| 6005 |
-
|
| 6006 |
-
|
| 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 |
-
|
| 6017 |
-
|
| 6018 |
-
|
| 6019 |
-
|
| 6020 |
-
|
| 6021 |
-
|
|
|
|
| 6022 |
|
| 6023 |
-
|
| 6024 |
-
|
| 6025 |
-
|
| 6026 |
-
|
| 6027 |
-
|
|
|
|
| 6028 |
|
| 6029 |
-
|
| 6030 |
-
|
| 6031 |
-
|
| 6032 |
-
|
| 6033 |
-
|
| 6034 |
-
'showLabel': True,
|
| 6035 |
-
'showValue': True,
|
| 6036 |
-
'showPercent': True
|
| 6037 |
-
})
|
| 6038 |
-
}
|
| 6039 |
-
client_state['chartProperties']['chartLevelColorConfig'] = {
|
| 6040 |
-
'colorPalette': {'colors': DONUT_COLORS}
|
| 6041 |
-
}
|
| 6042 |
|
| 6043 |
-
|
| 6044 |
-
|
| 6045 |
-
|
| 6046 |
-
|
| 6047 |
-
|
| 6048 |
-
|
| 6049 |
-
|
| 6050 |
-
|
| 6051 |
-
|
| 6052 |
-
|
| 6053 |
-
|
| 6054 |
-
|
| 6055 |
-
|
| 6056 |
-
|
| 6057 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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.
|
|
@@ -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 |
|
|
@@ -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.
|
|
@@ -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'>"WSTR"</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'>"DATE"</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'>"WSTR"</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'>"WSTR"</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'>"WSTR"</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'>"WSTR"</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'>"WSTR"</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'>"DATE"</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'>"WSTR"</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'>"WSTR"</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'>"A1:J4118:no:A1:J4118:0"</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'>"WSTR"</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'>"WSTR"</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'>"R8"</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'>"I8"</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'>"I8"</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'>"I8"</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'>"WSTR"</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'>"WSTR"</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'>"A1:H8048:no:A1:H8048:0"</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>"Furniture"</bucket>
|
| 397 |
+
</map>
|
| 398 |
+
<map to='#e15759'>
|
| 399 |
+
<bucket>"Technology"</bucket>
|
| 400 |
+
</map>
|
| 401 |
+
<map to='#f28e2b'>
|
| 402 |
+
<bucket>"Office Supplies"</bucket>
|
| 403 |
+
</map>
|
| 404 |
+
</encoding>
|
| 405 |
+
</style-rule>
|
| 406 |
+
</style>
|
| 407 |
+
<semantic-values>
|
| 408 |
+
<semantic-value key='[Country].[Name]' value='"United States"' />
|
| 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'>"DATE"</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'>"WSTR"</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'>"I8"</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'>"A1:C145:no:A1:C145:0"</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='"United States"' />
|
| 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='"Furniture"' 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 & 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>
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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():
|
|
@@ -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
|
|
@@ -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='"East"'/>
|
| 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)
|
|
@@ -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"])
|
|
@@ -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
|
|
|
|
| 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())
|
|
@@ -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
|
|
|
|
| 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())
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|