Spaces:
Sleeping
feat: App/Chat tab split, Spotter Viz dual stories, Custom vertical, bug fixes
Browse filesUI:
- Rename Chat tab → App; split input into App (form) and Chat (freeform) sub-tabs
- Remove quick action buttons (Start Research, Configure, Help)
- Add Custom vertical option — disables Function dd, makes Context required
- Context field renamed from "Additional context (optional)" → "Context"
- Phase log timer now activates on Send + Enter, not just GO
- GO button bypasses init confirmation dialog, goes straight to pipeline
Spotter Viz Story:
- Split into two sub-tabs: ThoughtSpot Story (matrix-driven) and AI Story (pure LLM)
- New _generate_matrix_spotter_story() — builds from vertical×function KPIs/liveboard_questions
- New _generate_ai_spotter_story() — pure LLM, no matrix constraints
- New spotter_viz_story_matrix prompt in prompts.py
Bug fixes:
- NaN/Inf floats in LegitData population now replaced with NULL (fixes Snowflake 22018 errors)
- TS auth key: KEY_VAR value is the key directly, not a pointer to another env var
- Async pipeline plan added to sprint doc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- chat_interface.py +582 -247
- legitdata_bridge.py +9 -1
- liveboard_creator.py +32 -38
- model_semantic_updater.py +208 -248
- prompts.py +26 -0
- sprint_2026_03.md +93 -1
- supabase_client.py +0 -9
- tests/conftest.py +102 -0
- tests/e2e_chat.py +96 -0
- tests/e2e_settings.py +52 -0
- tests/e2e_smoke.py +92 -0
- tests/e2e_z_auth.py +24 -0
- thoughtspot_deployer.py +51 -10
|
@@ -19,10 +19,11 @@ from demo_builder_class import DemoBuilder
|
|
| 19 |
from supabase_client import load_gradio_settings, get_admin_setting, inject_admin_settings_to_env
|
| 20 |
from main_research import MultiLLMResearcher, Website
|
| 21 |
from demo_personas import (
|
| 22 |
-
build_company_analysis_prompt,
|
| 23 |
build_industry_research_prompt,
|
| 24 |
VERTICALS,
|
| 25 |
FUNCTIONS,
|
|
|
|
| 26 |
get_use_case_config,
|
| 27 |
parse_use_case
|
| 28 |
)
|
|
@@ -69,7 +70,7 @@ def get_ts_env_url(label: str) -> str:
|
|
| 69 |
|
| 70 |
def get_ts_env_auth_key(label: str) -> str:
|
| 71 |
"""Return the actual auth key value for a given environment label.
|
| 72 |
-
TS_ENV_N_KEY_VAR holds the
|
| 73 |
"""
|
| 74 |
i = 1
|
| 75 |
while True:
|
|
@@ -77,8 +78,7 @@ def get_ts_env_auth_key(label: str) -> str:
|
|
| 77 |
if not env_label:
|
| 78 |
break
|
| 79 |
if env_label == label:
|
| 80 |
-
|
| 81 |
-
return os.getenv(key_var, '').strip() if key_var else ''
|
| 82 |
i += 1
|
| 83 |
return ''
|
| 84 |
|
|
@@ -232,8 +232,10 @@ class ChatDemoInterface:
|
|
| 232 |
self.pending_generic_use_case = None
|
| 233 |
# New tab content
|
| 234 |
self.live_progress_log = [] # Real-time deployment progress
|
| 235 |
-
self.
|
| 236 |
-
self.
|
|
|
|
|
|
|
| 237 |
# Per-session loggers (NOT module-level singletons — avoids cross-session contamination)
|
| 238 |
self._session_logger = None
|
| 239 |
self._prompt_logger = None
|
|
@@ -247,8 +249,8 @@ class ChatDemoInterface:
|
|
| 247 |
"""Load settings from Supabase or defaults"""
|
| 248 |
# Fallback defaults (ONLY used if settings not found)
|
| 249 |
defaults = {
|
| 250 |
-
'company': '
|
| 251 |
-
'use_case': '
|
| 252 |
'model': DEFAULT_LLM_MODEL,
|
| 253 |
'fact_table_size': '1000',
|
| 254 |
'dim_table_size': '100',
|
|
@@ -291,50 +293,28 @@ class ChatDemoInterface:
|
|
| 291 |
|
| 292 |
def format_welcome_message(self, company, use_case):
|
| 293 |
"""Create the initial welcome message"""
|
| 294 |
-
#
|
| 295 |
-
needs_setup = True
|
| 296 |
-
|
| 297 |
-
if needs_setup:
|
| 298 |
-
# Build use case examples from VERTICALS × FUNCTIONS
|
| 299 |
-
uc_examples = []
|
| 300 |
-
for v_name in list(VERTICALS.keys())[:3]: # Show first 3 verticals
|
| 301 |
-
for f_name in list(FUNCTIONS.keys())[:2]: # Show first 2 functions each
|
| 302 |
-
uc_examples.append(f" - {v_name} {f_name}")
|
| 303 |
-
uc_list = "\n".join(uc_examples)
|
| 304 |
-
|
| 305 |
-
return f"""👋 **Welcome to ThoughtSpot Demo Builder!**
|
| 306 |
-
|
| 307 |
-
Tell me who you're demoing to and what they care about — I'll build the whole thing.
|
| 308 |
-
|
| 309 |
-
**Just say something like:**
|
| 310 |
-
- *"Nike.com, Retail Sales"*
|
| 311 |
-
- *"Salesforce.com, Software Sales"*
|
| 312 |
-
- *"Target.com — supply chain analytics for their VP of Operations"*
|
| 313 |
-
- *"Pfizer.com — analyzing clinical trial pipeline for a CMO persona"*
|
| 314 |
-
|
| 315 |
-
**Pre-configured use cases** (KPIs, outliers, and Spotter questions ready to go):
|
| 316 |
-
{uc_list}
|
| 317 |
-
- ...and more!
|
| 318 |
|
| 319 |
-
|
| 320 |
|
| 321 |
-
|
|
|
|
|
|
|
| 322 |
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
return f"""👋 **Welcome to ThoughtSpot Demo Builder!**
|
| 326 |
-
|
| 327 |
-
I'm ready to create a perfect ThoughtSpot demo!
|
| 328 |
|
| 329 |
-
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
|
| 332 |
-
|
| 333 |
-
- Type "start" to begin research
|
| 334 |
-
- Type `/over` to change company or use case
|
| 335 |
-
- Or just tell me what you'd like to do!
|
| 336 |
|
| 337 |
-
|
| 338 |
|
| 339 |
def validate_required_settings(self) -> list:
|
| 340 |
"""
|
|
@@ -346,8 +326,6 @@ What's your first step?"""
|
|
| 346 |
# Check admin settings (from environment, injected from Supabase)
|
| 347 |
admin_checks = {
|
| 348 |
'THOUGHTSPOT_URL': get_admin_setting('THOUGHTSPOT_URL', required=False),
|
| 349 |
-
'THOUGHTSPOT_TRUSTED_AUTH_KEY': get_admin_setting('THOUGHTSPOT_TRUSTED_AUTH_KEY', required=False),
|
| 350 |
-
'THOUGHTSPOT_ADMIN_USER': get_admin_setting('THOUGHTSPOT_ADMIN_USER', required=False),
|
| 351 |
'SNOWFLAKE_ACCOUNT': get_admin_setting('SNOWFLAKE_ACCOUNT', required=False),
|
| 352 |
'SNOWFLAKE_KP_USER': get_admin_setting('SNOWFLAKE_KP_USER', required=False),
|
| 353 |
'SNOWFLAKE_KP_PK': get_admin_setting('SNOWFLAKE_KP_PK', required=False),
|
|
@@ -440,8 +418,10 @@ What's your first step?"""
|
|
| 440 |
from supabase_client import get_admin_setting
|
| 441 |
|
| 442 |
ts_url = (self.settings.get('thoughtspot_url') or '').strip() or get_admin_setting('THOUGHTSPOT_URL')
|
| 443 |
-
ts_user =
|
| 444 |
-
ts_secret = (self.settings.get('thoughtspot_trusted_auth_key') or '').strip()
|
|
|
|
|
|
|
| 445 |
|
| 446 |
ts_client = ThoughtSpotDeployer(base_url=ts_url, username=ts_user, secret_key=ts_secret)
|
| 447 |
ts_client.authenticate()
|
|
@@ -495,7 +475,7 @@ Type **done** when finished."""
|
|
| 495 |
# User provided just a URL - ask them to include the use case too
|
| 496 |
detected_company = standalone_url.group(1)
|
| 497 |
# Build dynamic use case list from VERTICALS × FUNCTIONS
|
| 498 |
-
uc_opts = [f"- {v} {f}" for v in
|
| 499 |
uc_opts_str = "\n".join(uc_opts)
|
| 500 |
response = f"""I see you want to use **{detected_company}** - great choice!
|
| 501 |
|
|
@@ -511,7 +491,7 @@ I'm creating a demo for company: {detected_company} use case: Retail Sales
|
|
| 511 |
|
| 512 |
What use case would you like for {detected_company}?"""
|
| 513 |
chat_history[-1] = (message, response)
|
| 514 |
-
yield chat_history, current_stage, current_model,
|
| 515 |
return
|
| 516 |
|
| 517 |
# Check if user is providing company and use case
|
|
@@ -703,14 +683,14 @@ This may take 1-2 minutes. Watch the AI Feedback tab for progress!"""
|
|
| 703 |
return
|
| 704 |
|
| 705 |
elif extracted_company and not extracted_use_case:
|
| 706 |
-
uc_opts = "\n".join([f"- {v} {f}" for v in
|
| 707 |
response = f"""Got it — **{extracted_company}**!
|
| 708 |
|
| 709 |
What use case are we building? A few options:
|
| 710 |
{uc_opts}
|
| 711 |
- Or describe any custom scenario!"""
|
| 712 |
chat_history[-1] = (message, response)
|
| 713 |
-
yield chat_history, current_stage, current_model,
|
| 714 |
return
|
| 715 |
|
| 716 |
else:
|
|
@@ -748,8 +728,10 @@ Try something like:
|
|
| 748 |
|
| 749 |
if validation_mode == 'Off':
|
| 750 |
# AUTO-RUN MODE: Run entire pipeline without any more prompts
|
| 751 |
-
current_stage = '
|
| 752 |
-
|
|
|
|
|
|
|
| 753 |
auto_run_msg = f"""✅ **Starting Auto-Run Mode**
|
| 754 |
|
| 755 |
**Company:** {company}
|
|
@@ -776,23 +758,29 @@ Watch the AI Feedback tab for real-time progress!"""
|
|
| 776 |
|
| 777 |
self.log_feedback(f"DEBUG AUTO-RUN: Research loop EXITED after {research_yield_count} yields")
|
| 778 |
|
| 779 |
-
# Show research complete
|
|
|
|
|
|
|
|
|
|
| 780 |
chat_history[-1] = (message, "✅ **Research Complete!**\n\n📝 **Creating DDL...**")
|
| 781 |
yield chat_history, current_stage, current_model, company, use_case, ""
|
| 782 |
self.log_feedback("DEBUG AUTO-RUN: Moving to DDL creation...")
|
| 783 |
-
|
| 784 |
# Auto-create DDL
|
| 785 |
ddl_response, ddl_code = self.run_ddl_creation()
|
| 786 |
-
|
| 787 |
# Check if DDL creation failed
|
| 788 |
if not ddl_code or ddl_code.strip() == "":
|
| 789 |
chat_history[-1] = (message, f"{ddl_response}\n\n❌ **Cannot proceed without valid DDL.** Please fix the error and try again.")
|
| 790 |
yield chat_history, current_stage, current_model, company, use_case, ""
|
| 791 |
return
|
| 792 |
-
|
|
|
|
|
|
|
|
|
|
| 793 |
chat_history[-1] = (message, f"✅ DDL Created\n\n🚀 **Deploying to Snowflake...**")
|
| 794 |
yield chat_history, current_stage, current_model, company, use_case, ""
|
| 795 |
-
|
| 796 |
# Auto-deploy
|
| 797 |
with open('/tmp/demoprep_debug.log', 'a') as f:
|
| 798 |
f.write(f"AUTO-RUN: Starting deployment streaming\n")
|
|
@@ -825,9 +813,11 @@ Watch the AI Feedback tab for real-time progress!"""
|
|
| 825 |
deploy_response, _, auto_schema = final_result
|
| 826 |
with open('/tmp/demoprep_debug.log', 'a') as f:
|
| 827 |
f.write(f"AUTO-RUN: auto_ts detected, schema={auto_schema}\n")
|
|
|
|
|
|
|
| 828 |
chat_history[-1] = (message, deploy_response)
|
| 829 |
yield chat_history, current_stage, current_model, company, use_case, ""
|
| 830 |
-
|
| 831 |
# Run ThoughtSpot deployment with detailed logging
|
| 832 |
with open('/tmp/demoprep_debug.log', 'a') as f:
|
| 833 |
f.write(f"AUTO-RUN: Creating TS generator...\n")
|
|
@@ -848,6 +838,8 @@ Watch the AI Feedback tab for real-time progress!"""
|
|
| 848 |
yield chat_history, current_stage, current_model, company, use_case, ""
|
| 849 |
with open('/tmp/demoprep_debug.log', 'a') as f:
|
| 850 |
f.write(f"AUTO-RUN: TS deployment loop complete, {ts_update_count} updates\n")
|
|
|
|
|
|
|
| 851 |
else:
|
| 852 |
# Not auto_ts, just show the result
|
| 853 |
if len(final_result) >= 2:
|
|
@@ -1658,7 +1650,7 @@ Try a different request or type **'done'** to finish."""
|
|
| 1658 |
continue
|
| 1659 |
return use_case
|
| 1660 |
|
| 1661 |
-
# Fallback: text after a comma or dash following a domain.tld
|
| 1662 |
# Handles: "Nike.com, Retail Sales" / "Nike.com - Supply Chain"
|
| 1663 |
after_domain = re.search(
|
| 1664 |
r'[a-zA-Z0-9-]+\.[a-zA-Z]{2,}[\s]*[,\-–—]\s*(.+)',
|
|
@@ -1669,6 +1661,17 @@ Try a different request or type **'done'** to finish."""
|
|
| 1669 |
if use_case and not re.search(r'\.(com|org|net|io|co|ai)\b', use_case, re.IGNORECASE):
|
| 1670 |
return use_case
|
| 1671 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1672 |
return None
|
| 1673 |
|
| 1674 |
def handle_override(self, message):
|
|
@@ -2267,96 +2270,108 @@ To change settings, use:
|
|
| 2267 |
- **Ask questions**: Let the AI demonstrate natural language
|
| 2268 |
- **End with action**: Show how insights lead to decisions""")
|
| 2269 |
|
| 2270 |
-
def
|
| 2271 |
-
|
| 2272 |
-
"""
|
| 2273 |
-
|
| 2274 |
-
|
| 2275 |
-
Returns two sections:
|
| 2276 |
-
1. Persona-driven story built from liveboard_questions config (always present)
|
| 2277 |
-
2. AI-generated story from LLM (appended after a divider)
|
| 2278 |
"""
|
| 2279 |
-
from
|
|
|
|
| 2280 |
|
| 2281 |
v, f = parse_use_case(use_case or '')
|
| 2282 |
-
|
| 2283 |
-
|
| 2284 |
-
|
| 2285 |
-
|
| 2286 |
-
|
| 2287 |
-
|
| 2288 |
-
|
| 2289 |
-
|
| 2290 |
-
|
| 2291 |
-
|
| 2292 |
-
|
| 2293 |
-
|
| 2294 |
-
## Part 1: Structured Demo Flow
|
| 2295 |
-
|
| 2296 |
-
"""
|
| 2297 |
-
steps = []
|
| 2298 |
-
step_num = 1
|
| 2299 |
-
for q in lq:
|
| 2300 |
-
title = q['title']
|
| 2301 |
-
viz_q = q['viz_question']
|
| 2302 |
-
insight = q.get('insight', '')
|
| 2303 |
-
spotter_qs = q.get('spotter_qs', [])
|
| 2304 |
-
|
| 2305 |
-
step = f"### Step {step_num}: {title}\n"
|
| 2306 |
-
step += f'> "{viz_q}"\n\n'
|
| 2307 |
-
if insight:
|
| 2308 |
-
step += f"**What to look for:** {insight}\n\n"
|
| 2309 |
-
if spotter_qs:
|
| 2310 |
-
step += "**Follow-up Spotter questions:**\n"
|
| 2311 |
-
for sq in spotter_qs[:2]:
|
| 2312 |
-
step += f'> "{sq}"\n\n'
|
| 2313 |
-
steps.append(step)
|
| 2314 |
-
step_num += 1
|
| 2315 |
-
|
| 2316 |
-
if steps:
|
| 2317 |
-
persona_section = header + "\n".join(steps)
|
| 2318 |
-
else:
|
| 2319 |
-
persona_section = header + f'> "Build a {use_case} dashboard for {company_name} using {data_source}"\n\n'
|
| 2320 |
-
|
| 2321 |
-
persona_section += "\n---\n\n"
|
| 2322 |
|
| 2323 |
-
# --- Section 2: AI-generated story ---
|
| 2324 |
try:
|
| 2325 |
-
from prompts import build_prompt
|
| 2326 |
-
|
| 2327 |
-
vertical = v or "Generic"
|
| 2328 |
-
function = f or "Generic"
|
| 2329 |
-
|
| 2330 |
-
company_context = f"Company: {company_name}\nUse Case: {use_case}"
|
| 2331 |
-
if model_name:
|
| 2332 |
-
company_context += f"\nData Source/Model: {model_name}"
|
| 2333 |
-
if liveboard_name:
|
| 2334 |
-
company_context += f"\nLiveboard Name: {liveboard_name}"
|
| 2335 |
-
if hasattr(self, 'demo_builder') and self.demo_builder:
|
| 2336 |
-
research = getattr(self.demo_builder, 'company_summary', '') or ''
|
| 2337 |
-
if research:
|
| 2338 |
-
company_context += f"\n\nCompany Research:\n{research[:1500]}"
|
| 2339 |
-
|
| 2340 |
prompt = build_prompt(
|
| 2341 |
stage="spotter_viz_story",
|
| 2342 |
vertical=vertical,
|
| 2343 |
function=function,
|
| 2344 |
company_context=company_context,
|
| 2345 |
)
|
| 2346 |
-
|
| 2347 |
llm_model = self.settings.get('model', DEFAULT_LLM_MODEL)
|
| 2348 |
self.log_feedback(f"🎬 Generating AI Spotter Viz story ({llm_model})...")
|
| 2349 |
-
|
| 2350 |
provider_name, model_name_str = map_llm_display_to_provider(llm_model)
|
| 2351 |
researcher = MultiLLMResearcher(provider=provider_name, model=model_name_str)
|
| 2352 |
-
|
| 2353 |
-
ai_section = "## Part 2: AI-Generated Story\n\n" + ai_story
|
| 2354 |
-
|
| 2355 |
except Exception as e:
|
| 2356 |
self.log_feedback(f"⚠️ AI Spotter Viz story generation failed: {e}")
|
| 2357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2358 |
|
| 2359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2360 |
|
| 2361 |
def _build_fallback_spotter_story(self, company_name: str, use_case: str,
|
| 2362 |
model_name: str = None) -> str:
|
|
@@ -3538,9 +3553,11 @@ Tables: Created and populated
|
|
| 3538 |
|
| 3539 |
# Get ThoughtSpot settings
|
| 3540 |
ts_url = get_admin_setting('THOUGHTSPOT_URL')
|
| 3541 |
-
ts_user =
|
| 3542 |
-
ts_secret =
|
| 3543 |
-
|
|
|
|
|
|
|
| 3544 |
liveboard_method = 'HYBRID' # Only HYBRID method is supported
|
| 3545 |
|
| 3546 |
# Clean company name for display (strip .com, .org, etc)
|
|
@@ -3672,10 +3689,12 @@ Cannot deploy to ThoughtSpot without tables.""",
|
|
| 3672 |
yield f"**Starting ThoughtSpot Deployment...**\n\nSchema verified: {database}.{schema_name}\nFound {len(tables)} tables\n\n"
|
| 3673 |
|
| 3674 |
# Create deployer — prefer session-selected env (from TS env dropdown),
|
| 3675 |
-
# fall back to admin settings
|
| 3676 |
ts_url = (self.settings.get('thoughtspot_url') or '').strip() or get_admin_setting('THOUGHTSPOT_URL')
|
| 3677 |
-
ts_user =
|
| 3678 |
-
ts_secret =
|
|
|
|
|
|
|
| 3679 |
|
| 3680 |
deployer = ThoughtSpotDeployer(
|
| 3681 |
base_url=ts_url,
|
|
@@ -3760,6 +3779,7 @@ This chat will update when complete."""
|
|
| 3760 |
tag_name=tag_name_value,
|
| 3761 |
liveboard_method=liveboard_method,
|
| 3762 |
share_with=self.settings.get('share_with', '').strip() or None,
|
|
|
|
| 3763 |
progress_callback=progress_callback
|
| 3764 |
)
|
| 3765 |
except Exception as e:
|
|
@@ -3822,10 +3842,15 @@ Steps:
|
|
| 3822 |
if results.get('success'):
|
| 3823 |
safe_print("DEPLOYMENT COMPLETE", flush=True)
|
| 3824 |
self.live_progress_log.extend(["", "=" * 60, "DEPLOYMENT COMPLETE", "=" * 60])
|
|
|
|
|
|
|
|
|
|
| 3825 |
else:
|
| 3826 |
safe_print("DEPLOYMENT FAILED", flush=True)
|
| 3827 |
safe_print(f"Errors: {results.get('errors', [])}", flush=True)
|
| 3828 |
self.live_progress_log.extend(["", "DEPLOYMENT FAILED", f"Errors: {results.get('errors', [])}"])
|
|
|
|
|
|
|
| 3829 |
safe_print("="*60 + "\n", flush=True)
|
| 3830 |
|
| 3831 |
progress_log = '\n'.join(progress_messages) if progress_messages else 'No progress messages captured'
|
|
@@ -3894,21 +3919,23 @@ Ask these questions to showcase ThoughtSpot's AI capabilities:
|
|
| 3894 |
safe_print(f"Could not generate demo pack: {e}", flush=True)
|
| 3895 |
self.demo_pack_content = f"*Demo pack generation failed: {e}*"
|
| 3896 |
|
| 3897 |
-
# Generate Spotter Viz
|
| 3898 |
try:
|
| 3899 |
-
|
| 3900 |
-
liveboard_name_for_story = results.get('liveboard', None)
|
| 3901 |
-
self.spotter_viz_story = self._generate_spotter_viz_story(
|
| 3902 |
company_name=company_name,
|
| 3903 |
use_case=use_case,
|
| 3904 |
-
model_name=
|
| 3905 |
-
liveboard_name=
|
| 3906 |
)
|
| 3907 |
-
|
| 3908 |
-
self.
|
|
|
|
|
|
|
| 3909 |
except Exception as e:
|
| 3910 |
safe_print(f"Could not generate Spotter Viz story: {e}", flush=True)
|
| 3911 |
-
|
|
|
|
|
|
|
| 3912 |
|
| 3913 |
# Build final response
|
| 3914 |
if results.get('success'):
|
|
@@ -4265,15 +4292,23 @@ def create_chat_interface():
|
|
| 4265 |
|
| 4266 |
# Bootstrap defaults before authenticated user-specific settings are loaded
|
| 4267 |
default_settings = {
|
| 4268 |
-
"company": "
|
| 4269 |
-
"use_case": "
|
| 4270 |
"model": DEFAULT_LLM_MODEL,
|
| 4271 |
"stage": "initialization",
|
| 4272 |
}
|
| 4273 |
|
| 4274 |
with gr.Blocks(
|
| 4275 |
title="ThoughtSpot Demo Builder - Chat",
|
| 4276 |
-
theme=gr.themes.Soft(primary_hue="blue", secondary_hue="cyan")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4277 |
) as interface:
|
| 4278 |
|
| 4279 |
# SESSION STATE: Each user gets their own ChatDemoInterface instance
|
|
@@ -4317,7 +4352,7 @@ def create_chat_interface():
|
|
| 4317 |
demo_pack_state = gr.State("")
|
| 4318 |
|
| 4319 |
with gr.Tabs():
|
| 4320 |
-
with gr.Tab("
|
| 4321 |
chat_components = create_chat_tab(
|
| 4322 |
chat_controller_state, default_settings, current_stage, current_model,
|
| 4323 |
current_company, current_usecase,
|
|
@@ -4366,16 +4401,28 @@ def create_chat_interface():
|
|
| 4366 |
)
|
| 4367 |
|
| 4368 |
with gr.Tab("🎬 Spotter Viz Story"):
|
| 4369 |
-
gr.Markdown("
|
| 4370 |
-
|
| 4371 |
-
|
| 4372 |
-
|
| 4373 |
-
|
| 4374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4375 |
|
| 4376 |
with gr.Tab("⚙️ Settings"):
|
| 4377 |
settings_components = create_settings_tab()
|
| 4378 |
-
|
|
|
|
|
|
|
|
|
|
| 4379 |
with gr.Tab("🤖 AI Feedback"):
|
| 4380 |
ai_feedback_display = gr.TextArea(
|
| 4381 |
label="AI Processing Log",
|
|
@@ -4567,14 +4614,12 @@ def create_chat_interface():
|
|
| 4567 |
|
| 4568 |
# Hidden fields — values still saved/loaded but not shown in UI
|
| 4569 |
admin_ts_url = gr.Textbox(visible=False)
|
| 4570 |
-
admin_ts_auth_key = gr.Textbox(visible=False)
|
| 4571 |
admin_openai_key = gr.Textbox(visible=False)
|
| 4572 |
admin_google_key = gr.Textbox(visible=False)
|
| 4573 |
|
| 4574 |
with gr.Row():
|
| 4575 |
with gr.Column():
|
| 4576 |
gr.Markdown("#### ThoughtSpot Connection")
|
| 4577 |
-
admin_ts_user = gr.Textbox(label="ThoughtSpot Admin Username", placeholder="admin@company.com")
|
| 4578 |
admin_share_with = gr.Textbox(
|
| 4579 |
label="Default Share With (User or Group)",
|
| 4580 |
placeholder="user@company.com or group-name",
|
|
@@ -4599,7 +4644,7 @@ def create_chat_interface():
|
|
| 4599 |
|
| 4600 |
# Admin settings field list (order matches ADMIN_SETTINGS_KEYS)
|
| 4601 |
admin_fields = [
|
| 4602 |
-
admin_ts_url,
|
| 4603 |
admin_openai_key, admin_google_key,
|
| 4604 |
admin_sf_account, admin_sf_kp_user, admin_sf_kp_pk,
|
| 4605 |
admin_sf_kp_pass, admin_sf_role, admin_sf_warehouse,
|
|
@@ -4608,7 +4653,7 @@ def create_chat_interface():
|
|
| 4608 |
]
|
| 4609 |
|
| 4610 |
admin_keys_order = [
|
| 4611 |
-
"THOUGHTSPOT_URL",
|
| 4612 |
"OPENAI_API_KEY", "GOOGLE_API_KEY",
|
| 4613 |
"SNOWFLAKE_ACCOUNT", "SNOWFLAKE_KP_USER", "SNOWFLAKE_KP_PK",
|
| 4614 |
"SNOWFLAKE_KP_PASSPHRASE", "SNOWFLAKE_ROLE", "SNOWFLAKE_WAREHOUSE",
|
|
@@ -4751,8 +4796,8 @@ def create_chat_interface():
|
|
| 4751 |
)
|
| 4752 |
|
| 4753 |
# Create update function for tabs
|
| 4754 |
-
|
| 4755 |
-
|
| 4756 |
def update_all_tabs(controller):
|
| 4757 |
if controller is None:
|
| 4758 |
return (
|
|
@@ -4760,35 +4805,34 @@ def create_chat_interface():
|
|
| 4760 |
"-- DDL will appear here after generation",
|
| 4761 |
"Progress will appear here during deployment...",
|
| 4762 |
"Demo pack will be generated after deployment completes.\n\nThis will include:\n- Key insights/outliers\n- Spotter questions to ask\n- Talking points for the demo",
|
| 4763 |
-
|
|
|
|
| 4764 |
)
|
| 4765 |
-
|
| 4766 |
-
# Get live progress from controller (captures deployment output)
|
| 4767 |
live_progress = getattr(controller, 'live_progress_log', [])
|
| 4768 |
live_progress_text = "\n".join(live_progress) if live_progress else "Progress will appear here during deployment..."
|
| 4769 |
-
|
| 4770 |
-
# Get demo pack from controller
|
| 4771 |
demo_pack = getattr(controller, 'demo_pack_content', '')
|
| 4772 |
demo_pack_text = demo_pack if demo_pack else "Demo pack will be generated after deployment completes.\n\nThis will include:\n- Key insights/outliers\n- Spotter questions to ask\n- Talking points for the demo"
|
| 4773 |
-
|
| 4774 |
-
|
| 4775 |
-
|
| 4776 |
-
|
| 4777 |
-
|
| 4778 |
return (
|
| 4779 |
"\n".join(controller.ai_feedback_log),
|
| 4780 |
controller.ddl_code if controller.ddl_code else "-- DDL will appear here after generation",
|
| 4781 |
live_progress_text,
|
| 4782 |
demo_pack_text,
|
| 4783 |
-
|
|
|
|
| 4784 |
)
|
| 4785 |
-
|
| 4786 |
# Wire up tab updates on chat interactions
|
| 4787 |
-
# These will update after each message
|
| 4788 |
chat_components['chatbot'].change(
|
| 4789 |
fn=update_all_tabs,
|
| 4790 |
inputs=[chat_controller_state],
|
| 4791 |
-
outputs=[ai_feedback_display, ddl_display, live_progress_display, demo_pack_display,
|
|
|
|
| 4792 |
)
|
| 4793 |
|
| 4794 |
# Load settings from Supabase on startup (uses SETTINGS_SCHEMA)
|
|
@@ -4818,11 +4862,10 @@ def create_chat_interface():
|
|
| 4818 |
try:
|
| 4819 |
settings = load_gradio_settings(user_email)
|
| 4820 |
|
| 4821 |
-
company =
|
| 4822 |
-
use_case =
|
| 4823 |
model = (str(settings.get("default_llm", "")).strip() or DEFAULT_LLM_MODEL)
|
| 4824 |
liveboard_name = str(settings.get("liveboard_name", "")).strip()
|
| 4825 |
-
initial_message = build_initial_chat_message(company, use_case)
|
| 4826 |
print(f"[LOAD] load_session_state_on_startup OK — model={model}, company={company}")
|
| 4827 |
return (
|
| 4828 |
"initialization",
|
|
@@ -4832,13 +4875,13 @@ def create_chat_interface():
|
|
| 4832 |
liveboard_name,
|
| 4833 |
gr.update(value=model),
|
| 4834 |
gr.update(value=liveboard_name),
|
| 4835 |
-
|
| 4836 |
)
|
| 4837 |
except Exception as e:
|
| 4838 |
import traceback
|
| 4839 |
print(f"[LOAD ERROR] load_session_state_on_startup failed: {e}\n{traceback.format_exc()}")
|
| 4840 |
return (
|
| 4841 |
-
"initialization", DEFAULT_LLM_MODEL, "
|
| 4842 |
gr.update(value=DEFAULT_LLM_MODEL), gr.update(value=""), "",
|
| 4843 |
)
|
| 4844 |
|
|
@@ -4885,33 +4928,71 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
|
|
| 4885 |
with gr.Row():
|
| 4886 |
# Left column - Chat
|
| 4887 |
with gr.Column(scale=3):
|
|
|
|
|
|
|
| 4888 |
chatbot = gr.Chatbot(
|
| 4889 |
-
value=[
|
| 4890 |
-
height=
|
| 4891 |
label="Demo Builder Assistant",
|
| 4892 |
show_label=False,
|
| 4893 |
-
avatar_images=None,
|
| 4894 |
-
type='tuples'
|
|
|
|
|
|
|
| 4895 |
)
|
| 4896 |
-
|
| 4897 |
-
|
| 4898 |
-
|
| 4899 |
-
|
| 4900 |
-
|
| 4901 |
-
|
| 4902 |
-
|
| 4903 |
-
|
| 4904 |
-
|
| 4905 |
-
|
| 4906 |
-
|
| 4907 |
-
|
| 4908 |
-
|
| 4909 |
-
|
| 4910 |
-
|
| 4911 |
-
|
| 4912 |
-
|
| 4913 |
-
|
| 4914 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4915 |
|
| 4916 |
# Right column - Status & Settings
|
| 4917 |
with gr.Column(scale=1):
|
|
@@ -5004,7 +5085,21 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
|
|
| 5004 |
return html
|
| 5005 |
|
| 5006 |
progress_html = gr.HTML(get_progress_html('initialization'))
|
| 5007 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5008 |
# Event handlers - each creates/uses session-specific controller
|
| 5009 |
def send_message(controller, message, history, stage, model, company, usecase, env_label=None, liveboard_name_ui=None, request: gr.Request = None):
|
| 5010 |
"""Handle sending a message - creates controller if needed"""
|
|
@@ -5024,13 +5119,15 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
|
|
| 5024 |
# Always use the current UI value — takes priority over DB-loaded default
|
| 5025 |
if liveboard_name_ui is not None:
|
| 5026 |
controller.settings['liveboard_name'] = liveboard_name_ui
|
|
|
|
| 5027 |
try:
|
| 5028 |
for result in controller.process_chat_message(
|
| 5029 |
message, history, stage, model, company, usecase
|
| 5030 |
):
|
| 5031 |
new_stage = result[1] if len(result) > 1 else stage
|
| 5032 |
progress = get_progress_html(new_stage)
|
| 5033 |
-
|
|
|
|
| 5034 |
except Exception as e:
|
| 5035 |
err_tb = traceback.format_exc()
|
| 5036 |
print(f"[ERROR] send_message unhandled exception:\n{err_tb}")
|
|
@@ -5041,15 +5138,52 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
|
|
| 5041 |
)
|
| 5042 |
history = history or []
|
| 5043 |
history.append((message, err_msg))
|
| 5044 |
-
yield (controller, history, stage, model, company, usecase, "", get_progress_html(stage))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5045 |
|
| 5046 |
-
|
| 5047 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5048 |
username = getattr(request, 'username', None) if request else None
|
| 5049 |
if controller is None:
|
| 5050 |
controller = ChatDemoInterface(user_email=username)
|
| 5051 |
print(f"[SESSION] Created new ChatDemoInterface for {username or 'anonymous'}")
|
| 5052 |
-
# Apply selected TS environment settings to the new controller
|
| 5053 |
if env_label:
|
| 5054 |
_url = get_ts_env_url(env_label)
|
| 5055 |
_key_value = get_ts_env_auth_key(env_label)
|
|
@@ -5057,42 +5191,103 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
|
|
| 5057 |
controller.settings['thoughtspot_url'] = _url
|
| 5058 |
if _key_value:
|
| 5059 |
controller.settings['thoughtspot_trusted_auth_key'] = _key_value
|
| 5060 |
-
|
| 5061 |
-
|
| 5062 |
-
controller.settings['liveboard_name'] = liveboard_name_ui
|
| 5063 |
|
| 5064 |
-
|
| 5065 |
-
|
| 5066 |
-
|
| 5067 |
-
|
| 5068 |
-
|
| 5069 |
-
yield (controller,) + result + (progress,)
|
| 5070 |
-
|
| 5071 |
-
# Wire up send button and enter key
|
| 5072 |
-
_send_inputs = [chat_controller_state, msg, chatbot, current_stage, current_model, current_company, current_usecase, ts_env_dropdown, liveboard_name_input]
|
| 5073 |
-
_send_outputs = [chat_controller_state, chatbot, current_stage, current_model, current_company, current_usecase, msg, progress_html]
|
| 5074 |
|
| 5075 |
-
|
| 5076 |
-
send_btn.click(fn=send_message, inputs=_send_inputs, outputs=_send_outputs)
|
| 5077 |
-
|
| 5078 |
-
# Quick action wrapper functions
|
| 5079 |
-
def start_action(controller, history, stage, model, company, usecase, env_label, liveboard_name_ui):
|
| 5080 |
-
yield from quick_action(controller, "Start research", history, stage, model, company, usecase, env_label, liveboard_name_ui)
|
| 5081 |
|
| 5082 |
-
|
| 5083 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5084 |
|
| 5085 |
-
|
| 5086 |
-
|
|
|
|
| 5087 |
|
| 5088 |
-
|
| 5089 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5090 |
|
| 5091 |
-
# Quick action buttons
|
| 5092 |
-
start_btn.click(fn=start_action, inputs=_action_inputs, outputs=_action_outputs)
|
| 5093 |
-
configure_btn.click(fn=configure_action, inputs=_action_inputs, outputs=_action_outputs)
|
| 5094 |
-
help_btn.click(fn=help_action, inputs=_action_inputs, outputs=_action_outputs)
|
| 5095 |
-
|
| 5096 |
# Model dropdown change
|
| 5097 |
def update_model(new_model, controller, history):
|
| 5098 |
if controller is not None:
|
|
@@ -5142,13 +5337,153 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
|
|
| 5142 |
'msg': msg,
|
| 5143 |
'model_dropdown': model_dropdown,
|
| 5144 |
'send_btn': send_btn,
|
| 5145 |
-
'send_btn_ref': send_btn,
|
| 5146 |
'ts_env_dropdown': ts_env_dropdown,
|
| 5147 |
'liveboard_name_input': liveboard_name_input,
|
| 5148 |
-
'progress_html': progress_html
|
|
|
|
|
|
|
| 5149 |
}
|
| 5150 |
|
| 5151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5152 |
def create_settings_tab():
|
| 5153 |
"""Create the settings configuration tab - returns components for loading
|
| 5154 |
|
|
|
|
| 19 |
from supabase_client import load_gradio_settings, get_admin_setting, inject_admin_settings_to_env
|
| 20 |
from main_research import MultiLLMResearcher, Website
|
| 21 |
from demo_personas import (
|
| 22 |
+
build_company_analysis_prompt,
|
| 23 |
build_industry_research_prompt,
|
| 24 |
VERTICALS,
|
| 25 |
FUNCTIONS,
|
| 26 |
+
MATRIX_OVERRIDES,
|
| 27 |
get_use_case_config,
|
| 28 |
parse_use_case
|
| 29 |
)
|
|
|
|
| 70 |
|
| 71 |
def get_ts_env_auth_key(label: str) -> str:
|
| 72 |
"""Return the actual auth key value for a given environment label.
|
| 73 |
+
TS_ENV_N_KEY_VAR holds the trusted auth key directly.
|
| 74 |
"""
|
| 75 |
i = 1
|
| 76 |
while True:
|
|
|
|
| 78 |
if not env_label:
|
| 79 |
break
|
| 80 |
if env_label == label:
|
| 81 |
+
return os.getenv(f'TS_ENV_{i}_KEY_VAR', '').strip()
|
|
|
|
| 82 |
i += 1
|
| 83 |
return ''
|
| 84 |
|
|
|
|
| 232 |
self.pending_generic_use_case = None
|
| 233 |
# New tab content
|
| 234 |
self.live_progress_log = [] # Real-time deployment progress
|
| 235 |
+
self.phase_log = [] # High-level pipeline status (shown in right panel)
|
| 236 |
+
self.demo_pack_content = "" # Generated demo pack markdown
|
| 237 |
+
self.spotter_story_ai = "" # Pure AI-generated Spotter Viz story
|
| 238 |
+
self.spotter_story_matrix = "" # Matrix/ThoughtSpot-recommended Spotter Viz story
|
| 239 |
# Per-session loggers (NOT module-level singletons — avoids cross-session contamination)
|
| 240 |
self._session_logger = None
|
| 241 |
self._prompt_logger = None
|
|
|
|
| 249 |
"""Load settings from Supabase or defaults"""
|
| 250 |
# Fallback defaults (ONLY used if settings not found)
|
| 251 |
defaults = {
|
| 252 |
+
'company': '',
|
| 253 |
+
'use_case': '',
|
| 254 |
'model': DEFAULT_LLM_MODEL,
|
| 255 |
'fact_table_size': '1000',
|
| 256 |
'dim_table_size': '100',
|
|
|
|
| 293 |
|
| 294 |
def format_welcome_message(self, company, use_case):
|
| 295 |
"""Create the initial welcome message"""
|
| 296 |
+
return """## Welcome to ThoughtSpot Demo Builder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
|
| 298 |
+
I'll research a company, build a Snowflake schema, generate realistic data, and deploy a ThoughtSpot model and liveboard — all from a single prompt.
|
| 299 |
|
| 300 |
+
**How to start:**
|
| 301 |
+
- **Defined** — pick a vertical and function from the dropdowns below, add a company URL if you have one, and hit **→ GO**.
|
| 302 |
+
- **Custom** — describe what you want in your own words in the chat box.
|
| 303 |
|
| 304 |
+
<details>
|
| 305 |
+
<summary>📋 Example use cases</summary>
|
|
|
|
|
|
|
|
|
|
| 306 |
|
| 307 |
+
| Company | Vertical | Function | Story |
|
| 308 |
+
|---------|----------|----------|-------|
|
| 309 |
+
| Target.com | Retail | Sales | ASP decline, regional variance, holiday surge |
|
| 310 |
+
| Walmart.com | Retail | Supply Chain | Stockout risk, OTIF, days on hand |
|
| 311 |
+
| Chase.com | Banking | Marketing | Funnel drop-off, channel CTR, cost per acquisition |
|
| 312 |
+
| Salesforce.com | Software | Sales | ARR by segment, pipeline coverage, win rate |
|
| 313 |
+
| Caterpillar.com | Manufacturing | Supply Chain | Inventory levels, supplier performance |
|
| 314 |
|
| 315 |
+
</details>
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
+
> 💡 Select your ThoughtSpot environment in the right panel before starting."""
|
| 318 |
|
| 319 |
def validate_required_settings(self) -> list:
|
| 320 |
"""
|
|
|
|
| 326 |
# Check admin settings (from environment, injected from Supabase)
|
| 327 |
admin_checks = {
|
| 328 |
'THOUGHTSPOT_URL': get_admin_setting('THOUGHTSPOT_URL', required=False),
|
|
|
|
|
|
|
| 329 |
'SNOWFLAKE_ACCOUNT': get_admin_setting('SNOWFLAKE_ACCOUNT', required=False),
|
| 330 |
'SNOWFLAKE_KP_USER': get_admin_setting('SNOWFLAKE_KP_USER', required=False),
|
| 331 |
'SNOWFLAKE_KP_PK': get_admin_setting('SNOWFLAKE_KP_PK', required=False),
|
|
|
|
| 418 |
from supabase_client import get_admin_setting
|
| 419 |
|
| 420 |
ts_url = (self.settings.get('thoughtspot_url') or '').strip() or get_admin_setting('THOUGHTSPOT_URL')
|
| 421 |
+
ts_user = self._get_effective_user_email()
|
| 422 |
+
ts_secret = (self.settings.get('thoughtspot_trusted_auth_key') or '').strip()
|
| 423 |
+
if not ts_secret:
|
| 424 |
+
raise ValueError("ThoughtSpot auth key not set — select a TS environment from the dropdown")
|
| 425 |
|
| 426 |
ts_client = ThoughtSpotDeployer(base_url=ts_url, username=ts_user, secret_key=ts_secret)
|
| 427 |
ts_client.authenticate()
|
|
|
|
| 475 |
# User provided just a URL - ask them to include the use case too
|
| 476 |
detected_company = standalone_url.group(1)
|
| 477 |
# Build dynamic use case list from VERTICALS × FUNCTIONS
|
| 478 |
+
uc_opts = [f"- {v} {f}" for v in VERTICALS.keys() for f in FUNCTIONS.keys()]
|
| 479 |
uc_opts_str = "\n".join(uc_opts)
|
| 480 |
response = f"""I see you want to use **{detected_company}** - great choice!
|
| 481 |
|
|
|
|
| 491 |
|
| 492 |
What use case would you like for {detected_company}?"""
|
| 493 |
chat_history[-1] = (message, response)
|
| 494 |
+
yield chat_history, current_stage, current_model, detected_company, use_case, ""
|
| 495 |
return
|
| 496 |
|
| 497 |
# Check if user is providing company and use case
|
|
|
|
| 683 |
return
|
| 684 |
|
| 685 |
elif extracted_company and not extracted_use_case:
|
| 686 |
+
uc_opts = "\n".join([f"- {v} {f}" for v in VERTICALS.keys() for f in FUNCTIONS.keys()])
|
| 687 |
response = f"""Got it — **{extracted_company}**!
|
| 688 |
|
| 689 |
What use case are we building? A few options:
|
| 690 |
{uc_opts}
|
| 691 |
- Or describe any custom scenario!"""
|
| 692 |
chat_history[-1] = (message, response)
|
| 693 |
+
yield chat_history, current_stage, current_model, extracted_company, use_case, ""
|
| 694 |
return
|
| 695 |
|
| 696 |
else:
|
|
|
|
| 728 |
|
| 729 |
if validation_mode == 'Off':
|
| 730 |
# AUTO-RUN MODE: Run entire pipeline without any more prompts
|
| 731 |
+
current_stage = 'research'
|
| 732 |
+
self.phase_log.append(f"🚀 Starting pipeline — {company} · {use_case}")
|
| 733 |
+
self.phase_log.append("🔍 Phase 1: Research")
|
| 734 |
+
|
| 735 |
auto_run_msg = f"""✅ **Starting Auto-Run Mode**
|
| 736 |
|
| 737 |
**Company:** {company}
|
|
|
|
| 758 |
|
| 759 |
self.log_feedback(f"DEBUG AUTO-RUN: Research loop EXITED after {research_yield_count} yields")
|
| 760 |
|
| 761 |
+
# Show research complete, move to DDL stage
|
| 762 |
+
current_stage = 'create_ddl'
|
| 763 |
+
self.phase_log.append("✅ Research complete")
|
| 764 |
+
self.phase_log.append("📐 Phase 2: DDL creation")
|
| 765 |
chat_history[-1] = (message, "✅ **Research Complete!**\n\n📝 **Creating DDL...**")
|
| 766 |
yield chat_history, current_stage, current_model, company, use_case, ""
|
| 767 |
self.log_feedback("DEBUG AUTO-RUN: Moving to DDL creation...")
|
| 768 |
+
|
| 769 |
# Auto-create DDL
|
| 770 |
ddl_response, ddl_code = self.run_ddl_creation()
|
| 771 |
+
|
| 772 |
# Check if DDL creation failed
|
| 773 |
if not ddl_code or ddl_code.strip() == "":
|
| 774 |
chat_history[-1] = (message, f"{ddl_response}\n\n❌ **Cannot proceed without valid DDL.** Please fix the error and try again.")
|
| 775 |
yield chat_history, current_stage, current_model, company, use_case, ""
|
| 776 |
return
|
| 777 |
+
|
| 778 |
+
current_stage = 'deploy'
|
| 779 |
+
self.phase_log.append("✅ DDL created")
|
| 780 |
+
self.phase_log.append("🏗️ Phase 3: Snowflake deploy")
|
| 781 |
chat_history[-1] = (message, f"✅ DDL Created\n\n🚀 **Deploying to Snowflake...**")
|
| 782 |
yield chat_history, current_stage, current_model, company, use_case, ""
|
| 783 |
+
|
| 784 |
# Auto-deploy
|
| 785 |
with open('/tmp/demoprep_debug.log', 'a') as f:
|
| 786 |
f.write(f"AUTO-RUN: Starting deployment streaming\n")
|
|
|
|
| 813 |
deploy_response, _, auto_schema = final_result
|
| 814 |
with open('/tmp/demoprep_debug.log', 'a') as f:
|
| 815 |
f.write(f"AUTO-RUN: auto_ts detected, schema={auto_schema}\n")
|
| 816 |
+
self.phase_log.append("✅ Snowflake deploy complete")
|
| 817 |
+
self.phase_log.append("🔷 Phase 4: ThoughtSpot")
|
| 818 |
chat_history[-1] = (message, deploy_response)
|
| 819 |
yield chat_history, current_stage, current_model, company, use_case, ""
|
| 820 |
+
|
| 821 |
# Run ThoughtSpot deployment with detailed logging
|
| 822 |
with open('/tmp/demoprep_debug.log', 'a') as f:
|
| 823 |
f.write(f"AUTO-RUN: Creating TS generator...\n")
|
|
|
|
| 838 |
yield chat_history, current_stage, current_model, company, use_case, ""
|
| 839 |
with open('/tmp/demoprep_debug.log', 'a') as f:
|
| 840 |
f.write(f"AUTO-RUN: TS deployment loop complete, {ts_update_count} updates\n")
|
| 841 |
+
self.phase_log.append("✅ ThoughtSpot complete")
|
| 842 |
+
self.phase_log.append("🎉 Pipeline done!")
|
| 843 |
else:
|
| 844 |
# Not auto_ts, just show the result
|
| 845 |
if len(final_result) >= 2:
|
|
|
|
| 1650 |
continue
|
| 1651 |
return use_case
|
| 1652 |
|
| 1653 |
+
# Fallback 1: text after a comma or dash following a domain.tld
|
| 1654 |
# Handles: "Nike.com, Retail Sales" / "Nike.com - Supply Chain"
|
| 1655 |
after_domain = re.search(
|
| 1656 |
r'[a-zA-Z0-9-]+\.[a-zA-Z]{2,}[\s]*[,\-–—]\s*(.+)',
|
|
|
|
| 1661 |
if use_case and not re.search(r'\.(com|org|net|io|co|ai)\b', use_case, re.IGNORECASE):
|
| 1662 |
return use_case
|
| 1663 |
|
| 1664 |
+
# Fallback 2: text after domain.tld separated only by a space
|
| 1665 |
+
# Handles: "Caterpillar.com Manufacturing Supply Chain"
|
| 1666 |
+
after_domain_space = re.search(
|
| 1667 |
+
r'[a-zA-Z0-9-]+\.[a-zA-Z]{2,}\s+(.+)',
|
| 1668 |
+
message, re.IGNORECASE
|
| 1669 |
+
)
|
| 1670 |
+
if after_domain_space:
|
| 1671 |
+
use_case = after_domain_space.group(1).strip().rstrip('.')
|
| 1672 |
+
if use_case and not re.search(r'\.(com|org|net|io|co|ai)\b', use_case, re.IGNORECASE):
|
| 1673 |
+
return use_case
|
| 1674 |
+
|
| 1675 |
return None
|
| 1676 |
|
| 1677 |
def handle_override(self, message):
|
|
|
|
| 2270 |
- **Ask questions**: Let the AI demonstrate natural language
|
| 2271 |
- **End with action**: Show how insights lead to decisions""")
|
| 2272 |
|
| 2273 |
+
def _generate_ai_spotter_story(self, company_name: str, use_case: str,
|
| 2274 |
+
model_name: str = None, liveboard_name: str = None) -> str:
|
| 2275 |
+
"""Pure AI-generated Spotter Viz story — no matrix reference.
|
| 2276 |
+
AI decides what a compelling liveboard story looks like for this company + use case.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2277 |
"""
|
| 2278 |
+
from prompts import build_prompt
|
| 2279 |
+
from demo_personas import parse_use_case
|
| 2280 |
|
| 2281 |
v, f = parse_use_case(use_case or '')
|
| 2282 |
+
vertical = v or "Generic"
|
| 2283 |
+
function = f or "Generic"
|
| 2284 |
+
|
| 2285 |
+
company_context = f"Company: {company_name}\nUse Case: {use_case}"
|
| 2286 |
+
if model_name:
|
| 2287 |
+
company_context += f"\nData Source/Model: {model_name}"
|
| 2288 |
+
if liveboard_name:
|
| 2289 |
+
company_context += f"\nLiveboard Name: {liveboard_name}"
|
| 2290 |
+
if hasattr(self, 'demo_builder') and self.demo_builder:
|
| 2291 |
+
research = getattr(self.demo_builder, 'company_summary', '') or ''
|
| 2292 |
+
if research:
|
| 2293 |
+
company_context += f"\n\nCompany Research:\n{research[:1500]}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2294 |
|
|
|
|
| 2295 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2296 |
prompt = build_prompt(
|
| 2297 |
stage="spotter_viz_story",
|
| 2298 |
vertical=vertical,
|
| 2299 |
function=function,
|
| 2300 |
company_context=company_context,
|
| 2301 |
)
|
|
|
|
| 2302 |
llm_model = self.settings.get('model', DEFAULT_LLM_MODEL)
|
| 2303 |
self.log_feedback(f"🎬 Generating AI Spotter Viz story ({llm_model})...")
|
|
|
|
| 2304 |
provider_name, model_name_str = map_llm_display_to_provider(llm_model)
|
| 2305 |
researcher = MultiLLMResearcher(provider=provider_name, model=model_name_str)
|
| 2306 |
+
return researcher.make_request(prompt, max_tokens=2000, temperature=0.7)
|
|
|
|
|
|
|
| 2307 |
except Exception as e:
|
| 2308 |
self.log_feedback(f"⚠️ AI Spotter Viz story generation failed: {e}")
|
| 2309 |
+
return f"*(Generation failed: {e})*"
|
| 2310 |
+
|
| 2311 |
+
def _generate_matrix_spotter_story(self, company_name: str, use_case: str,
|
| 2312 |
+
model_name: str = None, liveboard_name: str = None) -> str:
|
| 2313 |
+
"""Matrix/ThoughtSpot-recommended Spotter Viz story.
|
| 2314 |
+
Builds from the vertical×function matrix (KPIs, liveboard_questions, story controls, persona).
|
| 2315 |
+
AI writes it — adds narrative — but every step comes from the matrix.
|
| 2316 |
+
"""
|
| 2317 |
+
from prompts import build_prompt
|
| 2318 |
+
from demo_personas import parse_use_case, get_use_case_config
|
| 2319 |
+
|
| 2320 |
+
v, f = parse_use_case(use_case or '')
|
| 2321 |
+
uc_cfg = get_use_case_config(v or "Generic", f or "Generic")
|
| 2322 |
+
data_source = model_name or f"{company_name} model"
|
| 2323 |
+
|
| 2324 |
+
# Build rich matrix context for the prompt
|
| 2325 |
+
kpis = uc_cfg.get("kpis", [])
|
| 2326 |
+
lq = uc_cfg.get("liveboard_questions", [])
|
| 2327 |
+
story_controls = uc_cfg.get("story_controls", {})
|
| 2328 |
+
persona = uc_cfg.get("persona", "")
|
| 2329 |
+
business_problem = uc_cfg.get("business_problem", "")
|
| 2330 |
+
use_case_name = uc_cfg.get("use_case_name", use_case)
|
| 2331 |
+
|
| 2332 |
+
matrix_context = f"Company: {company_name}\nUse Case: {use_case_name}\nData Source/Model: {data_source}"
|
| 2333 |
+
if liveboard_name:
|
| 2334 |
+
matrix_context += f"\nLiveboard Name: {liveboard_name}"
|
| 2335 |
+
if persona:
|
| 2336 |
+
matrix_context += f"\nTarget Persona: {persona}"
|
| 2337 |
+
if business_problem:
|
| 2338 |
+
matrix_context += f"\nBusiness Problem: {business_problem}"
|
| 2339 |
+
|
| 2340 |
+
if kpis:
|
| 2341 |
+
kpi_lines = "\n".join(f" - {k['name']}: {k.get('definition', '')}" for k in kpis)
|
| 2342 |
+
matrix_context += f"\n\nKPIs (from ThoughtSpot matrix):\n{kpi_lines}"
|
| 2343 |
+
|
| 2344 |
+
if lq:
|
| 2345 |
+
matrix_context += "\n\nLiveboard Questions (in order):"
|
| 2346 |
+
for q in lq:
|
| 2347 |
+
req = " [required]" if q.get("required") else ""
|
| 2348 |
+
matrix_context += f"\n - {q['title']} ({q.get('viz_type','chart')}){req}: {q['viz_question']}"
|
| 2349 |
+
if q.get('insight'):
|
| 2350 |
+
matrix_context += f"\n Insight: {q['insight']}"
|
| 2351 |
+
|
| 2352 |
+
if story_controls:
|
| 2353 |
+
dims = story_controls.get("dimensions", [])
|
| 2354 |
+
if dims:
|
| 2355 |
+
matrix_context += f"\n\nKey Dimensions: {', '.join(dims)}"
|
| 2356 |
+
seasonal = story_controls.get("seasonal_strength") or story_controls.get("seasonal")
|
| 2357 |
+
if seasonal:
|
| 2358 |
+
matrix_context += f"\nSeasonal pattern: {seasonal}"
|
| 2359 |
|
| 2360 |
+
try:
|
| 2361 |
+
prompt = build_prompt(
|
| 2362 |
+
stage="spotter_viz_story_matrix",
|
| 2363 |
+
vertical=v or "Generic",
|
| 2364 |
+
function=f or "Generic",
|
| 2365 |
+
company_context=matrix_context,
|
| 2366 |
+
)
|
| 2367 |
+
llm_model = self.settings.get('model', DEFAULT_LLM_MODEL)
|
| 2368 |
+
self.log_feedback(f"🎬 Generating Matrix Spotter Viz story ({llm_model})...")
|
| 2369 |
+
provider_name, model_name_str = map_llm_display_to_provider(llm_model)
|
| 2370 |
+
researcher = MultiLLMResearcher(provider=provider_name, model=model_name_str)
|
| 2371 |
+
return researcher.make_request(prompt, max_tokens=2000, temperature=0.6)
|
| 2372 |
+
except Exception as e:
|
| 2373 |
+
self.log_feedback(f"⚠️ Matrix Spotter Viz story generation failed: {e}")
|
| 2374 |
+
return f"*(Generation failed: {e})*"
|
| 2375 |
|
| 2376 |
def _build_fallback_spotter_story(self, company_name: str, use_case: str,
|
| 2377 |
model_name: str = None) -> str:
|
|
|
|
| 3553 |
|
| 3554 |
# Get ThoughtSpot settings
|
| 3555 |
ts_url = get_admin_setting('THOUGHTSPOT_URL')
|
| 3556 |
+
ts_user = self._get_effective_user_email()
|
| 3557 |
+
ts_secret = self.settings.get('thoughtspot_trusted_auth_key')
|
| 3558 |
+
if not ts_secret:
|
| 3559 |
+
raise ValueError("ThoughtSpot trusted auth key not set. Select a TS environment from the dropdown.")
|
| 3560 |
+
|
| 3561 |
liveboard_method = 'HYBRID' # Only HYBRID method is supported
|
| 3562 |
|
| 3563 |
# Clean company name for display (strip .com, .org, etc)
|
|
|
|
| 3689 |
yield f"**Starting ThoughtSpot Deployment...**\n\nSchema verified: {database}.{schema_name}\nFound {len(tables)} tables\n\n"
|
| 3690 |
|
| 3691 |
# Create deployer — prefer session-selected env (from TS env dropdown),
|
| 3692 |
+
# fall back to admin settings. Use logged-in user so TS objects are owned by them.
|
| 3693 |
ts_url = (self.settings.get('thoughtspot_url') or '').strip() or get_admin_setting('THOUGHTSPOT_URL')
|
| 3694 |
+
ts_user = self._get_effective_user_email()
|
| 3695 |
+
ts_secret = self.settings.get('thoughtspot_trusted_auth_key')
|
| 3696 |
+
if not ts_secret:
|
| 3697 |
+
raise ValueError("ThoughtSpot trusted auth key not set. Select a TS environment from the dropdown.")
|
| 3698 |
|
| 3699 |
deployer = ThoughtSpotDeployer(
|
| 3700 |
base_url=ts_url,
|
|
|
|
| 3779 |
tag_name=tag_name_value,
|
| 3780 |
liveboard_method=liveboard_method,
|
| 3781 |
share_with=self.settings.get('share_with', '').strip() or None,
|
| 3782 |
+
company_research=self.demo_builder.get_research_context() if self.demo_builder else None,
|
| 3783 |
progress_callback=progress_callback
|
| 3784 |
)
|
| 3785 |
except Exception as e:
|
|
|
|
| 3842 |
if results.get('success'):
|
| 3843 |
safe_print("DEPLOYMENT COMPLETE", flush=True)
|
| 3844 |
self.live_progress_log.extend(["", "=" * 60, "DEPLOYMENT COMPLETE", "=" * 60])
|
| 3845 |
+
# Capture any non-fatal errors (e.g. enhance failure) so they reach session_logs
|
| 3846 |
+
if results.get('errors'):
|
| 3847 |
+
_ts_error = '; '.join(results['errors'])
|
| 3848 |
else:
|
| 3849 |
safe_print("DEPLOYMENT FAILED", flush=True)
|
| 3850 |
safe_print(f"Errors: {results.get('errors', [])}", flush=True)
|
| 3851 |
self.live_progress_log.extend(["", "DEPLOYMENT FAILED", f"Errors: {results.get('errors', [])}"])
|
| 3852 |
+
if results.get('errors'):
|
| 3853 |
+
_ts_error = '; '.join(results['errors'])
|
| 3854 |
safe_print("="*60 + "\n", flush=True)
|
| 3855 |
|
| 3856 |
progress_log = '\n'.join(progress_messages) if progress_messages else 'No progress messages captured'
|
|
|
|
| 3919 |
safe_print(f"Could not generate demo pack: {e}", flush=True)
|
| 3920 |
self.demo_pack_content = f"*Demo pack generation failed: {e}*"
|
| 3921 |
|
| 3922 |
+
# Generate Spotter Viz Stories (both matrix and AI versions)
|
| 3923 |
try:
|
| 3924 |
+
_story_args = dict(
|
|
|
|
|
|
|
| 3925 |
company_name=company_name,
|
| 3926 |
use_case=use_case,
|
| 3927 |
+
model_name=results.get('model', None),
|
| 3928 |
+
liveboard_name=results.get('liveboard', None),
|
| 3929 |
)
|
| 3930 |
+
self.spotter_story_matrix = self._generate_matrix_spotter_story(**_story_args)
|
| 3931 |
+
self.spotter_story_ai = self._generate_ai_spotter_story(**_story_args)
|
| 3932 |
+
safe_print("Spotter Viz Stories generated - check the Spotter Viz Story tab.", flush=True)
|
| 3933 |
+
self.live_progress_log.append("Spotter Viz Stories generated")
|
| 3934 |
except Exception as e:
|
| 3935 |
safe_print(f"Could not generate Spotter Viz story: {e}", flush=True)
|
| 3936 |
+
_err = f"*(Generation failed: {e})*"
|
| 3937 |
+
self.spotter_story_matrix = _err
|
| 3938 |
+
self.spotter_story_ai = _err
|
| 3939 |
|
| 3940 |
# Build final response
|
| 3941 |
if results.get('success'):
|
|
|
|
| 4292 |
|
| 4293 |
# Bootstrap defaults before authenticated user-specific settings are loaded
|
| 4294 |
default_settings = {
|
| 4295 |
+
"company": "",
|
| 4296 |
+
"use_case": "",
|
| 4297 |
"model": DEFAULT_LLM_MODEL,
|
| 4298 |
"stage": "initialization",
|
| 4299 |
}
|
| 4300 |
|
| 4301 |
with gr.Blocks(
|
| 4302 |
title="ThoughtSpot Demo Builder - Chat",
|
| 4303 |
+
theme=gr.themes.Soft(primary_hue="blue", secondary_hue="cyan"),
|
| 4304 |
+
css="""
|
| 4305 |
+
.tabitem { padding-top: 6px !important; }
|
| 4306 |
+
/* Remove chatbot box — let content sit directly on page */
|
| 4307 |
+
#main-chatbot { border: none !important; background: transparent !important; box-shadow: none !important; padding: 0 !important; }
|
| 4308 |
+
#main-chatbot > div { border: none !important; background: transparent !important; box-shadow: none !important; }
|
| 4309 |
+
#main-chatbot .bubble-wrap { background: transparent !important; padding-top: 0 !important; padding-bottom: 0 !important; }
|
| 4310 |
+
#main-chatbot .wrap { background: transparent !important; border: none !important; }
|
| 4311 |
+
""",
|
| 4312 |
) as interface:
|
| 4313 |
|
| 4314 |
# SESSION STATE: Each user gets their own ChatDemoInterface instance
|
|
|
|
| 4352 |
demo_pack_state = gr.State("")
|
| 4353 |
|
| 4354 |
with gr.Tabs():
|
| 4355 |
+
with gr.Tab("📱 App"):
|
| 4356 |
chat_components = create_chat_tab(
|
| 4357 |
chat_controller_state, default_settings, current_stage, current_model,
|
| 4358 |
current_company, current_usecase,
|
|
|
|
| 4401 |
)
|
| 4402 |
|
| 4403 |
with gr.Tab("🎬 Spotter Viz Story"):
|
| 4404 |
+
gr.Markdown("*Conversational prompts to enter into ThoughtSpot Spotter Viz. Generated after liveboard creation.*")
|
| 4405 |
+
_spotter_default = "Story will be generated after liveboard creation.\n\n**What is Spotter Viz?** An AI agent in ThoughtSpot that builds Liveboards through natural language — type a request, it creates and refines the dashboard step by step."
|
| 4406 |
+
with gr.Tabs():
|
| 4407 |
+
with gr.Tab("🗺️ ThoughtSpot Story"):
|
| 4408 |
+
gr.Markdown("*Built from the ThoughtSpot-recommended KPIs and visualizations for this vertical × function.*")
|
| 4409 |
+
spotter_matrix_display = gr.Markdown(
|
| 4410 |
+
value=_spotter_default,
|
| 4411 |
+
elem_classes=["spotter-viz-story-content"]
|
| 4412 |
+
)
|
| 4413 |
+
with gr.Tab("✨ AI Story"):
|
| 4414 |
+
gr.Markdown("*Pure AI — no matrix constraints. What the AI thinks would make a compelling liveboard story.*")
|
| 4415 |
+
spotter_ai_display = gr.Markdown(
|
| 4416 |
+
value=_spotter_default,
|
| 4417 |
+
elem_classes=["spotter-viz-story-content"]
|
| 4418 |
+
)
|
| 4419 |
|
| 4420 |
with gr.Tab("⚙️ Settings"):
|
| 4421 |
settings_components = create_settings_tab()
|
| 4422 |
+
|
| 4423 |
+
with gr.Tab("🧩 Matrix"):
|
| 4424 |
+
matrix_components = create_matrix_tab(interface)
|
| 4425 |
+
|
| 4426 |
with gr.Tab("🤖 AI Feedback"):
|
| 4427 |
ai_feedback_display = gr.TextArea(
|
| 4428 |
label="AI Processing Log",
|
|
|
|
| 4614 |
|
| 4615 |
# Hidden fields — values still saved/loaded but not shown in UI
|
| 4616 |
admin_ts_url = gr.Textbox(visible=False)
|
|
|
|
| 4617 |
admin_openai_key = gr.Textbox(visible=False)
|
| 4618 |
admin_google_key = gr.Textbox(visible=False)
|
| 4619 |
|
| 4620 |
with gr.Row():
|
| 4621 |
with gr.Column():
|
| 4622 |
gr.Markdown("#### ThoughtSpot Connection")
|
|
|
|
| 4623 |
admin_share_with = gr.Textbox(
|
| 4624 |
label="Default Share With (User or Group)",
|
| 4625 |
placeholder="user@company.com or group-name",
|
|
|
|
| 4644 |
|
| 4645 |
# Admin settings field list (order matches ADMIN_SETTINGS_KEYS)
|
| 4646 |
admin_fields = [
|
| 4647 |
+
admin_ts_url,
|
| 4648 |
admin_openai_key, admin_google_key,
|
| 4649 |
admin_sf_account, admin_sf_kp_user, admin_sf_kp_pk,
|
| 4650 |
admin_sf_kp_pass, admin_sf_role, admin_sf_warehouse,
|
|
|
|
| 4653 |
]
|
| 4654 |
|
| 4655 |
admin_keys_order = [
|
| 4656 |
+
"THOUGHTSPOT_URL",
|
| 4657 |
"OPENAI_API_KEY", "GOOGLE_API_KEY",
|
| 4658 |
"SNOWFLAKE_ACCOUNT", "SNOWFLAKE_KP_USER", "SNOWFLAKE_KP_PK",
|
| 4659 |
"SNOWFLAKE_KP_PASSPHRASE", "SNOWFLAKE_ROLE", "SNOWFLAKE_WAREHOUSE",
|
|
|
|
| 4796 |
)
|
| 4797 |
|
| 4798 |
# Create update function for tabs
|
| 4799 |
+
_spotter_waiting = "Story will be generated after liveboard creation."
|
| 4800 |
+
|
| 4801 |
def update_all_tabs(controller):
|
| 4802 |
if controller is None:
|
| 4803 |
return (
|
|
|
|
| 4805 |
"-- DDL will appear here after generation",
|
| 4806 |
"Progress will appear here during deployment...",
|
| 4807 |
"Demo pack will be generated after deployment completes.\n\nThis will include:\n- Key insights/outliers\n- Spotter questions to ask\n- Talking points for the demo",
|
| 4808 |
+
_spotter_waiting,
|
| 4809 |
+
_spotter_waiting,
|
| 4810 |
)
|
| 4811 |
+
|
|
|
|
| 4812 |
live_progress = getattr(controller, 'live_progress_log', [])
|
| 4813 |
live_progress_text = "\n".join(live_progress) if live_progress else "Progress will appear here during deployment..."
|
| 4814 |
+
|
|
|
|
| 4815 |
demo_pack = getattr(controller, 'demo_pack_content', '')
|
| 4816 |
demo_pack_text = demo_pack if demo_pack else "Demo pack will be generated after deployment completes.\n\nThis will include:\n- Key insights/outliers\n- Spotter questions to ask\n- Talking points for the demo"
|
| 4817 |
+
|
| 4818 |
+
matrix_story = getattr(controller, 'spotter_story_matrix', '') or _spotter_waiting
|
| 4819 |
+
ai_story = getattr(controller, 'spotter_story_ai', '') or _spotter_waiting
|
| 4820 |
+
|
|
|
|
| 4821 |
return (
|
| 4822 |
"\n".join(controller.ai_feedback_log),
|
| 4823 |
controller.ddl_code if controller.ddl_code else "-- DDL will appear here after generation",
|
| 4824 |
live_progress_text,
|
| 4825 |
demo_pack_text,
|
| 4826 |
+
matrix_story,
|
| 4827 |
+
ai_story,
|
| 4828 |
)
|
| 4829 |
+
|
| 4830 |
# Wire up tab updates on chat interactions
|
|
|
|
| 4831 |
chat_components['chatbot'].change(
|
| 4832 |
fn=update_all_tabs,
|
| 4833 |
inputs=[chat_controller_state],
|
| 4834 |
+
outputs=[ai_feedback_display, ddl_display, live_progress_display, demo_pack_display,
|
| 4835 |
+
spotter_matrix_display, spotter_ai_display]
|
| 4836 |
)
|
| 4837 |
|
| 4838 |
# Load settings from Supabase on startup (uses SETTINGS_SCHEMA)
|
|
|
|
| 4862 |
try:
|
| 4863 |
settings = load_gradio_settings(user_email)
|
| 4864 |
|
| 4865 |
+
company = str(settings.get("default_company_url", "")).strip()
|
| 4866 |
+
use_case = str(settings.get("default_use_case", "")).strip()
|
| 4867 |
model = (str(settings.get("default_llm", "")).strip() or DEFAULT_LLM_MODEL)
|
| 4868 |
liveboard_name = str(settings.get("liveboard_name", "")).strip()
|
|
|
|
| 4869 |
print(f"[LOAD] load_session_state_on_startup OK — model={model}, company={company}")
|
| 4870 |
return (
|
| 4871 |
"initialization",
|
|
|
|
| 4875 |
liveboard_name,
|
| 4876 |
gr.update(value=model),
|
| 4877 |
gr.update(value=liveboard_name),
|
| 4878 |
+
"",
|
| 4879 |
)
|
| 4880 |
except Exception as e:
|
| 4881 |
import traceback
|
| 4882 |
print(f"[LOAD ERROR] load_session_state_on_startup failed: {e}\n{traceback.format_exc()}")
|
| 4883 |
return (
|
| 4884 |
+
"initialization", DEFAULT_LLM_MODEL, "", "", "",
|
| 4885 |
gr.update(value=DEFAULT_LLM_MODEL), gr.update(value=""), "",
|
| 4886 |
)
|
| 4887 |
|
|
|
|
| 4928 |
with gr.Row():
|
| 4929 |
# Left column - Chat
|
| 4930 |
with gr.Column(scale=3):
|
| 4931 |
+
# Welcome shown as plain Markdown (no box); swapped out on first message
|
| 4932 |
+
welcome_md = gr.Markdown(value=initial_welcome, visible=True)
|
| 4933 |
chatbot = gr.Chatbot(
|
| 4934 |
+
value=[],
|
| 4935 |
+
height=400,
|
| 4936 |
label="Demo Builder Assistant",
|
| 4937 |
show_label=False,
|
| 4938 |
+
avatar_images=None,
|
| 4939 |
+
type='tuples',
|
| 4940 |
+
elem_id="main-chatbot",
|
| 4941 |
+
visible=False,
|
| 4942 |
)
|
| 4943 |
+
|
| 4944 |
+
# Input mode: App (dropdown form) or Chat (freeform)
|
| 4945 |
+
with gr.Tabs():
|
| 4946 |
+
with gr.Tab("App"):
|
| 4947 |
+
_override_keys = {(v, f) for v, f in MATRIX_OVERRIDES.keys()}
|
| 4948 |
+
_vertical_choices = list(VERTICALS.keys()) + ["Custom"]
|
| 4949 |
+
vertical_dd = gr.Dropdown(
|
| 4950 |
+
label="Vertical",
|
| 4951 |
+
choices=_vertical_choices,
|
| 4952 |
+
value=_vertical_choices[0],
|
| 4953 |
+
interactive=True,
|
| 4954 |
+
)
|
| 4955 |
+
function_dd = gr.Dropdown(
|
| 4956 |
+
label="Function",
|
| 4957 |
+
choices=list(FUNCTIONS.keys()),
|
| 4958 |
+
value=list(FUNCTIONS.keys())[0],
|
| 4959 |
+
interactive=True,
|
| 4960 |
+
)
|
| 4961 |
+
with gr.Row():
|
| 4962 |
+
url_input = gr.Textbox(
|
| 4963 |
+
label="Company URL",
|
| 4964 |
+
placeholder="e.g. Amazon.com",
|
| 4965 |
+
lines=1,
|
| 4966 |
+
scale=4,
|
| 4967 |
+
interactive=True,
|
| 4968 |
+
)
|
| 4969 |
+
use_url_cb = gr.Checkbox(
|
| 4970 |
+
label="Use URL",
|
| 4971 |
+
value=True,
|
| 4972 |
+
scale=1,
|
| 4973 |
+
interactive=True,
|
| 4974 |
+
)
|
| 4975 |
+
additional_info_input = gr.Textbox(
|
| 4976 |
+
label="Context",
|
| 4977 |
+
placeholder="Any extra context for the demo...",
|
| 4978 |
+
lines=2,
|
| 4979 |
+
interactive=True,
|
| 4980 |
+
)
|
| 4981 |
+
go_btn = gr.Button("→ GO", variant="primary")
|
| 4982 |
+
|
| 4983 |
+
with gr.Tab("Chat"):
|
| 4984 |
+
with gr.Row():
|
| 4985 |
+
msg = gr.Textbox(
|
| 4986 |
+
label="Your message",
|
| 4987 |
+
value="",
|
| 4988 |
+
placeholder="e.g. Amazon.com Retail Sales — or continue a conversation here",
|
| 4989 |
+
lines=1,
|
| 4990 |
+
max_lines=1,
|
| 4991 |
+
scale=5,
|
| 4992 |
+
show_label=False,
|
| 4993 |
+
interactive=True
|
| 4994 |
+
)
|
| 4995 |
+
send_btn = gr.Button("Send", variant="primary", scale=1)
|
| 4996 |
|
| 4997 |
# Right column - Status & Settings
|
| 4998 |
with gr.Column(scale=1):
|
|
|
|
| 5085 |
return html
|
| 5086 |
|
| 5087 |
progress_html = gr.HTML(get_progress_html('initialization'))
|
| 5088 |
+
|
| 5089 |
+
# Phase log stream — scrolling status updates from controller.phase_log
|
| 5090 |
+
phase_log_display = gr.Textbox(
|
| 5091 |
+
label="Pipeline Status",
|
| 5092 |
+
value="",
|
| 5093 |
+
lines=6,
|
| 5094 |
+
max_lines=6,
|
| 5095 |
+
interactive=False,
|
| 5096 |
+
placeholder="Pipeline status will appear here when the GO button is pressed...",
|
| 5097 |
+
elem_classes=["phase-log-stream"],
|
| 5098 |
+
)
|
| 5099 |
+
|
| 5100 |
+
# Timer polls controller.phase_log every 2 seconds (activated when GO is pressed)
|
| 5101 |
+
phase_log_timer = gr.Timer(value=2, active=False)
|
| 5102 |
+
|
| 5103 |
# Event handlers - each creates/uses session-specific controller
|
| 5104 |
def send_message(controller, message, history, stage, model, company, usecase, env_label=None, liveboard_name_ui=None, request: gr.Request = None):
|
| 5105 |
"""Handle sending a message - creates controller if needed"""
|
|
|
|
| 5119 |
# Always use the current UI value — takes priority over DB-loaded default
|
| 5120 |
if liveboard_name_ui is not None:
|
| 5121 |
controller.settings['liveboard_name'] = liveboard_name_ui
|
| 5122 |
+
hide_welcome = gr.update(visible=False)
|
| 5123 |
try:
|
| 5124 |
for result in controller.process_chat_message(
|
| 5125 |
message, history, stage, model, company, usecase
|
| 5126 |
):
|
| 5127 |
new_stage = result[1] if len(result) > 1 else stage
|
| 5128 |
progress = get_progress_html(new_stage)
|
| 5129 |
+
chatbot_update = gr.update(value=result[0], visible=True)
|
| 5130 |
+
yield (controller, chatbot_update) + result[1:] + (progress, hide_welcome)
|
| 5131 |
except Exception as e:
|
| 5132 |
err_tb = traceback.format_exc()
|
| 5133 |
print(f"[ERROR] send_message unhandled exception:\n{err_tb}")
|
|
|
|
| 5138 |
)
|
| 5139 |
history = history or []
|
| 5140 |
history.append((message, err_msg))
|
| 5141 |
+
yield (controller, gr.update(value=history, visible=True), stage, model, company, usecase, "", get_progress_html(stage), hide_welcome)
|
| 5142 |
+
|
| 5143 |
+
# Wire up send button and enter key
|
| 5144 |
+
_send_inputs = [chat_controller_state, msg, chatbot, current_stage, current_model, current_company, current_usecase, ts_env_dropdown, liveboard_name_input]
|
| 5145 |
+
_send_outputs = [chat_controller_state, chatbot, current_stage, current_model, current_company, current_usecase, msg, progress_html, welcome_md]
|
| 5146 |
|
| 5147 |
+
msg.submit(fn=send_message, inputs=_send_inputs, outputs=_send_outputs)
|
| 5148 |
+
send_btn.click(fn=send_message, inputs=_send_inputs, outputs=_send_outputs)
|
| 5149 |
+
|
| 5150 |
+
# App tab: update function dropdown and context field when vertical changes
|
| 5151 |
+
def update_function_on_vertical(vertical):
|
| 5152 |
+
"""Reset function dropdown when vertical changes; mark overrides.
|
| 5153 |
+
If Custom vertical: disable function dd, make context required."""
|
| 5154 |
+
if vertical == "Custom":
|
| 5155 |
+
return (
|
| 5156 |
+
gr.Dropdown(choices=list(FUNCTIONS.keys()), value=list(FUNCTIONS.keys())[0], interactive=False),
|
| 5157 |
+
gr.Textbox(label="Context *", placeholder="Describe your use case...", interactive=True),
|
| 5158 |
+
)
|
| 5159 |
+
choices = []
|
| 5160 |
+
for fname in FUNCTIONS.keys():
|
| 5161 |
+
if (vertical, fname) in MATRIX_OVERRIDES:
|
| 5162 |
+
choices.append(f"{fname} ✓")
|
| 5163 |
+
else:
|
| 5164 |
+
choices.append(fname)
|
| 5165 |
+
return (
|
| 5166 |
+
gr.Dropdown(choices=choices, value=choices[0], interactive=True),
|
| 5167 |
+
gr.Textbox(label="Context", placeholder="Any extra context for the demo...", interactive=True),
|
| 5168 |
+
)
|
| 5169 |
+
|
| 5170 |
+
vertical_dd.change(
|
| 5171 |
+
fn=update_function_on_vertical,
|
| 5172 |
+
inputs=[vertical_dd],
|
| 5173 |
+
outputs=[function_dd, additional_info_input]
|
| 5174 |
+
)
|
| 5175 |
+
|
| 5176 |
+
# Defined tab: GO button handler
|
| 5177 |
+
def defined_go(controller, vertical, function, url, use_url, additional_info,
|
| 5178 |
+
history, stage, model, company, usecase, env_label, lb_name,
|
| 5179 |
+
request: gr.Request = None):
|
| 5180 |
+
import traceback
|
| 5181 |
+
# Strip the "✓" suffix if present (added by update_function_on_vertical)
|
| 5182 |
+
function_clean = function.replace(" ✓", "").strip() if function else function
|
| 5183 |
username = getattr(request, 'username', None) if request else None
|
| 5184 |
if controller is None:
|
| 5185 |
controller = ChatDemoInterface(user_email=username)
|
| 5186 |
print(f"[SESSION] Created new ChatDemoInterface for {username or 'anonymous'}")
|
|
|
|
| 5187 |
if env_label:
|
| 5188 |
_url = get_ts_env_url(env_label)
|
| 5189 |
_key_value = get_ts_env_auth_key(env_label)
|
|
|
|
| 5191 |
controller.settings['thoughtspot_url'] = _url
|
| 5192 |
if _key_value:
|
| 5193 |
controller.settings['thoughtspot_trusted_auth_key'] = _key_value
|
| 5194 |
+
if lb_name is not None:
|
| 5195 |
+
controller.settings['liveboard_name'] = lb_name
|
|
|
|
| 5196 |
|
| 5197 |
+
# Derive company name from URL or vertical+function label
|
| 5198 |
+
if use_url and url.strip():
|
| 5199 |
+
raw_company = url.strip()
|
| 5200 |
+
else:
|
| 5201 |
+
raw_company = f"{vertical} Demo"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5202 |
|
| 5203 |
+
is_custom = (vertical == "Custom")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5204 |
|
| 5205 |
+
if is_custom:
|
| 5206 |
+
# Custom vertical: context IS the use case; function dropdown is ignored
|
| 5207 |
+
use_case_str = (additional_info or "").strip()
|
| 5208 |
+
if not use_case_str:
|
| 5209 |
+
history = history or []
|
| 5210 |
+
history.append(("GO", "❌ Please describe your use case in the Context field when using Custom vertical."))
|
| 5211 |
+
yield (controller, gr.update(value=history, visible=True), stage, model, company, usecase, get_progress_html(stage), hide_welcome)
|
| 5212 |
+
return
|
| 5213 |
+
controller.vertical = None
|
| 5214 |
+
controller.function = None
|
| 5215 |
+
controller.use_case_config = get_use_case_config("Generic", "Generic")
|
| 5216 |
+
controller.is_generic_use_case = True
|
| 5217 |
+
controller.generic_use_case_context = ""
|
| 5218 |
+
else:
|
| 5219 |
+
# Standard vertical: build use case from vertical + function
|
| 5220 |
+
use_case_str = f"{vertical} {function_clean}"
|
| 5221 |
+
controller.vertical = vertical
|
| 5222 |
+
controller.function = function_clean
|
| 5223 |
+
controller.use_case_config = get_use_case_config(
|
| 5224 |
+
vertical or "Generic", function_clean or "Generic"
|
| 5225 |
+
)
|
| 5226 |
+
is_known = (vertical and function_clean
|
| 5227 |
+
and not controller.use_case_config.get('is_generic'))
|
| 5228 |
+
controller.is_generic_use_case = not is_known
|
| 5229 |
+
controller.generic_use_case_context = additional_info.strip() if additional_info else ""
|
| 5230 |
|
| 5231 |
+
controller.pending_generic_company = raw_company
|
| 5232 |
+
use_case_display = controller.use_case_config.get('use_case_name', use_case_str)
|
| 5233 |
+
controller.pending_generic_use_case = use_case_display
|
| 5234 |
|
| 5235 |
+
# Clear phase log for a fresh run
|
| 5236 |
+
controller.phase_log = []
|
| 5237 |
+
|
| 5238 |
+
# Drive straight to awaiting_context → 'proceed' to skip confirmation dialog
|
| 5239 |
+
history = history or []
|
| 5240 |
+
hide_welcome = gr.update(visible=False)
|
| 5241 |
+
try:
|
| 5242 |
+
for result in controller.process_chat_message(
|
| 5243 |
+
"proceed", history, 'awaiting_context', model,
|
| 5244 |
+
raw_company, use_case_display
|
| 5245 |
+
):
|
| 5246 |
+
new_stage = result[1] if len(result) > 1 else stage
|
| 5247 |
+
progress = get_progress_html(new_stage)
|
| 5248 |
+
chatbot_update = gr.update(value=result[0], visible=True)
|
| 5249 |
+
yield (controller, chatbot_update) + result[1:5] + (progress, hide_welcome)
|
| 5250 |
+
except Exception as e:
|
| 5251 |
+
err_tb = traceback.format_exc()
|
| 5252 |
+
print(f"[ERROR] defined_go unhandled exception:\n{err_tb}")
|
| 5253 |
+
err_msg = (
|
| 5254 |
+
f"❌ **An unexpected error occurred**\n\n"
|
| 5255 |
+
f"`{type(e).__name__}: {e}`\n\n"
|
| 5256 |
+
f"The pipeline has been interrupted. You can try again or start a new session."
|
| 5257 |
+
)
|
| 5258 |
+
history.append((f"GO: {use_case_str}", err_msg))
|
| 5259 |
+
yield (controller, gr.update(value=history, visible=True), stage, model, company, usecase, get_progress_html(stage), hide_welcome)
|
| 5260 |
+
|
| 5261 |
+
_go_inputs = [
|
| 5262 |
+
chat_controller_state, vertical_dd, function_dd, url_input, use_url_cb,
|
| 5263 |
+
additional_info_input, chatbot, current_stage, current_model,
|
| 5264 |
+
current_company, current_usecase, ts_env_dropdown, liveboard_name_input
|
| 5265 |
+
]
|
| 5266 |
+
_go_outputs = [
|
| 5267 |
+
chat_controller_state, chatbot, current_stage, current_model,
|
| 5268 |
+
current_company, current_usecase, progress_html, welcome_md
|
| 5269 |
+
]
|
| 5270 |
+
go_btn.click(fn=defined_go, inputs=_go_inputs, outputs=_go_outputs)
|
| 5271 |
+
|
| 5272 |
+
# Phase log timer — poll controller.phase_log and render as newline-joined text
|
| 5273 |
+
def refresh_phase_log(controller):
|
| 5274 |
+
if controller is None:
|
| 5275 |
+
return gr.update()
|
| 5276 |
+
log = getattr(controller, 'phase_log', [])
|
| 5277 |
+
return "\n".join(log) if log else ""
|
| 5278 |
+
|
| 5279 |
+
phase_log_timer.tick(
|
| 5280 |
+
fn=refresh_phase_log,
|
| 5281 |
+
inputs=[chat_controller_state],
|
| 5282 |
+
outputs=[phase_log_display],
|
| 5283 |
+
)
|
| 5284 |
+
|
| 5285 |
+
# Activate phase log timer when GO or Send is pressed
|
| 5286 |
+
_activate_timer = dict(fn=lambda: gr.update(active=True), inputs=[], outputs=[phase_log_timer], queue=False)
|
| 5287 |
+
go_btn.click(**_activate_timer)
|
| 5288 |
+
send_btn.click(**_activate_timer)
|
| 5289 |
+
msg.submit(**_activate_timer)
|
| 5290 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5291 |
# Model dropdown change
|
| 5292 |
def update_model(new_model, controller, history):
|
| 5293 |
if controller is not None:
|
|
|
|
| 5337 |
'msg': msg,
|
| 5338 |
'model_dropdown': model_dropdown,
|
| 5339 |
'send_btn': send_btn,
|
|
|
|
| 5340 |
'ts_env_dropdown': ts_env_dropdown,
|
| 5341 |
'liveboard_name_input': liveboard_name_input,
|
| 5342 |
+
'progress_html': progress_html,
|
| 5343 |
+
'phase_log_display': phase_log_display,
|
| 5344 |
+
'phase_log_timer': phase_log_timer,
|
| 5345 |
}
|
| 5346 |
|
| 5347 |
|
| 5348 |
+
def create_matrix_tab(interface):
|
| 5349 |
+
"""Matrix editor tab — view and edit Vertical × Function cell configs."""
|
| 5350 |
+
|
| 5351 |
+
def _cell_data(vertical, function):
|
| 5352 |
+
"""Compute all display values for a given cell."""
|
| 5353 |
+
config = get_use_case_config(vertical, function)
|
| 5354 |
+
has_override = (vertical, function) in MATRIX_OVERRIDES
|
| 5355 |
+
|
| 5356 |
+
# Coverage line
|
| 5357 |
+
if has_override:
|
| 5358 |
+
coverage = "✅ **Override** — enriched persona, custom questions, tuned story controls"
|
| 5359 |
+
else:
|
| 5360 |
+
coverage = "🔵 **Base merge** — vertical + function defaults combined"
|
| 5361 |
+
|
| 5362 |
+
# Persona / problem (overrides only)
|
| 5363 |
+
persona = config.get('target_persona', '')
|
| 5364 |
+
problem = config.get('business_problem', '')
|
| 5365 |
+
persona_md = ""
|
| 5366 |
+
if persona:
|
| 5367 |
+
persona_md += f"**Target persona:** {persona} \n"
|
| 5368 |
+
if problem:
|
| 5369 |
+
persona_md += f"**Business problem:** {problem}"
|
| 5370 |
+
|
| 5371 |
+
# KPIs
|
| 5372 |
+
kpis = config.get('kpis', [])
|
| 5373 |
+
kpi_defs = config.get('kpi_definitions', {})
|
| 5374 |
+
lines = []
|
| 5375 |
+
for k in kpis:
|
| 5376 |
+
d = kpi_defs.get(k, '')
|
| 5377 |
+
lines.append(f"**{k}** — {d}" if d else f"**{k}**")
|
| 5378 |
+
kpi_md = " \n".join(lines) if lines else "_None defined_"
|
| 5379 |
+
|
| 5380 |
+
# Liveboard questions → dataframe rows
|
| 5381 |
+
questions = config.get('liveboard_questions', [])
|
| 5382 |
+
rows = []
|
| 5383 |
+
for q in questions:
|
| 5384 |
+
spotter = ", ".join(q.get('spotter_qs', []))
|
| 5385 |
+
rows.append([
|
| 5386 |
+
q.get('title', ''),
|
| 5387 |
+
q.get('viz_type', ''),
|
| 5388 |
+
q.get('required', False),
|
| 5389 |
+
q.get('viz_question', ''),
|
| 5390 |
+
q.get('insight', ''),
|
| 5391 |
+
spotter,
|
| 5392 |
+
])
|
| 5393 |
+
|
| 5394 |
+
# Story controls as formatted text
|
| 5395 |
+
sc = config.get('story_controls', {})
|
| 5396 |
+
sc_lines = []
|
| 5397 |
+
for k, v in sc.items():
|
| 5398 |
+
if isinstance(v, dict):
|
| 5399 |
+
sc_lines.append(f"**{k}:** {v}")
|
| 5400 |
+
elif isinstance(v, list):
|
| 5401 |
+
sc_lines.append(f"**{k}:** {', '.join(str(x) for x in v)}")
|
| 5402 |
+
elif isinstance(v, float):
|
| 5403 |
+
sc_lines.append(f"**{k}:** {v:.4g}")
|
| 5404 |
+
else:
|
| 5405 |
+
sc_lines.append(f"**{k}:** {v}")
|
| 5406 |
+
sc_md = " \n".join(sc_lines) if sc_lines else "_None_"
|
| 5407 |
+
|
| 5408 |
+
return coverage, persona_md, kpi_md, rows, sc_md
|
| 5409 |
+
|
| 5410 |
+
# --- Initial values ---
|
| 5411 |
+
init_v = list(VERTICALS.keys())[0]
|
| 5412 |
+
init_f = list(FUNCTIONS.keys())[0]
|
| 5413 |
+
init_cov, init_persona, init_kpi, init_rows, init_sc = _cell_data(init_v, init_f)
|
| 5414 |
+
|
| 5415 |
+
gr.Markdown("## 🧩 Matrix Editor")
|
| 5416 |
+
gr.Markdown(
|
| 5417 |
+
"The matrix defines what gets built for every Vertical × Function combination — "
|
| 5418 |
+
"KPIs, visualizations, story controls, and persona. \n"
|
| 5419 |
+
"Changes here are **in-session only** for now; persistence via Supabase is coming next."
|
| 5420 |
+
)
|
| 5421 |
+
|
| 5422 |
+
# --- Selectors ---
|
| 5423 |
+
with gr.Row():
|
| 5424 |
+
m_vertical = gr.Dropdown(
|
| 5425 |
+
label="Vertical",
|
| 5426 |
+
choices=list(VERTICALS.keys()),
|
| 5427 |
+
value=init_v,
|
| 5428 |
+
interactive=True,
|
| 5429 |
+
scale=1,
|
| 5430 |
+
)
|
| 5431 |
+
m_function = gr.Dropdown(
|
| 5432 |
+
label="Function",
|
| 5433 |
+
choices=list(FUNCTIONS.keys()),
|
| 5434 |
+
value=init_f,
|
| 5435 |
+
interactive=True,
|
| 5436 |
+
scale=1,
|
| 5437 |
+
)
|
| 5438 |
+
|
| 5439 |
+
coverage_md = gr.Markdown(value=init_cov)
|
| 5440 |
+
persona_md = gr.Markdown(value=init_persona)
|
| 5441 |
+
|
| 5442 |
+
gr.Markdown("---")
|
| 5443 |
+
|
| 5444 |
+
# --- KPIs ---
|
| 5445 |
+
with gr.Accordion("📊 KPIs", open=True):
|
| 5446 |
+
kpi_md = gr.Markdown(value=init_kpi)
|
| 5447 |
+
|
| 5448 |
+
# --- Liveboard Questions ---
|
| 5449 |
+
with gr.Accordion("📋 Liveboard Questions", open=True):
|
| 5450 |
+
gr.Markdown(
|
| 5451 |
+
"*Edit cells directly. Add rows with the + button. "
|
| 5452 |
+
"Changes affect this session until persistence is wired up.*"
|
| 5453 |
+
)
|
| 5454 |
+
questions_df = gr.Dataframe(
|
| 5455 |
+
value=init_rows,
|
| 5456 |
+
headers=["Title", "Viz Type", "Required", "Question", "Insight", "Spotter Questions"],
|
| 5457 |
+
datatype=["str", "str", "bool", "str", "str", "str"],
|
| 5458 |
+
interactive=True,
|
| 5459 |
+
row_count=(len(init_rows), "dynamic"),
|
| 5460 |
+
col_count=(6, "fixed"),
|
| 5461 |
+
wrap=True,
|
| 5462 |
+
)
|
| 5463 |
+
|
| 5464 |
+
# --- Story Controls ---
|
| 5465 |
+
with gr.Accordion("⚙️ Story Controls", open=False):
|
| 5466 |
+
sc_md = gr.Markdown(value=init_sc)
|
| 5467 |
+
|
| 5468 |
+
# --- Wire dropdowns ---
|
| 5469 |
+
def on_cell_change(vertical, function):
|
| 5470 |
+
cov, persona, kpi, rows, sc = _cell_data(vertical, function)
|
| 5471 |
+
return cov, persona, kpi, rows, sc
|
| 5472 |
+
|
| 5473 |
+
_outputs = [coverage_md, persona_md, kpi_md, questions_df, sc_md]
|
| 5474 |
+
m_vertical.change(fn=on_cell_change, inputs=[m_vertical, m_function], outputs=_outputs)
|
| 5475 |
+
m_function.change(fn=on_cell_change, inputs=[m_vertical, m_function], outputs=_outputs)
|
| 5476 |
+
|
| 5477 |
+
# Populate on tab load
|
| 5478 |
+
interface.load(
|
| 5479 |
+
fn=on_cell_change,
|
| 5480 |
+
inputs=[m_vertical, m_function],
|
| 5481 |
+
outputs=_outputs,
|
| 5482 |
+
)
|
| 5483 |
+
|
| 5484 |
+
return {'vertical': m_vertical, 'function': m_function, 'questions_df': questions_df}
|
| 5485 |
+
|
| 5486 |
+
|
| 5487 |
def create_settings_tab():
|
| 5488 |
"""Create the settings configuration tab - returns components for loading
|
| 5489 |
|
|
@@ -205,10 +205,16 @@ class KeyPairSnowflakeWriter:
|
|
| 205 |
sql = f"INSERT INTO {table_name} ({col_list}) VALUES ({placeholders})"
|
| 206 |
|
| 207 |
def convert_value(value, col_name):
|
|
|
|
| 208 |
if value is None:
|
| 209 |
return None
|
|
|
|
|
|
|
| 210 |
if isinstance(value, Decimal):
|
| 211 |
value = float(value)
|
|
|
|
|
|
|
|
|
|
| 212 |
if isinstance(value, datetime):
|
| 213 |
return value.strftime('%Y-%m-%d %H:%M:%S')
|
| 214 |
if isinstance(value, date):
|
|
@@ -265,7 +271,9 @@ class KeyPairSnowflakeWriter:
|
|
| 265 |
self.cursor.execute(sql, row)
|
| 266 |
total_inserted += 1
|
| 267 |
except Exception as row_error:
|
| 268 |
-
|
|
|
|
|
|
|
| 269 |
|
| 270 |
self.connection.commit()
|
| 271 |
print(f"Inserted {total_inserted} rows into {table_name}")
|
|
|
|
| 205 |
sql = f"INSERT INTO {table_name} ({col_list}) VALUES ({placeholders})"
|
| 206 |
|
| 207 |
def convert_value(value, col_name):
|
| 208 |
+
import math
|
| 209 |
if value is None:
|
| 210 |
return None
|
| 211 |
+
if isinstance(value, bool):
|
| 212 |
+
return int(value)
|
| 213 |
if isinstance(value, Decimal):
|
| 214 |
value = float(value)
|
| 215 |
+
# NaN and Inf are not valid SQL values — replace with NULL
|
| 216 |
+
if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
|
| 217 |
+
return None
|
| 218 |
if isinstance(value, datetime):
|
| 219 |
return value.strftime('%Y-%m-%d %H:%M:%S')
|
| 220 |
if isinstance(value, date):
|
|
|
|
| 271 |
self.cursor.execute(sql, row)
|
| 272 |
total_inserted += 1
|
| 273 |
except Exception as row_error:
|
| 274 |
+
col_val_pairs = list(zip(filtered_columns, row))
|
| 275 |
+
print(f" Failed row ({type(row_error).__name__}: {row_error})")
|
| 276 |
+
print(f" Values: {col_val_pairs[:6]}")
|
| 277 |
|
| 278 |
self.connection.commit()
|
| 279 |
print(f"Inserted {total_inserted} rows into {table_name}")
|
|
@@ -84,24 +84,18 @@ def _clean_viz_title(title: str) -> str:
|
|
| 84 |
return ' '.join(result)
|
| 85 |
|
| 86 |
|
| 87 |
-
def _get_direct_api_session():
|
| 88 |
"""
|
| 89 |
-
|
| 90 |
-
Uses trusted auth
|
|
|
|
| 91 |
"""
|
| 92 |
-
global _direct_api_token, _direct_api_session
|
| 93 |
-
|
| 94 |
-
if _direct_api_session is not None:
|
| 95 |
-
return _direct_api_session
|
| 96 |
-
|
| 97 |
base_url = get_admin_setting('THOUGHTSPOT_URL').rstrip('/')
|
| 98 |
-
|
| 99 |
-
secret_key = get_admin_setting('THOUGHTSPOT_TRUSTED_AUTH_KEY')
|
| 100 |
-
|
| 101 |
if not all([base_url, username, secret_key]):
|
| 102 |
-
print(f"⚠️ Direct API: Missing
|
| 103 |
-
return None
|
| 104 |
-
|
| 105 |
# Get auth token
|
| 106 |
auth_url = f"{base_url}/api/rest/2.0/auth/token/full"
|
| 107 |
resp = requests.post(auth_url, json={
|
|
@@ -109,42 +103,42 @@ def _get_direct_api_session():
|
|
| 109 |
"secret_key": secret_key,
|
| 110 |
"validity_time_in_sec": 3600
|
| 111 |
})
|
| 112 |
-
|
| 113 |
if resp.status_code != 200:
|
| 114 |
print(f"⚠️ Direct API auth failed: {resp.status_code}")
|
| 115 |
-
return None
|
| 116 |
-
|
| 117 |
token = resp.json().get('token')
|
| 118 |
if not token:
|
| 119 |
print(f"⚠️ Direct API: No token in response")
|
| 120 |
-
return None
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
_direct_api_session.headers['Accept'] = 'application/json'
|
| 127 |
-
|
| 128 |
-
print(f"✅ Direct API: Authenticated to {base_url}")
|
| 129 |
-
return _direct_api_session
|
| 130 |
|
|
|
|
|
|
|
| 131 |
|
| 132 |
-
|
|
|
|
| 133 |
"""
|
| 134 |
Get an answer from ThoughtSpot using direct API call instead of MCP.
|
| 135 |
-
|
| 136 |
This bypasses MCP's agent.thoughtspot.app proxy which may have issues
|
| 137 |
with certain clusters (e.g., Spotter Agent v3 clusters).
|
| 138 |
-
|
| 139 |
Args:
|
| 140 |
question: Natural language question
|
| 141 |
model_id: ThoughtSpot model GUID
|
| 142 |
-
|
|
|
|
| 143 |
Returns:
|
| 144 |
dict with answer data including session_identifier, tokens, question, etc.
|
| 145 |
Returns None on failure.
|
| 146 |
"""
|
| 147 |
-
session = _get_direct_api_session()
|
| 148 |
if not session:
|
| 149 |
return None
|
| 150 |
|
|
@@ -3024,20 +3018,20 @@ def create_liveboard_from_model_mcp(
|
|
| 3024 |
print(f"[MCP] MCP modules imported successfully (streamable HTTP transport)")
|
| 3025 |
|
| 3026 |
# Get trusted auth token for bearer auth
|
| 3027 |
-
print(f"[MCP] Getting trusted auth token...")
|
| 3028 |
-
|
| 3029 |
-
if not
|
| 3030 |
return {
|
| 3031 |
'success': False,
|
| 3032 |
'error': 'Failed to get trusted auth token. Check THOUGHTSPOT_TRUSTED_AUTH_KEY in .env'
|
| 3033 |
}
|
| 3034 |
-
|
| 3035 |
# Build bearer auth header: {token}@{host}
|
| 3036 |
ts_url = get_admin_setting('THOUGHTSPOT_URL').rstrip('/')
|
| 3037 |
ts_host = ts_url.replace('https://', '').replace('http://', '')
|
| 3038 |
mcp_endpoint = "https://agent.thoughtspot.app/bearer/mcp"
|
| 3039 |
headers = {
|
| 3040 |
-
"Authorization": f"Bearer {
|
| 3041 |
}
|
| 3042 |
print(f"[MCP] Connecting to {mcp_endpoint} (bearer auth, host={ts_host})")
|
| 3043 |
|
|
@@ -3140,7 +3134,7 @@ def create_liveboard_from_model_mcp(
|
|
| 3140 |
|
| 3141 |
if use_direct_api:
|
| 3142 |
# Use direct ThoughtSpot API (bypasses MCP proxy issues)
|
| 3143 |
-
answer_data = _get_answer_direct(question_text, model_id)
|
| 3144 |
if answer_data:
|
| 3145 |
# Clean up the viz title
|
| 3146 |
if 'question' in answer_data:
|
|
|
|
| 84 |
return ' '.join(result)
|
| 85 |
|
| 86 |
|
| 87 |
+
def _get_direct_api_session(username: str, secret_key: str):
|
| 88 |
"""
|
| 89 |
+
Create an authenticated session for direct ThoughtSpot API calls.
|
| 90 |
+
Uses trusted auth — authenticates as the given username.
|
| 91 |
+
Returns (session, token) tuple, or (None, None) on failure.
|
| 92 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
base_url = get_admin_setting('THOUGHTSPOT_URL').rstrip('/')
|
| 94 |
+
|
|
|
|
|
|
|
| 95 |
if not all([base_url, username, secret_key]):
|
| 96 |
+
print(f"⚠️ Direct API: Missing settings (URL={bool(base_url)}, USER={bool(username)}, KEY={bool(secret_key)})")
|
| 97 |
+
return None, None
|
| 98 |
+
|
| 99 |
# Get auth token
|
| 100 |
auth_url = f"{base_url}/api/rest/2.0/auth/token/full"
|
| 101 |
resp = requests.post(auth_url, json={
|
|
|
|
| 103 |
"secret_key": secret_key,
|
| 104 |
"validity_time_in_sec": 3600
|
| 105 |
})
|
| 106 |
+
|
| 107 |
if resp.status_code != 200:
|
| 108 |
print(f"⚠️ Direct API auth failed: {resp.status_code}")
|
| 109 |
+
return None, None
|
| 110 |
+
|
| 111 |
token = resp.json().get('token')
|
| 112 |
if not token:
|
| 113 |
print(f"⚠️ Direct API: No token in response")
|
| 114 |
+
return None, None
|
| 115 |
+
|
| 116 |
+
session = requests.Session()
|
| 117 |
+
session.headers['Authorization'] = f'Bearer {token}'
|
| 118 |
+
session.headers['Content-Type'] = 'application/json'
|
| 119 |
+
session.headers['Accept'] = 'application/json'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
+
print(f"✅ Direct API: Authenticated as {username} to {base_url}")
|
| 122 |
+
return session, token
|
| 123 |
|
| 124 |
+
|
| 125 |
+
def _get_answer_direct(question: str, model_id: str, username: str, secret_key: str) -> dict:
|
| 126 |
"""
|
| 127 |
Get an answer from ThoughtSpot using direct API call instead of MCP.
|
| 128 |
+
|
| 129 |
This bypasses MCP's agent.thoughtspot.app proxy which may have issues
|
| 130 |
with certain clusters (e.g., Spotter Agent v3 clusters).
|
| 131 |
+
|
| 132 |
Args:
|
| 133 |
question: Natural language question
|
| 134 |
model_id: ThoughtSpot model GUID
|
| 135 |
+
username: ThoughtSpot username to authenticate as
|
| 136 |
+
|
| 137 |
Returns:
|
| 138 |
dict with answer data including session_identifier, tokens, question, etc.
|
| 139 |
Returns None on failure.
|
| 140 |
"""
|
| 141 |
+
session, _ = _get_direct_api_session(username, secret_key)
|
| 142 |
if not session:
|
| 143 |
return None
|
| 144 |
|
|
|
|
| 3018 |
print(f"[MCP] MCP modules imported successfully (streamable HTTP transport)")
|
| 3019 |
|
| 3020 |
# Get trusted auth token for bearer auth
|
| 3021 |
+
print(f"[MCP] Getting trusted auth token for {ts_client.username}...")
|
| 3022 |
+
_, mcp_token = _get_direct_api_session(ts_client.username, ts_client.secret_key)
|
| 3023 |
+
if not mcp_token:
|
| 3024 |
return {
|
| 3025 |
'success': False,
|
| 3026 |
'error': 'Failed to get trusted auth token. Check THOUGHTSPOT_TRUSTED_AUTH_KEY in .env'
|
| 3027 |
}
|
| 3028 |
+
|
| 3029 |
# Build bearer auth header: {token}@{host}
|
| 3030 |
ts_url = get_admin_setting('THOUGHTSPOT_URL').rstrip('/')
|
| 3031 |
ts_host = ts_url.replace('https://', '').replace('http://', '')
|
| 3032 |
mcp_endpoint = "https://agent.thoughtspot.app/bearer/mcp"
|
| 3033 |
headers = {
|
| 3034 |
+
"Authorization": f"Bearer {mcp_token}@{ts_host}"
|
| 3035 |
}
|
| 3036 |
print(f"[MCP] Connecting to {mcp_endpoint} (bearer auth, host={ts_host})")
|
| 3037 |
|
|
|
|
| 3134 |
|
| 3135 |
if use_direct_api:
|
| 3136 |
# Use direct ThoughtSpot API (bypasses MCP proxy issues)
|
| 3137 |
+
answer_data = _get_answer_direct(question_text, model_id, ts_client.username, ts_client.secret_key)
|
| 3138 |
if answer_data:
|
| 3139 |
# Clean up the viz title
|
| 3140 |
if 'question' in answer_data:
|
|
@@ -1,8 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
Model Semantic Layer Updater
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import json
|
|
@@ -18,349 +21,306 @@ from llm_client_factory import create_openai_client
|
|
| 18 |
|
| 19 |
|
| 20 |
class ModelSemanticUpdater:
|
| 21 |
-
"""
|
| 22 |
|
| 23 |
def __init__(self, ts_client, llm_model: str = DEFAULT_LLM_MODEL):
|
| 24 |
-
"""
|
| 25 |
-
Initialize ModelSemanticUpdater
|
| 26 |
-
|
| 27 |
-
Args:
|
| 28 |
-
ts_client: ThoughtSpotDeployer instance (already authenticated)
|
| 29 |
-
"""
|
| 30 |
self.ts_client = ts_client
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
)
|
| 36 |
self.openai_client = create_openai_client()
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
"""
|
| 45 |
try:
|
| 46 |
response = self.ts_client.session.post(
|
| 47 |
-
f"{self.ts_client.base_url}/api/rest/2.0/metadata/
|
| 48 |
-
headers=self.ts_client.headers,
|
| 49 |
json={
|
| 50 |
-
"metadata": [{"
|
| 51 |
-
"
|
|
|
|
| 52 |
}
|
| 53 |
)
|
| 54 |
-
|
| 55 |
if response.status_code == 200:
|
| 56 |
result = response.json()
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
for item in result:
|
| 62 |
-
# Extract from metadata_header if present
|
| 63 |
-
header = item.get('metadata_header', {})
|
| 64 |
-
models.append({
|
| 65 |
-
'id': item.get('metadata_id') or header.get('id'),
|
| 66 |
-
'name': item.get('metadata_name') or header.get('name'),
|
| 67 |
-
'description': header.get('description', ''),
|
| 68 |
-
'author': header.get('authorName', ''),
|
| 69 |
-
'created': header.get('created'),
|
| 70 |
-
'modified': header.get('modified')
|
| 71 |
-
})
|
| 72 |
-
else:
|
| 73 |
-
# Fallback for dict response
|
| 74 |
-
headers = result.get('headers', [])
|
| 75 |
-
for header in headers:
|
| 76 |
-
models.append({
|
| 77 |
-
'id': header.get('id'),
|
| 78 |
-
'name': header.get('name'),
|
| 79 |
-
'description': header.get('description', ''),
|
| 80 |
-
'author': header.get('author', {}).get('name', ''),
|
| 81 |
-
'created': header.get('created'),
|
| 82 |
-
'modified': header.get('modified')
|
| 83 |
-
})
|
| 84 |
-
|
| 85 |
-
return models
|
| 86 |
else:
|
| 87 |
-
print(f"Error
|
| 88 |
-
return []
|
| 89 |
except Exception as e:
|
| 90 |
-
print(f"Exception
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def get_model_tml(self, model_id: str) -> Optional[Dict]:
|
| 94 |
-
"""
|
| 95 |
-
Export existing model TML
|
| 96 |
-
|
| 97 |
-
Args:
|
| 98 |
-
model_id: GUID of the model to export
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
"""
|
| 103 |
try:
|
| 104 |
response = self.ts_client.session.post(
|
| 105 |
-
f"{self.ts_client.base_url}/api/rest/2.0/metadata/tml/
|
| 106 |
-
headers=self.ts_client.headers,
|
| 107 |
json={
|
| 108 |
-
"
|
| 109 |
-
"
|
| 110 |
-
"
|
| 111 |
}
|
| 112 |
)
|
| 113 |
-
|
| 114 |
if response.status_code == 200:
|
| 115 |
result = response.json()
|
|
|
|
| 116 |
if result.get('object') and len(result['object']) > 0:
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
return yaml.safe_load(edoc)
|
| 120 |
-
return None
|
| 121 |
else:
|
| 122 |
-
|
| 123 |
-
return None
|
| 124 |
except Exception as e:
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
def generate_column_semantics(
|
| 129 |
self,
|
| 130 |
company_research: str,
|
| 131 |
model_tml: Dict,
|
| 132 |
-
use_case: str = ""
|
|
|
|
| 133 |
) -> Dict[str, Dict]:
|
| 134 |
"""
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
Returns:
|
| 144 |
-
Dictionary mapping column names to semantic metadata:
|
| 145 |
-
{
|
| 146 |
-
"column_name": {
|
| 147 |
-
"description": "Business-friendly description",
|
| 148 |
-
"synonyms": ["synonym1", "synonym2", ...]
|
| 149 |
-
}
|
| 150 |
}
|
|
|
|
| 151 |
"""
|
| 152 |
-
# Extract column information from model TML
|
| 153 |
columns = model_tml.get('model', {}).get('columns', [])
|
| 154 |
-
|
| 155 |
column_info = []
|
| 156 |
for col in columns:
|
| 157 |
column_info.append({
|
| 158 |
'name': col.get('name'),
|
| 159 |
-
'column_id': col.get('column_id', ''),
|
| 160 |
'type': col.get('properties', {}).get('column_type', 'UNKNOWN'),
|
| 161 |
-
'current_description': col.get('
|
| 162 |
})
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
if
|
| 167 |
-
company_research = company_research[:max_research_length] + "..."
|
| 168 |
|
| 169 |
-
|
|
|
|
| 170 |
|
| 171 |
-
|
|
|
|
| 172 |
|
| 173 |
-
|
| 174 |
-
{company_research}
|
| 175 |
-
{use_case_context}
|
| 176 |
-
Data Model Columns:
|
| 177 |
{json.dumps(column_info, indent=2)}
|
| 178 |
|
| 179 |
-
For
|
| 180 |
-
1. **description**: A clear, business-friendly description (1-2 sentences) that helps users understand what the column represents and how to use it. Consider the company context and use case.
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
-
|
| 186 |
-
-
|
|
|
|
| 187 |
|
| 188 |
Guidelines:
|
| 189 |
-
- Make descriptions actionable
|
| 190 |
-
-
|
| 191 |
-
- For
|
| 192 |
-
-
|
| 193 |
-
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
Return a JSON object with this structure:
|
| 197 |
{{
|
| 198 |
"column_name": {{
|
| 199 |
-
"description": "
|
| 200 |
-
"synonyms": ["
|
| 201 |
-
|
| 202 |
-
|
| 203 |
}}"""
|
| 204 |
|
| 205 |
try:
|
| 206 |
-
token_kwargs = build_openai_chat_token_kwargs(self.llm_model,
|
| 207 |
response = self.openai_client.chat.completions.create(
|
| 208 |
model=self.llm_model,
|
| 209 |
messages=[{"role": "user", "content": prompt}],
|
| 210 |
response_format={"type": "json_object"},
|
| 211 |
-
temperature=0.3,
|
| 212 |
-
**token_kwargs
|
| 213 |
)
|
| 214 |
-
|
| 215 |
-
result = json.loads(response.choices[0].message.content)
|
| 216 |
-
return result
|
| 217 |
except Exception as e:
|
| 218 |
print(f"Error generating column semantics: {e}")
|
| 219 |
return {}
|
| 220 |
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
-
Returns
|
| 230 |
-
Updated model TML as YAML string
|
| 231 |
"""
|
| 232 |
-
|
| 233 |
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
col_name = column.get('name')
|
| 236 |
-
if col_name in
|
| 237 |
-
|
|
|
|
| 238 |
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
column['properties']['description'] = semantic_data['description']
|
| 242 |
|
| 243 |
-
|
| 244 |
-
if 'synonyms' in semantic_data and semantic_data['synonyms']:
|
| 245 |
-
column['properties']['synonyms'] = semantic_data['synonyms']
|
| 246 |
|
| 247 |
-
|
|
|
|
|
|
|
| 248 |
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
|
| 253 |
-
|
| 254 |
-
model_tml_yaml: Updated model TML as YAML string
|
| 255 |
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
- model_id: GUID of updated model
|
| 260 |
-
- error: Error message if failed
|
| 261 |
-
"""
|
| 262 |
-
try:
|
| 263 |
-
response = self.ts_client.session.post(
|
| 264 |
-
f"{self.ts_client.base_url}/api/rest/2.0/metadata/tml/import",
|
| 265 |
-
headers=self.ts_client.headers,
|
| 266 |
-
json={
|
| 267 |
-
"metadata_tmls": [model_tml_yaml],
|
| 268 |
-
"import_policy": "PARTIAL",
|
| 269 |
-
"force_create": False # Update existing, don't create new
|
| 270 |
-
}
|
| 271 |
-
)
|
| 272 |
-
|
| 273 |
-
if response.status_code == 200:
|
| 274 |
-
result = response.json()
|
| 275 |
-
|
| 276 |
-
# Extract model ID from response
|
| 277 |
-
model_id = None
|
| 278 |
-
if result.get('object') and len(result['object']) > 0:
|
| 279 |
-
model_id = result['object'][0].get('response', {}).get('header', {}).get('id')
|
| 280 |
-
|
| 281 |
-
return {
|
| 282 |
-
'success': True,
|
| 283 |
-
'model_id': model_id,
|
| 284 |
-
'response': result
|
| 285 |
-
}
|
| 286 |
-
else:
|
| 287 |
-
return {
|
| 288 |
-
'success': False,
|
| 289 |
-
'error': f"API returned status {response.status_code}: {response.text}"
|
| 290 |
-
}
|
| 291 |
-
except Exception as e:
|
| 292 |
-
return {
|
| 293 |
-
'success': False,
|
| 294 |
-
'error': str(e)
|
| 295 |
-
}
|
| 296 |
|
| 297 |
-
def
|
| 298 |
self,
|
| 299 |
model_id: str,
|
| 300 |
company_research: str,
|
| 301 |
-
use_case: str = ""
|
|
|
|
|
|
|
| 302 |
) -> Dict:
|
| 303 |
"""
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
model_id: GUID of model to update
|
| 308 |
-
company_research: Company research text
|
| 309 |
-
use_case: Optional use case context
|
| 310 |
-
|
| 311 |
-
Returns:
|
| 312 |
-
Dictionary with result:
|
| 313 |
-
- success: Boolean
|
| 314 |
-
- model_id: GUID of updated model
|
| 315 |
-
- columns_updated: Number of columns updated
|
| 316 |
-
- error: Error message if failed
|
| 317 |
"""
|
| 318 |
-
# Export current model TML
|
| 319 |
model_tml = self.get_model_tml(model_id)
|
| 320 |
if not model_tml:
|
| 321 |
-
return {
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
if not semantics:
|
| 329 |
-
return {
|
| 330 |
-
'success': False,
|
| 331 |
-
'error': 'Could not generate semantic metadata'
|
| 332 |
-
}
|
| 333 |
-
|
| 334 |
-
# Update TML with semantics
|
| 335 |
-
updated_tml_yaml = self.update_model_tml_semantics(model_tml, semantics)
|
| 336 |
|
| 337 |
-
#
|
| 338 |
-
|
| 339 |
|
|
|
|
|
|
|
| 340 |
if result['success']:
|
| 341 |
result['columns_updated'] = len(semantics)
|
| 342 |
-
|
| 343 |
return result
|
| 344 |
|
| 345 |
|
| 346 |
-
|
|
|
|
| 347 |
ts_client,
|
| 348 |
model_id: str,
|
| 349 |
company_research: str,
|
| 350 |
use_case: str = "",
|
|
|
|
|
|
|
| 351 |
llm_model: str = DEFAULT_LLM_MODEL,
|
| 352 |
) -> Dict:
|
| 353 |
-
"""
|
| 354 |
-
Convenience function to update model semantic layer
|
| 355 |
-
|
| 356 |
-
Args:
|
| 357 |
-
ts_client: Authenticated ThoughtSpotDeployer instance
|
| 358 |
-
model_id: GUID of model to update
|
| 359 |
-
company_research: Company research text
|
| 360 |
-
use_case: Optional use case context
|
| 361 |
-
|
| 362 |
-
Returns:
|
| 363 |
-
Dictionary with update result
|
| 364 |
-
"""
|
| 365 |
updater = ModelSemanticUpdater(ts_client, llm_model=llm_model)
|
| 366 |
-
return updater.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
Model Semantic Layer Updater
|
| 3 |
|
| 4 |
+
Enriches ThoughtSpot model TML with:
|
| 5 |
+
- A model-level description (business context for the whole dataset)
|
| 6 |
+
- Per-column descriptions (human-readable)
|
| 7 |
+
- Per-column synonyms (search terms business users would type)
|
| 8 |
+
- Per-column AI context (≤400 char coaching hints for Spotter)
|
| 9 |
"""
|
| 10 |
|
| 11 |
import json
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class ModelSemanticUpdater:
|
| 24 |
+
"""Enrich a ThoughtSpot model with descriptions, synonyms, and AI context."""
|
| 25 |
|
| 26 |
def __init__(self, ts_client, llm_model: str = DEFAULT_LLM_MODEL):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
self.ts_client = ts_client
|
| 28 |
+
# Fall back to default if a non-OpenAI model is passed — this module uses
|
| 29 |
+
# json_object response_format which requires OpenAI.
|
| 30 |
+
resolved = resolve_model_name(llm_model)
|
| 31 |
+
self.llm_model = resolved if is_openai_model_name(resolved) else resolve_model_name(DEFAULT_LLM_MODEL)
|
|
|
|
| 32 |
self.openai_client = create_openai_client()
|
| 33 |
|
| 34 |
+
# ------------------------------------------------------------------
|
| 35 |
+
# TML export / import helpers
|
| 36 |
+
# ------------------------------------------------------------------
|
| 37 |
|
| 38 |
+
def get_model_tml(self, model_id: str) -> Optional[Dict]:
|
| 39 |
+
"""Export existing model TML from ThoughtSpot, return parsed dict."""
|
|
|
|
| 40 |
try:
|
| 41 |
response = self.ts_client.session.post(
|
| 42 |
+
f"{self.ts_client.base_url}/api/rest/2.0/metadata/tml/export",
|
|
|
|
| 43 |
json={
|
| 44 |
+
"metadata": [{"identifier": model_id}],
|
| 45 |
+
"export_associated": False,
|
| 46 |
+
"export_fqn": True,
|
| 47 |
}
|
| 48 |
)
|
|
|
|
| 49 |
if response.status_code == 200:
|
| 50 |
result = response.json()
|
| 51 |
+
if result.get('object') and len(result['object']) > 0:
|
| 52 |
+
edoc = result['object'][0].get('edoc')
|
| 53 |
+
if edoc:
|
| 54 |
+
return yaml.safe_load(edoc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
else:
|
| 56 |
+
print(f"Error exporting model TML: {response.status_code} - {response.text}")
|
|
|
|
| 57 |
except Exception as e:
|
| 58 |
+
print(f"Exception exporting model TML: {e}")
|
| 59 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
+
def deploy_updated_model(self, model_tml_yaml: str) -> Dict:
|
| 62 |
+
"""Re-import updated model TML (update in place, not create new)."""
|
|
|
|
| 63 |
try:
|
| 64 |
response = self.ts_client.session.post(
|
| 65 |
+
f"{self.ts_client.base_url}/api/rest/2.0/metadata/tml/import",
|
|
|
|
| 66 |
json={
|
| 67 |
+
"metadata_tmls": [model_tml_yaml],
|
| 68 |
+
"import_policy": "PARTIAL",
|
| 69 |
+
"force_create": False,
|
| 70 |
}
|
| 71 |
)
|
|
|
|
| 72 |
if response.status_code == 200:
|
| 73 |
result = response.json()
|
| 74 |
+
model_id = None
|
| 75 |
if result.get('object') and len(result['object']) > 0:
|
| 76 |
+
model_id = result['object'][0].get('response', {}).get('header', {}).get('id')
|
| 77 |
+
return {'success': True, 'model_id': model_id, 'response': result}
|
|
|
|
|
|
|
| 78 |
else:
|
| 79 |
+
return {'success': False, 'error': f"HTTP {response.status_code}: {response.text}"}
|
|
|
|
| 80 |
except Exception as e:
|
| 81 |
+
return {'success': False, 'error': str(e)}
|
| 82 |
+
|
| 83 |
+
# ------------------------------------------------------------------
|
| 84 |
+
# LLM generation
|
| 85 |
+
# ------------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
def generate_model_description(
|
| 88 |
+
self,
|
| 89 |
+
company_research: str,
|
| 90 |
+
use_case: str,
|
| 91 |
+
company_name: str,
|
| 92 |
+
model_name: str,
|
| 93 |
+
) -> str:
|
| 94 |
+
"""
|
| 95 |
+
Generate a 2-3 sentence model-level description for ThoughtSpot.
|
| 96 |
+
|
| 97 |
+
Returns plain text (not JSON).
|
| 98 |
+
"""
|
| 99 |
+
research_snippet = company_research[:2000] if len(company_research) > 2000 else company_research
|
| 100 |
+
|
| 101 |
+
prompt = f"""Write a 2-3 sentence business description for a ThoughtSpot analytics model.
|
| 102 |
+
|
| 103 |
+
Company: {company_name}
|
| 104 |
+
Use Case: {use_case}
|
| 105 |
+
Model Name: {model_name}
|
| 106 |
+
|
| 107 |
+
Company/Industry Context:
|
| 108 |
+
{research_snippet}
|
| 109 |
+
|
| 110 |
+
Requirements:
|
| 111 |
+
- 2-3 sentences, plain text
|
| 112 |
+
- Business-facing, no technical jargon
|
| 113 |
+
- State what data the model covers and what business questions it answers
|
| 114 |
+
- Mention the company name and use case
|
| 115 |
+
- Do NOT use markdown formatting
|
| 116 |
+
|
| 117 |
+
Example style:
|
| 118 |
+
"Analyze Target's retail sales performance across stores, product categories, and regions. \
|
| 119 |
+
Track key metrics including revenue, units sold, and basket size to identify trends and \
|
| 120 |
+
opportunities. Designed for sales and merchandising teams to monitor performance and drive decisions."
|
| 121 |
+
|
| 122 |
+
Write only the description, nothing else."""
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
token_kwargs = build_openai_chat_token_kwargs(self.llm_model, 300)
|
| 126 |
+
response = self.openai_client.chat.completions.create(
|
| 127 |
+
model=self.llm_model,
|
| 128 |
+
messages=[{"role": "user", "content": prompt}],
|
| 129 |
+
temperature=0.4,
|
| 130 |
+
**token_kwargs,
|
| 131 |
+
)
|
| 132 |
+
return response.choices[0].message.content.strip()
|
| 133 |
+
except Exception as e:
|
| 134 |
+
print(f"Error generating model description: {e}")
|
| 135 |
+
return f"{company_name} {use_case} analytics model."
|
| 136 |
|
| 137 |
def generate_column_semantics(
|
| 138 |
self,
|
| 139 |
company_research: str,
|
| 140 |
model_tml: Dict,
|
| 141 |
+
use_case: str = "",
|
| 142 |
+
company_name: str = "",
|
| 143 |
) -> Dict[str, Dict]:
|
| 144 |
"""
|
| 145 |
+
Generate per-column semantic metadata using the LLM.
|
| 146 |
+
|
| 147 |
+
Returns a dict keyed by column name:
|
| 148 |
+
{
|
| 149 |
+
"column_name": {
|
| 150 |
+
"description": "Human-readable 1-2 sentence description",
|
| 151 |
+
"synonyms": ["term1", "term2", ...],
|
| 152 |
+
"ai_context": "Spotter coaching hint ≤400 chars"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
}
|
| 154 |
+
}
|
| 155 |
"""
|
|
|
|
| 156 |
columns = model_tml.get('model', {}).get('columns', [])
|
|
|
|
| 157 |
column_info = []
|
| 158 |
for col in columns:
|
| 159 |
column_info.append({
|
| 160 |
'name': col.get('name'),
|
|
|
|
| 161 |
'type': col.get('properties', {}).get('column_type', 'UNKNOWN'),
|
| 162 |
+
'current_description': col.get('description', ''),
|
| 163 |
})
|
| 164 |
|
| 165 |
+
research_snippet = company_research[:3000] if len(company_research) > 3000 else company_research
|
| 166 |
+
use_case_line = f"\nUse case: {use_case}" if use_case else ""
|
| 167 |
+
company_line = f"\nCompany: {company_name}" if company_name else ""
|
|
|
|
| 168 |
|
| 169 |
+
prompt = f"""You are a data analyst enriching a ThoughtSpot analytics model with semantic metadata.
|
| 170 |
+
{company_line}{use_case_line}
|
| 171 |
|
| 172 |
+
Company/Industry Context:
|
| 173 |
+
{research_snippet}
|
| 174 |
|
| 175 |
+
Columns in this model:
|
|
|
|
|
|
|
|
|
|
| 176 |
{json.dumps(column_info, indent=2)}
|
| 177 |
|
| 178 |
+
For EVERY column listed, generate three fields:
|
|
|
|
| 179 |
|
| 180 |
+
1. description (1-2 sentences, business-friendly, helps users understand the column)
|
| 181 |
+
2. synonyms (3-5 alternative terms a business user would type to find this data)
|
| 182 |
+
3. ai_context (a Spotter coaching hint, ≤350 characters, written as clear instructions to an AI)
|
| 183 |
+
- Tell Spotter when/how to use the column, default aggregation, or key business rules
|
| 184 |
+
- Examples: "Use this for revenue analysis. Default to SUM. Combine with Date for trends."
|
| 185 |
+
- "Filter dimension for product category. Never aggregate. Use for grouping and comparison."
|
| 186 |
|
| 187 |
Guidelines:
|
| 188 |
+
- Make descriptions actionable, no technical jargon
|
| 189 |
+
- For MEASURE columns: mention aggregation (sum, avg, count)
|
| 190 |
+
- For ATTRIBUTE columns: mention how it's used for grouping/filtering
|
| 191 |
+
- Synonyms should be realistic search terms
|
| 192 |
+
- ai_context must be ≤350 characters — keep it concise and direct
|
| 193 |
+
|
| 194 |
+
Return a JSON object keyed by the exact column name:
|
|
|
|
| 195 |
{{
|
| 196 |
"column_name": {{
|
| 197 |
+
"description": "...",
|
| 198 |
+
"synonyms": ["term1", "term2", "term3"],
|
| 199 |
+
"ai_context": "..."
|
| 200 |
+
}}
|
| 201 |
}}"""
|
| 202 |
|
| 203 |
try:
|
| 204 |
+
token_kwargs = build_openai_chat_token_kwargs(self.llm_model, 4000)
|
| 205 |
response = self.openai_client.chat.completions.create(
|
| 206 |
model=self.llm_model,
|
| 207 |
messages=[{"role": "user", "content": prompt}],
|
| 208 |
response_format={"type": "json_object"},
|
| 209 |
+
temperature=0.3,
|
| 210 |
+
**token_kwargs,
|
| 211 |
)
|
| 212 |
+
return json.loads(response.choices[0].message.content)
|
|
|
|
|
|
|
| 213 |
except Exception as e:
|
| 214 |
print(f"Error generating column semantics: {e}")
|
| 215 |
return {}
|
| 216 |
|
| 217 |
+
# ------------------------------------------------------------------
|
| 218 |
+
# TML mutation
|
| 219 |
+
# ------------------------------------------------------------------
|
| 220 |
|
| 221 |
+
def apply_to_model_tml(
|
| 222 |
+
self,
|
| 223 |
+
model_tml: Dict,
|
| 224 |
+
column_semantics: Dict[str, Dict],
|
| 225 |
+
model_description: str = None,
|
| 226 |
+
) -> str:
|
| 227 |
+
"""
|
| 228 |
+
Apply model description + column semantics to the model TML dict.
|
| 229 |
|
| 230 |
+
Returns updated TML as a YAML string.
|
|
|
|
| 231 |
"""
|
| 232 |
+
model_section = model_tml.setdefault('model', {})
|
| 233 |
|
| 234 |
+
# Model-level description
|
| 235 |
+
if model_description:
|
| 236 |
+
model_section['description'] = model_description
|
| 237 |
+
|
| 238 |
+
# Column-level enrichment
|
| 239 |
+
for column in model_section.get('columns', []):
|
| 240 |
col_name = column.get('name')
|
| 241 |
+
if col_name not in column_semantics:
|
| 242 |
+
continue
|
| 243 |
+
data = column_semantics[col_name]
|
| 244 |
|
| 245 |
+
if data.get('description'):
|
| 246 |
+
column['description'] = data['description']
|
|
|
|
| 247 |
|
| 248 |
+
props = column.setdefault('properties', {})
|
|
|
|
|
|
|
| 249 |
|
| 250 |
+
if data.get('synonyms'):
|
| 251 |
+
props['synonyms'] = data['synonyms']
|
| 252 |
+
props['synonym_type'] = 'USER_DEFINED'
|
| 253 |
|
| 254 |
+
if data.get('ai_context'):
|
| 255 |
+
# Enforce 400-char limit
|
| 256 |
+
column['ai_context'] = data['ai_context'][:400]
|
| 257 |
|
| 258 |
+
return yaml.dump(model_tml, allow_unicode=True, sort_keys=False)
|
|
|
|
| 259 |
|
| 260 |
+
# ------------------------------------------------------------------
|
| 261 |
+
# High-level entry point
|
| 262 |
+
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
|
| 264 |
+
def enrich_model(
|
| 265 |
self,
|
| 266 |
model_id: str,
|
| 267 |
company_research: str,
|
| 268 |
+
use_case: str = "",
|
| 269 |
+
company_name: str = "",
|
| 270 |
+
model_name: str = "",
|
| 271 |
) -> Dict:
|
| 272 |
"""
|
| 273 |
+
Full workflow: export model TML → generate semantics → reimport.
|
| 274 |
+
|
| 275 |
+
Returns dict with success, columns_updated, error.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
"""
|
|
|
|
| 277 |
model_tml = self.get_model_tml(model_id)
|
| 278 |
if not model_tml:
|
| 279 |
+
return {'success': False, 'error': 'Could not export model TML'}
|
| 280 |
+
|
| 281 |
+
# Generate model description
|
| 282 |
+
model_description = self.generate_model_description(
|
| 283 |
+
company_research, use_case, company_name,
|
| 284 |
+
model_name or model_tml.get('model', {}).get('name', ''),
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
# Generate column semantics
|
| 288 |
+
semantics = self.generate_column_semantics(
|
| 289 |
+
company_research, model_tml, use_case, company_name
|
| 290 |
+
)
|
| 291 |
if not semantics:
|
| 292 |
+
return {'success': False, 'error': 'Could not generate column semantics'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
|
| 294 |
+
# Apply to TML
|
| 295 |
+
updated_yaml = self.apply_to_model_tml(model_tml, semantics, model_description)
|
| 296 |
|
| 297 |
+
# Deploy
|
| 298 |
+
result = self.deploy_updated_model(updated_yaml)
|
| 299 |
if result['success']:
|
| 300 |
result['columns_updated'] = len(semantics)
|
| 301 |
+
result['model_description'] = model_description
|
| 302 |
return result
|
| 303 |
|
| 304 |
|
| 305 |
+
# Convenience function for direct use
|
| 306 |
+
def enrich_model_semantics(
|
| 307 |
ts_client,
|
| 308 |
model_id: str,
|
| 309 |
company_research: str,
|
| 310 |
use_case: str = "",
|
| 311 |
+
company_name: str = "",
|
| 312 |
+
model_name: str = "",
|
| 313 |
llm_model: str = DEFAULT_LLM_MODEL,
|
| 314 |
) -> Dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
updater = ModelSemanticUpdater(ts_client, llm_model=llm_model)
|
| 316 |
+
return updater.enrich_model(
|
| 317 |
+
model_id=model_id,
|
| 318 |
+
company_research=company_research,
|
| 319 |
+
use_case=use_case,
|
| 320 |
+
company_name=company_name,
|
| 321 |
+
model_name=model_name,
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# Backwards-compatibility alias used by demo_prep.py
|
| 326 |
+
update_model_semantic_layer = enrich_model_semantics
|
|
@@ -556,6 +556,32 @@ Create a bullet outline demo script with:
|
|
| 556 |
- Spotter questions to ask live
|
| 557 |
- Closing value proposition""",
|
| 558 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
"spotter_viz_story": """You are creating a Spotter Viz story for ThoughtSpot's AI-powered liveboard builder.
|
| 560 |
|
| 561 |
Spotter Viz is an AI agent in ThoughtSpot that creates, structures, and styles Liveboards through natural language prompts. Users type conversational requests and the agent builds the dashboard step by step, allowing iterative refinement.
|
|
|
|
| 556 |
- Spotter questions to ask live
|
| 557 |
- Closing value proposition""",
|
| 558 |
|
| 559 |
+
"spotter_viz_story_matrix": """You are creating a ThoughtSpot-recommended Spotter Viz story.
|
| 560 |
+
|
| 561 |
+
Spotter Viz is an AI agent in ThoughtSpot that builds Liveboards through natural language prompts.
|
| 562 |
+
|
| 563 |
+
You have been given the ThoughtSpot-recommended KPIs, visualizations, and liveboard questions for this exact vertical and function. Your job is to turn those into a natural, conversational demo script — a sequence of prompts a user would type into Spotter Viz to build the liveboard step by step.
|
| 564 |
+
|
| 565 |
+
{context}
|
| 566 |
+
|
| 567 |
+
---
|
| 568 |
+
|
| 569 |
+
Write the story as a numbered sequence of prompts. Every KPI and visualization listed above must appear as a step. Format each step as:
|
| 570 |
+
|
| 571 |
+
### Step N: [Brief label]
|
| 572 |
+
> "[The exact prompt to type into Spotter Viz]"
|
| 573 |
+
|
| 574 |
+
**What to look for:** [1 sentence on what the viz reveals — use the insight from the matrix if provided]
|
| 575 |
+
|
| 576 |
+
Rules:
|
| 577 |
+
- Step 1: set context — company name, data source, overall goal
|
| 578 |
+
- KPI steps: include time dimension and granularity (e.g. "revenue for the last 12 months as a monthly KPI sparkline")
|
| 579 |
+
- Chart steps: keep the metric name and dimension from the matrix (e.g. "revenue by region as a bar chart")
|
| 580 |
+
- Add 1-2 narrative sentences between steps to tell a story — what are we looking for, what does it mean?
|
| 581 |
+
- Final step: the "aha moment" — name the key insight or outlier from the story controls
|
| 582 |
+
- Use the company name and real metric names from the matrix above
|
| 583 |
+
- Do not invent KPIs or visualizations that are not in the matrix""",
|
| 584 |
+
|
| 585 |
"spotter_viz_story": """You are creating a Spotter Viz story for ThoughtSpot's AI-powered liveboard builder.
|
| 586 |
|
| 587 |
Spotter Viz is an AI agent in ThoughtSpot that creates, structures, and styles Liveboards through natural language prompts. Users type conversational requests and the agent builds the dashboard step by step, allowing iterative refinement.
|
|
@@ -110,8 +110,24 @@ This sprint covers hardening, settings, and new capabilities before that happens
|
|
| 110 |
- [ ] **Existing Model Selection + Self-Join Skip** — may be done; needs confirmation test + verify self-join skip is working correctly
|
| 111 |
- [ ] **Universal Context Prompt** — double-test this feature end-to-end
|
| 112 |
- [ ] **Chat Adjustment Using Outlier System** — never got to this
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
- [ ] **Interface Mode Refactor** (`DemoWorkflowEngine` shared class concept)
|
| 114 |
-
- [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
- [ ] **Tag Assignment to Models** — returns 404 (works for tables, not models); needs investigation
|
| 116 |
- [ ] **Spotter Viz Story Verification** — run end-to-end and verify story generation + blank viz (ASP, Total Sales Weekly) and brand colors rendering
|
| 117 |
- [ ] **Fix Research Cache Not Loading** — relative path issue; fix was ready, needs test
|
|
@@ -157,6 +173,50 @@ This sprint covers hardening, settings, and new capabilities before that happens
|
|
| 157 |
|
| 158 |
---
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
## Phase 3 (Future)
|
| 161 |
|
| 162 |
- [ ] **OAuth/SSO Login** — swap Gradio auth for proper OAuth flow
|
|
@@ -175,6 +235,38 @@ This sprint covers hardening, settings, and new capabilities before that happens
|
|
| 175 |
|
| 176 |
## Done
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
### Session: March 27, 2026 — Concurrency Fix + Logging Fix
|
| 179 |
|
| 180 |
- [x] **Concurrent session UI interference fixed** ✅ — `chat_interface.py`
|
|
|
|
| 110 |
- [ ] **Existing Model Selection + Self-Join Skip** — may be done; needs confirmation test + verify self-join skip is working correctly
|
| 111 |
- [ ] **Universal Context Prompt** — double-test this feature end-to-end
|
| 112 |
- [ ] **Chat Adjustment Using Outlier System** — never got to this
|
| 113 |
+
- [x] **Matrix Editor Tab** ✅ — 🧩 Matrix tab added; first draft view+edit
|
| 114 |
+
- Vertical + Function dropdowns; coverage badge (Override / Base merge)
|
| 115 |
+
- Target persona + business problem shown for overrides
|
| 116 |
+
- KPIs accordion with definitions
|
| 117 |
+
- Liveboard Questions editable dataframe (add/delete rows)
|
| 118 |
+
- Story Controls accordion (read-only for now)
|
| 119 |
+
- Persistence via Supabase deferred to next sprint
|
| 120 |
+
- Reference doc: `dev_notes/matrix_reference.md`
|
| 121 |
+
- [x] **Remember Me on Login** ✅ — injected into Gradio venv `index.html` template
|
| 122 |
+
- `div.form` (not `<form>`) found via polling; no shadow DOM
|
| 123 |
+
- Saves username to localStorage on login; pre-fills on return visit
|
| 124 |
- [ ] **Interface Mode Refactor** (`DemoWorkflowEngine` shared class concept)
|
| 125 |
+
- [x] **Wizard Tab UI** ✅ — Defined/Custom sub-tabs in Chat; Defined tab first; GO button wires into pipeline
|
| 126 |
+
- Vertical dropdown → Function dropdown (stacked, marks overrides with ✓)
|
| 127 |
+
- Company URL + "Use URL" checkbox + Additional context + → GO
|
| 128 |
+
- Custom tab = original chat flow unchanged
|
| 129 |
+
- Welcome message redesigned: title + How to start + collapsible example table
|
| 130 |
+
- Chatbot box removed (transparent) to reduce visual clutter
|
| 131 |
- [ ] **Tag Assignment to Models** — returns 404 (works for tables, not models); needs investigation
|
| 132 |
- [ ] **Spotter Viz Story Verification** — run end-to-end and verify story generation + blank viz (ASP, Total Sales Weekly) and brand colors rendering
|
| 133 |
- [ ] **Fix Research Cache Not Loading** — relative path issue; fix was ready, needs test
|
|
|
|
| 173 |
|
| 174 |
---
|
| 175 |
|
| 176 |
+
## Ideas to Think About (Not Implementing Yet)
|
| 177 |
+
|
| 178 |
+
### Spotter Viz Story — Matrix-Grounded Single Story
|
| 179 |
+
Current state: `_generate_spotter_viz_story()` generates two sections (persona-driven from matrix + AI-generated LLM story) combined into one Markdown block.
|
| 180 |
+
|
| 181 |
+
**Proposed approach:**
|
| 182 |
+
- Single cohesive story (no split tabs) — the matrix IS the foundation, AI adds glue
|
| 183 |
+
- Take `liveboard_questions` from the config → generate high-level NL prompts (not granular)
|
| 184 |
+
- e.g. `"Show revenue for the last 3 months as a KPI"` / `"Revenue by region as a bar chart"`
|
| 185 |
+
- AI's role: light narrative that connects the dots, names the story, adds 1-2 sentences per step
|
| 186 |
+
- Keep it simple — the goal is a ready-to-use demo script, not documentation
|
| 187 |
+
- Both sections currently generated sequentially after ThoughtSpot deploy; could stay that way
|
| 188 |
+
|
| 189 |
+
### Async Pipeline — Parallel ThoughtSpot + Data Population
|
| 190 |
+
Current pipeline is fully sequential: Research → DDL → Create Tables → Populate → TS Model → Liveboard
|
| 191 |
+
|
| 192 |
+
**Dependency analysis:**
|
| 193 |
+
```
|
| 194 |
+
Research ──► DDL ──► Create Tables ─┬──► Populate Data (LegitData) ──┐
|
| 195 |
+
│ ├──► MCP Liveboard
|
| 196 |
+
└──► TS Model + Connections ────────┘
|
| 197 |
+
(semantic layer, column naming,
|
| 198 |
+
sharing, tagging, Sage index)
|
| 199 |
+
```
|
| 200 |
+
- TS model creation needs **schema** (tables exist) but NOT data rows
|
| 201 |
+
- Data population needs **tables** but not the TS model
|
| 202 |
+
- MCP liveboard (Spotter Viz) needs **both** — reads actual data via the model
|
| 203 |
+
- Everything from "Create Tables" onward can be parallelized except the final liveboard step
|
| 204 |
+
|
| 205 |
+
**What runs in parallel after tables are created:**
|
| 206 |
+
- Thread A: LegitData population
|
| 207 |
+
- Thread B: TS Model creation → column naming → semantic update → sharing → tagging → Sage indexing
|
| 208 |
+
|
| 209 |
+
**Estimated savings:** Model creation + semantic update is ~30-60s; LegitData is ~60-120s.
|
| 210 |
+
Running in parallel saves roughly the model-creation time off the total wall clock.
|
| 211 |
+
|
| 212 |
+
**Risks/considerations:**
|
| 213 |
+
- Gradio streaming yields from a generator — parallel work needs to run in threads and feed a shared queue that the generator drains
|
| 214 |
+
- Error handling: if one branch fails, need to cancel/report both and decide whether to proceed to liveboard
|
| 215 |
+
- Progress reporting needs to interleave updates from both branches
|
| 216 |
+
- Could implement with `concurrent.futures.ThreadPoolExecutor` + a `queue.Queue` for progress messages
|
| 217 |
+
|
| 218 |
+
---
|
| 219 |
+
|
| 220 |
## Phase 3 (Future)
|
| 221 |
|
| 222 |
- [ ] **OAuth/SSO Login** — swap Gradio auth for proper OAuth flow
|
|
|
|
| 235 |
|
| 236 |
## Done
|
| 237 |
|
| 238 |
+
### Session: March 27, 2026 — ts_user Fix + Semantics Pipeline + UX Fixes
|
| 239 |
+
|
| 240 |
+
- [x] **ts_user fix** ✅ — ThoughtSpot objects now created under logged-in user, not admin
|
| 241 |
+
- All 3 `ts_user` locations in `chat_interface.py` → `self._get_effective_user_email()`
|
| 242 |
+
- `ThoughtSpotDeployer` raises `ValueError` if username or secret_key not passed
|
| 243 |
+
- `_get_direct_api_session` / `_get_answer_direct` in `liveboard_creator.py`: accept `secret_key` param, no global singleton cache
|
| 244 |
+
- [x] **THOUGHTSPOT_ADMIN_USER removed** ✅ — from all non-test Python files
|
| 245 |
+
- `supabase_client.py`: removed from `ADMIN_SETTINGS_KEYS` and `inject_admin_settings_to_env`
|
| 246 |
+
- `chat_interface.py`: removed startup check + admin hidden field
|
| 247 |
+
- [x] **THOUGHTSPOT_TRUSTED_AUTH_KEY removed from Supabase settings** ✅
|
| 248 |
+
- All reads changed to `self.settings.get('thoughtspot_trusted_auth_key')` — set per-env via dropdown
|
| 249 |
+
- Removed `admin_ts_auth_key` hidden textbox from Settings UI
|
| 250 |
+
- [x] **Model semantic enrichment wired into pipeline** ✅ — `thoughtspot_deployer.py` `deploy_all`
|
| 251 |
+
- Model description, per-column description + synonyms + ai_context generated via LLM in one call
|
| 252 |
+
- Applied to model TML and reimported in same Spotter-enable cycle
|
| 253 |
+
- `model_semantic_updater.py` rewritten: `generate_model_description`, `generate_column_semantics`, `apply_to_model_tml`, `enrich_model` + backwards-compat alias `update_model_semantic_layer`
|
| 254 |
+
- [x] **Welcome page examples updated** ✅ — 5 hardcoded examples (Retail/Banking/Software/Manufacturing verticals)
|
| 255 |
+
- Persona column removed; examples guaranteed to work
|
| 256 |
+
- [x] **Boolean type mismatch fixed** ✅ — `legitdata_bridge.py` `convert_value()`
|
| 257 |
+
- `isinstance(value, bool): return int(value)` added before int check (bool is subclass of int)
|
| 258 |
+
- Fixes SUPPLY_CHAIN_EVENTS population failure (`NUMBER(38,0)` vs `BOOLEAN`)
|
| 259 |
+
- [x] **Company parsing fix** ✅ — "Caterpillar.com Manufacturing Supply Chain" now parses in one shot
|
| 260 |
+
- Added space-only separator pattern `r'[a-zA-Z0-9-]+\.[a-zA-Z]{2,}\s+(.+)'` to `extract_use_case_from_message`
|
| 261 |
+
- Company state preserved when only company detected (not overwritten with old value)
|
| 262 |
+
- [x] **Manufacturing in use case list** ✅ — removed `[:3]` slice so all 4 verticals show
|
| 263 |
+
- [x] **Progress bar stage fix** ✅ — auto-run now shows correct stage at each step
|
| 264 |
+
- `current_stage = 'research'` at start of research loop (was `'deploy'`)
|
| 265 |
+
- `current_stage = 'create_ddl'` after research, before DDL creation
|
| 266 |
+
- `current_stage = 'deploy'` after DDL, before Snowflake deployment
|
| 267 |
+
- [x] **Remove default company from input bar** ✅ — input now starts blank
|
| 268 |
+
- All `"Amazon.com"` fallbacks replaced with `""` in `load_session_state_on_startup`, `get_session_defaults`, and `default_settings`
|
| 269 |
+
|
| 270 |
### Session: March 27, 2026 — Concurrency Fix + Logging Fix
|
| 271 |
|
| 272 |
- [x] **Concurrent session UI interference fixed** ✅ — `chat_interface.py`
|
|
@@ -530,8 +530,6 @@ ADMIN_USER_KEY = "__admin__"
|
|
| 530 |
ADMIN_SETTINGS_KEYS = {
|
| 531 |
# ThoughtSpot
|
| 532 |
"THOUGHTSPOT_URL": "ThoughtSpot Instance URL",
|
| 533 |
-
"THOUGHTSPOT_TRUSTED_AUTH_KEY": "ThoughtSpot Trusted Auth Key",
|
| 534 |
-
"THOUGHTSPOT_ADMIN_USER": "ThoughtSpot Admin Username",
|
| 535 |
# LLM API Keys
|
| 536 |
"OPENAI_API_KEY": "OpenAI API Key",
|
| 537 |
"GOOGLE_API_KEY": "Google API Key",
|
|
@@ -642,8 +640,6 @@ def inject_admin_settings_to_env() -> bool:
|
|
| 642 |
|
| 643 |
Called once after login, before any pipeline runs.
|
| 644 |
|
| 645 |
-
Also maps THOUGHTSPOT_ADMIN_USER -> THOUGHTSPOT_USERNAME for backwards compatibility.
|
| 646 |
-
|
| 647 |
Returns:
|
| 648 |
True if settings were loaded and injected successfully.
|
| 649 |
"""
|
|
@@ -662,11 +658,6 @@ def inject_admin_settings_to_env() -> bool:
|
|
| 662 |
os.environ[key] = str(value)
|
| 663 |
injected += 1
|
| 664 |
|
| 665 |
-
# Map THOUGHTSPOT_ADMIN_USER -> THOUGHTSPOT_USERNAME for backwards compat
|
| 666 |
-
admin_user = settings.get("THOUGHTSPOT_ADMIN_USER", "")
|
| 667 |
-
if admin_user:
|
| 668 |
-
os.environ["THOUGHTSPOT_USERNAME"] = admin_user
|
| 669 |
-
|
| 670 |
print(
|
| 671 |
f"✅ Injected {injected} admin settings from Supabase into environment "
|
| 672 |
"(LLM keys remain environment-only)."
|
|
|
|
| 530 |
ADMIN_SETTINGS_KEYS = {
|
| 531 |
# ThoughtSpot
|
| 532 |
"THOUGHTSPOT_URL": "ThoughtSpot Instance URL",
|
|
|
|
|
|
|
| 533 |
# LLM API Keys
|
| 534 |
"OPENAI_API_KEY": "OpenAI API Key",
|
| 535 |
"GOOGLE_API_KEY": "Google API Key",
|
|
|
|
| 640 |
|
| 641 |
Called once after login, before any pipeline runs.
|
| 642 |
|
|
|
|
|
|
|
| 643 |
Returns:
|
| 644 |
True if settings were loaded and injected successfully.
|
| 645 |
"""
|
|
|
|
| 658 |
os.environ[key] = str(value)
|
| 659 |
injected += 1
|
| 660 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
print(
|
| 662 |
f"✅ Injected {injected} admin settings from Supabase into environment "
|
| 663 |
"(LLM keys remain environment-only)."
|
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pytest configuration and shared fixtures for DemoPrep E2E tests.
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import pytest
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
from playwright.sync_api import Page, BrowserContext, Browser, expect
|
| 8 |
+
|
| 9 |
+
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
| 10 |
+
|
| 11 |
+
BASE_URL = "https://thoughtspot-dp-demoprep.hf.space"
|
| 12 |
+
TEST_USER = os.getenv("TEST_USER")
|
| 13 |
+
TEST_PASSWORD = os.getenv("TEST_PASSWORD")
|
| 14 |
+
|
| 15 |
+
if not TEST_USER or not TEST_PASSWORD:
|
| 16 |
+
raise EnvironmentError("TEST_USER and TEST_PASSWORD must be set in .env")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _do_login(page: Page) -> None:
|
| 20 |
+
"""Perform the login flow on a page."""
|
| 21 |
+
page.goto(BASE_URL, timeout=90000)
|
| 22 |
+
page.wait_for_selector('input[placeholder="Type here..."]', timeout=90000)
|
| 23 |
+
page.fill('input[type=text]', TEST_USER)
|
| 24 |
+
page.fill('input[type=password]', TEST_PASSWORD)
|
| 25 |
+
page.click('button:has-text("Login")')
|
| 26 |
+
page.wait_for_selector('.gradio-container', timeout=90000)
|
| 27 |
+
page.wait_for_timeout(3000) # allow Gradio JS to settle
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def login(page: Page) -> None:
|
| 31 |
+
"""Log in to the app with test credentials (for tests that need a fresh login)."""
|
| 32 |
+
_do_login(page)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@pytest.fixture(scope="session")
|
| 36 |
+
def auth_context(browser: Browser, tmp_path_factory):
|
| 37 |
+
"""
|
| 38 |
+
Session-scoped authenticated browser context.
|
| 39 |
+
|
| 40 |
+
Logs in once, saves storage state, and yields a persistent context.
|
| 41 |
+
All logged_in_page fixtures share this context, avoiding repeated expensive
|
| 42 |
+
logins and connection setup against HF Spaces.
|
| 43 |
+
"""
|
| 44 |
+
state_file = str(tmp_path_factory.mktemp("auth") / "state.json")
|
| 45 |
+
|
| 46 |
+
# Log in on a temp page to capture auth state
|
| 47 |
+
setup_ctx = browser.new_context(viewport={"width": 1280, "height": 900})
|
| 48 |
+
setup_page = setup_ctx.new_page()
|
| 49 |
+
_do_login(setup_page)
|
| 50 |
+
setup_ctx.storage_state(path=state_file)
|
| 51 |
+
setup_page.close()
|
| 52 |
+
setup_ctx.close()
|
| 53 |
+
|
| 54 |
+
# Create the long-lived context with saved auth state
|
| 55 |
+
ctx = browser.new_context(
|
| 56 |
+
storage_state=state_file,
|
| 57 |
+
viewport={"width": 1280, "height": 900},
|
| 58 |
+
)
|
| 59 |
+
yield ctx
|
| 60 |
+
ctx.close()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@pytest.fixture()
|
| 64 |
+
def logged_in_page(auth_context: BrowserContext):
|
| 65 |
+
"""
|
| 66 |
+
Function-scoped: opens a fresh page in the shared authenticated context.
|
| 67 |
+
Each test gets an isolated page (own URL, own Gradio session) without
|
| 68 |
+
the overhead of a full login or new browser context.
|
| 69 |
+
"""
|
| 70 |
+
page = auth_context.new_page()
|
| 71 |
+
for attempt in range(2):
|
| 72 |
+
try:
|
| 73 |
+
page.goto(BASE_URL, timeout=90000)
|
| 74 |
+
# Wait for the Gradio app to fully initialize — specifically the
|
| 75 |
+
# chat input and tab buttons, which appear after full JS execution.
|
| 76 |
+
page.wait_for_selector('button[role=tab]', timeout=90000)
|
| 77 |
+
page.wait_for_selector('input[placeholder*="Type your message"]', timeout=90000)
|
| 78 |
+
page.wait_for_timeout(1000)
|
| 79 |
+
break
|
| 80 |
+
except Exception:
|
| 81 |
+
if attempt == 1:
|
| 82 |
+
raise
|
| 83 |
+
page.wait_for_timeout(5000)
|
| 84 |
+
yield page
|
| 85 |
+
page.close()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def get_chat_input(page: Page):
|
| 89 |
+
"""Return the main chat message input field."""
|
| 90 |
+
return page.locator('input[placeholder*="Type your message"]')
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# Alias for backwards compatibility
|
| 94 |
+
def get_chat_textarea(page: Page):
|
| 95 |
+
return get_chat_input(page)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def wait_for_response(page: Page, timeout_ms: int = 90000) -> None:
|
| 99 |
+
"""Wait until the assistant has finished responding."""
|
| 100 |
+
send_btn = page.locator('button:has-text("Send")')
|
| 101 |
+
expect(send_btn).to_be_enabled(timeout=timeout_ms)
|
| 102 |
+
page.wait_for_timeout(500)
|
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chat interaction tests — verify the chat responds correctly to user input.
|
| 3 |
+
|
| 4 |
+
Note: These tests send real messages to the app and wait for LLM responses.
|
| 5 |
+
They are intentionally scoped to short/fast interactions. Pipeline tests
|
| 6 |
+
(research → DDL → deploy) are in e2e_pipeline.py.
|
| 7 |
+
|
| 8 |
+
Run with: pytest tests/e2e_chat.py -v
|
| 9 |
+
"""
|
| 10 |
+
import pytest
|
| 11 |
+
from playwright.sync_api import Page, expect
|
| 12 |
+
from tests.conftest import logged_in_page, get_chat_textarea, wait_for_response # noqa: F401
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def send_message(page: Page, text: str):
|
| 16 |
+
"""Type and send a chat message."""
|
| 17 |
+
chat_input = get_chat_textarea(page)
|
| 18 |
+
chat_input.click()
|
| 19 |
+
chat_input.fill(text)
|
| 20 |
+
page.click('button:has-text("Send")')
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class TestChatBasics:
|
| 24 |
+
def test_help_command_responds(self, logged_in_page: Page):
|
| 25 |
+
"""Sending 'help' returns a response from the assistant."""
|
| 26 |
+
send_message(logged_in_page, "help")
|
| 27 |
+
wait_for_response(logged_in_page, timeout_ms=30000)
|
| 28 |
+
body_text = logged_in_page.inner_text('body')
|
| 29 |
+
# Should contain some meaningful help content
|
| 30 |
+
assert any(word in body_text.lower() for word in ["use case", "company", "demo", "help"])
|
| 31 |
+
|
| 32 |
+
def test_message_clears_after_send(self, logged_in_page: Page):
|
| 33 |
+
"""Chat input is cleared after sending a message."""
|
| 34 |
+
chat_input = get_chat_textarea(logged_in_page)
|
| 35 |
+
chat_input.click()
|
| 36 |
+
chat_input.fill("hello")
|
| 37 |
+
logged_in_page.click('button:has-text("Send")')
|
| 38 |
+
# Input should be empty immediately after send
|
| 39 |
+
expect(chat_input).to_have_value("")
|
| 40 |
+
|
| 41 |
+
def test_invalid_input_gets_response(self, logged_in_page: Page):
|
| 42 |
+
"""Nonsense input still gets a graceful response."""
|
| 43 |
+
send_message(logged_in_page, "asdfghjkl zxcvbnm")
|
| 44 |
+
wait_for_response(logged_in_page, timeout_ms=30000)
|
| 45 |
+
body_text = logged_in_page.inner_text('body')
|
| 46 |
+
# Should have more content than just the welcome message
|
| 47 |
+
assert "asdfghjkl" in body_text or len(body_text) > 500
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class TestQuickActions:
|
| 51 |
+
def test_start_research_button_sends_message(self, logged_in_page: Page):
|
| 52 |
+
"""Clicking Start Research triggers a response."""
|
| 53 |
+
logged_in_page.click('button:has-text("🔍 Start Research")')
|
| 54 |
+
wait_for_response(logged_in_page, timeout_ms=30000)
|
| 55 |
+
body_text = logged_in_page.inner_text('body')
|
| 56 |
+
# Should prompt for company info or explain research
|
| 57 |
+
assert len(body_text) > 200
|
| 58 |
+
|
| 59 |
+
def test_configure_button_responds(self, logged_in_page: Page):
|
| 60 |
+
"""Configure button triggers a response."""
|
| 61 |
+
logged_in_page.click('button:has-text("⚙️ Configure")')
|
| 62 |
+
wait_for_response(logged_in_page, timeout_ms=30000)
|
| 63 |
+
body_text = logged_in_page.inner_text('body')
|
| 64 |
+
assert len(body_text) > 200
|
| 65 |
+
|
| 66 |
+
def test_help_button_responds(self, logged_in_page: Page):
|
| 67 |
+
"""Help button triggers a response."""
|
| 68 |
+
logged_in_page.click('button:has-text("💡 Help")')
|
| 69 |
+
wait_for_response(logged_in_page, timeout_ms=30000)
|
| 70 |
+
body_text = logged_in_page.inner_text('body')
|
| 71 |
+
assert len(body_text) > 200
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class TestTabNavigation:
|
| 75 |
+
def test_can_switch_to_settings_tab(self, logged_in_page: Page):
|
| 76 |
+
"""Settings tab is clickable and shows settings content."""
|
| 77 |
+
logged_in_page.click('button[role=tab]:has-text("⚙️ Settings")')
|
| 78 |
+
logged_in_page.wait_for_timeout(1000)
|
| 79 |
+
body_text = logged_in_page.inner_text('body')
|
| 80 |
+
assert any(word in body_text.lower() for word in ["model", "settings", "liveboard", "save"])
|
| 81 |
+
|
| 82 |
+
def test_can_switch_back_to_chat(self, logged_in_page: Page):
|
| 83 |
+
"""Can navigate away from and back to the Chat tab."""
|
| 84 |
+
logged_in_page.click('button[role=tab]:has-text("⚙️ Settings")')
|
| 85 |
+
logged_in_page.wait_for_timeout(500)
|
| 86 |
+
logged_in_page.click('button[role=tab]:has-text("💬 Chat")')
|
| 87 |
+
logged_in_page.wait_for_timeout(500)
|
| 88 |
+
expect(logged_in_page.locator('button:has-text("Send")')).to_be_visible()
|
| 89 |
+
|
| 90 |
+
def test_live_progress_tab_loads(self, logged_in_page: Page):
|
| 91 |
+
"""Live Progress tab is accessible and renders."""
|
| 92 |
+
logged_in_page.click('button[role=tab]:has-text("Live Progress")')
|
| 93 |
+
logged_in_page.wait_for_timeout(1000)
|
| 94 |
+
# Tab should now be selected
|
| 95 |
+
tab = logged_in_page.locator('button[role=tab]:has-text("Live Progress")')
|
| 96 |
+
expect(tab).to_have_attribute('aria-selected', 'true')
|
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Settings tab tests — verify settings load, display, and save correctly.
|
| 3 |
+
|
| 4 |
+
Run with: pytest tests/e2e_settings.py -v
|
| 5 |
+
"""
|
| 6 |
+
import pytest
|
| 7 |
+
from playwright.sync_api import Page, expect
|
| 8 |
+
from tests.conftest import logged_in_page # noqa: F401
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def go_to_settings(page: Page):
|
| 12 |
+
"""Navigate to the Settings tab."""
|
| 13 |
+
page.click('button[role=tab]:has-text("⚙️ Settings")')
|
| 14 |
+
page.wait_for_timeout(1500)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class TestSettingsTab:
|
| 18 |
+
def test_settings_tab_accessible(self, logged_in_page: Page):
|
| 19 |
+
"""Settings tab loads without error."""
|
| 20 |
+
go_to_settings(logged_in_page)
|
| 21 |
+
body_text = logged_in_page.inner_text('body')
|
| 22 |
+
assert any(word in body_text.lower() for word in ["settings", "model", "save"])
|
| 23 |
+
|
| 24 |
+
def test_save_settings_button_present(self, logged_in_page: Page):
|
| 25 |
+
"""Save Settings button is visible in the Settings tab."""
|
| 26 |
+
go_to_settings(logged_in_page)
|
| 27 |
+
expect(logged_in_page.locator('button:has-text("💾 Save Settings")')).to_be_visible()
|
| 28 |
+
|
| 29 |
+
def test_reset_to_defaults_button_present(self, logged_in_page: Page):
|
| 30 |
+
"""Reset to Defaults button is visible."""
|
| 31 |
+
go_to_settings(logged_in_page)
|
| 32 |
+
expect(logged_in_page.locator('button:has-text("🔄 Reset to Defaults")')).to_be_visible()
|
| 33 |
+
|
| 34 |
+
def test_liveboard_name_field_present(self, logged_in_page: Page):
|
| 35 |
+
"""Liveboard name textarea is accessible in settings."""
|
| 36 |
+
go_to_settings(logged_in_page)
|
| 37 |
+
logged_in_page.wait_for_timeout(1000)
|
| 38 |
+
field = logged_in_page.locator('textarea[placeholder="My Demo Liveboard"]')
|
| 39 |
+
expect(field).to_be_visible(timeout=10000)
|
| 40 |
+
|
| 41 |
+
def test_change_password_section_present(self, logged_in_page: Page):
|
| 42 |
+
"""Change Password section is accessible for the test user."""
|
| 43 |
+
go_to_settings(logged_in_page)
|
| 44 |
+
expect(logged_in_page.locator('button:has-text("🔒 Change Password")')).to_be_visible()
|
| 45 |
+
|
| 46 |
+
def test_admin_sections_not_visible_for_test_user(self, logged_in_page: Page):
|
| 47 |
+
"""Admin-only sections (User Management) should not appear for testrunner."""
|
| 48 |
+
go_to_settings(logged_in_page)
|
| 49 |
+
body_text = logged_in_page.inner_text('body')
|
| 50 |
+
# testrunner is not an admin — user management table should not be visible
|
| 51 |
+
# (or at least not show other users' emails)
|
| 52 |
+
assert "mike.boone@thoughtspot.com" not in body_text
|
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Smoke tests — verify the app is up and core UI elements are present.
|
| 3 |
+
Run with: pytest tests/e2e_smoke.py -v
|
| 4 |
+
"""
|
| 5 |
+
import pytest
|
| 6 |
+
from playwright.sync_api import Page, Browser, expect
|
| 7 |
+
from tests.conftest import login, get_chat_textarea, BASE_URL, TEST_USER
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestAppAvailability:
|
| 11 |
+
def test_app_loads(self, page: Page):
|
| 12 |
+
"""App responds and shows the login page."""
|
| 13 |
+
page.goto(BASE_URL, timeout=60000)
|
| 14 |
+
expect(page.locator('input[type=password]')).to_be_visible(timeout=45000)
|
| 15 |
+
|
| 16 |
+
def test_login_page_has_expected_fields(self, page: Page):
|
| 17 |
+
"""Login form has username, password, and login button."""
|
| 18 |
+
page.goto(BASE_URL, timeout=60000)
|
| 19 |
+
page.wait_for_selector('input[type=text]', timeout=45000)
|
| 20 |
+
expect(page.locator('input[type=text]')).to_be_visible()
|
| 21 |
+
expect(page.locator('input[type=password]')).to_be_visible()
|
| 22 |
+
expect(page.locator('button:has-text("Login")')).to_be_visible()
|
| 23 |
+
|
| 24 |
+
def test_bad_credentials_rejected(self, page: Page):
|
| 25 |
+
"""Wrong password does not log in."""
|
| 26 |
+
page.goto(BASE_URL, timeout=60000)
|
| 27 |
+
page.wait_for_selector('input[type=text]', timeout=45000)
|
| 28 |
+
page.fill('input[type=text]', TEST_USER)
|
| 29 |
+
page.fill('input[type=password]', 'definitely-wrong-password')
|
| 30 |
+
page.click('button:has-text("Login")')
|
| 31 |
+
page.wait_for_timeout(3000)
|
| 32 |
+
# Should still show the login form
|
| 33 |
+
expect(page.locator('input[type=password]')).to_be_visible()
|
| 34 |
+
|
| 35 |
+
def test_login_succeeds(self, page: Page):
|
| 36 |
+
"""Valid credentials get past the login page."""
|
| 37 |
+
login(page)
|
| 38 |
+
# The login-specific password field (placeholder ••••••••) should be gone
|
| 39 |
+
expect(page.locator('input[placeholder="••••••••"]')).not_to_be_visible()
|
| 40 |
+
# App body should be present
|
| 41 |
+
expect(page.locator('.gradio-container')).to_be_visible()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class TestMainUIStructure:
|
| 45 |
+
def test_all_tabs_present(self, logged_in_page: Page):
|
| 46 |
+
"""All expected tabs are visible after login."""
|
| 47 |
+
expected_tabs = [
|
| 48 |
+
"💬 Chat",
|
| 49 |
+
"Live Progress",
|
| 50 |
+
"📋 Demo Pack",
|
| 51 |
+
"🎬 Spotter Viz Story",
|
| 52 |
+
"⚙️ Settings",
|
| 53 |
+
"🤖 AI Feedback",
|
| 54 |
+
"📄 DDL Code",
|
| 55 |
+
]
|
| 56 |
+
for tab_text in expected_tabs:
|
| 57 |
+
expect(logged_in_page.locator(f'button[role=tab]:has-text("{tab_text}")')).to_be_visible()
|
| 58 |
+
|
| 59 |
+
def test_chat_tab_active_by_default(self, logged_in_page: Page):
|
| 60 |
+
"""Chat tab is selected on first load."""
|
| 61 |
+
tab = logged_in_page.locator('button[role=tab]:has-text("💬 Chat")')
|
| 62 |
+
expect(tab).to_have_attribute('aria-selected', 'true')
|
| 63 |
+
|
| 64 |
+
def test_chat_input_visible(self, logged_in_page: Page):
|
| 65 |
+
"""Chat textarea and Send button are visible."""
|
| 66 |
+
expect(logged_in_page.locator('button:has-text("Send")')).to_be_visible()
|
| 67 |
+
expect(logged_in_page.locator('textarea').first).to_be_visible()
|
| 68 |
+
|
| 69 |
+
def test_quick_action_buttons_present(self, logged_in_page: Page):
|
| 70 |
+
"""Quick action buttons are visible in the chat tab."""
|
| 71 |
+
expect(logged_in_page.locator('button:has-text("🔍 Start Research")')).to_be_visible()
|
| 72 |
+
expect(logged_in_page.locator('button:has-text("⚙️ Configure")')).to_be_visible()
|
| 73 |
+
expect(logged_in_page.locator('button:has-text("💡 Help")')).to_be_visible()
|
| 74 |
+
|
| 75 |
+
def test_welcome_message_displayed(self, logged_in_page: Page):
|
| 76 |
+
"""The welcome message appears in the chatbot on first load."""
|
| 77 |
+
body_text = logged_in_page.inner_text('body')
|
| 78 |
+
assert "Welcome to ThoughtSpot Demo Builder" in body_text
|
| 79 |
+
|
| 80 |
+
def test_sign_out_link_present(self, logged_in_page: Page):
|
| 81 |
+
"""Sign Out link is visible after login."""
|
| 82 |
+
expect(logged_in_page.locator('text=Sign Out')).to_be_visible()
|
| 83 |
+
|
| 84 |
+
def test_sign_out_link_navigates_to_logout(self, logged_in_page: Page):
|
| 85 |
+
"""The Sign Out link points to /logout (verified by href, not by clicking).
|
| 86 |
+
|
| 87 |
+
NOTE: We do NOT navigate to /logout here because doing so with the shared
|
| 88 |
+
test session would invalidate auth_context and break subsequent tests.
|
| 89 |
+
The logout flow is covered separately in e2e_auth.py which runs last.
|
| 90 |
+
"""
|
| 91 |
+
link = logged_in_page.locator('a[href="/logout"]')
|
| 92 |
+
expect(link).to_be_visible()
|
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Auth teardown tests — verify logout works.
|
| 3 |
+
|
| 4 |
+
This file is named with 'z_' prefix so it runs LAST alphabetically.
|
| 5 |
+
These tests navigate to /logout which invalidates the shared test session,
|
| 6 |
+
so they must not run before any test that depends on auth_context.
|
| 7 |
+
|
| 8 |
+
Run with: pytest tests/e2e_z_auth.py -v
|
| 9 |
+
"""
|
| 10 |
+
import pytest
|
| 11 |
+
from playwright.sync_api import Page, Browser, expect
|
| 12 |
+
from tests.conftest import login, BASE_URL
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TestLogout:
|
| 16 |
+
def test_sign_out_logs_out(self, browser: Browser):
|
| 17 |
+
"""Navigating to /logout clears the session and shows the login page."""
|
| 18 |
+
# Use a dedicated fresh context so the shared auth_context is not affected.
|
| 19 |
+
ctx = browser.new_context(viewport={"width": 1280, "height": 900})
|
| 20 |
+
page = ctx.new_page()
|
| 21 |
+
login(page)
|
| 22 |
+
page.goto(f"{BASE_URL}/logout", timeout=30000)
|
| 23 |
+
expect(page.locator('button:has-text("Login")')).to_be_visible(timeout=15000)
|
| 24 |
+
ctx.close()
|
|
@@ -121,8 +121,12 @@ class ThoughtSpotDeployer:
|
|
| 121 |
Raises ValueError if any required setting is missing.
|
| 122 |
"""
|
| 123 |
self.base_url = base_url if base_url else get_admin_setting('THOUGHTSPOT_URL')
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
# Snowflake connection details from environment (key pair auth)
|
| 128 |
self.sf_account = get_admin_setting('SNOWFLAKE_ACCOUNT')
|
|
@@ -1823,6 +1827,7 @@ class ThoughtSpotDeployer:
|
|
| 1823 |
use_case: str = None, liveboard_name: str = None,
|
| 1824 |
llm_model: str = None, tag_name: str = None,
|
| 1825 |
liveboard_method: str = None, share_with: str = None,
|
|
|
|
| 1826 |
progress_callback=None) -> Dict:
|
| 1827 |
"""
|
| 1828 |
Deploy complete data model to ThoughtSpot
|
|
@@ -2230,8 +2235,8 @@ class ThoughtSpotDeployer:
|
|
| 2230 |
log_progress(f"Sharing model with '{_effective_share}'...")
|
| 2231 |
self.share_objects([model_guid], 'LOGICAL_TABLE', _effective_share)
|
| 2232 |
|
| 2233 |
-
# Step 3.5: Enable Spotter
|
| 2234 |
-
# create_new=True import ignores spotter_config
|
| 2235 |
try:
|
| 2236 |
export_resp = self.session.post(
|
| 2237 |
f"{self.base_url}/api/rest/2.0/metadata/tml/export",
|
|
@@ -2245,8 +2250,41 @@ class ThoughtSpotDeployer:
|
|
| 2245 |
export_data = export_resp.json()
|
| 2246 |
if export_data and 'edoc' in export_data[0]:
|
| 2247 |
model_tml_dict = json.loads(export_data[0]['edoc'])
|
| 2248 |
-
|
|
|
|
| 2249 |
model_tml_dict.setdefault('model', {}).setdefault('properties', {})['spotter_config'] = {'is_spotter_enabled': True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2250 |
updated_tml = yaml.dump(model_tml_dict, allow_unicode=True, sort_keys=False)
|
| 2251 |
update_resp = self.session.post(
|
| 2252 |
f"{self.base_url}/api/rest/2.0/metadata/tml/import",
|
|
@@ -2257,15 +2295,15 @@ class ThoughtSpotDeployer:
|
|
| 2257 |
}
|
| 2258 |
)
|
| 2259 |
if update_resp.status_code == 200:
|
| 2260 |
-
log_progress(
|
| 2261 |
else:
|
| 2262 |
-
log_progress(f"🤖
|
| 2263 |
else:
|
| 2264 |
-
log_progress(
|
| 2265 |
else:
|
| 2266 |
log_progress(f"🤖 Spotter enable: export failed HTTP {export_resp.status_code}")
|
| 2267 |
except Exception as spotter_error:
|
| 2268 |
-
log_progress(f"🤖 Spotter
|
| 2269 |
|
| 2270 |
# Step 4: Auto-create Liveboard from model
|
| 2271 |
lb_start = time.time()
|
|
@@ -2408,7 +2446,9 @@ class ThoughtSpotDeployer:
|
|
| 2408 |
if enhance_result.get('success'):
|
| 2409 |
log_progress(f" [OK] Enhancement applied")
|
| 2410 |
else:
|
| 2411 |
-
|
|
|
|
|
|
|
| 2412 |
|
| 2413 |
# Check result
|
| 2414 |
print(f"🔍 DEBUG: Liveboard result received: {liveboard_result}")
|
|
@@ -2441,6 +2481,7 @@ class ThoughtSpotDeployer:
|
|
| 2441 |
except Exception as lb_error:
|
| 2442 |
error = f"Liveboard creation exception: {str(lb_error)}"
|
| 2443 |
results['errors'].append(error)
|
|
|
|
| 2444 |
else:
|
| 2445 |
# Extract detailed error information
|
| 2446 |
obj_response = objects[0].get('response', {})
|
|
|
|
| 121 |
Raises ValueError if any required setting is missing.
|
| 122 |
"""
|
| 123 |
self.base_url = base_url if base_url else get_admin_setting('THOUGHTSPOT_URL')
|
| 124 |
+
if not username:
|
| 125 |
+
raise ValueError("ThoughtSpotDeployer requires username — pass the logged-in user's email")
|
| 126 |
+
self.username = username
|
| 127 |
+
if not secret_key:
|
| 128 |
+
raise ValueError("ThoughtSpotDeployer requires secret_key — pass the trusted auth key for the selected environment")
|
| 129 |
+
self.secret_key = secret_key
|
| 130 |
|
| 131 |
# Snowflake connection details from environment (key pair auth)
|
| 132 |
self.sf_account = get_admin_setting('SNOWFLAKE_ACCOUNT')
|
|
|
|
| 1827 |
use_case: str = None, liveboard_name: str = None,
|
| 1828 |
llm_model: str = None, tag_name: str = None,
|
| 1829 |
liveboard_method: str = None, share_with: str = None,
|
| 1830 |
+
company_research: str = None,
|
| 1831 |
progress_callback=None) -> Dict:
|
| 1832 |
"""
|
| 1833 |
Deploy complete data model to ThoughtSpot
|
|
|
|
| 2235 |
log_progress(f"Sharing model with '{_effective_share}'...")
|
| 2236 |
self.share_objects([model_guid], 'LOGICAL_TABLE', _effective_share)
|
| 2237 |
|
| 2238 |
+
# Step 3.5: Enable Spotter + enrich model semantics in a single export→update→reimport
|
| 2239 |
+
# (create_new=True import ignores spotter_config, so we always re-import here)
|
| 2240 |
try:
|
| 2241 |
export_resp = self.session.post(
|
| 2242 |
f"{self.base_url}/api/rest/2.0/metadata/tml/export",
|
|
|
|
| 2250 |
export_data = export_resp.json()
|
| 2251 |
if export_data and 'edoc' in export_data[0]:
|
| 2252 |
model_tml_dict = json.loads(export_data[0]['edoc'])
|
| 2253 |
+
|
| 2254 |
+
# Enable Spotter
|
| 2255 |
model_tml_dict.setdefault('model', {}).setdefault('properties', {})['spotter_config'] = {'is_spotter_enabled': True}
|
| 2256 |
+
|
| 2257 |
+
# Enrich with description, synonyms, and AI context
|
| 2258 |
+
if company_research:
|
| 2259 |
+
try:
|
| 2260 |
+
from model_semantic_updater import ModelSemanticUpdater
|
| 2261 |
+
log_progress("Generating model description, synonyms, and AI context...")
|
| 2262 |
+
sem_start = time.time()
|
| 2263 |
+
updater = ModelSemanticUpdater(self, llm_model=llm_model)
|
| 2264 |
+
|
| 2265 |
+
model_description = updater.generate_model_description(
|
| 2266 |
+
company_research=company_research,
|
| 2267 |
+
use_case=use_case or '',
|
| 2268 |
+
company_name=company_name or '',
|
| 2269 |
+
model_name=model_name,
|
| 2270 |
+
)
|
| 2271 |
+
column_semantics = updater.generate_column_semantics(
|
| 2272 |
+
company_research=company_research,
|
| 2273 |
+
model_tml=model_tml_dict,
|
| 2274 |
+
use_case=use_case or '',
|
| 2275 |
+
company_name=company_name or '',
|
| 2276 |
+
)
|
| 2277 |
+
# Apply to TML dict in-place (returns YAML string)
|
| 2278 |
+
enriched_yaml = updater.apply_to_model_tml(
|
| 2279 |
+
model_tml_dict, column_semantics, model_description
|
| 2280 |
+
)
|
| 2281 |
+
# Parse back so we can still dump consistently below
|
| 2282 |
+
model_tml_dict = yaml.safe_load(enriched_yaml)
|
| 2283 |
+
sem_time = time.time() - sem_start
|
| 2284 |
+
log_progress(f"[OK] Semantics generated: {len(column_semantics)} columns enriched ({sem_time:.1f}s)")
|
| 2285 |
+
except Exception as sem_err:
|
| 2286 |
+
log_progress(f"[WARN] Semantic enrichment failed (non-fatal): {sem_err}")
|
| 2287 |
+
|
| 2288 |
updated_tml = yaml.dump(model_tml_dict, allow_unicode=True, sort_keys=False)
|
| 2289 |
update_resp = self.session.post(
|
| 2290 |
f"{self.base_url}/api/rest/2.0/metadata/tml/import",
|
|
|
|
| 2295 |
}
|
| 2296 |
)
|
| 2297 |
if update_resp.status_code == 200:
|
| 2298 |
+
log_progress("🤖 Spotter enabled + model semantics applied")
|
| 2299 |
else:
|
| 2300 |
+
log_progress(f"🤖 Model update failed: HTTP {update_resp.status_code} — {update_resp.text[:200]}")
|
| 2301 |
else:
|
| 2302 |
+
log_progress("🤖 Spotter enable: export returned no edoc")
|
| 2303 |
else:
|
| 2304 |
log_progress(f"🤖 Spotter enable: export failed HTTP {export_resp.status_code}")
|
| 2305 |
except Exception as spotter_error:
|
| 2306 |
+
log_progress(f"🤖 Spotter/semantics exception: {spotter_error}")
|
| 2307 |
|
| 2308 |
# Step 4: Auto-create Liveboard from model
|
| 2309 |
lb_start = time.time()
|
|
|
|
| 2446 |
if enhance_result.get('success'):
|
| 2447 |
log_progress(f" [OK] Enhancement applied")
|
| 2448 |
else:
|
| 2449 |
+
enhance_err = f"TML enhancement failed: {enhance_result.get('message', 'unknown')[:100]}"
|
| 2450 |
+
log_progress(f" [ERROR] {enhance_err}")
|
| 2451 |
+
results['errors'].append(enhance_err)
|
| 2452 |
|
| 2453 |
# Check result
|
| 2454 |
print(f"🔍 DEBUG: Liveboard result received: {liveboard_result}")
|
|
|
|
| 2481 |
except Exception as lb_error:
|
| 2482 |
error = f"Liveboard creation exception: {str(lb_error)}"
|
| 2483 |
results['errors'].append(error)
|
| 2484 |
+
log_progress(f"[ERROR] {error}")
|
| 2485 |
else:
|
| 2486 |
# Extract detailed error information
|
| 2487 |
obj_response = objects[0].get('response', {})
|