Spaces:
Running
Running
mike boone commited on
Commit ·
a6d4bca
1
Parent(s): 4489319
Honor explicit custom dataset contracts
Browse files- chat_interface.py +161 -6
- demoprep_app/dataset/explicit_prompt.py +404 -0
- demoprep_app/dataset/generators/post_merger_integration.py +0 -610
- demoprep_app/dataset/generators/router.py +0 -2
- demoprep_app/pipeline/dataset_first.py +41 -6
- demoprep_app/scenario/extractor.py +0 -2
- demoprep_app/scenario/families.py +0 -14
- demoprep_app/scenario/selector.py +0 -1
- tests/e2e_quality.py +17 -0
- tests/test_dataset_first_builders.py +48 -100
- thoughtspot_deployer.py +57 -1
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:] + (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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] + (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 == "
|
| 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") ==
|
| 540 |
-
assert build.ddl.count("FOREIGN KEY") >=
|
| 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["
|
| 547 |
-
assert len(tables["
|
| 548 |
-
assert
|
| 549 |
-
|
| 550 |
-
|
| 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()
|