Spaces:
Running
fix(model): galaxy joins preserved, DIM_/FACT_ naming banned, keys hidden, agg defaults
Browse filesFour systemic model-quality fixes from the TRI_08190447_DXO review:
- _remove_diamond_joins(): directed-reachability pruning replaces the
undirected spanning tree that capped every model at N-1 joins and
silently dropped legitimate fact->dim joins in multi-fact schemas.
Only true diamonds (two paths between the same pair) and directed
cycles are removed now.
- Naming: blueprint prompt rule 2a + _table_ident() strip DIM_/FACT_
prefixes from table names (even when the user request includes them);
deployer strips them from display names, and collisions resolve to
"Broker Key (Broker Payment)" instead of "Dim Dim Broker Key" /
"fact_Fact ..." / "_2".
- Surrogate/FK key columns get is_hidden: true in model TML (present
for joins, invisible in search/Spotter).
- _determine_column_type(): token-based matching β INVOICES_PAID is a
measure again (endswith-'ID' trap), CORPORATE no longer matches RATE,
and RATIO/PCT/DAYS_TO_* default to AVERAGE instead of SUM.
Verified against the exact TRI structure: scratch/verify_fixes.py, 25/25.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- demoprep_app/scenario/blueprint.py +19 -3
- demoprep_app/scenario/blueprint_author.py +10 -6
- sprint_2026_04.md +7 -0
- thoughtspot_deployer.py +127 -112
|
@@ -213,7 +213,7 @@ class DemoBlueprint:
|
|
| 213 |
performance=float(v.get("performance", 1.0) or 1.0),
|
| 214 |
))
|
| 215 |
dimensions.append(BlueprintDimension(
|
| 216 |
-
name=
|
| 217 |
description=str(d.get("description") or ""),
|
| 218 |
attribute_columns=[_ident(c) for c in (d.get("attribute_columns") or [])],
|
| 219 |
values=[v for v in values if v.name],
|
|
@@ -248,9 +248,9 @@ class DemoBlueprint:
|
|
| 248 |
per=_ident(m.get("per") or m.get("per_entity") or ""),
|
| 249 |
description=str(m.get("description") or ""),
|
| 250 |
))
|
| 251 |
-
requested_dims = [
|
| 252 |
facts.append(FactSpec(
|
| 253 |
-
name=
|
| 254 |
grain=str(f.get("grain") or ""),
|
| 255 |
dimension_names=requested_dims,
|
| 256 |
measures=measures,
|
|
@@ -586,6 +586,22 @@ def _ident(text: Any) -> str:
|
|
| 586 |
return cleaned[:60]
|
| 587 |
|
| 588 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
def _looks_generic(name: str) -> bool:
|
| 590 |
"""Detect placeholder values like 'Product 3' or 'Category A'."""
|
| 591 |
import re
|
|
|
|
| 213 |
performance=float(v.get("performance", 1.0) or 1.0),
|
| 214 |
))
|
| 215 |
dimensions.append(BlueprintDimension(
|
| 216 |
+
name=_table_ident(d.get("name", "")),
|
| 217 |
description=str(d.get("description") or ""),
|
| 218 |
attribute_columns=[_ident(c) for c in (d.get("attribute_columns") or [])],
|
| 219 |
values=[v for v in values if v.name],
|
|
|
|
| 248 |
per=_ident(m.get("per") or m.get("per_entity") or ""),
|
| 249 |
description=str(m.get("description") or ""),
|
| 250 |
))
|
| 251 |
+
requested_dims = [_table_ident(n) for n in (f.get("dimension_names") or dim_names)]
|
| 252 |
facts.append(FactSpec(
|
| 253 |
+
name=_table_ident(f.get("name", "FACT")),
|
| 254 |
grain=str(f.get("grain") or ""),
|
| 255 |
dimension_names=requested_dims,
|
| 256 |
measures=measures,
|
|
|
|
| 586 |
return cleaned[:60]
|
| 587 |
|
| 588 |
|
| 589 |
+
def _table_ident(text: Any) -> str:
|
| 590 |
+
"""Normalize a TABLE name: SQL-safe UPPER_SNAKE with no DIM_/FACT_ prefix.
|
| 591 |
+
|
| 592 |
+
We never use warehouse-style prefixes β BROKER vs LOAD_TRANSACTIONS reads
|
| 593 |
+
fine to data people, and the prefixes cascade into ugly derived column
|
| 594 |
+
names (DIM_BROKER_KEY -> "Dim Broker Key"). If the user's request named
|
| 595 |
+
tables WITH prefixes, directives matching already strips them too, so the
|
| 596 |
+
stripped blueprint still satisfies the directive.
|
| 597 |
+
"""
|
| 598 |
+
name = _ident(text)
|
| 599 |
+
for prefix in ("DIM_", "FACT_"):
|
| 600 |
+
if name.startswith(prefix) and len(name) > len(prefix):
|
| 601 |
+
return name[len(prefix):]
|
| 602 |
+
return name
|
| 603 |
+
|
| 604 |
+
|
| 605 |
def _looks_generic(name: str) -> bool:
|
| 606 |
"""Detect placeholder values like 'Product 3' or 'Category A'."""
|
| 607 |
import re
|
|
@@ -99,11 +99,11 @@ EXAMPLE_RETAIL = {
|
|
| 99 |
# about the company's actual business (selling data products), not the
|
| 100 |
# generic operations of its industry.
|
| 101 |
EXAMPLE_DATA_PRODUCTS = {
|
| 102 |
-
"notes": "User explicitly requested DIM_CUSTOMER_ACCOUNT, DIM_DATA_PRODUCT, FACT_PRODUCT_USAGE β all
|
| 103 |
"date": {"grain": "month", "months_of_history": 24, "include_date_dimension": False},
|
| 104 |
"dimensions": [
|
| 105 |
{
|
| 106 |
-
"name": "
|
| 107 |
"attribute_columns": ["CUSTOMER_TYPE", "CONTRACT_TIER"],
|
| 108 |
"values": [
|
| 109 |
{"name": "Meridian Pharma Insights", "attributes": {"CUSTOMER_TYPE": "Pharmaceutical", "CONTRACT_TIER": "Enterprise"}, "performance": 1.5},
|
|
@@ -113,7 +113,7 @@ EXAMPLE_DATA_PRODUCTS = {
|
|
| 113 |
],
|
| 114 |
},
|
| 115 |
{
|
| 116 |
-
"name": "
|
| 117 |
"attribute_columns": ["PRODUCT_FAMILY"],
|
| 118 |
"values": [
|
| 119 |
{"name": "Open Claims Feed", "attributes": {"PRODUCT_FAMILY": "Patient Data"}, "mix_weight": 1.6},
|
|
@@ -125,9 +125,9 @@ EXAMPLE_DATA_PRODUCTS = {
|
|
| 125 |
],
|
| 126 |
"facts": [
|
| 127 |
{
|
| 128 |
-
"name": "
|
| 129 |
"grain": "one row per customer per data product per month",
|
| 130 |
-
"dimension_names": ["
|
| 131 |
"date_column": "MONTH_DATE",
|
| 132 |
"measures": [
|
| 133 |
{"name": "QUERIES_RUN", "kind": "base", "distribution": "lognormal", "params": {"mean": 4200, "sigma": 0.7}, "fmt": "int"},
|
|
@@ -147,7 +147,7 @@ EXAMPLE_DATA_PRODUCTS = {
|
|
| 147 |
"id": "nlq_adoption",
|
| 148 |
"headline": "Natural-language querying took off after the embedded relaunch",
|
| 149 |
"insight_type": "trend_break",
|
| 150 |
-
"dimension": "
|
| 151 |
"measure": "NL_QUESTIONS", "magnitude": 1.6, "window_start": "2026-01-01",
|
| 152 |
"spotter_question": "Show natural language questions by customer account by month",
|
| 153 |
"expected_finding": "Meridian's NLQ volume breaks upward from January 2026",
|
|
@@ -198,6 +198,10 @@ and grounded in this company's actual world.
|
|
| 198 |
2. Star schema: 2-6 dimensions and 1-3 fact tables. Multiple facts may share dimensions
|
| 199 |
(e.g. a usage fact and a journey fact both keyed to customers and products). Every
|
| 200 |
measure name must be unique across all facts.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
3. Base measures get a distribution (lognormal/normal/uniform) with realistic params for a
|
| 202 |
company of this size. Derived measures get arithmetic formulas over measures in the
|
| 203 |
same fact.
|
|
|
|
| 99 |
# about the company's actual business (selling data products), not the
|
| 100 |
# generic operations of its industry.
|
| 101 |
EXAMPLE_DATA_PRODUCTS = {
|
| 102 |
+
"notes": "User explicitly requested DIM_CUSTOMER_ACCOUNT, DIM_DATA_PRODUCT, FACT_PRODUCT_USAGE β all honored, with the DIM_/FACT_ prefixes dropped per our naming convention.",
|
| 103 |
"date": {"grain": "month", "months_of_history": 24, "include_date_dimension": False},
|
| 104 |
"dimensions": [
|
| 105 |
{
|
| 106 |
+
"name": "CUSTOMER_ACCOUNT",
|
| 107 |
"attribute_columns": ["CUSTOMER_TYPE", "CONTRACT_TIER"],
|
| 108 |
"values": [
|
| 109 |
{"name": "Meridian Pharma Insights", "attributes": {"CUSTOMER_TYPE": "Pharmaceutical", "CONTRACT_TIER": "Enterprise"}, "performance": 1.5},
|
|
|
|
| 113 |
],
|
| 114 |
},
|
| 115 |
{
|
| 116 |
+
"name": "DATA_PRODUCT",
|
| 117 |
"attribute_columns": ["PRODUCT_FAMILY"],
|
| 118 |
"values": [
|
| 119 |
{"name": "Open Claims Feed", "attributes": {"PRODUCT_FAMILY": "Patient Data"}, "mix_weight": 1.6},
|
|
|
|
| 125 |
],
|
| 126 |
"facts": [
|
| 127 |
{
|
| 128 |
+
"name": "PRODUCT_USAGE",
|
| 129 |
"grain": "one row per customer per data product per month",
|
| 130 |
+
"dimension_names": ["CUSTOMER_ACCOUNT", "DATA_PRODUCT"],
|
| 131 |
"date_column": "MONTH_DATE",
|
| 132 |
"measures": [
|
| 133 |
{"name": "QUERIES_RUN", "kind": "base", "distribution": "lognormal", "params": {"mean": 4200, "sigma": 0.7}, "fmt": "int"},
|
|
|
|
| 147 |
"id": "nlq_adoption",
|
| 148 |
"headline": "Natural-language querying took off after the embedded relaunch",
|
| 149 |
"insight_type": "trend_break",
|
| 150 |
+
"dimension": "CUSTOMER_ACCOUNT", "dimension_value": "Meridian Pharma Insights",
|
| 151 |
"measure": "NL_QUESTIONS", "magnitude": 1.6, "window_start": "2026-01-01",
|
| 152 |
"spotter_question": "Show natural language questions by customer account by month",
|
| 153 |
"expected_finding": "Meridian's NLQ volume breaks upward from January 2026",
|
|
|
|
| 198 |
2. Star schema: 2-6 dimensions and 1-3 fact tables. Multiple facts may share dimensions
|
| 199 |
(e.g. a usage fact and a journey fact both keyed to customers and products). Every
|
| 200 |
measure name must be unique across all facts.
|
| 201 |
+
2a. Table names are plain UPPER_SNAKE business nouns β NEVER prefixed with DIM_ or FACT_.
|
| 202 |
+
BROKER vs LOAD_TRANSACTIONS reads fine to data people; the prefixes cascade into ugly
|
| 203 |
+
derived column names. If the user's request names tables with DIM_/FACT_ prefixes,
|
| 204 |
+
honor the tables but drop the prefixes (DIM_CUSTOMER_ACCOUNT -> CUSTOMER_ACCOUNT).
|
| 205 |
3. Base measures get a distribution (lognormal/normal/uniform) with realistic params for a
|
| 206 |
company of this size. Derived measures get arithmetic formulas over measures in the
|
| 207 |
same fact.
|
|
@@ -256,6 +256,13 @@ should tell. KPI targets, growth trends, and anomaly patterns live in the matrix
|
|
| 256 |
|
| 257 |
### Sprint 4 (this sprint)
|
| 258 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
- [x] **Table-create latency root cause + fix (Aug 10)** β ~250s/import was the unscoped TS connection scanning all ~507 Snowflake DBs visible to SE_ROLE (measured identical on sebe AND secloud; NOT instance load, July SRE docs corrected). Fix: (1) connection TML now scoped via required `database` property (`create_connection_tml`), (2) demos write to monthly-rotating DB `<SNOWFLAKE_DATABASE>_<YYYY_MM>` (`get_demo_database()`/`ensure_demo_database()` in `snowflake_auth.py`) since bloated DEMOBUILD (1,003 schemas) still cost 33s vs 0.7s fresh. Probe: `tests/ts_table_create_deepdive.py` β
|
| 260 |
- [x] **Perf fix verified + deployed (Aug 10)** β app e2e: 655s Grade B, 0 timeouts (pre-fix record: 1,084β2,711s, 12/24 timeouts). Deployed to `hf-test` AND MCP Space `thoughtspot-demoprep/mcp` (`df44611`, rollback `3d735c7`); MCP Tixr rehearsal on new code: PASS, 9.2 min vs 16β20 min. Full research notes: `docs/build_time_deep_dive_2026_08_10.md` β
|
| 261 |
- [x] **CHECKOUT_* column-eating bug found + fixed (Aug 10)** β the CREATE TABLE CHECK-constraint sanitizer (`cdw_connector._sanitize_create_table_statement`) used prefix matching, silently deleting any column named CHECK* (CHECKOUT_INITIATED, CHECKOUT_ATTEMPTS...) while the dataset still carried values β the "random" schema/data-mismatch failures (Cloudscape, Atlas Freight, Tixr 02ec3e7). Fixed with keyword+paren matching, 4 regression tests, proven end-to-end into Snowflake. Deployed to MCP Space (`bd28045`); Tixr rehearsal PASS 10.0 min β
|
|
|
|
| 256 |
|
| 257 |
### Sprint 4 (this sprint)
|
| 258 |
|
| 259 |
+
- [x] **Model-quality fixes from TRI_08190447_DXO review (Aug 24)** β investigated mani's Triumph model (sebe); found four systemic pipeline defects and fixed all in code (NOT applied to the existing TRI objects β that demo stays as-is per boone):
|
| 260 |
+
- **Join spanning-tree bug**: `_remove_diamond_joins()` reduced every model to Nβ1 joins via undirected union-find, silently dropping legitimate factβdim joins in galaxy schemas (TRI lost FACT_BROKER_PAYMENTβDIM_BROKER and FACT_CARRIER_CAPACITYβDIM_LANE β payment metrics unsliceable by broker). Rewritten to directed-reachability: only prunes true diamonds (two paths between same pair) and directed cycles; shared conformed dims are kept β
|
| 261 |
+
- **DIM_/FACT_ naming banned end-to-end**: blueprint prompt rule 2a + `_table_ident()` normalization in `blueprint.py` (strips prefixes even when the user request includes them; directives matching already prefix-tolerant); Example B updated. Deployer `_strip_dim_fact_prefix()` strips prefixes from display names; conflict resolution now gives the home table the clean name ("Broker Key") and others a readable suffix ("Broker Key (Broker Payment)") β no more "Dim Dim Broker Key" / "fact_Fact β¦" / "_2" β
|
| 262 |
+
- **Key columns hidden**: surrogate PKs + FKs get `is_hidden: true` in model TML (still present for joins) β
|
| 263 |
+
- **Column classification**: token-based matching in `_determine_column_type()` β INVOICES_PAID no longer an ATTRIBUTE (endswith-'ID' trap), CORPORATE/GENERATED no longer match 'RATE'; RATIO/PCT/DAYS_TO_* now AVERAGE instead of SUM β
|
| 264 |
+
- Verified offline against the exact TRI structure: `scratch/verify_fixes.py` β 25/25 pass. Repaired TRI TMLs staged in scratch for reference only (`model_tri_fixed.tml.yml`, `lb_*_fixed.tml.yml`), not imported.
|
| 265 |
+
|
| 266 |
- [x] **Table-create latency root cause + fix (Aug 10)** β ~250s/import was the unscoped TS connection scanning all ~507 Snowflake DBs visible to SE_ROLE (measured identical on sebe AND secloud; NOT instance load, July SRE docs corrected). Fix: (1) connection TML now scoped via required `database` property (`create_connection_tml`), (2) demos write to monthly-rotating DB `<SNOWFLAKE_DATABASE>_<YYYY_MM>` (`get_demo_database()`/`ensure_demo_database()` in `snowflake_auth.py`) since bloated DEMOBUILD (1,003 schemas) still cost 33s vs 0.7s fresh. Probe: `tests/ts_table_create_deepdive.py` β
|
| 267 |
- [x] **Perf fix verified + deployed (Aug 10)** β app e2e: 655s Grade B, 0 timeouts (pre-fix record: 1,084β2,711s, 12/24 timeouts). Deployed to `hf-test` AND MCP Space `thoughtspot-demoprep/mcp` (`df44611`, rollback `3d735c7`); MCP Tixr rehearsal on new code: PASS, 9.2 min vs 16β20 min. Full research notes: `docs/build_time_deep_dive_2026_08_10.md` β
|
| 268 |
- [x] **CHECKOUT_* column-eating bug found + fixed (Aug 10)** β the CREATE TABLE CHECK-constraint sanitizer (`cdw_connector._sanitize_create_table_statement`) used prefix matching, silently deleting any column named CHECK* (CHECKOUT_INITIATED, CHECKOUT_ATTEMPTS...) while the dataset still carried values β the "random" schema/data-mismatch failures (Cloudscape, Atlas Freight, Tixr 02ec3e7). Fixed with keyword+paren matching, 4 regression tests, proven end-to-end into Snowflake. Deployed to MCP Space (`bd28045`); Tixr rehearsal PASS 10.0 min β
|
|
@@ -133,6 +133,19 @@ def _to_snake_case(name: str) -> str:
|
|
| 133 |
return _apply_naming_style(name, "snake_case")
|
| 134 |
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
def _infer_liveboard_context_from_custom_request(text: str) -> Tuple[str, str]:
|
| 137 |
"""Infer a coarse vertical/function label for custom liveboard question generation."""
|
| 138 |
normalized = (text or "").lower()
|
|
@@ -1236,23 +1249,19 @@ class ThoughtSpotDeployer:
|
|
| 1236 |
original_name=original_col_name
|
| 1237 |
)
|
| 1238 |
|
| 1239 |
-
# If the display name is still used,
|
|
|
|
| 1240 |
original_display_name = display_name
|
| 1241 |
-
counter =
|
| 1242 |
while display_name.lower() in used_display_names:
|
| 1243 |
-
|
| 1244 |
-
|
| 1245 |
-
|
| 1246 |
-
|
| 1247 |
-
|
| 1248 |
-
else:
|
| 1249 |
-
prefix = table_name_upper[:4].lower()
|
| 1250 |
-
# Use snake_case: prefix_name
|
| 1251 |
-
display_name = f"{prefix}_{original_display_name}"
|
| 1252 |
else:
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
counter += 1
|
| 1256 |
|
| 1257 |
used_display_names.add(display_name.lower())
|
| 1258 |
|
|
@@ -1393,56 +1402,39 @@ class ThoughtSpotDeployer:
|
|
| 1393 |
|
| 1394 |
# Add columns with proper global conflict resolution (same as working version)
|
| 1395 |
used_display_names = set()
|
| 1396 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1397 |
for table_name, columns in tables.items():
|
| 1398 |
table_name_upper = table_name.upper()
|
| 1399 |
for col in columns:
|
| 1400 |
col_name = col['name'].upper()
|
| 1401 |
original_col_name = col.get('original_name', col['name']) # Use original casing for display
|
| 1402 |
|
| 1403 |
-
# NOTE: We used to skip FK/PK columns, but ThoughtSpot requires them for joins
|
| 1404 |
-
# Even though users don't search "customer 23455", the join columns must be present
|
| 1405 |
-
# in the model's columns section for the joins to work properly.
|
| 1406 |
-
#
|
| 1407 |
-
# SKIP foreign key columns - they're join keys, not analytics columns
|
| 1408 |
-
# if self._is_foreign_key_column(col_name, table_name_upper, foreign_keys):
|
| 1409 |
-
# print(f" βοΈ Skipping FK column: {table_name_upper}.{col_name}")
|
| 1410 |
-
# continue
|
| 1411 |
-
#
|
| 1412 |
-
# SKIP surrogate primary keys (numeric IDs) - nobody searches "customer 23455"
|
| 1413 |
-
# if self._is_surrogate_primary_key(col, col_name):
|
| 1414 |
-
# print(f" βοΈ Skipping surrogate PK: {table_name_upper}.{col_name}")
|
| 1415 |
-
# continue
|
| 1416 |
-
|
| 1417 |
# Start with basic conflict resolution
|
| 1418 |
display_name = self._resolve_column_name_conflict(
|
| 1419 |
col_name, table_name_upper, column_name_counts,
|
| 1420 |
original_name=original_col_name
|
| 1421 |
)
|
| 1422 |
|
| 1423 |
-
# If the display name is still used,
|
|
|
|
| 1424 |
original_display_name = display_name
|
| 1425 |
-
counter =
|
| 1426 |
while display_name.lower() in used_display_names:
|
| 1427 |
-
|
| 1428 |
-
|
| 1429 |
-
|
| 1430 |
-
|
| 1431 |
-
|
| 1432 |
-
display_name = f"prod_{original_display_name}"
|
| 1433 |
-
elif table_name_upper == 'ORDERS':
|
| 1434 |
-
display_name = f"order_{original_display_name}"
|
| 1435 |
-
elif table_name_upper == 'ORDERITEMS':
|
| 1436 |
-
display_name = f"item_{original_display_name}"
|
| 1437 |
-
elif table_name_upper == 'SALES':
|
| 1438 |
-
display_name = f"sale_{original_display_name}"
|
| 1439 |
-
elif table_name_upper == 'SALESREPS':
|
| 1440 |
-
display_name = f"rep_{original_display_name}"
|
| 1441 |
-
else:
|
| 1442 |
-
display_name = f"{table_name_upper[:4].lower()}_{original_display_name}"
|
| 1443 |
else:
|
| 1444 |
-
display_name = f"{original_display_name}
|
| 1445 |
-
|
| 1446 |
|
| 1447 |
used_display_names.add(display_name.lower())
|
| 1448 |
|
|
@@ -1461,6 +1453,11 @@ class ThoughtSpotDeployer:
|
|
| 1461 |
if aggregation:
|
| 1462 |
column_def['properties']['aggregation'] = aggregation
|
| 1463 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1464 |
# Add calendar property for DATE columns so ThoughtSpot enables
|
| 1465 |
# time bucketing (.weekly, .monthly, etc.) on them
|
| 1466 |
if self._map_data_type(col['type']) == 'DATE':
|
|
@@ -1487,12 +1484,19 @@ class ThoughtSpotDeployer:
|
|
| 1487 |
return yaml_output
|
| 1488 |
|
| 1489 |
def _remove_diamond_joins(self, model_tables: list):
|
| 1490 |
-
"""Remove
|
| 1491 |
-
|
| 1492 |
-
|
| 1493 |
-
|
| 1494 |
-
|
| 1495 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1496 |
"""
|
| 1497 |
def edge_key(src_name: str, join_def: dict):
|
| 1498 |
return (
|
|
@@ -1508,58 +1512,66 @@ class ThoughtSpotDeployer:
|
|
| 1508 |
src_name = t['name']
|
| 1509 |
for j in t.get('joins', []):
|
| 1510 |
all_edges.append((src_name, j.get('with'), j, edge_key(src_name, j)))
|
| 1511 |
-
|
| 1512 |
if not all_edges:
|
| 1513 |
print(f" β
No joins to check for cycles")
|
| 1514 |
return
|
| 1515 |
-
|
| 1516 |
out_degree = {}
|
| 1517 |
for t in model_tables:
|
| 1518 |
out_degree[t['name']] = len(t.get('joins', []))
|
| 1519 |
-
|
| 1520 |
in_degree = {t['name']: 0 for t in model_tables}
|
| 1521 |
for src, dst, _, _ in all_edges:
|
| 1522 |
in_degree[dst] = in_degree.get(dst, 0) + 1
|
| 1523 |
-
|
|
|
|
|
|
|
|
|
|
| 1524 |
all_edges.sort(key=lambda e: (-out_degree.get(e[0], 0), -in_degree.get(e[1], 0), e[0], e[1]))
|
| 1525 |
-
|
| 1526 |
-
|
| 1527 |
-
|
| 1528 |
-
|
| 1529 |
-
|
| 1530 |
-
x = parent[x]
|
| 1531 |
-
return x
|
| 1532 |
-
def union(a, b):
|
| 1533 |
-
ra, rb = find(a), find(b)
|
| 1534 |
-
if ra == rb:
|
| 1535 |
-
return False
|
| 1536 |
-
parent[ra] = rb
|
| 1537 |
-
return True
|
| 1538 |
-
|
| 1539 |
kept_edge_keys = set()
|
| 1540 |
removed = []
|
| 1541 |
for src, dst, join_def, e_key in all_edges:
|
| 1542 |
-
if
|
| 1543 |
-
kept_edge_keys.add(e_key)
|
| 1544 |
-
else:
|
| 1545 |
removed.append(f"{src}->{dst} ({join_def.get('on', '')})")
|
| 1546 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1547 |
for t in model_tables:
|
| 1548 |
if 'joins' not in t:
|
| 1549 |
continue
|
| 1550 |
src_name = t['name']
|
| 1551 |
t['joins'] = [j for j in t['joins'] if edge_key(src_name, j) in kept_edge_keys]
|
| 1552 |
-
|
| 1553 |
for t in model_tables:
|
| 1554 |
if 'joins' in t and not t['joins']:
|
| 1555 |
del t['joins']
|
| 1556 |
-
|
| 1557 |
if removed:
|
| 1558 |
-
print(f" πΆ Removed {len(removed)}
|
| 1559 |
for r in removed:
|
| 1560 |
print(f" - {r}")
|
| 1561 |
else:
|
| 1562 |
-
print(f" β
No join
|
| 1563 |
|
| 1564 |
def _generate_constraint_id(self) -> str:
|
| 1565 |
"""Generate a constraint ID similar to ThoughtSpot's system constraints"""
|
|
@@ -1619,33 +1631,26 @@ class ThoughtSpotDeployer:
|
|
| 1619 |
column_name_counts: Dict tracking column name occurrences
|
| 1620 |
original_name: Original casing of column name (for proper camelCase detection)
|
| 1621 |
"""
|
| 1622 |
-
# Use original name if provided (preserves camelCase boundaries)
|
| 1623 |
-
|
| 1624 |
-
|
|
|
|
| 1625 |
# Apply configured naming style
|
| 1626 |
styled_name = _apply_naming_style(name_for_styling, self.column_naming_style)
|
| 1627 |
-
|
| 1628 |
if len(column_name_counts.get(col_name, [])) <= 1:
|
| 1629 |
# No conflict - use styled name directly
|
| 1630 |
return styled_name
|
| 1631 |
-
|
| 1632 |
-
#
|
| 1633 |
-
|
| 1634 |
-
|
| 1635 |
-
|
| 1636 |
-
|
| 1637 |
-
|
| 1638 |
-
|
| 1639 |
-
|
| 1640 |
-
|
| 1641 |
-
elif 'order' in table_name.lower():
|
| 1642 |
-
prefix = 'order' # Common abbreviation
|
| 1643 |
-
else:
|
| 1644 |
-
prefix = table_name[:4].lower() # First 4 characters
|
| 1645 |
-
|
| 1646 |
-
# Apply naming style to prefix + name combination
|
| 1647 |
-
prefixed_name = f"{prefix}_{styled_name}" if styled_name else prefix
|
| 1648 |
-
return _apply_naming_style(prefixed_name, self.column_naming_style)
|
| 1649 |
|
| 1650 |
def _get_table_prefix(self, table_name: str) -> str:
|
| 1651 |
"""Get appropriate prefix for table to avoid column conflicts"""
|
|
@@ -1680,18 +1685,28 @@ class ThoughtSpotDeployer:
|
|
| 1680 |
|
| 1681 |
# Numeric types should be measures (unless they're IDs or keys)
|
| 1682 |
if base_type in ['NUMBER', 'DECIMAL', 'FLOAT', 'DOUBLE', 'INT', 'INTEGER', 'BIGINT']:
|
| 1683 |
-
# Skip ID/KEY columns - they're join keys, not analytics columns
|
| 1684 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1685 |
return 'ATTRIBUTE', None
|
| 1686 |
-
|
| 1687 |
-
# All other numeric columns are measures
|
| 1688 |
-
#
|
| 1689 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1690 |
return 'MEASURE', 'SUM'
|
| 1691 |
-
elif
|
| 1692 |
return 'MEASURE', 'SUM'
|
| 1693 |
-
elif any(word in col_upper for word in ['RATING', 'SCORE', 'MARGIN', 'PERCENT', 'RATE']):
|
| 1694 |
-
return 'MEASURE', 'AVERAGE'
|
| 1695 |
else:
|
| 1696 |
# Default: numeric = measure with SUM
|
| 1697 |
return 'MEASURE', 'SUM'
|
|
|
|
| 133 |
return _apply_naming_style(name, "snake_case")
|
| 134 |
|
| 135 |
|
| 136 |
+
def _strip_dim_fact_prefix(name: str) -> str:
|
| 137 |
+
"""Drop a leading DIM_/FACT_ token from a physical name for display purposes.
|
| 138 |
+
|
| 139 |
+
We never surface warehouse-style prefixes in ThoughtSpot column names:
|
| 140 |
+
DIM_BROKER_KEY -> BROKER_KEY, FACT_LOAD_TRANSACTION_KEY -> LOAD_TRANSACTION_KEY.
|
| 141 |
+
"""
|
| 142 |
+
upper = (name or "").upper()
|
| 143 |
+
for prefix in ("DIM_", "FACT_"):
|
| 144 |
+
if upper.startswith(prefix) and len(name) > len(prefix):
|
| 145 |
+
return name[len(prefix):]
|
| 146 |
+
return name
|
| 147 |
+
|
| 148 |
+
|
| 149 |
def _infer_liveboard_context_from_custom_request(text: str) -> Tuple[str, str]:
|
| 150 |
"""Infer a coarse vertical/function label for custom liveboard question generation."""
|
| 151 |
normalized = (text or "").lower()
|
|
|
|
| 1249 |
original_name=original_col_name
|
| 1250 |
)
|
| 1251 |
|
| 1252 |
+
# If the display name is still used, disambiguate with a readable
|
| 1253 |
+
# "(Table)" suffix β never a table-name prefix
|
| 1254 |
original_display_name = display_name
|
| 1255 |
+
counter = 2
|
| 1256 |
while display_name.lower() in used_display_names:
|
| 1257 |
+
if display_name == original_display_name:
|
| 1258 |
+
table_label = _apply_naming_style(
|
| 1259 |
+
_strip_dim_fact_prefix(table_name_upper), self.column_naming_style
|
| 1260 |
+
) or table_name_upper
|
| 1261 |
+
display_name = f"{original_display_name} ({table_label})"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1262 |
else:
|
| 1263 |
+
display_name = f"{original_display_name} {counter}"
|
| 1264 |
+
counter += 1
|
|
|
|
| 1265 |
|
| 1266 |
used_display_names.add(display_name.lower())
|
| 1267 |
|
|
|
|
| 1402 |
|
| 1403 |
# Add columns with proper global conflict resolution (same as working version)
|
| 1404 |
used_display_names = set()
|
| 1405 |
+
|
| 1406 |
+
# Key columns (surrogate PKs and FKs) must be IN the model for joins to
|
| 1407 |
+
# work, but they're join plumbing, not demo content β mark them hidden.
|
| 1408 |
+
fk_columns = set()
|
| 1409 |
+
for fk in foreign_keys or []:
|
| 1410 |
+
fk_columns.add((fk.get('from_table', '').upper(), fk.get('from_column', '').upper()))
|
| 1411 |
+
fk_columns.add((fk.get('to_table', '').upper(), fk.get('to_column', '').upper()))
|
| 1412 |
+
|
| 1413 |
for table_name, columns in tables.items():
|
| 1414 |
table_name_upper = table_name.upper()
|
| 1415 |
for col in columns:
|
| 1416 |
col_name = col['name'].upper()
|
| 1417 |
original_col_name = col.get('original_name', col['name']) # Use original casing for display
|
| 1418 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1419 |
# Start with basic conflict resolution
|
| 1420 |
display_name = self._resolve_column_name_conflict(
|
| 1421 |
col_name, table_name_upper, column_name_counts,
|
| 1422 |
original_name=original_col_name
|
| 1423 |
)
|
| 1424 |
|
| 1425 |
+
# If the display name is still used, disambiguate with a readable
|
| 1426 |
+
# "(Table)" suffix β never a table-name prefix
|
| 1427 |
original_display_name = display_name
|
| 1428 |
+
counter = 2
|
| 1429 |
while display_name.lower() in used_display_names:
|
| 1430 |
+
if display_name == original_display_name:
|
| 1431 |
+
table_label = _apply_naming_style(
|
| 1432 |
+
_strip_dim_fact_prefix(table_name_upper), self.column_naming_style
|
| 1433 |
+
) or table_name_upper
|
| 1434 |
+
display_name = f"{original_display_name} ({table_label})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1435 |
else:
|
| 1436 |
+
display_name = f"{original_display_name} {counter}"
|
| 1437 |
+
counter += 1
|
| 1438 |
|
| 1439 |
used_display_names.add(display_name.lower())
|
| 1440 |
|
|
|
|
| 1453 |
if aggregation:
|
| 1454 |
column_def['properties']['aggregation'] = aggregation
|
| 1455 |
|
| 1456 |
+
# Hide surrogate/foreign key columns β required for joins,
|
| 1457 |
+
# but nobody demos "broker key 23455"
|
| 1458 |
+
if col_name.endswith('_KEY') or (table_name_upper, col_name) in fk_columns:
|
| 1459 |
+
column_def['properties']['is_hidden'] = True
|
| 1460 |
+
|
| 1461 |
# Add calendar property for DATE columns so ThoughtSpot enables
|
| 1462 |
# time bucketing (.weekly, .monthly, etc.) on them
|
| 1463 |
if self._map_data_type(col['type']) == 'DATE':
|
|
|
|
| 1484 |
return yaml_output
|
| 1485 |
|
| 1486 |
def _remove_diamond_joins(self, model_tables: list):
|
| 1487 |
+
"""Remove ONLY joins that create a second directed path between two tables.
|
| 1488 |
+
|
| 1489 |
+
The old implementation reduced the join graph to an undirected spanning
|
| 1490 |
+
tree (max N-1 joins). That silently broke galaxy schemas: two facts
|
| 1491 |
+
sharing conformed dimensions is standard and ThoughtSpot supports it,
|
| 1492 |
+
but the union-find pass saw an undirected cycle and dropped legitimate
|
| 1493 |
+
fact->dim joins (e.g. FACT_BROKER_PAYMENT lost its DIM_BROKER join in
|
| 1494 |
+
the TRI build, making every payment metric unsliceable by broker).
|
| 1495 |
+
|
| 1496 |
+
What ThoughtSpot actually rejects is ambiguity: two DIRECTED join paths
|
| 1497 |
+
from one table to another (a diamond, e.g. F->D1->X and F->D2->X) or a
|
| 1498 |
+
directed cycle. Multiple facts pointing at one shared dim is fine β
|
| 1499 |
+
no table ends up with two routes to any other table.
|
| 1500 |
"""
|
| 1501 |
def edge_key(src_name: str, join_def: dict):
|
| 1502 |
return (
|
|
|
|
| 1512 |
src_name = t['name']
|
| 1513 |
for j in t.get('joins', []):
|
| 1514 |
all_edges.append((src_name, j.get('with'), j, edge_key(src_name, j)))
|
| 1515 |
+
|
| 1516 |
if not all_edges:
|
| 1517 |
print(f" β
No joins to check for cycles")
|
| 1518 |
return
|
| 1519 |
+
|
| 1520 |
out_degree = {}
|
| 1521 |
for t in model_tables:
|
| 1522 |
out_degree[t['name']] = len(t.get('joins', []))
|
| 1523 |
+
|
| 1524 |
in_degree = {t['name']: 0 for t in model_tables}
|
| 1525 |
for src, dst, _, _ in all_edges:
|
| 1526 |
in_degree[dst] = in_degree.get(dst, 0) + 1
|
| 1527 |
+
|
| 1528 |
+
# Priority order: when a genuine diamond has to be broken, the
|
| 1529 |
+
# fact-side join (high out-degree source) survives and the
|
| 1530 |
+
# dim-to-dim snowflake edge is the one pruned.
|
| 1531 |
all_edges.sort(key=lambda e: (-out_degree.get(e[0], 0), -in_degree.get(e[1], 0), e[0], e[1]))
|
| 1532 |
+
|
| 1533 |
+
nodes = {t['name'] for t in model_tables}
|
| 1534 |
+
reach = {n: set() for n in nodes} # nodes reachable from n via kept joins
|
| 1535 |
+
parents = {n: set() for n in nodes} # nodes that can reach n via kept joins
|
| 1536 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1537 |
kept_edge_keys = set()
|
| 1538 |
removed = []
|
| 1539 |
for src, dst, join_def, e_key in all_edges:
|
| 1540 |
+
if src not in nodes or dst not in nodes or src == dst:
|
|
|
|
|
|
|
| 1541 |
removed.append(f"{src}->{dst} ({join_def.get('on', '')})")
|
| 1542 |
+
continue
|
| 1543 |
+
sources = {src} | parents[src]
|
| 1544 |
+
targets = {dst} | reach[dst]
|
| 1545 |
+
# Directed cycle: something downstream of dst already reaches src.
|
| 1546 |
+
# Diamond: some ancestor of src already reaches some target β the
|
| 1547 |
+
# new edge would give it a second path there.
|
| 1548 |
+
if (sources & targets) or any(
|
| 1549 |
+
t_ in reach[s_] for s_ in sources for t_ in targets
|
| 1550 |
+
):
|
| 1551 |
+
removed.append(f"{src}->{dst} ({join_def.get('on', '')})")
|
| 1552 |
+
continue
|
| 1553 |
+
kept_edge_keys.add(e_key)
|
| 1554 |
+
for s_ in sources:
|
| 1555 |
+
reach[s_].update(targets)
|
| 1556 |
+
for t_ in targets:
|
| 1557 |
+
parents[t_].update(sources)
|
| 1558 |
+
|
| 1559 |
for t in model_tables:
|
| 1560 |
if 'joins' not in t:
|
| 1561 |
continue
|
| 1562 |
src_name = t['name']
|
| 1563 |
t['joins'] = [j for j in t['joins'] if edge_key(src_name, j) in kept_edge_keys]
|
| 1564 |
+
|
| 1565 |
for t in model_tables:
|
| 1566 |
if 'joins' in t and not t['joins']:
|
| 1567 |
del t['joins']
|
| 1568 |
+
|
| 1569 |
if removed:
|
| 1570 |
+
print(f" πΆ Removed {len(removed)} joins that created ambiguous paths or cycles:")
|
| 1571 |
for r in removed:
|
| 1572 |
print(f" - {r}")
|
| 1573 |
else:
|
| 1574 |
+
print(f" β
No ambiguous join paths detected")
|
| 1575 |
|
| 1576 |
def _generate_constraint_id(self) -> str:
|
| 1577 |
"""Generate a constraint ID similar to ThoughtSpot's system constraints"""
|
|
|
|
| 1631 |
column_name_counts: Dict tracking column name occurrences
|
| 1632 |
original_name: Original casing of column name (for proper camelCase detection)
|
| 1633 |
"""
|
| 1634 |
+
# Use original name if provided (preserves camelCase boundaries),
|
| 1635 |
+
# and never surface DIM_/FACT_ warehouse prefixes in display names
|
| 1636 |
+
name_for_styling = _strip_dim_fact_prefix(original_name if original_name else col_name)
|
| 1637 |
+
|
| 1638 |
# Apply configured naming style
|
| 1639 |
styled_name = _apply_naming_style(name_for_styling, self.column_naming_style)
|
| 1640 |
+
|
| 1641 |
if len(column_name_counts.get(col_name, [])) <= 1:
|
| 1642 |
# No conflict - use styled name directly
|
| 1643 |
return styled_name
|
| 1644 |
+
|
| 1645 |
+
# Cross-table collision (e.g. BROKER_KEY exists on the BROKER dim and as
|
| 1646 |
+
# an FK on fact tables): the column's home table keeps the clean name,
|
| 1647 |
+
# every other table gets a readable "(Table)" suffix.
|
| 1648 |
+
business_table = _strip_dim_fact_prefix(table_name)
|
| 1649 |
+
if _strip_dim_fact_prefix(col_name).upper().startswith(business_table.upper()):
|
| 1650 |
+
return styled_name
|
| 1651 |
+
|
| 1652 |
+
table_label = _apply_naming_style(business_table, self.column_naming_style) or business_table
|
| 1653 |
+
return f"{styled_name} ({table_label})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1654 |
|
| 1655 |
def _get_table_prefix(self, table_name: str) -> str:
|
| 1656 |
"""Get appropriate prefix for table to avoid column conflicts"""
|
|
|
|
| 1685 |
|
| 1686 |
# Numeric types should be measures (unless they're IDs or keys)
|
| 1687 |
if base_type in ['NUMBER', 'DECIMAL', 'FLOAT', 'DOUBLE', 'INT', 'INTEGER', 'BIGINT']:
|
| 1688 |
+
# Skip ID/KEY columns - they're join keys, not analytics columns.
|
| 1689 |
+
# Match whole name tokens, not substrings: a bare endswith('ID')
|
| 1690 |
+
# misclassified INVOICES_PAID (and anything ending PAID/VALID/GRID)
|
| 1691 |
+
# as an attribute.
|
| 1692 |
+
tokens = col_upper.split('_')
|
| 1693 |
+
if tokens[-1] in ('ID', 'KEY', 'CODE') or col_upper in ('ID', 'KEY'):
|
| 1694 |
return 'ATTRIBUTE', None
|
| 1695 |
+
|
| 1696 |
+
# All other numeric columns are measures.
|
| 1697 |
+
# Aggregation from whole-word tokens (substring matching hit
|
| 1698 |
+
# CORPORATE/GENERATED for 'RATE'). Ratios, rates, percentages and
|
| 1699 |
+
# per-row durations must AVERAGE β summing a rate is meaningless.
|
| 1700 |
+
token_set = set(tokens)
|
| 1701 |
+
if token_set & {'RATING', 'SCORE', 'MARGIN', 'PERCENT', 'PCT', 'RATE', 'RATIO', 'AVG', 'AVERAGE'}:
|
| 1702 |
+
return 'MEASURE', 'AVERAGE'
|
| 1703 |
+
elif 'DAYS' in token_set and ('TO' in token_set or 'SINCE' in token_set):
|
| 1704 |
+
# DAYS_TO_PAY / DAYS_SINCE_X are per-row durations, not additive
|
| 1705 |
+
return 'MEASURE', 'AVERAGE'
|
| 1706 |
+
elif token_set & {'QUANTITY', 'QTY', 'COUNT', 'SOLD'}:
|
| 1707 |
return 'MEASURE', 'SUM'
|
| 1708 |
+
elif token_set & {'PRICE', 'COST', 'REVENUE', 'AMOUNT', 'TOTAL', 'PROFIT', 'DISCOUNT', 'SHIPPING', 'TAX'}:
|
| 1709 |
return 'MEASURE', 'SUM'
|
|
|
|
|
|
|
| 1710 |
else:
|
| 1711 |
# Default: numeric = measure with SUM
|
| 1712 |
return 'MEASURE', 'SUM'
|