mikeboone Claude Sonnet 4.6 commited on
Commit
f87fe7f
·
1 Parent(s): 0b82bc5

feat: App/Chat tab split, Spotter Viz dual stories, Custom vertical, bug fixes

Browse files

UI:
- 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 CHANGED
@@ -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 name of the env var containing the token.
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
- key_var = os.getenv(f'TS_ENV_{i}_KEY_VAR', '').strip()
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.demo_pack_content = "" # Generated demo pack markdown
236
- self.spotter_viz_story = "" # Spotter Viz story (NL prompts for Spotter Viz agent)
 
 
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': 'Amazon.com',
251
- 'use_case': 'Sales Analytics',
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
- # Always show the setup message - user must explicitly provide company and use case
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
- **Or describe any custom use case** AI will research and build it from scratch.
320
 
321
- > 💡 **Tip:** Select your ThoughtSpot environment and AI model in the panel to the right before starting.
 
 
322
 
323
- *What company and use case are you working on?*"""
324
- else:
325
- return f"""👋 **Welcome to ThoughtSpot Demo Builder!**
326
-
327
- I'm ready to create a perfect ThoughtSpot demo!
328
 
329
- **Company:** {company}
330
- **Use Case:** {use_case}
 
 
 
 
 
331
 
332
- **Ready to start?**
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
- What's your first step?"""
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 = get_admin_setting('THOUGHTSPOT_ADMIN_USER')
444
- ts_secret = (self.settings.get('thoughtspot_trusted_auth_key') or '').strip() or get_admin_setting('THOUGHTSPOT_TRUSTED_AUTH_KEY')
 
 
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 list(VERTICALS.keys())[:3] for f in FUNCTIONS.keys()]
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, company, use_case, ""
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 list(VERTICALS.keys())[:3] for f in FUNCTIONS.keys()])
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, company, use_case, ""
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 = 'deploy'
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 _generate_spotter_viz_story(self, company_name: str, use_case: str,
2271
- model_name: str = None, liveboard_name: str = None) -> str:
2272
- """Generate a Spotter Viz story — a conversational sequence of NL prompts
2273
- that can be entered into ThoughtSpot's Spotter Viz agent to build a liveboard.
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 demo_personas import parse_use_case, get_use_case_config
 
2280
 
2281
  v, f = parse_use_case(use_case or '')
2282
- uc_cfg = get_use_case_config(v or "Generic", f or "Generic")
2283
- lq = uc_cfg.get("liveboard_questions", [])
2284
- data_source = model_name or f"{company_name} model"
2285
-
2286
- # --- Section 1: Persona-driven story from liveboard_questions ---
2287
- header = f"""# Spotter Viz Story: {company_name}
2288
- ## {use_case}
2289
-
2290
- *Copy these prompts into ThoughtSpot Spotter Viz to build this liveboard interactively.*
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
- ai_story = researcher.make_request(prompt, max_tokens=2000, temperature=0.7)
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
- ai_section = "## Part 2: AI-Generated Story\n\n*(Generation failed — use the structured flow above.)*"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2358
 
2359
- return persona_section + ai_section
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = get_admin_setting('THOUGHTSPOT_ADMIN_USER')
3542
- ts_secret = get_admin_setting('THOUGHTSPOT_TRUSTED_AUTH_KEY')
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 = get_admin_setting('THOUGHTSPOT_ADMIN_USER')
3678
- ts_secret = (self.settings.get('thoughtspot_trusted_auth_key') or '').strip() or get_admin_setting('THOUGHTSPOT_TRUSTED_AUTH_KEY')
 
 
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 Story
3898
  try:
3899
- model_name_for_story = results.get('model', None)
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=model_name_for_story,
3905
- liveboard_name=liveboard_name_for_story
3906
  )
3907
- safe_print("Spotter Viz Story generated - check the Spotter Viz Story tab.", flush=True)
3908
- self.live_progress_log.append("Spotter Viz Story generated")
 
 
3909
  except Exception as e:
3910
  safe_print(f"Could not generate Spotter Viz story: {e}", flush=True)
3911
- self.spotter_viz_story = f"*Spotter Viz story generation failed: {e}*"
 
 
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": "Amazon.com",
4269
- "use_case": "Sales Analytics",
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("💬 Chat"):
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("### Spotter Viz Story Natural Language Liveboard Builder")
4370
- gr.Markdown("*Conversational prompts you can enter into ThoughtSpot Spotter Viz to recreate this liveboard.*")
4371
- spotter_viz_story_display = gr.Markdown(
4372
- value="Spotter Viz story will be generated after liveboard creation.\n\n**What is Spotter Viz?**\nSpotter Viz is an AI agent in ThoughtSpot that creates, structures, and styles Liveboards through natural language prompts. The agent reviews the data, proposes layouts, generates KPIs and visualizations, and allows conversational refinement.",
4373
- elem_classes=["spotter-viz-story-content"]
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, admin_ts_auth_key, admin_ts_user,
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", "THOUGHTSPOT_TRUSTED_AUTH_KEY", "THOUGHTSPOT_ADMIN_USER",
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
- spotter_viz_default = "Spotter Viz story will be generated after liveboard creation.\n\n**What is Spotter Viz?**\nSpotter Viz is an AI agent in ThoughtSpot that creates, structures, and styles Liveboards through natural language prompts. The agent reviews the data, proposes layouts, generates KPIs and visualizations, and allows conversational refinement."
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
- spotter_viz_default
 
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
- # Get Spotter Viz story from controller
4775
- spotter_story = getattr(controller, 'spotter_viz_story', '')
4776
- spotter_story_text = spotter_story if spotter_story else spotter_viz_default
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
- spotter_story_text
 
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, spotter_viz_story_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 = (str(settings.get("default_company_url", "")).strip() or "Amazon.com")
4822
- use_case = (str(settings.get("default_use_case", "")).strip() or "Sales Analytics")
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
- initial_message,
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, "Amazon.com", "Sales Analytics", "",
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=[(None, initial_welcome)],
4890
- height=650,
4891
  label="Demo Builder Assistant",
4892
  show_label=False,
4893
- avatar_images=None, # No avatars - keeps it clean
4894
- type='tuples'
 
 
4895
  )
4896
-
4897
- with gr.Row():
4898
- msg = gr.Textbox(
4899
- label="Your message",
4900
- value="",
4901
- placeholder="Type your message here or use /over to change settings...",
4902
- lines=1,
4903
- max_lines=1,
4904
- scale=5,
4905
- show_label=False,
4906
- interactive=True
4907
- )
4908
- send_btn = gr.Button("Send", variant="primary", scale=1)
4909
-
4910
- # Quick action buttons
4911
- with gr.Row():
4912
- start_btn = gr.Button("🔍 Start Research", size="sm")
4913
- configure_btn = gr.Button("⚙️ Configure", size="sm")
4914
- help_btn = gr.Button("💡 Help", size="sm")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- yield (controller,) + result + (progress,)
 
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
- def quick_action(controller, action_text, history, stage, model, company, usecase, env_label=None, liveboard_name_ui=None, request: gr.Request = None):
5047
- """Handle quick action button clicks"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Always use the current UI value — takes priority over DB-loaded default
5061
- if liveboard_name_ui is not None:
5062
- controller.settings['liveboard_name'] = liveboard_name_ui
5063
 
5064
- for result in controller.process_chat_message(
5065
- action_text, history, stage, model, company, usecase
5066
- ):
5067
- new_stage = result[1] if len(result) > 1 else stage
5068
- progress = get_progress_html(new_stage)
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
- msg.submit(fn=send_message, inputs=_send_inputs, outputs=_send_outputs)
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
- def configure_action(controller, history, stage, model, company, usecase, env_label, liveboard_name_ui):
5083
- yield from quick_action(controller, "Configure settings", history, stage, model, company, usecase, env_label, liveboard_name_ui)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5084
 
5085
- def help_action(controller, history, stage, model, company, usecase, env_label, liveboard_name_ui):
5086
- yield from quick_action(controller, "Help", history, stage, model, company, usecase, env_label, liveboard_name_ui)
 
5087
 
5088
- _action_inputs = [chat_controller_state, chatbot, current_stage, current_model, current_company, current_usecase, ts_env_dropdown, liveboard_name_input]
5089
- _action_outputs = [chat_controller_state, chatbot, current_stage, current_model, current_company, current_usecase, msg, progress_html]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
legitdata_bridge.py CHANGED
@@ -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
- print(f" Failed row: {row[:3]}... - {row_error}")
 
 
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}")
liveboard_creator.py CHANGED
@@ -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
- Get or create an authenticated session for direct ThoughtSpot API calls.
90
- Uses trusted auth from environment variables.
 
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
- username = get_admin_setting('THOUGHTSPOT_ADMIN_USER')
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 env vars (URL={bool(base_url)}, USER={bool(username)}, KEY={bool(secret_key)})")
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
- _direct_api_token = token
123
- _direct_api_session = requests.Session()
124
- _direct_api_session.headers['Authorization'] = f'Bearer {token}'
125
- _direct_api_session.headers['Content-Type'] = 'application/json'
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
- def _get_answer_direct(question: str, model_id: str) -> dict:
 
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
- _get_direct_api_session() # Ensures _direct_api_token is populated
3029
- if not _direct_api_token:
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 {_direct_api_token}@{ts_host}"
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:
model_semantic_updater.py CHANGED
@@ -1,8 +1,11 @@
1
  """
2
  Model Semantic Layer Updater
3
 
4
- Adds descriptions and synonyms to ThoughtSpot model columns to improve search relevance.
5
- Uses AI to generate business-friendly semantic metadata from company research.
 
 
 
6
  """
7
 
8
  import json
@@ -18,349 +21,306 @@ from llm_client_factory import create_openai_client
18
 
19
 
20
  class ModelSemanticUpdater:
21
- """Add descriptions and synonyms to existing model columns"""
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
- self.llm_model = resolve_model_name(llm_model)
32
- if not is_openai_model_name(self.llm_model):
33
- raise ValueError(
34
- f"ModelSemanticUpdater only supports OpenAI GPT/Codex models. Received '{self.llm_model}'."
35
- )
36
  self.openai_client = create_openai_client()
37
 
38
- def get_model_list(self) -> List[Dict]:
39
- """
40
- Fetch list of available models for user selection
41
 
42
- Returns:
43
- List of model metadata dictionaries with 'id' and 'name'
44
- """
45
  try:
46
  response = self.ts_client.session.post(
47
- f"{self.ts_client.base_url}/api/rest/2.0/metadata/search",
48
- headers=self.ts_client.headers,
49
  json={
50
- "metadata": [{"type": "LOGICAL_TABLE"}],
51
- "record_size": 10000 # Get all models - there can be thousands
 
52
  }
53
  )
54
-
55
  if response.status_code == 200:
56
  result = response.json()
57
- models = []
58
-
59
- # API returns a list of metadata objects
60
- if isinstance(result, list):
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 fetching models: {response.status_code} - {response.text}")
88
- return []
89
  except Exception as e:
90
- print(f"Exception fetching models: {e}")
91
- return []
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
- Returns:
101
- Parsed model TML dictionary, or None if failed
102
- """
103
  try:
104
  response = self.ts_client.session.post(
105
- f"{self.ts_client.base_url}/api/rest/2.0/metadata/tml/export",
106
- headers=self.ts_client.headers,
107
  json={
108
- "metadata": [{"identifier": model_id}],
109
- "export_associated": False,
110
- "export_fqn": True
111
  }
112
  )
113
-
114
  if response.status_code == 200:
115
  result = response.json()
 
116
  if result.get('object') and len(result['object']) > 0:
117
- edoc = result['object'][0].get('edoc')
118
- if edoc:
119
- return yaml.safe_load(edoc)
120
- return None
121
  else:
122
- print(f"Error exporting model TML: {response.status_code} - {response.text}")
123
- return None
124
  except Exception as e:
125
- print(f"Exception exporting model TML: {e}")
126
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Use AI to generate descriptions and synonyms for columns
136
- based on company research and business context
137
-
138
- Args:
139
- company_research: Text containing company research/analysis
140
- model_tml: Parsed model TML dictionary
141
- use_case: Optional use case context for more targeted semantics
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('properties', {}).get('description', '')
162
  })
163
 
164
- # Truncate research if too long (to fit in prompt)
165
- max_research_length = 3000
166
- if len(company_research) > max_research_length:
167
- company_research = company_research[:max_research_length] + "..."
168
 
169
- use_case_context = f"\nUse case context: {use_case}\n" if use_case else ""
 
170
 
171
- prompt = f"""You are a data analyst creating semantic metadata for a data model.
 
172
 
173
- Company Research:
174
- {company_research}
175
- {use_case_context}
176
- Data Model Columns:
177
  {json.dumps(column_info, indent=2)}
178
 
179
- For each column, generate:
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
- 2. **synonyms**: 3-5 alternative terms or phrases that users might search for when looking for this data. Include:
183
- - Common business terms
184
- - Abbreviations and full forms
185
- - Related concepts
186
- - Industry-specific terminology
 
187
 
188
  Guidelines:
189
- - Make descriptions actionable and relevant to business users
190
- - Use natural language, not technical jargon
191
- - For measure columns, mention what aggregation makes sense (sum, average, count, etc.)
192
- - For dimension columns, mention what they're used for grouping/filtering
193
- - Synonyms should be realistic search terms a business user would type
194
- - Consider the company's industry and use case when choosing terminology
195
-
196
- Return a JSON object with this structure:
197
  {{
198
  "column_name": {{
199
- "description": "Clear business description",
200
- "synonyms": ["synonym1", "synonym2", "synonym3"]
201
- }},
202
- ...
203
  }}"""
204
 
205
  try:
206
- token_kwargs = build_openai_chat_token_kwargs(self.llm_model, 3000)
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, # Lower temperature for more consistent semantic metadata
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
- def update_model_tml_semantics(self, model_tml: Dict, semantics: Dict[str, Dict]) -> str:
222
- """
223
- Update model TML with descriptions and synonyms
224
 
225
- Args:
226
- model_tml: Parsed model TML dictionary
227
- semantics: Dictionary mapping column names to semantic metadata
 
 
 
 
 
228
 
229
- Returns:
230
- Updated model TML as YAML string
231
  """
232
- columns = model_tml.get('model', {}).get('columns', [])
233
 
234
- for column in columns:
 
 
 
 
 
235
  col_name = column.get('name')
236
- if col_name in semantics:
237
- semantic_data = semantics[col_name]
 
238
 
239
- # Add or update description
240
- if 'description' in semantic_data and semantic_data['description']:
241
- column['properties']['description'] = semantic_data['description']
242
 
243
- # Add or update synonyms
244
- if 'synonyms' in semantic_data and semantic_data['synonyms']:
245
- column['properties']['synonyms'] = semantic_data['synonyms']
246
 
247
- return yaml.dump(model_tml, default_flow_style=False, sort_keys=False)
 
 
248
 
249
- def deploy_updated_model(self, model_tml_yaml: str) -> Dict:
250
- """
251
- Deploy updated model TML back to ThoughtSpot
252
 
253
- Args:
254
- model_tml_yaml: Updated model TML as YAML string
255
 
256
- Returns:
257
- Dictionary with deployment result:
258
- - success: Boolean
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 update_model_semantics(
298
  self,
299
  model_id: str,
300
  company_research: str,
301
- use_case: str = ""
 
 
302
  ) -> Dict:
303
  """
304
- Complete workflow to update model semantics
305
-
306
- Args:
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
- 'success': False,
323
- 'error': 'Could not export model TML'
324
- }
325
-
326
- # Generate semantic metadata
327
- semantics = self.generate_column_semantics(company_research, model_tml, use_case)
 
 
 
 
 
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
- # Deploy updated model
338
- result = self.deploy_updated_model(updated_tml_yaml)
339
 
 
 
340
  if result['success']:
341
  result['columns_updated'] = len(semantics)
342
-
343
  return result
344
 
345
 
346
- def update_model_semantic_layer(
 
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.update_model_semantics(model_id, company_research, use_case)
 
 
 
 
 
 
 
 
 
 
 
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
prompts.py CHANGED
@@ -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.
sprint_2026_03.md CHANGED
@@ -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
- - [ ] **Wizard Tab UI** — not started
 
 
 
 
 
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`
supabase_client.py CHANGED
@@ -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)."
tests/conftest.py ADDED
@@ -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)
tests/e2e_chat.py ADDED
@@ -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')
tests/e2e_settings.py ADDED
@@ -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
tests/e2e_smoke.py ADDED
@@ -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()
tests/e2e_z_auth.py ADDED
@@ -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()
thoughtspot_deployer.py CHANGED
@@ -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
- self.username = username if username else get_admin_setting('THOUGHTSPOT_ADMIN_USER')
125
- self.secret_key = secret_key if secret_key else get_admin_setting('THOUGHTSPOT_TRUSTED_AUTH_KEY')
 
 
 
 
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 via TML update
2234
- # create_new=True import ignores spotter_config must export then re-import to set it
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
- # Set spotter_config inside properties (correct location per golden demo TML)
 
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(f"🤖 Spotter enabled on model")
2261
  else:
2262
- log_progress(f"🤖 Spotter enable update failed: HTTP {update_resp.status_code} — {update_resp.text[:200]}")
2263
  else:
2264
- log_progress(f"🤖 Spotter enable: export returned no edoc")
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 enable exception: {spotter_error}")
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
- log_progress(f" [WARN] Enhancement partial: {enhance_result.get('message', '')[:50]}")
 
 
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', {})