mikeboone Claude Sonnet 4.6 commited on
Commit
ec1630e
·
1 Parent(s): 37d59da

feat: admin user list — sort by last login desc, convert to EST

Browse files
Files changed (3) hide show
  1. chat_interface.py +15 -2
  2. tests/e2e_quality.py +259 -398
  3. tests/quality_config.yaml +57 -46
chat_interface.py CHANGED
@@ -4603,7 +4603,7 @@ def create_chat_interface():
4603
  with gr.Column(scale=2):
4604
  gr.Markdown("#### Current Users")
4605
  user_list_display = gr.Dataframe(
4606
- headers=["Email", "Display Name", "Admin", "Active", "Last Login"],
4607
  datatype=["str", "str", "bool", "bool", "str"],
4608
  interactive=False,
4609
  label="Users"
@@ -4633,16 +4633,29 @@ def create_chat_interface():
4633
  """Load user list from Supabase."""
4634
  try:
4635
  from supabase_client import UserManager
 
4636
  um = UserManager()
4637
  users = um.list_users()
 
 
4638
  rows = []
4639
  for u in users:
 
 
 
 
 
 
 
 
 
 
4640
  rows.append([
4641
  u.get('email', ''),
4642
  u.get('display_name', ''),
4643
  u.get('is_admin', False),
4644
  u.get('is_active', True),
4645
- str(u.get('last_login', 'Never'))[:19] if u.get('last_login') else 'Never'
4646
  ])
4647
  return rows
4648
  except Exception as e:
 
4603
  with gr.Column(scale=2):
4604
  gr.Markdown("#### Current Users")
4605
  user_list_display = gr.Dataframe(
4606
+ headers=["Email", "Display Name", "Admin", "Active", "Last Login EST"],
4607
  datatype=["str", "str", "bool", "bool", "str"],
4608
  interactive=False,
4609
  label="Users"
 
4633
  """Load user list from Supabase."""
4634
  try:
4635
  from supabase_client import UserManager
4636
+ from zoneinfo import ZoneInfo
4637
  um = UserManager()
4638
  users = um.list_users()
4639
+ users = sorted(users, key=lambda u: u.get('last_login') or '', reverse=True)
4640
+ eastern = ZoneInfo('America/New_York')
4641
  rows = []
4642
  for u in users:
4643
+ raw_login = u.get('last_login')
4644
+ if raw_login:
4645
+ try:
4646
+ from datetime import datetime
4647
+ dt = datetime.fromisoformat(str(raw_login).replace('Z', '+00:00'))
4648
+ last_login_str = dt.astimezone(eastern).strftime('%Y-%m-%d %H:%M')
4649
+ except Exception:
4650
+ last_login_str = str(raw_login)[:19]
4651
+ else:
4652
+ last_login_str = 'Never'
4653
  rows.append([
4654
  u.get('email', ''),
4655
  u.get('display_name', ''),
4656
  u.get('is_admin', False),
4657
  u.get('is_active', True),
4658
+ last_login_str
4659
  ])
4660
  return rows
4661
  except Exception as e:
tests/e2e_quality.py CHANGED
@@ -1,24 +1,19 @@
1
  """
2
  Quality regression test suite for DemoPrep.
3
 
4
- Runs 6 pipeline tests against the live HF Space:
5
- - 2 fixed (same company/use case every run — regression baseline)
6
- - 2 random (drawn from the pool in quality_config.yaml)
7
- - 2 AI-generated (Claude picks a novel company + use case each run)
8
 
9
- Each test submits a prompt via the Chat tab, monitors pipeline progress,
10
- then grades the result:
11
  - Stage completion → up to 25 pts (research 5, ddl 7, data 8, thoughtspot 5)
12
  - Data quality → up to 50 pts (LLM grades model TML + Snowflake sample, 0-100 scaled)
13
  - Liveboard quality → up to 25 pts (LLM grades liveboard TML, 0-100 scaled)
14
- Total: 100 pts, A-F grade
15
 
16
  Usage:
17
  source demoprep/bin/activate
18
  python tests/e2e_quality.py
19
-
20
- Or via pytest:
21
- pytest tests/e2e_quality.py -v -s
22
  """
23
 
24
  import json
@@ -37,7 +32,6 @@ import yaml
37
  from dotenv import load_dotenv
38
  from playwright.sync_api import Page, sync_playwright
39
 
40
- # Make project modules importable (snowflake_auth, main_research, etc.)
41
  sys.path.insert(0, str(Path(__file__).parent.parent))
42
 
43
  # ---------------------------------------------------------------------------
@@ -49,8 +43,8 @@ BASE_URL = "https://thoughtspot-dp-demoprep.hf.space"
49
  TEST_USER = os.getenv("TEST_USER")
50
  TEST_PASSWORD = os.getenv("TEST_PASSWORD")
51
 
52
- CONFIG_FILE = Path(__file__).parent / "quality_config.yaml"
53
- RESULTS_DIR = Path(__file__).parent / "quality_results"
54
  RESULTS_DIR.mkdir(exist_ok=True)
55
 
56
  STAGE_LABELS = {
@@ -71,154 +65,187 @@ def load_config() -> dict:
71
 
72
 
73
  # ---------------------------------------------------------------------------
74
- # Test case generation
75
  # ---------------------------------------------------------------------------
76
  def _get_researcher():
77
- """Return a MultiLLMResearcher using the app's configured default LLM."""
78
  from main_research import MultiLLMResearcher
79
  from llm_config import DEFAULT_LLM_MODEL, map_llm_display_to_provider
80
  provider, model = map_llm_display_to_provider(DEFAULT_LLM_MODEL)
81
  return MultiLLMResearcher(provider=provider, model=model)
82
 
83
 
84
- def generate_ai_test_case(config: dict) -> dict:
85
- """Ask the configured LLM to pick a novel company + use case."""
86
  researcher = _get_researcher()
87
- prompt = config["ai_generated"]["generation_prompt"]
88
- raw = researcher.make_request(
89
  [{"role": "user", "content": prompt}],
90
- max_tokens=200,
91
  stream=False,
92
- )
93
- if not raw:
94
- raise ValueError("LLM returned empty response for test case generation")
95
- match = re.search(r'\{.*\}', raw.strip(), re.DOTALL)
 
96
  if not match:
97
- raise ValueError(f"LLM did not return valid JSON: {raw}")
98
- data = json.loads(match.group())
 
 
 
 
 
 
 
 
 
99
  return {
100
- "name": f"AI: {data['company']} — {data['use_case']}",
101
- "type": "ai_generated",
102
- "prompt": data["prompt"],
103
- "company": data["company"],
104
- "use_case": data["use_case"],
 
 
105
  }
106
 
107
 
108
- def pick_random_test_case(config: dict, used_combos: set) -> dict:
109
- """
110
- Pick a use case at random, then ask the LLM to select a matching company.
111
- Avoids use cases already used this run.
112
- """
113
- pool = config["random_pool"]
114
- use_cases = pool["use_cases"]
115
- company_prompt_template = pool["company_prompt"]
116
 
117
- # Pick a use case not yet used this run
118
- available = [uc for uc in use_cases if uc not in used_combos]
119
  if not available:
120
- available = use_cases # all used — allow repeats
121
- use_case = random.choice(available)
122
- used_combos.add(use_case)
123
-
124
- # Fixed test companies to exclude so the LLM doesn't repeat them
125
- fixed_companies = [tc["company"] for tc in config.get("fixed_tests", [])]
126
- exclude_list = ", ".join(fixed_companies)
127
-
128
- prompt_text = company_prompt_template.format(
129
- use_case=use_case,
130
- exclude_list=exclude_list,
131
  )
132
 
133
  try:
134
- researcher = _get_researcher()
135
- raw = researcher.make_request(
136
- [{"role": "user", "content": prompt_text}],
137
- max_tokens=200,
138
- stream=False,
139
- ) or ""
140
- match = re.search(r'\{.*\}', raw.strip(), re.DOTALL)
141
- if not match:
142
- raise ValueError(f"No JSON in response: {raw}")
143
- data = json.loads(match.group())
144
- return {
145
- "name": f"Random: {data['company']} — {use_case}",
146
- "type": "random",
147
- "prompt": data["prompt"],
148
- "company": data["company"],
149
- "use_case": use_case,
150
- }
151
  except Exception as e:
152
- # Hard fallback shouldn't happen often
153
- print(f" ⚠️ Random company selection failed ({e}), using generic fallback")
154
- fallbacks = {
155
- "Retail Sales": ("Target", "target.com"),
156
- "Retail Supply Chain": ("Walmart", "walmart.com"),
157
- "Banking Sales": ("JPMorgan Chase", "jpmorganchase.com"),
158
- "Banking Supply Chain": ("Bank of America", "bankofamerica.com"),
159
- "Software Sales": ("Salesforce", "salesforce.com"),
160
- "Software Supply Chain": ("Microsoft", "microsoft.com"),
161
- }
162
- company, url = fallbacks.get(use_case, ("Amazon", "amazon.com"))
163
- return {
164
- "name": f"Random: {company} — {use_case}",
165
- "type": "random",
166
- "prompt": f"{url}, {use_case}",
167
- "company": company,
168
- "use_case": use_case,
169
- }
170
 
171
 
172
  def build_test_suite(config: dict) -> list:
173
  suite = []
174
- used_combos = set()
175
 
176
  for tc in config["fixed_tests"]:
177
  suite.append({**tc, "type": "fixed"})
178
 
179
  for _ in range(2):
180
- suite.append(pick_random_test_case(config, used_combos))
181
 
182
  ai_count = config["ai_generated"].get("count", 2)
183
  for i in range(ai_count):
184
  try:
185
  suite.append(generate_ai_test_case(config))
186
  except Exception as e:
187
- print(f" ⚠️ AI test case {i+1} generation failed ({e}), substituting random")
188
- suite.append(pick_random_test_case(config, used_combos))
189
 
190
  random.shuffle(suite)
 
 
191
  return suite
192
 
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  # ---------------------------------------------------------------------------
195
  # Pipeline stage detection
196
  # ---------------------------------------------------------------------------
197
  def read_progress(page: Page) -> dict:
198
  """
199
- Read the pipeline progress from the page.
200
- Returns stage_key -> 'complete' | 'running' | 'failed' | 'not_started' | 'unknown'
201
  """
 
202
  try:
203
  page.click('button[role=tab]:has-text("📱 App")', timeout=5000)
204
  page.wait_for_timeout(500)
 
 
205
  except Exception:
206
  pass
207
 
208
- progress_text = page.evaluate('''() => {
209
- const el = document.getElementById("component-39");
210
- return el ? el.innerText : "";
211
- }''')
212
- if not progress_text:
213
- progress_text = page.inner_text('body')
214
 
215
  stages = {}
216
  for key, label in STAGE_LABELS.items():
217
- if f" {label}" in progress_text or f" {label}" in progress_text:
218
  stages[key] = "complete"
219
- elif f" {label}" in progress_text or f"✗ {label}" in progress_text:
220
- stages[key] = "failed"
221
- elif f"▶ {label}" in progress_text or f"⏳ {label}" in progress_text:
222
  stages[key] = "running"
223
  elif f"○ {label}" in progress_text:
224
  stages[key] = "not_started"
@@ -228,33 +255,26 @@ def read_progress(page: Page) -> dict:
228
 
229
 
230
  def pipeline_finished(stages: dict) -> bool:
231
- """True when Complete shows ✓. Timeout handles stuck pipelines."""
232
  return stages.get("complete") == "complete"
233
 
234
 
235
  # ---------------------------------------------------------------------------
236
- # Post-run context extraction (GUIDs + TS environment)
237
  # ---------------------------------------------------------------------------
238
  def extract_run_context(page: Page) -> dict:
239
  """
240
- After pipeline completion, scrape the page for model and liveboard URLs.
241
- Returns: { ts_base_url, model_guid, liveboard_guid }
242
  """
243
  body = page.inner_text('body')
244
 
245
- # Model URL: https://ts.domain/#/data/tables/{guid}
246
- model_match = re.search(
247
- r'(https://[^\s/#]+)/#/data/tables/([a-f0-9-]{36})', body
248
- )
249
- # Liveboard URL: https://ts.domain/#/pinboard/{guid}
250
- lb_match = re.search(
251
- r'(https://[^\s/#]+)/#/pinboard/([a-f0-9-]{36})', body
252
- )
253
 
254
  return {
255
  "ts_base_url": model_match.group(1) if model_match else None,
256
  "model_guid": model_match.group(2) if model_match else None,
257
- "liveboard_guid": lb_match.group(2) if lb_match else None,
258
  }
259
 
260
 
@@ -262,32 +282,24 @@ def extract_run_context(page: Page) -> dict:
262
  # ThoughtSpot API helpers
263
  # ---------------------------------------------------------------------------
264
  def _find_ts_key_for_url(ts_base_url: str) -> str:
265
- """Match a TS base URL to its trusted-auth key from .env TS_ENV_* vars."""
266
  target = (ts_base_url or "").rstrip("/")
267
  for i in range(1, 10):
268
  url = os.getenv(f"TS_ENV_{i}_URL", "").rstrip("/")
269
  key = os.getenv(f"TS_ENV_{i}_KEY_VAR", "")
270
  if url and key and url == target:
271
  return key
272
- # Fallback to ENV_1
273
  return os.getenv("TS_ENV_1_KEY_VAR", "")
274
 
275
 
276
  def ts_authenticate(ts_base_url: str) -> requests.Session:
277
- """Authenticate with ThoughtSpot trusted auth, return session with bearer token."""
278
  secret_key = _find_ts_key_for_url(ts_base_url)
279
  if not secret_key:
280
  raise RuntimeError(f"No trusted auth key found for {ts_base_url}")
281
-
282
  session = requests.Session()
283
  session.headers["Accept"] = "application/json"
284
  resp = session.post(
285
  f"{ts_base_url}/api/rest/2.0/auth/token/full",
286
- json={
287
- "username": TEST_USER,
288
- "secret_key": secret_key,
289
- "validity_time_in_sec": 3600,
290
- },
291
  timeout=30,
292
  )
293
  resp.raise_for_status()
@@ -298,14 +310,9 @@ def ts_authenticate(ts_base_url: str) -> requests.Session:
298
 
299
 
300
  def export_tml(ts_base_url: str, session: requests.Session, guid: str) -> str:
301
- """Export TML for a model or liveboard GUID, return raw YAML string."""
302
  resp = session.post(
303
  f"{ts_base_url}/api/rest/2.0/metadata/tml/export",
304
- json={
305
- "metadata": [{"identifier": guid}],
306
- "export_associated": False,
307
- "export_fqn": True,
308
- },
309
  timeout=30,
310
  )
311
  resp.raise_for_status()
@@ -314,7 +321,6 @@ def export_tml(ts_base_url: str, session: requests.Session, guid: str) -> str:
314
 
315
 
316
  def extract_db_schema(model_tml_str: str) -> tuple:
317
- """Pull database + schema from model TML (needed for Snowflake query)."""
318
  try:
319
  tml = yaml.safe_load(model_tml_str)
320
  tables = tml.get("model", {}).get("tables", [])
@@ -327,21 +333,14 @@ def extract_db_schema(model_tml_str: str) -> tuple:
327
 
328
 
329
  def get_snowflake_sample(db: str, schema: str) -> str:
330
- """
331
- Connect to Snowflake and pull sample rows from each table in the schema.
332
- Returns a formatted string suitable for pasting into the grader prompt.
333
- """
334
  try:
335
  from snowflake_auth import get_snowflake_connection
336
- conn = get_snowflake_connection()
337
  cursor = conn.cursor()
338
-
339
  cursor.execute(f'SHOW TABLES IN SCHEMA "{db}"."{schema}"')
340
- # Column index 1 = table name in SHOW TABLES output
341
  tables = [row[1] for row in cursor.fetchall()]
342
-
343
- parts = []
344
- for table in tables[:6]: # cap at 6 tables
345
  try:
346
  cursor.execute(f'SELECT * FROM "{db}"."{schema}"."{table}" LIMIT 30')
347
  cols = [d[0] for d in cursor.description]
@@ -351,250 +350,167 @@ def get_snowflake_sample(db: str, schema: str) -> str:
351
  for row in rows[:15]:
352
  parts.append(" " + str(dict(zip(cols, row))))
353
  except Exception as e:
354
- parts.append(f"\nTable: {table} — error fetching sample: {e}")
355
-
356
  cursor.close()
357
  conn.close()
358
  return "\n".join(parts) if parts else "No tables found"
359
-
360
  except Exception as e:
361
  return f"Snowflake connection failed: {e}"
362
 
363
 
364
  # ---------------------------------------------------------------------------
365
- # LLM grading calls
366
  # ---------------------------------------------------------------------------
367
  def _call_grader(prompt: str) -> dict:
368
- """Send a grading prompt to the app's configured LLM. Returns parsed JSON dict."""
369
- researcher = _get_researcher()
370
- raw = researcher.make_request(
371
- [{"role": "user", "content": prompt}],
372
- max_tokens=1000,
373
- stream=False,
374
- ) or ""
375
- raw = raw.strip()
376
  match = re.search(r'\{.*\}', raw, re.DOTALL)
377
  if match:
378
  try:
379
  return json.loads(match.group())
380
  except json.JSONDecodeError:
381
  pass
382
- return {
383
- "score": 0,
384
- "reasoning": "Could not parse grader response",
385
- "strengths": [],
386
- "weaknesses": [raw[:300]],
387
- }
388
 
389
 
390
- def grade_data_quality(company: str, use_case: str, model_tml: str, sample_data: str) -> dict:
391
- """
392
- Grade the data model and Snowflake sample on a 0-100 scale.
393
- Rubric: realism(20), story potential(30), time coverage(20),
394
- schema fitness(15), completeness(15).
395
- """
396
- prompt = f"""You are grading a ThoughtSpot demo dataset for {company} ({use_case}).
397
 
398
- The purpose of this demo is to tell a compelling business story for a persona —
399
- a line of business leader or data analyst — using realistic data with outliers
400
- and trends that drive a narrative conversation.
401
 
402
- MODEL TML (schema and structure):
 
 
 
403
  {model_tml[:3000]}
404
 
405
  SNOWFLAKE SAMPLE DATA:
406
  {sample_data[:3000]}
407
 
408
- Grade this dataset 0–100 using this rubric:
409
-
410
- 1. REALISM (20 pts): Do values look like real {company} data? Are measures in believable
411
- ranges for this company's scale? Do dimension members reflect real-world entities
412
- (actual product categories, realistic region names, plausible customer segments)?
413
-
414
- 2. STORY POTENTIAL (30 pts): Is there a "so what"? Are there outliers — products,
415
- regions, or time periods that clearly stand out and would anchor a demo conversation?
416
- Do trends exist with directionality, or is the data flat/random noise?
417
-
418
- 3. TIME COVERAGE (20 pts): Does the data span 12–24 months of history? Is there enough
419
- range for meaningful trend analysis? Are seasonal patterns present where expected for
420
- this industry?
421
-
422
- 4. SCHEMA FITNESS (15 pts): Does the star schema match the {use_case} use case?
423
- Are the right KPIs computable from this schema (revenue, units, margins, etc.)?
424
- Are dimension tables appropriate for the vertical?
425
 
426
- 5. COMPLETENESS (15 pts): Are key measure columns populated with no suspicious nulls?
427
- Do dimension tables have enough distinct members (20+) to support meaningful slicing?
428
-
429
- Return ONLY valid JSON — no other text:
430
  {{"score": 0, "reasoning": "...", "strengths": ["..."], "weaknesses": ["..."]}}"""
431
-
432
  return _call_grader(prompt)
433
 
434
 
435
- def grade_liveboard_quality(company: str, use_case: str, liveboard_tml: str) -> dict:
436
- """
437
- Grade the liveboard on a 0-100 scale.
438
- Rubric: data coverage(25), trend coherence(20), story structure(25),
439
- viz variety(15), use case alignment(15).
440
- """
441
- prompt = f"""You are grading a ThoughtSpot liveboard for {company} ({use_case}).
442
-
443
- A great demo liveboard tells a complete business story: it opens with top-line KPIs,
444
- shows trends over time with clear directionality, and breaks down performance by
445
- key dimensions so a presenter can walk a prospect through a compelling narrative.
446
-
447
- LIVEBOARD TML (structure, visualization titles, and question text):
448
- {liveboard_tml[:4000]}
449
-
450
- Grade this liveboard 0–100 using this rubric:
451
-
452
- 1. DATA COVERAGE (25 pts): Do all visualizations appear to have backing data?
453
- Are questions written against specific column names (not generic placeholders)?
454
- Are there any obviously broken or empty chart patterns?
455
 
456
- 2. TREND COHERENCE (20 pts): Are line/trend charts asking questions that would produce
457
- coherent time series — not random noise? Do KPI questions include a time grain
458
- (weekly, monthly, or quarterly) as required by ThoughtSpot?
459
 
460
- 3. STORY STRUCTURE (25 pts): Does the liveboard flow logically KPIs at the top,
461
- trend charts in the middle, dimensional breakdowns to support the narrative?
462
- Could a sales rep walk through this board and tell a story without confusion?
463
 
464
- 4. VISUALIZATION VARIETY (15 pts): Is there a healthy mix of KPIs, line/trend charts,
465
- bar/categorical charts? Or is it monotonously the same chart type repeated?
466
 
467
- 5. USE CASE ALIGNMENT (15 pts): Do visualization titles and question text clearly match
468
- the {use_case} use case at {company}? Are the most important KPIs for this
469
- vertical present and prominent?
 
 
 
470
 
471
- Return ONLY valid JSON — no other text:
472
  {{"score": 0, "reasoning": "...", "strengths": ["..."], "weaknesses": ["..."]}}"""
473
-
474
  return _call_grader(prompt)
475
 
476
 
477
- # ---------------------------------------------------------------------------
478
- # AI quality grading orchestration
479
- # ---------------------------------------------------------------------------
480
- def run_ai_grading(run_context: dict, company: str, use_case: str) -> dict:
481
- """
482
- Fetch TML from ThoughtSpot, sample Snowflake, call LLM graders.
483
- Returns a dict with scores, points, reasoning, and any errors.
484
- """
485
  result = {
486
- "data_score": None,
487
- "data_points": 0.0,
488
- "data_reasoning": "Not graded",
489
- "data_strengths": [],
490
- "data_weaknesses": [],
491
- "liveboard_score": None,
492
- "liveboard_points": 0.0,
493
- "liveboard_reasoning": "Not graded",
494
- "liveboard_strengths": [],
495
- "liveboard_weaknesses": [],
496
- "grading_errors": [],
497
  }
498
 
499
- ts_base = run_context.get("ts_base_url")
500
- model_guid = run_context.get("model_guid")
501
- lb_guid = run_context.get("liveboard_guid")
502
 
503
  if not ts_base or not model_guid:
504
- result["grading_errors"].append(
505
- "No model URL found in page output — pipeline may not have completed"
506
- )
507
  return result
508
 
509
- # Authenticate once, reuse session for both exports
510
  try:
511
  session = ts_authenticate(ts_base)
512
  except Exception as e:
513
  result["grading_errors"].append(f"ThoughtSpot auth failed: {e}")
514
  return result
515
 
516
- # --- Data quality ---
517
  try:
518
  print(" 🔍 Exporting model TML...")
519
  model_tml = export_tml(ts_base, session, model_guid)
520
  db, schema = extract_db_schema(model_tml)
521
-
522
- if db and schema:
523
- print(f" 🗄️ Sampling Snowflake {db}.{schema}...")
524
- sample = get_snowflake_sample(db, schema)
525
- else:
526
- sample = "Could not determine database/schema from model TML"
527
- result["grading_errors"].append("db/schema not found in model TML")
528
-
529
  print(" 🤖 Grading data quality...")
530
- dg = grade_data_quality(company, use_case, model_tml, sample)
531
  score = max(0, min(100, int(dg.get("score", 0))))
532
- result["data_score"] = score
533
- result["data_points"] = round(score * 0.50, 1)
534
- result["data_reasoning"] = dg.get("reasoning", "")
535
- result["data_strengths"] = dg.get("strengths", [])
536
- result["data_weaknesses"] = dg.get("weaknesses", [])
537
- print(f" 📊 Data score: {score}/100 → {result['data_points']} pts")
538
-
539
  except Exception as e:
540
  result["grading_errors"].append(f"Data grading failed: {e}")
541
 
542
- # --- Liveboard quality ---
543
  if lb_guid:
544
  try:
545
  print(" 🔍 Exporting liveboard TML...")
546
  lb_tml = export_tml(ts_base, session, lb_guid)
547
  print(" 🤖 Grading liveboard quality...")
548
- lg = grade_liveboard_quality(company, use_case, lb_tml)
549
  lb_score = max(0, min(100, int(lg.get("score", 0))))
550
- result["liveboard_score"] = lb_score
551
- result["liveboard_points"] = round(lb_score * 0.25, 1)
552
- result["liveboard_reasoning"] = lg.get("reasoning", "")
553
- result["liveboard_strengths"] = lg.get("strengths", [])
554
- result["liveboard_weaknesses"] = lg.get("weaknesses", [])
555
- print(f" 📊 Liveboard score: {lb_score}/100 → {result['liveboard_points']} pts")
 
556
  except Exception as e:
557
  result["grading_errors"].append(f"Liveboard grading failed: {e}")
558
  else:
559
- result["grading_errors"].append(
560
- "No liveboard GUID found — liveboard may not have been created"
561
- )
562
 
563
  return result
564
 
565
 
566
  # ---------------------------------------------------------------------------
567
- # Stage-based grading
568
  # ---------------------------------------------------------------------------
569
  def grade_stages(stages: dict, config: dict) -> dict:
570
- """
571
- Score stage completions (up to 25 pts).
572
- Returns: { stage_scores, stage_total, breakdown }
573
- """
574
  weights = config["grading"]["stages"]
575
  breakdown = {}
576
  total = 0
577
-
578
  for key, weight in weights.items():
579
  status = stages.get(key, "unknown")
580
- if status == "complete":
581
- earned = weight
582
- elif status == "running":
583
- earned = weight // 2
584
- else:
585
- earned = 0
586
  breakdown[key] = {"weight": weight, "earned": earned, "status": status}
587
  total += earned
588
-
589
  return {"stage_total": total, "breakdown": breakdown}
590
 
591
 
592
- def compute_grade(total_score: float, config: dict) -> str:
593
- pct = total_score # already out of 100
594
- thresholds = config["grading"]["thresholds"]
595
  grade = "F"
596
- for letter, threshold in sorted(thresholds.items(), key=lambda x: -x[1]):
597
- if pct >= threshold:
598
  grade = letter
599
  break
600
  return grade
@@ -604,47 +520,21 @@ def compute_grade(total_score: float, config: dict) -> str:
604
  # Single test runner
605
  # ---------------------------------------------------------------------------
606
  def run_single_test(page: Page, test_case: dict, config: dict) -> dict:
607
- """
608
- Submit one prompt, monitor until completion or timeout, then grade.
609
- Returns the full result dict.
610
- """
611
- timeout_sec = config["grading"]["timeout_minutes"] * 60
612
- start = time.time()
613
-
614
- print(f"\n 📤 Submitting: {test_case['prompt']}")
615
 
616
  result = {
617
- "name": test_case["name"],
618
- "type": test_case["type"],
619
- "company": test_case.get("company", ""),
620
- "use_case": test_case.get("use_case", ""),
621
- "prompt": test_case["prompt"],
622
- "stages": {},
623
- "run_context": {},
624
- "stage_grading": {},
625
- "ai_grading": {},
626
- "total_score": 0.0,
627
- "grade": "F",
628
- "error": None,
629
- "timed_out": False,
630
- "duration_seconds": 0,
631
  }
632
 
633
  try:
634
- # Navigate to Chat sub-tab
635
- page.goto(BASE_URL, timeout=90000)
636
- page.wait_for_selector('button[role=tab]', timeout=60000)
637
- page.wait_for_timeout(2000)
638
-
639
- page.click('button[role=tab]:has-text("Chat")', timeout=10000)
640
- page.wait_for_timeout(1000)
641
-
642
- chat_input = page.locator('input[placeholder*="Amazon.com"]')
643
- chat_input.wait_for(state='visible', timeout=30000)
644
- chat_input.click()
645
- chat_input.fill(test_case["prompt"])
646
- page.click('button:has-text("Send")', timeout=10000)
647
-
648
  print(f" ⏳ Monitoring pipeline (timeout: {config['grading']['timeout_minutes']}min)...")
649
 
650
  poll_interval = 15
@@ -655,28 +545,18 @@ def run_single_test(page: Page, test_case: dict, config: dict) -> dict:
655
  if stages != last_stages:
656
  done = [k for k, v in stages.items() if v == "complete"]
657
  running = [k for k, v in stages.items() if v == "running"]
658
- failed = [k for k, v in stages.items() if v == "failed"]
659
- print(f" ✅ {done} ▶ {running} ❌ {failed}")
660
  last_stages = stages
661
  if pipeline_finished(stages):
662
- print(" ✅ Pipeline finished")
663
  break
664
  else:
665
  result["timed_out"] = True
666
- print(f" ⏰ Timed out after {config['grading']['timeout_minutes']} minutes")
667
 
668
  result["stages"] = last_stages or read_progress(page)
669
 
670
- # Navigate back to Chat tab so the completion message (with URLs) is in the DOM
671
- try:
672
- page.click('button[role=tab]:has-text("📱 App")', timeout=5000)
673
- page.wait_for_timeout(500)
674
- page.get_by_role('tab', name='Chat', exact=True).click(timeout=5000)
675
- page.wait_for_timeout(1500)
676
- except Exception:
677
- pass
678
-
679
- # Extract GUIDs from the completion message
680
  run_ctx = extract_run_context(page)
681
  result["run_context"] = run_ctx
682
 
@@ -686,51 +566,48 @@ def run_single_test(page: Page, test_case: dict, config: dict) -> dict:
686
  try:
687
  result["stages"] = read_progress(page)
688
  except Exception:
689
- result["stages"] = {}
690
 
691
  result["duration_seconds"] = round(time.time() - start)
692
 
693
- # --- Stage scoring (25 pts max) ---
694
  sg = grade_stages(result["stages"], config)
695
  result["stage_grading"] = sg
696
 
697
- # --- AI quality grading (75 pts max) ---
698
  ag = {"data_points": 0.0, "liveboard_points": 0.0, "grading_errors": []}
699
  if result["run_context"].get("model_guid"):
700
  print(" 🔬 Running AI quality grading...")
701
  ag = run_ai_grading(
702
  result["run_context"],
703
- result["company"],
704
- result["use_case"],
705
  )
706
  else:
707
  ag["grading_errors"].append("Skipped — no model GUID (pipeline did not complete)")
708
  result["ai_grading"] = ag
709
 
710
- # --- Total score (out of 100) ---
711
  total = sg["stage_total"] + ag.get("data_points", 0) + ag.get("liveboard_points", 0)
712
  result["total_score"] = round(total, 1)
713
  result["grade"] = compute_grade(total, config)
714
-
715
  return result
716
 
717
 
718
  # ---------------------------------------------------------------------------
719
- # Results persistence
720
  # ---------------------------------------------------------------------------
721
  def save_results(run: dict) -> Path:
722
  ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
723
  path = RESULTS_DIR / f"{ts}_quality_run.json"
724
  with open(path, "w") as f:
725
  json.dump(run, f, indent=2, default=str)
726
- print(f"\n💾 Results saved: {path}")
727
  return path
728
 
729
 
730
  # ---------------------------------------------------------------------------
731
- # Main runner
732
  # ---------------------------------------------------------------------------
733
- def run_quality_suite():
734
  if not TEST_USER or not TEST_PASSWORD:
735
  raise RuntimeError("TEST_USER and TEST_PASSWORD must be set in .env")
736
 
@@ -743,12 +620,11 @@ def run_quality_suite():
743
  f"{sum(1 for t in suite if t['type']=='fixed')} fixed "
744
  f"{sum(1 for t in suite if t['type']=='random')} random "
745
  f"{sum(1 for t in suite if t['type']=='ai_generated')} AI-generated")
746
- print(f" Scoring: stages(25) + data quality(50) + liveboard(25) = 100 pts")
747
  print(f"{'='*62}")
748
-
749
  for i, tc in enumerate(suite, 1):
750
- label = {"fixed": "🔒 Fixed", "random": "🎲 Random", "ai_generated": "🤖 AI"}[tc["type"]]
751
- print(f" [{i}] {label}: {tc['name']}")
752
 
753
  run_id = str(uuid.uuid4())[:8]
754
  results = []
@@ -776,17 +652,15 @@ def run_quality_suite():
776
  result = run_single_test(page, test_case, config)
777
  results.append(result)
778
 
779
- # Mini-report after each test
780
  ag = result["ai_grading"]
781
  sg = result["stage_grading"]
782
- print(f" Stage pts: {sg.get('stage_total', 0)}/25")
783
  if ag.get("data_score") is not None:
784
- print(f" Data quality: {ag['data_score']}/100 → {ag['data_points']} pts")
785
  if ag.get("liveboard_score") is not None:
786
- print(f" Liveboard: {ag['liveboard_score']}/100 → {ag['liveboard_points']} pts")
787
- if ag.get("grading_errors"):
788
- for err in ag["grading_errors"]:
789
- print(f" ⚠️ {err}")
790
  print(f" TOTAL: {result['total_score']}/100 Grade: {result['grade']}"
791
  f" ({result['duration_seconds']}s)"
792
  f"{' ⏰ TIMEOUT' if result['timed_out'] else ''}"
@@ -795,51 +669,38 @@ def run_quality_suite():
795
  ctx.close()
796
  browser.close()
797
 
798
- # Compile run summary
799
- total_pts = sum(r["total_score"] for r in results)
800
- avg_score = round(total_pts / len(results), 1) if results else 0
801
- overall_grade = compute_grade(avg_score, config)
802
 
803
  run = {
804
- "run_id": run_id,
805
- "timestamp": datetime.now().isoformat(),
806
- "avg_score": avg_score,
807
- "overall_grade": overall_grade,
808
- "test_count": len(results),
809
- "tests": results,
810
  }
811
-
812
  save_results(run)
813
 
814
- # Final report
815
  print(f"\n{'='*62}")
816
- print(f" QUALITY RUN COMPLETE")
817
- print(f"{'='*62}")
818
- print(f" Average Score: {avg_score}/100 — Grade: {overall_grade}")
819
- print(f"\n Per-test results:")
820
  for r in results:
821
  label = {"fixed": "🔒", "random": "🎲", "ai_generated": "🤖"}[r["type"]]
822
  ag = r["ai_grading"]
823
  ds = f"{ag['data_score']}/100" if ag.get("data_score") is not None else "n/a"
824
  ls = f"{ag['liveboard_score']}/100" if ag.get("liveboard_score") is not None else "n/a"
825
- print(f" {label} {r['name']}")
826
- print(f" Score: {r['total_score']}/100 Grade: {r['grade']}"
827
- f" data={ds} lb={ls} ({r['duration_seconds']}s)")
828
  print(f"{'='*62}\n")
829
-
830
  return run
831
 
832
 
833
- # ---------------------------------------------------------------------------
834
- # Pytest entry point
835
- # ---------------------------------------------------------------------------
836
  def test_quality_run():
837
- """Pytest wrapper. Fails if average grade is F."""
838
  run = run_quality_suite()
839
- assert run["overall_grade"] != "F", (
840
- f"Quality run averaged {run['avg_score']}% — too many pipeline failures."
841
- )
842
 
843
 
844
  if __name__ == "__main__":
845
- run_quality_suite()
 
 
 
 
 
 
1
  """
2
  Quality regression test suite for DemoPrep.
3
 
4
+ Runs 6 pipeline tests against the live HF Space using the form UI:
5
+ - 2 fixed (same every run — regression baselines)
6
+ - 2 random (use case picked from pool, AI selects matching company)
7
+ - 2 AI-generated (AI picks vertical, line, function, and company)
8
 
9
+ Scoring (100 pts total):
 
10
  - Stage completion → up to 25 pts (research 5, ddl 7, data 8, thoughtspot 5)
11
  - Data quality → up to 50 pts (LLM grades model TML + Snowflake sample, 0-100 scaled)
12
  - Liveboard quality → up to 25 pts (LLM grades liveboard TML, 0-100 scaled)
 
13
 
14
  Usage:
15
  source demoprep/bin/activate
16
  python tests/e2e_quality.py
 
 
 
17
  """
18
 
19
  import json
 
32
  from dotenv import load_dotenv
33
  from playwright.sync_api import Page, sync_playwright
34
 
 
35
  sys.path.insert(0, str(Path(__file__).parent.parent))
36
 
37
  # ---------------------------------------------------------------------------
 
43
  TEST_USER = os.getenv("TEST_USER")
44
  TEST_PASSWORD = os.getenv("TEST_PASSWORD")
45
 
46
+ CONFIG_FILE = Path(__file__).parent / "quality_config.yaml"
47
+ RESULTS_DIR = Path(__file__).parent / "quality_results"
48
  RESULTS_DIR.mkdir(exist_ok=True)
49
 
50
  STAGE_LABELS = {
 
65
 
66
 
67
  # ---------------------------------------------------------------------------
68
+ # LLM helper (uses app's configured LLM)
69
  # ---------------------------------------------------------------------------
70
  def _get_researcher():
 
71
  from main_research import MultiLLMResearcher
72
  from llm_config import DEFAULT_LLM_MODEL, map_llm_display_to_provider
73
  provider, model = map_llm_display_to_provider(DEFAULT_LLM_MODEL)
74
  return MultiLLMResearcher(provider=provider, model=model)
75
 
76
 
77
+ def _llm(prompt: str, max_tokens: int = 300) -> str:
 
78
  researcher = _get_researcher()
79
+ return (researcher.make_request(
 
80
  [{"role": "user", "content": prompt}],
81
+ max_tokens=max_tokens,
82
  stream=False,
83
+ ) or "").strip()
84
+
85
+
86
+ def _parse_json(text: str) -> dict:
87
+ match = re.search(r'\{.*\}', text, re.DOTALL)
88
  if not match:
89
+ raise ValueError(f"No JSON found in: {text[:200]}")
90
+ return json.loads(match.group())
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Test case generation
95
+ # ---------------------------------------------------------------------------
96
+ def generate_ai_test_case(config: dict) -> dict:
97
+ """AI picks vertical, line, function, and a matching company."""
98
+ prompt = config["ai_generated"]["generation_prompt"]
99
+ data = _parse_json(_llm(prompt))
100
  return {
101
+ "name": f"AI: {data['company']} — {data['vertical']} / {data['line']} / {data['function']}",
102
+ "type": "ai_generated",
103
+ "company": data["company"],
104
+ "company_url": data["company_url"],
105
+ "vertical": data["vertical"],
106
+ "line": data["line"],
107
+ "function": data["function"],
108
  }
109
 
110
 
111
+ def pick_random_test_case(config: dict, used_labels: set) -> dict:
112
+ """Pick a use case from the pool, ask AI for a matching company."""
113
+ pool = config["random_pool"]["use_cases"]
114
+ template = config["random_pool"]["company_prompt"]
115
+ fixed_companies = [t["company"] for t in config.get("fixed_tests", [])]
 
 
 
116
 
117
+ available = [uc for uc in pool if uc["label"] not in used_labels]
 
118
  if not available:
119
+ available = pool
120
+ uc = random.choice(available)
121
+ used_labels.add(uc["label"])
122
+
123
+ exclude = ", ".join(fixed_companies)
124
+ prompt = template.format(
125
+ label=uc["label"],
126
+ vertical=uc["vertical"],
127
+ line=uc["line"],
128
+ function=uc["function"],
129
+ exclude_list=exclude,
130
  )
131
 
132
  try:
133
+ data = _parse_json(_llm(prompt))
134
+ company = data["company"]
135
+ company_url = data["company_url"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  except Exception as e:
137
+ print(f" ⚠️ Company selection failed ({e}), using fallback")
138
+ company = uc["label"].split()[0]
139
+ company_url = "example.com"
140
+
141
+ return {
142
+ "name": f"Random: {company} — {uc['label']}",
143
+ "type": "random",
144
+ "company": company,
145
+ "company_url": company_url,
146
+ "vertical": uc["vertical"],
147
+ "line": uc["line"],
148
+ "function": uc["function"],
149
+ }
 
 
 
 
 
150
 
151
 
152
  def build_test_suite(config: dict) -> list:
153
  suite = []
154
+ used_labels = set()
155
 
156
  for tc in config["fixed_tests"]:
157
  suite.append({**tc, "type": "fixed"})
158
 
159
  for _ in range(2):
160
+ suite.append(pick_random_test_case(config, used_labels))
161
 
162
  ai_count = config["ai_generated"].get("count", 2)
163
  for i in range(ai_count):
164
  try:
165
  suite.append(generate_ai_test_case(config))
166
  except Exception as e:
167
+ print(f" ⚠️ AI test case {i+1} failed ({e}), substituting random")
168
+ suite.append(pick_random_test_case(config, used_labels))
169
 
170
  random.shuffle(suite)
171
+ if max_tests:
172
+ suite = suite[:max_tests]
173
  return suite
174
 
175
 
176
+ # ---------------------------------------------------------------------------
177
+ # Form interaction helpers
178
+ # ---------------------------------------------------------------------------
179
+ def select_gradio_dropdown(page: Page, label: str, value: str):
180
+ """Select a value from a Gradio dropdown by its label text."""
181
+ # Find the input inside the dropdown container that follows this label
182
+ container = page.locator(f'label:has-text("{label}")').locator('xpath=ancestor::div[contains(@class,"form") or contains(@class,"block")][1]')
183
+ inp = container.locator('input').first
184
+ inp.click()
185
+ inp.fill("")
186
+ inp.type(value, delay=50)
187
+ page.wait_for_timeout(400)
188
+ # Click the matching option in the listbox
189
+ option = page.get_by_role('option', name=value, exact=True)
190
+ option.wait_for(timeout=5000)
191
+ option.click()
192
+ page.wait_for_timeout(300)
193
+
194
+
195
+ def submit_job(page: Page, test_case: dict):
196
+ """Fill the form and click GO."""
197
+ # Navigate to App tab, then App sub-tab
198
+ page.goto(BASE_URL, timeout=90000)
199
+ page.wait_for_selector('button[role=tab]', timeout=60000)
200
+ page.wait_for_timeout(2000)
201
+
202
+ page.click('button[role=tab]:has-text("📱 App")', timeout=10000)
203
+ page.wait_for_timeout(500)
204
+ page.get_by_role('tab', name='App', exact=True).click(timeout=10000)
205
+ page.wait_for_timeout(1000)
206
+
207
+ # Select dropdowns
208
+ select_gradio_dropdown(page, "Vertical", test_case["vertical"])
209
+ select_gradio_dropdown(page, "Line", test_case["line"])
210
+ select_gradio_dropdown(page, "Function", test_case["function"])
211
+
212
+ # Fill company URL
213
+ url_input = page.locator('input[placeholder*="company"]').first
214
+ if not url_input.is_visible():
215
+ url_input = page.locator('label:has-text("Company URL")').locator('xpath=ancestor::div[1]//input')
216
+ url_input.click()
217
+ url_input.fill(test_case["company_url"])
218
+ page.wait_for_timeout(300)
219
+
220
+ # Click GO
221
+ page.click('button:has-text("→ GO")', timeout=10000)
222
+ print(f" ✅ Form submitted: {test_case['vertical']} / {test_case['line']} / {test_case['function']} — {test_case['company_url']}")
223
+
224
+
225
  # ---------------------------------------------------------------------------
226
  # Pipeline stage detection
227
  # ---------------------------------------------------------------------------
228
  def read_progress(page: Page) -> dict:
229
  """
230
+ Read pipeline progress from the right-side progress panel.
231
+ Returns stage_key -> 'complete' | 'running' | 'not_started' | 'unknown'
232
  """
233
+ # Stay on App tab — progress panel is on the right side
234
  try:
235
  page.click('button[role=tab]:has-text("📱 App")', timeout=5000)
236
  page.wait_for_timeout(500)
237
+ page.get_by_role('tab', name='App', exact=True).click(timeout=3000)
238
+ page.wait_for_timeout(300)
239
  except Exception:
240
  pass
241
 
242
+ progress_text = page.inner_text('body')
 
 
 
 
 
243
 
244
  stages = {}
245
  for key, label in STAGE_LABELS.items():
246
+ if f" {label}" in progress_text or f" {label}" in progress_text:
247
  stages[key] = "complete"
248
+ elif f" {label}" in progress_text:
 
 
249
  stages[key] = "running"
250
  elif f"○ {label}" in progress_text:
251
  stages[key] = "not_started"
 
255
 
256
 
257
  def pipeline_finished(stages: dict) -> bool:
 
258
  return stages.get("complete") == "complete"
259
 
260
 
261
  # ---------------------------------------------------------------------------
262
+ # Post-run GUID extraction
263
  # ---------------------------------------------------------------------------
264
  def extract_run_context(page: Page) -> dict:
265
  """
266
+ After completion, find model and liveboard URLs in the page.
267
+ Checks both the progress panel area and any result display.
268
  """
269
  body = page.inner_text('body')
270
 
271
+ model_match = re.search(r'(https://[^\s/#]+)/#/data/tables/([a-f0-9-]{36})', body)
272
+ lb_match = re.search(r'(https://[^\s/#]+)/#/pinboard/([a-f0-9-]{36})', body)
 
 
 
 
 
 
273
 
274
  return {
275
  "ts_base_url": model_match.group(1) if model_match else None,
276
  "model_guid": model_match.group(2) if model_match else None,
277
+ "liveboard_guid": lb_match.group(2) if lb_match else None,
278
  }
279
 
280
 
 
282
  # ThoughtSpot API helpers
283
  # ---------------------------------------------------------------------------
284
  def _find_ts_key_for_url(ts_base_url: str) -> str:
 
285
  target = (ts_base_url or "").rstrip("/")
286
  for i in range(1, 10):
287
  url = os.getenv(f"TS_ENV_{i}_URL", "").rstrip("/")
288
  key = os.getenv(f"TS_ENV_{i}_KEY_VAR", "")
289
  if url and key and url == target:
290
  return key
 
291
  return os.getenv("TS_ENV_1_KEY_VAR", "")
292
 
293
 
294
  def ts_authenticate(ts_base_url: str) -> requests.Session:
 
295
  secret_key = _find_ts_key_for_url(ts_base_url)
296
  if not secret_key:
297
  raise RuntimeError(f"No trusted auth key found for {ts_base_url}")
 
298
  session = requests.Session()
299
  session.headers["Accept"] = "application/json"
300
  resp = session.post(
301
  f"{ts_base_url}/api/rest/2.0/auth/token/full",
302
+ json={"username": TEST_USER, "secret_key": secret_key, "validity_time_in_sec": 3600},
 
 
 
 
303
  timeout=30,
304
  )
305
  resp.raise_for_status()
 
310
 
311
 
312
  def export_tml(ts_base_url: str, session: requests.Session, guid: str) -> str:
 
313
  resp = session.post(
314
  f"{ts_base_url}/api/rest/2.0/metadata/tml/export",
315
+ json={"metadata": [{"identifier": guid}], "export_associated": False, "export_fqn": True},
 
 
 
 
316
  timeout=30,
317
  )
318
  resp.raise_for_status()
 
321
 
322
 
323
  def extract_db_schema(model_tml_str: str) -> tuple:
 
324
  try:
325
  tml = yaml.safe_load(model_tml_str)
326
  tables = tml.get("model", {}).get("tables", [])
 
333
 
334
 
335
  def get_snowflake_sample(db: str, schema: str) -> str:
 
 
 
 
336
  try:
337
  from snowflake_auth import get_snowflake_connection
338
+ conn = get_snowflake_connection()
339
  cursor = conn.cursor()
 
340
  cursor.execute(f'SHOW TABLES IN SCHEMA "{db}"."{schema}"')
 
341
  tables = [row[1] for row in cursor.fetchall()]
342
+ parts = []
343
+ for table in tables[:6]:
 
344
  try:
345
  cursor.execute(f'SELECT * FROM "{db}"."{schema}"."{table}" LIMIT 30')
346
  cols = [d[0] for d in cursor.description]
 
350
  for row in rows[:15]:
351
  parts.append(" " + str(dict(zip(cols, row))))
352
  except Exception as e:
353
+ parts.append(f"\nTable: {table} — error: {e}")
 
354
  cursor.close()
355
  conn.close()
356
  return "\n".join(parts) if parts else "No tables found"
 
357
  except Exception as e:
358
  return f"Snowflake connection failed: {e}"
359
 
360
 
361
  # ---------------------------------------------------------------------------
362
+ # AI quality grading
363
  # ---------------------------------------------------------------------------
364
  def _call_grader(prompt: str) -> dict:
365
+ raw = _llm(prompt, max_tokens=1000)
 
 
 
 
 
 
 
366
  match = re.search(r'\{.*\}', raw, re.DOTALL)
367
  if match:
368
  try:
369
  return json.loads(match.group())
370
  except json.JSONDecodeError:
371
  pass
372
+ return {"score": 0, "reasoning": "Could not parse response", "strengths": [], "weaknesses": [raw[:200]]}
 
 
 
 
 
373
 
374
 
375
+ def grade_data_quality(company: str, vertical: str, line: str, function: str,
376
+ model_tml: str, sample_data: str) -> dict:
377
+ prompt = f"""You are grading a ThoughtSpot demo dataset.
 
 
 
 
378
 
379
+ Company: {company}
380
+ Vertical: {vertical} / {line}
381
+ Analytics function: {function}
382
 
383
+ The goal is a compelling demo with realistic data, outliers that drive a narrative,
384
+ and a schema that supports the key KPIs for this use case.
385
+
386
+ MODEL TML:
387
  {model_tml[:3000]}
388
 
389
  SNOWFLAKE SAMPLE DATA:
390
  {sample_data[:3000]}
391
 
392
+ Grade 0–100:
393
+ 1. REALISM (20 pts): Values look like real {company} data at scale.
394
+ 2. STORY POTENTIAL (30 pts): Outliers and trends exist that would anchor a demo conversation.
395
+ 3. TIME COVERAGE (20 pts): 12–24 months of history with meaningful trends.
396
+ 4. SCHEMA FITNESS (15 pts): Star schema supports the right KPIs for {line} {function}.
397
+ 5. COMPLETENESS (15 pts): Tables fully populated, dimensions have 20+ members.
 
 
 
 
 
 
 
 
 
 
 
398
 
399
+ Return ONLY valid JSON:
 
 
 
400
  {{"score": 0, "reasoning": "...", "strengths": ["..."], "weaknesses": ["..."]}}"""
 
401
  return _call_grader(prompt)
402
 
403
 
404
+ def grade_liveboard_quality(company: str, vertical: str, line: str, function: str,
405
+ liveboard_tml: str) -> dict:
406
+ prompt = f"""You are grading a ThoughtSpot liveboard.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
 
408
+ Company: {company}
409
+ Vertical: {vertical} / {line}
410
+ Analytics function: {function}
411
 
412
+ A great liveboard opens with KPIs, shows trends with clear directionality,
413
+ and breaks down performance by dimensions telling a story a presenter can walk through.
 
414
 
415
+ LIVEBOARD TML:
416
+ {liveboard_tml[:4000]}
417
 
418
+ Grade 0–100:
419
+ 1. DATA COVERAGE (25 pts): All vizzes have backing data, questions use real column names.
420
+ 2. TREND COHERENCE (20 pts): Line charts produce coherent time series; KPIs have time grains.
421
+ 3. STORY STRUCTURE (25 pts): Flows KPIs → trends → breakdowns; walkable in a demo.
422
+ 4. VISUALIZATION VARIETY (15 pts): Mix of KPIs, line charts, bar charts.
423
+ 5. USE CASE ALIGNMENT (15 pts): Titles and questions match {line} {function} at {company}.
424
 
425
+ Return ONLY valid JSON:
426
  {{"score": 0, "reasoning": "...", "strengths": ["..."], "weaknesses": ["..."]}}"""
 
427
  return _call_grader(prompt)
428
 
429
 
430
+ def run_ai_grading(run_context: dict, company: str, vertical: str, line: str, function: str) -> dict:
 
 
 
 
 
 
 
431
  result = {
432
+ "data_score": None, "data_points": 0.0,
433
+ "data_reasoning": "Not graded", "data_strengths": [], "data_weaknesses": [],
434
+ "liveboard_score": None, "liveboard_points": 0.0,
435
+ "liveboard_reasoning": "Not graded", "liveboard_strengths": [], "liveboard_weaknesses": [],
436
+ "grading_errors": [],
 
 
 
 
 
 
437
  }
438
 
439
+ ts_base = run_context.get("ts_base_url")
440
+ model_guid = run_context.get("model_guid")
441
+ lb_guid = run_context.get("liveboard_guid")
442
 
443
  if not ts_base or not model_guid:
444
+ result["grading_errors"].append("No model URL found — pipeline may not have completed")
 
 
445
  return result
446
 
 
447
  try:
448
  session = ts_authenticate(ts_base)
449
  except Exception as e:
450
  result["grading_errors"].append(f"ThoughtSpot auth failed: {e}")
451
  return result
452
 
453
+ # Data quality
454
  try:
455
  print(" 🔍 Exporting model TML...")
456
  model_tml = export_tml(ts_base, session, model_guid)
457
  db, schema = extract_db_schema(model_tml)
458
+ sample = get_snowflake_sample(db, schema) if db and schema else "Could not determine db/schema"
 
 
 
 
 
 
 
459
  print(" 🤖 Grading data quality...")
460
+ dg = grade_data_quality(company, vertical, line, function, model_tml, sample)
461
  score = max(0, min(100, int(dg.get("score", 0))))
462
+ result.update({
463
+ "data_score": score, "data_points": round(score * 0.50, 1),
464
+ "data_reasoning": dg.get("reasoning", ""),
465
+ "data_strengths": dg.get("strengths", []),
466
+ "data_weaknesses": dg.get("weaknesses", []),
467
+ })
468
+ print(f" 📊 Data: {score}/100 → {result['data_points']} pts")
469
  except Exception as e:
470
  result["grading_errors"].append(f"Data grading failed: {e}")
471
 
472
+ # Liveboard quality
473
  if lb_guid:
474
  try:
475
  print(" 🔍 Exporting liveboard TML...")
476
  lb_tml = export_tml(ts_base, session, lb_guid)
477
  print(" 🤖 Grading liveboard quality...")
478
+ lg = grade_liveboard_quality(company, vertical, line, function, lb_tml)
479
  lb_score = max(0, min(100, int(lg.get("score", 0))))
480
+ result.update({
481
+ "liveboard_score": lb_score, "liveboard_points": round(lb_score * 0.25, 1),
482
+ "liveboard_reasoning": lg.get("reasoning", ""),
483
+ "liveboard_strengths": lg.get("strengths", []),
484
+ "liveboard_weaknesses": lg.get("weaknesses", []),
485
+ })
486
+ print(f" 📊 Liveboard: {lb_score}/100 → {result['liveboard_points']} pts")
487
  except Exception as e:
488
  result["grading_errors"].append(f"Liveboard grading failed: {e}")
489
  else:
490
+ result["grading_errors"].append("No liveboard GUID — liveboard may not have been created")
 
 
491
 
492
  return result
493
 
494
 
495
  # ---------------------------------------------------------------------------
496
+ # Stage grading
497
  # ---------------------------------------------------------------------------
498
  def grade_stages(stages: dict, config: dict) -> dict:
 
 
 
 
499
  weights = config["grading"]["stages"]
500
  breakdown = {}
501
  total = 0
 
502
  for key, weight in weights.items():
503
  status = stages.get(key, "unknown")
504
+ earned = weight if status == "complete" else (weight // 2 if status == "running" else 0)
 
 
 
 
 
505
  breakdown[key] = {"weight": weight, "earned": earned, "status": status}
506
  total += earned
 
507
  return {"stage_total": total, "breakdown": breakdown}
508
 
509
 
510
+ def compute_grade(score: float, config: dict) -> str:
 
 
511
  grade = "F"
512
+ for letter, threshold in sorted(config["grading"]["thresholds"].items(), key=lambda x: -x[1]):
513
+ if score >= threshold:
514
  grade = letter
515
  break
516
  return grade
 
520
  # Single test runner
521
  # ---------------------------------------------------------------------------
522
  def run_single_test(page: Page, test_case: dict, config: dict) -> dict:
523
+ timeout_sec = config["grading"]["timeout_minutes"] * 60
524
+ start = time.time()
 
 
 
 
 
 
525
 
526
  result = {
527
+ "name": test_case["name"], "type": test_case["type"],
528
+ "company": test_case.get("company", ""),
529
+ "vertical": test_case.get("vertical", ""), "line": test_case.get("line", ""),
530
+ "function": test_case.get("function", ""), "company_url": test_case.get("company_url", ""),
531
+ "stages": {}, "run_context": {}, "stage_grading": {}, "ai_grading": {},
532
+ "total_score": 0.0, "grade": "F",
533
+ "error": None, "timed_out": False, "duration_seconds": 0,
 
 
 
 
 
 
 
534
  }
535
 
536
  try:
537
+ submit_job(page, test_case)
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  print(f" ⏳ Monitoring pipeline (timeout: {config['grading']['timeout_minutes']}min)...")
539
 
540
  poll_interval = 15
 
545
  if stages != last_stages:
546
  done = [k for k, v in stages.items() if v == "complete"]
547
  running = [k for k, v in stages.items() if v == "running"]
548
+ print(f" ✓ {done} ▶ {running}")
 
549
  last_stages = stages
550
  if pipeline_finished(stages):
551
+ print(" ✅ Pipeline complete")
552
  break
553
  else:
554
  result["timed_out"] = True
555
+ print(f" ⏰ Timed out after {config['grading']['timeout_minutes']} min")
556
 
557
  result["stages"] = last_stages or read_progress(page)
558
 
559
+ # Extract GUIDs check body (progress panel may show URLs)
 
 
 
 
 
 
 
 
 
560
  run_ctx = extract_run_context(page)
561
  result["run_context"] = run_ctx
562
 
 
566
  try:
567
  result["stages"] = read_progress(page)
568
  except Exception:
569
+ pass
570
 
571
  result["duration_seconds"] = round(time.time() - start)
572
 
573
+ # Stage scoring
574
  sg = grade_stages(result["stages"], config)
575
  result["stage_grading"] = sg
576
 
577
+ # AI grading
578
  ag = {"data_points": 0.0, "liveboard_points": 0.0, "grading_errors": []}
579
  if result["run_context"].get("model_guid"):
580
  print(" 🔬 Running AI quality grading...")
581
  ag = run_ai_grading(
582
  result["run_context"],
583
+ result["company"], result["vertical"], result["line"], result["function"],
 
584
  )
585
  else:
586
  ag["grading_errors"].append("Skipped — no model GUID (pipeline did not complete)")
587
  result["ai_grading"] = ag
588
 
 
589
  total = sg["stage_total"] + ag.get("data_points", 0) + ag.get("liveboard_points", 0)
590
  result["total_score"] = round(total, 1)
591
  result["grade"] = compute_grade(total, config)
 
592
  return result
593
 
594
 
595
  # ---------------------------------------------------------------------------
596
+ # Results
597
  # ---------------------------------------------------------------------------
598
  def save_results(run: dict) -> Path:
599
  ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
600
  path = RESULTS_DIR / f"{ts}_quality_run.json"
601
  with open(path, "w") as f:
602
  json.dump(run, f, indent=2, default=str)
603
+ print(f"\n💾 Results: {path}")
604
  return path
605
 
606
 
607
  # ---------------------------------------------------------------------------
608
+ # Main
609
  # ---------------------------------------------------------------------------
610
+ def run_quality_suite(max_tests: int = None):
611
  if not TEST_USER or not TEST_PASSWORD:
612
  raise RuntimeError("TEST_USER and TEST_PASSWORD must be set in .env")
613
 
 
620
  f"{sum(1 for t in suite if t['type']=='fixed')} fixed "
621
  f"{sum(1 for t in suite if t['type']=='random')} random "
622
  f"{sum(1 for t in suite if t['type']=='ai_generated')} AI-generated")
623
+ print(f" Scoring: stages(25) + data(50) + liveboard(25) = 100 pts")
624
  print(f"{'='*62}")
 
625
  for i, tc in enumerate(suite, 1):
626
+ label = {"fixed": "🔒", "random": "🎲", "ai_generated": "🤖"}[tc["type"]]
627
+ print(f" [{i}] {label} {tc['name']}")
628
 
629
  run_id = str(uuid.uuid4())[:8]
630
  results = []
 
652
  result = run_single_test(page, test_case, config)
653
  results.append(result)
654
 
 
655
  ag = result["ai_grading"]
656
  sg = result["stage_grading"]
657
+ print(f" Stages: {sg.get('stage_total', 0)}/25")
658
  if ag.get("data_score") is not None:
659
+ print(f" Data: {ag['data_score']}/100 → {ag['data_points']} pts")
660
  if ag.get("liveboard_score") is not None:
661
+ print(f" Board: {ag['liveboard_score']}/100 → {ag['liveboard_points']} pts")
662
+ for err in ag.get("grading_errors", []):
663
+ print(f" ⚠️ {err}")
 
664
  print(f" TOTAL: {result['total_score']}/100 Grade: {result['grade']}"
665
  f" ({result['duration_seconds']}s)"
666
  f"{' ⏰ TIMEOUT' if result['timed_out'] else ''}"
 
669
  ctx.close()
670
  browser.close()
671
 
672
+ avg = round(sum(r["total_score"] for r in results) / len(results), 1) if results else 0
673
+ grade = compute_grade(avg, config)
 
 
674
 
675
  run = {
676
+ "run_id": run_id, "timestamp": datetime.now().isoformat(),
677
+ "avg_score": avg, "overall_grade": grade,
678
+ "test_count": len(results), "tests": results,
 
 
 
679
  }
 
680
  save_results(run)
681
 
 
682
  print(f"\n{'='*62}")
683
+ print(f" COMPLETE — Avg: {avg}/100 Grade: {grade}")
 
 
 
684
  for r in results:
685
  label = {"fixed": "🔒", "random": "🎲", "ai_generated": "🤖"}[r["type"]]
686
  ag = r["ai_grading"]
687
  ds = f"{ag['data_score']}/100" if ag.get("data_score") is not None else "n/a"
688
  ls = f"{ag['liveboard_score']}/100" if ag.get("liveboard_score") is not None else "n/a"
689
+ print(f" {label} {r['name']}")
690
+ print(f" {r['total_score']}/100 Grade:{r['grade']} data={ds} lb={ls} ({r['duration_seconds']}s)")
 
691
  print(f"{'='*62}\n")
 
692
  return run
693
 
694
 
 
 
 
695
  def test_quality_run():
 
696
  run = run_quality_suite()
697
+ assert run["overall_grade"] != "F", f"Quality run averaged {run['avg_score']}% — too many failures."
 
 
698
 
699
 
700
  if __name__ == "__main__":
701
+ import argparse
702
+ parser = argparse.ArgumentParser()
703
+ parser.add_argument("--count", type=int, default=0,
704
+ help="Run only N tests (default: all 6)")
705
+ args = parser.parse_args()
706
+ run_quality_suite(max_tests=args.count or None)
tests/quality_config.yaml CHANGED
@@ -1,31 +1,26 @@
1
  # ============================================================
2
  # DemoPrep Quality Test Configuration
3
  # ============================================================
4
- # All grading weights and test cases live here — change them
5
- # without touching any Python code.
6
 
7
  # ------------------------------------------------------------
8
  # Grading
9
- # Each stage is worth points. Scores sum to max_score.
10
- # Adjust weights to reflect what matters most.
 
11
  # ------------------------------------------------------------
12
  grading:
13
- # Stage completion points — these sum to 25.
14
- # AI quality grades supply the other 75 pts (data 50, liveboard 25).
15
  stages:
16
- research: 5 # Company researched successfully
17
- ddl: 7 # Schema generated
18
- data: 8 # Data populated in Snowflake
19
- thoughtspot: 5 # Model deployed to ThoughtSpot
20
 
21
- # AI quality weights — each LLM score (0-100) is multiplied by weight/100
22
  ai_weights:
23
- data_quality: 50 # LLM grades model TML + Snowflake sample
24
- liveboard_quality: 25 # LLM grades liveboard TML
25
 
26
- max_score: 100 # stages(25) + data_quality(50) + liveboard_quality(25)
27
 
28
- # Letter grade thresholds (%)
29
  thresholds:
30
  A: 90
31
  B: 75
@@ -33,59 +28,75 @@ grading:
33
  D: 40
34
  F: 0
35
 
36
- # How long to wait per test before declaring timeout
37
  timeout_minutes: 45
38
 
39
  # ------------------------------------------------------------
40
- # Fixed tests — same company/use case every run.
41
- # Use these as regression baselines.
 
42
  # ------------------------------------------------------------
43
  fixed_tests:
44
  - name: "Nike — Retail Sales"
45
- prompt: "nike.com, Retail Sales"
46
  company: "Nike"
47
- use_case: "Retail Sales"
 
 
48
 
49
  - name: "Wells Fargo — Banking Marketing"
50
- prompt: "wellsfargo.com, Banking Marketing"
51
  company: "Wells Fargo"
52
- use_case: "Banking Marketing"
 
 
53
 
54
  # ------------------------------------------------------------
55
- # Random pool — we pick a use case at random, then ask the LLM
56
- # to select a well-known company whose industry matches it.
57
  # ------------------------------------------------------------
58
  random_pool:
59
  use_cases:
60
- - "Retail Sales"
61
- - "Retail Supply Chain"
62
- - "Banking Sales"
63
- - "Banking Supply Chain"
64
- - "Software Sales"
65
- - "Software Supply Chain"
 
 
 
 
66
 
67
- # Prompt sent to the LLM to pick a matching company.
68
- # {use_case} and {exclude_list} are filled in at runtime.
69
  company_prompt: |
70
- Pick a well-known company whose primary industry matches the use case: {use_case}.
71
- Do NOT pick any company in this list: {exclude_list}.
 
72
  Return ONLY valid JSON, no other text:
73
- {{"company": "...", "company_url": "domain.com", "use_case": "{use_case}", "prompt": "domain.com, {use_case}"}}
74
 
75
  # ------------------------------------------------------------
76
- # AI-generated tests — Claude picks a novel company + use case.
77
- # Count must match what e2e_quality.py expects (default: 2).
78
  # ------------------------------------------------------------
79
  ai_generated:
80
  count: 2
81
- # Prompt sent to Claude to generate a test case.
82
- # Must return valid JSON with keys: company, company_url, use_case, prompt
83
  generation_prompt: |
84
- You are generating test cases for a ThoughtSpot demo builder.
85
- Pick a well-known company that is NOT in this list: Nike, Salesforce, Amazon, Target,
86
- JPMorgan Chase, Microsoft, Pfizer, Walmart, Delta Airlines, UPS.
87
- Also pick an analytics use case from: Retail Sales, Retail Supply Chain,
88
- Banking Sales, Banking Supply Chain, Software Sales, Software Supply Chain.
89
- Choose a company whose industry matches the use case.
 
 
 
 
 
 
 
 
 
 
 
 
90
  Return ONLY valid JSON, no other text:
91
- {"company": "...", "company_url": "domain.com", "use_case": "...", "prompt": "domain.com, Use Case"}
 
1
  # ============================================================
2
  # DemoPrep Quality Test Configuration
3
  # ============================================================
 
 
4
 
5
  # ------------------------------------------------------------
6
  # Grading
7
+ # Stage completion = 25 pts total.
8
+ # AI quality grades supply the other 75 pts (data 50, liveboard 25).
9
+ # Each AI grade is 0-100, multiplied by weight/100 for points.
10
  # ------------------------------------------------------------
11
  grading:
 
 
12
  stages:
13
+ research: 5
14
+ ddl: 7
15
+ data: 8
16
+ thoughtspot: 5
17
 
 
18
  ai_weights:
19
+ data_quality: 50
20
+ liveboard_quality: 25
21
 
22
+ max_score: 100
23
 
 
24
  thresholds:
25
  A: 90
26
  B: 75
 
28
  D: 40
29
  F: 0
30
 
 
31
  timeout_minutes: 45
32
 
33
  # ------------------------------------------------------------
34
+ # Fixed tests — same every run, regression baselines.
35
+ # vertical/line must match values in demo_personas.VERTICAL_LINES.
36
+ # function must match DEMO_FUNCTIONS: Sales, Marketing, Finance, HR, IT, Legal
37
  # ------------------------------------------------------------
38
  fixed_tests:
39
  - name: "Nike — Retail Sales"
40
+ company_url: "nike.com"
41
  company: "Nike"
42
+ vertical: "Retail & Consumer Goods"
43
+ line: "Fashion/Apparel"
44
+ function: "Sales"
45
 
46
  - name: "Wells Fargo — Banking Marketing"
47
+ company_url: "wellsfargo.com"
48
  company: "Wells Fargo"
49
+ vertical: "Financial Services"
50
+ line: "Banking"
51
+ function: "Marketing"
52
 
53
  # ------------------------------------------------------------
54
+ # Random pool — pick a use case combo, AI selects the company.
 
55
  # ------------------------------------------------------------
56
  random_pool:
57
  use_cases:
58
+ - { vertical: "Retail & Consumer Goods", line: "Department Stores", function: "Sales", label: "Retail Department Store Sales" }
59
+ - { vertical: "Retail & Consumer Goods", line: "Consumer Electronics", function: "Marketing", label: "Consumer Electronics Marketing" }
60
+ - { vertical: "Retail & Consumer Goods", line: "Grocery", function: "Finance", label: "Grocery Finance" }
61
+ - { vertical: "Financial Services", line: "Insurance", function: "Sales", label: "Insurance Sales" }
62
+ - { vertical: "Financial Services", line: "Asset & Wealth Management", function: "Marketing", label: "Wealth Management Marketing" }
63
+ - { vertical: "Technology", line: "Software as a Service", function: "Sales", label: "SaaS Sales" }
64
+ - { vertical: "Technology", line: "Software as a Service", function: "Marketing", label: "SaaS Marketing" }
65
+ - { vertical: "Transportation & Logistics", line: "Shipping", function: "Finance", label: "Shipping Finance" }
66
+ - { vertical: "Healthcare & Life Sciences", line: "Life Sciences", function: "Sales", label: "Life Sciences Sales" }
67
+ - { vertical: "Manufacturing", line: "Automotive", function: "Sales", label: "Automotive Sales" }
68
 
69
+ # Prompt for AI to pick a matching company for the chosen use case.
 
70
  company_prompt: |
71
+ Pick a well-known company that is a strong fit for this use case: {label}.
72
+ Vertical: {vertical}, Line: {line}, Function: {function}.
73
+ Do NOT pick: {exclude_list}.
74
  Return ONLY valid JSON, no other text:
75
+ {{"company": "...", "company_url": "domain.com"}}
76
 
77
  # ------------------------------------------------------------
78
+ # AI-generated tests — LLM picks vertical, line, function, AND company.
 
79
  # ------------------------------------------------------------
80
  ai_generated:
81
  count: 2
 
 
82
  generation_prompt: |
83
+ You are generating a test case for a ThoughtSpot demo builder.
84
+
85
+ Pick a well-known company and a matching analytics use case.
86
+ The use case must be expressed as a vertical, line, and function from these options:
87
+
88
+ Verticals and their lines:
89
+ - "Retail & Consumer Goods": Fashion/Apparel, Consumer Electronics, Department Stores, Grocery, Specialty Retail
90
+ - "Financial Services": Banking, Insurance, Asset & Wealth Management
91
+ - "Technology": Software as a Service, Cybersecurity, Hardware, Artificial Intelligence, IT Services
92
+ - "Healthcare & Life Sciences": Life Sciences, Healthcare Providers, Healthcare Payers
93
+ - "Transportation & Logistics": Shipping, Supply Chain Management, Air Transport, Trucking, Freight
94
+ - "Manufacturing": Automotive, Aerospace, Chemical Production, Electronics Manufacturing
95
+
96
+ Functions: Sales, Marketing, Finance, HR, IT, Legal
97
+
98
+ Do NOT pick: Nike, Wells Fargo.
99
+ Choose a company whose industry clearly matches the vertical and line.
100
+
101
  Return ONLY valid JSON, no other text:
102
+ {{"company": "...", "company_url": "domain.com", "vertical": "...", "line": "...", "function": "..."}}