mike boone commited on
Commit
a6d4bca
·
1 Parent(s): 4489319

Honor explicit custom dataset contracts

Browse files
chat_interface.py CHANGED
@@ -274,11 +274,125 @@ class ChatDemoInterface:
274
  self.demo_pack_content = "" # Generated demo pack markdown
275
  self.spotter_story_ai = "" # Pure AI-generated Spotter Viz story
276
  self.spotter_story_matrix = "" # Matrix/ThoughtSpot-recommended Spotter Viz story
 
277
  # Per-session loggers (NOT module-level singletons — avoids cross-session contamination)
278
  self._session_logger = None
279
  self._prompt_logger = None
280
  self._dataset_first_bundle = None
281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  def _dataset_first_enabled(self) -> bool:
283
  """Read dataset-first mode from app config/.env or saved settings."""
284
  setting = str(self.settings.get("data_generation_mode", "")).strip().lower()
@@ -4326,6 +4440,7 @@ Steps:
4326
  raise Exception(ts_result["error"])
4327
 
4328
  results = ts_result["results"]
 
4329
  _ts_meta = {
4330
  "schema_name": schema_name,
4331
  "connection": results.get("connection"),
@@ -6223,6 +6338,8 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
6223
  elem_classes=["phase-log-stream"],
6224
  )
6225
 
 
 
6226
  # Timer polls controller.phase_log every 2 seconds (activated when GO is pressed)
6227
  phase_log_timer = gr.Timer(value=2, active=True)
6228
 
@@ -6257,7 +6374,11 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
6257
  new_stage = result[1] if len(result) > 1 else stage
6258
  progress = get_progress_html(new_stage)
6259
  chatbot_update = gr.update(value=result[0], visible=True)
6260
- yield (controller, chatbot_update) + result[1:] + (progress, hide_welcome)
 
 
 
 
6261
  except Exception as e:
6262
  err_tb = traceback.format_exc()
6263
  print(f"[ERROR] send_message unhandled exception:\n{err_tb}")
@@ -6268,11 +6389,27 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
6268
  )
6269
  history = history or []
6270
  history.append((message, err_msg))
6271
- yield (controller, gr.update(value=history, visible=True), stage, model, company, usecase, "", get_progress_html(stage), hide_welcome)
 
 
 
 
 
 
 
 
 
 
 
 
6272
 
6273
  # Wire up send button and enter key
6274
  _send_inputs = [chat_controller_state, msg, chatbot, current_stage, current_model, current_company, current_usecase, ts_env_dropdown, liveboard_name_input]
6275
- _send_outputs = [chat_controller_state, chatbot, current_stage, current_model, current_company, current_usecase, msg, progress_html, welcome_md]
 
 
 
 
6276
 
6277
  msg.submit(fn=send_message, inputs=_send_inputs, outputs=_send_outputs)
6278
  send_btn.click(fn=send_message, inputs=_send_inputs, outputs=_send_outputs)
@@ -6364,6 +6501,7 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
6364
  get_progress_html(stage),
6365
  hide_welcome,
6366
  gr.update(open=False),
 
6367
  )
6368
  return
6369
 
@@ -6402,6 +6540,7 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
6402
 
6403
  # Clear phase log for a fresh run
6404
  controller.phase_log = [f"→ GO received — preparing {raw_company} · {use_case_display}"]
 
6405
 
6406
  # Tag the run source so session logging can record which interface was used
6407
  controller._run_source = 'app_custom' if is_custom else 'app_defined'
@@ -6419,7 +6558,12 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
6419
  new_stage = result[1] if len(result) > 1 else stage
6420
  progress = get_progress_html(new_stage)
6421
  chatbot_update = gr.update(value=result[0], visible=True)
6422
- yield (controller, chatbot_update) + result[1:5] + (progress, hide_welcome, gr.update(open=False))
 
 
 
 
 
6423
  except Exception as e:
6424
  err_tb = traceback.format_exc()
6425
  print(f"[ERROR] defined_go unhandled exception:\n{err_tb}")
@@ -6430,7 +6574,18 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
6430
  f"The pipeline has been interrupted. You can try again or start a new session."
6431
  )
6432
  history.append((f"GO: {use_case_str}", err_msg))
6433
- yield (controller, gr.update(value=history, visible=True), stage, model, company, usecase, get_progress_html(stage), hide_welcome, gr.update(open=False))
 
 
 
 
 
 
 
 
 
 
 
6434
 
6435
  _go_inputs = [
6436
  chat_controller_state, vertical_dd, line_dd, function_dd, url_input, use_url_cb,
@@ -6442,7 +6597,7 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
6442
  _go_outputs = [
6443
  chat_controller_state, chatbot, current_stage, current_model,
6444
  current_company, current_usecase, progress_html, welcome_md,
6445
- settings_accordion,
6446
  ]
6447
  go_btn.click(fn=defined_go, inputs=_go_inputs, outputs=_go_outputs)
6448
 
 
274
  self.demo_pack_content = "" # Generated demo pack markdown
275
  self.spotter_story_ai = "" # Pure AI-generated Spotter Viz story
276
  self.spotter_story_matrix = "" # Matrix/ThoughtSpot-recommended Spotter Viz story
277
+ self.deployment_completion = None # Final model/liveboard links shown in the right panel
278
  # Per-session loggers (NOT module-level singletons — avoids cross-session contamination)
279
  self._session_logger = None
280
  self._prompt_logger = None
281
  self._dataset_first_bundle = None
282
 
283
+ def clear_deployment_completion(self) -> None:
284
+ self.deployment_completion = None
285
+
286
+ def record_deployment_completion(self, results: dict, database: str, schema_name: str, use_case: str) -> None:
287
+ """Store final ThoughtSpot artifact links for the UI completion panel."""
288
+ if not isinstance(results, dict):
289
+ self.deployment_completion = None
290
+ return
291
+
292
+ ts_url = (self.settings.get('thoughtspot_url') or '').rstrip('/')
293
+ model_guid = results.get('model_guid') or ''
294
+ liveboard_guid = results.get('liveboard_guid') or results.get('liveboard_id') or ''
295
+ liveboard_url = results.get('liveboard_url') or (
296
+ f"{ts_url}/#/pinboard/{liveboard_guid}" if ts_url and liveboard_guid else ''
297
+ )
298
+ model_url = f"{ts_url}/#/data/tables/{model_guid}" if ts_url and model_guid else ''
299
+ backup = bool(results.get('backup_liveboard')) or results.get('liveboard_creation_path') == 'spotter_tml_backup'
300
+ warnings = results.get('warnings') or []
301
+ errors = results.get('errors') or []
302
+
303
+ if backup:
304
+ status = "Backup liveboard created"
305
+ note = (
306
+ "MCP/Spotter answer generation was unavailable, so DemoPrep created a clearly marked "
307
+ "Spotter/TML backup liveboard. The model and data are still available."
308
+ )
309
+ elif results.get('success') and liveboard_url:
310
+ status = "MCP liveboard created"
311
+ note = "The liveboard was created through the MCP path and enhanced after creation."
312
+ elif model_url:
313
+ status = "Model created"
314
+ note = "The model was created, but a liveboard link was not returned. Use the model link to continue in ThoughtSpot."
315
+ else:
316
+ status = "Deployment finished"
317
+ note = "Review the pipeline status for details."
318
+
319
+ self.deployment_completion = {
320
+ "status": status,
321
+ "note": note,
322
+ "model_url": model_url,
323
+ "liveboard_url": liveboard_url,
324
+ "model_guid": model_guid,
325
+ "liveboard_guid": liveboard_guid,
326
+ "connection": results.get('connection') or '',
327
+ "schema": f"{database}.{schema_name}" if database and schema_name else schema_name,
328
+ "use_case": use_case,
329
+ "warnings": warnings,
330
+ "errors": errors,
331
+ "success": bool(results.get('success')),
332
+ }
333
+
334
+ def render_deployment_completion_html(self):
335
+ """Render final artifact links for the completion panel."""
336
+ if not self.deployment_completion:
337
+ return gr.update(value="", visible=False)
338
+
339
+ import html as _html
340
+ item = self.deployment_completion
341
+ status = _html.escape(str(item.get("status") or "Deployment complete"))
342
+ note = _html.escape(str(item.get("note") or ""))
343
+ model_url = str(item.get("model_url") or "")
344
+ liveboard_url = str(item.get("liveboard_url") or "")
345
+ schema = _html.escape(str(item.get("schema") or ""))
346
+ connection = _html.escape(str(item.get("connection") or ""))
347
+ warnings = item.get("warnings") or []
348
+ errors = item.get("errors") or []
349
+ is_mcp = item.get("status") == "MCP liveboard created"
350
+ border = "#22c55e" if is_mcp and item.get("success") and not item.get("errors") else "#f59e0b"
351
+ badge_bg = "#dcfce7" if is_mcp else "#fef3c7"
352
+ badge_color = "#166534" if is_mcp else "#92400e"
353
+
354
+ def _link_button(label, url):
355
+ if not url:
356
+ return f"<span style='color:#6b7280;font-size:13px;'>{_html.escape(label)} unavailable</span>"
357
+ safe_url = _html.escape(url, quote=True)
358
+ return (
359
+ f"<a href='{safe_url}' target='_blank' rel='noopener noreferrer' "
360
+ "style='display:inline-block;padding:9px 12px;margin:4px 6px 4px 0;"
361
+ "border-radius:6px;background:#2563eb;color:white;text-decoration:none;"
362
+ "font-weight:600;font-size:13px;'>"
363
+ f"{_html.escape(label)}</a>"
364
+ )
365
+
366
+ warning_html = ""
367
+ if warnings:
368
+ warning_items = "".join(f"<li>{_html.escape(str(w))}</li>" for w in warnings[:3])
369
+ warning_html = f"<ul style='margin:8px 0 0 18px;color:#92400e;font-size:12px;'>{warning_items}</ul>"
370
+ error_html = ""
371
+ if errors:
372
+ error_items = "".join(f"<li>{_html.escape(str(e))}</li>" for e in errors[:2])
373
+ error_html = f"<ul style='margin:8px 0 0 18px;color:#991b1b;font-size:12px;'>{error_items}</ul>"
374
+
375
+ html_value = f"""
376
+ <div style="border:1px solid {border};border-left:5px solid {border};border-radius:8px;padding:12px;margin:10px 0;background:#fff;">
377
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
378
+ <div style="font-weight:700;color:#111827;">Deployment Links</div>
379
+ <div style="padding:3px 8px;border-radius:999px;background:{badge_bg};color:{badge_color};font-size:12px;font-weight:700;">{status}</div>
380
+ </div>
381
+ <div style="margin-top:8px;color:#374151;font-size:13px;line-height:1.35;">{note}</div>
382
+ <div style="margin-top:10px;">
383
+ {_link_button("Open Liveboard", liveboard_url)}
384
+ {_link_button("Open Model", model_url)}
385
+ </div>
386
+ <div style="margin-top:8px;color:#6b7280;font-size:12px;line-height:1.4;">
387
+ <div><strong>Schema:</strong> {schema or "n/a"}</div>
388
+ <div><strong>Connection:</strong> {connection or "n/a"}</div>
389
+ </div>
390
+ {warning_html}
391
+ {error_html}
392
+ </div>
393
+ """
394
+ return gr.update(value=html_value, visible=True)
395
+
396
  def _dataset_first_enabled(self) -> bool:
397
  """Read dataset-first mode from app config/.env or saved settings."""
398
  setting = str(self.settings.get("data_generation_mode", "")).strip().lower()
 
4440
  raise Exception(ts_result["error"])
4441
 
4442
  results = ts_result["results"]
4443
+ self.record_deployment_completion(results, database, schema_name, use_case)
4444
  _ts_meta = {
4445
  "schema_name": schema_name,
4446
  "connection": results.get("connection"),
 
6338
  elem_classes=["phase-log-stream"],
6339
  )
6340
 
6341
+ deployment_links_panel = gr.HTML(value="", visible=False)
6342
+
6343
  # Timer polls controller.phase_log every 2 seconds (activated when GO is pressed)
6344
  phase_log_timer = gr.Timer(value=2, active=True)
6345
 
 
6374
  new_stage = result[1] if len(result) > 1 else stage
6375
  progress = get_progress_html(new_stage)
6376
  chatbot_update = gr.update(value=result[0], visible=True)
6377
+ yield (controller, chatbot_update) + result[1:] + (
6378
+ progress,
6379
+ hide_welcome,
6380
+ controller.render_deployment_completion_html(),
6381
+ )
6382
  except Exception as e:
6383
  err_tb = traceback.format_exc()
6384
  print(f"[ERROR] send_message unhandled exception:\n{err_tb}")
 
6389
  )
6390
  history = history or []
6391
  history.append((message, err_msg))
6392
+ completion_update = controller.render_deployment_completion_html() if controller else gr.update(value="", visible=False)
6393
+ yield (
6394
+ controller,
6395
+ gr.update(value=history, visible=True),
6396
+ stage,
6397
+ model,
6398
+ company,
6399
+ usecase,
6400
+ "",
6401
+ get_progress_html(stage),
6402
+ hide_welcome,
6403
+ completion_update,
6404
+ )
6405
 
6406
  # Wire up send button and enter key
6407
  _send_inputs = [chat_controller_state, msg, chatbot, current_stage, current_model, current_company, current_usecase, ts_env_dropdown, liveboard_name_input]
6408
+ _send_outputs = [
6409
+ chat_controller_state, chatbot, current_stage, current_model,
6410
+ current_company, current_usecase, msg, progress_html, welcome_md,
6411
+ deployment_links_panel,
6412
+ ]
6413
 
6414
  msg.submit(fn=send_message, inputs=_send_inputs, outputs=_send_outputs)
6415
  send_btn.click(fn=send_message, inputs=_send_inputs, outputs=_send_outputs)
 
6501
  get_progress_html(stage),
6502
  hide_welcome,
6503
  gr.update(open=False),
6504
+ gr.update(value="", visible=False),
6505
  )
6506
  return
6507
 
 
6540
 
6541
  # Clear phase log for a fresh run
6542
  controller.phase_log = [f"→ GO received — preparing {raw_company} · {use_case_display}"]
6543
+ controller.clear_deployment_completion()
6544
 
6545
  # Tag the run source so session logging can record which interface was used
6546
  controller._run_source = 'app_custom' if is_custom else 'app_defined'
 
6558
  new_stage = result[1] if len(result) > 1 else stage
6559
  progress = get_progress_html(new_stage)
6560
  chatbot_update = gr.update(value=result[0], visible=True)
6561
+ yield (controller, chatbot_update) + result[1:5] + (
6562
+ progress,
6563
+ hide_welcome,
6564
+ gr.update(open=False),
6565
+ controller.render_deployment_completion_html(),
6566
+ )
6567
  except Exception as e:
6568
  err_tb = traceback.format_exc()
6569
  print(f"[ERROR] defined_go unhandled exception:\n{err_tb}")
 
6574
  f"The pipeline has been interrupted. You can try again or start a new session."
6575
  )
6576
  history.append((f"GO: {use_case_str}", err_msg))
6577
+ yield (
6578
+ controller,
6579
+ gr.update(value=history, visible=True),
6580
+ stage,
6581
+ model,
6582
+ company,
6583
+ usecase,
6584
+ get_progress_html(stage),
6585
+ hide_welcome,
6586
+ gr.update(open=False),
6587
+ controller.render_deployment_completion_html(),
6588
+ )
6589
 
6590
  _go_inputs = [
6591
  chat_controller_state, vertical_dd, line_dd, function_dd, url_input, use_url_cb,
 
6597
  _go_outputs = [
6598
  chat_controller_state, chatbot, current_stage, current_model,
6599
  current_company, current_usecase, progress_html, welcome_md,
6600
+ settings_accordion, deployment_links_panel,
6601
  ]
6602
  go_btn.click(fn=defined_go, inputs=_go_inputs, outputs=_go_outputs)
6603
 
demoprep_app/dataset/explicit_prompt.py ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build datasets directly from explicit table specs in a custom prompt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import re
7
+ from dataclasses import dataclass
8
+ from datetime import date, timedelta
9
+ from typing import Any
10
+
11
+ from demoprep_app.dataset.contracts import DatasetBundle, DatasetColumn, DatasetTable
12
+ from demoprep_app.scenario.contract import ScenarioContract
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class ExplicitTableSpec:
17
+ name: str
18
+ requested_rows: int | None
19
+ columns: list[str]
20
+
21
+
22
+ def build_explicit_prompt_dataset(
23
+ *,
24
+ company_name: str,
25
+ company_url: str,
26
+ use_case: str,
27
+ row_count_guidance: int | None,
28
+ seed: int,
29
+ ) -> DatasetBundle | None:
30
+ specs = _parse_explicit_table_specs(use_case)
31
+ if len(specs) < 2 or not any(spec.name.upper().startswith("FACT_") for spec in specs):
32
+ return None
33
+
34
+ rng = random.Random(seed)
35
+ scenario = ScenarioContract(
36
+ company_name=company_name,
37
+ company_url=company_url,
38
+ use_case=use_case,
39
+ scenario_type="explicit_table_contract",
40
+ fact_grain="explicit prompt table grain",
41
+ demo_audience="Business leaders",
42
+ business_problem="Analyze the specific tables and metrics requested in the prompt.",
43
+ dashboard_questions=[
44
+ "Which metrics need executive attention?",
45
+ "Where are aging, leakage, or performance gaps concentrated?",
46
+ "Which customers, regions, or categories are driving the issue?",
47
+ ],
48
+ metadata={
49
+ "seed": seed,
50
+ "contract_source": "explicit_prompt_tables",
51
+ "explicit_tables": [spec.name for spec in specs],
52
+ },
53
+ )
54
+
55
+ tables: list[DatasetTable] = []
56
+ table_rows: dict[str, list[dict[str, Any]]] = {}
57
+ primary_keys: dict[str, str] = {}
58
+
59
+ for spec in specs:
60
+ is_fact = spec.name.upper().startswith("FACT_")
61
+ columns = _columns_for_spec(spec, is_fact=is_fact)
62
+ row_count = _row_count_for_spec(spec, is_fact=is_fact, row_count_guidance=row_count_guidance)
63
+ rows = _rows_for_table(spec, columns, row_count, rng, table_rows, primary_keys)
64
+ table = DatasetTable(spec.name.upper(), _grain_for_table(spec.name), columns, rows, is_fact=is_fact)
65
+ tables.append(table)
66
+ table_rows[table.name] = rows
67
+ if columns:
68
+ primary_keys[table.name] = columns[0].name
69
+
70
+ return DatasetBundle(scenario=scenario, tables=tables)
71
+
72
+
73
+ def _parse_explicit_table_specs(text: str) -> list[ExplicitTableSpec]:
74
+ normalized = (text or "").replace("\u00a0", " ")
75
+ lines = [line.strip() for line in normalized.splitlines()]
76
+ specs: list[ExplicitTableSpec] = []
77
+ table_line_re = re.compile(r"^([A-Za-z][A-Za-z0-9_]*)\s*(?:\((.*?)\))?\s*$")
78
+
79
+ idx = 0
80
+ while idx < len(lines):
81
+ line = lines[idx]
82
+ match = table_line_re.match(line)
83
+ if not match or "_" not in match.group(1):
84
+ idx += 1
85
+ continue
86
+
87
+ table_name = match.group(1).upper()
88
+ if not (table_name.startswith("DIM_") or table_name.startswith("FACT_")):
89
+ idx += 1
90
+ continue
91
+
92
+ requested_rows = _parse_requested_rows(match.group(2) or "")
93
+ column_line = ""
94
+ lookahead = idx + 1
95
+ while lookahead < len(lines):
96
+ candidate = lines[lookahead].strip()
97
+ if not candidate:
98
+ lookahead += 1
99
+ continue
100
+ if table_line_re.match(candidate) and "_" in candidate.split()[0]:
101
+ break
102
+ column_line = candidate
103
+ break
104
+
105
+ columns = _split_column_line(column_line)
106
+ if columns:
107
+ specs.append(ExplicitTableSpec(table_name, requested_rows, columns))
108
+ idx = max(lookahead + 1, idx + 1)
109
+
110
+ return specs
111
+
112
+
113
+ def _parse_requested_rows(text: str) -> int | None:
114
+ match = re.search(r"~?\s*([\d,]+)\s*rows?", text or "", flags=re.IGNORECASE)
115
+ if not match:
116
+ return None
117
+ return int(match.group(1).replace(",", ""))
118
+
119
+
120
+ def _split_column_line(line: str) -> list[str]:
121
+ if not line:
122
+ return []
123
+ parts = [part.strip() for part in line.split(",")]
124
+ columns = []
125
+ for part in parts:
126
+ clean = re.sub(r"\s*\(.*?\)", "", part).strip()
127
+ clean = clean.strip(".")
128
+ if clean:
129
+ columns.append(clean)
130
+ return columns
131
+
132
+
133
+ def _columns_for_spec(spec: ExplicitTableSpec, *, is_fact: bool) -> list[DatasetColumn]:
134
+ columns = []
135
+ for index, raw_name in enumerate(spec.columns):
136
+ name = _safe_column_name(raw_name)
137
+ role = "fact_key" if is_fact and index == 0 else f"{spec.name.lower()}_key" if index == 0 else _semantic_role(name)
138
+ columns.append(DatasetColumn(name, role, _data_type_for_column(name), nullable=_nullable_for_column(name)))
139
+
140
+ existing = {column.name for column in columns}
141
+ if spec.name.upper() == "FACT_INVOICES":
142
+ for name, data_type in [
143
+ ("DAYS_OUTSTANDING", "NUMBER"),
144
+ ("AGING_BUCKET", "VARCHAR(30)"),
145
+ ("PAST_DUE_AMOUNT_USD", "NUMBER(14,2)"),
146
+ ]:
147
+ if name not in existing:
148
+ columns.append(DatasetColumn(name, name.lower(), data_type, nullable=False))
149
+ return columns
150
+
151
+
152
+ def _safe_column_name(name: str) -> str:
153
+ safe = re.sub(r"[^A-Za-z0-9]+", "_", name.strip()).strip("_").upper()
154
+ aliases = {
155
+ "INVOICE_CATEGORY_ID": "CATEGORY_ID",
156
+ }
157
+ safe = aliases.get(safe, safe)
158
+ return safe or "COLUMN"
159
+
160
+
161
+ def _semantic_role(name: str) -> str:
162
+ if name.endswith("_ID") or name.endswith("_KEY"):
163
+ return "dimension_key"
164
+ if "DATE" in name:
165
+ return "date"
166
+ if any(token in name for token in ("AMOUNT", "USD", "DAYS", "REVENUE", "RATE")):
167
+ return "measure"
168
+ return "attribute"
169
+
170
+
171
+ def _data_type_for_column(name: str) -> str:
172
+ if "DATE" in name:
173
+ return "DATE"
174
+ if any(token in name for token in ("AMOUNT", "USD", "REVENUE", "PAID")):
175
+ return "NUMBER(14,2)"
176
+ if name.startswith("DAYS_") or name.endswith("_DAYS"):
177
+ return "NUMBER"
178
+ if name.endswith("_ID") or name.endswith("_KEY"):
179
+ return "NUMBER"
180
+ return "VARCHAR(120)"
181
+
182
+
183
+ def _nullable_for_column(name: str) -> bool:
184
+ return name in {"PAYMENT_RECEIVED_DATE", "DISPUTE_REASON"}
185
+
186
+
187
+ def _row_count_for_spec(spec: ExplicitTableSpec, *, is_fact: bool, row_count_guidance: int | None) -> int:
188
+ requested = spec.requested_rows or (1000 if is_fact else 50)
189
+ if is_fact and row_count_guidance:
190
+ return max(250, min(requested, row_count_guidance))
191
+ return max(1, requested)
192
+
193
+
194
+ def _grain_for_table(table_name: str) -> str:
195
+ low = table_name.lower()
196
+ if low.startswith("fact_invoice"):
197
+ return "invoice"
198
+ if low.startswith("fact_"):
199
+ return low.replace("fact_", "").replace("_", " ")
200
+ return "dimension"
201
+
202
+
203
+ def _rows_for_table(
204
+ spec: ExplicitTableSpec,
205
+ columns: list[DatasetColumn],
206
+ row_count: int,
207
+ rng: random.Random,
208
+ table_rows: dict[str, list[dict[str, Any]]],
209
+ primary_keys: dict[str, str],
210
+ ) -> list[dict[str, Any]]:
211
+ name = spec.name.upper()
212
+ if name == "DIM_CUSTOMER":
213
+ return _customer_rows(columns, row_count, rng)
214
+ if name == "DIM_INVOICE_CATEGORY":
215
+ return _invoice_category_rows(columns, row_count)
216
+ if name == "FACT_INVOICES":
217
+ return _invoice_rows(columns, row_count, rng, table_rows)
218
+ return _generic_rows(name, columns, row_count, rng, table_rows, primary_keys)
219
+
220
+
221
+ def _customer_rows(columns: list[DatasetColumn], row_count: int, rng: random.Random) -> list[dict[str, Any]]:
222
+ industries = ["LNG", "Data Centers", "Power Generation", "Industrial Gas", "New Energy", "Refining", "Metals & Mining", "Hydrogen", "CCUS"]
223
+ regions = {
224
+ "Americas": ["United States", "Canada", "Brazil", "Mexico"],
225
+ "EMEA": ["United Kingdom", "Germany", "Italy", "Norway"],
226
+ "APAC": ["India", "Singapore", "Australia", "Japan"],
227
+ "Middle East": ["Qatar", "United Arab Emirates", "Saudi Arabia", "Oman"],
228
+ }
229
+ tiers = ["Strategic", "Growth", "Transactional"]
230
+ legacy_orgs = ["Baker Hughes", "Chart Industries", "Both"]
231
+ terms = ["Net 30", "Net 45", "Net 60", "Net 90"]
232
+ base_names = [
233
+ "QatarEnergy LNG", "ADNOC Gas Processing", "Saudi Aramco", "NextEra Energy", "Air Liquide",
234
+ "Linde", "Equinor", "Cheniere Energy", "Rio Tinto", "ArcelorMittal", "Digital Realty", "Air Products",
235
+ ]
236
+ rows = []
237
+ for idx in range(1, row_count + 1):
238
+ region = rng.choice(list(regions))
239
+ country = rng.choice(regions[region])
240
+ industry = rng.choice(industries)
241
+ legacy = rng.choices(legacy_orgs, weights=[42, 42, 16], k=1)[0]
242
+ term = rng.choice(terms if legacy != "Baker Hughes" else ["Net 30", "Net 45", "Net 60"])
243
+ values = {
244
+ "CUSTOMER_ID": idx,
245
+ "CUSTOMER_NAME": f"{rng.choice(base_names)} {idx:03d}" if idx > len(base_names) else base_names[idx - 1],
246
+ "INDUSTRY": industry,
247
+ "COUNTRY": country,
248
+ "REGION": region,
249
+ "CUSTOMER_TIER": rng.choice(tiers),
250
+ "LEGACY_ORG": legacy,
251
+ "CONTRACTED_PAYMENT_TERMS": term,
252
+ }
253
+ rows.append(_project_row(columns, values, idx, rng))
254
+ return rows
255
+
256
+
257
+ def _invoice_category_rows(columns: list[DatasetColumn], row_count: int) -> list[dict[str, Any]]:
258
+ categories = [
259
+ ("New Equipment", "IET", "Project-Based"),
260
+ ("Aftermarket Parts", "RSL", "Recurring"),
261
+ ("Field Service", "OFSE", "Recurring"),
262
+ ("Digital", "IET", "Recurring"),
263
+ ("Leasing", "Cryo Tank Solutions", "Recurring"),
264
+ ("Heat Exchanger Overhaul", "Heat Transfer Systems", "Project-Based"),
265
+ ("Specialty Products", "Specialty Products", "One-Time"),
266
+ ]
267
+ rows = []
268
+ for idx in range(1, row_count + 1):
269
+ category, business_unit, order_type = categories[(idx - 1) % len(categories)]
270
+ values = {
271
+ "CATEGORY_ID": idx,
272
+ "CATEGORY_NAME": category,
273
+ "BUSINESS_UNIT": business_unit,
274
+ "ORDER_TYPE": order_type,
275
+ }
276
+ rows.append(_project_row(columns, values, idx, random.Random(idx)))
277
+ return rows
278
+
279
+
280
+ def _invoice_rows(
281
+ columns: list[DatasetColumn],
282
+ row_count: int,
283
+ rng: random.Random,
284
+ table_rows: dict[str, list[dict[str, Any]]],
285
+ ) -> list[dict[str, Any]]:
286
+ customers = table_rows.get("DIM_CUSTOMER") or [{"CUSTOMER_ID": 1, "REGION": "Americas", "LEGACY_ORG": "Baker Hughes", "CONTRACTED_PAYMENT_TERMS": "Net 45"}]
287
+ categories = table_rows.get("DIM_INVOICE_CATEGORY") or [{"CATEGORY_ID": 1, "CATEGORY_NAME": "New Equipment", "BUSINESS_UNIT": "IET", "ORDER_TYPE": "Project-Based"}]
288
+ start = date(2025, 1, 1)
289
+ end = date(2026, 6, 30)
290
+ as_of = end
291
+ dispute_reasons = ["Pricing Discrepancy", "Missing PO", "Quantity Dispute", "Contract Terms"]
292
+ rows = []
293
+ for idx in range(1, row_count + 1):
294
+ customer = rng.choice(customers)
295
+ category = rng.choice(categories)
296
+ invoice_date = start + timedelta(days=rng.randint(0, (end - start).days))
297
+ terms = int(re.search(r"\d+", str(customer.get("CONTRACTED_PAYMENT_TERMS", "Net 45"))).group(0))
298
+ if customer.get("LEGACY_ORG") == "Chart Industries" and customer.get("REGION") in {"APAC", "Middle East"}:
299
+ terms = rng.choice([60, 75, 90])
300
+ due_date = invoice_date + timedelta(days=terms)
301
+ large_lng_gap = idx <= 15
302
+ if large_lng_gap:
303
+ amount = round(rng.uniform(5_000_000, 20_000_000), 2)
304
+ due_date = as_of - timedelta(days=rng.randint(60, 90))
305
+ invoice_date = due_date - timedelta(days=terms)
306
+ paid = False
307
+ dispute = "N"
308
+ else:
309
+ rsl_drag = category.get("BUSINESS_UNIT") == "RSL" and rng.random() < 0.42
310
+ amount = round(rng.uniform(8_000, 48_000) if rsl_drag else rng.uniform(40_000, 2_500_000), 2)
311
+ paid = rng.random() < 0.68
312
+ dispute = "Y" if rng.random() < 0.12 else "N"
313
+ if paid:
314
+ pay_delay = max(0, int(rng.gauss(terms + (22 if customer.get("REGION") in {"APAC", "Middle East"} else 4), 18)))
315
+ payment_date = invoice_date + timedelta(days=pay_delay)
316
+ if payment_date > as_of:
317
+ paid = False
318
+ if paid:
319
+ payment_received_date = payment_date
320
+ amount_paid = amount
321
+ days_outstanding = 0
322
+ else:
323
+ payment_received_date = None
324
+ amount_paid = 0.0
325
+ days_outstanding = max(0, (as_of - due_date).days)
326
+ bucket = _aging_bucket(days_outstanding)
327
+ values = {
328
+ "INVOICE_ID": idx,
329
+ "INVOICE_DATE": invoice_date,
330
+ "DUE_DATE": due_date,
331
+ "CUSTOMER_ID": customer.get("CUSTOMER_ID"),
332
+ "CATEGORY_ID": category.get("CATEGORY_ID"),
333
+ "INVOICE_AMOUNT_USD": amount,
334
+ "PAYMENT_RECEIVED_DATE": payment_received_date,
335
+ "AMOUNT_PAID_USD": amount_paid,
336
+ "DISPUTE_FLAG": dispute,
337
+ "DISPUTE_REASON": None if dispute == "N" else rng.choice(dispute_reasons),
338
+ "COLLECTOR_ASSIGNED": "Y" if (not paid and days_outstanding > 30 and rng.random() < 0.72) else "N",
339
+ "LEGACY_ORG": customer.get("LEGACY_ORG", rng.choice(["Baker Hughes", "Chart Industries"])),
340
+ "DAYS_OUTSTANDING": days_outstanding,
341
+ "AGING_BUCKET": bucket,
342
+ "PAST_DUE_AMOUNT_USD": 0.0 if paid or days_outstanding == 0 else amount,
343
+ }
344
+ rows.append(_project_row(columns, values, idx, rng))
345
+ return rows
346
+
347
+
348
+ def _aging_bucket(days: int) -> str:
349
+ if days <= 0:
350
+ return "Current"
351
+ if days <= 30:
352
+ return "1-30 Days"
353
+ if days <= 60:
354
+ return "30-60 Days"
355
+ if days <= 90:
356
+ return "60-90 Days"
357
+ return "90+ Days"
358
+
359
+
360
+ def _generic_rows(
361
+ table_name: str,
362
+ columns: list[DatasetColumn],
363
+ row_count: int,
364
+ rng: random.Random,
365
+ table_rows: dict[str, list[dict[str, Any]]],
366
+ primary_keys: dict[str, str],
367
+ ) -> list[dict[str, Any]]:
368
+ rows = []
369
+ for idx in range(1, row_count + 1):
370
+ values = {}
371
+ for column in columns:
372
+ if column.semantic_role == "fact_key" or column.name == columns[0].name:
373
+ values[column.name] = idx
374
+ elif column.name.endswith("_ID"):
375
+ source_rows = next((rows for name, rows in table_rows.items() if column.name in rows[0]), None)
376
+ values[column.name] = rng.choice(source_rows)[column.name] if source_rows else idx
377
+ else:
378
+ values[column.name] = _generic_value(column.name, idx, rng)
379
+ rows.append(values)
380
+ return rows
381
+
382
+
383
+ def _project_row(columns: list[DatasetColumn], values: dict[str, Any], idx: int, rng: random.Random) -> dict[str, Any]:
384
+ return {column.name: values.get(column.name, _generic_value(column.name, idx, rng)) for column in columns}
385
+
386
+
387
+ def _generic_value(name: str, idx: int, rng: random.Random) -> Any:
388
+ if "DATE" in name:
389
+ return date(2025, 1, 1) + timedelta(days=rng.randint(0, 545))
390
+ if any(token in name for token in ("AMOUNT", "USD", "REVENUE")):
391
+ return round(rng.uniform(10_000, 1_000_000), 2)
392
+ if name.startswith("DAYS_") or name.endswith("_DAYS"):
393
+ return rng.randint(0, 120)
394
+ if name.endswith("_ID") or name.endswith("_KEY"):
395
+ return idx
396
+ if "FLAG" in name or "ASSIGNED" in name:
397
+ return rng.choice(["Y", "N"])
398
+ if "REGION" in name:
399
+ return rng.choice(["Americas", "EMEA", "APAC", "Middle East"])
400
+ if "COUNTRY" in name:
401
+ return rng.choice(["United States", "Qatar", "United Arab Emirates", "Germany", "India"])
402
+ if "LEGACY_ORG" in name:
403
+ return rng.choice(["Baker Hughes", "Chart Industries", "Both"])
404
+ return f"{name.title().replace('_', ' ')} {idx:03d}"
demoprep_app/dataset/generators/post_merger_integration.py DELETED
@@ -1,610 +0,0 @@
1
- """Post-merger integration dataset generator.
2
-
3
- This generator is designed for acquisition synergy demos where the story spans
4
- procurement cost synergy, aftermarket revenue acceleration, and cash-flow/AR
5
- improvement. It is intentionally scenario-family based rather than tied to a
6
- single company name.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import random
12
- from datetime import date, timedelta
13
-
14
- from demoprep_app.dataset.contracts import DatasetBundle, DatasetColumn, DatasetTable
15
- from demoprep_app.dataset.generators.base import ScenarioDatasetGenerator
16
- from demoprep_app.scenario.contract import ScenarioContract
17
-
18
-
19
- class PostMergerIntegrationDatasetGenerator(ScenarioDatasetGenerator):
20
- scenario_types = ("post_merger_integration", "acquisition_synergy")
21
-
22
- def generate(self, scenario: ScenarioContract, row_count: int | None = None) -> DatasetBundle:
23
- rng = random.Random(scenario.metadata.get("seed", 202606))
24
- scale = min(1.0, max(0.02, (row_count or 10000) / 10000.0))
25
-
26
- suppliers = self._suppliers(rng)
27
- commodities = self._commodities()
28
- customers = self._customers(rng)
29
- products = self._products()
30
- invoice_categories = self._invoice_categories()
31
-
32
- spend_rows = self._spend_rows(rng, suppliers, commodities, int(50000 * min(scale, 1.0)))
33
- installed_rows = self._installed_rows(rng, customers, products, int(5000 * min(scale, 1.0)))
34
- order_rows = self._order_rows(rng, customers, products, installed_rows, int(30000 * min(scale, 1.0)))
35
- invoice_rows = self._invoice_rows(rng, customers, invoice_categories, int(25000 * min(scale, 1.0)))
36
-
37
- return DatasetBundle(
38
- scenario=scenario,
39
- tables=[
40
- DatasetTable("DIM_SUPPLIER", "one row per supplier", self._supplier_columns(), suppliers),
41
- DatasetTable("DIM_COMMODITY", "one row per commodity", self._commodity_columns(), commodities),
42
- DatasetTable("DIM_CUSTOMER", "one row per customer", self._customer_columns(), customers),
43
- DatasetTable("DIM_PRODUCT", "one row per product", self._product_columns(), products),
44
- DatasetTable("DIM_INVOICE_CATEGORY", "one row per invoice category", self._invoice_category_columns(), invoice_categories),
45
- DatasetTable("FACT_SPEND", "purchase-order supplier commodity site day", self._spend_columns(), spend_rows, is_fact=True),
46
- DatasetTable("FACT_EQUIPMENT_INSTALLED", "installed equipment unit", self._installed_columns(), installed_rows, is_fact=True),
47
- DatasetTable("FACT_ORDERS", "customer product order day", self._order_columns(), order_rows, is_fact=True),
48
- DatasetTable("FACT_INVOICES", "customer invoice day", self._invoice_columns(), invoice_rows, is_fact=True),
49
- ],
50
- )
51
-
52
- @staticmethod
53
- def _supplier_columns() -> list[DatasetColumn]:
54
- return [
55
- DatasetColumn("SUPPLIER_ID", "supplier_key", "NUMBER", nullable=False),
56
- DatasetColumn("SUPPLIER_NAME", "supplier_name", "VARCHAR(120)", nullable=False),
57
- DatasetColumn("SUPPLIER_CATEGORY", "category", "VARCHAR(80)", nullable=False),
58
- DatasetColumn("COUNTRY", "country", "VARCHAR(80)", nullable=False),
59
- DatasetColumn("REGION", "region", "VARCHAR(40)", nullable=False),
60
- DatasetColumn("STRATEGIC_TIER", "tier", "VARCHAR(40)", nullable=False),
61
- DatasetColumn("LEGACY_ORG", "legacy_org", "VARCHAR(40)", nullable=False),
62
- DatasetColumn("IS_DUPLICATE_SUPPLIER", "flag", "BOOLEAN", nullable=False),
63
- ]
64
-
65
- @staticmethod
66
- def _commodity_columns() -> list[DatasetColumn]:
67
- return [
68
- DatasetColumn("COMMODITY_CODE", "commodity_key", "NUMBER", nullable=False),
69
- DatasetColumn("COMMODITY_NAME", "commodity_name", "VARCHAR(120)", nullable=False),
70
- DatasetColumn("COMMODITY_GROUP", "commodity_group", "VARCHAR(80)", nullable=False),
71
- ]
72
-
73
- @staticmethod
74
- def _customer_columns() -> list[DatasetColumn]:
75
- return [
76
- DatasetColumn("CUSTOMER_ID", "customer_key", "NUMBER", nullable=False),
77
- DatasetColumn("CUSTOMER_NAME", "customer_name", "VARCHAR(120)", nullable=False),
78
- DatasetColumn("INDUSTRY", "industry", "VARCHAR(80)", nullable=False),
79
- DatasetColumn("COUNTRY", "country", "VARCHAR(80)", nullable=False),
80
- DatasetColumn("REGION", "region", "VARCHAR(40)", nullable=False),
81
- DatasetColumn("CUSTOMER_TIER", "tier", "VARCHAR(40)", nullable=False),
82
- DatasetColumn("LEGACY_ORG", "legacy_org", "VARCHAR(40)", nullable=False),
83
- DatasetColumn("CONTRACTED_PAYMENT_TERMS", "payment_terms", "VARCHAR(20)", nullable=False),
84
- ]
85
-
86
- @staticmethod
87
- def _product_columns() -> list[DatasetColumn]:
88
- return [
89
- DatasetColumn("PRODUCT_ID", "product_key", "NUMBER", nullable=False),
90
- DatasetColumn("PRODUCT_NAME", "product_name", "VARCHAR(120)", nullable=False),
91
- DatasetColumn("PRODUCT_CATEGORY", "category", "VARCHAR(80)", nullable=False),
92
- DatasetColumn("LEGACY_ORG", "legacy_org", "VARCHAR(40)", nullable=False),
93
- DatasetColumn("BUSINESS_UNIT", "business_unit", "VARCHAR(80)", nullable=False),
94
- DatasetColumn("SERVICEABLE", "flag", "BOOLEAN", nullable=False),
95
- ]
96
-
97
- @staticmethod
98
- def _invoice_category_columns() -> list[DatasetColumn]:
99
- return [
100
- DatasetColumn("CATEGORY_ID", "category_key", "NUMBER", nullable=False),
101
- DatasetColumn("CATEGORY_NAME", "category_name", "VARCHAR(80)", nullable=False),
102
- DatasetColumn("BUSINESS_UNIT", "business_unit", "VARCHAR(80)", nullable=False),
103
- DatasetColumn("ORDER_TYPE", "order_type", "VARCHAR(40)", nullable=False),
104
- ]
105
-
106
- @staticmethod
107
- def _spend_columns() -> list[DatasetColumn]:
108
- return [
109
- DatasetColumn("SPEND_ID", "fact_key", "NUMBER", nullable=False),
110
- DatasetColumn("PO_ID", "po_id", "VARCHAR(30)", nullable=False),
111
- DatasetColumn("PO_DATE", "date", "DATE", nullable=False),
112
- DatasetColumn("SUPPLIER_ID", "supplier_key", "NUMBER", nullable=False),
113
- DatasetColumn("COMMODITY_CODE", "commodity_key", "NUMBER", nullable=False),
114
- DatasetColumn("SPEND_AMOUNT_USD", "spend", "NUMBER(14,2)", nullable=False),
115
- DatasetColumn("CONTRACTED_PAYMENT_TERMS", "payment_terms", "VARCHAR(20)", nullable=False),
116
- DatasetColumn("INVOICE_DATE", "date", "DATE", nullable=False),
117
- DatasetColumn("PAYMENT_DATE", "date", "DATE", nullable=False),
118
- DatasetColumn("SITE_NAME", "site", "VARCHAR(100)", nullable=False),
119
- DatasetColumn("SITE_CITY", "city", "VARCHAR(80)", nullable=False),
120
- DatasetColumn("SITE_COUNTRY", "country", "VARCHAR(80)", nullable=False),
121
- DatasetColumn("SITE_REGION", "region", "VARCHAR(40)", nullable=False),
122
- DatasetColumn("SITE_TYPE", "site_type", "VARCHAR(40)", nullable=False),
123
- DatasetColumn("SITE_LEGACY_ORG", "legacy_org", "VARCHAR(40)", nullable=False),
124
- DatasetColumn("BUSINESS_UNIT", "business_unit", "VARCHAR(80)", nullable=False),
125
- DatasetColumn("CONSOLIDATION_OPPORTUNITY_USD", "opportunity", "NUMBER(14,2)", nullable=False),
126
- ]
127
-
128
- @staticmethod
129
- def _installed_columns() -> list[DatasetColumn]:
130
- return [
131
- DatasetColumn("INSTALLATION_ID", "fact_key", "NUMBER", nullable=False),
132
- DatasetColumn("CUSTOMER_ID", "customer_key", "NUMBER", nullable=False),
133
- DatasetColumn("PRODUCT_ID", "product_key", "NUMBER", nullable=False),
134
- DatasetColumn("SITE_COUNTRY", "country", "VARCHAR(80)", nullable=False),
135
- DatasetColumn("SITE_REGION", "region", "VARCHAR(40)", nullable=False),
136
- DatasetColumn("INSTALLATION_DATE", "date", "DATE", nullable=False),
137
- DatasetColumn("EXPECTED_OVERHAUL_DATE", "date", "DATE", nullable=False),
138
- DatasetColumn("SERVICE_CONTRACT_IN_PLACE", "flag", "VARCHAR(1)", nullable=False),
139
- DatasetColumn("SERVICE_CONTRACT_TYPE", "contract_type", "VARCHAR(40)", nullable=False),
140
- DatasetColumn("LAST_SERVICE_DATE", "date", "DATE", nullable=True),
141
- DatasetColumn("ASSIGNED_SERVICE_ORG", "service_org", "VARCHAR(40)", nullable=False),
142
- DatasetColumn("AFTERMARKET_GAP_USD", "opportunity", "NUMBER(14,2)", nullable=False),
143
- ]
144
-
145
- @staticmethod
146
- def _order_columns() -> list[DatasetColumn]:
147
- return [
148
- DatasetColumn("ORDER_ID", "fact_key", "NUMBER", nullable=False),
149
- DatasetColumn("ORDER_DATE", "date", "DATE", nullable=False),
150
- DatasetColumn("CUSTOMER_ID", "customer_key", "NUMBER", nullable=False),
151
- DatasetColumn("PRODUCT_ID", "product_key", "NUMBER", nullable=False),
152
- DatasetColumn("ORDER_TYPE", "order_type", "VARCHAR(40)", nullable=False),
153
- DatasetColumn("REVENUE_USD", "revenue", "NUMBER(14,2)", nullable=False),
154
- DatasetColumn("MARGIN_USD", "margin", "NUMBER(14,2)", nullable=False),
155
- DatasetColumn("PROJECT_ID", "project", "VARCHAR(30)", nullable=True),
156
- DatasetColumn("LEGACY_ORG_FULFILLING_ORDER", "legacy_org", "VARCHAR(40)", nullable=False),
157
- DatasetColumn("CROSS_SELL_GAP_FLAG", "flag", "BOOLEAN", nullable=False),
158
- ]
159
-
160
- @staticmethod
161
- def _invoice_columns() -> list[DatasetColumn]:
162
- return [
163
- DatasetColumn("INVOICE_ID", "fact_key", "NUMBER", nullable=False),
164
- DatasetColumn("INVOICE_DATE", "date", "DATE", nullable=False),
165
- DatasetColumn("DUE_DATE", "date", "DATE", nullable=False),
166
- DatasetColumn("CUSTOMER_ID", "customer_key", "NUMBER", nullable=False),
167
- DatasetColumn("CATEGORY_ID", "category_key", "NUMBER", nullable=False),
168
- DatasetColumn("INVOICE_AMOUNT_USD", "amount", "NUMBER(14,2)", nullable=False),
169
- DatasetColumn("PAYMENT_RECEIVED_DATE", "date", "DATE", nullable=True),
170
- DatasetColumn("AMOUNT_PAID_USD", "amount", "NUMBER(14,2)", nullable=False),
171
- DatasetColumn("DAYS_OUTSTANDING", "days", "NUMBER", nullable=False),
172
- DatasetColumn("AGING_BUCKET", "aging_bucket", "VARCHAR(30)", nullable=False),
173
- DatasetColumn("DISPUTE_FLAG", "flag", "VARCHAR(1)", nullable=False),
174
- DatasetColumn("DISPUTE_REASON", "reason", "VARCHAR(80)", nullable=True),
175
- DatasetColumn("COLLECTOR_ASSIGNED", "flag", "VARCHAR(1)", nullable=False),
176
- DatasetColumn("LEGACY_ORG", "legacy_org", "VARCHAR(40)", nullable=False),
177
- DatasetColumn("CASH_ACCELERATION_OPPORTUNITY_USD", "opportunity", "NUMBER(14,2)", nullable=False),
178
- ]
179
-
180
- def _suppliers(self, rng: random.Random) -> list[dict]:
181
- supplier_names = [
182
- "Allegheny Specialty Metals", "Ametek Process Instruments", "Atlas Copco Compressors",
183
- "BASF Industrial Coatings", "Bekaert Steel Wire", "BorgWarner Thermal Systems",
184
- "Bray International", "Chartwell Cryogenic Services", "Curtiss-Wright Valves",
185
- "Dover Precision Components", "Emerson Automation Solutions", "Flowserve Corporation",
186
- "Gardner Denver Nash", "Hubbell Industrial Controls", "ITT Engineered Valves",
187
- "John Crane Sealing Systems", "Linde Engineering Services", "Marmon Industrial Energy",
188
- "Mitsubishi Heavy Compressor", "Mueller Cryogenic Products", "Neles Flow Control",
189
- "Parker Hannifin Motion", "Rotork Controls", "Sandvik Materials Technology",
190
- "Schneider Electric", "Siemens Energy Controls", "SKF Industrial Bearings",
191
- "Sulzer Turbo Services", "Technip Energies Logistics", "Tenaris Precision Tubes",
192
- "Thermo Fisher Controls", "Timken Power Systems", "Trane Industrial Fans",
193
- "Valmet Automation", "Viega Industrial Piping", "Wartsila Gas Solutions",
194
- "Woodward Turbine Controls", "Xylem Cryogenic Pumps", "Yokogawa Process Systems",
195
- "Zeeco Combustion Controls",
196
- ]
197
- countries = [
198
- ("United States", "Americas"), ("Mexico", "Americas"), ("Germany", "EMEA"),
199
- ("Italy", "EMEA"), ("United Kingdom", "EMEA"), ("India", "APAC"),
200
- ("China", "APAC"), ("Japan", "APAC"), ("Singapore", "APAC"),
201
- ("United Arab Emirates", "Middle East"),
202
- ]
203
- categories = ["Raw Materials", "Engineered Components", "MRO", "Logistics", "IT & Software", "Professional Services"]
204
- rows = []
205
- supplier_id = 1
206
- duplicate_base = supplier_names[:40] if len(supplier_names) >= 40 else supplier_names
207
- for name in duplicate_base:
208
- country, region = rng.choice(countries)
209
- rows.append({
210
- "SUPPLIER_ID": supplier_id,
211
- "SUPPLIER_NAME": name,
212
- "SUPPLIER_CATEGORY": "Engineered Components" if supplier_id <= 30 else "IT & Software",
213
- "COUNTRY": country,
214
- "REGION": region,
215
- "STRATEGIC_TIER": "Approved",
216
- "LEGACY_ORG": "Both",
217
- "IS_DUPLICATE_SUPPLIER": True,
218
- })
219
- supplier_id += 1
220
- while supplier_id <= 300:
221
- category = rng.choices(categories, weights=[18, 26, 18, 12, 10, 6], k=1)[0]
222
- country, region = rng.choice(countries)
223
- legacy = rng.choices(["Baker Hughes", "Chart Industries"], weights=[58, 42], k=1)[0]
224
- tail = legacy == "Chart Industries" and region == "APAC" and supplier_id <= 240
225
- rows.append({
226
- "SUPPLIER_ID": supplier_id,
227
- "SUPPLIER_NAME": self._supplier_name(category, supplier_id, rng),
228
- "SUPPLIER_CATEGORY": category,
229
- "COUNTRY": country,
230
- "REGION": region,
231
- "STRATEGIC_TIER": "Tail" if tail else rng.choices(["Preferred", "Approved", "Tail"], weights=[18, 58, 24], k=1)[0],
232
- "LEGACY_ORG": legacy,
233
- "IS_DUPLICATE_SUPPLIER": False,
234
- })
235
- supplier_id += 1
236
- return rows
237
-
238
- @staticmethod
239
- def _supplier_name(category: str, supplier_id: int, rng: random.Random) -> str:
240
- prefixes = {
241
- "Raw Materials": ["Summit Alloy", "Frontier Steel", "Crescent Aluminum", "Keystone Metals", "Allegheny Nickel", "Blue Ridge Metals"],
242
- "Engineered Components": ["Precision Turbine", "CryoValve Systems", "Apex Compressor", "ThermalCore", "Vector Flow", "Atlas Controls"],
243
- "MRO": ["PlantCare Industrial", "Reliant MRO", "MaintainPro Services", "Northstar Repair", "Sagefield Maintenance", "Evergreen Industrial"],
244
- "Logistics": ["GlobalLift Logistics", "HarborLine Freight", "Vector Heavy Haul", "TransOcean Forwarding", "Bridgeport Logistics", "Meridian Cargo"],
245
- "IT & Software": ["CloudBridge Systems", "SecureWorks Industrial", "ERPWorks", "DataLink Software", "Northstar Cloud", "Helix Applications"],
246
- "Professional Services": ["Pinnacle Advisory", "Integration Partners", "Apex Consulting", "NorthBridge Legal", "Cedar Strategy", "HarborPoint Advisors"],
247
- }
248
- suffixes = ["Group", "Solutions", "Services", "Industries", "Partners", "Supply", "Works", "Technologies", "Associates", "International"]
249
- regions = ["Americas", "Gulf Coast", "North Sea", "Adriatic", "Singapore", "Midlands", "Bavaria", "Piedmont", "Kanto", "Jebel Ali"]
250
- prefix = prefixes[category][supplier_id % len(prefixes[category])]
251
- suffix = suffixes[(supplier_id // len(prefixes[category])) % len(suffixes)]
252
- region = regions[(supplier_id // 7) % len(regions)]
253
- return f"{region} {prefix} {suffix}"
254
-
255
- @staticmethod
256
- def _commodities() -> list[dict]:
257
- items = [
258
- ("Aluminum Alloys", "Raw Materials"), ("Steel Forgings", "Raw Materials"),
259
- ("Nickel Alloy Tubing", "Raw Materials"), ("Stainless Plate", "Raw Materials"),
260
- ("Brazed Aluminum Heat Exchanger Cores", "Engineered Components"),
261
- ("Centrifugal Compressor Impellers", "Engineered Components"),
262
- ("Cryogenic Valves", "Engineered Components"), ("Control Valves", "Engineered Components"),
263
- ("Industrial Fans and Blowers", "Engineered Components"), ("Electronic Controls", "Engineered Components"),
264
- ("Insulation Materials", "Raw Materials"), ("Rotating Equipment Bearings", "Engineered Components"),
265
- ("Seals and Gaskets", "MRO"), ("Field Service Tooling", "MRO"),
266
- ("Freight and Heavy Haul", "Logistics"), ("Ocean Freight", "Logistics"),
267
- ("ERP Software Licenses", "IT & Software"), ("Engineering CAD Subscriptions", "IT & Software"),
268
- ("Cybersecurity Subscriptions", "IT & Software"), ("Cloud Data Platforms", "IT & Software"),
269
- ("Copper Bus Bar Assemblies", "Engineered Components"), ("Pressure Vessel Shells", "Engineered Components"),
270
- ("Plate Fin Assemblies", "Engineered Components"), ("Compressor Dry Gas Seals", "Engineered Components"),
271
- ("Turbine Blade Coatings", "Engineered Components"), ("Cryogenic Pump Skids", "Engineered Components"),
272
- ("Thermal Insulation Jackets", "Raw Materials"), ("Specialty Welding Consumables", "MRO"),
273
- ("Instrumentation Calibration", "MRO"), ("Plant Safety Supplies", "MRO"),
274
- ("Air Freight Expedites", "Logistics"), ("Regional Trucking Lanes", "Logistics"),
275
- ("Customs Brokerage", "Logistics"), ("Warehouse Handling", "Logistics"),
276
- ("PLM Software Seats", "IT & Software"), ("Procurement Platform Licenses", "IT & Software"),
277
- ("Manufacturing Execution Systems", "IT & Software"), ("Managed Network Services", "IT & Software"),
278
- ("Integration Advisory Services", "Professional Services"), ("Engineering Contractor Services", "Professional Services"),
279
- ]
280
- return [
281
- {"COMMODITY_CODE": idx, "COMMODITY_NAME": name, "COMMODITY_GROUP": group}
282
- for idx, (name, group) in enumerate(items, start=1)
283
- ]
284
-
285
- def _customers(self, rng: random.Random) -> list[dict]:
286
- base = [
287
- ("QatarEnergy LNG", "LNG", "Qatar", "Middle East", "Strategic"),
288
- ("ADNOC Gas Processing", "LNG", "United Arab Emirates", "Middle East", "Strategic"),
289
- ("Saudi Aramco", "Refining", "Saudi Arabia", "Middle East", "Strategic"),
290
- ("Microsoft Data Center Operations", "Data Centers", "United States", "Americas", "Strategic"),
291
- ("Amazon Web Services", "Data Centers", "United States", "Americas", "Strategic"),
292
- ("Air Liquide", "Industrial Gas", "France", "EMEA", "Strategic"),
293
- ("Linde", "Industrial Gas", "Germany", "EMEA", "Strategic"),
294
- ("JERA Power", "Power Generation", "Japan", "APAC", "Strategic"),
295
- ("Rio Tinto Minerals", "Metals & Mining", "Australia", "APAC", "Growth"),
296
- ("Plug Power Hydrogen", "New Energy", "United States", "Americas", "Growth"),
297
- ("Occidental Low Carbon Ventures", "New Energy", "United States", "Americas", "Growth"),
298
- ]
299
- industries = ["LNG", "Data Centers", "Power Generation", "Industrial Gas", "New Energy", "Refining", "Metals & Mining"]
300
- countries = [
301
- ("United States", "Americas"), ("Brazil", "Americas"), ("Germany", "EMEA"), ("France", "EMEA"),
302
- ("United Kingdom", "EMEA"), ("India", "APAC"), ("Japan", "APAC"), ("Australia", "APAC"),
303
- ("Qatar", "Middle East"), ("United Arab Emirates", "Middle East"), ("Saudi Arabia", "Middle East"),
304
- ]
305
- rows = []
306
- for idx, (name, industry, country, region, tier) in enumerate(base, start=1):
307
- rows.append(self._customer_row(idx, name, industry, country, region, tier, rng))
308
- for idx in range(len(rows) + 1, 201):
309
- industry = rng.choice(industries)
310
- country, region = rng.choice(countries)
311
- tier = rng.choices(["Strategic", "Growth", "Transactional"], weights=[18, 44, 38], k=1)[0]
312
- name = f"{rng.choice(['Atlas', 'Bluewater', 'Cedar', 'Frontier', 'Helios', 'Nexus', 'Orion', 'Summit'])} {industry} {rng.choice(['Holdings', 'Operations', 'Energy', 'Systems', 'Partners'])}"
313
- rows.append(self._customer_row(idx, name, industry, country, region, tier, rng))
314
- return rows
315
-
316
- @staticmethod
317
- def _customer_row(idx: int, name: str, industry: str, country: str, region: str, tier: str, rng: random.Random) -> dict:
318
- legacy = rng.choices(["Baker Hughes", "Chart Industries", "Both"], weights=[48, 36, 16], k=1)[0]
319
- terms = rng.choices(["Net 30", "Net 45", "Net 60", "Net 90"], weights=[36, 28, 24, 12], k=1)[0]
320
- if legacy == "Chart Industries" and region in {"APAC", "Middle East"}:
321
- terms = rng.choices(["Net 60", "Net 90"], weights=[45, 55], k=1)[0]
322
- return {
323
- "CUSTOMER_ID": idx,
324
- "CUSTOMER_NAME": name,
325
- "INDUSTRY": industry,
326
- "COUNTRY": country,
327
- "REGION": region,
328
- "CUSTOMER_TIER": tier,
329
- "LEGACY_ORG": legacy,
330
- "CONTRACTED_PAYMENT_TERMS": terms,
331
- }
332
-
333
- @staticmethod
334
- def _products() -> list[dict]:
335
- product_defs = [
336
- ("LM9000 Gas Turbine Package", "Rotating Equipment", "Baker Hughes", "IET", True),
337
- ("NovaLT Compressor Train", "Rotating Equipment", "Baker Hughes", "IET", True),
338
- ("Centrifugal Compressor Bundle", "Rotating Equipment", "Baker Hughes", "IET", True),
339
- ("Masoneilan Control Valve", "Flow Control", "Baker Hughes", "IET", True),
340
- ("Bently Nevada Monitoring Suite", "Digital & Services", "Baker Hughes", "IET", False),
341
- ("Brazed Aluminum Heat Exchanger", "Heat Transfer", "Chart Industries", "Heat Transfer Systems", True),
342
- ("Cryogenic Storage Tank", "Cryogenic Equipment", "Chart Industries", "Cryo Tank Solutions", True),
343
- ("Hydrogen Liquefaction Cold Box", "Cryogenic Equipment", "Chart Industries", "Specialty Products", True),
344
- ("Hudson Industrial Fan", "Industrial Fans & Blowers", "Chart Industries", "RSL", True),
345
- ("Howden Roots Blower", "Industrial Fans & Blowers", "Chart Industries", "RSL", True),
346
- ]
347
- variants = [
348
- "Base", "High Efficiency", "Low Emissions", "Arctic Service", "Desert Service",
349
- "Modular", "Compact", "High Pressure", "Low Temperature", "Digital Ready",
350
- "Service Plus", "Reliability", "Premium", "Extended Duty", "Rapid Deploy",
351
- ]
352
- rows = []
353
- for idx in range(1, 151):
354
- name, category, legacy, bu, serviceable = product_defs[(idx - 1) % len(product_defs)]
355
- suffix = "" if idx <= len(product_defs) else f" - {variants[((idx - 1) // len(product_defs) - 1) % len(variants)]}"
356
- rows.append({
357
- "PRODUCT_ID": idx,
358
- "PRODUCT_NAME": f"{name}{suffix}",
359
- "PRODUCT_CATEGORY": category,
360
- "LEGACY_ORG": legacy,
361
- "BUSINESS_UNIT": bu,
362
- "SERVICEABLE": serviceable,
363
- })
364
- return rows
365
-
366
- @staticmethod
367
- def _invoice_categories() -> list[dict]:
368
- categories = [
369
- ("New Equipment", "IET", "Project-Based"), ("Aftermarket Parts", "IET", "Recurring"),
370
- ("Field Service", "IET", "Recurring"), ("Digital", "IET", "Recurring"),
371
- ("Leasing", "Cryo Tank Solutions", "Recurring"), ("New Equipment", "Cryo Tank Solutions", "Project-Based"),
372
- ("Aftermarket Parts", "Heat Transfer Systems", "Recurring"), ("Field Service", "RSL", "One-Time"),
373
- ("Digital", "RSL", "Recurring"), ("New Equipment", "Specialty Products", "Project-Based"),
374
- ]
375
- while len(categories) < 20:
376
- categories.append(categories[len(categories) % 10])
377
- return [
378
- {"CATEGORY_ID": idx, "CATEGORY_NAME": name, "BUSINESS_UNIT": bu, "ORDER_TYPE": order_type}
379
- for idx, (name, bu, order_type) in enumerate(categories, start=1)
380
- ]
381
-
382
- def _spend_rows(self, rng: random.Random, suppliers: list[dict], commodities: list[dict], count: int) -> list[dict]:
383
- start = date(2025, 1, 1)
384
- days = (date(2026, 6, 30) - start).days
385
- sites = [
386
- ("Florence Turbomachinery Plant", "Florence", "Italy", "EMEA", "Manufacturing", "Baker Hughes", "IET"),
387
- ("Houston IET Service Center", "Houston", "United States", "Americas", "Service Center", "Baker Hughes", "IET"),
388
- ("Tulsa Compressor Works", "Tulsa", "United States", "Americas", "Manufacturing", "Baker Hughes", "OFSE"),
389
- ("The Woodlands Integration Office", "The Woodlands", "United States", "Americas", "Office", "Baker Hughes", "IET"),
390
- ("Ball Ground Cryogenic Plant", "Ball Ground", "United States", "Americas", "Manufacturing", "Chart Industries", "Cryo Tank Solutions"),
391
- ("New Prague Heat Transfer Plant", "New Prague", "United States", "Americas", "Manufacturing", "Chart Industries", "Heat Transfer Systems"),
392
- ("Changzhou Cryogenic Plant", "Changzhou", "China", "APAC", "Manufacturing", "Chart Industries", "Cryo Tank Solutions"),
393
- ("Singapore RSL Service Hub", "Singapore", "Singapore", "APAC", "Service Center", "Chart Industries", "RSL"),
394
- ]
395
- dup_suppliers = [s for s in suppliers if s["IS_DUPLICATE_SUPPLIER"]]
396
- chart_tail_apac = [s for s in suppliers if s["LEGACY_ORG"] == "Chart Industries" and s["REGION"] == "APAC" and s["STRATEGIC_TIER"] == "Tail"]
397
- it_commodities = [c for c in commodities if c["COMMODITY_GROUP"] == "IT & Software"]
398
- engineered = [c for c in commodities if c["COMMODITY_GROUP"] == "Engineered Components"]
399
- rows = []
400
- for idx in range(1, count + 1):
401
- anomaly_roll = rng.random()
402
- if anomaly_roll < 0.26:
403
- supplier = rng.choice(dup_suppliers)
404
- commodity = rng.choice(engineered)
405
- amount = rng.uniform(75000, 650000)
406
- elif anomaly_roll < 0.44 and chart_tail_apac:
407
- supplier = rng.choice(chart_tail_apac)
408
- commodity = rng.choice(commodities)
409
- amount = rng.uniform(3500, 22000)
410
- elif anomaly_roll < 0.54:
411
- supplier = rng.choice([s for s in suppliers if s["SUPPLIER_CATEGORY"] == "IT & Software"])
412
- commodity = rng.choice(it_commodities)
413
- amount = rng.uniform(18000, 175000)
414
- else:
415
- supplier = rng.choice(suppliers)
416
- commodity = rng.choice(commodities)
417
- amount = rng.uniform(8000, 260000)
418
- site = rng.choice(sites)
419
- po_date = start + timedelta(days=rng.randint(0, days))
420
- invoice_date = po_date + timedelta(days=rng.randint(1, 12))
421
- if site[5] == "Baker Hughes" and commodity["COMMODITY_NAME"] in {"Aluminum Alloys", "Steel Forgings"}:
422
- terms = "Net 30"
423
- elif site[5] == "Chart Industries" and commodity["COMMODITY_NAME"] in {"Aluminum Alloys", "Steel Forgings"}:
424
- terms = rng.choice(["Net 60", "Net 90"])
425
- else:
426
- terms = rng.choice(["Net 30", "Net 45", "Net 60", "Net 90"])
427
- term_days = int(terms.split()[1])
428
- payment_date = invoice_date + timedelta(days=max(5, int(rng.gauss(term_days, 8))))
429
- consolidation_opportunity = amount * (0.10 if supplier["IS_DUPLICATE_SUPPLIER"] else 0.04 if supplier["SUPPLIER_CATEGORY"] == "IT & Software" else 0.0)
430
- rows.append({
431
- "SPEND_ID": idx,
432
- "PO_ID": f"PO-{po_date.year}-{idx:07d}",
433
- "PO_DATE": po_date.isoformat(),
434
- "SUPPLIER_ID": supplier["SUPPLIER_ID"],
435
- "COMMODITY_CODE": commodity["COMMODITY_CODE"],
436
- "SPEND_AMOUNT_USD": round(amount, 2),
437
- "CONTRACTED_PAYMENT_TERMS": terms,
438
- "INVOICE_DATE": invoice_date.isoformat(),
439
- "PAYMENT_DATE": payment_date.isoformat(),
440
- "SITE_NAME": site[0],
441
- "SITE_CITY": site[1],
442
- "SITE_COUNTRY": site[2],
443
- "SITE_REGION": site[3],
444
- "SITE_TYPE": site[4],
445
- "SITE_LEGACY_ORG": site[5],
446
- "BUSINESS_UNIT": site[6],
447
- "CONSOLIDATION_OPPORTUNITY_USD": round(consolidation_opportunity, 2),
448
- })
449
- return rows
450
-
451
- def _installed_rows(self, rng: random.Random, customers: list[dict], products: list[dict], count: int) -> list[dict]:
452
- rows = []
453
- start = date(2016, 1, 1)
454
- for idx in range(1, count + 1):
455
- customer = rng.choice(customers)
456
- product = rng.choice([p for p in products if p["SERVICEABLE"]])
457
- install_date = start + timedelta(days=rng.randint(0, 3650))
458
- expected_overhaul = install_date + timedelta(days=rng.randint(1800, 2900))
459
- chart_gap = product["LEGACY_ORG"] == "Chart Industries" and customer["REGION"] in {"APAC", "Middle East"}
460
- near_overhaul = product["PRODUCT_CATEGORY"] == "Heat Transfer" and date(2018, 1, 1) <= install_date <= date(2021, 12, 31)
461
- attach_prob = 0.11 if chart_gap else 0.58 if product["LEGACY_ORG"] == "Baker Hughes" else 0.28
462
- has_contract = rng.random() < attach_prob and not near_overhaul
463
- contract_type = rng.choice(["Full", "Preventive"]) if has_contract else "None"
464
- last_service = install_date + timedelta(days=rng.randint(120, max(121, (date(2026, 6, 30) - install_date).days)))
465
- aftermarket_gap = 0.0 if has_contract else rng.uniform(85000, 750000)
466
- rows.append({
467
- "INSTALLATION_ID": idx,
468
- "CUSTOMER_ID": customer["CUSTOMER_ID"],
469
- "PRODUCT_ID": product["PRODUCT_ID"],
470
- "SITE_COUNTRY": customer["COUNTRY"],
471
- "SITE_REGION": customer["REGION"],
472
- "INSTALLATION_DATE": install_date.isoformat(),
473
- "EXPECTED_OVERHAUL_DATE": expected_overhaul.isoformat(),
474
- "SERVICE_CONTRACT_IN_PLACE": "Y" if has_contract else "N",
475
- "SERVICE_CONTRACT_TYPE": contract_type,
476
- "LAST_SERVICE_DATE": last_service.isoformat() if has_contract or rng.random() < 0.35 else None,
477
- "ASSIGNED_SERVICE_ORG": rng.choice(["Baker Hughes", "Chart RSL", "Third Party"]) if has_contract else rng.choice(["Third Party", "None"]),
478
- "AFTERMARKET_GAP_USD": round(aftermarket_gap, 2),
479
- })
480
- return rows
481
-
482
- def _order_rows(self, rng: random.Random, customers: list[dict], products: list[dict], installed: list[dict], count: int) -> list[dict]:
483
- start = date(2024, 1, 1)
484
- days = (date(2026, 6, 30) - start).days
485
- project_ids = [f"PRJ-LNG-{idx:03d}" for idx in range(1, 19)] + [f"PRJ-DC-{idx:03d}" for idx in range(1, 13)]
486
- project_customers = {pid: rng.choice([c for c in customers if c["INDUSTRY"] in {"LNG", "Data Centers"}]) for pid in project_ids}
487
- rows = []
488
- for idx in range(1, count + 1):
489
- order_date = start + timedelta(days=rng.randint(0, days))
490
- if idx <= len(project_ids) * 2:
491
- project_id = project_ids[(idx - 1) // 2]
492
- customer = project_customers[project_id]
493
- product = rng.choice([p for p in products if p["LEGACY_ORG"] == ("Baker Hughes" if idx % 2 else "Chart Industries")])
494
- order_type = "New Equipment"
495
- cross_gap = product["LEGACY_ORG"] == "Chart Industries"
496
- else:
497
- customer = rng.choice(customers)
498
- product = rng.choice(products)
499
- project_id = None if rng.random() < 0.82 else rng.choice(project_ids)
500
- if customer["INDUSTRY"] in {"New Energy"} and product["LEGACY_ORG"] == "Chart Industries":
501
- order_type = rng.choices(["New Equipment", "Aftermarket Parts", "Service", "Upgrade", "Digital"], weights=[82, 4, 3, 8, 3], k=1)[0]
502
- else:
503
- order_type = rng.choices(["New Equipment", "Aftermarket Parts", "Service", "Upgrade", "Digital"], weights=[30, 28, 24, 12, 6], k=1)[0]
504
- cross_gap = bool(project_id and product["LEGACY_ORG"] == "Chart Industries" and order_type == "New Equipment")
505
- base = {
506
- "New Equipment": rng.uniform(250000, 8_500_000),
507
- "Aftermarket Parts": rng.uniform(12000, 425000),
508
- "Service": rng.uniform(25000, 850000),
509
- "Upgrade": rng.uniform(90000, 1_600_000),
510
- "Digital": rng.uniform(15000, 280000),
511
- }[order_type]
512
- margin_pct = {"New Equipment": 0.22, "Aftermarket Parts": 0.36, "Service": 0.42, "Upgrade": 0.34, "Digital": 0.48}[order_type]
513
- revenue = round(base * rng.uniform(0.72, 1.36), 2)
514
- margin = round(revenue * rng.uniform(margin_pct - 0.06, margin_pct + 0.08), 2)
515
- rows.append({
516
- "ORDER_ID": idx,
517
- "ORDER_DATE": order_date.isoformat(),
518
- "CUSTOMER_ID": customer["CUSTOMER_ID"],
519
- "PRODUCT_ID": product["PRODUCT_ID"],
520
- "ORDER_TYPE": order_type,
521
- "REVENUE_USD": revenue,
522
- "MARGIN_USD": margin,
523
- "PROJECT_ID": project_id,
524
- "LEGACY_ORG_FULFILLING_ORDER": product["LEGACY_ORG"],
525
- "CROSS_SELL_GAP_FLAG": cross_gap,
526
- })
527
- return rows
528
-
529
- def _invoice_rows(self, rng: random.Random, customers: list[dict], categories: list[dict], count: int) -> list[dict]:
530
- start = date(2025, 1, 1)
531
- end = date(2026, 6, 30)
532
- days = (end - start).days
533
- rows = []
534
- lng_customers = [c for c in customers if c["INDUSTRY"] == "LNG"]
535
- chart_apac_me = [c for c in customers if c["LEGACY_ORG"] in {"Chart Industries", "Both"} and c["REGION"] in {"APAC", "Middle East"}]
536
- rsl_categories = [c for c in categories if c["BUSINESS_UNIT"] == "RSL"] or categories
537
- for idx in range(1, count + 1):
538
- if idx <= 15 and lng_customers:
539
- customer = rng.choice(lng_customers)
540
- category = rng.choice([c for c in categories if c["ORDER_TYPE"] == "Project-Based"])
541
- amount = rng.uniform(5_000_000, 20_000_000)
542
- days_outstanding = rng.randint(61, 89)
543
- dispute = "N"
544
- reason = None
545
- elif idx <= int(count * 0.18) and chart_apac_me:
546
- customer = rng.choice(chart_apac_me)
547
- category = rng.choice(categories)
548
- amount = rng.uniform(70000, 1_200_000)
549
- days_outstanding = rng.randint(75, 105)
550
- dispute = rng.choice(["N", "N", "Y"])
551
- reason = None if dispute == "N" else rng.choice(["Missing PO", "Contract Terms"])
552
- elif idx <= int(count * 0.32):
553
- customer = rng.choice([c for c in customers if c["INDUSTRY"] == "New Energy"])
554
- category = rng.choice(categories)
555
- amount = rng.uniform(90000, 2_500_000)
556
- days_outstanding = rng.randint(20, 95)
557
- dispute = rng.choice(["N", "Y"])
558
- reason = None if dispute == "N" else "Contract Terms"
559
- elif idx <= int(count * 0.50):
560
- customer = rng.choice(customers)
561
- category = rng.choice(rsl_categories)
562
- amount = rng.uniform(2500, 50000)
563
- days_outstanding = rng.randint(61, 140)
564
- dispute = rng.choice(["N", "N", "Y"])
565
- reason = None if dispute == "N" else rng.choice(["Missing PO", "Quantity Dispute"])
566
- else:
567
- customer = rng.choice(customers)
568
- category = rng.choice(categories)
569
- amount = rng.uniform(8000, 950000)
570
- days_outstanding = max(0, int(rng.gauss(42 if customer["LEGACY_ORG"] == "Baker Hughes" else 62, 22)))
571
- dispute = rng.choices(["N", "Y"], weights=[86, 14], k=1)[0]
572
- reason = None if dispute == "N" else rng.choice(["Pricing Discrepancy", "Missing PO", "Quantity Dispute", "Contract Terms"])
573
- invoice_date = end - timedelta(days=days_outstanding)
574
- term_days = int(customer["CONTRACTED_PAYMENT_TERMS"].split()[1])
575
- due_date = invoice_date + timedelta(days=term_days)
576
- paid = days_outstanding <= term_days + rng.randint(0, 18)
577
- payment_date = invoice_date + timedelta(days=days_outstanding) if paid else None
578
- amount_paid = amount if paid else 0.0
579
- bucket = self._aging_bucket(max(0, (end - due_date).days if not paid else 0))
580
- cash_opportunity = 0.0 if paid else amount * min(max(days_outstanding - 45, 0), 90) / 365.0
581
- rows.append({
582
- "INVOICE_ID": idx,
583
- "INVOICE_DATE": invoice_date.isoformat(),
584
- "DUE_DATE": due_date.isoformat(),
585
- "CUSTOMER_ID": customer["CUSTOMER_ID"],
586
- "CATEGORY_ID": category["CATEGORY_ID"],
587
- "INVOICE_AMOUNT_USD": round(amount, 2),
588
- "PAYMENT_RECEIVED_DATE": payment_date.isoformat() if payment_date else None,
589
- "AMOUNT_PAID_USD": round(amount_paid, 2),
590
- "DAYS_OUTSTANDING": days_outstanding,
591
- "AGING_BUCKET": bucket,
592
- "DISPUTE_FLAG": dispute,
593
- "DISPUTE_REASON": reason,
594
- "COLLECTOR_ASSIGNED": "Y" if (not paid and days_outstanding > 60 and rng.random() < 0.55) else "N",
595
- "LEGACY_ORG": customer["LEGACY_ORG"],
596
- "CASH_ACCELERATION_OPPORTUNITY_USD": round(cash_opportunity, 2),
597
- })
598
- return rows
599
-
600
- @staticmethod
601
- def _aging_bucket(days_past_due: int) -> str:
602
- if days_past_due <= 0:
603
- return "Current"
604
- if days_past_due <= 30:
605
- return "1-30 Days"
606
- if days_past_due <= 60:
607
- return "30-60 Days"
608
- if days_past_due <= 90:
609
- return "60-90 Days"
610
- return "90+ Days"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
demoprep_app/dataset/generators/router.py CHANGED
@@ -4,7 +4,6 @@ from __future__ import annotations
4
 
5
  from demoprep_app.dataset.contracts import DatasetBundle
6
  from demoprep_app.dataset.generators.base import ScenarioDatasetGenerator
7
- from demoprep_app.dataset.generators.post_merger_integration import PostMergerIntegrationDatasetGenerator
8
  from demoprep_app.dataset.generators.retail_sales import RetailSalesDatasetGenerator
9
  from demoprep_app.dataset.generators.saas_sales import SaaSSalesDatasetGenerator
10
  from demoprep_app.dataset.generators.template_generator import TemplateDatasetGenerator
@@ -14,7 +13,6 @@ from demoprep_app.scenario.contract import ScenarioContract
14
  class DatasetGeneratorRouter:
15
  def __init__(self, generators: list[ScenarioDatasetGenerator] | None = None) -> None:
16
  self.generators = generators or [
17
- PostMergerIntegrationDatasetGenerator(),
18
  RetailSalesDatasetGenerator(),
19
  SaaSSalesDatasetGenerator(),
20
  TemplateDatasetGenerator(),
 
4
 
5
  from demoprep_app.dataset.contracts import DatasetBundle
6
  from demoprep_app.dataset.generators.base import ScenarioDatasetGenerator
 
7
  from demoprep_app.dataset.generators.retail_sales import RetailSalesDatasetGenerator
8
  from demoprep_app.dataset.generators.saas_sales import SaaSSalesDatasetGenerator
9
  from demoprep_app.dataset.generators.template_generator import TemplateDatasetGenerator
 
13
  class DatasetGeneratorRouter:
14
  def __init__(self, generators: list[ScenarioDatasetGenerator] | None = None) -> None:
15
  self.generators = generators or [
 
16
  RetailSalesDatasetGenerator(),
17
  SaaSSalesDatasetGenerator(),
18
  TemplateDatasetGenerator(),
demoprep_app/pipeline/dataset_first.py CHANGED
@@ -8,6 +8,7 @@ from dataclasses import dataclass
8
 
9
  from demoprep_app.dataset.company_contract import build_company_data_contract
10
  from demoprep_app.dataset.contracts import DatasetBundle
 
11
  from demoprep_app.dataset.generators.router import DatasetGeneratorRouter
12
  from demoprep_app.ddl import DatasetDdlCompiler
13
  from demoprep_app.scenario.contract import DimensionSpec, ScenarioContract
@@ -62,11 +63,6 @@ def infer_scenario_type(
62
 
63
  if any(tok in text for tok in ("air transport", "airline", "airport", "aircraft", "flight", "passenger", "baggage")) and "finance" in text:
64
  return "airline_finance"
65
- if (
66
- any(tok in text for tok in ("post-merger", "post merger", "acquisition", "acquired", "integration", "synergy", "synergies"))
67
- and any(tok in text for tok in ("supplier", "spend", "aftermarket", "installed base", "attach rate", "ar aging", "dso", "invoicing"))
68
- ):
69
- return "post_merger_integration"
70
  if any(tok in text for tok in ("air transport", "airline", "airport", "aircraft", "flight", "passenger", "baggage")):
71
  return "airline_route_operations"
72
  if _has_any_term(text, ("ad yield", "ad monetization", "advertising monetization", "arpu", "ecpm", "fill rate", "ctv", "connected tv", "smart tv", "smartcast", "fast channel", "ad requests", "completion rate")):
@@ -112,6 +108,11 @@ def _has_any_term(text: str, terms: tuple[str, ...]) -> bool:
112
  return any(re.search(rf"(?<![a-z0-9]){re.escape(term.lower())}(?![a-z0-9])", text) for term in terms)
113
 
114
 
 
 
 
 
 
115
  def build_dataset_first_demo(
116
  *,
117
  company_name: str,
@@ -124,7 +125,27 @@ def build_dataset_first_demo(
124
  llm_model: str | None = None,
125
  use_llm_contract: bool = False,
126
  ) -> DatasetFirstBuild | None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  preliminary_type = infer_scenario_type(use_case, vertical, function, company_name, company_url)
 
 
128
  deterministic_types = {
129
  "saas_sales",
130
  "retail_sales",
@@ -148,7 +169,6 @@ def build_dataset_first_demo(
148
  "cpg_finance",
149
  "portfolio_financials",
150
  "inventory_supply_chain",
151
- "post_merger_integration",
152
  }
153
  extraction = extract_dataset_scenario(
154
  company_name=company_name,
@@ -160,6 +180,19 @@ def build_dataset_first_demo(
160
  llm_model=llm_model,
161
  use_llm=use_llm_contract,
162
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  if (
164
  use_llm_contract
165
  and preliminary_type not in deterministic_types
@@ -177,6 +210,8 @@ def build_dataset_first_demo(
177
  scenario_type = preliminary_type
178
  else:
179
  scenario_type = extraction.scenario_type if extraction else preliminary_type
 
 
180
  if not scenario_type:
181
  return None
182
 
 
8
 
9
  from demoprep_app.dataset.company_contract import build_company_data_contract
10
  from demoprep_app.dataset.contracts import DatasetBundle
11
+ from demoprep_app.dataset.explicit_prompt import build_explicit_prompt_dataset
12
  from demoprep_app.dataset.generators.router import DatasetGeneratorRouter
13
  from demoprep_app.ddl import DatasetDdlCompiler
14
  from demoprep_app.scenario.contract import DimensionSpec, ScenarioContract
 
63
 
64
  if any(tok in text for tok in ("air transport", "airline", "airport", "aircraft", "flight", "passenger", "baggage")) and "finance" in text:
65
  return "airline_finance"
 
 
 
 
 
66
  if any(tok in text for tok in ("air transport", "airline", "airport", "aircraft", "flight", "passenger", "baggage")):
67
  return "airline_route_operations"
68
  if _has_any_term(text, ("ad yield", "ad monetization", "advertising monetization", "arpu", "ecpm", "fill rate", "ctv", "connected tv", "smart tv", "smartcast", "fast channel", "ad requests", "completion rate")):
 
108
  return any(re.search(rf"(?<![a-z0-9]){re.escape(term.lower())}(?![a-z0-9])", text) for term in terms)
109
 
110
 
111
+ def _is_custom_request(vertical: str | None, function: str | None) -> bool:
112
+ markers = {value.strip().lower() for value in (vertical or "", function or "") if value.strip()}
113
+ return "custom" in markers or "* custom *" in markers
114
+
115
+
116
  def build_dataset_first_demo(
117
  *,
118
  company_name: str,
 
125
  llm_model: str | None = None,
126
  use_llm_contract: bool = False,
127
  ) -> DatasetFirstBuild | None:
128
+ seed_text = f"{company_url}|{use_case}".lower()
129
+ explicit_seed = int(hashlib.sha256(seed_text.encode("utf-8")).hexdigest()[:8], 16)
130
+ explicit_dataset = build_explicit_prompt_dataset(
131
+ company_name=company_name,
132
+ company_url=company_url,
133
+ use_case=use_case,
134
+ row_count_guidance=row_count_guidance,
135
+ seed=explicit_seed,
136
+ )
137
+ if explicit_dataset is not None:
138
+ ddl = DatasetDdlCompiler().compile(explicit_dataset)
139
+ return DatasetFirstBuild(
140
+ scenario=explicit_dataset.scenario,
141
+ dataset=explicit_dataset,
142
+ ddl=ddl,
143
+ )
144
+
145
+ custom_request = _is_custom_request(vertical, function)
146
  preliminary_type = infer_scenario_type(use_case, vertical, function, company_name, company_url)
147
+ if custom_request:
148
+ preliminary_type = None
149
  deterministic_types = {
150
  "saas_sales",
151
  "retail_sales",
 
169
  "cpg_finance",
170
  "portfolio_financials",
171
  "inventory_supply_chain",
 
172
  }
173
  extraction = extract_dataset_scenario(
174
  company_name=company_name,
 
180
  llm_model=llm_model,
181
  use_llm=use_llm_contract,
182
  )
183
+ if custom_request and (extraction is None or extraction.source != "llm"):
184
+ if use_llm_contract:
185
+ issue_detail = "; ".join(extraction.constraints[-3:]) if extraction and extraction.constraints else "no LLM contract returned"
186
+ scenario_name = extraction.scenario_type if extraction else "unknown"
187
+ confidence = extraction.confidence if extraction else 0.0
188
+ source = extraction.source if extraction else "none"
189
+ raise ValueError(
190
+ "AI dataset contract unavailable for custom demo. "
191
+ f"Refusing weak fallback contract for scenario '{scenario_name}' "
192
+ f"(source={source}, confidence={confidence}). "
193
+ f"{issue_detail}"
194
+ )
195
+ return None
196
  if (
197
  use_llm_contract
198
  and preliminary_type not in deterministic_types
 
210
  scenario_type = preliminary_type
211
  else:
212
  scenario_type = extraction.scenario_type if extraction else preliminary_type
213
+ if scenario_type == "post_merger_integration":
214
+ return None
215
  if not scenario_type:
216
  return None
217
 
demoprep_app/scenario/extractor.py CHANGED
@@ -218,8 +218,6 @@ def _fallback_extract(text: str, use_case: str, vertical: str | None, function:
218
 
219
  def _infer_from_text(text: str) -> str | None:
220
  low = text.lower()
221
- if _has_any(low, ("post-merger", "post merger", "acquisition", "acquired", "integration", "synergy", "synergies", "installed base", "aftermarket attach", "ar aging", "dso")):
222
- return "post_merger_integration"
223
  if _has_any(low, ("professional services", "consulting", "assurance", "audit", "advisory", "billable", "realized rate", "engagement margin", "staff pyramid", "cross-sell")):
224
  return "professional_services_engagements"
225
  if _has_any(low, ("arena", "venue", "stadium", "ticket", "attendance", "fan", "season ticket", "suite", "sponsorship", "basketball", "hockey", "concert", "theater", "theatre")):
 
218
 
219
  def _infer_from_text(text: str) -> str | None:
220
  low = text.lower()
 
 
221
  if _has_any(low, ("professional services", "consulting", "assurance", "audit", "advisory", "billable", "realized rate", "engagement margin", "staff pyramid", "cross-sell")):
222
  return "professional_services_engagements"
223
  if _has_any(low, ("arena", "venue", "stadium", "ticket", "attendance", "fan", "season ticket", "suite", "sponsorship", "basketball", "hockey", "concert", "theater", "theatre")):
demoprep_app/scenario/families.py CHANGED
@@ -192,20 +192,6 @@ SCENARIO_FAMILIES: dict[str, ScenarioFamilyTemplate] = {
192
  "operations",
193
  ("Which lines have the best yield?", "Where is downtime increasing?", "How is throughput trending?"),
194
  ),
195
- "post_merger_integration": ScenarioFamilyTemplate(
196
- "post_merger_integration",
197
- "FACT_SPEND",
198
- "supplier-commodity-site-purchase-order",
199
- "SPEND_AMOUNT_USD",
200
- ("DIM_SUPPLIER", "DIM_COMMODITY", "DIM_CUSTOMER", "DIM_PRODUCT", "DIM_INVOICE_CATEGORY"),
201
- ("Supplier Consolidation", "Aftermarket Attach", "Cross-Sell", "AR Aging", "Cash Flow"),
202
- "post_merger_integration",
203
- (
204
- "Where are duplicate suppliers and fragmented spend creating synergy opportunities?",
205
- "Which installed-base cohorts have low aftermarket attach rate?",
206
- "Which overdue invoices and regions are trapping working capital?",
207
- ),
208
- ),
209
  "automotive_sales": ScenarioFamilyTemplate(
210
  "automotive_sales",
211
  "VEHICLE_SALES",
 
192
  "operations",
193
  ("Which lines have the best yield?", "Where is downtime increasing?", "How is throughput trending?"),
194
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  "automotive_sales": ScenarioFamilyTemplate(
196
  "automotive_sales",
197
  "VEHICLE_SALES",
demoprep_app/scenario/selector.py CHANGED
@@ -16,7 +16,6 @@ def infer_scenario_family(use_case: str, vertical: str | None = None, function:
16
  text = f"{vertical or ''} {function or ''} {use_case or ''}".lower()
17
 
18
  rules: tuple[tuple[str, tuple[str, ...], tuple[str, ...]], ...] = (
19
- ("post_merger_integration", ("post-merger", "post merger", "acquisition", "acquired", "integration", "synergy", "synergies", "installed base", "attach rate", "ar aging", "dso"), ("supplier", "spend", "aftermarket", "invoice", "cash flow", "working capital")),
20
  ("professional_services_engagements", ("professional services", "consulting", "assurance", "audit", "advisory", "billable", "realized rate", "engagement margin", "staff pyramid", "cross-sell"), ("sector", "client", "partner", "utilization", "margin")),
21
  ("shipping_sales", ("shipping", "parcel", "shipment", "shipments", "delivery service", "express", "ground", "freight"), ("sales", "account", "revenue", "lane", "service")),
22
  ("trucking_finance", ("trucking", "truckload", "carrier", "driver", "fleet", "load", "loads", "empty mile", "revenue per mile"), ("finance", "fuel", "margin", "cost per mile")),
 
16
  text = f"{vertical or ''} {function or ''} {use_case or ''}".lower()
17
 
18
  rules: tuple[tuple[str, tuple[str, ...], tuple[str, ...]], ...] = (
 
19
  ("professional_services_engagements", ("professional services", "consulting", "assurance", "audit", "advisory", "billable", "realized rate", "engagement margin", "staff pyramid", "cross-sell"), ("sector", "client", "partner", "utilization", "margin")),
20
  ("shipping_sales", ("shipping", "parcel", "shipment", "shipments", "delivery service", "express", "ground", "freight"), ("sales", "account", "revenue", "lane", "service")),
21
  ("trucking_finance", ("trucking", "truckload", "carrier", "driver", "fleet", "load", "loads", "empty mile", "revenue per mile"), ("finance", "fuel", "margin", "cost per mile")),
tests/e2e_quality.py CHANGED
@@ -2235,7 +2235,24 @@ if __name__ == "__main__":
2235
  help="Tag for summary filename: 'test' → latest_test_summary.md, 'prod' → latest_prod_summary.md")
2236
  parser.add_argument("--ts-environment", type=str, default="",
2237
  help="Override the TS Environment dropdown value for this run")
 
 
 
 
 
 
2238
  args = parser.parse_args()
 
 
 
 
 
 
 
 
 
 
 
2239
  if args.dry_run:
2240
  DRY_RUN = True
2241
  if args.url:
 
2235
  help="Tag for summary filename: 'test' → latest_test_summary.md, 'prod' → latest_prod_summary.md")
2236
  parser.add_argument("--ts-environment", type=str, default="",
2237
  help="Override the TS Environment dropdown value for this run")
2238
+ parser.add_argument("--test-user", type=str, default="",
2239
+ help="Override TEST_USER for this run only")
2240
+ parser.add_argument("--test-password", type=str, default="",
2241
+ help="Override TEST_PASSWORD for this run only; prefer --test-password-env")
2242
+ parser.add_argument("--test-password-env", type=str, default="",
2243
+ help="Environment variable containing the password for --test-user")
2244
  args = parser.parse_args()
2245
+ if args.test_user:
2246
+ TEST_USER = args.test_user
2247
+ if not args.test_password and not args.test_password_env:
2248
+ raise SystemExit(
2249
+ "--test-user requires --test-password or --test-password-env; "
2250
+ "otherwise the runner would use the default TEST_PASSWORD for a different user."
2251
+ )
2252
+ if args.test_password_env:
2253
+ TEST_PASSWORD = os.getenv(args.test_password_env, "")
2254
+ elif args.test_password:
2255
+ TEST_PASSWORD = args.test_password
2256
  if args.dry_run:
2257
  DRY_RUN = True
2258
  if args.url:
tests/test_dataset_first_builders.py CHANGED
@@ -118,7 +118,7 @@ def test_custom_dataset_first_refuses_weak_fallback_when_llm_contract_required(m
118
  assert "Refusing weak fallback contract" in message
119
 
120
 
121
- def test_custom_sports_venue_contract_uses_domain_shape_and_values():
122
  use_case = (
123
  "A live entertainment arena needs analytics across its basketball teams, "
124
  "Downtown Center, Uptown Theater, concerts, fan demographics, ticket tiers, "
@@ -134,37 +134,10 @@ def test_custom_sports_venue_contract_uses_domain_shape_and_values():
134
  row_count_guidance=100,
135
  )
136
 
137
- assert build is not None
138
- assert build.scenario.scenario_type == "sports_venue_fan_engagement"
139
- assert [table.name for table in build.dataset.tables] == [
140
- "DATES",
141
- "TEAMS",
142
- "VENUES",
143
- "EVENTS",
144
- "TICKET_TIERS",
145
- "FAN_SEGMENTS",
146
- "CHANNELS",
147
- "EVENT_FAN_ENGAGEMENT",
148
- ]
149
-
150
- table_map = build.dataset.table_map()
151
- team_names = {row["TEAM_NAME"] for row in table_map["TEAMS"].rows}
152
- venue_names = {row["VENUE_NAME"] for row in table_map["VENUES"].rows}
153
- channel_names = {row["CHANNEL_NAME"] for row in table_map["CHANNELS"].rows}
154
- assert any("Team Alpha" in name for name in team_names)
155
- assert any("Downtown Center" in name for name in venue_names)
156
- assert any("Email" in name for name in channel_names)
157
-
158
- sample = table_map["EVENT_FAN_ENGAGEMENT"].rows[:50]
159
- assert sample
160
- for row in sample:
161
- assert row["ATTENDANCE"] <= row["CAPACITY"]
162
- assert row["PAID_ATTENDANCE"] <= row["ATTENDANCE"]
163
- assert row["EMAIL_CLICKS"] <= row["EMAIL_OPENS"] <= row["EMAIL_SENDS"]
164
- assert 0 <= row["UTILIZATION_PCT"] <= 100
165
 
166
 
167
- def test_revenue_does_not_trigger_venue_scenario_for_professional_services():
168
  use_case = (
169
  "Build a professional services demo for EY's assurance and consulting lines. "
170
  "Track billable hours vs. budget, revenue per sector, staff pyramid health, "
@@ -180,31 +153,7 @@ def test_revenue_does_not_trigger_venue_scenario_for_professional_services():
180
  row_count_guidance=100,
181
  )
182
 
183
- assert build is not None
184
- assert build.scenario.scenario_type == "professional_services_engagements"
185
- assert [table.name for table in build.dataset.tables] == [
186
- "MONTHS",
187
- "CLIENTS",
188
- "SERVICE_LINES",
189
- "SECTORS",
190
- "CONSULTANTS",
191
- "REGIONS",
192
- "CLIENT_ENGAGEMENTS",
193
- ]
194
-
195
- table_map = build.dataset.table_map()
196
- service_lines = {row["SERVICE_LINE_NAME"] for row in table_map["SERVICE_LINES"].rows}
197
- assert "Assurance" in service_lines
198
- assert "Consulting" in service_lines
199
-
200
- sample = table_map["CLIENT_ENGAGEMENTS"].rows[:50]
201
- assert sample
202
- for row in sample:
203
- assert row["WRITEOFF_USD"] <= row["GROSS_REVENUE_USD"]
204
- assert row["REALIZED_REVENUE_USD"] == round(row["GROSS_REVENUE_USD"] - row["WRITEOFF_USD"], 2)
205
- assert row["LABOR_COST_USD"] <= row["REALIZED_REVENUE_USD"]
206
- assert row["ENGAGEMENT_MARGIN_USD"] == round(row["REALIZED_REVENUE_USD"] - row["LABOR_COST_USD"], 2)
207
- assert row["ADVISORY_CROSS_SELLS"] <= row["AUDIT_CLIENTS"]
208
 
209
 
210
  def test_quality_pool_domains_do_not_fall_back_to_generic_templates():
@@ -508,7 +457,7 @@ def test_grocery_finance_routes_to_cpg_financials_not_sales_fact():
508
  assert "LTV_USD" not in fact_columns
509
 
510
 
511
- def test_baker_hughes_chart_post_merger_demo_has_three_value_stories():
512
  use_case = (
513
  "Baker Hughes acquired Chart Industries and needs a post-merger integration "
514
  "demo for supplier spend consolidation, aftermarket attach rate on Chart's "
@@ -523,59 +472,58 @@ def test_baker_hughes_chart_post_merger_demo_has_three_value_stories():
523
  row_count_guidance=200,
524
  )
525
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  assert build is not None
527
- assert build.scenario.scenario_type == "post_merger_integration"
528
  assert [table.name for table in build.dataset.tables] == [
529
- "DIM_SUPPLIER",
530
- "DIM_COMMODITY",
531
  "DIM_CUSTOMER",
532
- "DIM_PRODUCT",
533
  "DIM_INVOICE_CATEGORY",
534
- "FACT_SPEND",
535
- "FACT_EQUIPMENT_INSTALLED",
536
- "FACT_ORDERS",
537
  "FACT_INVOICES",
538
  ]
539
- assert build.ddl.count("CREATE TABLE") == 9
540
- assert build.ddl.count("FOREIGN KEY") >= 8
541
 
542
  tables = build.dataset.table_map()
543
- assert len(tables["DIM_SUPPLIER"].rows) == 300
544
- assert len(tables["DIM_COMMODITY"].rows) == 40
545
  assert len(tables["DIM_CUSTOMER"].rows) == 200
546
- assert len(tables["DIM_PRODUCT"].rows) == 150
547
- assert len(tables["FACT_SPEND"].rows) >= 1000
548
- assert len(tables["FACT_EQUIPMENT_INSTALLED"].rows) >= 100
549
- assert len(tables["FACT_ORDERS"].rows) >= 600
550
- assert len(tables["FACT_INVOICES"].rows) >= 500
551
-
552
- duplicate_suppliers = [row for row in tables["DIM_SUPPLIER"].rows if row["IS_DUPLICATE_SUPPLIER"]]
553
- assert len(duplicate_suppliers) >= 30
554
- assert any(row["CONSOLIDATION_OPPORTUNITY_USD"] > 0 for row in tables["FACT_SPEND"].rows)
555
-
556
- chart_gap_installations = [
557
- row for row in tables["FACT_EQUIPMENT_INSTALLED"].rows
558
- if row["SITE_REGION"] in {"APAC", "Middle East"} and row["SERVICE_CONTRACT_IN_PLACE"] == "N"
559
- ]
560
- assert chart_gap_installations
561
- assert any(row["AFTERMARKET_GAP_USD"] > 0 for row in chart_gap_installations)
562
-
563
- overdue_invoices = [
564
- row for row in tables["FACT_INVOICES"].rows
565
- if row["AGING_BUCKET"] in {"60-90 Days", "90+ Days"} and row["PAYMENT_RECEIVED_DATE"] is None
566
- ]
567
- assert overdue_invoices
568
- assert any(row["CASH_ACCELERATION_OPPORTUNITY_USD"] > 0 for row in overdue_invoices)
569
-
570
- synthetic_suffix_values = []
571
- for table_name in ("DIM_SUPPLIER", "DIM_COMMODITY", "DIM_CUSTOMER", "DIM_PRODUCT"):
572
- for row in tables[table_name].rows:
573
- for key, value in row.items():
574
- if not key.endswith("_NAME"):
575
- continue
576
- if isinstance(value, str) and value.split()[-1].isdigit():
577
- synthetic_suffix_values.append(value)
578
- assert not synthetic_suffix_values[:5]
579
 
580
 
581
  def test_saas_finance_routes_to_subscription_revenue_not_generic_unit_economics():
 
118
  assert "Refusing weak fallback contract" in message
119
 
120
 
121
+ def test_custom_sports_venue_without_explicit_tables_does_not_use_canned_template():
122
  use_case = (
123
  "A live entertainment arena needs analytics across its basketball teams, "
124
  "Downtown Center, Uptown Theater, concerts, fan demographics, ticket tiers, "
 
134
  row_count_guidance=100,
135
  )
136
 
137
+ assert build is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
 
140
+ def test_custom_professional_services_without_explicit_tables_does_not_use_canned_template():
141
  use_case = (
142
  "Build a professional services demo for EY's assurance and consulting lines. "
143
  "Track billable hours vs. budget, revenue per sector, staff pyramid health, "
 
153
  row_count_guidance=100,
154
  )
155
 
156
+ assert build is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
 
159
  def test_quality_pool_domains_do_not_fall_back_to_generic_templates():
 
457
  assert "LTV_USD" not in fact_columns
458
 
459
 
460
+ def test_baker_hughes_chart_post_merger_keywords_do_not_trigger_nine_table_template():
461
  use_case = (
462
  "Baker Hughes acquired Chart Industries and needs a post-merger integration "
463
  "demo for supplier spend consolidation, aftermarket attach rate on Chart's "
 
472
  row_count_guidance=200,
473
  )
474
 
475
+ assert build is None
476
+
477
+
478
+ def test_explicit_table_prompt_overrides_post_merger_template_for_ar_aging():
479
+ use_case = """
480
+ Customer Invoicing & AR Aging
481
+ Generate synthetic accounts receivable and invoicing data for a Baker Hughes + Chart Industries cash flow demo.
482
+ The request is intentionally scoped to the following tables only.
483
+
484
+ Tables
485
+
486
+ dim_customer (~200 rows)
487
+ Customer ID, customer name, industry, country, region, customer tier, legacy org, contracted payment terms.
488
+
489
+ dim_invoice_category (~20 rows)
490
+ Category ID, category name, business unit, order type.
491
+
492
+ fact_invoices (~25,000 rows, Jan 2025 - Jun 2026)
493
+ Invoice ID, invoice date, due date, customer ID, invoice category ID, invoice amount USD,
494
+ payment received date, amount paid USD, dispute flag, dispute reason, collector assigned, legacy org.
495
+
496
+ Anomalies to Seed
497
+ APAC and Middle East Chart customers averaging 75-90 days to pay vs BKR benchmark of 45 days.
498
+ Large LNG project invoices sitting 60-90 days overdue.
499
+ """
500
+ build = build_dataset_first_demo(
501
+ company_name="Baker Hughes + Chart Industries",
502
+ company_url="https://www.bakerhughes.com",
503
+ use_case=use_case,
504
+ vertical="* CUSTOM *",
505
+ function=None,
506
+ row_count_guidance=1000,
507
+ )
508
+
509
  assert build is not None
510
+ assert build.scenario.scenario_type == "explicit_table_contract"
511
  assert [table.name for table in build.dataset.tables] == [
 
 
512
  "DIM_CUSTOMER",
 
513
  "DIM_INVOICE_CATEGORY",
 
 
 
514
  "FACT_INVOICES",
515
  ]
516
+ assert build.ddl.count("CREATE TABLE") == 3
517
+ assert build.ddl.count("FOREIGN KEY") >= 2
518
 
519
  tables = build.dataset.table_map()
 
 
520
  assert len(tables["DIM_CUSTOMER"].rows) == 200
521
+ assert len(tables["DIM_INVOICE_CATEGORY"].rows) == 20
522
+ assert len(tables["FACT_INVOICES"].rows) == 1000
523
+ assert {"DAYS_OUTSTANDING", "AGING_BUCKET", "PAST_DUE_AMOUNT_USD"} <= {
524
+ column.name for column in tables["FACT_INVOICES"].columns
525
+ }
526
+ assert any(row["AGING_BUCKET"] in {"60-90 Days", "90+ Days"} for row in tables["FACT_INVOICES"].rows)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
 
528
 
529
  def test_saas_finance_routes_to_subscription_revenue_not_generic_unit_economics():
thoughtspot_deployer.py CHANGED
@@ -3479,7 +3479,7 @@ class ThoughtSpotDeployer:
3479
  # Use the enhanced model creation that includes constraint references
3480
  model_tml = self._create_model_with_constraints(tables, foreign_keys, table_guids, table_constraints, model_name, connection_name)
3481
  print(f"\n📄 Model TML being sent:\n{model_tml}")
3482
-
3483
  response = self.session.post(
3484
  f"{self.base_url}/api/rest/2.0/metadata/tml/import",
3485
  json={
@@ -3488,6 +3488,62 @@ class ThoughtSpotDeployer:
3488
  "create_new": True
3489
  }
3490
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3491
 
3492
  if response.status_code == 200:
3493
  result = response.json()
 
3479
  # Use the enhanced model creation that includes constraint references
3480
  model_tml = self._create_model_with_constraints(tables, foreign_keys, table_guids, table_constraints, model_name, connection_name)
3481
  print(f"\n📄 Model TML being sent:\n{model_tml}")
3482
+
3483
  response = self.session.post(
3484
  f"{self.base_url}/api/rest/2.0/metadata/tml/import",
3485
  json={
 
3488
  "create_new": True
3489
  }
3490
  )
3491
+
3492
+ # Some complex multi-fact models are rejected by ThoughtSpot when
3493
+ # the Model TML repeats explicit model-table joins even though the
3494
+ # table objects already have joins from Batch 2. Retry once with the
3495
+ # same tables/columns but no model-table joins.
3496
+ if response.status_code == 200:
3497
+ try:
3498
+ _model_import_preview = response.json()
3499
+ _preview_objects = self._normalize_tml_import_response_objects(_model_import_preview) or []
3500
+ _first_status = (
3501
+ _preview_objects[0].get('response', {}).get('status', {})
3502
+ if _preview_objects else {}
3503
+ )
3504
+ _first_status_code = str(_first_status.get('status_code') or '')
3505
+ _first_error = str(_first_status.get('error_message') or '')
3506
+ _first_error_code = str(_first_status.get('error_code') or '')
3507
+ except Exception:
3508
+ _first_status_code = ''
3509
+ _first_error = ''
3510
+ _first_error_code = ''
3511
+
3512
+ if (
3513
+ _first_status_code == 'ERROR'
3514
+ and (
3515
+ _first_error_code == '13122'
3516
+ or 'schema validation failed' in _first_error.lower()
3517
+ )
3518
+ ):
3519
+ warning = (
3520
+ "Model import with explicit joins failed schema validation; "
3521
+ "retrying model import without model-table joins."
3522
+ )
3523
+ log_progress(f" ⚠️ {warning}")
3524
+ results['warnings'].append(warning)
3525
+ if _slog:
3526
+ _slog.log(
3527
+ "thoughtspot",
3528
+ "model import retrying without joins",
3529
+ error_code=_first_error_code,
3530
+ error=_first_error[:500],
3531
+ )
3532
+ model_tml = self.create_actual_model_tml(
3533
+ tables,
3534
+ foreign_keys,
3535
+ table_guids=table_guids,
3536
+ model_name=model_name,
3537
+ connection_name=connection_name,
3538
+ )
3539
+ response = self.session.post(
3540
+ f"{self.base_url}/api/rest/2.0/metadata/tml/import",
3541
+ json={
3542
+ "metadata_tmls": [model_tml],
3543
+ "import_policy": "ALL_OR_NONE",
3544
+ "create_new": True
3545
+ }
3546
+ )
3547
 
3548
  if response.status_code == 200:
3549
  result = response.json()