diff --git a/.gitignore b/.gitignore index e3b5aea3849827fcaf3664a81fbc2275908bd712..3fe5e01f1818cf59e27ceadb59f889272edf16d3 100644 --- a/.gitignore +++ b/.gitignore @@ -228,3 +228,16 @@ scratch/ legitdata_project/venv/ legitdata_project/*.egg-info/ legitdata_project/.DS_Store + +# Portal builder (demoprep_app/portal) — vendored template + specs must be tracked. +# Overrides the broad *.json / lib/ / dist rules above for this directory only. +!demoprep_app/portal/**/*.json +!demoprep_app/portal/template-tse/src/lib/ +!demoprep_app/portal/template-tse/src/lib/** +demoprep_app/portal/template-tse/node_modules/ +demoprep_app/portal/template-tse/dist/ +# Generated portal apps (codemod output) stay out of the repo +/*-tse/ + +# Portal build working dir (scripts/build_portal.py output) +/build/ diff --git a/CLAUDE.md b/CLAUDE.md index 69b2c392c04d27d60b5a73ecde53b22e9f81e6a8..a2c8ae2f09e14f04c4d52854e0b7fc95efd9f155 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,37 @@ For overall project status, see `PROJECT_STATUS.md` in root. | `main` | Production — always stable and shippable | `hf` (prod) | | `develop` | Sprint integration — current sprint work | `hf-test` | | `feature/*` | Individual features — branch off `develop` | local / `hf-test` | +| `deploy/mcp` | MCP Space integration — shares history via `develop` merges | `hf-mcp-test`, then `hf-mcp` (prod) | + +### Remotes + +| Remote | Space | Tier | +|--------|-------|------| +| `hf` | `thoughtspot-dp/demoprep` | app — **PROD** | +| `hf-test` | `thoughtspot-dp/test-demoprep` | app — test | +| `hf-mcp` | `thoughtspot-demoprep/mcp` | MCP — **PROD** | +| `hf-mcp-test` | `thoughtspot-demoprep/test-mcp` | MCP — test | + +### ⚠️ Reality check (verified 2026-08-26) — the model below is ASPIRATIONAL + +The documented flow and the deployed truth have drifted. Verify before trusting: + +| Claim in this doc | Actual state on 2026-08-26 | +|---|---| +| `develop` → `hf-test` | **False.** `hf-test/main` == `spike/portal-template`. A spike branch is the de facto integration branch. | +| `develop` is sprint integration | **Stale.** `develop` (tip `8c598cc`) is missing the current CLAUDE.md and the whole "Data Quality Push" sprint section. | +| Space remotes only receive branch pushes | **False.** `hf`/`hf-mcp` accept direct pushes and routinely hold commits no local branch has (that is how `4b87c36` came to exist only on the Space). | + +**Always confirm what is actually deployed before reasoning about it:** +```bash +git fetch --all +git log --oneline -1 hf-mcp/main # what PROD MCP is really running +git branch --all --contains # who has a given fix +``` +Deployed sha is also visible per-Space via `HfApi().space_info(repo_id).sha`. + +**Do not "fix" this drift by rewriting the model here** — decide the intended +model with boone first, then make doc and reality match in one deliberate pass. ### Rules @@ -117,8 +148,38 @@ git push hf-test develop:main git push origin main develop ``` +**MCP Space deploys (`deploy/mcp`) — test tier is MANDATORY:** +```bash +git checkout deploy/mcp +git merge develop # pick up sprint work +# ... MCP-specific work ... +git push hf-mcp-test deploy/mcp:main # 1. deploy to TEST first — ALWAYS +# verify (see smoke check below), THEN: +git push hf-mcp deploy/mcp:main # 2. deploy to PROD +``` + +**MCP post-deploy smoke check — a green build log is NOT proof:** +```bash +curl -s https://thoughtspot-demoprep-test-mcp.hf.space/ +``` +Expect `{"status":"ok"}`. The health endpoint is unauthenticated, but it only +proves the container booted — it does **not** exercise a build. Also call the +`ping` tool, and run one real `build_demo_from_brief` against test before prod. + +> **The 2026-08-25 outage happened because logging code that only the MCP path +> constructs (`PromptLogger`) raised `PermissionError: '/app/logs'` under the +> Space's non-root user — every MCP build returned `failed`, while `GET /` still +> answered `ok`. HF Spaces run the container as a non-root user inside a +> root-owned WORKDIR, and `logs/` is gitignored so it is absent from the image. +> Anything that writes to disk at runtime must resolve a writable dir or degrade.** + > **Never push sprint/feature work directly to `main`. -> Never push to `hf` (prod) from anything other than `main`.** +> Never push to `hf` (prod) from anything other than `main`. +> Never push to `hf-mcp` (prod) without deploying to `hf-mcp-test` first. +> Never leave a hotfix living only on a Space remote** — a fix pushed straight to +> `hf-mcp`/`hf` that is not merged back into `deploy/mcp`, `develop`, AND `main` +> will be silently reverted by the next routine deploy. After ANY Space hotfix, +> run `git branch --all --contains ` and confirm every deploy branch lists it. ### Sprint Doc Workflow - IMPORTANT @@ -209,7 +270,7 @@ Example: ## Key Project Facts ### Environment Setup -- **Python**: Using virtual environment at `./demoprep/bin/activate` +- **Python**: Using virtual environment at `./.venv/bin/activate` - **NOT using conda** - if you don't see packages, activate the venv first - **Supabase**: IS installed and configured - credentials in .env work fine - **ThoughtSpot**: Trusted auth via per-environment key (selected from TS Environment dropdown). No global admin user — objects are created under the logged-in user's email. @@ -218,7 +279,7 @@ Example: ```bash # Run the app (chat_interface.py IS the full app — App tab, Chat tab, Admin, all tabs) -source ./demoprep/bin/activate && python chat_interface.py +source ./.venv/bin/activate && python chat_interface.py # Check git changes git diff --stat @@ -232,7 +293,7 @@ lsof -i :7860 2. **DO NOT** try to install supabase or other packages - they're already in the venv 3. **DO NOT** change default values to "thoughtspot.com" - that's for customer URLs, not ThoughtSpot's 4. **DO NOT** assume worksheets are needed - they're deprecated, we use models now -5. **ALWAYS** use the venv when running Python: `source ./demoprep/bin/activate && python` +5. **ALWAYS** use the venv when running Python: `source ./.venv/bin/activate && python` ### Working Patterns - Settings save/load through Supabase - this WORKS when using venv diff --git a/Dockerfile b/Dockerfile index 02e5276604051ebf62204076c012a47cecef8be6..97d1eb62bbcb23f9a495c443403071c228c75051 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,13 +12,25 @@ FROM python:3.11-slim RUN useradd -m -u 1000 user WORKDIR /app -RUN apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/* +# node + npm are required by the build_portal tool: the portal codemod is a Node +# script and the site is built with vite. Debian bookworm ships Node 18, which +# satisfies vite 5 (^18 || >=20). +RUN apt-get update && apt-get install -y git curl nodejs npm \ + && rm -rf /var/lib/apt/lists/* COPY --chown=user requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY --chown=user . . +# Pre-install the portal template's node_modules AS ROOT, at image build time. +# The codemod installs them on first use if absent — but the runtime user cannot +# write into /app, so that would fail exactly like the 2026-08-25 PromptLogger +# outage. Installing here means every portal build finds them ready. +RUN cd demoprep_app/portal/template-tse \ + && npm install --no-audit --no-fund --loglevel=error \ + && chown -R user:user node_modules + USER user ENV MCP_TRANSPORT=http \ MCP_HTTP_PORT=7860 \ diff --git a/chat_interface.py b/chat_interface.py index 3157fbac9e452d1ce36b66bcbf150524244694fe..2a5a01c76aaa9cb4441339c0138827a58b5dad95 100644 --- a/chat_interface.py +++ b/chat_interface.py @@ -395,6 +395,7 @@ class ChatDemoInterface: self._session_logger = None self._prompt_logger = None self._demo_bundle = None + self._demo_blueprint = None def clear_deployment_completion(self) -> None: self.deployment_completion = None @@ -3165,6 +3166,7 @@ To change settings, use: schema_name = f"DM{yymmdd}_{hhmmss}_{company_clean}_{usecase_clean}" self._demo_bundle = None + self._demo_blueprint = None from demoprep_app.pipeline.build_demo import build_demo row_count_guidance = int(self.settings.get("fact_table_size", 5000) or 5000) @@ -3194,6 +3196,10 @@ To change settings, use: "Check the warnings above and retry." ) self._demo_bundle = build.dataset + # The blueprint is what the post-load quality gate needs: the + # formulas it can recompute a bad column from, and the measures the + # demo's story actually depends on. + self._demo_blueprint = build.blueprint self.demo_builder.schema_generation_results = build.ddl self.ddl_code = build.ddl for warning in build.warnings: @@ -3959,22 +3965,34 @@ LegitData will generate realistic, AI-powered data. log_progress(f"[OK] {pop_message}") # Data-quality gate: after every load, profile the MEASURE columns - # that were actually written to Snowflake and fail loudly if any is - # entirely zero/null (recurring zero-measure defect — derived - # measures like TOTAL_*_USD loading as all zeros → blank tiles). + # that were actually written to Snowflake, then REPAIR what it can + # (recompute a derived column from its formula, clamp a part to its + # whole, binarize a flag) and OMIT what it cannot. It aborts the run + # only when there is nothing demo-able left — a board with one + # missing tile beats an empty schema. from demoprep_app.integrations.snowflake import ( - ZeroMeasureError, + MeasureQualityGateError, + apply_measure_omissions, + measure_formulas, run_measure_quality_gate, ) progress += f"\n\n**Verifying data quality (measure columns)...**" yield progress log_progress("") log_progress("Running data-quality gate on measure columns...") + _dq_formulas = measure_formulas(self._demo_bundle) if self._demo_bundle else {} + _dq_required = sorted({ + i.measure for i in getattr(self._demo_blueprint, "insights", []) or [] if i.measure + }) try: dq_profile = run_measure_quality_gate( - deployer.connection, schema_name, progress_callback=log_progress + deployer.connection, + schema_name, + progress_callback=log_progress, + formulas=_dq_formulas, + required_measures=_dq_required, ) - except ZeroMeasureError as e: + except MeasureQualityGateError as e: self._last_population_error = str(e) self._last_schema_name = schema_name _deploy_meta = { @@ -3991,20 +4009,46 @@ LegitData will generate realistic, AI-powered data. raise Exception(str(e)) dq_warnings = dq_profile.get("warnings", []) + dq_repairs = dq_profile.get("repairs", []) + dq_omissions = dq_profile.get("omissions", []) self._dq_warnings = dq_warnings progress += ( - f"\n[OK] Data quality gate passed " - f"({dq_profile['measure_columns_checked']} measure columns " - f"across {dq_profile['tables_checked']} tables)" + f"\n[OK] Data quality gate: " + f"{dq_profile['measure_columns_checked']} measure columns " + f"across {dq_profile['tables_checked']} tables" ) + for _dq_r in dq_repairs: + progress += f"\n[REPAIR] {_dq_r}" + if dq_omissions: + # A column dropped in Snowflake has to disappear from the DDL + # too: the ThoughtSpot model and liveboard are built from it + # later in the run and would otherwise declare a column that no + # longer exists. + _removed = apply_measure_omissions(self._demo_bundle, dq_omissions) + if _removed: + from demoprep_app.ddl import DatasetDdlCompiler + _new_ddl = DatasetDdlCompiler().compile(self._demo_bundle) + self.ddl_code = _new_ddl + self.demo_builder.schema_generation_results = _new_ddl + for _dq_o in dq_omissions: + progress += ( + f"\n[OMITTED] {_dq_o['table']}.{_dq_o['column']} " + f"({_dq_o['defect']}: {_dq_o['detail']})" + ) for _dq_w in dq_warnings: progress += f"\n[WARN] {_dq_w}" if _slog: _slog.log_verbose( - "deploy", "data quality gate passed", + "deploy", "data quality gate complete", measure_columns_checked=dq_profile["measure_columns_checked"], tables_checked=dq_profile["tables_checked"], dq_warnings=dq_warnings, + dq_repairs=dq_repairs, + dq_omissions=[f"{o['table']}.{o['column']}" for o in dq_omissions], + dq_defects=[ + f"{d['table']}.{d['column']}:{d['defect']}" + for d in dq_profile.get("defects", []) + ], ) self._deployed_schema_name = schema_name @@ -4017,6 +4061,12 @@ LegitData will generate realistic, AI-powered data. "measure_columns_checked": dq_profile["measure_columns_checked"], "tables_checked": dq_profile["tables_checked"], "warnings": dq_warnings, + "repairs": dq_repairs, + "omissions": [f"{o['table']}.{o['column']}" for o in dq_omissions], + "defects": [ + f"{d['table']}.{d['column']}:{d['defect']}" + for d in dq_profile.get("defects", []) + ], }, } diff --git a/demoprep_app/dataset/engine.py b/demoprep_app/dataset/engine.py index 72e93de699d186fd7237149e7ac24cb8ef313b7e..54afd3261e67826f2e0d5e38461de5217cb6b4ef 100644 --- a/demoprep_app/dataset/engine.py +++ b/demoprep_app/dataset/engine.py @@ -15,12 +15,15 @@ from __future__ import annotations import ast import math -import operator import random from datetime import date, timedelta from typing import Any from demoprep_app.dataset.contracts import DatasetBundle, DatasetColumn, DatasetTable +from demoprep_app.dataset.formula import ( + evaluate as eval_formula, + identifiers as formula_identifiers, +) from demoprep_app.scenario.blueprint import ( BlueprintDimension, BlueprintMeasure, @@ -46,7 +49,22 @@ def _safe_date(value): class BlueprintEngine: """Deterministic executor for DemoBlueprint -> DatasetBundle.""" + def __init__(self) -> None: + # Warnings raised while evaluating formulas (currently: division by + # zero, which is legitimate but worth surfacing). Everything that is + # NOT legitimate raises FormulaEvaluationError instead of silently + # writing 0.0 to every row. + self._formula_warnings: list[str] = [] + + def formula_warnings(self) -> list[str]: + """One deduplicated line per distinct formula warning, with row counts.""" + from collections import Counter + + counts = Counter(self._formula_warnings) + return [f"{message} ({count} row(s))" for message, count in sorted(counts.items())] + def generate(self, blueprint: DemoBlueprint, row_count: int | None = None) -> DatasetBundle: + self._formula_warnings = [] rng = random.Random(blueprint.seed or 1) total_target = max(500, int(row_count or 5000)) per_fact = max(500, total_target // max(1, len(blueprint.facts))) @@ -198,7 +216,27 @@ class BlueprintEngine: # Never clamp a derived value: a negative margin / ending # inventory is legitimate and must still satisfy its formula # (the validator re-checks it against the stored values). - values[m.name] = _format_value(_eval_formula(m.formula, values), m.fmt, clamp=False) + # eval_formula RAISES on an uncomputable formula — it used to + # return 0.0, which shipped all-zero and fabricated columns. + values[m.name] = _format_value( + eval_formula( + m.formula, values, measure=m.name, warnings=self._formula_warnings + ), + m.fmt, + clamp=False, + ) + continue + if _is_flag_measure(m): + # A *_FLAG column is a boolean event marker: 0 or 1, never + # 5 or 158 (Triumph shipped both). Sample the EVENT, not a + # magnitude, so any rate built on it is meaningful. Insights + # and dimension performance move the probability, so a + # planted story on a flag still shows up. + flag_p = _flag_probability(m) + flag_p *= _insight_multiplier(insights_by_measure.get(m.name, ()), picked, d) + if m.seasonal: + flag_p *= perf_multiplier + values[m.name] = 1 if rng.random() < min(max(flag_p, 0.01), 0.99) else 0 continue if m.share_of: # "part of a whole": a fraction of the parent's value. The parent @@ -209,8 +247,36 @@ class BlueprintEngine: low = m.params.get("low", 0.05) high = m.params.get("high", 0.85) imult = _insight_multiplier(insights_by_measure.get(m.name, ()), picked, d) - frac = min(max(rng.uniform(low, high) * imult, 0.0), 1.0) - values[m.name] = _format_value(values.get(m.share_of, 0.0) * frac, m.fmt) + # The FRACTION must carry dimension performance, or every + # rate built as part/whole is identical across the board. + # perf_multiplier scales the parent AND (without this) the + # part equally, so it cancels: rate = (whole*p*f)/(whole*p) + # = f. A collection rate then reads the same for the best and + # worst provider — AdvancedMD shipped 0.7% spread. Damped, + # because a rate should move less than the money it derives + # from: a 40% better provider collects somewhat more of what + # it bills, not 40% more. + # Performance moves WHERE IN THE BAND this row lands, it does + # not push past it. Multiplying the sampled fraction instead + # drove 16% of rows through the ceiling and pinned them at a + # 100% collection rate — a clamp artifact that reads as a bug + # on a chart. [low, high] is the blueprint's statement of what + # is plausible; a strong performer sits high in that range, + # not outside it. + # WARP the sampling curve rather than scaling and clamping. + # u**(1/p**2) is a smooth monotone map of uniform(0,1) onto + # itself: a strong performer's draws bunch toward the top of + # the band, a weak one's toward the bottom, with no hard edge + # and so no pile-up. Scaling the fraction directly instead + # pinned 16% of rows at a 100% collection rate; positioning + # linearly inside the band removed the artifact but flattened + # the spread back to 5.9%, under the 10-12% the reviewer set. + frac_perf = max(1.0 + (perf_multiplier - 1.0) * RATE_PERF_SENSITIVITY, 0.05) + position = min(max(rng.random() ** (1.0 / frac_perf ** 2) * imult, 0.0), 1.0) + frac = min(max(low + (high - low) * position, 0.0), 1.0) + values[m.name] = _format_value( + values.get(m.share_of, 0.0) * frac, m.fmt, pct_max=_pct_bound(m) + ) continue if m.per and f"{m.per}_KEY" in row: # Stable characteristic of an entity: one level per member, @@ -227,10 +293,20 @@ class BlueprintEngine: mult *= _insight_multiplier(insights_by_measure.get(m.name, ()), picked, d) # Format base values NOW so derived formulas — and the validator, # which re-checks formulas against stored values — see identical numbers. - values[m.name] = _format_value(base * mult, m.fmt) + values[m.name] = _format_value( + base * mult, m.fmt, pct_max=_pct_bound(m), + signed=getattr(m, "signed", False), + ) for m in fact.measures: - row[m.name] = values.get(m.name, 0.0) + if m.name not in values: + # Dependency ordering covers every measure, so this can only + # mean an engine bug. Say so instead of writing a silent 0. + raise RuntimeError( + f"Fact {fact.name}: measure {m.name} was never evaluated " + f"(kind={m.kind}, formula='{m.formula}') — refusing to store 0." + ) + row[m.name] = values[m.name] rows.append(row) return DatasetTable(fact.name, fact.grain, columns, rows, is_fact=True) @@ -266,6 +342,12 @@ def _trend_multiplier(pct_per_year: float, start: date, current: date) -> float: return (1.0 + pct_per_year / 100.0) ** years_elapsed +# How strongly a part/whole FRACTION responds to dimension performance. Below +# 1.0 so rates vary less than the money they come from, and so the bound +# part <= whole is not constantly pushed against. +RATE_PERF_SENSITIVITY = 1.0 + + def _sample(measure: BlueprintMeasure, rng: random.Random) -> float: p = measure.params if measure.distribution == "uniform": @@ -273,6 +355,12 @@ def _sample(measure: BlueprintMeasure, rng: random.Random) -> float: mean = p.get("mean", 1.0) sigma = p.get("sigma", 0.4) if measure.distribution == "normal": + if getattr(measure, "signed", False): + # sigma is an ABSOLUTE spread here. A signed measure is centred at + # or near zero, and the relative branch below (mean * sigma) would + # collapse to a stddev of 0 — an all-zero column, the exact defect + # this release exists to remove. + return rng.gauss(mean, abs(sigma) or 1.0) return max(0.0, rng.gauss(mean, abs(sigma) if sigma > 1 else mean * abs(sigma))) # lognormal: mean is the desired arithmetic mean, sigma the log-space spread sigma = min(max(abs(sigma), 0.05), 1.5) @@ -298,55 +386,79 @@ def _insight_multiplier(entries, picked: dict[str, str], d: date) -> float: return mult -def _format_value(value: float, fmt: str, clamp: bool = True) -> Any: +def _format_value(value: float, fmt: str, clamp: bool = True, pct_max: float = 100.0, + signed: bool = False) -> Any: """Round a value for storage. clamp bounds sampled base measures to a sane - range (no negative units, pct in 0-100); derived measures pass clamp=False - so their stored value stays faithful to the formula the validator checks.""" + range (no negative units, pct within its natural bound); derived measures + pass clamp=False so their stored value stays faithful to the formula the + validator checks. + + pct_max is the measure's natural upper bound: 1.0 for a 0-1 rate, 100.0 for + a 0-100 percentage. Guessing 100 for a 0-1 rate is how a "utilization rate" + of 42.0 (meaning 4200%) reaches a KPI tile. + """ if fmt == "int": v = int(round(value)) - return max(0, v) if clamp else v + return max(0, v) if (clamp and not signed) else v if fmt in ("currency", "float"): return round(value, 2) if fmt == "pct": if clamp: - value = min(max(value, 0.0), 100.0) - return round(value, 2) + value = min(max(value, -pct_max if signed else 0.0), pct_max) + return round(value, 4 if pct_max <= 1.0 else 2) return round(value, 2) -def _sql_type(measure: BlueprintMeasure) -> str: - return "NUMBER" if measure.fmt == "int" else "NUMBER(16,2)" - - -# --- Safe arithmetic formula evaluation (no eval) -------------------------- - -_OPS = { - ast.Add: operator.add, - ast.Sub: operator.sub, - ast.Mult: operator.mul, - ast.Div: lambda a, b: a / b if b else 0.0, - ast.USub: operator.neg, -} +def _pct_bound(measure: BlueprintMeasure) -> float: + """Infer whether a pct measure is on a 0-1 or 0-100 scale from its params. + + The blueprint does not declare the scale, so read it off the numbers the + LLM asked for: a mean/high above 1.5 can only mean a 0-100 percentage. + """ + if measure.fmt != "pct": + return 100.0 + declared = [ + measure.params.get(key) + for key in ("mean", "high", "low") + if isinstance(measure.params.get(key), (int, float)) + ] + return 100.0 if any(value > 1.5 for value in declared) else 1.0 + + +def _is_flag_measure(measure: BlueprintMeasure) -> bool: + """A *_FLAG measure is a 0/1 event marker regardless of what the LLM declared.""" + return measure.name.upper().endswith("_FLAG") + + +def _flag_probability(measure: BlueprintMeasure) -> float: + """Probability that a flag is 1, read from whatever the blueprint declared. + + Accepts an explicit params.p, else a mean/high already expressed as a + fraction; anything else (a lognormal mean of 40 for a flag) falls back to + an even split rather than inventing a magnitude. + """ + for key in ("p", "probability"): + value = measure.params.get(key) + if isinstance(value, (int, float)) and 0.0 < float(value) <= 1.0: + return float(value) + for key in ("mean", "high"): + value = measure.params.get(key) + # Strictly < 1: a declared mean of exactly 1 (or 9.6) is a magnitude, + # not a probability, and reading it as one produces an always-1 flag — + # which is just the all-constant defect wearing a different hat. + if isinstance(value, (int, float)) and 0.0 < float(value) < 1.0: + return float(value) + return 0.5 -def _eval_formula(formula: str, values: dict[str, float]) -> float: - try: - tree = ast.parse(formula, mode="eval") - return float(_eval_node(tree.body, values)) - except Exception: - return 0.0 +def _sql_type(measure: BlueprintMeasure) -> str: + return "NUMBER" if measure.fmt == "int" else "NUMBER(16,2)" -def _eval_node(node: ast.AST, values: dict[str, float]) -> float: - if isinstance(node, ast.BinOp) and type(node.op) in _OPS: - return _OPS[type(node.op)](_eval_node(node.left, values), _eval_node(node.right, values)) - if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS: - return _OPS[type(node.op)](_eval_node(node.operand, values)) - if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): - return float(node.value) - if isinstance(node, ast.Name): - return float(values.get(node.id, values.get(node.id.upper(), 0.0))) - raise ValueError(f"Unsupported expression node: {ast.dump(node)}") +# --- Formula evaluation ---------------------------------------------------- +# The language (parse, validate, evaluate, compile-to-SQL) lives in +# demoprep_app/dataset/formula.py so the blueprint validator, this engine, the +# dataset validator and the Snowflake quality gate all agree on it. # --- Measure dependency analysis ------------------------------------------- @@ -387,10 +499,7 @@ def _order_measures(measures: list) -> list: def _formula_names(formula: str) -> set[str]: - try: - return {n.id for n in ast.walk(ast.parse(formula, mode="eval")) if isinstance(n, ast.Name)} - except Exception: - return set() + return formula_identifiers(formula) def _signed_bases(formula: str, measures_by_name: dict, base_names: set[str]) -> tuple[set[str], set[str]]: @@ -413,6 +522,11 @@ def _signed_bases(formula: str, measures_by_name: dict, base_names: set[str]) -> walk(node.right, -sign, seen) elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): walk(node.operand, -sign, seen) + elif isinstance(node, ast.Call): + # ABS/ROUND/MIN/MAX/COALESCE preserve the direction of their inputs + # closely enough to pick a base to scale for an insight. + for arg in node.args: + walk(arg, sign, seen) elif isinstance(node, ast.Name): if node.id in base_names: (pos if sign > 0 else neg).add(node.id) diff --git a/demoprep_app/dataset/formula.py b/demoprep_app/dataset/formula.py new file mode 100644 index 0000000000000000000000000000000000000000..5e1b23e512b78b0f13098929026f547fee163088 --- /dev/null +++ b/demoprep_app/dataset/formula.py @@ -0,0 +1,456 @@ +"""The measure-formula language: parse, validate, evaluate, compile to SQL. + +ONE definition of what a derived-measure formula may contain, shared by +everything that touches formulas: + + * blueprint.py — validates formulas BEFORE any generation, Snowflake, or + ThoughtSpot work happens, and repairs the fixable ones. + * engine.py — evaluates them per row while generating the dataset. + * validator.py — re-checks stored values against their formulas. + * quality_gate.py— recomputes a bad column IN Snowflake via to_sql(). + +Why this module exists (2026-08-26): the evaluator used to swallow every +failure and return 0.0, so an unsupported function (`ABS(...)`), a typo, or a +reference to a measure in a DIFFERENT fact table silently produced a column of +zeros — or, worse, a plausible-looking wrong number (`0 - FORECAST_PRICE`). +Nothing validated formulas upstream. See +dev_notes/BLOCKING_ISSUES_2026_08_26.md ISSUE A. + +Rules of the language: + * arithmetic: + - * / and unary minus + * numeric literals + * identifiers: measures in the SAME fact table, nothing else + * functions: only the whitelist below (the ones LLMs actually write) + * division by zero yields 0.0 and records a warning — that one is legitimate + (a rate with no denominator rows), everything else raises. +""" + +from __future__ import annotations + +import ast +import math +import operator +from typing import Any, Callable, Iterable, Mapping, MutableSequence + + +class FormulaEvaluationError(ValueError): + """A measure formula could not be evaluated or validated. + + Carries the measure, the formula, and the offending token so the message + is actionable instead of a bare "unsupported node". + """ + + def __init__(self, reason: str, *, measure: str = "", formula: str = "", token: str = ""): + self.reason = reason + self.measure = measure + self.formula = formula + self.token = token + where = f"measure {measure}" if measure else "formula" + detail = f" (offending token: {token})" if token else "" + super().__init__(f"{where}: formula '{formula}' cannot be computed — {reason}{detail}") + + +# --- the whitelist ----------------------------------------------------------- +# (min_args, max_args or None for variadic, python impl, sql template kind) + +def _round(*args: float) -> float: + value = args[0] + digits = int(args[1]) if len(args) > 1 else 0 + return round(value, digits) + + +def _coalesce(*args: float) -> float: + for value in args: + if value is not None: + return value + return 0.0 + + +ALLOWED_FUNCTIONS: dict[str, tuple[int, int | None, Callable[..., float], str]] = { + # name min max python sql + "ABS": (1, 1, lambda x: abs(x), "ABS"), + "ROUND": (1, 2, _round, "ROUND"), + "MIN": (2, None, lambda *a: min(a), "LEAST"), + "MAX": (2, None, lambda *a: max(a), "GREATEST"), + "LEAST": (2, None, lambda *a: min(a), "LEAST"), + "GREATEST": (2, None, lambda *a: max(a), "GREATEST"), + "COALESCE": (1, None, _coalesce, "COALESCE"), + "IFNULL": (2, 2, _coalesce, "COALESCE"), +} + +_BIN_OPS: dict[type, Callable[[float, float], float]] = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, +} + +_SQL_BIN_OPS: dict[type, str] = { + ast.Add: "+", + ast.Sub: "-", + ast.Mult: "*", + ast.Div: "/", +} + +ALLOWED_SYNTAX_SUMMARY = ( + "use + - * / and the functions " + + ", ".join(sorted(ALLOWED_FUNCTIONS)) + + " over other measures in the same fact table" +) + + +# --- parsing ---------------------------------------------------------------- + + +def parse(formula: str, *, measure: str = "") -> ast.expr: + """Parse a formula into an AST expression, raising a named error.""" + text = (formula or "").strip() + if not text: + raise FormulaEvaluationError("formula is empty", measure=measure, formula=formula or "") + try: + return ast.parse(text, mode="eval").body + except SyntaxError as exc: + raise FormulaEvaluationError( + f"not valid arithmetic ({exc.msg}); {ALLOWED_SYNTAX_SUMMARY}", + measure=measure, + formula=text, + ) from exc + + +def identifiers(formula: str) -> set[str]: + """Every identifier a formula references. Empty set if it does not parse.""" + try: + tree = ast.parse((formula or "").strip() or "0", mode="eval") + except SyntaxError: + return set() + return { + node.id + for node in ast.walk(tree) + if isinstance(node, ast.Name) and not _is_function_slot(node, tree) + } + + +def _is_function_slot(name_node: ast.Name, tree: ast.AST) -> bool: + """True when this Name is the callee of a call (a function, not a measure).""" + for node in ast.walk(tree): + if isinstance(node, ast.Call) and node.func is name_node: + return True + return False + + +def ratio_pairs(formula: str) -> list[tuple[str, str]]: + """Every `A / B` where both sides are bare identifiers. + + Used two ways: to infer `share_of` (the part must be a fraction of the + whole) and, in the quality gate, to check part <= whole in Snowflake. + Finds the pair inside any wrapper — `A / B`, `(A / B) * 100`, + `ROUND(A / B, 2)` all yield [(A, B)]. + """ + try: + tree = ast.parse((formula or "").strip() or "0", mode="eval") + except SyntaxError: + return [] + pairs: list[tuple[str, str]] = [] + for node in ast.walk(tree): + if ( + isinstance(node, ast.BinOp) + and isinstance(node.op, ast.Div) + and isinstance(node.left, ast.Name) + and isinstance(node.right, ast.Name) + ): + pairs.append((node.left.id, node.right.id)) + return pairs + + +def validate(formula: str, allowed_identifiers: Iterable[str], *, measure: str = "") -> list[str]: + """Report every reason this formula cannot be computed. Empty list == valid. + + Messages are written for two audiences: the LLM repair round (they go + straight into the repair prompt) and a human reading the run log. + """ + problems: list[str] = [] + try: + tree = parse(formula, measure=measure) + except FormulaEvaluationError as exc: + return [f"Measure {measure}: {exc.reason}." if measure else f"{exc.reason}."] + + allowed = {str(name) for name in allowed_identifiers} + prefix = f"Measure {measure}: " if measure else "" + + for node in ast.walk(tree): + if isinstance(node, ast.Call): + func = node.func.id.upper() if isinstance(node.func, ast.Name) else "" + if func not in ALLOWED_FUNCTIONS: + shown = func or "" + problems.append( + f"{prefix}formula '{formula}' calls unsupported function {shown}() — " + f"{ALLOWED_SYNTAX_SUMMARY}." + ) + continue + low, high, _, _ = ALLOWED_FUNCTIONS[func] + argc = len(node.args) + if node.keywords or argc < low or (high is not None and argc > high): + problems.append( + f"{prefix}formula '{formula}' calls {func}() with {argc} argument(s); " + f"it takes {low}{'+' if high is None else f'..{high}'}." + ) + elif isinstance(node, ast.Name): + if _is_function_slot(node, tree): + continue + if node.id == measure: + problems.append(f"{prefix}formula '{formula}' references itself.") + elif node.id not in allowed: + problems.append( + f"{prefix}formula '{formula}' references '{node.id}', which is not a measure " + f"in the same fact table. Every identifier in a formula must be another " + f"measure of that fact." + ) + elif isinstance(node, ast.BinOp): + if not isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)): + problems.append( + f"{prefix}formula '{formula}' uses an unsupported operator — " + f"{ALLOWED_SYNTAX_SUMMARY}." + ) + elif isinstance(node, ast.UnaryOp): + if not isinstance(node.op, ast.USub): + problems.append( + f"{prefix}formula '{formula}' uses an unsupported unary operator — " + f"{ALLOWED_SYNTAX_SUMMARY}." + ) + elif isinstance(node, ast.Constant): + if not isinstance(node.value, (int, float)) or isinstance(node.value, bool): + problems.append( + f"{prefix}formula '{formula}' contains the non-numeric literal " + f"{node.value!r} — {ALLOWED_SYNTAX_SUMMARY}." + ) + elif isinstance(node, (ast.Expression, ast.Load, ast.operator, ast.unaryop, ast.expr_context)): + # ast.walk() yields operator nodes (Add, Sub, Div...) as well as the + # BinOp/UnaryOp that owns them; the branches above already checked + # whether the operator is allowed. + continue + else: + problems.append( + f"{prefix}formula '{formula}' contains unsupported syntax " + f"({type(node).__name__}) — {ALLOWED_SYNTAX_SUMMARY}." + ) + # dedupe, preserve order + seen: set[str] = set() + return [p for p in problems if not (p in seen or seen.add(p))] + + +# --- evaluation ------------------------------------------------------------- + + +def evaluate( + formula: str, + values: Mapping[str, Any], + *, + measure: str = "", + warnings: MutableSequence[str] | None = None, +) -> float: + """Evaluate a formula against measure values for ONE row. + + Raises FormulaEvaluationError naming the measure, formula and offending + token for anything it cannot compute. It NEVER returns a made-up 0.0 — + that behavior shipped columns of zeros and fabricated metrics for months. + + Division by zero returns 0.0 and appends a warning to `warnings` (a rate + with an empty denominator is legitimate; a silently wrong metric is not). + """ + node = parse(formula, measure=measure) + return float(_eval_node(node, values, measure=measure, formula=formula, warnings=warnings)) + + +def _eval_node( + node: ast.AST, + values: Mapping[str, Any], + *, + measure: str, + formula: str, + warnings: MutableSequence[str] | None, +) -> float: + def sub(child: ast.AST) -> float: + return _eval_node(child, values, measure=measure, formula=formula, warnings=warnings) + + if isinstance(node, ast.BinOp): + op_type = type(node.op) + if op_type in _BIN_OPS: + return _BIN_OPS[op_type](sub(node.left), sub(node.right)) + if op_type is ast.Div: + numerator = sub(node.left) + denominator = sub(node.right) + if not denominator: + if warnings is not None: + warnings.append( + f"{measure or 'formula'}: division by zero in '{formula}' — stored 0" + ) + return 0.0 + return numerator / denominator + raise FormulaEvaluationError( + f"unsupported operator {op_type.__name__}; {ALLOWED_SYNTAX_SUMMARY}", + measure=measure, formula=formula, token=op_type.__name__, + ) + + if isinstance(node, ast.UnaryOp): + if isinstance(node.op, ast.USub): + return -sub(node.operand) + raise FormulaEvaluationError( + f"unsupported unary operator {type(node.op).__name__}; {ALLOWED_SYNTAX_SUMMARY}", + measure=measure, formula=formula, token=type(node.op).__name__, + ) + + if isinstance(node, ast.Call): + name = node.func.id.upper() if isinstance(node.func, ast.Name) else "" + spec = ALLOWED_FUNCTIONS.get(name) + if spec is None or node.keywords: + raise FormulaEvaluationError( + f"unsupported function {name or type(node.func).__name__}(); " + f"{ALLOWED_SYNTAX_SUMMARY}", + measure=measure, formula=formula, token=name or "call", + ) + low, high, impl, _ = spec + args = [sub(arg) for arg in node.args] + if len(args) < low or (high is not None and len(args) > high): + raise FormulaEvaluationError( + f"{name}() takes {low}{'+' if high is None else f'..{high}'} arguments, got {len(args)}", + measure=measure, formula=formula, token=name, + ) + result = float(impl(*args)) + if math.isnan(result) or math.isinf(result): + raise FormulaEvaluationError( + f"{name}() produced a non-finite value", measure=measure, formula=formula, token=name + ) + return result + + if isinstance(node, ast.Constant): + if isinstance(node.value, bool) or not isinstance(node.value, (int, float)): + raise FormulaEvaluationError( + f"non-numeric literal {node.value!r}", measure=measure, formula=formula, + token=repr(node.value), + ) + return float(node.value) + + if isinstance(node, ast.Name): + if node.id in values: + return float(values[node.id]) + upper = node.id.upper() + if upper in values: + return float(values[upper]) + raise FormulaEvaluationError( + f"'{node.id}' is not a measure of this fact table, so it has no value for this row", + measure=measure, formula=formula, token=node.id, + ) + + raise FormulaEvaluationError( + f"unsupported syntax ({type(node).__name__}); {ALLOWED_SYNTAX_SUMMARY}", + measure=measure, formula=formula, token=type(node).__name__, + ) + + +# --- SQL compilation (used by the quality gate to recompute in place) ------- + + +def to_sql(formula: str, columns: Mapping[str, str], *, measure: str = "") -> str: + """Compile a formula to a Snowflake expression over `columns`. + + columns maps UPPERCASE identifier -> real column name. Raises + FormulaEvaluationError if any identifier is not a column of the table, so + the caller can fall back to omitting the measure instead of writing SQL + that references something that does not exist. + """ + node = parse(formula, measure=measure) + return _to_sql_node(node, columns, measure=measure, formula=formula) + + +def _quote(identifier: str) -> str: + return '"' + identifier.replace('"', '""') + '"' + + +def _to_sql_node(node: ast.AST, columns: Mapping[str, str], *, measure: str, formula: str) -> str: + def sub(child: ast.AST) -> str: + return _to_sql_node(child, columns, measure=measure, formula=formula) + + if isinstance(node, ast.BinOp): + op = _SQL_BIN_OPS.get(type(node.op)) + if op is None: + raise FormulaEvaluationError( + f"unsupported operator {type(node.op).__name__}", measure=measure, formula=formula, + token=type(node.op).__name__, + ) + left, right = sub(node.left), sub(node.right) + if op == "/": + # Same semantics as the Python evaluator: /0 -> 0, never NULL. + return f"IFF(({right}) = 0, 0, ({left}) / NULLIF(({right}), 0))" + return f"({left} {op} {right})" + + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + return f"(-{sub(node.operand)})" + + if isinstance(node, ast.Call): + name = node.func.id.upper() if isinstance(node.func, ast.Name) else "" + spec = ALLOWED_FUNCTIONS.get(name) + if spec is None or node.keywords: + raise FormulaEvaluationError( + f"unsupported function {name or 'call'}()", measure=measure, formula=formula, + token=name or "call", + ) + low, high, _, sql_name = spec + args = [sub(a) for a in node.args] + if len(args) < low or (high is not None and len(args) > high): + raise FormulaEvaluationError( + f"{name}() takes {low}{'+' if high is None else f'..{high}'} arguments, got {len(args)}", + measure=measure, formula=formula, token=name, + ) + return f"{sql_name}({', '.join(args)})" + + if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool): + return repr(float(node.value)) + + if isinstance(node, ast.Name): + actual = columns.get(node.id) or columns.get(node.id.upper()) + if actual is None: + raise FormulaEvaluationError( + f"'{node.id}' is not a column of this table", + measure=measure, formula=formula, token=node.id, + ) + return _quote(actual) + + raise FormulaEvaluationError( + f"unsupported syntax ({type(node).__name__})", measure=measure, formula=formula, + token=type(node).__name__, + ) + + +# --- sign analysis ----------------------------------------------------------- +# Only ever consulted when a formula has to be DEMOTED to a sampled measure +# (blueprint._repair_one_formula). The sample must keep the sign range the +# formula would have had: `ACTUAL - FORECAST` is a signed error and sampling it +# from a strictly-positive lognormal produces a "forecast error" that is never +# negative — plausible-looking and wrong, which is the failure mode this whole +# module exists to stop. `ABS(...)` is genuinely one-sided; a difference is not. + +_SIGN_PRESERVING_FUNCTIONS = frozenset( + {"ROUND", "MIN", "MAX", "LEAST", "GREATEST", "COALESCE", "IFNULL"} +) + + +def is_signed(formula: str) -> bool: + """True when `formula` could legitimately evaluate to a negative number.""" + try: + return _node_is_signed(parse(formula)) + except FormulaEvaluationError: + return False + + +def _node_is_signed(node: ast.AST) -> bool: + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + name = node.func.id.upper() + if name in _SIGN_PRESERVING_FUNCTIONS: + return any(_node_is_signed(arg) for arg in node.args) + return False # ABS, and anything unrecognised, is non-negative + if isinstance(node, ast.BinOp): + if isinstance(node.op, ast.Sub): + return True + return _node_is_signed(node.left) or _node_is_signed(node.right) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + return True + return False diff --git a/demoprep_app/dataset/quality.py b/demoprep_app/dataset/quality.py index ee706306e18753bbd1f40583fd6b963555c5feb8..be4deac598d9aac08caf9ac5e09b1671205cbbd0 100644 --- a/demoprep_app/dataset/quality.py +++ b/demoprep_app/dataset/quality.py @@ -204,6 +204,7 @@ def validate_dataset_quality(dataset: DatasetBundle) -> DatasetQualityReport: if family: dimension_value_sets.setdefault((table.name, column, family), set()).add(text.lower()) + issues.extend(_flat_measure_issues(dataset)) issues.extend(_duplicate_dimension_set_issues(dimension_value_sets)) issues.extend(_scenario_contract_issues(dataset, table_map)) issues.extend(_metric_consistency_issues(dataset)) @@ -359,3 +360,68 @@ def _issue(severity: str, table: str, column: str, value: Any, message: str) -> value=str(value)[:255], message=message, ) + + +# A measure whose grouped averages differ by less than this across a dimension +# cannot answer "which one is underperforming" — the question every one of these +# boards exists to answer. The reviewer set 10-12% from graded boards; 0.10 is +# the low end so the gate flags only genuinely dead measures. +MIN_DIMENSION_SPREAD = 0.10 + + +def _flat_measure_issues(dataset: "DatasetBundle") -> list[DatasetQualityIssue]: + """Flag measures that are IDENTICAL across the dimensions a board slices by. + + The existing checks catch an all-ZERO column. All-IDENTICAL is a different + failure and nearly as demo-fatal: mani.uppala's AdvancedMD build reported + success with zero warnings while NET_REVENUE varied 2.4% and COLLECTION_RATE + 0.7% across providers, so "which providers are underperforming" — the entire + point of practice-management analytics — could not be told. + + WARNING, not blocker, deliberately. A flat board is a weak demo; a blocked + build is no demo, and no demo is the worse outcome (the governing principle + behind the repair-not-veto gate). This tells the SE what is wrong while + still handing them something to show. + """ + issues: list[DatasetQualityIssue] = [] + for table in dataset.tables: + if not table.is_fact or not table.rows: + continue + dim_keys = [c.name for c in table.columns if c.semantic_role == "dimension_key"] + measures = [c.name for c in table.columns + if c.semantic_role not in {"dimension_key", "date_key", "fact_key", "date"} + and c.name not in dim_keys] + for measure in measures: + # Best spread this measure achieves across ANY dimension the board + # can group by. Flat on one dimension is fine — a measure priced per + # payer is legitimately flat by provider — but flat on ALL of them + # means it cannot carry a chart anywhere. + best = 0.0 + best_dim = "" + for dim_key in dim_keys: + grouped: dict[Any, list[float]] = {} + for row in table.rows: + value = _number(row.get(measure)) + if value is not None: + grouped.setdefault(row.get(dim_key), []).append(value) + if len(grouped) < 2: + continue + averages = [sum(v) / len(v) for v in grouped.values() if v] + if len(averages) < 2: + continue + top = max(averages) + if not top: + continue # all-zero: the other gate owns it + spread = (top - min(averages)) / abs(top) + if spread > best: + best, best_dim = spread, dim_key + if best_dim and best < MIN_DIMENSION_SPREAD: + issues.append(_issue( + "warning", table.name, measure, f"{best * 100:.1f}%", + f"Measure is effectively identical across every dimension " + f"(best spread {best * 100:.1f}% on {best_dim}, need " + f"{MIN_DIMENSION_SPREAD * 100:.0f}%). No chart grouped by a " + f"dimension can show a leader or a laggard, so this measure " + f"cannot carry a story.", + )) + return issues diff --git a/demoprep_app/dataset/validator.py b/demoprep_app/dataset/validator.py index 7c04b6328da24a3069a8782e611975265bfa9fcc..5fbc0bfcc1b9900d594101a3f3ecebaef63c2227 100644 --- a/demoprep_app/dataset/validator.py +++ b/demoprep_app/dataset/validator.py @@ -155,20 +155,36 @@ def _check_integrity(blueprint: DemoBlueprint, fact, tables: dict, fact_rows: li def _check_formulas(fact, fact_rows: list[dict[str, Any]], report: ValidationReport) -> None: - from demoprep_app.dataset.engine import _eval_formula # single source of truth + # demoprep_app/dataset/formula.py is the single source of truth for the + # formula language — the same evaluator the engine used to produce the rows. + from demoprep_app.dataset.formula import FormulaEvaluationError, evaluate derived = [m for m in fact.measures if m.kind == "derived" and m.formula] for m in derived: sample = fact_rows[:: max(1, len(fact_rows) // 200)] # ~200 spot checks misses = 0 for row in sample: - expected = _eval_formula(m.formula, {k: float(v) for k, v in row.items() if isinstance(v, (int, float))}) + try: + expected = evaluate( + m.formula, + {k: float(v) for k, v in row.items() if isinstance(v, (int, float))}, + measure=m.name, + ) + except FormulaEvaluationError as exc: + # The engine could not have produced these rows from this + # formula either — name it once and move to the next measure + # instead of counting 200 identical failures. + report.formula_errors.append(str(exc)) + break actual = float(row.get(m.name, 0.0)) tolerance = max(1.0, abs(expected) * 0.02) if abs(actual - expected) > tolerance: misses += 1 - if misses > len(sample) * 0.05: - report.formula_errors.append(f"{m.name}: {misses}/{len(sample)} sampled rows deviate from formula '{m.formula}'.") + else: + if misses > len(sample) * 0.05: + report.formula_errors.append( + f"{m.name}: {misses}/{len(sample)} sampled rows deviate from formula '{m.formula}'." + ) def _key_lookup(blueprint: DemoBlueprint, tables: dict) -> dict[str, dict[int, str]]: diff --git a/demoprep_app/integrations/snowflake/__init__.py b/demoprep_app/integrations/snowflake/__init__.py index e11909a696dcfcf72da3d46349a9b0fd4c55b287..451c852a03e599ea726e6b1199b0e43485fa004a 100644 --- a/demoprep_app/integrations/snowflake/__init__.py +++ b/demoprep_app/integrations/snowflake/__init__.py @@ -2,9 +2,18 @@ from demoprep_app.integrations.snowflake.dataset_writer import populate_dataset_bundle from demoprep_app.integrations.snowflake.quality_gate import ( + MeasureQualityGateError, ZeroMeasureError, + apply_measure_omissions, + measure_formulas, run_measure_quality_gate, ) -__all__ = ["populate_dataset_bundle", "run_measure_quality_gate", "ZeroMeasureError"] - +__all__ = [ + "populate_dataset_bundle", + "run_measure_quality_gate", + "measure_formulas", + "apply_measure_omissions", + "MeasureQualityGateError", + "ZeroMeasureError", +] diff --git a/demoprep_app/integrations/snowflake/quality_gate.py b/demoprep_app/integrations/snowflake/quality_gate.py index 9dc3104b5671f245d45c53974c1a7d338f0c3ff2..ddb73f3ad154297ab6ab44ac57b2d68cf952b60a 100644 --- a/demoprep_app/integrations/snowflake/quality_gate.py +++ b/demoprep_app/integrations/snowflake/quality_gate.py @@ -1,20 +1,54 @@ -"""Post-load data-quality gate for generated demo schemas. - -Guards against the recurring zero-measure defect: fact tables where derived -measure columns (e.g. TOTAL_LOAD_REVENUE_USD, BROKER_MARGIN_USD) load as 100% -zero/null while their input columns are populated, which renders liveboard -tiles blank. Runs immediately after data population, against what was actually -loaded into Snowflake, so it is independent of how the data was generated. - -The gate profiles every MEASURE column (numeric columns that are not join -keys, mirroring ThoughtSpotDeployer._determine_column_type) and raises -ZeroMeasureError when any measure is entirely zero/null. No silent fallback: -a failed gate fails the run with the offending columns named. +"""Post-load data-quality REPAIR stage for generated demo schemas. + +Runs immediately after data population, against what was actually loaded into +Snowflake, so it is independent of how the data was generated. + +Why this is a repair stage and not a veto (2026-08-26) +----------------------------------------------------- +It used to profile measure columns, raise ZeroMeasureError on the first +all-zero one, and abort the deployment. On 2026-08-26 that left an SE with a +schema containing six tables and nothing else: no model, no liveboard, nothing +to show. An empty schema is indistinguishable from the pipeline never having +run. + +Governing principle (boone): producing NOTHING is the worst possible outcome. +A visible error beats silence; a liveboard with one omitted or obviously broken +tile beats no liveboard, because it is demo-able and the defect is fixable. +Fail-open with a reported gap always beats fail-closed with nothing. This gate +must never be the reason an SE has no demo. + +So the order of preference is: + + 1. REPAIR in place — recompute a derived column from its formula, clamp a + part to its whole, binarize a flag, clamp a rate to its scale. + 2. OMIT — drop the measure from the table (and therefore from the model and + liveboard that are built from it afterwards) and report the omission. + 3. HARD FAIL — only when there is no demo left at all: every measure the + brief's core claim depends on is unusable. + +Detection covers more than the all-zero symptom it used to (see +dev_notes/BLOCKING_ISSUES_2026_08_26.md ISSUE A: the gate blocked on the benign +symptom, all-zero, and waved through the dangerous one, a plausible-looking +column that was exactly minus another column): + + * all zero / null + * constant (MIN == MAX) + * exactly equal to +/- another column in the same table + * *_RATE / *_PCT outside its scale bound + * *_FLAG not in {0, 1} + * part > whole, for any part/whole pair implied by a ratio formula """ from __future__ import annotations -from typing import Any, Callable, Optional +from typing import Any, Callable, Iterable, Mapping, Optional + +from demoprep_app.dataset.formula import ( + FormulaEvaluationError, + identifiers as formula_identifiers, + ratio_pairs as formula_ratio_pairs, + to_sql as formula_to_sql, +) # Snowflake normalizes numeric types, but accept the common aliases too. _NUMERIC_DATA_TYPES = { @@ -27,22 +61,31 @@ _NUMERIC_DATA_TYPES = { # (a partial variant of the all-zero defect) but not an outright failure. MOSTLY_ZERO_WARN_THRESHOLD = 0.01 +# Suffixes that promise a bounded quantity. +_RATE_SUFFIXES = ("_RATE", "_PCT", "_PERCENT", "_RATIO") +_FLAG_SUFFIXES = ("_FLAG",) -class ZeroMeasureError(Exception): - """One or more measure columns loaded entirely zero/null.""" +# Tolerance when comparing MIN/MAX signatures to shortlist mirrored columns. +_MIRROR_EPS = 1e-9 - def __init__(self, failures: list[dict], profile: dict): + +class MeasureQualityGateError(Exception): + """Nothing demo-able could be produced — the only hard failure left. + + Raised when every measure the brief's core claim depends on is unusable, or + when the schema has no usable measure columns at all. Anything narrower is + repaired or omitted so the build continues. + """ + + def __init__(self, message: str, failures: list[dict], profile: dict): self.failures = failures self.profile = profile - details = "; ".join( - f"{f['table']}.{f['column']} ({f['row_count']} rows, all zero/null)" - for f in failures - ) - super().__init__( - f"Data quality gate failed — {len(failures)} measure column(s) are " - f"entirely zero/null: {details}. The data generator did not compute " - f"these derived measures; the run must be regenerated." - ) + super().__init__(message) + + +# Legacy name: chat_interface and older tests catch this. Keeping it as an alias +# means an existing `except ZeroMeasureError` still catches the hard failure. +ZeroMeasureError = MeasureQualityGateError def is_measure_column(column_name: str, data_type: str) -> bool: @@ -57,35 +100,434 @@ def is_measure_column(column_name: str, data_type: str) -> bool: return True +def is_flag_column(column_name: str) -> bool: + return column_name.upper().endswith(_FLAG_SUFFIXES) + + +def is_rate_column(column_name: str) -> bool: + return column_name.upper().endswith(_RATE_SUFFIXES) + + +def rate_scale_bound(avg: float | None) -> float: + """Whether a rate column is on the 0-1 or the 0-100 scale. + + The blueprint does not record the scale, so read it off the data: a genuine + 0-100 percentage column averages well above 1. Known failure mode: a 0-100 + column whose values are nearly all below 1% would be read as a 0-1 rate. + That is why an out-of-bounds base rate is clamped only when it is clearly + broken (see _repair_plan) rather than on this signal alone. + """ + return 100.0 if (avg is not None and float(avg) > 1.5) else 1.0 + + +def measure_formulas(bundle: Any) -> dict[str, str]: + """{"TABLE.COLUMN": "FORMULA"} for every derived measure in a DatasetBundle. + + The formulas are what make repair possible: without them a bad column can + only be omitted. + """ + formulas: dict[str, str] = {} + for table in getattr(bundle, "tables", []) or []: + for column in table.columns: + if getattr(column, "formula", None): + formulas[f"{table.name}.{column.name}"] = column.formula + return formulas + + +def apply_measure_omissions(bundle: Any, omissions: Iterable[Mapping[str, str]]) -> list[str]: + """Remove omitted measure columns from an in-memory DatasetBundle. + + The ThoughtSpot side of the deploy is driven by DDL compiled from this + bundle, so a column dropped in Snowflake has to disappear here too or the + model would declare a column that no longer exists. + """ + removed: list[str] = [] + by_name = {table.name: table for table in getattr(bundle, "tables", []) or []} + for omission in omissions: + table = by_name.get(omission.get("table", "")) + column = omission.get("column", "") + if table is None or not column: + continue + before = len(table.columns) + table.columns = [c for c in table.columns if c.name != column] + if len(table.columns) != before: + removed.append(f"{table.name}.{column}") + for row in table.rows: + row.pop(column, None) + return removed + + def _quote_ident(identifier: str) -> str: escaped = identifier.replace('"', '""') return f'"{escaped}"' +def _table_ref(schema_name: str, table_name: str) -> str: + return f"{_quote_ident(schema_name)}.{_quote_ident(table_name)}" + + +# --------------------------------------------------------------------------- +# Profiling +# --------------------------------------------------------------------------- + + +def _profile_table(cursor: Any, schema_name: str, table_name: str, columns: list[str]) -> dict: + """One aggregate query per table: row count plus stats for every measure.""" + select_parts = ["COUNT(*)"] + index_of: dict[str, dict[str, int]] = {} + for col in columns: + q = _quote_ident(col) + slots = {} + slots["non_zero"] = len(select_parts) + select_parts.append(f"SUM(IFF({q} IS NOT NULL AND {q} <> 0, 1, 0))") + slots["min"] = len(select_parts) + select_parts.append(f"MIN({q})") + slots["max"] = len(select_parts) + select_parts.append(f"MAX({q})") + slots["avg"] = len(select_parts) + select_parts.append(f"AVG({q})") + if is_flag_column(col): + slots["non_binary"] = len(select_parts) + select_parts.append(f"SUM(IFF({q} IS NOT NULL AND {q} NOT IN (0, 1), 1, 0))") + index_of[col] = slots + + cursor.execute(f"SELECT {', '.join(select_parts)} FROM {_table_ref(schema_name, table_name)}") + result = cursor.fetchone() + row_count = int(result[0] or 0) + + stats: dict[str, dict] = {} + for col, slots in index_of.items(): + def value(key: str): + idx = slots.get(key) + return None if idx is None else result[idx] + + stats[col] = { + "row_count": row_count, + "non_zero": int(value("non_zero") or 0), + "min": None if value("min") is None else float(value("min")), + "max": None if value("max") is None else float(value("max")), + "avg": None if value("avg") is None else float(value("avg")), + "non_binary": None if value("non_binary") is None else int(value("non_binary") or 0), + } + return {"row_count": row_count, "columns": stats} + + +def _mirror_defects( + cursor: Any, + schema_name: str, + table_name: str, + stats: dict[str, dict], + formulas: Mapping[str, str], +) -> list[dict]: + """Columns that are exactly +/- another column of the same table. + + This is the defect that used to ship silently: a formula referencing a + measure that does not exist in the fact resolved that identifier to 0, so + `ACTUAL - FORECAST` stored `-FORECAST` in all 5000 rows and looked + plausible. Shortlist on the MIN/MAX signature (cheap, from the profile we + already have), then verify row by row in ONE query. + """ + # An all-zero column is already reported as all_zero, and two all-zero + # columns trivially "mirror" each other — skip them to keep the signal clean. + names = [ + c for c, s in stats.items() + if s["row_count"] and s["min"] is not None and s["non_zero"] + ] + candidates: list[tuple[str, str, int]] = [] # (col, other, sign) + for col in names: + formula = formulas.get(f"{table_name}.{col}") + if not formula: + continue # only a derived column can be "wrong" in this specific way + col_min, col_max = stats[col]["min"], stats[col]["max"] + for other in names: + if other == col: + continue + # A formula that IS just the other column (or its negation) is + # legitimately identical — don't call that a defect. + idents = formula_identifiers(formula) + if idents == {other} and formula.replace(" ", "").lstrip("-") == other: + continue + other_min, other_max = stats[other]["min"], stats[other]["max"] + if abs(col_min - other_min) <= _MIRROR_EPS and abs(col_max - other_max) <= _MIRROR_EPS: + candidates.append((col, other, 1)) + elif abs(col_min + other_max) <= _MIRROR_EPS and abs(col_max + other_min) <= _MIRROR_EPS: + candidates.append((col, other, -1)) + + if not candidates: + return [] + + parts = [] + for col, other, sign in candidates: + left, right = _quote_ident(col), _quote_ident(other) + expr = right if sign > 0 else f"-{right}" + parts.append(f"SUM(IFF({left} IS DISTINCT FROM {expr}, 1, 0))") + cursor.execute(f"SELECT {', '.join(parts)} FROM {_table_ref(schema_name, table_name)}") + row = cursor.fetchone() + + defects: list[dict] = [] + for idx, (col, other, sign) in enumerate(candidates): + differing = int(row[idx] or 0) + if differing == 0: + defects.append({ + "table": table_name, + "column": col, + "defect": "mirrors_other_column", + "detail": ( + f"every row equals {'-' if sign < 0 else ''}{other} — the formula's other " + f"input resolved to nothing" + ), + }) + return defects + + +def _part_whole_defects( + cursor: Any, + schema_name: str, + table_name: str, + stats: dict[str, dict], + formulas: Mapping[str, str], +) -> list[dict]: + """part > whole for every part/whole pair implied by a ratio formula.""" + pairs: list[tuple[str, str, str]] = [] # (part, whole, ratio_column) + for key, formula in formulas.items(): + tbl, _, ratio_col = key.partition(".") + if tbl != table_name: + continue + for part, whole in formula_ratio_pairs(formula): + if part in stats and whole in stats and (part, whole, ratio_col) not in pairs: + pairs.append((part, whole, ratio_col)) + if not pairs: + return [] + + parts_sql = [ + f"SUM(IFF({_quote_ident(part)} > {_quote_ident(whole)}, 1, 0))" + for part, whole, _ in pairs + ] + cursor.execute(f"SELECT {', '.join(parts_sql)} FROM {_table_ref(schema_name, table_name)}") + row = cursor.fetchone() + + defects: list[dict] = [] + for idx, (part, whole, ratio_col) in enumerate(pairs): + offending = int(row[idx] or 0) + if offending: + defects.append({ + "table": table_name, + "column": part, + "defect": "part_exceeds_whole", + "detail": f"{offending} row(s) where {part} > {whole} (used by {ratio_col})", + "whole": whole, + "ratio_column": ratio_col, + }) + return defects + + +def _column_defects(table_name: str, stats: dict[str, dict]) -> tuple[list[dict], list[str]]: + """Per-column defects and warnings derivable from the profile alone.""" + defects: list[dict] = [] + warnings: list[str] = [] + for col, s in stats.items(): + row_count, non_zero = s["row_count"], s["non_zero"] + if not row_count: + continue + if non_zero == 0: + defects.append({ + "table": table_name, "column": col, "defect": "all_zero", + "detail": f"{row_count} rows, all zero/null", + }) + continue + if s["min"] is not None and s["max"] is not None and s["min"] == s["max"] and row_count > 1: + defects.append({ + "table": table_name, "column": col, "defect": "constant", + "detail": f"every row is {s['min']}", + }) + if is_flag_column(col) and s.get("non_binary"): + defects.append({ + "table": table_name, "column": col, "defect": "flag_not_binary", + "detail": f"{s['non_binary']}/{row_count} rows outside {{0,1}} (max {s['max']})", + }) + if is_rate_column(col) and s["max"] is not None: + bound = rate_scale_bound(s["avg"]) + if s["max"] > bound or (s["min"] is not None and s["min"] < -bound): + defects.append({ + "table": table_name, "column": col, "defect": "rate_out_of_bounds", + "detail": ( + f"min={s['min']} max={s['max']} avg={s['avg']} outside +/-{bound:g} " + f"for a rate on that scale" + ), + "bound": bound, + }) + if non_zero / row_count < MOSTLY_ZERO_WARN_THRESHOLD: + pct_zero = 100.0 * (1 - non_zero / row_count) + warnings.append( + f"{table_name}.{col} is {pct_zero:.1f}% zero/null ({non_zero}/{row_count} non-zero)" + ) + return defects, warnings + + +# --------------------------------------------------------------------------- +# Repair +# --------------------------------------------------------------------------- + + +def _repair_statements( + schema_name: str, + table_name: str, + defect: dict, + columns: list[str], + formulas: Mapping[str, str], + *, + allow_clamp: bool = True, +) -> list[tuple[str, str]]: + """(description, SQL) repairs for one defect, most faithful first. + + Every statement here targets a table this run created seconds earlier in a + throwaway demo schema. Nothing outside the generated schema is touched. + """ + column = defect["column"] + ref = _table_ref(schema_name, table_name) + q = _quote_ident(column) + column_map = {c.upper(): c for c in columns} + formula = formulas.get(f"{table_name}.{column}") + + def recompute() -> list[tuple[str, str]]: + if not formula: + return [] + try: + expr = formula_to_sql(formula, column_map, measure=column) + except FormulaEvaluationError: + return [] # inputs are not in this table — recompute is impossible + return [(f"recomputed {table_name}.{column} from '{formula}'", f"UPDATE {ref} SET {q} = {expr}")] + + if defect["defect"] == "part_exceeds_whole": + whole = _quote_ident(defect["whole"]) + repairs = [( + f"clamped {table_name}.{column} to {defect['whole']} so the part cannot exceed the whole", + f"UPDATE {ref} SET {q} = LEAST({q}, {whole}) WHERE {q} > {whole}", + )] + ratio_formula = formulas.get(f"{table_name}.{defect.get('ratio_column', '')}") + if ratio_formula: + ratio_col = defect["ratio_column"] + try: + expr = formula_to_sql(ratio_formula, column_map, measure=ratio_col) + except FormulaEvaluationError: + return repairs + repairs.append(( + f"recomputed {table_name}.{ratio_col} from '{ratio_formula}' after clamping", + f"UPDATE {ref} SET {_quote_ident(ratio_col)} = {expr}", + )) + return repairs + + if defect["defect"] == "flag_not_binary": + return [( + f"binarized {table_name}.{column} to 0/1", + f"UPDATE {ref} SET {q} = IFF({q} >= 0.5, 1, 0)", + )] + + if defect["defect"] == "rate_out_of_bounds": + # Recomputing from the formula is always safe and always preferred. + recomputed = recompute() + if recomputed: + return recomputed + if not allow_clamp: + # Clamping is destructive if the scale was misread, so the caller + # only allows it when the column is broken beyond any reading. + return [] + bound = float(defect.get("bound", 1.0)) + return [( + f"clamped {table_name}.{column} into [0, {bound:g}]", + f"UPDATE {ref} SET {q} = LEAST(GREATEST({q}, 0), {bound})", + )] + + # all_zero / constant / mirrors_other_column: only a formula can fix these. + return recompute() + + +# Repairs run in this order: fix the INPUTS (clamp a part to its whole, +# binarize a flag) before recomputing anything derived from them, otherwise a +# recomputed ratio is computed from values that are about to change. +_REPAIR_ORDER = {"part_exceeds_whole": 0, "flag_not_binary": 1} + + +def _repair_rank(defect: dict) -> int: + return _REPAIR_ORDER.get(defect["defect"], 2) + + +# Which unrepaired defects justify removing the measure from the demo. +# A column that is blank or actively lying is worse than a missing tile; a +# column that is merely odd (an out-of-scale rate, a part slightly over its +# whole) is better shown, and reported, than deleted. +_OMIT_WORTHY = {"all_zero", "mirrors_other_column"} + + +def _should_omit(defect: dict, formulas: Mapping[str, str]) -> bool: + kind = defect["defect"] + if kind in _OMIT_WORTHY: + return True + if kind == "constant": + # A derived constant means its formula is broken. A base constant may + # simply be a flat attribute — keep it and say so. + return bool(formulas.get(f"{defect['table']}.{defect['column']}")) + return False + + +def _is_clamp_worth_it(defect: dict, stats: dict) -> bool: + """Guard for the one destructive repair whose trigger is a heuristic.""" + if defect["defect"] != "rate_out_of_bounds": + return True + bound = float(defect.get("bound", 1.0)) + maximum = stats.get("max") + return maximum is not None and (maximum > 100.0 or maximum > bound * 2) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + def run_measure_quality_gate( connection: Any, schema_name: str, progress_callback: Optional[Callable[[str], None]] = None, + *, + formulas: Optional[Mapping[str, str]] = None, + required_measures: Optional[Iterable[str]] = None, + repair: bool = True, + drop_unrepairable: bool = True, ) -> dict: - """Profile every measure column in the schema; fail loudly on all-zero ones. + """Profile every measure column in the schema; repair, omit, or (rarely) fail. - Returns a profile dict: + formulas: {"TABLE.COLUMN": "FORMULA"} for derived measures — see + measure_formulas(bundle). Without them nothing can be recomputed and a + bad column can only be omitted. + required_measures: measure names (bare or "TABLE.COLUMN") the brief's core + claim depends on. The gate hard-fails only if EVERY one of them ends up + unusable — one missing tile is not worth denying an SE a demo. + repair / drop_unrepairable: switches for tests and dry runs. + + Returns: { - "tables_checked": int, - "measure_columns_checked": int, - "failures": [], # empty on success (non-empty raises instead) - "warnings": ["FACT_X.COL is 99.8% zero/null (1/500 non-zero)", ...], - "columns": {"TABLE.COLUMN": {"row_count": n, "non_zero": m}, ...}, + "tables_checked": int, + "measure_columns_checked": int, + "failures": [ ... unrepaired defects that were NOT dropped ... ], + "defects": [ ... everything detected, with defect type + detail ... ], + "repairs": ["recomputed FACT.X from '...'", ...], + "omissions": [{"table":..., "column":..., "defect":..., "detail":...}], + "warnings": [...], + "columns": {"TABLE.COLUMN": {"row_count":n, "non_zero":m, "min":.., + "max":.., "avg":..}}, } - Raises ZeroMeasureError when any measure column in a populated table is - entirely zero/null. Profiling errors (bad schema name, connection loss) - propagate as-is — the gate never passes silently. + Raises MeasureQualityGateError only when there is nothing demo-able left. + Profiling errors (bad schema name, connection loss) propagate as-is — the + gate never passes silently. """ def log(msg: str) -> None: if progress_callback: progress_callback(msg) + formulas = dict(formulas or {}) + required = {str(m).upper() for m in (required_measures or ())} + cursor = connection.cursor() try: cursor.execute( @@ -113,57 +555,171 @@ def run_measure_quality_gate( "tables_checked": 0, "measure_columns_checked": 0, "failures": [], + "defects": [], + "repairs": [], + "omissions": [], "warnings": [], "columns": {}, } + stats_by_table: dict[str, dict] = {} for table_name, columns in sorted(measures_by_table.items()): - select_parts = ["COUNT(*)"] - for col in columns: - q = _quote_ident(col) - select_parts.append(f"SUM(IFF({q} IS NOT NULL AND {q} <> 0, 1, 0))") - cursor.execute( - f"SELECT {', '.join(select_parts)} " - f"FROM {_quote_ident(schema_name)}.{_quote_ident(table_name)}" - ) - result = cursor.fetchone() - row_count = int(result[0] or 0) - + table_profile = _profile_table(cursor, schema_name, table_name, columns) + stats_by_table[table_name] = table_profile profile["tables_checked"] += 1 - if row_count == 0: + if not table_profile["row_count"]: profile["warnings"].append( f"{table_name} has 0 rows — measure columns could not be verified" ) continue - - for idx, col in enumerate(columns): - non_zero = int(result[idx + 1] or 0) + for col, stats in table_profile["columns"].items(): profile["measure_columns_checked"] += 1 profile["columns"][f"{table_name}.{col}"] = { - "row_count": row_count, - "non_zero": non_zero, + "row_count": stats["row_count"], + "non_zero": stats["non_zero"], + "min": stats["min"], + "max": stats["max"], + "avg": stats["avg"], } - if non_zero == 0: - profile["failures"].append( - {"table": table_name, "column": col, "row_count": row_count} - ) - elif non_zero / row_count < MOSTLY_ZERO_WARN_THRESHOLD: - pct_zero = 100.0 * (1 - non_zero / row_count) - profile["warnings"].append( - f"{table_name}.{col} is {pct_zero:.1f}% zero/null " - f"({non_zero}/{row_count} non-zero)" - ) + column_defects, column_warnings = _column_defects(table_name, table_profile["columns"]) + profile["defects"].extend(column_defects) + profile["warnings"].extend(column_warnings) + profile["defects"].extend( + _part_whole_defects(cursor, schema_name, table_name, table_profile["columns"], formulas) + ) + profile["defects"].extend( + _mirror_defects(cursor, schema_name, table_name, table_profile["columns"], formulas) + ) log( f"Data quality gate: profiled {profile['measure_columns_checked']} measure " f"column(s) across {profile['tables_checked']} table(s)" ) + for defect in profile["defects"]: + log(f"[DEFECT] {defect['table']}.{defect['column']}: {defect['defect']} — {defect['detail']}") for warning in profile["warnings"]: log(f"[WARN] Data quality: {warning}") - if profile["failures"]: - raise ZeroMeasureError(profile["failures"], profile) + if not profile["defects"]: + return profile + + # --- repair pass ------------------------------------------------- + repaired_tables: set[str] = set() + executed_repairs: set[str] = set() + if repair: + for defect in sorted(profile["defects"], key=_repair_rank): + table_name = defect["table"] + stats = stats_by_table[table_name]["columns"].get(defect["column"], {}) + allow_clamp = _is_clamp_worth_it(defect, stats) + statements = _repair_statements( + schema_name, table_name, defect, + measures_by_table.get(table_name, []), formulas, + allow_clamp=allow_clamp, + ) + if not statements and not allow_clamp: + profile["warnings"].append( + f"{table_name}.{defect['column']}: {defect['detail']} — left as-is; " + "clamping could be wrong if this column is on the 0-100 scale" + ) + continue + for description, sql in statements: + if sql in executed_repairs: + continue # a column can carry two defects with one fix + executed_repairs.add(sql) + cursor.execute(sql) + profile["repairs"].append(description) + repaired_tables.add(table_name) + log(f"[REPAIR] {description}") + if repaired_tables: + connection.commit() + + # --- re-profile only what we touched ----------------------------- + remaining: list[dict] = [] + for defect in profile["defects"]: + if defect["table"] not in repaired_tables: + remaining.append(defect) + for table_name in sorted(repaired_tables): + columns = measures_by_table.get(table_name, []) + table_profile = _profile_table(cursor, schema_name, table_name, columns) + stats_by_table[table_name] = table_profile + for col, stats in table_profile["columns"].items(): + profile["columns"][f"{table_name}.{col}"] = { + "row_count": stats["row_count"], + "non_zero": stats["non_zero"], + "min": stats["min"], + "max": stats["max"], + "avg": stats["avg"], + } + column_defects, _ = _column_defects(table_name, table_profile["columns"]) + remaining.extend(column_defects) + remaining.extend( + _part_whole_defects(cursor, schema_name, table_name, table_profile["columns"], formulas) + ) + remaining.extend( + _mirror_defects(cursor, schema_name, table_name, table_profile["columns"], formulas) + ) + if not remaining: + log("Data quality gate: all detected defects repaired in place") + return profile + + # --- decide the hard-fail BEFORE dropping anything --------------- + to_omit = [d for d in remaining if _should_omit(d, formulas)] + for defect in remaining: + if defect in to_omit: + continue + profile["failures"].append(defect) + profile["warnings"].append( + f"{defect['table']}.{defect['column']}: {defect['defect']} — {defect['detail']}. " + "Kept on the board (visible and fixable beats missing)" + ) + unusable = {f"{d['table']}.{d['column']}".upper() for d in to_omit} + unusable |= {d["column"].upper() for d in to_omit} + if required and required <= unusable: + raise MeasureQualityGateError( + "Data quality gate: every measure this demo's story depends on is unusable " + f"({', '.join(sorted(required))}). Nothing demo-able could be produced. " + f"Schema {schema_name} is left in place for inspection.", + to_omit, profile, + ) + + # --- omit what could not be repaired ----------------------------- + for defect in to_omit: + table_name, column = defect["table"], defect["column"] + surviving = [ + c for c in measures_by_table.get(table_name, []) + if c != column and f"{table_name}.{c}".upper() not in + {f"{o['table']}.{o['column']}".upper() for o in profile["omissions"]} + ] + if not drop_unrepairable or not surviving: + # Never strip a table's last measure: a fact with no measures + # breaks the model outright, which is worse than a bad column. + reason = ( + "dropping it would leave the table with no measures" + if surviving == [] else "column removal is disabled for this run" + ) + profile["failures"].append(defect) + profile["warnings"].append( + f"{table_name}.{column}: {defect['defect']} could not be repaired and was " + f"KEPT ({reason})" + ) + continue + cursor.execute( + f"ALTER TABLE {_table_ref(schema_name, table_name)} DROP COLUMN {_quote_ident(column)}" + ) + profile["omissions"].append(defect) + log( + f"[OMIT] dropped {table_name}.{column} ({defect['defect']}: {defect['detail']}) — " + "the demo ships without this measure" + ) + if profile["omissions"]: + connection.commit() + + if not profile["columns"]: + raise MeasureQualityGateError( + f"Data quality gate: schema {schema_name} has no usable measure columns at all.", + remaining, profile, + ) return profile finally: cursor.close() diff --git a/demoprep_app/liveboards/blueprint.py b/demoprep_app/liveboards/blueprint.py index f70b9db0ab8dbe381254b07fb0e03ea70f79e47b..0c060ce77ed6edf76a92c52171164cdbce86e347 100644 --- a/demoprep_app/liveboards/blueprint.py +++ b/demoprep_app/liveboards/blueprint.py @@ -21,8 +21,17 @@ def build_liveboard_blueprint( use_case: str, model_columns: List[Dict[str, Any]] | None = None, target_filter_count: int = 3, + include_demo_notes_tile: bool = True, ) -> Dict[str, Any]: - """Build a small liveboard blueprint from model metadata and story context.""" + """Build a small liveboard blueprint from model metadata and story context. + + include_demo_notes_tile gates the "Demo Guide" tile (ISS-5). Golden-demo + style organises a board with GROUPS, and the tile renders as an awkward + loose block — worse now that it is appended AFTER the layout pass, so it + lands ungrouped on whichever tab happens to be last. Callers on the golden + path pass False. Kept rather than deleted so the standalone/portal callers + that want it can still ask. + """ model_columns = model_columns or [] filters = choose_global_filters(model_columns, target_filter_count=target_filter_count) return { @@ -30,7 +39,10 @@ def build_liveboard_blueprint( "use_case": use_case or company_data.get("use_case", "Analytics"), "app_name": company_data.get("app_name", DEFAULT_APP_NAME), "filters": filters, - "demo_notes_tile": build_demo_notes_spec(company_data, use_case, filters), + "demo_notes_tile": ( + build_demo_notes_spec(company_data, use_case, filters) + if include_demo_notes_tile else {} + ), "validation_rules": { "filter_count_min": 2, "filter_count_max": 6, @@ -218,12 +230,36 @@ def _resolve_demo_guide_id(visualizations: List[Dict[str, Any]], requested_id: s def _place_demo_tile_at_bottom(liveboard: Dict[str, Any], demo_id: str) -> None: + """Pin the Demo Guide tile to the bottom of the SHOWCASE tab. + + boone's ruling 2026-09-02 (closes ISS-5): the tile MOVES to the third page — + not removed, not gated. The third page is the ungraded experiment zone, so a + text tile there does not violate golden-demo style. If the layout has no + showcase tab yet (a board with no tables or custom charts), create it: the + tile must never fall back onto the executive or manager tab. + """ + from demoprep_app.liveboards.golden_layout import SHOWCASE_TAB + tabs = liveboard.setdefault("layout", {}).setdefault("tabs", []) if not tabs: - tabs.append({"name": "Executive Overview", "tiles": []}) - target_tab = tabs[-1] + # A board with no layout yet must still lead with the executive tab; the + # showcase tab is appended below. Never let "Random Charts" be page one. + tabs.append({"name": "Executive Overview", "tiles": [], "group_layouts": []}) + # Remove any previous placement wherever it was. + for tab in tabs: + tiles = tab.get("tiles") or [] + tiles[:] = [tile for tile in tiles if tile.get("visualization_id") != demo_id] + + target_tab = next((tab for tab in tabs if tab.get("name") == SHOWCASE_TAB), None) + if target_tab is None: + target_tab = { + "name": SHOWCASE_TAB, + "description": "Custom visuals, exploratory charts, and the demo guide", + "tiles": [], + "group_layouts": [], + } + tabs.append(target_tab) tiles = target_tab.setdefault("tiles", []) - tiles[:] = [tile for tile in tiles if tile.get("visualization_id") != demo_id] y = _next_tile_y(tiles) tiles.append({"visualization_id": demo_id, "x": 0, "y": y, "height": 7, "width": 12}) diff --git a/demoprep_app/liveboards/golden_layout.py b/demoprep_app/liveboards/golden_layout.py index bf9f2b70b39ff2720e2b196afc2754cb4d1c8636..1f13a06c8d9a015cc7f0817f94bd785c5eb2f6ad 100644 --- a/demoprep_app/liveboards/golden_layout.py +++ b/demoprep_app/liveboards/golden_layout.py @@ -14,6 +14,9 @@ from typing import Any, Dict, Iterable, List, Tuple EXECUTIVE_TAB = "Executive Overview" OPERATIONS_TAB = "Manager Drilldown" +# Third tab: the grab-bag. Custom/Muze charts, the one sanctioned table, and any +# chart too interesting to cut but too odd for the first two tabs. +SHOWCASE_TAB = "Random Charts" def build_dashboard_plan( @@ -31,8 +34,11 @@ def build_dashboard_plan( and lead with the KPI row. Set True to retain a single branded overview tile. """ target_viz_count = max(8, min(int(target_viz_count or 16), 24)) - kpi_count = 4 if target_viz_count >= 14 else 3 + # Four KPIs across the top is THE standard (both golden reference boards), + # independent of how many visualizations the board has. + kpi_count = KPI_STANDARD_COUNT executive_tab, manager_tab = _tab_names_for_use_case(use_case) + showcase_tab = SHOWCASE_TAB return { "company_name": company_name, "use_case": use_case, @@ -42,6 +48,7 @@ def build_dashboard_plan( "model_columns": model_columns, "executive_tab": executive_tab, "manager_tab": manager_tab, + "showcase_tab": showcase_tab, "target_viz_count": target_viz_count, "audiences": [ { @@ -54,6 +61,11 @@ def build_dashboard_plan( "tab": manager_tab, "intent": "Operator-level breakdowns for action, ownership, and follow-up.", }, + { + "name": "Analyst", + "tab": showcase_tab, + "intent": "Exploratory and custom visuals, plus row-level detail.", + }, ], "sections": [ { @@ -98,6 +110,12 @@ def build_dashboard_plan( "role": "detail", "target_count": 2, }, + { + "name": "Chart Showcase", + "tab": showcase_tab, + "role": "showcase", + "target_count": SHOWCASE_TAB_TARGET, + }, ], "style": { "border": "CURVED", @@ -160,17 +178,39 @@ def apply_golden_two_tab_layout( # Auto-build a geo map when the model has coordinates and the board has none. geo_viz = _maybe_build_geo_viz(data_visualizations, plan.get("model_columns")) geo_change = "" + if geo_viz is None: + untagged = untagged_geo_candidates(plan.get("model_columns")) + if untagged: + geo_change = ( + "No geo map: " + ", ".join(untagged[:3]) + " look geographic but " + "carry no geo_config in the model, and ThoughtSpot renders an " + "untagged GEO_* chart as a blank map. Fix the model, not the board." + ) if geo_viz is not None: data_visualizations.append(geo_viz) visualizations.append(geo_viz) liveboard["visualizations"] = visualizations geo_change = f"Built geo map: {geo_viz['answer']['name']}" + # One detail table for the showcase tab, built the same way as the geo map. + table_viz = _maybe_build_detail_table(data_visualizations, plan.get("model_columns")) + table_change = "" + if table_viz is not None: + data_visualizations.append(table_viz) + visualizations.append(table_viz) + liveboard["visualizations"] = visualizations + table_change = f"Built detail table: {table_viz['answer']['name']}" + kpi_changes = _convert_periodic_metric_charts(data_visualizations) + sparkline_changes = _fix_kpi_sparklines(data_visualizations) + muze_changes = _muze_upgrade_surplus_heatmaps(data_visualizations) chart_changes = _reduce_heatmaps_and_apply_palettes(data_visualizations) + # After every type mutation above, and covering types MCP chose itself. + axis_changes = repair_secondary_axis_mismatch(data_visualizations) mode_changes = _force_chart_mode(data_visualizations) color_changes = _apply_color_by_dimension(data_visualizations) - groups = _build_groups(data_visualizations, plan) + group_notes: List[str] = [] + groups = _build_groups(data_visualizations, plan, notes=group_notes) # Cosmetic title cleanup AFTER classification so roles use the original names. title_changes = _clean_viz_titles(data_visualizations) if note_viz: @@ -184,13 +224,19 @@ def apply_golden_two_tab_layout( f"Applied golden two-tab layout ({len(groups)} groups)", f"Tabs: {_executive_tab_name(plan)}, {_manager_tab_name(plan)}", ] - for change in (geo_change, kpi_changes, chart_changes, mode_changes, color_changes, title_changes): + for change in (geo_change, table_change, kpi_changes, sparkline_changes, chart_changes, + axis_changes, muze_changes, mode_changes, color_changes, + title_changes, *group_notes): if change: changes.append(change) return updated, changes -def _build_groups(visualizations: List[Dict[str, Any]], plan: Dict[str, Any]) -> List[Dict[str, Any]]: +def _build_groups( + visualizations: List[Dict[str, Any]], + plan: Dict[str, Any], + notes: List[str] | None = None, +) -> List[Dict[str, Any]]: buckets = { "kpi": [], "geo": [], @@ -199,6 +245,7 @@ def _build_groups(visualizations: List[Dict[str, Any]], plan: Dict[str, Any]) -> "breakdown": [], "ranking": [], "detail": [], + "showcase": [], } for viz in visualizations: @@ -210,7 +257,8 @@ def _build_groups(visualizations: List[Dict[str, Any]], plan: Dict[str, Any]) -> overflow = [] assigned = set() - for role in ("kpi", "geo", "trend", "driver", "breakdown", "ranking", "detail"): + for role in ("kpi", "geo", "trend", "driver", "breakdown", "ranking", + "detail", "showcase"): assigned.update(buckets[role]) for viz in visualizations: viz_id = viz.get("id", "") @@ -218,7 +266,12 @@ def _build_groups(visualizations: List[Dict[str, Any]], plan: Dict[str, Any]) -> overflow.append(viz_id) buckets["breakdown"].extend(overflow) - _promote_executive_analysis(buckets) + _fill_showcase(buckets, visualizations) + quarantine_note = _quarantine_problem_vizzes(buckets, visualizations) + if quarantine_note and notes is not None: + notes.append(quarantine_note) + _demote_heatmaps(buckets, visualizations) + _rebalance_tabs(buckets) groups = [] for section in plan.get("sections", []): @@ -241,33 +294,139 @@ def _build_groups(visualizations: List[Dict[str, Any]], plan: Dict[str, Any]) -> return groups -EXEC_ANALYSIS_TARGET = 5 # trend + driver charts we want on the executive tab -MANAGER_MIN = 1 # leave at least this many analysis charts for the manager tab +MANAGER_TAB_TARGET = 6 # tab 2 is a themed drilldown +SHOWCASE_TAB_TARGET = 4 # tab 3 is a short grab-bag +EXEC_MIN_ANALYSIS = 4 # non-KPI charts the exec tab keeps before later tabs fill +MANAGER_MIN_TILES = 3 # handoff 2026-09-02: a one-viz manager tab is a failure; floor is 3 +_MANAGER_ROLES = ("breakdown", "ranking", "detail") +_EXEC_ANALYSIS_ROLES = ("trend", "driver") +# Chart types that belong on the showcase tab: hand-authored/custom visuals and +# the exotic types we would not put in front of an executive. +SHOWCASE_CHART_TYPES = frozenset({ + "MUZE_STUDIO", "CUSTOM_CHART", "SANKEY", "SPIDER_WEB", "PIE", +}) -def _promote_executive_analysis(buckets: Dict[str, List[str]]) -> None: - """Keep the first tab substantial (~EXEC_ANALYSIS_TARGET analysis charts) by - promoting breakdown/ranking charts to drivers — without dropping the manager - tab below MANAGER_MIN charts.""" - executive_analysis_count = len(buckets["trend"]) + len(buckets["driver"]) - if executive_analysis_count >= EXEC_ANALYSIS_TARGET: - return - available = len(buckets["breakdown"]) + len(buckets["ranking"]) - if available < 4: - # Small board: leave breakdowns/rankings on the manager tab as-is. +def _is_table_viz(viz: Dict[str, Any]) -> bool: + return "TABLE" in _chart_type(viz) + + +def _demote_heatmaps( + buckets: Dict[str, List[str]], + visualizations: List[Dict[str, Any]], +) -> None: + """Heatmaps go to the END of the board — the showcase tab, appended last. + + Run this AFTER _fill_showcase so heatmaps sit behind the custom charts and + the table rather than leading the tab. + """ + heatmap_ids = [ + viz.get("id", "") + for viz in visualizations + if _chart_type(viz) == "HEATMAP" and viz.get("id") + ] + if not heatmap_ids: return - promotable = max(0, available - MANAGER_MIN) - if promotable <= 0: + heatmap_set = set(heatmap_ids) + for role in ("geo", "trend", "driver", "breakdown", "ranking", "detail", "showcase"): + buckets[role] = [i for i in buckets[role] if i not in heatmap_set] + buckets["showcase"].extend(heatmap_ids) + + +def _fill_showcase( + buckets: Dict[str, List[str]], + visualizations: List[Dict[str, Any]], +) -> None: + """Populate the showcase tab: custom/exotic charts first, then exactly ONE + table, then whatever is left over from the manager tab. + + Tables are banned everywhere else on the board; the showcase tab is the + single sanctioned exception, and it gets one. + """ + by_id = {viz.get("id"): viz for viz in visualizations if viz.get("id")} + other_roles = ("breakdown", "ranking", "detail", "driver", "trend") + + def take(predicate, limit) -> None: + for role in other_roles: + for viz_id in list(buckets[role]): + if len(buckets["showcase"]) >= limit: + return + viz = by_id.get(viz_id) + if viz is not None and predicate(viz): + buckets[role].remove(viz_id) + buckets["showcase"].append(viz_id) + + # 1. Custom and exotic visuals — the reason this tab exists. + take(lambda v: _chart_type(v) in SHOWCASE_CHART_TYPES, SHOWCASE_TAB_TARGET) + + # 2. Exactly one table. + if not any(_is_table_viz(by_id[i]) for i in buckets["showcase"] if i in by_id): + take(_is_table_viz, len(buckets["showcase"]) + 1) + + # 3. Any remaining tables go nowhere near the exec/manager tabs. + for role in other_roles: + keep = [] + for viz_id in buckets[role]: + viz = by_id.get(viz_id) + if viz is not None and _is_table_viz(viz): + buckets["showcase"].append(viz_id) + else: + keep.append(viz_id) + buckets[role] = keep + + +def _rebalance_tabs(buckets: Dict[str, List[str]]) -> None: + """Split analysis charts across the executive and manager tabs. + + Retuned ONCE for three tabs (handoff 2026-09-02, item 3). The showcase tab + has already taken its charts by the time this runs, so `chart_total` is what + the first two tabs share. + + Priorities, in order: + 1. The manager tab gets at least MANAGER_MIN_TILES (3). A one-viz manager + tab is a failure the SEs called out; "keep that one and add two". + 2. The executive tab keeps at least one analysis chart under its KPIs. + 3. Beyond that, the exec tab is front-loaded: it takes everything past + MANAGER_TAB_TARGET, and the manager tab does not grow past it. + + Worked examples (analysis charts only; KPIs and showcase excluded): + 12 charts -> exec 6, manager 6 + 10 charts -> exec 4, manager 6 + 6 charts -> exec 3, manager 3 + 3 charts -> exec 1, manager 2 (floor yields to rule 2) + 1 chart -> exec 1, manager tab omitted + """ + chart_total = sum(len(buckets[r]) for r in _EXEC_ANALYSIS_ROLES + _MANAGER_ROLES) + if chart_total == 0: return - needed = min(EXEC_ANALYSIS_TARGET - executive_analysis_count, promotable) - while needed > 0 and buckets["breakdown"]: - buckets["driver"].append(buckets["breakdown"].pop(0)) - needed -= 1 - while needed > 0 and buckets["ranking"]: - buckets["driver"].append(buckets["ranking"].pop(0)) - needed -= 1 + # Rule 3, then rule 1, then rule 2 caps it. + manager_target = max(MANAGER_MIN_TILES, chart_total - EXEC_MIN_ANALYSIS) + manager_target = min(manager_target, MANAGER_TAB_TARGET) + manager_target = min(manager_target, chart_total - 1) # exec keeps >= 1 + manager_target = max(manager_target, 0) + + # Too many on the manager tab -> promote to the exec tab's driver section, + # taking from the END of each bucket so the tiles that most belong on a + # drilldown (multi-dimensional charts, detail tables) stay put — and never + # the strongest remaining chart, which sits at the front. + surplus = sum(len(buckets[r]) for r in _MANAGER_ROLES) - manager_target + for role in _MANAGER_ROLES: + while surplus > 0 and buckets[role]: + buckets["driver"].append(buckets[role].pop(0 if role == "detail" else -1)) + surplus -= 1 + + # Too few -> borrow from the end of the exec analysis roles, but leave the + # exec tab at least one analysis chart in total. + deficit = manager_target - sum(len(buckets[r]) for r in _MANAGER_ROLES) + for role in ("driver", "trend"): + while deficit > 0 and buckets[role]: + exec_left = len(buckets["driver"]) + len(buckets["trend"]) + if exec_left <= 1: + break + buckets["breakdown"].append(buckets[role].pop()) + deficit -= 1 def _build_tabs( @@ -278,32 +437,36 @@ def _build_tabs( plan = plan or {} executive_tab = _executive_tab_name(plan) manager_tab = _manager_tab_name(plan) - executive_groups = [group for group in groups if group.get("tab") == executive_tab] - operations_groups = [group for group in groups if group.get("tab") == manager_tab] + showcase_tab = _showcase_tab_name(plan) + + executive_groups = [g for g in groups if g.get("tab") == executive_tab] + operations_groups = [g for g in groups if g.get("tab") == manager_tab] + showcase_groups = [g for g in groups if g.get("tab") == showcase_tab] if not executive_groups: executive_groups = groups[:1] - operations_groups = groups[1:] + operations_groups = [g for g in groups[1:] if g.get("tab") != showcase_tab] - tabs = [] - tabs.append( + tabs = [ { "name": executive_tab, "description": "Executive-ready performance summary and key drivers", "tiles": _top_level_tiles(executive_groups, note_viz_id=note_viz_id), "group_layouts": [_group_layout(group) for group in executive_groups], } - ) - - if operations_groups: - tabs.append( - { - "name": manager_tab, - "description": "Manager-level breakdowns, rankings, and detail", - "tiles": _top_level_tiles(operations_groups), - "group_layouts": [_group_layout(group) for group in operations_groups], - } - ) + ] + for name, description, tab_groups in ( + (manager_tab, "Manager-level breakdowns, rankings, and detail", operations_groups), + (showcase_tab, "Custom visuals, exploratory charts, and row-level detail", + showcase_groups), + ): + if tab_groups: + tabs.append({ + "name": name, + "description": description, + "tiles": _top_level_tiles(tab_groups), + "group_layouts": [_group_layout(group) for group in tab_groups], + }) return tabs @@ -364,8 +527,11 @@ def _group_layout(group: Dict[str, Any]) -> Dict[str, Any]: return {"id": group.get("id", ""), "tiles": tiles} -KPI_ROW_H = 5 -CHART_ROW_H = 10 +# Golden reference heights: the IoT board's 4-KPI hero row is h=4 (w=3 each) and +# its chart rows are h=6..8. We were shipping 5/10, which made boards feel loose. +KPI_ROW_H = 4 +CHART_ROW_H = 8 +KPI_STANDARD_COUNT = 4 def _chart_group_widths(n: int) -> List[int]: @@ -401,11 +567,12 @@ def _apply_style( { "lb_border_type": style_config.get("border", "CURVED"), "lb_brand_color": style_config.get("brand_color", "LBC_A"), + # Verbatim from goldendemo/Vizio.com + Manufacturing_IoT (both agree). "hide_group_title": "false", - "hide_group_description": "true", + "hide_group_description": "false", "hide_group_tile_description": "false", - "hide_tile_description": "false", - "kpi_hero_font_size": "L", + "hide_tile_description": "true", + "kpi_hero_font_size": "M", }, ) @@ -504,6 +671,9 @@ def _logo_block(company_name: str, logo_url: str | None) -> str: ) +HEATMAP_LIMIT = 1 # heatmaps read as a wall of green; keep at most this many + + def _reduce_heatmaps_and_apply_palettes(visualizations: List[Dict[str, Any]]) -> str: """Keep heatmaps rare and add varied chart colors to avoid all-blue boards.""" heatmaps_seen = 0 @@ -517,7 +687,7 @@ def _reduce_heatmaps_and_apply_palettes(visualizations: List[Dict[str, Any]]) -> if chart_type == "HEATMAP": heatmaps_seen += 1 - if heatmaps_seen > 2: + if heatmaps_seen > HEATMAP_LIMIT: chart["type"] = _replacement_chart_type(answer) chart_type = chart["type"] heatmaps_converted += 1 @@ -534,13 +704,175 @@ def _reduce_heatmaps_and_apply_palettes(visualizations: List[Dict[str, Any]]) -> return "; ".join(parts) +# Chart types we know render correctly from the specs we produce. Anything else +# is a chart we cannot vouch for, so it goes to the showcase tab rather than in +# front of an executive. +KNOWN_GOOD_CHART_TYPES = frozenset({ + "KPI", "LINE", "AREA", "COLUMN", "BAR", "PIE", "HEATMAP", + "STACKED_COLUMN", "STACKED_BAR", "STACKED_AREA", "LINE_COLUMN", + "ADVANCED_LINE", "ADVANCED_AREA", "ADVANCED_COLUMN", "ADVANCED_BAR", + "ADVANCED_STACKED_COLUMN", "ADVANCED_STACKED_BAR", "ADVANCED_STACKED_AREA", + "ADVANCED_LINE_COLUMN", "PIVOT_TABLE", "TABLE", "GRID_TABLE", + "GEO_AREA", "GEO_BUBBLE", "MUZE_STUDIO", +}) + +MUZE_SHOWCASE_MAX = 2 # how many surplus heatmaps to upgrade rather than downgrade + + +def is_problem_viz(viz: Dict[str, Any]) -> str: + """Return a reason string when a viz is one we cannot vouch for, else "". + + Rather than shipping a doubtful tile on the executive tab, move it to the + showcase tab where it is clearly experimental. boone's framing: "if we are + having trouble with a viz let's move it to this tab". + """ + answer = viz.get("answer") or {} + chart = answer.get("chart") or {} + chart_type = str(chart.get("type") or "") + + if not chart_type: + return "no chart type" + if chart_type not in KNOWN_GOOD_CHART_TYPES: + return f"unrecognised chart type {chart_type}" + # Still contradictory after the repair pass ran. + if chart_type in SINGLE_AXIS_CHART_TYPES and _has_secondary_axis(answer): + return "chart type cannot render its secondary axis" + # Deliberately NOT checking column counts: MCP legitimately leaves + # answer_columns/chart_columns empty on exports that render fine, so that + # signal quarantines whole boards. Only quarantine what we can prove is bad. + return "" + + +def _muze_upgrade_surplus_heatmaps(visualizations: List[Dict[str, Any]]) -> str: + """Turn surplus heatmaps into designed Muze cell heatmaps instead of + downgrading them all to plain columns. + + The dim x dim -> measure shape is exactly what the Muze cell-heatmap + archetype draws, so this upgrades the tile boone objected to rather than + flattening it. Capped at MUZE_SHOWCASE_MAX because these are experimental + and land on the showcase tab. + """ + from demoprep_app.liveboards.muze_templates import ( + CELL_HEATMAP, build_custom_visual_props, + ) + + upgraded = 0 + seen = 0 + for viz in visualizations: + answer = viz.get("answer") or {} + chart = answer.get("chart") or {} + if chart.get("type") != "HEATMAP": + continue + seen += 1 + if seen <= HEATMAP_LIMIT or upgraded >= MUZE_SHOWCASE_MAX: + continue + + columns = _answer_column_names(answer) or _chart_column_names(chart) + measures = [c for c in columns if _is_measure_name(c)] + dimensions = [c for c in columns + if not _is_measure_name(c) and not _looks_like_date_column(c)] + if len(measures) != 1 or len(dimensions) < 2: + continue + bindings = {"x": dimensions[0], "y": dimensions[1], "measure": measures[0]} + try: + props = build_custom_visual_props(CELL_HEATMAP, bindings) + except ValueError: + continue # e.g. a column name carrying a quote + + chart["type"] = "MUZE_STUDIO" + chart["custom_visual_props"] = props + # The Muze program draws its own SVG; a stale axis config would only + # contradict it. + chart.pop("axis_configs", None) + upgraded += 1 + + if upgraded: + return f"Upgraded {upgraded} surplus heatmap(s) to Muze cell heatmaps" + return "" + + +def _quarantine_problem_vizzes( + buckets: Dict[str, List[str]], + visualizations: List[Dict[str, Any]], +) -> str: + """Move doubtful visualizations to the showcase tab, with the reason logged.""" + by_id = {v.get("id"): v for v in visualizations if v.get("id")} + moved = [] + for role in ("geo", "trend", "driver", "breakdown", "ranking", "detail"): + keep = [] + for viz_id in buckets[role]: + reason = is_problem_viz(by_id.get(viz_id, {})) + if reason: + buckets["showcase"].append(viz_id) + moved.append(f"{viz_id} ({reason})") + else: + keep.append(viz_id) + buckets[role] = keep + if not moved: + return "" + return "Quarantined to the showcase tab: " + "; ".join(moved) + + +def repair_secondary_axis_mismatch(visualizations: List[Dict[str, Any]]) -> str: + """Give every viz a chart type its OWN axis config allows — not just demoted heatmaps. + + e93e261 fixed this only where WE overwrite chart["type"] (the heatmap cap). + But Spotter/MCP hands us two-measure "X vs Y" answers that ALREADY arrive as + ADVANCED_COLUMN / ADVANCED_BAR while their client_state_v2 declares a + secondary Y axis (isOpposite: true). Those are the majority, and they were + never touched — measured on boards built 2026-09-01: + + 198e8478 (secloud, mani.uppala) Viz_7, Viz_9, Viz_10, Viz_11 + 25f8b739 (sebe, boone) Viz_6, Viz_9, Viz_10 + 2c3fc690 (sebe) Viz_5, Viz_6, Viz_8 + + Every one is an "X vs Y" / "X and Y" two-measure comparison, and every one + rendered "Cannot display the chart". + + Repair chosen: promote the TYPE to the combo chart, not strip the axis. + Stripping isOpposite also produces a valid spec, but it silently discards + the dual-scale intent and puts a $100M bar next to a 0.5 rate bar. The + promotion has direct rendering evidence behind it — on Larry's board + (eb6f9238) Viz_6 and Viz_12 are the SAME question with identical + axisProperties; the ADVANCED_COLUMN one blanked and the ADVANCED_LINE_COLUMN + one rendered. + + Idempotent, and safe to run after every other chart-type mutation. + + NOTE (golden-demo-v3): this is the MCP line's function (913c157), adopted + verbatim so the merge carries exactly ONE blank-tile repair. This branch's + own equivalent (2e3a32e) was deleted in its favour. + """ + repaired: List[str] = [] + for viz in visualizations: + if "note_tile" in viz: + continue + answer = viz.get("answer") or {} + chart = answer.get("chart") + if not isinstance(chart, dict): + continue + if str(chart.get("type", "")).upper() not in SINGLE_AXIS_CHART_TYPES: + continue + if not _has_secondary_axis(answer): + continue + chart["type"] = COMBO_CHART_TYPE + repaired.append(viz.get("id") or answer.get("name", "?")) + + if not repaired: + return "" + return ( + f"Repaired {len(repaired)} chart(s) whose type could not render their " + f"secondary axis ({', '.join(repaired)} -> {COMBO_CHART_TYPE})" + ) + + def _force_chart_mode(visualizations: List[Dict[str, Any]]) -> str: """Charts rendering as data grids (display_mode=TABLE_MODE) but with a real chart type should display as charts. Genuine tables/KPIs are left alone.""" fixed = 0 for viz in visualizations: chart_type = _chart_type(viz) - if chart_type in {"TABLE", "PIVOT_TABLE", "KPI", "NOTE", "UNKNOWN"}: + if chart_type in {"TABLE", "GRID_TABLE", "PIVOT_TABLE", "KPI", "NOTE", "UNKNOWN"}: continue answer = viz.get("answer", {}) if answer.get("display_mode") != "CHART_MODE": @@ -600,6 +932,118 @@ _GEO_DIM_HINTS = ( ) +_GEO_AREA_PREFERENCE = ( + # Descending preference. STATE FIRST: demo data is overwhelmingly US and a + # state choropleth is the useful one. A country map of a single-country + # dataset is one solid blob. + (("state", "province"), ("estate", "statement", "status")), + (("county",), ()), + (("city", "metro"), ()), + (("country",), ()), +) + + +def detect_geo_area_column(model_columns: List[Dict[str, Any]]) -> Dict[str, str] | None: + """Find an area-mappable geo attribute (country preferred) plus a measure. + + Unlike detect_geo_columns this needs NO lat/lon — GEO_AREA fills regions by + name. The model column must carry geo_config or the map renders blank + (see ThoughtSpotDeployer._geo_config_for_column). + """ + names = [(c.get("name", ""), str(c.get("type", "")).upper()) + for c in (model_columns or []) if c.get("name")] + # ThoughtSpot fills regions only for columns tagged with geo_config in the + # model. A GEO_AREA on an untagged column renders a BLANK map with no error, + # so an untagged candidate is worse than no map at all. When the caller does + # not report the flag at all (older callers, tests), fall back to name-based + # detection rather than silently disabling geo everywhere. + reports_geo_config = any("geo_config" in c for c in (model_columns or [])) + geo_tagged = { + c.get("name", "") for c in (model_columns or []) if c.get("geo_config") + } + attrs = [ + n for n, t in names + if t == "ATTRIBUTE" and (not reports_geo_config or n in geo_tagged) + ] + geodim = None + for hints, exclusions in _GEO_AREA_PREFERENCE: + geodim = next( + (n for n in attrs + if any(h in n.lower() for h in hints) + and not any(x in n.lower() for x in exclusions)), + None, + ) + if geodim: + break + if not geodim: + return None + measure = _preferred_measure(names) + if not measure: + return None + return {"geodim": geodim, "measure": measure} + + +def _preferred_measure(names: List[Tuple[str, str]]) -> str | None: + measures = [n for n, t in names if t == "MEASURE" + and not _LAT_RE.search(n) and not _LON_RE.search(n)] + return next( + (n for n in measures if any(k in n.lower() for k in + ("revenue", "sales", "amount", "value", "gmv", "spend"))), + measures[0] if measures else None, + ) + + +def _geo_area_client_state(geodim: str, measure_col: str) -> str: + """Golden-demo GEO_AREA state: yellow->red gradient fill with data labels.""" + gradient = ["#ffffb2", "#fddd87", "#fba35d", "#f75534", "#f9140a", "#d70315", "#b10026"] + state = { + "version": "V4DOT2", + "chartProperties": { + "chartSpecific": {"customProps": "{\"tileType\":\"light-v9\"}"}, + }, + "columnProperties": [ + {"columnId": geodim, "columnProperty": {}}, + {"columnId": measure_col, "columnProperty": {"dataLabels": True}}, + ], + "axisProperties": [ + {"id": "geo-area-y", "properties": { + "axisType": "Y", "linkedColumns": [measure_col], "isOpposite": False}}, + {"id": "geo-area-x", "properties": {"axisType": "X", "linkedColumns": [geodim]}}, + ], + "systemMultiColorSeriesColors": [ + {"serieName": measure_col, + "colorMap": [{"serieName": geodim, "color": gradient}]} + ], + } + return json_dumps_compact(state) + + +def _build_geo_area_viz( + sibling_answer: Dict[str, Any], + geo: Dict[str, str], + viz_id: str = "Viz_Geo", +) -> Dict[str, Any]: + geodim, measure = geo["geodim"], geo["measure"] + t_meas = f"Total {measure}" + return { + "id": viz_id, + "answer": { + "name": f"{measure} by {geodim}", + "tables": deepcopy(sibling_answer.get("tables", [])), + "search_query": f"sum [{measure}] [{geodim}]", + "answer_columns": [{"name": t_meas}, {"name": geodim}], + "chart": { + "type": "GEO_AREA", + "chart_columns": [{"column_id": t_meas}, {"column_id": geodim}], + "axis_configs": [{"x": [geodim], "y": [t_meas]}], + "client_state": "", + "client_state_v2": _geo_area_client_state(geodim, t_meas), + }, + "display_mode": "CHART_MODE", + }, + } + + def detect_geo_columns(model_columns: List[Dict[str, Any]]) -> Dict[str, str] | None: """From a model's columns [{name, type}], find lat/lon + a geo attribute + a measure. Returns {lat, lon, geodim, measure} or None if not geographic.""" @@ -670,6 +1114,148 @@ def _build_geo_viz(sibling_answer: Dict[str, Any], geo: Dict[str, str], viz_id: } +def untagged_geo_candidates(model_columns: List[Dict[str, Any]] | None) -> List[str]: + """Attribute columns that LOOK geographic but carry no geo_config. + + These are why a board has no map. Returned so the caller can say so out loud + rather than silently skipping — the model needs the tag, the liveboard can + do nothing about it. + """ + if not model_columns or not any("geo_config" in c for c in model_columns): + return [] + hints = tuple(h for hints, _ in _GEO_AREA_PREFERENCE for h in hints) + return [ + c.get("name", "") + for c in model_columns + if str(c.get("type", "")).upper() == "ATTRIBUTE" + and not c.get("geo_config") + and any(h in c.get("name", "").lower() for h in hints) + and not any(x in c.get("name", "").lower() + for x in ("estate", "statement", "status")) + ] + + +# ThoughtSpot's allowed values for answer.chart.type, verbatim from a TML import +# rejection (ZZ Lab build6, 2026-09-02). Note: "TABLE" is NOT in it — a table is +# a valid chart type plus display_mode: TABLE_MODE. Synthesised vizzes must use +# a type from this set or the WHOLE enhancement import is discarded. +TS_ALLOWED_CHART_TYPES = frozenset({ + "ADVANCED_AREA", "ADVANCED_BAR", "ADVANCED_BUBBLE", "ADVANCED_COLUMN", + "ADVANCED_DONUT", "ADVANCED_LINE", "ADVANCED_LINE_COLUMN", + "ADVANCED_LINE_STACKED_COLUMN", "ADVANCED_PIVOT_TABLE", "ADVANCED_SCATTER", + "ADVANCED_STACKED_AREA", "ADVANCED_STACKED_BAR", "ADVANCED_STACKED_COLUMN", + "ADVANCED_WATERFALL", "AREA", "BAR", "BUBBLE", "CANDLESTICK", "COLUMN", + "CUSTOM_CHART", "FUNNEL", "GEO_AREA", "GEO_BUBBLE", "GEO_EARTH_AREA", + "GEO_EARTH_BAR", "GEO_EARTH_BUBBLE", "GEO_EARTH_GRAPH", "GEO_EARTH_HEATMAP", + "GEO_HEATMAP", "GRID_TABLE", "HEATMAP", "KPI", "LINE", "LINE_COLUMN", + "LINE_STACKED_COLUMN", "MUZE_STUDIO", "PARETO", "PIE", "PIVOT_TABLE", + "SANKEY", "SCATTER", "SPIDER_WEB", "STACKED_AREA", "STACKED_BAR", + "STACKED_COLUMN", "TREEMAP", "WATERFALL", "WHISKER_SCATTER", +}) + +# The chart type we give a synthesised detail table. Golden-demo TABLE_MODE +# vizzes keep a real chart type; GRID_TABLE is the server's table type and +# contains "TABLE", which every table-routing check in this module keys on. +DETAIL_TABLE_CHART_TYPE = "GRID_TABLE" + + +_TABLE_ENTITY_HINTS = ( + "name", "payer", "customer", "account", "provider", "practice", "store", + "product", "rep", "vendor", "supplier", "client", "patient", "employee", +) +_TABLE_MEASURE_HINTS = ( + "revenue", "amount", "sales", "collected", "billed", "cost", "margin", + "rate", "count", "total", "spend", "value", +) + + +def detect_detail_table_columns(model_columns: List[Dict[str, Any]] | None) -> Dict[str, Any] | None: + """Pick one entity attribute and up to three measures for a detail table. + + Prefers an entity-like attribute (a "Name" column, payer, provider...) over a + low-cardinality category, and business measures over ids. Returns + {"entity": str, "measures": [str, ...]} or None when the model cannot support + a useful table. + """ + cols = [(c.get("name", ""), str(c.get("type", "")).upper()) + for c in (model_columns or []) if c.get("name")] + attrs = [n for n, t in cols if t == "ATTRIBUTE" and not _looks_like_date_column(n)] + measures = [n for n, t in cols if t == "MEASURE" + and not _LAT_RE.search(n) and not _LON_RE.search(n)] + if not attrs or not measures: + return None + + def attr_score(name: str) -> int: + low = name.lower() + return sum(10 for h in _TABLE_ENTITY_HINTS if h in low) + (5 if "name" in low else 0) + + def measure_score(name: str) -> int: + low = name.lower() + return sum(10 for h in _TABLE_MEASURE_HINTS if h in low) + + entity = max(attrs, key=attr_score) + if attr_score(entity) == 0: + return None # nothing entity-like; a table of categories is not a drill-down + ranked = sorted(measures, key=measure_score, reverse=True) + return {"entity": entity, "measures": ranked[:3]} + + +def _build_detail_table_viz( + sibling_answer: Dict[str, Any], + spec: Dict[str, Any], + viz_id: str = "Viz_Detail", +) -> Dict[str, Any]: + entity, measures = spec["entity"], spec["measures"] + totals = [f"Total {m}" for m in measures] + query = f"[{entity}] " + " ".join(f"[{m}]" for m in measures) + return { + "id": viz_id, + "answer": { + "name": f"{entity} Detail", + "tables": deepcopy(sibling_answer.get("tables", [])), + "search_query": query, + "answer_columns": [{"name": entity}] + [{"name": t} for t in totals], + "table": { + "table_columns": [{"column_id": entity}] + [{"column_id": t} for t in totals], + "ordered_column_ids": [entity] + totals, + "client_state": "", + "client_state_v2": '{"tableVizPropVersion": "V1","wrapTableHeader": true}', + }, + "chart": { + "type": DETAIL_TABLE_CHART_TYPE, + "chart_columns": [{"column_id": entity}] + [{"column_id": t} for t in totals], + }, + "display_mode": "TABLE_MODE", + }, + } + + +def _maybe_build_detail_table( + visualizations: List[Dict[str, Any]], + model_columns: List[Dict[str, Any]] | None, +) -> Dict[str, Any] | None: + """Synthesise the one sanctioned table when the board has none. + + boone asked for exactly one table chart on the third page. Asking the + question generator for it did not work (build4/build5 produced none — a + prompt request is advisory), so the table is built deterministically here, + the same way the geo map is. _fill_showcase routes it to the showcase tab. + """ + if not model_columns: + return None + if any("TABLE" in str((v.get("answer") or {}).get("chart", {}).get("type", "")) + for v in visualizations if "note_tile" not in v): + return None + spec = detect_detail_table_columns(model_columns) + if not spec: + return None + sibling = next((v.get("answer", {}) for v in visualizations + if "note_tile" not in v and v.get("answer", {}).get("tables")), None) + if not sibling: + return None + return _build_detail_table_viz(sibling, spec) + + def _maybe_build_geo_viz( visualizations: List[Dict[str, Any]], model_columns: List[Dict[str, Any]] | None, @@ -680,13 +1266,18 @@ def _maybe_build_geo_viz( if any(str(v.get("answer", {}).get("chart", {}).get("type", "")).startswith("GEO") for v in visualizations if "note_tile" not in v): return None - geo = detect_geo_columns(model_columns) - if not geo: - return None sibling = next((v.get("answer", {}) for v in visualizations if "note_tile" not in v and v.get("answer", {}).get("tables")), None) if not sibling: return None + # Prefer a filled country/region map (golden-demo style); fall back to + # lat/lon bubbles only when no area-mappable attribute exists. + area_geo = detect_geo_area_column(model_columns) + if area_geo: + return _build_geo_area_viz(sibling, area_geo) + geo = detect_geo_columns(model_columns) + if not geo: + return None return _build_geo_viz(sibling, geo) @@ -746,7 +1337,10 @@ def _convert_periodic_metric_charts(visualizations: List[Dict[str, Any]]) -> str {"column_id": date_column}, {"column_id": measure_column}, ] - chart.pop("client_state_v2", None) + # Previously this popped client_state_v2, which silently removed the only + # place sparkline/comparison settings can live (root cause of TASK 3: + # 0 of 18 graded boards had any sparkline state). + _apply_kpi_client_state(chart, date_column, measure_column) converted += 1 if converted: @@ -754,6 +1348,74 @@ def _convert_periodic_metric_charts(visualizations: List[Dict[str, Any]]) -> str return "" +# Golden-demo KPI customProps. showComparison + showSparkline are what render the +# trend line and the percent-change range chip; without them a KPI is a bare number. +# PREV_AVAILABLE compares against the previous populated period, which is resilient +# to demo data that ends mid-period. +def _kpi_custom_props() -> Dict[str, Any]: + return { + "showLabel": True, + "showComparison": True, + "showSparkline": True, + "showAnomalies": True, + "showBounds": True, + "customCompare": "PREV_AVAILABLE", + "showOnlyLatestAnomaly": False, + } + + +def _apply_kpi_client_state( + chart: Dict[str, Any], + date_column: str | None, + measure_column: str | None, +) -> None: + """Write sparkline + comparison state onto a KPI chart, preserving whatever + else the export already carried.""" + client_state = _load_chart_client_state(chart) + chart_properties = client_state.setdefault("chartProperties", {}) + chart_properties["responsiveLayoutPreference"] = "AUTO_ON" + chart_specific = chart_properties.setdefault("chartSpecific", {}) + chart_specific["customProps"] = json_dumps_compact(_kpi_custom_props()) + + if date_column and measure_column: + client_state["columnProperties"] = [ + {"columnId": measure_column, "columnProperty": {"conditionalFormatting": {}}}, + {"columnId": date_column, "columnProperty": {}}, + ] + client_state["axisProperties"] = [ + {"id": "kpi-y", "properties": { + "axisType": "Y", "linkedColumns": [measure_column], "isOpposite": False}}, + {"id": "kpi-x", "properties": {"axisType": "X", "linkedColumns": [date_column]}}, + ] + chart["client_state_v2"] = json_dumps_compact(client_state) + + +def _fix_kpi_sparklines(visualizations: List[Dict[str, Any]]) -> str: + """Ensure EVERY KPI tile carries sparkline/comparison state. + + _convert_periodic_metric_charts only touches answers it promotes to KPI; + KPIs that MCP already created never passed through it, which is why graded + boards showed bare numbers. A KPI with no date column gets the customProps + anyway — the comparison chip still renders; only the sparkline needs time. + """ + fixed = 0 + for viz in visualizations: + chart = viz.get("answer", {}).get("chart", {}) + if chart.get("type") != "KPI": + continue + if _kpi_state_is_complete(chart): + continue + date_column, measure_column = _kpi_axis_columns(viz.get("answer", {})) + _apply_kpi_client_state(chart, date_column, measure_column) + fixed += 1 + return f"Added sparkline/comparison state to {fixed} KPI(s)" if fixed else "" + + +def _kpi_state_is_complete(chart: Dict[str, Any]) -> bool: + raw = chart.get("client_state_v2") or "" + return "showSparkline" in raw and "showComparison" in raw + + def _kpi_axis_columns(answer: Dict[str, Any]) -> Tuple[str | None, str | None]: columns = _answer_column_names(answer) if len(columns) < 2: @@ -810,7 +1472,60 @@ def _looks_like_date_column(column: str) -> bool: ) +# Chart types that render a SINGLE Y axis. Assigning one of these to an answer +# whose saved axis config declares a secondary axis (isOpposite: true) produces a +# spec ThoughtSpot refuses to render — the tile shows "Cannot display the chart". +SINGLE_AXIS_CHART_TYPES = frozenset({ + "ADVANCED_COLUMN", "ADVANCED_BAR", + "ADVANCED_STACKED_COLUMN", "ADVANCED_STACKED_BAR", +}) + +# The combo type: bars for the money measure, line for the rate. Supports a +# secondary axis, and is what ThoughtSpot's own Search picks for two measures on +# different scales. +COMBO_CHART_TYPE = "ADVANCED_LINE_COLUMN" + + +def _has_secondary_axis(answer: Dict[str, Any]) -> bool: + """True when this answer's saved axis config declares a secondary Y axis. + + Two measures on different scales (revenue vs. a rate) get an opposite axis. + The flag can sit directly on an axisProperties entry or nested under its + "properties" object depending on who wrote the client state, so this walks + the decoded structure rather than assuming one shape. + """ + state = _load_chart_client_state(answer.get("chart", {}) or {}) + + def walk(node: Any) -> bool: + if isinstance(node, dict): + if node.get("isOpposite") is True: + return True + return any(walk(v) for v in node.values()) + if isinstance(node, list): + return any(walk(v) for v in node) + return False + + return walk(state) + + def _replacement_chart_type(answer: Dict[str, Any]) -> str: + """Pick a chart type for a demoted heatmap that is VALID for the axis config + already attached to the answer. + + The heatmap cap overwrites chart["type"] but leaves client_state_v2 alone. + When that state declares a secondary axis, every single-axis type is an + invalid spec — this is what blanked ~9 tiles across 4 customer boards + (dev_notes/fix_handoff_2026_09_01.md TASK 1). Larry's board proves it: + Viz_6 and Viz_12 are the same question with identical axisProperties; Viz_6 + was overwritten to ADVANCED_COLUMN and went blank, Viz_12 kept + ADVANCED_LINE_COLUMN and renders. + + Returning the combo type keeps the dual-scale intent. Stripping the + secondary axis instead would "work" but puts a $100M bar next to a 0.5 bar. + """ + if _has_secondary_axis(answer): + return COMBO_CHART_TYPE + name = answer.get("name", "").lower() query = answer.get("search_query", "").lower() text = f"{name} {query}" @@ -928,6 +1643,10 @@ def _executive_tab_name(plan: Dict[str, Any]) -> str: return plan.get("executive_tab") or EXECUTIVE_TAB +def _showcase_tab_name(plan: Dict[str, Any]) -> str: + return plan.get("showcase_tab") or SHOWCASE_TAB + + def _manager_tab_name(plan: Dict[str, Any]) -> str: return plan.get("manager_tab") or OPERATIONS_TAB @@ -1000,11 +1719,19 @@ def _role_for_viz(viz: Dict[str, Any]) -> str: return "geo" if "TABLE" in chart_type: return "detail" - # A single-metric periodic line is really a KPI (also handled upstream). + # A single-metric periodic question is a KPI regardless of the chart type MCP + # happened to draw it with. Keying off LINE/AREA alone lost the 4th KPI on the + # AdvancedMD board: "Net Collection Rate Quarterly" is one measure over one + # date column, but MCP rendered it as ADVANCED_COLUMN so it was filed as a + # trend and the board shipped 3 KPIs instead of the standard 4. + # + # The two guards that keep real charts out: no " by " (a breakdown), and at + # most one measure (two measures is a comparison chart, not a KPI). if ( - chart_type in {"LINE", "AREA", "ADVANCED_LINE", "ADVANCED_AREA"} + chart_type not in {"TABLE", "PIVOT_TABLE", "NOTE"} and any(period in text for period in ("weekly", "monthly", "quarterly", "daily", "yearly")) and " by " not in text + and len([c for c in _answer_column_names(viz.get("answer", {})) if _is_measure_name(c)]) <= 1 ): return "kpi" # Rankings before breakdowns: "Top 10 X by Y" is a ranking, not a plain breakdown. diff --git a/demoprep_app/liveboards/muze_templates.py b/demoprep_app/liveboards/muze_templates.py new file mode 100644 index 0000000000000000000000000000000000000000..8cb3201ab980ed23afc690b41fb103f5089c52d8 --- /dev/null +++ b/demoprep_app/liveboards/muze_templates.py @@ -0,0 +1,335 @@ +"""Templated ThoughtSpot custom charts (MUZE_STUDIO / BYOC). + +A MUZE_STUDIO visualization carries hand-authored JS + CSS + HTML, base64'd +inside ``answer.chart.custom_visual_props``. The programs are parameterised by a +handful of column-name constants, so they can be retargeted at a new model by +substitution — that is what makes them templatable at all. + +**Why these are not copied verbatim from the reference board.** The four +MUZE_STUDIO charts in ``goldendemo/Manufacturing_IoT`` each violate several +documented BYOC hard rules, every one of which fails *silently* as a blank tile: + +* no ``try``/``catch`` — the BYOC sandbox replaces the real error with + "Something went wrong", so there is nothing to debug; +* ``emitRenderCompletedEvent()`` on the success path only — a tile that throws + never reports in, which hangs Liveboard PDF export for the **whole board**; +* an unbounded CDN ``" in js or "" in spec["html"]: + raise ValueError("a literal breaks the host's HTML parser") + + client_state = { + "version": 1, + "playground": { + "version": "v1", + "code": { + "jsCodeBase64": _b64(js), + "cssCodeBase64": _b64(spec["css"]), + "htmlCodeBase64": _b64(spec["html"]), + }, + }, + } + return json.dumps({"clientState": json.dumps(client_state)}) + + +def build_muze_viz( + viz_id: str, + name: str, + archetype: str, + bindings: Dict[str, str], + search_query: str, + sibling_answer: Dict[str, Any], +) -> Dict[str, Any]: + """Build a MUZE_STUDIO visualization bound to the given columns. + + The caller owns the search query, and it MUST select every bound column — + a column the chart names and the search omits is silently absent. + """ + columns = [bindings[f] for f in _ARCHETYPES[archetype]["fields"]] + return { + "id": viz_id, + "answer": { + "name": name, + "tables": deepcopy(sibling_answer.get("tables", [])), + "search_query": search_query, + "answer_columns": [{"name": c} for c in columns], + "chart": { + "type": MUZE_CHART_TYPE, + "chart_columns": [{"column_id": c} for c in columns], + "client_state": "", + "custom_visual_props": build_custom_visual_props(archetype, bindings), + }, + "display_mode": "CHART_MODE", + }, + } diff --git a/demoprep_app/liveboards/tml_yaml.py b/demoprep_app/liveboards/tml_yaml.py new file mode 100644 index 0000000000000000000000000000000000000000..c3b1ae0ca0ca4ff65fa2aa0ebd405b627d57625b --- /dev/null +++ b/demoprep_app/liveboards/tml_yaml.py @@ -0,0 +1,41 @@ +"""Safe YAML loading for ThoughtSpot TML. + +TML is YAML, but ThoughtSpot emits constructs that `yaml.safe_load` refuses: + +* Date filters contain a bare ``oper: =``. In YAML 1.1 a lone ``=`` is the + reserved *value* tag (``tag:yaml.org,2002:value``), and SafeLoader has no + constructor for it, so the whole document raises ConstructorError. Any + liveboard carrying a ``LAST_N_PERIOD`` filter — which both golden reference + boards do — is unparseable without this. + +Use :func:`load_tml` anywhere TML is read back from ThoughtSpot. +""" + +from __future__ import annotations + +from typing import Any, Dict + +import yaml + + +class TMLLoader(yaml.SafeLoader): + """SafeLoader that tolerates ThoughtSpot's TML quirks.""" + + +def _construct_value(loader: yaml.Loader, node: yaml.Node) -> str: + """Return the reserved YAML ``=`` tag as the plain string it is in TML.""" + return loader.construct_scalar(node) + + +TMLLoader.add_constructor("tag:yaml.org,2002:value", _construct_value) + + +def load_tml(text: str | bytes) -> Dict[str, Any]: + """Parse a TML document. Returns {} for empty input. + + Raises yaml.YAMLError for genuinely malformed TML — callers should not + swallow that, since it means ThoughtSpot returned something unexpected. + """ + if not text: + return {} + return yaml.load(text, Loader=TMLLoader) or {} diff --git a/demoprep_app/pipeline/build_demo.py b/demoprep_app/pipeline/build_demo.py index f2f4b2c5206accd5cb9ccad62462e4d009f95b36..2b6b1e9236994eb62cf72844c9e547af328f18c0 100644 --- a/demoprep_app/pipeline/build_demo.py +++ b/demoprep_app/pipeline/build_demo.py @@ -30,6 +30,7 @@ from dataclasses import dataclass, field from demoprep_app.dataset.contracts import DatasetBundle from demoprep_app.dataset.engine import BlueprintEngine +from demoprep_app.dataset.formula import FormulaEvaluationError from demoprep_app.dataset.validator import ValidationReport, validate_bundle from demoprep_app.ddl import DatasetDdlCompiler from demoprep_app.scenario.blueprint import DemoBlueprint @@ -41,6 +42,10 @@ from demoprep_app.scenario.contract import ScenarioContract MAX_TUNING_ROUNDS = 3 # When an insight is too weak, multiply its distance-from-1.0 by this. BOOST_FACTOR = 1.6 +# How many times we repair a formula the engine refused to compute and try +# again. Formula validation at blueprint time should make this dead code; it +# exists so an unforeseen formula failure costs one measure, not the demo. +MAX_FORMULA_REPAIR_ATTEMPTS = 3 @dataclass(slots=True) @@ -133,14 +138,35 @@ def build_demo( dataset: DatasetBundle | None = None for round_num in range(1, MAX_TUNING_ROUNDS + 1): progress(f"Generating dataset (round {round_num})...") - dataset = engine.generate(blueprint, row_count_guidance) + dataset = _generate_with_formula_repair( + engine, blueprint, row_count_guidance, warnings, progress + ) report = validate_bundle(blueprint, dataset) progress(report.summary()) if report.passed: break - if report.integrity_errors or report.formula_errors: - # Engine bugs, not tuning issues — do not loop on these. + if report.integrity_errors: + # Dangling keys / column mismatches are engine bugs and the + # Snowflake load would fail on them anyway — fail here, clearly. raise RuntimeError("Generated dataset failed integrity checks:\n" + report.summary()) + if report.formula_errors: + # A stored derived value disagrees with its formula. Repair the + # formula and regenerate; if there is nothing left to repair, SHIP + # with a loud warning instead of aborting — the post-load quality + # gate recomputes or omits the column, and a board with one suspect + # tile beats no board at all (BLOCKING_ISSUES_2026_08_26, ISSUE C). + repaired = blueprint.repair_formulas() + if repaired: + for fix in repaired: + progress(f"formula repair: {fix}") + warnings.extend(f"formula repair: {fix}" for fix in repaired) + continue + warnings.append( + "Stored derived values deviate from their formulas; shipping anyway so the " + "demo exists. The Snowflake quality gate will recompute or omit them:\n" + + report.summary() + ) + break _boost_weak_insights(blueprint, report) if report is not None and not report.passed: @@ -165,6 +191,43 @@ def build_demo( ) +def _generate_with_formula_repair( + engine: BlueprintEngine, + blueprint: DemoBlueprint, + row_count_guidance: int | None, + warnings: list[str], + progress, +) -> DatasetBundle: + """Generate rows, repairing (never swallowing) a formula the engine rejects. + + The engine now RAISES on an uncomputable formula instead of writing 0.0 to + every row. That correctness fix must not become a new way to produce + nothing, so each failure costs the offending measure and the run continues. + """ + last_error: FormulaEvaluationError | None = None + for _ in range(MAX_FORMULA_REPAIR_ATTEMPTS): + try: + dataset = engine.generate(blueprint, row_count_guidance) + except FormulaEvaluationError as exc: + last_error = exc + progress(f"Formula rejected during generation: {exc}") + fixes = blueprint.repair_formulas() + if not fixes and exc.measure: + fixes = blueprint.drop_measure(exc.measure, reason=exc.reason) + if not fixes: + raise + for fix in fixes: + progress(f"formula repair: {fix}") + warnings.extend(f"formula repair: {fix}" for fix in fixes) + continue + warnings.extend(engine.formula_warnings()) + return dataset + raise RuntimeError( + "Dataset generation kept failing on measure formulas after " + f"{MAX_FORMULA_REPAIR_ATTEMPTS} repair attempts. Last error: {last_error}" + ) + + def _boost_weak_insights(blueprint: DemoBlueprint, report: ValidationReport) -> None: """Contract-level cleanup: strengthen only what failed, deterministically.""" failed = {c.insight_id for c in report.failed_insights()} diff --git a/demoprep_app/portal/README.md b/demoprep_app/portal/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ac2296b307bc8da3f1be4ee9a9d0a14c2a6d98cb --- /dev/null +++ b/demoprep_app/portal/README.md @@ -0,0 +1,55 @@ +# Portal builder assets (`build_portal` groundwork) + +Everything needed to turn a finished DemoPrep run into a branded static portal +site. The `build_portal(run_id)` MCP tool is **not implemented yet** — this +directory holds the verified building blocks it will use. + +Design doc: `docs/agentspot_portal_handoff_2026_08_24.md` +Verification results: `dev_notes/portal_template_spike.md` + +## Provenance — do not edit `template-tse/` by hand + +`template-tse/`, `scripts/apply-spec.mjs`, and `references/` are **vendored** from +the `rebrand-thoughtspot-portal` skill in ThoughtSpot's public `tse_demos` repo: + + repo: https://github.com/thoughtspot/tse_demos + path: .claude/skills/rebrand-thoughtspot-portal/ + commit: 0c68520f51b15dcd12088e638273b914af2c7077 (2026-08-25) + +The template is Mani's team's and evolves independently. Upgrade deliberately by +re-vendoring at a new pinned commit and re-testing — never by patching in place. + +`node_modules/` and `dist/` are intentionally excluded (121 MB of deps; the +codemod runs `npm install` itself on first use). + +## How it works + + interview -> spec.json -> deterministic codemod -> npm run build -> static host + +DemoPrep skips the interview: it already knows the host, liveboard GUID, model +GUID, columns, company and palette because it created the run, so it authors +`spec.json` directly. Spec contract: `references/spec-schema.md`. + +## Build a portal from a spec (verified working) + + node demoprep_app/portal/scripts/apply-spec.mjs demoprep_app/portal/specs/triumph.json + (cd -tse && npm run build) # output in dist/ + +`specs/triumph.json` and `specs/vantage.json` are real worked examples (live +GUIDs on sebe). Asset paths in a spec resolve relative to the current directory — +see `assets/` for the placeholder logos those two specs reference. + +## Known gotchas (hit during verification — keep these fixed in any generator) + +1. `client` must be a single alphanumeric token (it becomes component/file names). + `"Vantage Capital"` produces `AskVantage Capital.tsx` and ~40 tsc errors. Pass + CamelCase (`VantageCapital`); the display name belongs in `content.company`. +2. The codemod's tab pruning leaves dangling imports: with `inline:false` / + `action:false` it deletes `Carriers.tsx`/`Capacity.tsx` but not their `import` + lines in `src/App.tsx`, nor the now-unused `Truck`/`Activity` icons in + `src/components/TopBar.tsx`. `tsc` fails on both. Strip them post-codemod. +3. `spec.action` and every `spec.columns.*` field are read unconditionally, even + when those features are off — always populate them. +4. Hosting a portal needs the **exact** hostname in the cluster's CORS whitelist; + subdomain wildcards (`*.static.hf.space`) are rejected. `build_portal` must + return that instruction — the config API is 403 for non-admins. diff --git a/demoprep_app/portal/assets/triumph-logo.svg b/demoprep_app/portal/assets/triumph-logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..b3f06519aa8c7bbaa07be5e2c2757025aa49502f --- /dev/null +++ b/demoprep_app/portal/assets/triumph-logo.svg @@ -0,0 +1 @@ +TRIUMPH FINANCIAL diff --git a/demoprep_app/portal/assets/vantage-logo.svg b/demoprep_app/portal/assets/vantage-logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..e631e43e62ab02dcc66f97e43c0a4e0649f60bf8 --- /dev/null +++ b/demoprep_app/portal/assets/vantage-logo.svg @@ -0,0 +1 @@ +VANTAGE CAPITAL diff --git a/demoprep_app/portal/builder.py b/demoprep_app/portal/builder.py new file mode 100644 index 0000000000000000000000000000000000000000..bc1e9cc740f6e50ea943151ffae5c9f67b8953c3 --- /dev/null +++ b/demoprep_app/portal/builder.py @@ -0,0 +1,565 @@ +"""Build a branded ThoughtSpot portal from a deployed liveboard. + +The importable core behind the `build_portal` MCP tool. No CLI, no argparse: +callers pass values and get a dict back, or a PortalBuildError with a specific +message. `scripts/build_portal.py` is a thin CLI over this for local testing. + +Chain: liveboard GUID -> model TML -> spec.json -> codemod -> npm run build + -> static HF Space -> URL (+ the CORS entry the cluster requires) + +Two Hugging Face Space constraints shape this module: + +1. **Everything is written to a writable temp dir, never the repo.** HF Spaces + run the container as a non-root user inside a root-owned WORKDIR, so writing + build output next to the code raises PermissionError at runtime — the exact + shape of the 2026-08-25 MCP outage. +2. **The template's node_modules must already exist in the image.** The codemod + installs them itself when missing, which would be a write into that same + read-only tree. The Dockerfile pre-installs them as root at build time. +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent.parent +PORTAL = REPO / "demoprep_app" / "portal" +CODEMOD = PORTAL / "scripts" / "apply-spec.mjs" + + +class PortalBuildError(Exception): + """Any failure that should be reported to the caller verbatim.""" + + +# TS environment label -> (base url, .env key var). Mirrors the app's env table. +TS_ENVS = { + "secloud": ("https://se-thoughtspot-cloud.thoughtspot.cloud", "TS_ENV_1_KEY_VAR"), + "sebe": ("https://sebe.thoughtspotstaging.cloud", "TS_ENV_2_KEY_VAR"), + "sebe-demo": ("https://sebe.thoughtspotstaging.cloud", "TS_ENV_3_KEY_VAR"), +} + +# Measures whose name matches these read as money in the portal UI. +CURRENCY_HINTS = ("usd", "revenue", "amount", "cost", "pay", "price", "margin usd", + "balance", "savings", "spend") +# Attributes matching these make good grouping/segment filters. +SEGMENT_HINTS = ("tier", "segment", "category", "type", "grade", "status", "group", "class") + + +# ---------------------------------------------------------------- ThoughtSpot + +def ts_client(env: str, as_user: str = ""): + """Authenticated deployer. Fails loudly if the env key is missing. + + `as_user` selects the ThoughtSpot identity to act as via trusted auth. This + matters because TML export is permission-scoped: a model built by another SE + is FORBIDDEN to everyone else, so a portal over their liveboard has to be + built as them. + """ + from thoughtspot_deployer import ThoughtSpotDeployer + + if env not in TS_ENVS: + raise PortalBuildError(f"unknown ts_env '{env}'. choose from: {', '.join(TS_ENVS)}") + base, key_var = TS_ENVS[env] + secret = os.getenv(key_var) + if not secret: + raise PortalBuildError(f"{key_var} is not set in .env — cannot authenticate to {base}") + user = as_user or os.getenv("TEST_USER") or os.getenv("USER_EMAIL") + if not user: + raise PortalBuildError("no authenticated user available (set TEST_USER in .env)") + d = ThoughtSpotDeployer(base_url=base, username=user, secret_key=secret) + d.authenticate_trusted() + return d, base + + +def export_tml(client, base: str, guid: str, associated: bool = False) -> list[dict]: + r = client.session.post( + f"{base}/api/rest/2.0/metadata/tml/export", + json={"metadata": [{"identifier": guid}], "export_associated": associated, + "export_fqn": True}, + ) + if not r.ok: + raise PortalBuildError(f"TML export failed for {guid}: HTTP {r.status} {r.text[:200]}" + if hasattr(r, "status") else + f"TML export failed for {guid}: HTTP {r.status_code} {r.text[:200]}") + return r.json() + + +def resolve_model(client, base: str, liveboard_guid: str) -> str: + """Pull the model GUID out of a liveboard's first visualization.""" + import yaml + + docs = export_tml(client, base, liveboard_guid) + info = docs[0].get("info", {}) + if info.get("type") != "liveboard": + raise PortalBuildError(f"{liveboard_guid} is a '{info.get('type')}', not a liveboard — " + "a viz GUID in a liveboard slot is the #1 cause of a blank embed") + lb = yaml.safe_load(docs[0]["edoc"])["liveboard"] + for viz in lb.get("visualizations", []): + for tbl in (viz.get("answer", {}) or {}).get("tables", []) or []: + if tbl.get("fqn"): + return tbl["fqn"] + raise PortalBuildError("could not find a model GUID in that liveboard's visualizations") + + +def model_columns(client, base: str, model_guid: str): + """Return (attributes, measures, date_columns, col_table). + + `col_table` maps display name -> source table, which is what lets the date + column be matched to the fact its metrics actually live on. + + Date columns need the associated TABLE TMLs: a model TML export strips the + `calendar` property, so dates are only identifiable via the underlying + column data types. + """ + import yaml + + docs = export_tml(client, base, model_guid, associated=True) + readable = [d for d in docs if d.get("edoc")] + if not readable: + detail = "" + for d in docs: + msg = ((d.get("info") or {}).get("status") or {}).get("error_message", "") + if msg: + detail = msg.splitlines()[0] + break + owner_hint = ( + " The model is owned by another user and TML export is permission-scoped: " + "build as its owner (as_user=...) or have them share it." + if "FORBIDDEN" in detail.upper() else "" + ) + raise PortalBuildError( + f"cannot read model {model_guid}: {detail or 'export returned no TML'}.{owner_hint}" + ) + model_doc = next((d for d in readable if "model" in yaml.safe_load(d["edoc"])), None) + if not model_doc: + raise PortalBuildError(f"{model_guid} did not export as a model") + model = yaml.safe_load(model_doc["edoc"])["model"] + + attributes, measures, by_id, col_table = [], [], {}, {} + for col in model.get("columns", []) or []: + props = col.get("properties", {}) or {} + if props.get("is_hidden"): + continue + name = col["name"] + cid = col.get("column_id", "") + by_id[cid] = name + col_table[name] = cid.split("::")[0] if "::" in cid else "" + (measures if props.get("column_type") == "MEASURE" else attributes).append(name) + + # physical data types -> which model columns are dates + date_cols: list[str] = [] + for d in readable: + doc = yaml.safe_load(d["edoc"]) + tbl = doc.get("table") + if not tbl: + continue + tname = tbl.get("name", "") + for col in tbl.get("columns", []) or []: + dtype = str((col.get("db_column_properties") or {}).get("data_type", "")).upper() + if dtype in ("DATE", "DATETIME", "TIMESTAMP"): + display = by_id.get(f"{tname}::{col.get('db_column_name', col.get('name'))}") + if display and display not in date_cols: + date_cols.append(display) + # fall back to name-shaped detection only if types gave us nothing + if not date_cols: + date_cols = [a for a in attributes + if re.search(r"\b(date|month|day|week|quarter|year)\b", a, re.I)] + return attributes, measures, date_cols, col_table + + +# ---------------------------------------------------------------- spec build + +def camel_token(name: str) -> str: + """`client` becomes component and FILE names, so it must be one bare token. + + "Vantage Capital" -> "VantageCapital". A space here produces + `AskVantage Capital.tsx` and ~40 tsc errors. + """ + tok = re.sub(r"[^A-Za-z0-9]+", " ", name).title().replace(" ", "") + if not tok: + raise PortalBuildError(f"cannot derive a component token from company name {name!r}") + return tok + + +def pick_filters(attributes: list[str]) -> tuple[list[str], list[str]]: + """Two candidate lists for the portal's filter chips: first that returns data wins.""" + names = [a for a in attributes if a.lower().endswith("name")] + segments = [a for a in attributes if any(h in a.lower() for h in SEGMENT_HINTS)] + primary = (names[:1] + segments[:1]) or attributes[:2] + remaining = [a for a in names[1:] + segments if a not in primary] + secondary = remaining[:2] or [a for a in attributes if a not in primary][:2] + if not primary or not secondary: + raise PortalBuildError("model has too few attribute columns to build filters from") + return primary, secondary + + +def metric_spec(measures: list[str]) -> list[dict]: + out = [] + for m in measures[:2]: + fmt = "currency" if any(h in m.lower() for h in CURRENCY_HINTS) else "number" + label = re.sub(r"\s+Usd$", "", m, flags=re.I) + label = re.sub(r"\s+Pct$", " %", label, flags=re.I) + out.append({"label": label, "format": fmt, "candidates": [m]}) + return out + + +def theme(primary: str, accent: str) -> dict: + """Full :root token dictionaries — the codemod replaces the whole block, so + every token the template reads must be present in both light and dark.""" + def rgb(hex_): + h = hex_.lstrip("#") + return ", ".join(str(int(h[i:i + 2], 16)) for i in (0, 2, 4)) + + font = "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif" + light = { + "--sl-cream": "#f7f8fa", "--sl-cream-2": "#eceef2", "--sl-white": "#ffffff", + "--sl-evergreen": primary, "--sl-evergreen-2": primary, + "--sl-green": primary, "--sl-green-dark": accent, + "--sl-primary-rgb": rgb(primary), + "--intime-blue": accent, "--sl-gold": "#e8b53c", "--sl-gold-ink": "#5f4708", + "--intime-grad": f"linear-gradient(90deg, {primary} 0%, {accent} 100%)", + "--sl-mint": "#e8f1fb", "--sl-mint-2": "#d5e6f7", "--sl-coral": "#a2191f", + "--sl-warn": "#fffbe6", "--sl-warn-ink": "#5f4708", + "--sl-ink": "#111827", "--sl-muted": "#5a6a7e", + "--sl-border": "#e2e6ec", "--sl-border-soft": "#eef1f5", + "--font-serif": font, "--font-sans": font, "--topbar-h": "66px", + "--shadow-card": "0 1px 2px rgba(15, 23, 42, 0.05), 0 8px 24px rgba(15, 23, 42, 0.07)", + "--shadow-lift": "0 6px 14px rgba(15, 23, 42, 0.08), 0 18px 40px rgba(15, 23, 42, 0.12)", + "--brand-navy": primary, "--brand-accent": accent, + } + dark = dict(light) + dark.update({ + "--sl-cream": "#0b1622", "--sl-cream-2": "#101f2f", "--sl-white": "#14293d", + "--sl-evergreen": "#eef4fa", "--sl-evergreen-2": "#dbe6f0", + "--sl-green": accent, "--sl-green-dark": accent, + "--sl-primary-rgb": rgb(accent), + "--sl-mint": "#102a40", "--sl-mint-2": "#16354e", "--sl-coral": "#f0616a", + "--sl-warn": "#2a2413", "--sl-warn-ink": "#e8b53c", + "--sl-ink": "#eaf1f7", "--sl-muted": "#9fb2c6", + "--sl-border": "#23415a", "--sl-border-soft": "#172f44", + "--shadow-card": "0 1px 2px rgba(0, 0, 0, 0.5), 0 8px 24px rgba(0, 0, 0, 0.55)", + "--shadow-lift": "0 6px 14px rgba(0, 0, 0, 0.6), 0 18px 40px rgba(0, 0, 0, 0.65)", + }) + return {"default": "light", "light": light, "dark": dark} + + +def swaps(primary: str, accent: str) -> dict: + """Fixed left-hand template constants; only the right-hand side is brand.""" + return { + "embedSwaps": [["#4F5BD5", accent], ["#3F49B8", accent], ["#333C99", primary], + ["#22B8CF", accent], ["#0b0d10", "#0b1622"], ["#14181f", "#14293d"], + ["#1a212b", "#16354e"], ["#232a34", "#23415a"], ["#0b1f19", "#101f2f"], + ["rgba(45,164,191,0.14)", "rgba(122,180,232,0.16)"]], + "greenSwaps": [["#0f9a63", primary], ["#0c8554", primary], ["#d9ecea", "#d5e6f7"], + ["#cfe3e2", "#c2ddf4"], ["#1bb978", accent], ["#15ae6e", primary]], + "cssSwaps": [["#0e1116", "#101f2f"], ["#10222a", "#101f2f"], ["#123047", "#14293d"]], + } + + +def content(company: str, use_case: str, ai_name: str, website: str, + primary_label: str, secondary_label: str, samples: list[str]) -> dict: + return { + "company": company, + "aiName": ai_name, + "website": website, + "loginTitleHtml": f"{use_case},
in one place.", + "loginSubtitle": (f"Your live view of {use_case.lower()} — with AI-powered answers " + f"on real data. Built on {company}, powered by ThoughtSpot."), + "loginStats": [{"value": "Live", "label": "Operational Data"}, + {"value": "AI", "label": "Natural-Language Answers"}, + {"value": "Real-time", "label": "Decision Support"}], + "loginCardSubtitle": "Continue with your ThoughtSpot session", + "loginDemoNoteHtml": ("Continue using your existing ThoughtSpot " + "session on {host}. This origin must be " + "CORS-allowlisted on that cluster for data to load."), + "platformNav": ["Overview", "Analytics", "Insights"], + "tabs": {"myReports": "My Reports", "analytics": "Analytics", "inline": "Detail", + "action": "Actions", "ask": f"Ask {ai_name}", "spotter": "Spotter"}, + "analyticsSubtitle": f"Your live {use_case.lower()} dashboard", + "spotterSubtitle": f"Ask questions of your {company} data in natural language.", + "inline": {"subtitleReady": "Select a row to view its insights", + "subtitleIdle": "Key metrics across your portfolio", + "loading": "Loading…", "errorTitle": "Couldn't load data", + "errorMsg": "Unable to load.", "moreLabel": "View insights", + "hideLabel": "Hide insights"}, + "filters": {"primaryLabel": primary_label, "secondaryLabel": secondary_label}, + "sampleQuestions": samples, + "askWelcome": (f"Ask me anything about {company} — or ask about your own data " + "and I will chart it live."), + "askEmptySub": ("Ask any analytical question. Answers come straight from your live " + "data with an interactive chart you can drill into."), + "action": {"label": "Take Action", "subtitlePrefix": "Right-click a row and choose", + "subtitleSuffix": "to act on it.", "modalTitle": "New Request", + "modalLead": "Pre-filled for the selected row.", + "submitLabel": "Submit", "successTitle": "Request submitted"}, + "chatbot": {"welcome": f"Hi! I'm {ai_name}. Ask me about your data.", + "overview": f"{company}: {use_case}.", + "greetingExample": samples[0] if samples else "show me the trend", + "examples": samples[1:3]}, + "trialQuestionTrigger": 3, + "upgradeReason": f"Upgrade to unlock drill-down, Ask AI and downloads.", + "analyticsKeywords": sorted({w.lower() for s in samples for w in re.findall(r"[a-z]{4,}", s.lower())}), + } + + +def pick_date(dates: list[str], measures: list[str], col_table: dict[str, str]) -> str: + """Choose a date column on the SAME fact table as the metrics. + + A date from a different fact is the cross-fact chasm bug: the filter appears + to work but slices a table the measures do not live on, so tiles come back + empty or silently unfiltered. Never fall back to "any date column". + """ + if not dates: + raise PortalBuildError("no date column found on the model — the portal's date filter needs one") + fact = col_table.get(measures[0], "") + same_fact = [d for d in dates if col_table.get(d, "") == fact] + if not same_fact: + raise PortalBuildError( + f"no date column on '{fact}', the fact holding the chosen metrics " + f"({measures[0]}). Dates found: " + + ", ".join(f"{d} on {col_table.get(d, '?')}" for d in dates) + + ". Pick metrics from a dated fact, or add a date to that fact — " + "using another fact's date silently filters the wrong table." + ) + return same_fact[0] + + +def build_spec(*, company, use_case, host, liveboard, model, attributes, measures, + dates, primary, accent, logo_rel, website, col_table) -> dict: + fp, fs = pick_filters(attributes) + if len(measures) < 2: + raise PortalBuildError(f"model has only {len(measures)} measure(s); need at least 2 for the metrics") + date_col = pick_date(dates, measures, col_table) + segments = [a for a in attributes if any(h in a.lower() for h in SEGMENT_HINTS)] + ai_name = f"{company.split()[0]} AI" + samples = [f"{measures[0]} by {fp[0]}", f"{measures[1]} by {fs[0]}", + f"{measures[0]} over time", f"{measures[1]} by {segments[0] if segments else fp[0]}"] + return { + "client": camel_token(company), + "slug": re.sub(r"[^a-z0-9]+", "-", company.lower()).strip("-"), + "host": host, + "auth": "none", + "ids": {"analyticsLiveboard": liveboard, "model": model, + "inlineLiveboard": liveboard, "actionLiveboard": liveboard, + "actionViz": liveboard}, + "action": {"id": "take-action", "name": "Take Action"}, + "columns": { + "inlineName": fp, + "inlineMetrics": metric_spec(measures), + "filterPrimary": fp, + "filterSecondary": fs, + "date": date_col, + "hierarchy": {"segment": segments[0] if segments else fp[0], + "rep": fp[0], "cadence": fs[0]}, + }, + "logo": {"svgPath": logo_rel}, + "favicon": ('' + f''), + "theme": theme(primary, accent), + **swaps(primary, accent), + "features": {"askMode": "both", "inline": False, "action": False, + "monetize": False, "tiers": False, "pinning": False}, + "content": content(company, use_case, ai_name, website, fp[0], fs[0], samples), + } + + +# ---------------------------------------------------------------- build steps + +def wordmark(company: str, primary: str, path: Path) -> None: + label = company.upper()[:26] + width = 40 + len(label) * 11 + path.write_text( + f'{label}\n' + ) + + +def _tool(name: str) -> str: + """Absolute path to node/npm. Containers often lack them on the default PATH.""" + found = shutil.which(name) or shutil.which(name, path="/usr/bin:/usr/local/bin:" + f"{os.path.expanduser('~/.node/bin')}") + if not found: + raise PortalBuildError( + f"'{name}' is not available. The portal build needs Node to run the codemod " + "and vite. In the MCP Space this comes from the Dockerfile." + ) + return found + + +def run(cmd: list[str], cwd: Path) -> None: + p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if p.returncode != 0: + raise PortalBuildError( + f"command failed: {' '.join(cmd)}\n{p.stdout[-1500:]}\n{p.stderr[-1500:]}") + + +def patch_dangling_imports(app_dir: Path) -> None: + """The codemod's tab pruning deletes tab FILES but leaves their imports. + + With inline/action off it removes Carriers.tsx / Capacity.tsx plus their JSX + and nav entries, but not the `import` lines in App.tsx nor the now-unused + lucide icons in TopBar.tsx — and `tsc --noUnusedLocals` fails on both. + """ + app = app_dir / "src" / "App.tsx" + if app.exists(): + kept = [ln for ln in app.read_text().splitlines(keepends=True) + if not re.match(r"^import (Carriers|Capacity) from", ln)] + app.write_text("".join(kept)) + top = app_dir / "src" / "components" / "TopBar.tsx" + if top.exists(): + kept = [ln for ln in top.read_text().splitlines(keepends=True) + if not re.match(r"^\s*(Truck|Activity),\s*$", ln)] + top.write_text("".join(kept)) + + +def deploy_space(dist: Path, owner: str, space: str) -> str: + from huggingface_hub import HfApi + + token = os.getenv("HF_TOKEN") + if not token: + raise PortalBuildError("HF_TOKEN is not set in .env — cannot deploy the Space") + api = HfApi(token=token) + repo_id = f"{owner}/{space}" + api.create_repo(repo_id=repo_id, repo_type="space", space_sdk="static", + private=False, exist_ok=True) + api.upload_folder(folder_path=str(dist), repo_id=repo_id, repo_type="space", + commit_message="portal build") + return f"https://{owner.replace('.', '-')}-{space}.static.hf.space".replace("_", "-") + + + +# ---------------------------------------------------------------- public API + +def build_portal( + *, + company_name: str, + liveboard_guid: str, + model_guid: str = "", + use_case: str = "Analytics", + ts_env: str = "sebe", + primary: str = "#12283f", + accent: str = "#2f6fb0", + website: str = "", + logo_path: str = "", + space_name: str = "", + hf_owner: str = "thoughtspot-demoprep", + work_dir: str = "", + as_user: str = "", + deploy: bool = True, + on_progress=None, +) -> dict: + """Build (and optionally deploy) a portal for one deployed liveboard. + + Returns a dict with url/spec/paths. Raises PortalBuildError with a specific + message on any failure — no silent fallbacks. + + work_dir defaults to a fresh temp directory, because the repo tree is not + writable in the MCP Space (see module docstring). + """ + def say(msg: str) -> None: + if on_progress: + on_progress(msg) + + if not (company_name or "").strip(): + raise PortalBuildError("company_name is required") + if not (liveboard_guid or "").strip(): + raise PortalBuildError("liveboard_guid is required") + for label, hexval in (("primary", primary), ("accent", accent)): + if not re.fullmatch(r"#[0-9A-Fa-f]{6}", hexval or ""): + raise PortalBuildError(f"{label} colour must be a 6-digit hex, got {hexval!r}") + if not CODEMOD.exists(): + raise PortalBuildError(f"codemod missing at {CODEMOD}") + + work = Path(work_dir) if work_dir else Path(tempfile.mkdtemp(prefix="portal-")) + work.mkdir(parents=True, exist_ok=True) + + say(f"authenticating to {ts_env}" + (f" as {as_user}" if as_user else "")) + client, base = ts_client(ts_env, as_user) + + guid = model_guid or resolve_model(client, base, liveboard_guid) + if not model_guid: + say(f"model derived from liveboard: {guid}") + + say("reading model columns") + attributes, measures, dates, col_table = model_columns(client, base, guid) + say(f"{len(attributes)} attributes, {len(measures)} measures, {len(dates)} date column(s)") + + logo_name = "brand-logo.svg" + if logo_path: + shutil.copy(logo_path, work / logo_name) + else: + wordmark(company_name, primary, work / logo_name) + + host = base.replace("https://", "").replace("http://", "") + spec = build_spec( + company=company_name, use_case=use_case, host=host, liveboard=liveboard_guid, + model=guid, attributes=attributes, measures=measures, dates=dates, + primary=primary, accent=accent, logo_rel=logo_name, col_table=col_table, + website=website or f"https://{host}", + ) + (work / "spec.json").write_text(json.dumps(spec, indent=2)) + say(f"spec: filters {spec['columns']['filterPrimary'][0]} / " + f"{spec['columns']['filterSecondary'][0]}, date {spec['columns']['date']}") + + say("running codemod") + run([_tool("node"), str(CODEMOD), "spec.json"], cwd=work) + app_dir = work / f"{spec['slug']}-tse" + if not app_dir.is_dir(): + raise PortalBuildError(f"codemod did not produce {app_dir}") + + patch_dangling_imports(app_dir) + + say("building (vite)") + run([_tool("npm"), "run", "build"], cwd=app_dir) + dist = app_dir / "dist" + if not (dist / "index.html").exists(): + raise PortalBuildError(f"build produced no dist/index.html in {app_dir}") + + out = { + "status": "built", + "spec": spec, + "work_dir": str(work), + "app_dir": str(app_dir), + "dist": str(dist), + "liveboard_guid": liveboard_guid, + "model_guid": guid, + "resolved_columns": { + "filter_primary": spec["columns"]["filterPrimary"][0], + "filter_secondary": spec["columns"]["filterSecondary"][0], + "date": spec["columns"]["date"], + "metrics": [m["candidates"][0] for m in spec["columns"]["inlineMetrics"]], + }, + } + + if not deploy or not space_name: + out["message"] = "Built locally; not deployed (no space_name)." + return out + + say(f"deploying to {hf_owner}/{space_name}") + url = deploy_space(dist, hf_owner, space_name) + hostname = url.replace("https://", "") + out.update({ + "status": "success", + "url": url, + "space": f"{hf_owner}/{space_name}", + "cors_setup_required": { + "cluster": host, + "add_exact_host": hostname, + "where": "Develop -> Customizations -> Security Settings -> " + "CORS whitelisted domains", + "note": "Wildcards such as *.static.hf.space are rejected by validation. " + "Without this entry the portal renders but its filters and " + "metrics cannot load data.", + }, + "auth_note": "auth=none — viewers need an active ThoughtSpot session on that cluster.", + }) + return out diff --git a/demoprep_app/portal/references/SKILL.md b/demoprep_app/portal/references/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..773670e1464a0b7215ec566a35fa10c24be2fa61 --- /dev/null +++ b/demoprep_app/portal/references/SKILL.md @@ -0,0 +1,186 @@ +--- +name: rebrand-thoughtspot-portal +description: Guided builder that turns the reusable Vite + React + @thoughtspot/visual-embed-sdk portal template into a fully-skinned client app — brand, theme, data, tabs, custom actions, monetization and user tiers — from a plain-language interview. Use when someone wants to build / skin / white-label / clone a ThoughtSpot embedded analytics portal for a company (e.g. "build the portal for Acme", "skin the app for ", "make a ThoughtSpot demo app for X"). Walks a branching question tree (no coding required from the user), then clones the template and wires everything up, building and verifying at each step. Not for building a brand-new app architecture from scratch, or non-ThoughtSpot apps. +--- + +# Build a client ThoughtSpot portal (guided) + +You are running a **non-technical intake**: the user answers plain-language +questions, you do all the code. Flow: **interview → build → verify → (ask before) deploy.** +Never make the user touch code or GUIDs beyond pasting the values you ask for. + +## The base template + +The base app is **bundled inside this skill** at `template-tse/` (next to this +`SKILL.md`) — a brand-neutral ("Northwind") build that already contains every +feature toggleable: Analytics liveboard, inline-insights list tab, custom-action +viz tab, standalone Spotter tab, "fancy" Ask-AI chat, monetization paywall, +Premium/Basic tiers, host filters, light+dark theme. You keep what the answers ask +for and strip the rest. + +**First-time setup: none needed.** The bundled template's deps are installed +**automatically** by the codemod on the first build (it detects a missing +`node_modules` and runs `npm install` once, looking in `~/.node/bin` if npm isn't on +PATH). The codemod resolves the template relative to itself, so the skill works from +any project directory. Each generated `-tse/` app symlinks its `node_modules` to +the bundled template's, so nothing to install per app. + +The codemod copies the template to `-tse/` (kebab-case) in the current project, +excluding `node_modules dist .vercel .env.local`. + +## Step 0 — Show the "gather-first" card (before any question) + +People stall mid-interview when a question asks for a GUID they don't have open. So +**before** asking anything, post this checklist in one plain-language message and let +them collect it all at once. Keep it short — don't explain fields yet, just list what +to have ready: + +> **Before we start, grab these — you'll paste them as we go (takes ~2 min):** +> 1. **Your ThoughtSpot host URL** (e.g. `https://your-co.thoughtspot.cloud`) +> 2. **The main dashboard's Liveboard ID** — open the liveboard; it's the long ID in +> the URL after `/pinboard/` or `/liveboard/`. +> 3. **Your data model / worksheet ID** — the source the dashboard is built on. +> 4. **A brand screenshot** — your website, product UI, or logo. I'll pull the colors +> from it. (A hex color works too if you have no image.) +> 5. *(optional)* Any extra Liveboard/viz IDs if you want more than one tab. +> 6. Your **logo** file (SVG or PNG), or say the word and I'll generate a wordmark. +> +> Building a demo with no real cluster? Just say so — I'll make up realistic values. + +Wait for a "ready" (or their first pastes), then start the interview. Don't block on +it — if they'd rather answer as they go, proceed; the card just prevents the ambush. + +## Step 1 — Run the interview (the question tree) + +Ask with `AskUserQuestion`, **one section per round**, offering sensible defaults +and only expanding a branch when its gate is "yes". Collect answers into the +**spec** (schema at the bottom). Free-text values (GUIDs, column names, a pasted +screenshot) you request in a normal message. + +**Sequencing rule (important):** never fire `AskUserQuestion` in the *same turn* as +a free-text request — the popup blocks the text box, so the user can't paste. When a +section has both, **ask the free-text first, wait for the reply, then** pop the +choice(s). Batch multiple choices of the same section into one popup. If a section is +purely choices, popup directly; if purely free-text, just ask in the message. + +Walk the tree in this order. **Section 0 comes first — it prunes the tree:** + +0. **Basic or Advanced demo?** — *Basic* = a quick demo (Analytics dashboards + + Spotter + Ask-AI + theme). *Advanced* = the full build. **If Basic, skip + sections 3, 4 and 6 entirely** (no inline tab, no custom-action tab, no + Add-Report). Record as `mode`. +1. **Brand** — company name; AI assistant name (default "Ask AI"); + one-line "what they do"; website URL; **ask them to attach the logo** + (SVG preferred, PNG fine) — or offer to generate a wordmark if they have none. +2. **Data & filters** — ThoughtSpot host URL; main liveboard GUID; model/worksheet + GUID; **auth** (None = rides existing session · Basic = typed creds · Trusted = + advanced); and **which columns filter the main liveboard** (multiselect) + an + optional **date column**. +3. **Inline-insights tab?** *(advanced only)* → if yes: inline **liveboard** GUID + (confirm it's a *liveboard*, not a viz/answer), tab name, the list's **name + column**, and up to 3 **metric columns**. +4. **Custom-action workflow?** *(advanced only; optional — ask first, skip entirely + if no.)* Only if yes: **viz** GUID + its **liveboard** GUID, action label, tab name, and + **— required —** a description of the workflow: what screen/modal opens on click, which + row fields pre-fill which inputs, and what "Submit" does. **You build exactly that screen** + (rebuilding the scaffold modal); do NOT fall back to the generic "Request Bid" form. + A screenshot of the target screen is ideal — ask for one. If the user can't describe a + workflow, ask whether a simple labeled form is acceptable or the action should be dropped. +5. **Ask-AI** — Normal Spotter (standalone), Fancy chat, or both. + - **Floating chatbot in the bottom-right corner?** (yes/no) + - **Monetization?** → if yes: which question number triggers the paywall. + - **Feature gating (tiers)?** → if yes: which actions Basic loses (drill-down, + Ask AI, downloads, …). +6. **Pinning / "Add Report"?** *(advanced only)* — the Analytics "Add Report" + split-panel that lets a user build an answer (Report Builder / Spotter) and **pin + it to the dashboard**. If yes, **warn**: the Report Builder (`SearchEmbed`) renders + poorly in **dark** theme — suggest defaulting to light, or accepting the caveat. If + no, strip the Add-Report menu, the pin flow, and `PinModal`. +7. **Theme** — **REQUIRED FIRST: explicitly ask the user to UPLOAD a screenshot for + color reference — and WAIT for it before anything else in this section.** Say it in + plain words, e.g. *"Please upload a screenshot for reference (your website, product UI, + a marketing shot, or your logo) and I'll derive the palette from it."* Do NOT jump to a + color-picker or "what's your primary color?" — the screenshot is the reference. A pasted + **URL** is an acceptable substitute; a **named color** is a last resort only when the + user genuinely has no image at all (and even then, ask). Derive the whole palette from + the uploaded image. Then: light / dark / both (+ default); font(s) for titles vs body; + confirm the primary/accent you read from the image; any specific asks (gradients, hues, + "make titles/KPIs pop"). + +There is **no deploy question** — always **build + run locally** by default. Only +deploy to Vercel if the user explicitly asks for it afterward. + +### Completeness gate — do NOT skip, do NOT fabricate + +Before you build, you MUST have an explicit answer for **every section 0–7**, including the +free-text ones. Never invent or silently default a value the user could provide. + +- **Section 2 (Data) is REQUIRED free-text — actually ask for it and wait.** You must + collect the host URL, the Analytics liveboard GUID, the model/worksheet GUID, (if inline + enabled) the inline liveboard GUID, (if action enabled) the action viz + its liveboard + GUID, the filter columns, and the date column. **Do NOT invent GUIDs or a host.** If the + user is building a fictitious/demo app, ask explicitly "want me to make these up?" — get + a yes per-value; don't assume it. +- **Section 7 (Theme) — ask for BOTH the screenshot/URL AND the font** (a Google font by + name, or an attached file). Never pick a font yourself without asking. +- Batching choice-questions into one `AskUserQuestion` popup is fine, but it must **not + drop the free-text asks** that belong to the same section (Data GUIDs, font, columns). + +Then **echo the COMPLETE spec back in plain language** — every field, section by section, +flagging anything the user let you make up — and get an explicit **"go"** before building. +If you catch yourself about to fabricate a value or skip a section, stop and ask instead. + +## Step 2 — Build (spec-driven: interview → spec.json → codemod → hand-finish) + +The interview becomes a **`spec.json`**; a codemod does the mechanical ~90% in under a +second; you hand-finish the few things a script can't. + +1. **Write `spec.json`.** Copy **`references/spec.example.json`** (a complete working + example) and edit every value per the interview. Field-by-field mapping + the swap-array + rules are in **`references/spec-schema.md`**. Derive the `theme.light`/`theme.dark` token + dicts from the brand screenshot/URL; in the `embedSwaps`/`greenSwaps`/`cssSwaps` arrays + change only the **right-hand** (brand) values — the left-hand hexes are fixed template + constants. +2. **Run the codemod** from the project root (where you want the app written): + ```bash + node .claude/skills/rebrand-thoughtspot-portal/scripts/apply-spec.mjs spec.json + ``` + It clones the bundled `template-tse` → `-tse/`, renames the brand, writes `content.ts`, patches + `config.ts` (host/GUIDs/columns/embed theme), switches auth, regenerates the `globals.css` + theme tokens + `@font-face`, drops in logo/favicon/font, and prunes the tabs `features` + turns off. +3. **Hand-finish what the codemod can't** (it prints reminders): + - **Custom-action modal** — if a workflow was described, **MANDATORY**: rebuild the + scaffold modal to match it (build-playbook §5). Never leave the generic Request Bid form. + - Any bespoke UI the user flagged (custom tables, live lookups). + (Tabs and the monetize/tiers/pinning toggles are handled by the codemod — nothing to do.) +4. **Build:** `export PATH="$HOME/.node/bin:$PATH" && (cd -tse && npm run build)`. + Fix any `tsc` errors and rebuild until clean. + +**`references/build-playbook.md`** is the reference for WHAT each field maps to and the +non-obvious rules (host protection, solid-color embed buttons, masterpieces flag, +liveboard-vs-viz, `Action` enum names, candidate-column fallback) — consult it when a +codemod result looks off or when hand-editing. (The playbook's per-file manual steps still +work as a fallback if the codemod can't run.) + +## Step 3 — Verify, run, (ask to) deploy + +- `npm run dev` and give the user the localhost URL. +- Print a **spec recap** + a **"verify on your cluster"** list: exact column names + the candidate-lists resolved, the CSP/CORS **allowlist** step for the domain, and + the auth/session requirement. +- Deploy to Vercel only if the user asked (see the playbook's deploy section). + +## Spec schema + +The interview's answers are written to a **`spec.json`** consumed by the codemod. The full +field-by-field schema, the interview→field mapping, and the fixed-LHS swap rules live in +**`references/spec-schema.md`**; **`references/spec.example.json`** is a complete worked +example to copy and edit. (Deploy is always LOCAL by default — not interviewed; Vercel only +on an explicit later ask.) + +## Rules of engagement +- Confirm ambiguous ThoughtSpot IDs by **type** (liveboard / viz / saved answer) — the + #1 source of blank embeds. +- Prefer **candidate-column lists** over a single guessed name; the app falls back gracefully. +- Keep the user in plain language; you own the terminal, the build, and the fixes. diff --git a/demoprep_app/portal/references/build-playbook.md b/demoprep_app/portal/references/build-playbook.md new file mode 100644 index 0000000000000000000000000000000000000000..ded78cc950fd7fb20a6790adb450d3170b4fcd03 --- /dev/null +++ b/demoprep_app/portal/references/build-playbook.md @@ -0,0 +1,154 @@ +# Build playbook — spec → edits + +Deterministic steps to turn the interview **spec** into a working `-tse/`. +Run `npm run build` (with `export PATH="$HOME/.node/bin:$PATH"`) after each stage. +The template brand token is **`Northwind`** (casings: `Northwind` / `NORTHWIND` / +`northwind`); the placeholder data tokens are `YOUR-CLUSTER.thoughtspot.cloud` and +`REPLACE_*_GUID`. + +**Mode:** `basic` = strip the inline tab, the custom-action tab, and Add-Report +(keep My Reports · Analytics · Ask-AI · Spotter · theme). `advanced` = per the +answers. Deploy is always **local** (`npm run dev`) unless the user later asks for Vercel. + +## 0. Clone (manual fallback — the codemod does this for you) +The template is bundled inside the skill. From the project root: +```bash +TPL=.claude/skills/rebrand-thoughtspot-portal/template-tse +rsync -a --exclude node_modules --exclude dist --exclude .vercel --exclude .env.local \ + "$TPL"/ -tse/ +``` +Build locally by symlinking deps to the bundled template's: +`ln -s "$(cd "$TPL" && pwd)/node_modules" -tse/node_modules` (remove before any commit). + +## 1. Rebrand (Northwind → ) +Replace in all casings, then rename the two brand-named files. **zsh does not +word-split an unquoted `$var`** — always use `find -exec … {} +`, never +`perl … $(find …)`. +```bash +PERL='s/Northwind//g; s/NORTHWIND//g; s/northwind//g;' +find src -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.css' \) -exec perl -0pi -e "$PERL" {} + +perl -0pi -e "$PERL" index.html +perl -0pi -e 's/northwind-thoughtspot-portal/-thoughtspot-portal/g' package.json +mv src/components/NorthwindLogo.tsx src/components/Logo.tsx +mv src/tabs/AskNorthwind.tsx src/tabs/Ask.tsx +``` +Display casing: use title-case for the visible brand and "Ask AI". If the +client brand is stylized ALL-CAPS, do a targeted follow-up replace on visible +strings only (not const names). Re-grep for `northwind` — nothing should remain. + +**Logo:** if the user attached one, write it to `src/assets/-logo.svg` +(make the wordmark `fill="currentColor"` so it flips with the theme; keep one +accent color fixed) and a 32×32 `public/-icon.svg` favicon. Otherwise keep +the generated wordmark. The import paths were already rewritten by the rename pass. + +## 2. Data (`src/config.ts`) +Replace the placeholders with the spec: +- `THOUGHTSPOT_HOST` ← host +- `ANALYTICS_LIVEBOARD_ID` ← main liveboard GUID +- `CADENCE_WORKSHEET_ID` **and** `WORKSHEET_ID` ← model/worksheet GUID +- `INLINE_INSIGHTS_LIVEBOARD_ID` ← inline liveboard GUID (only if inline enabled) +- `CAPACITY_LIVEBOARD_ID` / `CAPACITY_VIZ_ID` ← the action viz's liveboard + viz (only if action enabled) + +**Confirm each ID's type.** A whole-liveboard GUID passed as `vizId` renders the +liveboard-empty "Nothing to see yet" state. Inline tab = `liveboardId` only (no +vizId), filtered by the name column. Action tab = `liveboardId` + `vizId`. + +**Auth** (`src/lib/thoughtspot.ts` `init`): `none` → `AuthType.None` (rides the +existing browser session; the login screen is a demo gate — keep the note telling +users to be signed in to the cluster in another tab). `basic` → `AuthType.Basic` +with the login form's username/password. `trusted` → wire a token endpoint (advanced). + +## 3. Columns (candidate lists in `config.ts`) +Every column the app reads is a **candidate list** (first that returns data wins; +graceful fallback). Set the user's real names as the first entry, keep sensible fallbacks: +- `CADENCE_COLUMN_CANDIDATES` ← inline list's **name column** (e.g. carrier/process name) +- `CADENCE_DETAIL_COLUMNS` ← the inline **metric columns** (label + format `number|text|currency|date` + candidates); keys are arbitrary, up to 3 +- `REGION_COLUMN_CANDIDATES` / `CORRIDOR_COLUMN_CANDIDATES` → rename to the spec's filter columns +- `DATE_COLUMN` ← the date filter column +- Org scoping is off by default (`ORG_COLUMN_CANDIDATES = []`); populate only if the client wants a tenant filter. + +## 4. Tabs / nav (`src/components/TopBar.tsx` + `src/App.tsx`) +Template tabs: My Reports · Analytics · **Carriers** (inline) · **Capacity** (action) · +**Ask AI** · **Spotter** (standalone). Keep/rename/remove per spec: +- Left platform nav (`PLATFORM_NAV`) → the client's context words. +- Rename the inline tab (`carriers`→spec tabName) and action tab (`capacity`→spec tabName). +- **Inline disabled** → remove the Carriers tab + route + delete `tabs/Carriers.tsx`. +- **Action disabled** → remove the Capacity tab + route + delete `tabs/Capacity.tsx` + `components/BidModal.tsx`. +- **Ask-AI mode**: `fancy` keeps `Ask` chat; `normal` keeps only the `Spotter` tab; `both` keeps both. +Update `TabId`, the `TABS` array, and the `{tab === …}` routes together; `tsc` catches mismatches. + +## 5. Custom-action workflow (only if enabled) — `tabs/Capacity.tsx` + `components/BidModal.tsx` +- `REQUEST_BID_ACTION_ID/NAME` ← the action label. +- Custom action: `position: CustomActionsPosition.CONTEXTMENU`, `target: CustomActionTarget.VIZ`, scoped via `metadataIds`. +- `onCustomAction` builds context from the clicked row (`buildSignalContext` in `lib/signalContext.ts` reads `contextMenuPoints.clickedPoint`). +- **MANDATORY: rebuild the modal to match the workflow the user described — never ship the + generic "Request Bid" form for a client who described a workflow.** `BidModal.tsx` is a + *scaffold*, not the deliverable. Rename it to the workflow (e.g. `LeaveRequestModal.tsx`), + rebuild its fields to match the description (what opens on click, which row fields pre-fill + which inputs, what "Submit" confirms), and update the action label + tab name. The generic + bid form is only acceptable when the user explicitly said "any simple form is fine" or + declined to describe one. If a `spec.json`/codemod scaffolds the app, this modal is the + one step you STILL implement by hand afterward — the codemod prints a NOTE reminding you. +- Verify at the end: right-click the viz → the action appears with the user's label → it opens + *their* described screen (not the bid form), pre-filled from the clicked row. + +## 6. Ask-AI, chatbot, monetization, tiers +- **Floating chatbot** (`components/ChatBot.tsx`, mounted in `App.tsx`): keep if the user wants the bottom-right bot; otherwise remove the `` mount. +- **Fancy Ask tab** copy: welcome, subtitle, and `_SAMPLE_QUESTIONS` → the client's domain. +- **Monetization**: paywall is `components/TrialModal.tsx`, shown once on the Nth question (trigger in `tabs/Ask.tsx`, `if (nextCount === N)`). Set N from spec. The template's modal is already the professional version. +- **Add Report / pinning** (Analytics tab, `tabs/Analytics.tsx`): the "Add Report" + split-panel = a Report Builder (`SearchEmbed`) + Spotter answer that pins to the + dashboard (`HostEvent.Pin`), via `components/PinModal.tsx`. If **not** wanted, + remove the Add-Report menu button, the `panel` split, the Search/Spotter report + column, the pin flow, and the `PinModal` import/mount. **Caveat to surface if + wanted:** `SearchEmbed` (Report Builder) renders poorly in **dark** theme — advise + defaulting to light, or accept it. +- **Tiers**: `context/TierContext.tsx` (Premium/Basic switch in TopBar) + `lib/tierActions.ts` `BASIC_DISABLED_ACTIONS`. Map requested features to `Action` enum members — **verify names in the installed SDK** (`grep "enum Action" node_modules/@thoughtspot/visual-embed-sdk/dist/*.d.ts`): it's `Action.AskAi` (not `AISearch`), `Action.DrillDown`, `Action.Download` / `DownloadAsCsv` / `DownloadAsPdf` / `DownloadAsXlsx`, `Action.SpotIQAnalyze`. Pass `disabledActions` + `disabledActionReason` on each liveboard embed. If tiers not wanted, remove the switcher + the disabledActions props. + +## 7. Theme (`src/styles/globals.css` tokens + `config.ts` embed vars) +Derive the palette from the **screenshot/URL**. Set: +- Host tokens on `:root` (light) and `:root[data-theme='dark']`: `--sl-cream`, + `--sl-white`, `--sl-evergreen` (logo/ink), `--sl-green` (**primary**), + `--sl-green-dark`, `--sl-mint`, `--sl-coral` (alert), `--sl-ink`, `--sl-muted`, borders. +- `---grad` gradient for titles; `.page-title` already clips it. +- Fonts: swap the Google-Fonts `` in `index.html` and `--font-serif`/`--font-sans` + + `TS_FONT_URL`. Titles vs body weights per spec. +- **Theme mode**: default in `context/ThemeContext.tsx` (`'dark'` or `'light'`); if + the client is single-theme, still keep both palettes defined. +- **Dark base is ANY color** (black, navy, red, green…): it's fully token-driven — the + template has no hardcoded hues. Set the `:root[data-theme='dark']` block + (`--sl-cream` page bg, `--sl-white` cards, `--sl-ink`/`--sl-muted` text, + `--sl-green`/`--sl-green-dark` primary, borders) **and `--sl-primary-rgb`** (the + primary as `r,g,b`) — every glow/rim uses `rgba(var(--sl-primary-rgb), a)` and every + shadow is neutral slate, so changing those tokens re-themes the whole dark mode. +- **Embed vars**: retint `TS_CSS_VARIABLES` (light) and `TS_VARS_DARK` (dark). + +**Embed gotchas (these bit us — do not repeat):** +- **Buttons must be a SOLID color, never a gradient**, on `--ts-var-*-button-*-background` + — the SDK applies them as `background-color`, so a gradient renders as invisible/white. + Put gradients only on host CSS and on customCSS **rules** (`Triumph_EMBED_RULES` → + rename to `_EMBED_RULES`), e.g. KPI-tile background and viz-title text. +- **Dark embed must blend**: set `--ts-var-liveboard-layout-background` / nav / group to + the app's page background, tiles one step lighter. Purge any leftover green + (`rgba(27,185,120,…)` ring in `--ts-var-viz-box-shadow`, `#143329` table header). +- **`isLiveboardMasterpiecesEnabled: true`** must be on **every** LiveboardEmbed + (it's in `LIVEBOARD_EMBED_FLAGS` — spread it everywhere, incl. the inline tab) or + custom/BYOC vizzes throw "features disabled by your admin". +- CustomCSS selectors for KPI tiles / viz titles / the AI-Highlights button are + version-dependent — they're best-effort; tell the user to Inspect and share a class + if a surface is missed. + +## 8. Copy +Login hero tagline + subtitle (`pages/Login.tsx`), the demo-access note (host name), +chatbot FAQ/system prompt (`lib/chatbot.ts`), and sample questions — all to the +client's domain, derived from "what they do". + +## 9. Verify + deploy +- `npm run build` clean, then `npm run dev` → give the localhost URL. +- Recap the spec; list the resolved column names, the **CSP/CORS allowlist** step for + the deploy domain, and the auth/session requirement. +- **Deploy only if asked.** Vercel CLI is cached (`npx vercel@latest`, scope + `koushik426s-projects` — personal can't be passed as `--scope `): + `vercel project add ` → `vercel link --yes --project --scope …` → + `vercel deploy --prod --yes --scope …`. A project named `` serves + `.vercel.app`. Remove the `node_modules` symlink before any git commit. diff --git a/demoprep_app/portal/references/intake.md b/demoprep_app/portal/references/intake.md new file mode 100644 index 0000000000000000000000000000000000000000..5d14cf3e0697690d5fbfadcb99577296a90ecd55 --- /dev/null +++ b/demoprep_app/portal/references/intake.md @@ -0,0 +1,52 @@ +# Portal intake (plain English) + +Answer what you can — anything skipped uses a sensible default, and the assistant +will ask follow-ups. No technical knowledge needed; just paste the values. + +## 0. How deep? +- **Basic** (quick demo: dashboards + Spotter + Ask-AI + theme) or **Advanced** + (full build)? If Basic, sections 3, 4 and 6 are skipped. + +## 1. Brand +- Company name: +- Name for the AI assistant (default "Ask AI"): +- One line on what the company does: +- Website URL: +- **Logo** — attach an SVG (preferred) or PNG. No logo? Say "generate one". + +## 2. Your ThoughtSpot data & filters +- ThoughtSpot URL (e.g. `your-co.thoughtspot.cloud`): +- Main dashboard (liveboard) — paste its GUID: +- Data model / worksheet — paste its GUID: +- Sign-in: (a) rely on being logged into ThoughtSpot in another tab · (b) type real credentials · (c) advanced/trusted +- Which fields should filter the main dashboard (e.g. Region, Corridor)? +- A date field to filter by? + +## 3. Inline-insights list tab? (a table of items, each opening its own dashboard) +- Want it? yes / no +- If yes → dashboard (liveboard) GUID to open inline · tab name · the column of item + names (e.g. Carrier, Process) · up to 3 numbers to show per row. + +## 4. Custom-action workflow? (right-click a chart row → run a Triumph-style action) — OPTIONAL +- Want it? yes / no (if no, we skip this entirely) +- If yes → visualization GUID + the dashboard it lives on · action label (e.g. + "Request Bid") · describe what should happen on click and which row details pre-fill it · tab name. + +## 5. Ask-AI +- Which style: (a) standalone Spotter · (b) fancy chat experience · (c) both +- Floating chatbot in the bottom-right corner? yes / no +- Paywall/monetization popup? yes / no — if yes, after how many questions? +- Premium vs Basic users? yes / no — if yes, what should Basic NOT be able to do + (drill-down, Ask AI, downloads, …)? + +## 6. Pinning / "Add Report" +- Want the "Add Report" feature — build a chart in-app and pin it to the dashboard? yes / no + (Heads-up: the Report Builder doesn't render well in **dark** mode — light theme recommended if you enable it.) + +## 7. Look & feel +- **Attach a screenshot of the brand's site** and/or its URL (colors are taken from it). +- Light, dark, or both? Which is the default? +- Fonts (title vs body), if specific. +- Primary color, and any specific asks (gradients, hues, "make the titles/KPIs pop"). + +_The app is built and run **locally** by default — ask afterward if you want it deployed to a public URL._ diff --git a/demoprep_app/portal/references/spec-schema.md b/demoprep_app/portal/references/spec-schema.md new file mode 100644 index 0000000000000000000000000000000000000000..991b59e6ae238b580bc23428cd3e4be0ebe852fb --- /dev/null +++ b/demoprep_app/portal/references/spec-schema.md @@ -0,0 +1,79 @@ +# spec.json — schema + interview mapping + +The interview produces a **`spec.json`**; the codemod `scripts/apply-spec.mjs` turns it +into a working `-tse/` app in well under a second. **Copy +`references/spec.example.json`** (a complete, working InTime example) and edit every +value per the interview — do not hand-write from scratch. + +Run from the project root (the codemod finds its bundled `template-tse/` relative to +itself, and resolves your `spec.json` asset paths against the current directory): + +```bash +node .claude/skills/rebrand-thoughtspot-portal/scripts/apply-spec.mjs spec.json +``` + +## Top-level fields + +| Field | From interview | Notes | +|---|---|---| +| `client` | Brand name, Title-case | e.g. `"InTime"`. Renames the `Northwind` token in all casings. | +| `slug` | derived, kebab/lower | output dir `-tse/`; also namespaces storage keys. | +| `host` | Data section | bare host, e.g. `team2.thoughtspot.cloud` (script adds `https://`). | +| `auth` | Data section | `none` \| `basic` \| `trusted`. `basic` wires `AuthType.Basic` + login creds. | +| `ids` | Data section | `analyticsLiveboard`, `model`, `inlineLiveboard`, `actionLiveboard`, `actionViz`. **Confirm each by TYPE** (liveboard vs viz) — a viz GUID in a liveboard slot = blank embed. | +| `action` | Custom-action section | `{ id, name }` — the context-menu action label. | +| `columns` | Data / inline / filters | `inlineName[]` (candidate list), `inlineMetrics[]` (`{label,format,candidates[]}`, ≤3), `filterPrimary[]`, `filterSecondary[]`, `date`, `hierarchy{segment,rep,cadence}`. Candidate lists = first that returns data wins. | +| `appFont` | — | **Ignored.** The embed font is now derived automatically from `font.family` (see below). Safe to omit. | +| `font` | Theme | the brand font. Two forms, both handled: **(1) Self-hosted** `{ family, file, srcPath }` — copied to `public/` and `@font-face`'d in `globals.css` (host UI = real font); embeds get the nearest Google font (below). **(2) Google font by name** `{ family }` (optionally `{ googleUrl }`) — host loads it via `index.html`'s `` (host UI = real font, no `@font-face`); embeds load it too (it's on a CDN → real font in embeds as well). Set `--font-serif`/`--font-sans` in `theme.light/dark` to `family`. **Embeds** always request `family` first, then the **nearest free Google font** as the reachable fallback (`FONT_FALLBACKS` in `apply-spec.mjs`: Larsseit→DM Sans, Gilroy→Poppins, Futura→Jost, Google faces→themselves, unknown→Inter); the real font renders in embeds only if it's a Google font or the cluster admin uploaded it. **Never self-host a font into the embeds** — it fails in the sandboxed iframe and blanks the theme. Omit `font` to keep Inter everywhere. | +| `logo` | Brand | `{ svgPath }` (repo-relative). Script rewrites `fill:#hex`→`currentColor` so it flips with theme. | +| `favicon` | Brand | inline SVG string → `public/-icon.svg`. | +| `theme` | Theme | see below. | +| `features` | multiple | see below — controls **tab pruning**. | +| `content` | all copy | the entire `content.ts` object (every human string). | +| `embedSwaps` / `greenSwaps` / `cssSwaps` | Theme | hex remaps — **fixed left side, brand right side** (see below). | + +## `theme` + +- `default`: `"light"` | `"dark"`. +- `light` / `dark`: the **full** `:root` token dictionaries (the codemod replaces the whole + block, so include every token — colors, fonts, `--topbar-h`, shadows). Derive the palette + from the brand screenshot/URL. Must include `--brand-navy` and `--brand-accent` (fixed + brand colors, **same value in light and dark** — they drive the chatbot, which is a light + card with a dark header in both themes). + +## `features` (tab pruning the codemod performs) + +- `askMode`: `"both"` (Ask + Spotter) · `"fancy"` (removes Spotter tab) · `"normal"` (removes Ask tab). +- `inline`: `false` removes the inline (Carriers/Shifts) tab + file. +- `action`: `false` removes the action (Capacity/Leaves) tab + BidModal. +- `monetize` / `tiers` / `pinning`: **automated** — the codemod writes `src/flags.ts` and the + template gates these in place (`{FLAGS.tiers && …}` etc.). `false` hides the paywall, the + tier switcher (tier stays Premium → everything unlocked), and the Add-Report panel. No hand-step. + +## The swap arrays — fixed LHS, brand RHS + +Each is `[from, to]`. The **left-hand values are constants** (the template's neutral +palette); change only the **right-hand** values to the brand equivalents. This is how the +embed CSS variables (indigo `config.ts`), the liveboard tab/button accents (green +`thoughtspot.ts`), and a few leftover dark-override rules (`globals.css`) get rebranded. + +- **`embedSwaps`** (config.ts) LHS: `#4F5BD5` (primary), `#3F49B8` (hover), `#333C99` (active), + `#22B8CF` (gradient end), `#0b0d10` `#14181f` `#1a212b` `#232a34` (dark embed bg family), + `#0b1f19` (dark input), `rgba(45,164,191,0.14)` (ring). +- **`greenSwaps`** (thoughtspot.ts liveboard accents) LHS: `#0f9a63` `#0c8554` `#d9ecea` + `#cfe3e2` `#1bb978` `#15ae6e`. +- **`cssSwaps`** (globals.css dark-override rules) LHS: `#0e1116` `#10222a` `#123047`. + +> The chatbot palette is **token-driven** (`--brand-navy`/`--brand-accent`), so it needs +> **no** swaps — just set those two theme tokens. + +## After the codemod — hand-steps (what a script can't do) + +1. **Custom-action modal** (if a workflow was described): **MANDATORY** — rebuild + `BidModal.tsx` to match the described screen (rename it, e.g. `LeaveRequestModal.tsx`), + per `build-playbook.md` §5. **Never ship the generic Request Bid form** for a described + workflow. +2. Any bespoke UI the user flagged (custom tables, live lookups). + +Then: `npm run build` (fix TS errors), `npm run dev`, and print the spec recap + the +"verify on your cluster" list (resolved column names, CORS/CSP allowlist, auth requirement). diff --git a/demoprep_app/portal/references/spec.example.json b/demoprep_app/portal/references/spec.example.json new file mode 100644 index 0000000000000000000000000000000000000000..a91206afd1999a1d5d568a2933aa794c22068cdc --- /dev/null +++ b/demoprep_app/portal/references/spec.example.json @@ -0,0 +1,312 @@ +{ + "client": "InTime", + "slug": "yourco", + "host": "YOUR-CLUSTER.thoughtspot.cloud", + "auth": "basic", + "ids": { + "analyticsLiveboard": "REPLACE_MAIN_LIVEBOARD_GUID", + "model": "REPLACE_MODEL_GUID", + "inlineLiveboard": "REPLACE_INLINE_LIVEBOARD_GUID", + "actionLiveboard": "REPLACE_MAIN_LIVEBOARD_GUID", + "actionViz": "REPLACE_ACTION_VIZ_GUID" + }, + "action": { + "id": "request-leave", + "name": "Request Leave" + }, + "columns": { + "inlineName": [ + "Rank Name", + "Rank", + "Rank Type", + "Employee" + ], + "inlineMetrics": [ + { + "label": "Manual Overtime Hours", + "format": "number", + "candidates": [ + "Total Manual Overtime Hours", + "Manual Overtime Hours", + "Overtime Hours" + ] + }, + { + "label": "Overtime Cost", + "format": "currency", + "candidates": [ + "Overtime Cost", + "Total Overtime Cost", + "OT Cost" + ] + } + ], + "filterPrimary": [ + "Branch Name", + "Company Name", + "Branch", + "Dim Branch" + ], + "filterSecondary": [ + "Rank Name", + "Rank", + "Rank Type" + ], + "date": "Sched Start Time", + "hierarchy": { + "segment": "Branch Name", + "rep": "Rank Name", + "cadence": "Employee" + } + }, + "appFont": "'Larsseit', 'Inter', 'Segoe UI', sans-serif", + "font": { + "family": "Manrope", + "googleUrl": "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap" + }, + "logo": { + "svgPath": "your-logo.svg" + }, + "favicon": "", + "theme": { + "default": "light", + "light": { + "--sl-cream": "#f4f7fb", + "--sl-cream-2": "#e9f0f7", + "--sl-white": "#ffffff", + "--sl-evergreen": "#0e3147", + "--sl-evergreen-2": "#0a2436", + "--sl-green": "#0e3147", + "--sl-green-dark": "#16466a", + "--sl-primary-rgb": "14, 49, 71", + "--intime-blue": "#2f6fb0", + "--sl-gold": "#f4d14e", + "--sl-gold-ink": "#6b5410", + "--intime-grad": "linear-gradient(90deg, #0e3147 0%, #2f6fb0 100%)", + "--sl-mint": "#e6f1fb", + "--sl-mint-2": "#d5e8f8", + "--sl-coral": "#a2191f", + "--sl-warn": "#fffbe6", + "--sl-warn-ink": "#6b5410", + "--sl-ink": "#12283f", + "--sl-muted": "#5a6a7e", + "--sl-border": "#e1e8f0", + "--sl-border-soft": "#eef2f7", + "--font-serif": "'Larsseit', 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif", + "--font-sans": "'Larsseit', 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif", + "--topbar-h": "66px", + "--shadow-card": "0 1px 2px rgba(15, 23, 42, 0.05), 0 8px 24px rgba(15, 23, 42, 0.07)", + "--shadow-lift": "0 6px 14px rgba(15, 23, 42, 0.08), 0 18px 40px rgba(15, 23, 42, 0.12)", + "--brand-navy": "#0e3147", + "--brand-accent": "#2f6fb0" + }, + "dark": { + "--sl-cream": "#0a2233", + "--sl-cream-2": "#0e293c", + "--sl-white": "#12314a", + "--sl-evergreen": "#eef4fa", + "--sl-evergreen-2": "#dbe6f0", + "--sl-green": "#7fb0e0", + "--sl-green-dark": "#5e97cc", + "--sl-primary-rgb": "127, 176, 224", + "--intime-blue": "#7fb0e0", + "--sl-gold": "#f4d14e", + "--sl-gold-ink": "#0a2233", + "--intime-grad": "linear-gradient(90deg, #7fb0e0 0%, #bfe0f5 100%)", + "--sl-mint": "#10293d", + "--sl-mint-2": "#16344c", + "--sl-coral": "#f0616a", + "--sl-ink": "#eaf1f7", + "--sl-muted": "#9fb2c6", + "--sl-border": "#23415a", + "--sl-border-soft": "#172f44", + "--shadow-card": "0 1px 2px rgba(0, 0, 0, 0.5), 0 8px 24px rgba(0, 0, 0, 0.55)", + "--shadow-lift": "0 6px 14px rgba(0, 0, 0, 0.6), 0 18px 40px rgba(0, 0, 0, 0.65)", + "--brand-navy": "#0e3147", + "--brand-accent": "#2f6fb0" + } + }, + "embedSwaps": [ + [ + "#4F5BD5", + "#2f6fb0" + ], + [ + "#3F49B8", + "#285f97" + ], + [ + "#333C99", + "#22527f" + ], + [ + "#22B8CF", + "#7fb0e0" + ], + [ + "#0b0d10", + "#0a2233" + ], + [ + "#14181f", + "#12314a" + ], + [ + "#1a212b", + "#16344c" + ], + [ + "#232a34", + "#23415a" + ], + [ + "#0b1f19", + "#0e293c" + ], + [ + "rgba(45,164,191,0.14)", + "rgba(127,176,224,0.16)" + ] + ], + "greenSwaps": [ + [ + "#0f9a63", + "#16344c" + ], + [ + "#0c8554", + "#12314a" + ], + [ + "#d9ecea", + "#d5e8f8" + ], + [ + "#cfe3e2", + "#c2ddf4" + ], + [ + "#1bb978", + "#7fb0e0" + ], + [ + "#15ae6e", + "#0e3147" + ] + ], + "cssSwaps": [ + [ + "#0e1116", + "#0e293c" + ], + [ + "#10222a", + "#0e293c" + ], + [ + "#123047", + "#12314a" + ] + ], + "features": { + "askMode": "both", + "inline": true, + "action": true, + "monetize": true, + "tiers": true, + "pinning": true + }, + "content": { + "company": "InTime", + "aiName": "InTime AI", + "website": "https://www.intime.com", + "loginTitleHtml": "Scheduling built for
public safety.", + "loginSubtitle": "Your command center for police, fire, corrections and dispatch \u2014 schedules, overtime and coverage, with AI-powered answers on live data. Built on InTime, powered by ThoughtSpot.", + "loginStats": [ + { + "value": "24/7", + "label": "Coverage Managed" + }, + { + "value": "98%", + "label": "Shifts Filled" + }, + { + "value": "Real-time", + "label": "Overtime Intel" + } + ], + "loginCardSubtitle": "Sign in with your ThoughtSpot credentials", + "loginDemoNoteHtml": "Sign in with your ThoughtSpot username & password for {host}. This origin must be CORS-allowlisted on that cluster for the embeds to load.", + "platformNav": [ + "Police", + "Fire", + "Corrections" + ], + "tabs": { + "myReports": "My Reports", + "analytics": "Analytics", + "inline": "Shifts", + "action": "Leaves", + "ask": "Ask InTime AI", + "spotter": "Spotter" + }, + "analyticsSubtitle": "Your live scheduling, overtime & coverage dashboard", + "spotterSubtitle": "Ask questions of your InTime scheduling & overtime data in natural language.", + "inline": { + "subtitleReady": "Overtime & hours by rank \u2014 select a rank to view its shift insights", + "subtitleIdle": "Overtime and hours across your ranks", + "loading": "Loading ranks\u2026", + "errorTitle": "Couldn't load shifts", + "errorMsg": "Unable to load shift data.", + "moreLabel": "View insights", + "hideLabel": "Hide insights" + }, + "filters": { + "primaryLabel": "Branch", + "secondaryLabel": "Rank" + }, + "sampleQuestions": [ + "Overtime hours by rank", + "Overtime cost by branch", + "Worked hours by employee", + "Leave hours over time" + ], + "askWelcome": "Ask me anything about InTime \u2014 scheduling, overtime, leave, coverage, best practices \u2014 or ask about your own data and I will chart it live.", + "askEmptySub": "Ask any analytical question about your schedules, overtime, leave, and coverage. Answers come straight from your live data with an interactive chart you can drill into.", + "action": { + "label": "Request Leave", + "subtitlePrefix": "Officer schedules and coverage. Right-click a row and choose", + "subtitleSuffix": "to open a leave request pre-filled for that officer.", + "modalTitle": "New Leave Request", + "modalLead": "Pre-filled for the selected officer. Review and submit to request time off through InTime.", + "submitLabel": "Submit", + "successTitle": "Leave request submitted" + }, + "chatbot": { + "welcome": "Hi! I'm InTime AI. Ask me about scheduling, overtime and coverage, or ask a question about your data.", + "overview": "InTime is workforce scheduling and time & attendance software built for public safety \u2014 police, sheriff, fire, corrections, and dispatch. It manages shift schedules, overtime, leave, court time, and certifications, with AI-powered answers on live workforce data.", + "greetingExample": "overtime hours by rank", + "examples": [ + "worked hours by week", + "overtime cost by branch" + ] + }, + "trialQuestionTrigger": 3, + "upgradeReason": "Upgrade to InTime Premium to unlock drill-down, Ask AI and downloads.", + "analyticsKeywords": [ + "overtime", + "worked", + "leave", + "coverage", + "shift", + "shifts", + "headcount", + "staffing", + "rank", + "branch", + "officer", + "hours" + ] + } +} \ No newline at end of file diff --git a/demoprep_app/portal/scripts/apply-spec.mjs b/demoprep_app/portal/scripts/apply-spec.mjs new file mode 100644 index 0000000000000000000000000000000000000000..df4cbcd96e887c7f4449f9b3d97bdf68bac515c5 --- /dev/null +++ b/demoprep_app/portal/scripts/apply-spec.mjs @@ -0,0 +1,294 @@ +#!/usr/bin/env node +// --------------------------------------------------------------------------- +// apply-spec.mjs — spec.json -> a fully-skinned -tse/ app. +// +// node scripts/apply-spec.mjs +// +// Deterministic codemod for the mechanizable ~90% of a reskin: clone the +// template, rename the brand token, write content.ts (all copy), patch config.ts +// (host / GUIDs / columns / action / embed theme), patch auth + fonts, regenerate +// the globals.css theme tokens, drop in the logo/favicon/font, and prune features +// the spec turns off. Bespoke workflow modals (e.g. a custom leave form) are NOT +// generated — they stay a manual step, and the script prints a reminder. +// +// Run from your project root. The skill is self-contained: the base template is +// bundled next to this script (../template-tse), and the generated -tse/ app +// is written into the current working directory. Paths in the spec (logo.svgPath, +// font.srcPath) are resolved relative to the current working directory. +// --------------------------------------------------------------------------- +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execSync } from 'node:child_process'; + +const ROOT = process.cwd(); // where the app is written +const SKILL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const TEMPLATE = path.join(SKILL_DIR, 'template-tse'); // bundled with the skill +const SKIP = new Set(['node_modules', 'dist', '.vercel', '.git']); + +const specPath = process.argv[2]; +if (!specPath) { console.error('usage: node scripts/apply-spec.mjs '); process.exit(1); } +const spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); +const t0 = Date.now(); + +const slug = spec.slug; +const client = spec.client; // Title-case, e.g. "InTime" +const CLIENT = client.toUpperCase(); +const clientLower = slug; // lowercase token +const OUT = path.join(ROOT, `${slug}-tse`); + +// Embed font: the brand font can't be pushed into ThoughtSpot's sandboxed iframe +// unless it's on a CDN or uploaded to the cluster. So we request the brand font +// first (renders if the cluster has it) and load the NEAREST Google font as the +// reachable fallback. Map common brand faces -> closest free Google font. +const gfontUrl = (fam) => + `https://fonts.googleapis.com/css2?family=${fam.replace(/ /g, '+')}:wght@400;500;600;700&display=swap`; +const FONT_FALLBACKS = { + larsseit: 'DM Sans', circular: 'Mulish', gilroy: 'Poppins', gotham: 'Montserrat', + avenir: 'Nunito Sans', 'avenir next': 'Nunito Sans', 'proxima nova': 'Montserrat', + metric: 'Inter', graphik: 'Public Sans', futura: 'Jost', 'brandon grotesque': 'Montserrat', + helvetica: 'Inter', arial: 'Inter', + // Google faces map to themselves (so an explicit Google font just works) + inter: 'Inter', roboto: 'Roboto', 'open sans': 'Open Sans', lato: 'Lato', + montserrat: 'Montserrat', poppins: 'Poppins', 'dm sans': 'DM Sans', mulish: 'Mulish', + nunito: 'Nunito', 'nunito sans': 'Nunito Sans', jost: 'Jost', manrope: 'Manrope', +}; +const brandFamily = (spec.font && spec.font.family) || 'Inter'; +const fbFamily = FONT_FALLBACKS[brandFamily.toLowerCase()] || 'Inter'; +const embedFamily = `'${brandFamily}', '${fbFamily}', system-ui, -apple-system, 'Segoe UI', sans-serif`; +const embedFontUrl = (spec.font && spec.font.googleUrl) || gfontUrl(fbFamily); +// Host-UI font: a self-hosted file is @font-face'd; a Google font (given by name +// or googleUrl) is loaded via index.html's . isBrandGoogleFont is true when +// the brand font IS itself a Google font (map returns it unchanged). +const isBrandGoogleFont = fbFamily.toLowerCase() === brandFamily.toLowerCase() && brandFamily.toLowerCase() !== 'inter'; +const hostGoogleUrl = spec.font + ? (spec.font.googleUrl || (isBrandGoogleFont && !spec.font.file ? gfontUrl(brandFamily) : null)) + : null; + +// ---- helpers --------------------------------------------------------------- +function copyDir(src, dst) { + fs.mkdirSync(dst, { recursive: true }); + for (const ent of fs.readdirSync(src, { withFileTypes: true })) { + if (SKIP.has(ent.name)) continue; + const s = path.join(src, ent.name), d = path.join(dst, ent.name); + if (ent.isDirectory()) copyDir(s, d); + else fs.copyFileSync(s, d); + } +} +function walk(dir, fn) { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + if (SKIP.has(ent.name)) continue; + const p = path.join(dir, ent.name); + if (ent.isDirectory()) walk(p, fn); + else fn(p); + } +} +const read = (p) => fs.readFileSync(p, 'utf8'); +const write = (p, s) => fs.writeFileSync(p, s); +function edit(p, fn) { write(p, fn(read(p))); } +function renameBrand(s) { + return s.split('Northwind').join(client) + .split('NORTHWIND').join(CLIENT) + .split('northwind').join(clientLower); +} + +// ---- 0. clone -------------------------------------------------------------- +// Zero-setup: the generated apps share the bundled template's node_modules. If it +// hasn't been installed yet (fresh clone of the skill), install it now — once — +// instead of failing the first build with a confusing missing-deps error. +ensureTemplateDeps(); + +if (fs.existsSync(OUT)) fs.rmSync(OUT, { recursive: true, force: true }); +copyDir(TEMPLATE, OUT); +// symlink deps to the bundled template's node_modules. Absolute target so it works +// wherever the generated app is created. +try { fs.symlinkSync(path.join(TEMPLATE, 'node_modules'), path.join(OUT, 'node_modules')); } catch {} + +function ensureTemplateDeps() { + // A real install has node_modules AND the SDK dep resolved (a bare/partial dir + // would still fail the build). Check for the SDK to catch a half-populated dir. + const nm = path.join(TEMPLATE, 'node_modules'); + const sdk = path.join(nm, '@thoughtspot', 'visual-embed-sdk'); + if (fs.existsSync(sdk)) return; + // node/npm often aren't on the default PATH (they live at ~/.node/bin here) — + // prepend it so `npm` resolves whether or not the caller exported it. + const home = process.env.HOME || ''; + const PATH = `${path.join(home, '.node', 'bin')}:${process.env.PATH || ''}`; + console.log('[apply-spec] template deps not installed — running `npm install` once (this can take a minute)…'); + try { + execSync('npm install --no-audit --no-fund', { + cwd: TEMPLATE, + stdio: 'inherit', + env: { ...process.env, PATH }, + }); + } catch { + console.error( + `[apply-spec] auto-install failed. Install manually, then re-run:\n` + + ` (cd "${TEMPLATE}" && npm install)`, + ); + process.exit(1); + } +} + +// ---- 1. brand rename (all casings) + file renames -------------------------- +walk(path.join(OUT, 'src'), (p) => { + if (/\.(ts|tsx|css)$/.test(p)) edit(p, renameBrand); +}); +edit(path.join(OUT, 'index.html'), renameBrand); +// Host webfont: point index.html's font at the brand Google font when the +// host font is a Google font (self-hosted fonts are handled by @font-face above). +if (hostGoogleUrl) { + edit(path.join(OUT, 'index.html'), (s) => + s.replace('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap', hostGoogleUrl)); +} +edit(path.join(OUT, 'package.json'), (s) => s.replace(/northwind-thoughtspot-portal/g, `${slug}-thoughtspot-portal`)); +const mv = (a, b) => { const A = path.join(OUT, a), B = path.join(OUT, b); if (fs.existsSync(A)) fs.renameSync(A, B); }; +mv('src/components/NorthwindLogo.tsx', `src/components/${client}Logo.tsx`); +mv('src/tabs/AskNorthwind.tsx', `src/tabs/Ask${client}.tsx`); + +// ---- 2. content.ts (all copy, from spec) ----------------------------------- +const contentFile = +`// Generated by apply-spec.mjs — single source of truth for all portal copy. +export interface LoginStat { value: string; label: string; } +export const CONTENT = ${JSON.stringify(spec.content, null, 2)} as const; +export type Content = typeof CONTENT; +`; +write(path.join(OUT, 'src/content.ts'), contentFile); + +// ---- 2b. flags.ts — feature toggles (monetize / tiers / pinning) ----------- +// Tabs (inline/action/askMode) are PRUNED below; these three are gated in-place +// via FLAGS so shared components (paywall, tier switcher, Add-Report) render only +// when on. Default true when the spec omits a flag. +{ + const ff = (spec.features || {}); + const b = (v) => (v === false ? 'false' : 'true'); + write(path.join(OUT, 'src/flags.ts'), + '// Generated by apply-spec.mjs from spec.features.\n' + + 'export const FLAGS = {\n' + + ` monetize: ${b(ff.monetize)},\n` + + ` tiers: ${b(ff.tiers)},\n` + + ` pinning: ${b(ff.pinning)},\n` + + '} as const;\n'); +} + +// ---- 3. config.ts — IDs / host / action / columns / embed theme ------------ +edit(path.join(OUT, 'src/config.ts'), (s) => { + const i = spec.ids; + s = s.replace('https://YOUR-CLUSTER.thoughtspot.cloud', `https://${spec.host}`) + .replace('REPLACE_MAIN_LIVEBOARD_GUID', i.analyticsLiveboard) + .replace('REPLACE_INLINE_LIVEBOARD_GUID', i.inlineLiveboard) + .replace(/REPLACE_MODEL_GUID/g, i.model) + .replace('REPLACE_ACTION_LIVEBOARD_GUID', i.actionLiveboard) + .replace('REPLACE_ACTION_VIZ_GUID', i.actionViz) + .replace("'request-bid'", `'${spec.action.id}'`) + .replace("'Request Bid'", `'${spec.action.name}'`); + // candidate column arrays + const arr = (name, items) => + s = s.replace(new RegExp(`export const ${name} = \\[[\\s\\S]*?\\];`), + `export const ${name} = [\n${items.map((x) => ` '${x}',`).join('\n')}\n];`); + arr('CADENCE_COLUMN_CANDIDATES', spec.columns.inlineName); + arr('REGION_COLUMN_CANDIDATES', spec.columns.filterPrimary); + arr('CORRIDOR_COLUMN_CANDIDATES', spec.columns.filterSecondary); + // inline detail metrics (CADENCE_DETAIL_COLUMNS) + const keys = spec.columns.inlineMetrics.map((_, n) => `metric${n + 1}`); + const body = spec.columns.inlineMetrics.map((m, n) => + ` ${keys[n]}: {\n label: '${m.label}',\n format: '${m.format}',\n candidates: [${m.candidates.map((c) => `'${c}'`).join(', ')}],\n },`).join('\n'); + s = s.replace(/export const CADENCE_DETAIL_COLUMNS: Record<[\s\S]*?\n> = \{[\s\S]*?\n\};/, + `export const CADENCE_DETAIL_COLUMNS: Record<\n ${keys.map((k) => `'${k}'`).join(' | ')},\n { label: string; format: 'text' | 'date' | 'number' | 'currency'; candidates: string[] }\n> = {\n${body}\n};`); + // hierarchy + date filter columns + s = s.replace(/export const SEGMENT_COLUMN = '[^']*';/, `export const SEGMENT_COLUMN = '${spec.columns.hierarchy.segment}';`) + .replace(/export const REP_COLUMN = '[^']*';/, `export const REP_COLUMN = '${spec.columns.hierarchy.rep}';`) + .replace(/export const CADENCE_NAME_COLUMN = '[^']*';/, `export const CADENCE_NAME_COLUMN = '${spec.columns.hierarchy.cadence}';`) + .replace(/export const DATE_COLUMN = '[^']*';/, `export const DATE_COLUMN = '${spec.columns.date}';`); + // embed theme hex swaps (indigo -> brand) + for (const [a, b] of spec.embedSwaps || []) s = s.split(a).join(b); + // Brand-tint the LIGHT embed surfaces (secondary buttons, chips, hovers, tile + // borders, action buttons, menus) by blending the primary toward white — so + // surfaces follow the brand instead of a flat grey. The neutral hexes below are + // the template's surface placeholders acting as swap keys. + { + const h2 = (h) => { h = h.replace('#', ''); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; }; + const x = (n) => Math.round(n).toString(16).padStart(2, '0'); + const mixW = (hex, t) => { const [r, g, b] = h2(hex); return '#' + x(r + (255 - r) * t) + x(g + (255 - g) * t) + x(b + (255 - b) * t); }; + const prim = (spec.theme && spec.theme.light && spec.theme.light['--sl-green']) || '#4F5BD5'; + const surf = { + '#FCFCFD': mixW(prim, 0.975), '#F5F6F8': mixW(prim, 0.96), '#F5F6F9': mixW(prim, 0.96), + '#EEF0F4': mixW(prim, 0.92), '#EEF0F5': mixW(prim, 0.92), '#F1F2F6': mixW(prim, 0.945), + '#E3E6EC': mixW(prim, 0.86), '#E8EAF1': mixW(prim, 0.90), '#E2E4EC': mixW(prim, 0.88), + '#D7DBE3': mixW(prim, 0.80), + }; + for (const [g, v] of Object.entries(surf)) s = s.split(g).join(v); + } + // embed font: brand font first, nearest reachable Google font as fallback + s = s.replace(/export const EMBED_FONT_FAMILY = '[^']*';/, + `export const EMBED_FONT_FAMILY = ${JSON.stringify(embedFamily)};`); + // the stylesheet actually loaded into the iframe — always a reliable Google URL + s = s.replace(/export const TS_FONT_URL =\n '[^']*';/, + `export const TS_FONT_URL =\n ${JSON.stringify(embedFontUrl)};`); + return s; +}); + +// ---- 4. thoughtspot.ts — auth + embed font + accent swaps ------------------ +edit(path.join(OUT, 'src/lib/thoughtspot.ts'), (s) => { + if (spec.auth === 'basic') { + s = s.replace(/void username;\n\s*void password;\n/, '') + .replace('authType: AuthType.None,', 'authType: AuthType.Basic,\n username,\n password,'); + } + s = s.replace(/const APP_FONT = "[^"]*";/, `const APP_FONT = ${JSON.stringify(embedFamily)};`); + for (const [a, b] of spec.embedSwaps || []) s = s.split(a).join(b); + for (const [a, b] of spec.greenSwaps || []) s = s.split(a).join(b); + return s; +}); + +// ---- 5. globals.css — regenerate theme tokens + @font-face ----------------- +edit(path.join(OUT, 'src/styles/globals.css'), (s) => { + // Guarantee the fixed brand tokens the chatbot depends on — if the spec's theme + // dicts omit them, the chat FAB/header background (var(--brand-accent/navy)) would + // be undefined and render transparent. Default from the light primary + ink. + for (const key of ['light', 'dark']) { + const d = spec.theme[key] || {}; + if (!d['--brand-accent']) d['--brand-accent'] = spec.theme.light['--sl-green'] || '#4F5BD5'; + if (!d['--brand-navy']) d['--brand-navy'] = spec.theme.light['--sl-evergreen'] || '#12203a'; + spec.theme[key] = d; + } + const block = (dict) => Object.entries(dict).map(([k, v]) => ` ${k}: ${v};`).join('\n'); + s = s.replace(/:root \{\n[\s\S]*?\n\}/, `:root {\n${block(spec.theme.light)}\n}`); + s = s.replace(/:root\[data-theme='dark'\] \{\n[\s\S]*?\n\}/, `:root[data-theme='dark'] {\n${block(spec.theme.dark)}\n}`); + // leftover dark-override rules (login-left gradient, branded header fills) + for (const [a, b] of spec.cssSwaps || []) s = s.split(a).join(b); + if (spec.font && spec.font.file) { + const face = +`@font-face {\n font-family: '${spec.font.family}';\n font-style: normal;\n font-weight: normal;\n font-display: swap;\n src: url('/${spec.font.file}') format('woff2');\n}\n\n`; + s = face + s; + } + return s; +}); + +// ---- 6. theme default + assets -------------------------------------------- +edit(path.join(OUT, 'src/context/ThemeContext.tsx'), (s) => + s.replace(/: 'dark';/, `: '${spec.theme.default}';`).replace(/: 'light';/, `: '${spec.theme.default}';`)); +if (spec.logo?.svgPath) { + const svg = read(path.join(ROOT, spec.logo.svgPath)).replace(/fill:\s*#[0-9A-Fa-f]{3,6}/g, 'fill:currentColor'); + write(path.join(OUT, `src/assets/${slug}-logo.svg`), svg); +} +if (spec.favicon) write(path.join(OUT, `public/${slug}-icon.svg`), spec.favicon); +if (spec.font?.srcPath) fs.copyFileSync(path.join(ROOT, spec.font.srcPath), path.join(OUT, `public/${spec.font.file}`)); +// remove stale template assets +for (const f of ['public/northwind-icon.svg', 'src/assets/northwind-logo.svg', 'public/figma-reference.png']) + { const p = path.join(OUT, f); if (fs.existsSync(p)) fs.rmSync(p); } + +// ---- 7. prune features the spec turns off ---------------------------------- +const f = spec.features || {}; +const rmTab = (id, extraFiles = []) => { + edit(path.join(OUT, 'src/App.tsx'), (s) => s.replace(new RegExp(`\\s*\\{tab === '${id}' && <[^>]+>\\}`), '')); + edit(path.join(OUT, 'src/components/TopBar.tsx'), (s) => s.replace(new RegExp(`\\s*\\{ id: '${id}',[^\\n]*\\},`), '')); + for (const rel of extraFiles) { const p = path.join(OUT, rel); if (fs.existsSync(p)) fs.rmSync(p); } +}; +if (f.inline === false) rmTab('carriers', ['src/tabs/Carriers.tsx']); +if (f.action === false) rmTab('capacity', ['src/tabs/Capacity.tsx', 'src/components/BidModal.tsx']); +if (f.askMode === 'fancy') rmTab('spotter', ['src/tabs/Spotter.tsx']); +if (f.askMode === 'normal') rmTab('ask', [`src/tabs/Ask${client}.tsx`]); + +console.log(`\n[apply-spec] ${client} -> ${slug}-tse/ in ${((Date.now() - t0) / 1000).toFixed(2)}s`); +if (f.action !== false) + console.log('[apply-spec] NOTE: the custom-action modal (BidModal) is generic — customize it by hand for a bespoke workflow.'); diff --git a/demoprep_app/portal/specs/triumph.json b/demoprep_app/portal/specs/triumph.json new file mode 100644 index 0000000000000000000000000000000000000000..1138215eaecff8398f811411bb243b64e81721e5 --- /dev/null +++ b/demoprep_app/portal/specs/triumph.json @@ -0,0 +1,239 @@ +{ + "client": "TriumphFinancial", + "slug": "triumph", + "host": "sebe.thoughtspotstaging.cloud", + "auth": "none", + "ids": { + "analyticsLiveboard": "ca684d71-dd94-4d70-a24a-efb2e443f9ea", + "model": "276f6862-3fe1-4df8-9ae0-12912c7f54c0", + "inlineLiveboard": "ca684d71-dd94-4d70-a24a-efb2e443f9ea", + "actionLiveboard": "ca684d71-dd94-4d70-a24a-efb2e443f9ea", + "actionViz": "ca684d71-dd94-4d70-a24a-efb2e443f9ea" + }, + "action": { + "id": "request-capacity", + "name": "Request Capacity" + }, + "columns": { + "inlineName": [ + "Broker Name", + "Broker Tier" + ], + "inlineMetrics": [ + { + "label": "Load Revenue", + "format": "currency", + "candidates": [ + "Total Load Revenue Usd", + "Total Load Revenue" + ] + }, + { + "label": "Broker Margin %", + "format": "number", + "candidates": [ + "Broker Margin Pct", + "Broker Margin" + ] + } + ], + "filterPrimary": [ + "Broker Name", + "Broker Tier" + ], + "filterSecondary": [ + "Lane Name", + "Lane Tier" + ], + "date": "Load Date", + "hierarchy": { + "segment": "Broker Tier", + "rep": "Broker Name", + "cadence": "Lane Name" + } + }, + "logo": { + "svgPath": "triumph-logo.svg" + }, + "favicon": "", + "theme": { + "default": "light", + "light": { + "--sl-cream": "#f5f8fc", + "--sl-cream-2": "#eaf1f8", + "--sl-white": "#ffffff", + "--sl-evergreen": "#10294a", + "--sl-evergreen-2": "#0a1d37", + "--sl-green": "#10294a", + "--sl-green-dark": "#1a3f6e", + "--sl-primary-rgb": "16, 41, 74", + "--intime-blue": "#2e86d1", + "--sl-gold": "#e8b53c", + "--sl-gold-ink": "#5f4708", + "--intime-grad": "linear-gradient(90deg, #10294a 0%, #2e86d1 100%)", + "--sl-mint": "#e5f2fc", + "--sl-mint-2": "#d2e8fa", + "--sl-coral": "#a2191f", + "--sl-warn": "#fffbe6", + "--sl-warn-ink": "#5f4708", + "--sl-ink": "#11253f", + "--sl-muted": "#5a6a7e", + "--sl-border": "#e0e8f0", + "--sl-border-soft": "#edf2f8", + "--font-serif": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif", + "--font-sans": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif", + "--topbar-h": "66px", + "--shadow-card": "0 1px 2px rgba(15, 23, 42, 0.05), 0 8px 24px rgba(15, 23, 42, 0.07)", + "--shadow-lift": "0 6px 14px rgba(15, 23, 42, 0.08), 0 18px 40px rgba(15, 23, 42, 0.12)", + "--brand-navy": "#10294a", + "--brand-accent": "#2e86d1" + }, + "dark": { + "--sl-cream": "#0a1a2e", + "--sl-cream-2": "#0e2238", + "--sl-white": "#122c48", + "--sl-evergreen": "#eef4fa", + "--sl-evergreen-2": "#dbe6f0", + "--sl-green": "#7ab4e8", + "--sl-green-dark": "#5a9bd6", + "--sl-primary-rgb": "122, 180, 232", + "--intime-blue": "#7ab4e8", + "--sl-gold": "#e8b53c", + "--sl-gold-ink": "#0a1a2e", + "--intime-grad": "linear-gradient(90deg, #7ab4e8 0%, #bfe0f7 100%)", + "--sl-mint": "#0f2a42", + "--sl-mint-2": "#153450", + "--sl-coral": "#f0616a", + "--sl-warn": "#2a2413", + "--sl-warn-ink": "#e8b53c", + "--sl-ink": "#eaf1f7", + "--sl-muted": "#9fb2c6", + "--sl-border": "#22405e", + "--sl-border-soft": "#162e48", + "--font-serif": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif", + "--font-sans": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif", + "--topbar-h": "66px", + "--shadow-card": "0 1px 2px rgba(0, 0, 0, 0.5), 0 8px 24px rgba(0, 0, 0, 0.55)", + "--shadow-lift": "0 6px 14px rgba(0, 0, 0, 0.6), 0 18px 40px rgba(0, 0, 0, 0.65)", + "--brand-navy": "#10294a", + "--brand-accent": "#2e86d1" + } + }, + "embedSwaps": [ + ["#4F5BD5", "#2e86d1"], + ["#3F49B8", "#2673b8"], + ["#333C99", "#1f5f99"], + ["#22B8CF", "#7ab4e8"], + ["#0b0d10", "#0a1a2e"], + ["#14181f", "#122c48"], + ["#1a212b", "#153450"], + ["#232a34", "#22405e"], + ["#0b1f19", "#0e2238"], + ["rgba(45,164,191,0.14)", "rgba(122,180,232,0.16)"] + ], + "greenSwaps": [ + ["#0f9a63", "#153450"], + ["#0c8554", "#122c48"], + ["#d9ecea", "#d2e8fa"], + ["#cfe3e2", "#c0ddf6"], + ["#1bb978", "#7ab4e8"], + ["#15ae6e", "#10294a"] + ], + "cssSwaps": [ + ["#0e1116", "#0e2238"], + ["#10222a", "#0e2238"], + ["#123047", "#122c48"] + ], + "features": { + "askMode": "both", + "inline": false, + "action": false, + "monetize": false, + "tiers": false, + "pinning": false + }, + "content": { + "company": "Triumph Financial", + "aiName": "Triumph AI", + "website": "https://www.triumph.io", + "loginTitleHtml": "Freight payments,
intelligently moved.", + "loginSubtitle": "Your broker capacity command center — loads, lanes, rates, margins and carrier capacity across the TriumphPay network, with AI-powered answers on live data. Built on Triumph Financial, powered by ThoughtSpot.", + "loginStats": [ + { "value": "$30B+", "label": "Payments Network" }, + { "value": "Live", "label": "Lane & Rate Intel" }, + { "value": "Real-time", "label": "Capacity Signals" } + ], + "loginCardSubtitle": "Sign in with your ThoughtSpot credentials", + "loginDemoNoteHtml": "Sign in with your ThoughtSpot username & password for {host}. This origin must be CORS-allowlisted on that cluster for the embeds to load.", + "platformNav": [ + "Brokers", + "Carriers", + "Lanes" + ], + "tabs": { + "myReports": "My Reports", + "analytics": "Capacity Analytics", + "inline": "Brokers", + "action": "Capacity", + "ask": "Ask Triumph AI", + "spotter": "Spotter" + }, + "analyticsSubtitle": "Your live broker, lane & carrier-capacity dashboard", + "spotterSubtitle": "Ask questions of your Triumph Financial freight & capacity data in natural language.", + "inline": { + "subtitleReady": "Revenue & margin by broker — select a broker to view its lane insights", + "subtitleIdle": "Revenue and margin across your broker network", + "loading": "Loading brokers…", + "errorTitle": "Couldn't load brokers", + "errorMsg": "Unable to load broker data.", + "moreLabel": "View insights", + "hideLabel": "Hide insights" + }, + "filters": { + "primaryLabel": "Broker", + "secondaryLabel": "Lane" + }, + "sampleQuestions": [ + "Broker margin by broker tier", + "Load revenue by lane over time", + "Capacity utilization rate by month", + "On time delivery rate by carrier" + ], + "askWelcome": "Ask me anything about Triumph Financial — loads, lanes, rates, broker margins, carrier capacity — or ask about your own data and I will chart it live.", + "askEmptySub": "Ask any analytical question about your loads, lanes, broker margins, and carrier capacity. Answers come straight from your live data with an interactive chart you can drill into.", + "action": { + "label": "Request Capacity", + "subtitlePrefix": "Loads by broker and lane. Right-click a row and choose", + "subtitleSuffix": "to open a capacity request pre-filled for that lane.", + "modalTitle": "New Capacity Request", + "modalLead": "Pre-filled for the selected lane. Review and submit to request carrier capacity through TriumphPay.", + "submitLabel": "Submit", + "successTitle": "Capacity request submitted" + }, + "chatbot": { + "welcome": "Hi! I'm Triumph AI. Ask me about loads, lanes, broker margins and carrier capacity, or ask a question about your data.", + "overview": "Triumph Financial is a transportation-fintech company: TBK Bank, Triumph Business Capital (invoice factoring for carriers), and TriumphPay, a freight-payments network digitizing invoice presentment, audit and settlement among brokers, carriers, shippers and factors. This portal covers broker capacity intelligence — loads, lanes, rates, margins and carrier capacity — with AI-powered answers on live data.", + "greetingExample": "broker margin by tier", + "examples": [ + "load revenue by lane", + "capacity utilization by month" + ] + }, + "trialQuestionTrigger": 3, + "upgradeReason": "Upgrade to TriumphPay Premium to unlock drill-down, Ask AI and downloads.", + "analyticsKeywords": [ + "load", + "loads", + "lane", + "broker", + "carrier", + "margin", + "revenue", + "rate", + "capacity", + "utilization", + "dispute", + "on-time" + ] + } +} diff --git a/demoprep_app/portal/specs/vantage.json b/demoprep_app/portal/specs/vantage.json new file mode 100644 index 0000000000000000000000000000000000000000..e8ee62335cbc668c6d10894754fb2ac628cdd279 --- /dev/null +++ b/demoprep_app/portal/specs/vantage.json @@ -0,0 +1,238 @@ +{ + "client": "VantageCapital", + "slug": "vantage", + "host": "sebe.thoughtspotstaging.cloud", + "auth": "none", + "ids": { + "analyticsLiveboard": "a476cb7d-5b61-4658-86ca-5f078a212a0d", + "model": "d9d910d7-d3b5-4a7a-a35e-de494405055f", + "inlineLiveboard": "a476cb7d-5b61-4658-86ca-5f078a212a0d", + "actionLiveboard": "a476cb7d-5b61-4658-86ca-5f078a212a0d", + "actionViz": "a476cb7d-5b61-4658-86ca-5f078a212a0d" + }, + "action": { + "id": "flag-for-review", + "name": "Flag for Review" + }, + "columns": { + "inlineName": [ + "Borrower Industry Name", + "Industry Sector" + ], + "inlineMetrics": [ + { + "label": "Outstanding Balance", + "format": "currency", + "candidates": [ + "Outstanding Balance Usd", + "Outstanding Balance" + ] + }, + { + "label": "Delinquency Rate", + "format": "number", + "candidates": [ + "Delinquency Rate" + ] + } + ], + "filterPrimary": [ + "Borrower Industry Name", + "Industry Sector" + ], + "filterSecondary": [ + "Risk Grade Name", + "Grade Tier" + ], + "date": "Snapshot Month", + "hierarchy": { + "segment": "Borrower Industry Name", + "rep": "Risk Grade Name", + "cadence": "Region Name" + } + }, + "logo": { + "svgPath": "vantage-logo.svg" + }, + "favicon": "", + "theme": { + "default": "light", + "light": { + "--sl-cream": "#f4f7fb", + "--sl-cream-2": "#e9f0f7", + "--sl-white": "#ffffff", + "--sl-evergreen": "#12283f", + "--sl-evergreen-2": "#0c1d30", + "--sl-green": "#12283f", + "--sl-green-dark": "#1b3f63", + "--sl-primary-rgb": "18, 40, 63", + "--intime-blue": "#2f6fb0", + "--sl-gold": "#f4d14e", + "--sl-gold-ink": "#6b5410", + "--intime-grad": "linear-gradient(90deg, #12283f 0%, #2f6fb0 100%)", + "--sl-mint": "#e6f1fb", + "--sl-mint-2": "#d5e8f8", + "--sl-coral": "#a2191f", + "--sl-warn": "#fffbe6", + "--sl-warn-ink": "#6b5410", + "--sl-ink": "#12283f", + "--sl-muted": "#5a6a7e", + "--sl-border": "#e1e8f0", + "--sl-border-soft": "#eef2f7", + "--font-serif": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif", + "--font-sans": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif", + "--topbar-h": "66px", + "--shadow-card": "0 1px 2px rgba(15, 23, 42, 0.05), 0 8px 24px rgba(15, 23, 42, 0.07)", + "--shadow-lift": "0 6px 14px rgba(15, 23, 42, 0.08), 0 18px 40px rgba(15, 23, 42, 0.12)", + "--brand-navy": "#12283f", + "--brand-accent": "#2f6fb0" + }, + "dark": { + "--sl-cream": "#0a1a2b", + "--sl-cream-2": "#0e2236", + "--sl-white": "#122c44", + "--sl-evergreen": "#eef4fa", + "--sl-evergreen-2": "#dbe6f0", + "--sl-green": "#7fb0e0", + "--sl-green-dark": "#5e97cc", + "--sl-primary-rgb": "127, 176, 224", + "--intime-blue": "#7fb0e0", + "--sl-gold": "#f4d14e", + "--sl-gold-ink": "#0a1a2b", + "--intime-grad": "linear-gradient(90deg, #7fb0e0 0%, #bfe0f5 100%)", + "--sl-mint": "#10293d", + "--sl-mint-2": "#16344c", + "--sl-coral": "#f0616a", + "--sl-warn": "#2a2413", + "--sl-warn-ink": "#f4d14e", + "--sl-ink": "#eaf1f7", + "--sl-muted": "#9fb2c6", + "--sl-border": "#23415a", + "--sl-border-soft": "#172f44", + "--font-serif": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif", + "--font-sans": "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif", + "--topbar-h": "66px", + "--shadow-card": "0 1px 2px rgba(0, 0, 0, 0.5), 0 8px 24px rgba(0, 0, 0, 0.55)", + "--shadow-lift": "0 6px 14px rgba(0, 0, 0, 0.6), 0 18px 40px rgba(0, 0, 0, 0.65)", + "--brand-navy": "#12283f", + "--brand-accent": "#2f6fb0" + } + }, + "embedSwaps": [ + ["#4F5BD5", "#2f6fb0"], + ["#3F49B8", "#285f97"], + ["#333C99", "#22527f"], + ["#22B8CF", "#7fb0e0"], + ["#0b0d10", "#0a1a2b"], + ["#14181f", "#122c44"], + ["#1a212b", "#16344c"], + ["#232a34", "#23415a"], + ["#0b1f19", "#0e2236"], + ["rgba(45,164,191,0.14)", "rgba(127,176,224,0.16)"] + ], + "greenSwaps": [ + ["#0f9a63", "#16344c"], + ["#0c8554", "#122c44"], + ["#d9ecea", "#d5e8f8"], + ["#cfe3e2", "#c2ddf4"], + ["#1bb978", "#7fb0e0"], + ["#15ae6e", "#12283f"] + ], + "cssSwaps": [ + ["#0e1116", "#0e2236"], + ["#10222a", "#0e2236"], + ["#123047", "#122c44"] + ], + "features": { + "askMode": "both", + "inline": false, + "action": false, + "monetize": false, + "tiers": false, + "pinning": false + }, + "content": { + "company": "Vantage Capital", + "aiName": "Vantage AI", + "website": "https://www.vantagecapital.example.com", + "loginTitleHtml": "Credit risk,
seen clearly.", + "loginSubtitle": "Your portfolio command center for commercial lending — outstanding balances, delinquency, charge-offs and provision coverage, with AI-powered answers on live data. Built on Vantage Capital, powered by ThoughtSpot.", + "loginStats": [ + { "value": "$4.2B", "label": "Portfolio Managed" }, + { "value": "7", "label": "Risk Grades Tracked" }, + { "value": "Real-time", "label": "Delinquency Intel" } + ], + "loginCardSubtitle": "Sign in with your ThoughtSpot credentials", + "loginDemoNoteHtml": "Sign in with your ThoughtSpot username & password for {host}. This origin must be CORS-allowlisted on that cluster for the embeds to load.", + "platformNav": [ + "Portfolio", + "Credit Risk", + "Reviews" + ], + "tabs": { + "myReports": "My Reports", + "analytics": "Portfolio Analytics", + "inline": "Industries", + "action": "Reviews", + "ask": "Ask Vantage AI", + "spotter": "Spotter" + }, + "analyticsSubtitle": "Your live portfolio, delinquency & credit-risk dashboard", + "spotterSubtitle": "Ask questions of your Vantage Capital portfolio & credit-risk data in natural language.", + "inline": { + "subtitleReady": "Balance & delinquency by industry — select an industry to view its insights", + "subtitleIdle": "Balance and delinquency across borrower industries", + "loading": "Loading industries…", + "errorTitle": "Couldn't load industries", + "errorMsg": "Unable to load industry data.", + "moreLabel": "View insights", + "hideLabel": "Hide insights" + }, + "filters": { + "primaryLabel": "Industry", + "secondaryLabel": "Risk Grade" + }, + "sampleQuestions": [ + "Delinquency rate by borrower industry", + "Outstanding balance by risk grade", + "Charge off rate by region over time", + "Provision coverage ratio by product family" + ], + "askWelcome": "Ask me anything about Vantage Capital — portfolio exposure, delinquency, charge-offs, credit reviews — or ask about your own data and I will chart it live.", + "askEmptySub": "Ask any analytical question about your loan portfolio, delinquency, risk grades, and credit reviews. Answers come straight from your live data with an interactive chart you can drill into.", + "action": { + "label": "Flag for Review", + "subtitlePrefix": "Loan exposures by industry and grade. Right-click a row and choose", + "subtitleSuffix": "to open a credit review pre-filled for that exposure.", + "modalTitle": "New Credit Review", + "modalLead": "Pre-filled for the selected exposure. Review and submit to open a credit review in Vantage.", + "submitLabel": "Submit", + "successTitle": "Credit review opened" + }, + "chatbot": { + "welcome": "Hi! I'm Vantage AI. Ask me about portfolio exposure, delinquency and credit risk, or ask a question about your data.", + "overview": "Vantage Capital is a commercial lender. This portal covers portfolio and credit-risk analytics — outstanding balances, exposure at default, delinquency and charge-off rates, provision coverage, and credit review activity — with AI-powered answers on live portfolio data.", + "greetingExample": "delinquency rate by industry", + "examples": [ + "outstanding balance by risk grade", + "charge off rate by region" + ] + }, + "trialQuestionTrigger": 3, + "upgradeReason": "Upgrade to Vantage Premium to unlock drill-down, Ask AI and downloads.", + "analyticsKeywords": [ + "delinquency", + "balance", + "exposure", + "charge-off", + "provision", + "risk", + "grade", + "industry", + "region", + "loan", + "portfolio", + "review" + ] + } +} diff --git a/demoprep_app/portal/template-tse/.gitignore b/demoprep_app/portal/template-tse/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..b32f4012aad7d62ea6486784510669ac4aa898f1 --- /dev/null +++ b/demoprep_app/portal/template-tse/.gitignore @@ -0,0 +1,20 @@ +node_modules/ +dist/ +.DS_Store +*.local +.env +.env.* +!.env.example +.vercel/ + +# Large local recordings +*.mov + +# Unrelated demos / local tooling kept in this working dir (repo = Salesloft app only) +clari/ +hirevue/ +hivewatch/ +simfoni/ +output/ +.vercel +.env* diff --git a/demoprep_app/portal/template-tse/README.md b/demoprep_app/portal/template-tse/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a6e4387b643eb087f2b73336349c8b17b14a1a2b --- /dev/null +++ b/demoprep_app/portal/template-tse/README.md @@ -0,0 +1,44 @@ +# Acme × ThoughtSpot — Embedded Analytics Portal + +A demo revenue-intelligence portal styled as an **Acme** product surface, with +ThoughtSpot embedded throughout via the +[Visual Embed SDK](https://developers.thoughtspot.com/docs). Built with Vite + +React + TypeScript. Deploys to Vercel. + +This is a rebranded twin of the Salesloft demo — identical functionality, theme, +and ThoughtSpot data; only the logo and the **Acme / Acme AI** branding differ. + +## What's in it + +| Tab | ThoughtSpot embed | Notes | +| --- | --- | --- | +| **My Reports** | — | Landing / overview | +| **Analytics** | `LiveboardEmbed` | Full liveboard with a **Rep** dropdown runtime filter (`HostEvent.UpdateRuntimeFilters`) | +| **Cadences** | — | Static view | +| **Signals** | `LiveboardEmbed` (single viz) | One visualization from the Analytics liveboard; right-click a row → **Re-engage cadence** custom action opens a Win-Back Cadence modal | +| **Ask Acme** | `SpotterEmbed` | "Acme AI" chat: general questions answered from a small FAQ/knowledge base, data questions routed to Spotter via `HostEvent.SpotterSearch`; playful trial-upgrade modal | + +## Getting started + +```bash +npm install +npm run dev # local dev server +npm run build # tsc + vite build -> dist/ +npm run preview # preview the production build +``` + +> If `node`/`npm` aren't on your PATH, they may live at `~/.node/bin`: +> `export PATH="$HOME/.node/bin:$PATH"`. + +## Configuration + +- **ThoughtSpot IDs & flags** live in [`src/config.ts`](src/config.ts). +- **ThoughtSpot host** and auth are set in [`src/lib/thoughtspot.ts`](src/lib/thoughtspot.ts). +- **Optional LLM routing** for Ask Acme — copy `.env.example` to `.env` and set + `VITE_ANTHROPIC_API_KEY`. Without it, the chatbot falls back to a keyword + router + FAQ and still routes data questions to Spotter. + +## Deploy + +Configured for Vercel ([`vercel.json`](vercel.json)) — framework `vite`, +output `dist/`, SPA rewrite to `/index.html`. diff --git a/demoprep_app/portal/template-tse/index.html b/demoprep_app/portal/template-tse/index.html new file mode 100644 index 0000000000000000000000000000000000000000..7afdc7d7bca2c440b381e5f7bc515dfa38195a9d --- /dev/null +++ b/demoprep_app/portal/template-tse/index.html @@ -0,0 +1,19 @@ + + + + + + + Northwind Analytics + + + + + +
+ + + diff --git a/demoprep_app/portal/template-tse/package-lock.json b/demoprep_app/portal/template-tse/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..67ff76bf3b2b1a0ab5ac4744e810ac5fb0cb1d9d --- /dev/null +++ b/demoprep_app/portal/template-tse/package-lock.json @@ -0,0 +1,1934 @@ +{ + "name": "northwind-thoughtspot-portal", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "northwind-thoughtspot-portal", + "version": "1.0.0", + "dependencies": { + "@thoughtspot/visual-embed-sdk": "^1.37.0", + "@vercel/analytics": "^2.0.1", + "lucide-react": "^0.300.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.0" + }, + "devDependencies": { + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.0.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@thoughtspot/visual-embed-sdk": { + "version": "1.47.3", + "resolved": "https://registry.npmjs.org/@thoughtspot/visual-embed-sdk/-/visual-embed-sdk-1.47.3.tgz", + "integrity": "sha512-PJ00KD6hmC3J83Z6iLnFIcpHg9zGFRXn1EP0Vrz/2peR7WSzZbhqLDEtIJs4Kd6Ly5yQuve7XNF4LBj6EtNclA==", + "license": "ThoughtSpot Development Tools End User License Agreement", + "dependencies": { + "classnames": "^2.3.1", + "eventemitter3": "^4.0.7", + "lodash": "^4.18.1", + "mixpanel-browser": "2.47.0", + "ts-deepmerge": "^6.0.2", + "tslib": "^2.5.3", + "use-deep-compare-effect": "^1.8.1", + "yaml": "^2.5.1" + }, + "peerDependencies": { + "react": "> 16.8.0", + "react-dom": "> 16.8.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vercel/analytics": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz", + "integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==", + "license": "MIT", + "peerDependencies": { + "@remix-run/react": "^2", + "@sveltejs/kit": "^1 || ^2", + "next": ">= 13", + "nuxt": ">= 3", + "react": "^18 || ^19 || ^19.0.0-rc", + "svelte": ">= 4", + "vue": "^3", + "vue-router": "^4" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "nuxt": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + } + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz", + "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.300.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.300.0.tgz", + "integrity": "sha512-rQxUUCmWAvNLoAsMZ5j04b2+OJv6UuNLYMY7VK0eVlm4aTwUEjEEHc09/DipkNIlhXUSDn2xoyIzVT0uh7dRsg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/mixpanel-browser": { + "version": "2.47.0", + "resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.47.0.tgz", + "integrity": "sha512-Ldrva0fRBEIFWmEibBQO1PulfpJVF3pf28Guk09lDirDaSQqqU/xs9zQLwN2rL5VwVtsP1aD3JaCgaa98EjojQ==", + "license": "Apache-2.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ts-deepmerge": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-6.2.1.tgz", + "integrity": "sha512-8CYSLazCyj0DJDpPIxOFzJG46r93uh6EynYjuey+bxcLltBeqZL7DMfaE5ZPzZNFlav7wx+2TDa/mBl8gkTYzw==", + "license": "ISC", + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-deep-compare-effect": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/use-deep-compare-effect/-/use-deep-compare-effect-1.8.1.tgz", + "integrity": "sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "dequal": "^2.0.2" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/demoprep_app/portal/template-tse/package.json b/demoprep_app/portal/template-tse/package.json new file mode 100644 index 0000000000000000000000000000000000000000..25ac61de9aed82082a5bc8acc73cea8ebaa59483 --- /dev/null +++ b/demoprep_app/portal/template-tse/package.json @@ -0,0 +1,26 @@ +{ + "name": "northwind-thoughtspot-portal", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@thoughtspot/visual-embed-sdk": "^1.37.0", + "@vercel/analytics": "^2.0.1", + "lucide-react": "^0.300.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.0" + }, + "devDependencies": { + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.0.10" + } +} diff --git a/demoprep_app/portal/template-tse/public/northwind-icon.svg b/demoprep_app/portal/template-tse/public/northwind-icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..9531a96a7e67d4299375dbdfb75f96ab8af0fd0c --- /dev/null +++ b/demoprep_app/portal/template-tse/public/northwind-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/demoprep_app/portal/template-tse/public/skyscanner-icon.svg b/demoprep_app/portal/template-tse/public/skyscanner-icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..e6b73e6595400538734dd058b417858e51da481b --- /dev/null +++ b/demoprep_app/portal/template-tse/public/skyscanner-icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/demoprep_app/portal/template-tse/src/App.tsx b/demoprep_app/portal/template-tse/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..08235de0a096d229b55d7a082cfce2c1f272ca3d --- /dev/null +++ b/demoprep_app/portal/template-tse/src/App.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { Analytics as VercelAnalytics } from '@vercel/analytics/react'; +import { useAuth } from './context/AuthContext'; +import Login from './pages/Login'; +import TopBar, { TabId } from './components/TopBar'; +import ChatBot from './components/ChatBot'; +import MyAnalytics from './tabs/MyAnalytics'; +import Analytics from './tabs/Analytics'; +import Carriers from './tabs/Carriers'; +import Capacity from './tabs/Capacity'; +import AskNorthwind from './tabs/AskNorthwind'; +import SpotterTab from './tabs/Spotter'; +import { + WORKSHEET_ID, + CADENCE_WORKSHEET_ID, + CHATBOT_WELCOME, + CHATBOT_CADENCES_WELCOME, +} from './config'; + +// Persist the active tab so a reload restores it instead of resetting to default. +const TAB_KEY = 'northwind.tab'; + +export default function App() { + const { isAuthenticated } = useAuth(); + const [tab, setTab] = useState( + () => (sessionStorage.getItem(TAB_KEY) as TabId) || 'analytics', + ); + + const changeTab = (next: TabId) => { + setTab(next); + try { + sessionStorage.setItem(TAB_KEY, next); + } catch { + /* storage unavailable */ + } + }; + + if (!isAuthenticated) { + return ; + } + + return ( +
+ +
+ {tab === 'my-analytics' && } + {tab === 'analytics' && } + {tab === 'carriers' && } + {tab === 'capacity' && } + {tab === 'ask' && } + {tab === 'spotter' && } +
+ + +
+ ); +} diff --git a/demoprep_app/portal/template-tse/src/assets/northwind-logo.svg b/demoprep_app/portal/template-tse/src/assets/northwind-logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..619d836467daa48f3173b97071c549149a00665d --- /dev/null +++ b/demoprep_app/portal/template-tse/src/assets/northwind-logo.svg @@ -0,0 +1,5 @@ + + + + Northwind + diff --git a/demoprep_app/portal/template-tse/src/components/BidModal.tsx b/demoprep_app/portal/template-tse/src/components/BidModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8efa7c3865ff5a7ccf3473ab7ceaef72dd2fae30 --- /dev/null +++ b/demoprep_app/portal/template-tse/src/components/BidModal.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from 'react'; +import { X, Gavel, Check, Send } from 'lucide-react'; +import { SignalContext } from '../lib/signalContext'; +import { CONTENT } from '../content'; + +interface Props { + open: boolean; + context: SignalContext | null; + onClose: () => void; +} + +// Pull a value out of the clicked-row fields by matching the column name. +function pick(ctx: SignalContext | null, re: RegExp): string { + const f = ctx?.fields.find((x) => re.test(x.name) && x.value !== '—'); + return f ? f.value : ''; +} + +export default function BidModal({ open, context, onClose }: Props) { + const [corridor, setCorridor] = useState(''); + const [carrier, setCarrier] = useState(''); + const [equipment, setEquipment] = useState('Dry Van'); + const [targetRate, setTargetRate] = useState(''); + const [notes, setNotes] = useState(''); + const [submitted, setSubmitted] = useState(false); + + // Pre-fill from the row the user right-clicked whenever a new bid is opened. + useEffect(() => { + if (!context) return; + setCorridor(pick(context, /corridor|lane|origin|destination/i) || ''); + setCarrier(pick(context, /carrier/i) || ''); + const rate = pick(context, /rate|price|\$|cost|spend/i); + setTargetRate(rate ? rate.replace(/[^0-9.]/g, '') : ''); + setNotes(''); + setSubmitted(false); + }, [context]); + + if (!open || !context) return null; + + // Everything else from the row, shown as read-only context chips. + const otherFields = context.fields.filter( + (f) => !/corridor|lane|carrier|rate|price|cost/i.test(f.name) && f.value !== '—', + ); + + return ( +
+
e.stopPropagation()}> +
+
+ + {CONTENT.action.modalTitle} +
+ +
+ + {submitted ? ( +
+
+ +
+

Bid request submitted

+

+ Your bid for{' '} + {corridor || 'this lane'} + {carrier ? <> to {carrier} : null} has been sent to the + Northwind capacity desk. +

+ +
+ ) : ( +
+

+ Pre-filled from the selected lane. Review and submit to request a carrier + bid through Northwind. +

+ +
+ + + + +
+ + {otherFields.length > 0 && ( +
+ From this lane +
+ {otherFields.slice(0, 6).map((f) => ( + + {f.name} {f.value} + + ))} +
+
+ )} + +