test-demoprep / tests /e2e_quality.py
mikeboone's picture
fix(e2e): remove remaining hardcoded DEMOBUILD refs in test harnesses
a22a600
Raw
History Blame Contribute Delete
105 kB
"""
Quality regression test suite for DemoPrep.
Runs 6 pipeline tests against the live HF Space using the form UI:
- 2 fixed (same every run — regression baselines)
- 2 random (use case picked from pool, AI selects matching company)
- 2 AI-generated (AI picks vertical, line, function, and company)
Scoring (100 pts total):
- Stage completion → up to 25 pts (research 5, ddl 7, data 8, thoughtspot 5)
- Data quality → up to 50 pts (LLM grades model TML + Snowflake sample, 0-100 scaled)
- Liveboard quality → up to 25 pts (LLM grades liveboard TML, 0-100 scaled)
Usage:
source demoprep/bin/activate
python tests/e2e_quality.py
"""
import json
import os
import random
import re
import sys
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
import requests
import yaml
from dotenv import load_dotenv
from playwright.sync_api import Page, sync_playwright
sys.path.insert(0, str(Path(__file__).parent.parent))
from llm_config import DEFAULT_LLM_MODEL
# ---------------------------------------------------------------------------
# Setup
# ---------------------------------------------------------------------------
load_dotenv(Path(__file__).parent.parent / ".env")
BASE_URL = os.getenv("TEST_TARGET_URL", "") # may be overridden by --url flag at runtime
TEST_USER = os.getenv("TEST_USER")
TEST_PASSWORD = os.getenv("TEST_PASSWORD")
TEST_NEW_PASSWORD = os.getenv("TEST_NEW_PASSWORD", "")
CONFIG_FILE = Path(__file__).parent / "quality_config.yaml"
RESULTS_DIR = Path(__file__).parent / "quality_results"
RESULTS_DIR.mkdir(exist_ok=True)
DRY_RUN = False # set to True via --dry-run; fills form but does not click GO
STAGE_LABELS = {
"research": "Research",
"ddl": "DDL",
"data": "Data",
"thoughtspot": "ThoughtSpot",
"complete": "Complete",
}
# ---------------------------------------------------------------------------
# Settings applied to every quality run via the Settings accordion in the UI.
# Change these here to adjust what the test runner uses.
# ---------------------------------------------------------------------------
RUN_SETTINGS = {
"data_size": "Medium", # Small=1k/50dim · Medium=10k/500dim
"column_naming": "Regular Case",
"tag_name": "TR",
"object_prefix": "tst",
"share_with": "mike.boone@thoughtspot.com",
"geo_scope": "USA Only",
"ai_model": "claude-sonnet-4-6", # model used for this test run
"ts_environment": "sebe - se", # se-cloud having issues; run on sebe
}
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
def load_config() -> dict:
with open(CONFIG_FILE) as f:
return yaml.safe_load(f)
# ---------------------------------------------------------------------------
# LLM helper (uses app's configured LLM)
# ---------------------------------------------------------------------------
def _get_researcher():
from main_research import MultiLLMResearcher
from llm_config import map_llm_display_to_provider
provider, model = map_llm_display_to_provider(RUN_SETTINGS["ai_model"])
return MultiLLMResearcher(provider=provider, model=model)
def _llm(prompt: str, max_tokens: int = 300) -> str:
researcher = _get_researcher()
return (researcher.make_request(
[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
stream=False,
) or "").strip()
def _parse_json(text: str) -> dict:
match = re.search(r'\{.*\}', text, re.DOTALL)
if not match:
raise ValueError(f"No JSON found in: {text[:200]}")
return json.loads(match.group())
# ---------------------------------------------------------------------------
# Test case generation
# ---------------------------------------------------------------------------
def generate_ai_test_case(config: dict, exclude_list: list = None) -> dict:
"""AI picks vertical, line, function, and a matching company."""
prompt = config["ai_generated"]["generation_prompt"]
exclude_str = ", ".join(exclude_list) if exclude_list else "none"
prompt = prompt.replace("{exclude_list}", exclude_str)
data = _parse_json(_llm(prompt))
return {
"name": f"AI: {data['company']}{data['vertical']} / {data['line']} / {data['function']}",
"type": "ai_generated",
"company": data["company"],
"company_url": data["company_url"],
"vertical": data["vertical"],
"line": data["line"],
"function": data["function"],
}
def pick_random_test_case(config: dict, used_labels: set, exclude_companies: list = None) -> dict:
"""Pick a use case from the pool and select a company.
If the pool entry has a `companies` list, pick one at random — no LLM call.
Falls back to LLM company selection only when no list is present.
"""
pool = config["random_pool"]["use_cases"]
template = config["random_pool"]["company_prompt"]
exclude_companies = exclude_companies or []
available = [uc for uc in pool if uc["label"] not in used_labels]
if not available:
# All labels used — reset and allow repeats (different company still possible)
available = pool
uc = random.choice(available)
used_labels.add(uc["label"])
# Prefer the pre-seeded companies list — avoids LLM call and ensures variety
if uc.get("companies"):
candidates = [c for c in uc["companies"] if c["company"] not in exclude_companies]
if not candidates:
candidates = uc["companies"] # all used — allow repeats rather than failing
chosen = random.choice(candidates)
company, company_url = chosen["company"], chosen["url"]
else:
exclude = ", ".join(exclude_companies)
prompt = template.format(
label=uc["label"],
vertical=uc["vertical"],
line=uc["line"],
function=uc["function"],
exclude_list=exclude,
)
try:
data = _parse_json(_llm(prompt))
company, company_url = data["company"], data["company_url"]
except Exception as e:
print(f" ⚠️ Company selection failed ({e}), using fallback")
company, company_url = uc["label"].split()[0], "example.com"
return {
"name": f"Pool: {company}{uc['label']}",
"type": "random",
"company": company,
"company_url": company_url,
"vertical": uc["vertical"],
"line": uc["line"],
"function": uc["function"],
}
def pick_custom_pool_test_case(config: dict, exclude_companies: list = None) -> dict:
"""Pick a Professional Services company from the custom_pool for the Custom tab."""
pool = config.get("custom_pool", [])
exclude_companies = exclude_companies or []
candidates = [c for c in pool if c["company"] not in exclude_companies]
if not candidates:
candidates = pool
entry = random.choice(candidates)
return {
"name": f"Custom: {entry['company']} — Professional Services",
"type": "custom",
"company": entry["company"],
"company_url": entry["url"],
"vertical": "* CUSTOM *",
"line": "",
"function": "",
"context": entry["context"].strip(),
}
def build_test_suite(config: dict) -> list:
"""Build an 8-test suite: 2 fixed + 4 pool + 2 custom (Pro Services).
Fixed baselines catch regressions — same companies every run.
Pool rotates companies so the pipeline can't be tuned to specific names.
Custom covers Professional Services (no app vertical match).
"""
suite = []
used_labels = set()
used_companies: list[str] = []
# 2 fixed baselines — same every run, regression anchors
for tc in config.get("fixed_tests", []):
suite.append({**tc, "type": "fixed"})
used_companies.append(tc["company"])
# 4 pool picks — random company from companies list, no repeats
for _ in range(4):
tc = pick_random_test_case(config, used_labels, exclude_companies=used_companies)
used_companies.append(tc["company"])
suite.append(tc)
# 2 custom — Professional Services (40 customers, no app vertical match)
for _ in range(2):
tc = pick_custom_pool_test_case(config, exclude_companies=used_companies)
used_companies.append(tc["company"])
suite.append(tc)
random.shuffle(suite)
return suite
# ---------------------------------------------------------------------------
# Form interaction helpers
# ---------------------------------------------------------------------------
def select_gradio_dropdown(page: Page, label: str, value: str):
"""Select a value from a Gradio dropdown using aria-label (confirmed from DOM inspection)."""
inp = page.locator(f'input[aria-label="{label}"]').first
try:
current_value = (inp.input_value(timeout=1000) or "").strip()
if current_value == value:
return
except Exception:
pass
inp.click(timeout=5000)
page.wait_for_timeout(300)
option = page.get_by_role('option', name=value, exact=True)
try:
option.click(timeout=5000)
except Exception as e:
visible_options = page.locator('[role="option"]').all_inner_texts()
raise RuntimeError(
f"Dropdown {label!r} does not contain {value!r}. "
f"Visible options: {visible_options}"
) from e
page.wait_for_timeout(300)
def _fill_textbox(page: Page, placeholder: str, value: str):
"""Fill a Gradio Textbox by placeholder using JS native setter to trigger Svelte reactivity."""
page.evaluate("""
(args) => {
const els = document.querySelectorAll('textarea[placeholder="' + args.placeholder + '"]');
const el = Array.from(els).find(e => e.offsetParent !== null) || els[0];
if (!el) return;
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
setter.call(el, args.value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
""", {"placeholder": placeholder, "value": value})
page.wait_for_timeout(150)
def _select_dropdown_force(page: Page, label: str, value: str):
"""Select a Gradio dropdown value, forcing click even if hidden."""
inp = page.locator(f'input[aria-label="{label}"]').first
inp.click(force=True, timeout=5000)
page.wait_for_timeout(300)
page.get_by_role('option', name=value, exact=True).click(timeout=5000)
page.wait_for_timeout(200)
def _open_settings_accordion(page: Page) -> bool:
"""
Click the Settings accordion open. Returns True if open, False if it couldn't be opened.
"""
data_size_input = page.locator('input[aria-label="Data Size"]').first
try:
if data_size_input.is_visible(timeout=500):
return True # already open
except Exception:
pass
for selector in [
'button:has-text("⚙️ Settings"):not([role=tab])',
'button:has-text("⚙ Settings"):not([role=tab])',
'button[aria-expanded]:has-text("Settings")',
]:
try:
btn = page.locator(selector).first
if btn.count() > 0:
btn.click(timeout=3000)
page.wait_for_timeout(400)
if data_size_input.is_visible(timeout=2000):
return True
except Exception:
continue
return False
def apply_run_settings(page: Page, lb_name: str = "", tag_name: str = ""):
"""
Open the Settings accordion and apply all RUN_SETTINGS values plus liveboard name.
Called once, after all form dropdowns are filled — avoids the accordion being
collapsed by a Gradio re-render triggered by Vertical/Line/Function selection.
If the accordion can't be opened, skips settings rather than force-clicking hidden
elements (which can open dangling dropdowns that block the GO button).
"""
opened = _open_settings_accordion(page)
if not opened:
print(" ⚠️ Settings accordion could not be opened — skipping settings")
return
try:
# Liveboard name — inside the accordion
if lb_name:
for placeholder in [
"Auto from company URL if blank",
"Auto-generated if blank",
]:
try:
el = page.locator(f'textarea[placeholder="{placeholder}"]').first
if el.is_visible(timeout=1000):
el.click(click_count=3, timeout=2000)
el.fill(lb_name)
page.wait_for_timeout(200)
break
except Exception:
continue
_select_dropdown_force(page, "Data Size", RUN_SETTINGS["data_size"])
_select_dropdown_force(page, "Geographic Scope", RUN_SETTINGS["geo_scope"])
_select_dropdown_force(page, "Column Naming Style", RUN_SETTINGS["column_naming"])
page.wait_for_timeout(500) # let Svelte settle after dropdown changes
_fill_textbox(page, "e.g. Sales_Demo (blank = no tag)", tag_name or RUN_SETTINGS["tag_name"])
_fill_textbox(page, "e.g. ACME_ (blank = none)", RUN_SETTINGS["object_prefix"])
_fill_textbox(page, "user@company.com or group-name (blank = no share)", RUN_SETTINGS["share_with"])
except Exception as e:
print(f" ⚠️ Settings error: {e}")
def _do_login(page: Page):
"""Fill and submit the login form, then wait for tabs."""
page.fill('input[type=text]', TEST_USER)
page.fill('input[type=password]', TEST_PASSWORD)
page.click('button:has-text("Login")')
_wait_for_visible_app_or_auth_control(page, include_login=False, timeout=90000)
_handle_forced_password_change(page)
page.wait_for_selector('button[role=tab]', timeout=90000)
page.wait_for_timeout(3000)
def _wait_for_visible_app_or_auth_control(page: Page, *, include_login: bool, timeout: int):
"""Wait until a visible app tab or auth control is present."""
page.wait_for_function(
"""
({ includeLogin }) => {
const visible = (el) => !!(
el &&
(el.offsetWidth || el.offsetHeight || el.getClientRects().length)
);
const hasVisibleTab = Array.from(document.querySelectorAll('button[role="tab"]'))
.some(visible);
if (hasVisibleTab) return true;
const buttons = Array.from(document.querySelectorAll('button'))
.filter(visible)
.map((button) => (button.textContent || '').trim());
if (buttons.some((text) => text.includes('Change Password'))) return true;
if (includeLogin && buttons.some((text) => text.includes('Login'))) return true;
return false;
}
""",
arg={"includeLogin": include_login},
timeout=timeout,
)
def _handle_forced_password_change(page: Page):
"""Handle or explicitly fail on the app's temporary-password gate."""
try:
gate = page.get_by_text("Change Password Required", exact=False)
if not gate.is_visible(timeout=1500):
return
except Exception:
return
if not TEST_NEW_PASSWORD:
raise RuntimeError(
"Test user is blocked by the temporary-password gate. "
"Clear must_change_password for TEST_USER or set TEST_NEW_PASSWORD "
"so the harness can complete the required password change."
)
page.locator('input[placeholder="Enter the password you just used to sign in"]').first.fill(TEST_PASSWORD)
page.locator('input[placeholder="At least 8 characters"]').first.fill(TEST_NEW_PASSWORD)
page.locator('input[placeholder="Repeat new password"]').first.fill(TEST_NEW_PASSWORD)
page.click('button:has-text("Change Password")', timeout=5000)
page.wait_for_selector('button[role=tab]', timeout=30000)
def _navigate_and_ensure_logged_in(page: Page, max_wait_secs: int = 300):
"""
Navigate to BASE_URL and ensure we're on the logged-in app.
Handles: HF space sleeping/rebuilding after a long test, session expiry.
Retries for up to max_wait_secs before raising.
"""
deadline = time.time() + max_wait_secs
attempt = 0
while True:
attempt += 1
try:
page.goto(BASE_URL, timeout=90000)
# Wait for either the logged-in app (tabs) or the login form
_wait_for_visible_app_or_auth_control(page, include_login=True, timeout=60000)
break
except Exception as nav_err:
remaining = int(deadline - time.time())
if remaining <= 0:
raise RuntimeError(
f"Space not reachable after {max_wait_secs}s: {nav_err}"
) from nav_err
print(f" ⏳ App still loading (attempt {attempt}) — waiting 30s ({remaining}s left)...")
time.sleep(30)
# If we landed on the login page (session expired or space rebuilt), re-login
try:
if page.locator('button:has-text("Login")').is_visible(timeout=2000):
print(" 🔑 Session expired — re-logging in...")
_do_login(page)
except Exception:
pass # Already on the app — no login needed
_handle_forced_password_change(page)
def submit_job(page: Page, test_case: dict):
"""Fill the form and click GO."""
# Navigate, re-logging in if the session expired (e.g. after a long prior test)
_navigate_and_ensure_logged_in(page)
page.wait_for_timeout(2000)
page.click('button[role=tab]:has-text("📱 App")', timeout=10000)
page.wait_for_timeout(500)
page.get_by_role('tab', name='App', exact=True).click(timeout=10000)
page.wait_for_timeout(1000)
lb_name = f"QA — {test_case['company']} {test_case.get('function', 'Demo')}"
# AI Model and TS Environment — always visible, set before form dropdowns
select_gradio_dropdown(page, "AI Model", RUN_SETTINGS["ai_model"])
select_gradio_dropdown(page, "TS Environment", RUN_SETTINGS["ts_environment"])
# Select vertical (always set)
select_gradio_dropdown(page, "Vertical", test_case["vertical"])
if test_case["vertical"] == "* CUSTOM *":
# Custom mode: wait for UI to settle after vertical dropdown change, then fill Context
page.wait_for_timeout(1500)
ctx_el = None
for sel in [
'textarea[placeholder="Describe your use case, industry, and key metrics..."]',
'[placeholder="Describe your use case, industry, and key metrics..."]',
'textarea[aria-label="Context *"]',
'textarea[aria-label="Context"]',
]:
try:
el = page.locator(sel).first
if el.is_visible(timeout=3000):
ctx_el = el
break
except Exception:
pass
if ctx_el is None:
raise Exception('Context textarea not found — tried placeholder and aria-label="Context"')
ctx_el.click(click_count=3, timeout=5000)
ctx_el.fill(test_case.get("context", ""))
page.wait_for_timeout(300)
else:
select_gradio_dropdown(page, "Line", test_case["line"])
select_gradio_dropdown(page, "Function", test_case["function"])
# Fill company URL — textarea with placeholder 'e.g. Amazon.com'
url_el = page.locator('textarea[placeholder="e.g. Amazon.com"]')
url_el.click(click_count=3, timeout=5000)
url_el.fill(test_case["company_url"])
page.wait_for_timeout(300)
# Generate a unique tag for this test case. Logs are diagnostic-only; the
# completed page's model/liveboard URLs identify the run under test.
test_tag = f"TR-{uuid.uuid4().hex[:8].upper()}"
test_case["_test_tag"] = test_tag # store so run_single_test can pass it to diagnostics
# Apply run settings + liveboard name — accordion opened once, after all form dropdowns
apply_run_settings(page, lb_name=lb_name, tag_name=test_tag)
# Click GO (skipped in dry-run mode)
if DRY_RUN:
print(f" 🔍 DRY RUN — form filled, pausing 120s so you can inspect the browser...")
print(f" Vertical={test_case['vertical']} Line={test_case.get('line')} Function={test_case.get('function')}")
print(f" URL={test_case['company_url']} lb={lb_name}")
print(f" Settings: {RUN_SETTINGS}")
time.sleep(120)
print(f" ⏭️ Skipping GO — dry run complete")
return
page.click('button:has-text("→ GO")', timeout=10000)
print(f" ✅ Form submitted: {test_case['vertical']} / {test_case['line']} / {test_case['function']}{test_case['company_url']} | lb: {lb_name}")
# ---------------------------------------------------------------------------
# Pipeline stage detection
# ---------------------------------------------------------------------------
def read_progress(page: Page) -> dict:
"""
Read pipeline progress from the right-side progress panel.
Returns stage_key -> 'complete' | 'running' | 'not_started' | 'unknown'
"""
# Stay on App tab — progress panel is on the right side
try:
page.click('button[role=tab]:has-text("📱 App")', timeout=5000)
page.wait_for_timeout(500)
page.get_by_role('tab', name='App', exact=True).click(timeout=3000)
page.wait_for_timeout(300)
except Exception:
pass
progress_text = page.inner_text('body')
stages = {}
for key, label in STAGE_LABELS.items():
if f"✓ {label}" in progress_text or f"✅ {label}" in progress_text:
stages[key] = "complete"
elif f"▶ {label}" in progress_text:
stages[key] = "running"
elif f"○ {label}" in progress_text:
stages[key] = "not_started"
else:
stages[key] = "unknown"
return stages
def pipeline_finished(stages: dict) -> bool:
# Done if app shows "Complete", OR if all 4 main stages are marked complete
if stages.get("complete") == "complete":
return True
main_stages = ("research", "ddl", "data", "thoughtspot")
return all(stages.get(s) == "complete" for s in main_stages)
# ---------------------------------------------------------------------------
# Post-run GUID extraction
# ---------------------------------------------------------------------------
def extract_run_context(page: Page) -> dict:
"""
After completion, find model and liveboard URLs in the page.
Uses visible text plus full HTML so GUIDs in href attributes are also
matched. This is the source of truth for the run under test.
"""
try:
visible = page.inner_text("body", timeout=5000)
except Exception:
visible = ""
body = f"{visible}\n{page.content()}" # full HTML catches GUIDs in href attrs too
model_match = re.search(r'(https://[^\s"\'<>#]+)/#/data/tables/([a-f0-9-]{36})', body)
lb_match = re.search(r'(https://[^\s"\'<>#]+)/#/pinboard/([a-f0-9-]{36})', body)
ts_base = None
if model_match:
ts_base = model_match.group(1)
elif lb_match:
ts_base = lb_match.group(1)
return {
"ts_base_url": ts_base,
"model_guid": model_match.group(2) if model_match else None,
"liveboard_guid": lb_match.group(2) if lb_match else None,
"source": "page",
}
# ---------------------------------------------------------------------------
# ThoughtSpot API helpers
# ---------------------------------------------------------------------------
def _find_ts_key_for_url(ts_base_url: str) -> str:
target = (ts_base_url or "").rstrip("/")
for i in range(1, 10):
url = os.getenv(f"TS_ENV_{i}_URL", "").rstrip("/")
key = os.getenv(f"TS_ENV_{i}_KEY_VAR", "")
if url and key and url == target:
return key
return os.getenv("TS_ENV_1_KEY_VAR", "")
def ts_authenticate(ts_base_url: str, username: str = None) -> requests.Session:
secret_key = _find_ts_key_for_url(ts_base_url)
if not secret_key:
raise RuntimeError(f"No trusted auth key found for {ts_base_url}")
auth_user = username or TEST_USER
session = requests.Session()
session.headers["Accept"] = "application/json"
resp = session.post(
f"{ts_base_url}/api/rest/2.0/auth/token/full",
json={"username": auth_user, "secret_key": secret_key, "validity_time_in_sec": 3600},
timeout=30,
)
resp.raise_for_status()
token = resp.json().get("token")
if token:
session.headers["Authorization"] = f"Bearer {token}"
return session
def export_tml(ts_base_url: str, session: requests.Session, guid: str) -> str:
"""Export TML for a liveboard or answer (JSON format)."""
resp = session.post(
f"{ts_base_url}/api/rest/2.0/metadata/tml/export",
json={"metadata": [{"identifier": guid}], "export_associated": False, "export_fqn": True},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
return data[0].get("edoc", "") if data else ""
def export_model_tml(ts_base_url: str, session: requests.Session, guid: str) -> str:
"""Export TML for a model (LOGICAL_TABLE) in YAML format — returns db/schema in tables[]."""
resp = session.post(
f"{ts_base_url}/api/rest/2.0/metadata/tml/export",
json={
"metadata": [{"identifier": guid, "type": "LOGICAL_TABLE"}],
"export_associated": False,
"export_fqn": True,
"format_type": "YAML",
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
edoc = data[0].get("edoc", "") if data else ""
return edoc
def export_model_related_tmls(ts_base_url: str, session: requests.Session, guid: str) -> list[str]:
"""Export model TML plus associated table TMLs so physical db/schema can be read directly."""
resp = session.post(
f"{ts_base_url}/api/rest/2.0/metadata/tml/export",
json={
"metadata": [{"identifier": guid, "type": "LOGICAL_TABLE"}],
"export_associated": True,
"export_fqn": True,
"format_type": "YAML",
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
return [item.get("edoc", "") for item in (data or []) if item.get("edoc")]
def _parse_db_schema_from_fqn(fqn: str) -> tuple[str, str]:
if not fqn or "." not in fqn:
return "", ""
quoted = re.findall(r'"([^"]+)"', fqn)
if len(quoted) >= 2:
return quoted[0], quoted[1]
parts = [p.strip().strip('"') for p in str(fqn).split(".") if p.strip()]
if len(parts) >= 3:
return parts[0], parts[1]
if len(parts) == 2:
return parts[0], parts[1]
return "", ""
def _walk_dicts(value):
if isinstance(value, dict):
yield value
for child in value.values():
yield from _walk_dicts(child)
elif isinstance(value, list):
for child in value:
yield from _walk_dicts(child)
def extract_db_schema(model_tml_str: str) -> tuple:
return extract_db_schema_from_tml_docs([model_tml_str])
def extract_db_schema_from_tml_docs(tml_docs: list[str]) -> tuple:
"""
Extract physical Snowflake db/schema from model or associated table TML.
This intentionally does not derive schema from naming convention.
"""
try:
for tml_str in tml_docs:
if not tml_str:
continue
tml = yaml.safe_load(tml_str) or {}
for node in _walk_dicts(tml):
table_node = node.get("table") if isinstance(node.get("table"), dict) else {}
db = (
node.get("db")
or node.get("database")
or node.get("database_name")
or node.get("db_name")
or table_node.get("db")
or table_node.get("database")
or table_node.get("database_name")
or table_node.get("db_name")
or ""
)
schema = (
node.get("schema")
or node.get("schema_name")
or table_node.get("schema")
or table_node.get("schema_name")
or ""
)
if db and schema:
return str(db), str(schema)
for key in ("fqn", "table_fqn", "physical_table", "db_table"):
fqn = node.get(key) or table_node.get(key)
db, schema = _parse_db_schema_from_fqn(str(fqn or ""))
if db and schema:
return db, schema
except Exception:
pass
return "", ""
def resolve_schema_from_model(run_context: dict) -> dict:
"""
Resolve the Snowflake schema from the exact ThoughtSpot model printed by
the app. No prefix/date guessing.
"""
ts_base = run_context.get("ts_base_url")
model_guid = run_context.get("model_guid")
if not ts_base or not model_guid:
return {"found": False, "reason": "missing model URL"}
try:
session = ts_authenticate(ts_base)
tml_docs = export_model_related_tmls(ts_base, session, model_guid)
db, schema = extract_db_schema_from_tml_docs(tml_docs)
if not schema:
return {"found": False, "reason": "model/associated table TML did not expose physical schema"}
return {"found": True, "database": db, "schema": schema}
except Exception as e:
return {"found": False, "reason": str(e)}
def get_snowflake_sample(db: str, schema: str) -> str:
try:
from snowflake_auth import get_snowflake_connection
conn = get_snowflake_connection()
cursor = conn.cursor()
cursor.execute(f'SHOW TABLES IN SCHEMA "{db}"."{schema}"')
tables = [row[1] for row in cursor.fetchall()]
# Count rows in every table first — so we can prioritize the fact table
row_counts = {}
for table in tables:
try:
cursor.execute(f'SELECT COUNT(*) FROM "{db}"."{schema}"."{table}"')
row_counts[table] = cursor.fetchone()[0]
except Exception:
row_counts[table] = 0
# Sort descending — fact table (most rows) sampled first
tables_sorted = sorted(tables, key=lambda t: row_counts.get(t, 0), reverse=True)
# Build row-count summary header so grader knows what's populated
header = ["Table row counts:"]
for t in tables_sorted:
header.append(f" {t}: {row_counts.get(t, 0)} rows")
empty_tables = [t for t in tables_sorted if row_counts.get(t, 0) == 0]
if empty_tables:
header.append(
f"\n⚠️ WARNING: {len(empty_tables)} table(s) have 0 rows: "
f"{', '.join(empty_tables)}"
)
parts = ["\n".join(header)]
# Sample from tables that actually have data (up to 6); fall back to first 3 if all empty
tables_with_data = [t for t in tables_sorted if row_counts.get(t, 0) > 0]
to_sample = tables_with_data[:6] if tables_with_data else tables_sorted[:3]
synthetic_hits = []
for table in to_sample:
try:
cursor.execute(f'SELECT * FROM "{db}"."{schema}"."{table}" LIMIT 200')
cols = [d[0] for d in cursor.description]
rows = cursor.fetchall()
parts.append(
f"\nTable: {table} "
f"({row_counts.get(table, 0)} total rows, {min(len(rows), 15)} displayed / {len(rows)} scanned)"
)
parts.append(f"Columns: {', '.join(cols)}")
for row in rows:
for col, value in zip(cols, row):
if isinstance(value, str) and SYNTHETIC_NUMERIC_SUFFIX_RE.search(value.strip()):
synthetic_hits.append((table, col, value.strip()))
for row in rows[:15]:
parts.append(" " + str(dict(zip(cols, row))))
except Exception as e:
parts.append(f"\nTable: {table} — error: {e}")
if synthetic_hits:
parts.append("\nDATA QUALITY HARD FAIL CANDIDATES:")
for table, col, value in synthetic_hits[:25]:
parts.append(f" {{'TABLE': '{table}', 'COLUMN': '{col}', 'SYNTHETIC_VALUE': '{value}'}}")
cursor.close()
conn.close()
return "\n".join(parts) if parts else "No tables found"
except Exception as e:
return f"Snowflake connection failed: {e}"
# ---------------------------------------------------------------------------
# AI quality grading
# ---------------------------------------------------------------------------
def _extract_grader_json(text: str) -> dict:
"""Find the first complete JSON object containing a 'score' key."""
decoder = json.JSONDecoder()
idx = 0
while idx < len(text):
brace = text.find('{', idx)
if brace == -1:
break
try:
obj, _ = decoder.raw_decode(text, brace)
if isinstance(obj, dict) and 'score' in obj:
return obj
except json.JSONDecodeError:
pass
idx = brace + 1
raise ValueError(f"No JSON with 'score' key in: {text[:200]}")
def _call_grader(prompt: str, max_retries: int = 3) -> dict:
last_raw = ""
for attempt in range(max_retries):
try:
raw = _llm(prompt, max_tokens=2000)
last_raw = raw
return _extract_grader_json(raw)
except (json.JSONDecodeError, ValueError, Exception):
pass
if attempt < max_retries - 1:
time.sleep(3)
print(f" ❌ Grader parse failed — raw response: {last_raw[:300]!r}")
return {"score": 0, "reasoning": "Could not parse response after retries",
"strengths": [], "weaknesses": [last_raw[:200]]}
SYNTHETIC_NUMERIC_SUFFIX_RE = re.compile(
r"\b(?:"
r"north|south|east|west|central|northeast|northwest|southeast|southwest|"
r"route|corridor|express|lane|zone|region|market|segment|category|"
r"customer|account|vendor|supplier|warehouse|store|location|product|"
r"service|plan|item|team"
r")\b(?:[\w\s&/-]{0,80})\s+\d{1,4}$",
re.IGNORECASE,
)
SYNTHETIC_DISTINCT_FAIL_THRESHOLD = 5
SYNTHETIC_OCCURRENCE_FAIL_THRESHOLD = 10
SYNTHETIC_WARNING_SCORE_CAP = 80
SYNTHETIC_FAILURE_SCORE = 45
def detect_synthetic_dimension_values(sample_data: str) -> dict:
"""Find generic dimension values like 'North Corridor Route 31'."""
if not sample_data:
return {
"fail": False,
"warn": False,
"examples": [],
"count": 0,
"occurrences": 0,
}
offenders = []
for match in re.finditer(r":\s*'([^']+)'", sample_data):
value = match.group(1).strip()
if SYNTHETIC_NUMERIC_SUFFIX_RE.search(value):
offenders.append(value)
for match in re.finditer(r':\s*"([^"]+)"', sample_data):
value = match.group(1).strip()
if SYNTHETIC_NUMERIC_SUFFIX_RE.search(value):
offenders.append(value)
distinct_offenders = sorted(set(offenders))
fail = (
len(distinct_offenders) >= SYNTHETIC_DISTINCT_FAIL_THRESHOLD
or len(offenders) >= SYNTHETIC_OCCURRENCE_FAIL_THRESHOLD
)
return {
"fail": fail,
"warn": bool(offenders),
"examples": distinct_offenders[:12],
"count": len(distinct_offenders),
"occurrences": len(offenders),
}
def grade_data_quality(company: str, vertical: str, line: str, function: str,
model_tml: str, sample_data: str) -> dict:
synthetic_check = detect_synthetic_dimension_values(sample_data)
if synthetic_check["fail"]:
examples = ", ".join(synthetic_check["examples"])
return {
"score": SYNTHETIC_FAILURE_SCORE,
"reasoning": (
"Automatic data-quality failure: Snowflake sample contains generic "
f"synthetic dimension values ending in numbers, such as {examples}. "
f"Detected {synthetic_check['occurrences']} occurrences across "
f"{synthetic_check['count']} distinct values. This is a data realism failure."
),
"strengths": [],
"weaknesses": [
"Synthetic numeric-suffix dimension values detected",
*synthetic_check["examples"],
],
"synthetic_dimension_failure": synthetic_check,
}
synthetic_warning = ""
if synthetic_check["warn"]:
examples = ", ".join(synthetic_check["examples"])
synthetic_warning = (
"\n\nDETERMINISTIC DATA QUALITY WARNING:\n"
f"Detected {synthetic_check['occurrences']} generic numeric-suffix "
f"dimension value(s), including {examples}. This is not an automatic "
"failure at this volume, but it should reduce realism/story quality.\n"
)
today = datetime.now().strftime("%Y-%m-%d")
prompt = f"""You are grading a ThoughtSpot demo dataset.
Company: {company}
Vertical: {vertical} / {line}
Analytics function: {function}
Today's date is {today}. Treat any date on or before today as HISTORICAL — do NOT
penalize current-year or recent dates as "future-dated"; the demo is built to run today.
The goal is a compelling demo with realistic data, outliers that drive a narrative,
and a schema that supports the key KPIs for this use case.
MODEL TML (full schema, column definitions, and relationships):
{model_tml[:10000]}
SNOWFLAKE DATA — actual row counts and sample rows:
{sample_data[:6000]}
{synthetic_warning}
Grade 0–100 using the actual data above. Do NOT hedge with phrases like "constrained by
partial TML" or "missing sample data" — the full TML and real row counts are provided.
Score based on what you can observe.
1. REALISM (20 pts): Values look like real {company} data at realistic scale and ranges.
2. STORY POTENTIAL (30 pts): Outliers, trends, or anomalies exist that anchor a demo narrative.
3. TIME COVERAGE (20 pts): 12–24 months of history with meaningful trends over time.
Judge coverage relative to today's date above — recent/current-year data is historical,
not "future"; only genuinely implausible far-future dates should count against this.
4. SCHEMA FITNESS (15 pts): Star schema design supports the key KPIs for {line} {function}.
5. COMPLETENESS (15 pts): Fact tables are well-populated (thousands of rows) with variation
across dimensions. Dimensions have REALISTIC cardinality for what they represent — a
handful of values for a naturally-small dimension (channel, region, tier, segment) is
CORRECT and must NOT be penalized; entity dimensions (products, customers, accounts,
stores) may have many. Do NOT require any fixed member count.
RULE: If the row counts above show any key table at 0 rows, score COMPLETENESS = 0 for
that criteria. If the fact table is 0 rows, also deduct heavily from STORY POTENTIAL.
PENALTY: If there are generated-looking dimension labels with numeric suffixes
such as "North Corridor Route 31", "Customer 17", or "Product 42", penalize realism.
If this pattern is repeated or widespread, the data should fail.
Return ONLY valid JSON:
{{"score": 0, "reasoning": "...", "strengths": ["..."], "weaknesses": ["..."]}}"""
result = _call_grader(prompt)
if synthetic_check["warn"]:
score = max(0, min(100, int(result.get("score", 0))))
if score > SYNTHETIC_WARNING_SCORE_CAP:
result["score"] = SYNTHETIC_WARNING_SCORE_CAP
result["reasoning"] = (
f"{result.get('reasoning', '')} Capped at {SYNTHETIC_WARNING_SCORE_CAP} "
"because isolated synthetic numeric-suffix dimension values were detected."
).strip()
result.setdefault("weaknesses", [])
result["weaknesses"].append("Synthetic numeric-suffix dimension values detected at low volume")
result["synthetic_dimension_warning"] = synthetic_check
return result
def grade_liveboard_quality(company: str, vertical: str, line: str, function: str,
liveboard_tml: str, viz_count: int = None) -> dict:
viz_note = ""
if viz_count is not None:
if viz_count < 3:
viz_note = f"\n⚠️ WARNING: This liveboard has only {viz_count} visualization(s). Penalize heavily under Visualization Variety."
else:
viz_note = f"\nNote: Liveboard contains {viz_count} visualizations."
prompt = f"""You are grading a ThoughtSpot liveboard.
Company: {company}
Vertical: {vertical} / {line}
Analytics function: {function}{viz_note}
A great liveboard opens with KPIs, shows trends with clear directionality,
and breaks down performance by dimensions — telling a story a presenter can walk through.
LIVEBOARD TML:
{liveboard_tml[:15000]}
Grade 0–100:
1. DATA COVERAGE (25 pts): All vizzes have backing data, questions use real column names.
2. TREND COHERENCE (20 pts): Line charts produce coherent time series; KPIs have time grains.
3. STORY STRUCTURE (25 pts): Flows KPIs → trends → breakdowns; walkable in a demo.
4. VISUALIZATION VARIETY (15 pts): Mix of KPIs, line charts, bar charts. Fewer than 3 vizzes = 0 pts here.
5. USE CASE ALIGNMENT (15 pts): Titles and questions match {line} {function} at {company}.
Return ONLY valid JSON:
{{"score": 0, "reasoning": "...", "strengths": ["..."], "weaknesses": ["..."]}}"""
return _call_grader(prompt)
def run_ai_grading(run_context: dict, company: str, vertical: str, line: str, function: str,
schema_override: str = None, username: str = None) -> dict:
result = {
"data_score": None, "data_points": 0.0,
"data_reasoning": "Not graded", "data_strengths": [], "data_weaknesses": [],
"liveboard_score": None, "liveboard_points": 0.0,
"liveboard_reasoning": "Not graded", "liveboard_strengths": [], "liveboard_weaknesses": [],
"grading_errors": [],
}
ts_base = run_context.get("ts_base_url")
model_guid = run_context.get("model_guid")
lb_guid = run_context.get("liveboard_guid")
if not ts_base or not model_guid:
result["grading_errors"].append("No model URL found — pipeline may not have completed")
return result
try:
session = ts_authenticate(ts_base, username=username)
except Exception as e:
result["grading_errors"].append(f"ThoughtSpot auth failed: {e}")
return result
# Data quality
try:
print(" 🔍 Exporting model TML...")
model_tml = export_model_tml(ts_base, session, model_guid)
db, schema = extract_db_schema(model_tml)
if not db or not schema:
tml_docs = export_model_related_tmls(ts_base, session, model_guid)
related_db, related_schema = extract_db_schema_from_tml_docs(tml_docs)
db = db or related_db
schema = schema or related_schema
if (not db or not schema) and schema_override:
from snowflake_auth import get_demo_database
db, schema = get_demo_database(), schema_override
print(f" ℹ️ Using schema resolved from model: {schema_override}")
sample = get_snowflake_sample(db, schema) if db and schema else "Could not determine db/schema"
print(" 🤖 Grading data quality...")
dg = grade_data_quality(company, vertical, line, function, model_tml, sample)
score = max(0, min(100, int(dg.get("score", 0))))
result.update({
"data_score": score, "data_points": round(score * 0.50, 1),
"data_reasoning": dg.get("reasoning", ""),
"data_strengths": dg.get("strengths", []),
"data_weaknesses": dg.get("weaknesses", []),
})
if dg.get("synthetic_dimension_failure"):
result["grading_errors"].append("Synthetic numeric-suffix dimension values detected")
reasoning = dg.get("reasoning", "")
reasoning_short = reasoning[:400].rstrip() + ("…" if len(reasoning) > 400 else "")
print(f" 📊 Data: {score}/100 → {result['data_points']} pts | {reasoning_short}")
except Exception as e:
result["grading_errors"].append(f"Data grading failed: {e}")
# Liveboard quality
if lb_guid:
try:
print(" 🔍 Exporting liveboard TML...")
lb_tml = export_tml(ts_base, session, lb_guid)
# Count visualizations — a liveboard with 0 vizzes scores 0, no AI needed
try:
lb_parsed = yaml.safe_load(lb_tml)
viz_count = len(lb_parsed.get("liveboard", {}).get("visualizations") or [])
except Exception:
viz_count = None
result["liveboard_viz_count"] = viz_count
print(f" 📋 Liveboard vizzes: {viz_count}")
if viz_count == 0:
lb_score = 0
result.update({
"liveboard_score": 0, "liveboard_points": 0.0,
"liveboard_reasoning": "Liveboard has 0 visualizations — empty board.",
"liveboard_strengths": [],
"liveboard_weaknesses": ["No visualizations in liveboard TML"],
})
result["grading_errors"].append("Liveboard has 0 visualizations")
print(" ❌ Liveboard is empty (0 vizzes) — score 0")
else:
print(" 🤖 Grading liveboard quality...")
lg = grade_liveboard_quality(company, vertical, line, function,
lb_tml, viz_count=viz_count)
lb_score = max(0, min(100, int(lg.get("score", 0))))
result.update({
"liveboard_score": lb_score, "liveboard_points": round(lb_score * 0.25, 1),
"liveboard_reasoning": lg.get("reasoning", ""),
"liveboard_strengths": lg.get("strengths", []),
"liveboard_weaknesses": lg.get("weaknesses", []),
})
print(f" 📊 Liveboard: {lb_score}/100 → {result['liveboard_points']} pts")
except Exception as e:
result["grading_errors"].append(f"Liveboard grading failed: {e}")
else:
result["grading_errors"].append("No liveboard GUID — liveboard may not have been created")
return result
# ---------------------------------------------------------------------------
# Group 1 settings verification
# ---------------------------------------------------------------------------
def verify_group1_settings(result: dict) -> dict:
"""
Verify Group 1 settings are reflected in the run output.
Checks: row counts, naming prefix, tag, share_with, column_naming_style, geo_scope.
Returns dict of {setting: {expected, actual, pass, note}}.
"""
checks = {}
sf = result.get("snowflake_check", {})
run_ctx = result.get("run_context", {})
ts_base = run_ctx.get("ts_base_url")
model_guid = run_ctx.get("model_guid")
lb_guid = run_ctx.get("liveboard_guid")
# Load testrunner settings for expected values
try:
from supabase_client import SupabaseSettings
raw = SupabaseSettings().load_all_settings(TEST_USER)
except Exception:
raw = {}
# ── 1. fact_table_size ────────────────────────────────────────
expected_fact = int(raw.get("fact_table_size") or 1000)
if sf.get("found"):
# Prefer name-based detection, fall back to largest table
fact_table = next(
(t for t in sf["tables"] if "SALES" in t["table"].upper() or "FACT" in t["table"].upper()),
None
)
if fact_table is None and sf["tables"]:
fact_table = max(sf["tables"], key=lambda t: t["rows"])
if fact_table:
checks["fact_table_size"] = {
"expected": expected_fact, "actual": fact_table["rows"],
"pass": fact_table["rows"] == expected_fact,
}
# ── 2. dim_table_size ────────────────────────────────────────
expected_dim = int(raw.get("dim_table_size") or 100)
if sf.get("found"):
# Exclude fact-sized tables by row count — avoids hardcoded name list failures
dim_tables = [t for t in sf["tables"] if t["rows"] < expected_fact]
if dim_tables:
mismatches = [t for t in dim_tables if t["rows"] != expected_dim]
checks["dim_table_size"] = {
"expected": expected_dim,
"actual": {t["table"]: t["rows"] for t in dim_tables},
"pass": len(mismatches) == 0,
"note": f"{len(mismatches)} dim tables don't match" if mismatches else "all match",
}
# ── 3. object_naming_prefix ───────────────────────────────────
expected_prefix = (raw.get("object_naming_prefix") or "").upper()
if sf.get("found") and sf.get("schema"):
schema = sf["schema"]
if expected_prefix:
passed = schema.upper().startswith(expected_prefix)
else:
passed = True # no prefix expected, anything goes
checks["object_naming_prefix"] = {
"expected": expected_prefix or "(blank)",
"actual": schema, "pass": passed,
}
# ── 4. geo_scope ─────────────────────────────────────────────
expected_geo = raw.get("geo_scope", "USA Only")
if sf.get("found") and sf.get("schema"):
try:
from snowflake_auth import get_snowflake_connection
conn = get_snowflake_connection()
cursor = conn.cursor()
schema = sf["schema"]
database = sf["database"]
# Look for a column named COUNTRY, REGION, or STATE
cursor.execute(f'SHOW TABLES IN SCHEMA "{database}"."{schema}"')
tables = [row[1] for row in cursor.fetchall()]
foreign_found = False
checked = False
for tname in tables:
cursor.execute(f'SHOW COLUMNS IN TABLE "{database}"."{schema}"."{tname}"')
cols = [row[2].upper() for row in cursor.fetchall()]
if "COUNTRY" in cols:
cursor.execute(f'SELECT DISTINCT "COUNTRY" FROM "{database}"."{schema}"."{tname}" LIMIT 20')
countries = [row[0] for row in cursor.fetchall() if row[0]]
non_us = [c for c in countries if c not in ("USA", "US", "United States", "United States of America")]
foreign_found = len(non_us) > 0
checked = True
break
cursor.close(); conn.close()
if checked:
if expected_geo == "USA Only":
checks["geo_scope"] = {
"expected": "USA Only", "actual": f"foreign countries: {non_us}" if foreign_found else "USA only",
"pass": not foreign_found,
}
else:
checks["geo_scope"] = {
"expected": "International", "actual": f"foreign countries found: {not foreign_found}",
"pass": foreign_found,
}
except Exception as e:
checks["geo_scope"] = {"pass": None, "note": f"geo check failed: {e}"}
# ── 5. column_naming_style ────────────────────────────────────
expected_style = raw.get("column_naming_style", "Regular Case")
if ts_base and model_guid:
try:
session = ts_authenticate(ts_base)
model_tml_str = export_tml(ts_base, session, model_guid)
tml = yaml.safe_load(model_tml_str)
columns = []
for tbl in (tml.get("model", {}).get("tables") or []):
for col in (tbl.get("columns") or []):
name = col.get("name", "")
if name:
columns.append(name)
if columns:
snake_count = sum(1 for c in columns if "_" in c and c == c.lower())
is_snake = snake_count > len(columns) * 0.5
actual_style = "snake_case" if is_snake else "Regular Case"
checks["column_naming_style"] = {
"expected": expected_style, "actual": actual_style,
"pass": actual_style == expected_style,
"sample": columns[:5],
}
except Exception as e:
checks["column_naming_style"] = {"pass": None, "note": f"TML check failed: {e}"}
# ── 6. tag_name ───────────────────────────────────────────────
expected_tag = raw.get("tag_name", "")
if expected_tag and ts_base and model_guid:
try:
session = ts_authenticate(ts_base)
resp = session.get(
f"{ts_base}/tspublic/v1/metadata/list",
params={"type": "LOGICAL_TABLE", "batchsize": 1,
"offset": 0, "pattern": model_guid},
)
body = resp.text.strip()
if not body:
checks["tag_name"] = {"pass": None, "note": "tag check skipped: empty API response"}
else:
try:
data = resp.json()
except Exception:
data = None
if data is None:
checks["tag_name"] = {"pass": None, "note": "tag check skipped: non-JSON API response"}
else:
headers_data = data.get("headers", []) if isinstance(data, dict) else []
obj_tags = []
for h in headers_data:
if h.get("id") == model_guid:
obj_tags = [t.get("name", "") for t in (h.get("tags") or [])]
break
checks["tag_name"] = {
"expected": expected_tag, "actual": obj_tags,
"pass": expected_tag in obj_tags,
}
except Exception as e:
checks["tag_name"] = {"pass": None, "note": f"tag check failed: {e}"}
# ── 7. share_with ────────────────────────────────────────────
expected_share = raw.get("share_with", "")
if expected_share and ts_base and model_guid:
try:
session = ts_authenticate(ts_base)
resp = session.post(
f"{ts_base}/api/rest/2.0/security/metadata/fetch",
json={"metadata": [{"type": "LOGICAL_TABLE", "identifier": model_guid}]},
timeout=15,
)
body = resp.text.strip()
if not body:
checks["share_with"] = {"pass": None, "note": "share check skipped: empty API response"}
else:
perms = resp.json()
principals = []
for item in (perms if isinstance(perms, list) else []):
for p in (item.get("permissions") or []):
principals.append(p.get("principal", {}).get("name", ""))
checks["share_with"] = {
"expected": expected_share, "actual": principals,
"pass": any(expected_share.lower() in p.lower() for p in principals),
}
except Exception as e:
checks["share_with"] = {"pass": None, "note": f"share check failed: {e}"}
return checks
def print_settings_verification(checks: dict):
if not checks:
return
print(" ── Settings Verification ────────────────────────────")
for setting, result in checks.items():
if result.get("pass") is True:
icon = "✅"
elif result.get("pass") is False:
icon = "❌"
else:
icon = "⚠️ "
exp = result.get("expected", "")
act = result.get("actual", result.get("note", ""))
print(f" {icon} {setting}: expected={exp!r} actual={str(act)[:60]}")
print(" ─────────────────────────────────────────────────────")
# ---------------------------------------------------------------------------
# Stage grading
# ---------------------------------------------------------------------------
def grade_stages(stages: dict, config: dict) -> dict:
weights = config["grading"]["stages"]
breakdown = {}
total = 0
for key, weight in weights.items():
status = stages.get(key, "unknown")
earned = weight if status == "complete" else (weight // 2 if status == "running" else 0)
breakdown[key] = {"weight": weight, "earned": earned, "status": status}
total += earned
return {"stage_total": total, "breakdown": breakdown}
def reconcile_stages_with_logs(stages: dict, diag: dict) -> dict:
"""
If session_logs confirms stages completed that the UI monitor missed
(e.g. slow DDL that triggered the 20-min bail-out), upgrade those stages to 'complete'.
Returns a new dict — does not mutate the original.
"""
if not diag.get("found"):
return stages
completed_in_logs = set(diag.get("stages_completed", []))
# Map session_log stage names → UI progress keys
log_to_ui = {
"research": "research",
"ddl": "ddl",
"deploy": "data", # app deploy stage creates/loads Snowflake data
"populate": "data", # session calls it 'populate', UI shows 'Data'
"data": "data",
"thoughtspot": "thoughtspot",
}
stages = dict(stages) # copy — don't mutate
for log_stage, ui_key in log_to_ui.items():
if log_stage in completed_in_logs and stages.get(ui_key) != "complete":
old = stages.get(ui_key, "unknown")
stages[ui_key] = "complete"
print(f" ℹ️ Stage '{ui_key}' upgraded to complete via session_logs (monitor saw: {old})")
# Synthetic 'complete' key — set if all main stages now complete
main = ("research", "ddl", "data", "thoughtspot")
if all(stages.get(s) == "complete" for s in main):
stages["complete"] = "complete"
return stages
def _effective_stuck_threshold(stages: dict) -> int:
"""Return stage-stale timeout seconds for the currently running UI stage."""
if any(k == "data" and v == "running" for k, v in stages.items()):
# Complex schemas with multiple fact tables can take 25-30 min.
return 35 * 60
if any(k == "thoughtspot" and v == "running" for k, v in stages.items()):
# ThoughtSpot table import + model semantics + MCP liveboard creation can
# legitimately sit on the same UI stage for 30+ minutes.
return 40 * 60
return 20 * 60
def compute_grade(score: float, config: dict) -> str:
grade = "F"
for letter, threshold in sorted(config["grading"]["thresholds"].items(), key=lambda x: -x[1]):
if score >= threshold:
grade = letter
break
return grade
# ---------------------------------------------------------------------------
# Failure diagnostics — Supabase session_logs + Snowflake verification
# ---------------------------------------------------------------------------
def fetch_run_diagnostics(start_time: float, company: str = "", test_tag: str = "") -> dict:
"""
Query session_logs for entries by testrunner after start_time.
Matches only by exact test tag. This is intentionally not used to identify
the model/schema under test because concurrent runs can contaminate logs.
"""
try:
from supabase_client import SupabaseSettings
from datetime import datetime, timezone
start_iso = datetime.fromtimestamp(start_time, tz=timezone.utc).isoformat()
s = SupabaseSettings()
result = (
s.client.table("session_logs")
.select("*")
.eq("user_email", TEST_USER)
.gte("ts", start_iso)
.order("ts", desc=False)
.execute()
)
if not result.data:
return {"found": False, "reason": "No session_logs entries found after test start"}
logs = result.data
# Group by session_id
sessions = {}
for log in logs:
sid = log["session_id"]
sessions.setdefault(sid, []).append(log)
if test_tag:
tag_matching = {
sid: entries for sid, entries in sessions.items()
if any((l.get("meta") or {}).get("test_tag") == test_tag for l in entries)
}
if not tag_matching:
return {"found": False, "reason": f"Exact test tag not found in session_logs: {test_tag}"}
session_logs = max(tag_matching.values(), key=len)
else:
return {"found": False, "reason": "No test tag provided; refusing to guess session"}
# Summarise
completed = [l["stage"] for l in session_logs if "completed" in (l.get("event") or "")]
errors = [l for l in session_logs if l.get("error")]
last = session_logs[-1]
# Pull everything useful from meta fields
total_rows = 0
tables_populated = 0
schema_name = None
model_guid = None
liveboard_guid = None
ts_base_url = None
for l in session_logs:
meta = l.get("meta") or {}
if "total_rows" in meta:
total_rows = meta["total_rows"]
tables_populated = meta.get("tables", 0)
if "schema" in meta:
schema_name = meta["schema"]
if "schema_name" in meta:
schema_name = meta["schema_name"]
if "model_guid" in meta and meta["model_guid"]:
model_guid = meta["model_guid"]
if "liveboard_guid" in meta and meta["liveboard_guid"]:
liveboard_guid = meta["liveboard_guid"]
if "ts_url" in meta and meta["ts_url"]:
ts_base_url = meta["ts_url"]
return {
"found": True,
"session_id": session_logs[0]["session_id"],
"stages_completed": completed,
"last_stage": last.get("stage"),
"last_event": last.get("event"),
"snowflake_schema": schema_name,
"total_rows": total_rows,
"tables_populated": tables_populated,
"model_guid": model_guid,
"liveboard_guid": liveboard_guid,
"ts_base_url": ts_base_url,
"errors": [
{
"stage": l["stage"],
"event": l.get("event"),
"error": (l["error"] or "")[:300],
}
for l in errors
],
"log_count": len(session_logs),
}
except Exception as e:
return {"found": False, "reason": f"Diagnostics query failed: {e}"}
def fetch_monitor_diagnostics(start_time: float, test_tag: str = "") -> dict:
"""
Lightweight exact-tag session log check used while the UI monitor is running.
This prevents the harness from declaring a stage stuck while the backend is
still logging progress for the same run.
"""
try:
from supabase_client import SupabaseSettings
from datetime import datetime, timezone
if not test_tag:
return {"found": False, "reason": "No test tag provided"}
start_iso = datetime.fromtimestamp(start_time, tz=timezone.utc).isoformat()
s = SupabaseSettings()
result = (
s.client.table("session_logs")
.select("session_id,ts,stage,event,error,meta")
.eq("user_email", TEST_USER)
.gte("ts", start_iso)
.order("ts", desc=False)
.execute()
)
logs = result.data or []
if not logs:
return {"found": False, "reason": "No session_logs entries found after test start"}
sessions = {}
for log in logs:
sid = log.get("session_id")
if sid:
sessions.setdefault(sid, []).append(log)
tag_matching = {
sid: entries for sid, entries in sessions.items()
if any((l.get("meta") or {}).get("test_tag") == test_tag for l in entries)
}
if not tag_matching:
return {"found": False, "reason": f"Exact test tag not found in session_logs: {test_tag}"}
session_logs = max(tag_matching.values(), key=len)
last = session_logs[-1]
completed = [l["stage"] for l in session_logs if "completed" in (l.get("event") or "")]
errors = [l for l in session_logs if l.get("error")]
model_guid = None
liveboard_guid = None
ts_base_url = None
for l in session_logs:
meta = l.get("meta") or {}
model_guid = meta.get("model_guid") or model_guid
liveboard_guid = meta.get("liveboard_guid") or liveboard_guid
ts_base_url = meta.get("ts_url") or ts_base_url
last_ts = datetime.fromisoformat(str(last.get("ts", "")).replace("Z", "+00:00"))
if last_ts.tzinfo is None:
last_ts = last_ts.replace(tzinfo=timezone.utc)
age_s = int((datetime.now(timezone.utc) - last_ts).total_seconds())
return {
"found": True,
"session_id": session_logs[0].get("session_id"),
"last_stage": last.get("stage"),
"last_event": last.get("event"),
"last_ts": last.get("ts"),
"last_age_s": age_s,
"stages_completed": completed,
"errors": errors,
"model_guid": model_guid,
"liveboard_guid": liveboard_guid,
"ts_base_url": ts_base_url,
"log_count": len(session_logs),
}
except Exception as e:
return {"found": False, "reason": f"Monitor diagnostics query failed: {e}"}
def check_snowflake_schema(company: str, start_time: float, schema_override: str = None,
database_override: str = None) -> dict:
"""
Get row counts for the Snowflake schema created during this test run.
Requires an explicit database + schema resolved from the exact ThoughtSpot
model (demos live in a rotating <base>_<YYYY_MM> database, so the database
must come from the model TML too). Never guesses by company/date prefix.
"""
try:
from snowflake_auth import get_snowflake_connection
if not schema_override:
return {"found": False, "reason": "No explicit schema provided; refusing to guess"}
if not database_override:
return {"found": False, "reason": "No explicit database provided; refusing to guess"}
conn = get_snowflake_connection()
cursor = conn.cursor()
schema = schema_override
database = database_override
cursor.execute(f'SHOW TABLES IN SCHEMA "{database}"."{schema}"')
tables = cursor.fetchall()
table_info = []
total_rows = 0
for t in tables:
tname = t[1]
try:
cursor.execute(f'SELECT COUNT(*) FROM "{database}"."{schema}"."{tname}"')
count = cursor.fetchone()[0]
total_rows += count
table_info.append({"table": tname, "rows": count})
except Exception:
table_info.append({"table": tname, "rows": "error"})
cursor.close()
conn.close()
return {"found": True, "database": database, "schema": schema,
"tables": table_info, "total_rows": total_rows}
except Exception as e:
return {"found": False, "error": str(e)}
def choose_snowflake_schema_for_check(schema_resolution: dict, diag: dict) -> tuple[Optional[str], str]:
"""Choose the safest explicit schema for Snowflake verification.
Prefer the schema resolved from the exact ThoughtSpot model. If model
creation failed, fall back to the exact-tag session log schema. Never guess
by company or date prefix.
"""
if schema_resolution.get("found") and schema_resolution.get("schema"):
return schema_resolution.get("schema"), "model"
if diag.get("snowflake_schema"):
return diag.get("snowflake_schema"), "session_logs"
return None, "none"
def print_diagnostics(diag: dict, sf: dict):
"""Print a human-readable failure summary."""
print(" ── Diagnostics ──────────────────────────────────────")
if diag.get("found"):
print(f" Session: {diag['session_id']}")
print(f" Last: [{diag['last_stage']}] {diag['last_event']}")
if diag["stages_completed"]:
print(f" Done: {', '.join(diag['stages_completed'])}")
if diag["total_rows"]:
print(f" Snowflake (from logs): {diag['tables_populated']} tables, {diag['total_rows']} rows")
for err in diag.get("errors", []):
msg = (err["error"] or "")[:120].replace("\n", " ")
print(f" ❌ [{err['stage']}] {msg}")
else:
print(f" Supabase: {diag.get('reason', 'no data')}")
if sf.get("found"):
print(f" Snowflake schema: {sf['schema']} ({sf['total_rows']} rows across {len(sf['tables'])} tables)")
for t in sf["tables"]:
print(f" {t['table']}: {t['rows']} rows")
elif sf:
print(f" Snowflake: {sf.get('reason') or sf.get('error') or 'schema not found'}")
print(" ─────────────────────────────────────────────────────")
# ---------------------------------------------------------------------------
# Single test runner
# ---------------------------------------------------------------------------
def run_single_test(page: Page, test_case: dict, config: dict) -> dict:
timeout_sec = config["grading"]["timeout_minutes"] * 60
start = time.time()
result = {
"name": test_case["name"], "type": test_case["type"],
"company": test_case.get("company", ""),
"vertical": test_case.get("vertical", ""), "line": test_case.get("line", ""),
"function": test_case.get("function", ""), "company_url": test_case.get("company_url", ""),
"stages": {}, "run_context": {}, "stage_grading": {}, "ai_grading": {},
"total_score": 0.0, "grade": "F",
"error": None, "timed_out": False, "late_complete": False, "duration_seconds": 0,
"diagnostics": {}, "snowflake_check": {},
"liveboard_viz_count": None,
}
try:
submit_job(page, test_case)
print(f" ⏳ Monitoring pipeline (timeout: {config['grading']['timeout_minutes']}min)...")
poll_interval = 15
last_stages = {}
last_change_time = time.time()
STUCK_THRESHOLD = 20 * 60 # 20 min with no stage change → bail early
BACKEND_STALE_THRESHOLD = 15 * 60
last_monitor_log_count = 0
PIPELINE_ERROR_INDICATORS = [
"Research failed",
"pipeline has been interrupted",
"An unexpected error occurred",
"Population failed",
"Pipeline failed",
"Something went wrong during the pipeline",
"Traceback (most recent call last)",
"NameError:",
]
while time.time() - start < timeout_sec:
time.sleep(poll_interval)
stages = read_progress(page)
if stages != last_stages:
done = [k for k, v in stages.items() if v == "complete"]
running = [k for k, v in stages.items() if v == "running"]
print(f" ✓ {done}{running}")
last_stages = stages
last_change_time = time.time()
if pipeline_finished(stages):
print(" ✅ Pipeline complete")
time.sleep(10) # let final output (URLs) finish rendering before extraction
break
# Detect hard pipeline failure in the chat output
try:
page_text = page.inner_text('body')
if any(ind in page_text for ind in PIPELINE_ERROR_INDICATORS):
result["timed_out"] = True
print(" ❌ Pipeline error detected in page — stopping early")
break
except Exception:
pass
# Bail if stages have been stuck for too long, with stage-specific
# allowances for slow data generation and ThoughtSpot object creation.
stuck_secs = time.time() - last_change_time
effective_threshold = _effective_stuck_threshold(stages)
if stuck_secs > effective_threshold and any(v == "running" for v in stages.values()):
monitor_diag = fetch_monitor_diagnostics(start, test_case.get("_test_tag", ""))
if monitor_diag.get("found"):
completed = set(monitor_diag.get("stages_completed", []))
errors = monitor_diag.get("errors") or []
backend_fresh = monitor_diag.get("last_age_s", 999999) <= BACKEND_STALE_THRESHOLD
backend_has_model = bool(monitor_diag.get("model_guid"))
if backend_has_model:
result["run_context"] = {
"ts_base_url": monitor_diag.get("ts_base_url"),
"model_guid": monitor_diag.get("model_guid"),
"liveboard_guid": monitor_diag.get("liveboard_guid"),
"source": "session_logs_monitor",
}
print(" ℹ️ Backend completed via session_logs while UI was still stale")
break
if backend_fresh and not errors:
last_change_time = time.time()
if monitor_diag.get("log_count", 0) != last_monitor_log_count:
last_monitor_log_count = monitor_diag.get("log_count", 0)
print(
" ℹ️ UI stage stale, but backend still active: "
f"[{monitor_diag.get('last_stage')}] {monitor_diag.get('last_event')}"
)
continue
if "thoughtspot" in completed and not errors:
last_change_time = time.time()
print(" ℹ️ ThoughtSpot stage completed in logs; waiting for model/liveboard GUIDs")
continue
result["timed_out"] = True
print(f" ⏰ Stage stuck for {int(stuck_secs/60)}min — treating as failure")
break
else:
result["timed_out"] = True
print(f" ⏰ Timed out after {config['grading']['timeout_minutes']} min")
result["stages"] = last_stages or read_progress(page)
# GUIDs normally come from the final page. The monitor may also set
# run_context if session_logs prove the backend completed after the UI
# became stale.
result["run_context"] = result.get("run_context") or {}
except Exception as e:
result["error"] = str(e)
print(f" ❌ Error: {e}")
try:
result["stages"] = read_progress(page)
except Exception:
pass
result["duration_seconds"] = round(time.time() - start)
# The completed page prints the exact model/liveboard URLs for this run.
# Treat that as authoritative; session_logs are diagnostics only.
page_ctx = extract_run_context(page)
if page_ctx.get("model_guid"):
result["run_context"] = page_ctx
if page_ctx.get("model_guid"):
lb_note = page_ctx.get("liveboard_guid", "")
print(f" 🔑 GUIDs from page: model={page_ctx['model_guid'][:8]}… lb={lb_note[:8] if lb_note else 'none'}…")
elif result["run_context"].get("model_guid"):
lb_note = result["run_context"].get("liveboard_guid", "")
print(
f" 🔑 GUIDs from {result['run_context'].get('source', 'session logs')}: "
f"model={result['run_context']['model_guid'][:8]}… "
f"lb={lb_note[:8] if lb_note else 'none'}…"
)
else:
print(" ⚠️ No model GUID found on final page — AI grading will be skipped")
# Fetch exact-tag diagnostics only for supplemental errors/stage reconciliation.
diag = fetch_run_diagnostics(start, company=result.get("company", ""),
test_tag=test_case.get("_test_tag", ""))
result["diagnostics"] = diag
if not diag.get("found"):
print(f" ℹ️ Session logs not used for identity: {diag.get('reason')}")
elif not result["run_context"].get("model_guid") and diag.get("model_guid"):
result["run_context"] = {
"ts_base_url": diag.get("ts_base_url"),
"model_guid": diag.get("model_guid"),
"liveboard_guid": diag.get("liveboard_guid"),
"source": "session_logs_exact_tag",
}
lb_note = result["run_context"].get("liveboard_guid", "")
print(
f" 🔑 GUIDs from exact-tag session logs: "
f"model={result['run_context']['model_guid'][:8]}… "
f"lb={lb_note[:8] if lb_note else 'none'}…"
)
schema_resolution = resolve_schema_from_model(result["run_context"])
known_schema, schema_source = choose_snowflake_schema_for_check(schema_resolution, diag)
if schema_source == "model":
print(f" 🧭 Schema from ThoughtSpot model: {known_schema}")
else:
print(f" ⚠️ Could not resolve schema from model: {schema_resolution.get('reason')}")
if schema_source == "session_logs":
print(f" 🧭 Schema from exact-tag session logs: {known_schema}")
known_database = schema_resolution.get("database")
if not known_database and known_schema:
# Schema came from session logs (model export failed): this run just
# wrote to the active rotating demo database, so that IS its database.
from snowflake_auth import get_demo_database
known_database = get_demo_database()
sf = check_snowflake_schema(result["company"], start, schema_override=known_schema,
database_override=known_database)
result["snowflake_check"] = sf
if sf.get("found"):
print(f" 📦 Snowflake: {sf['schema']} ({sf['total_rows']} rows, {len(sf['tables'])} tables)")
for t in sf["tables"]:
print(f" {t['table']}: {t['rows']} rows")
else:
print(f" 📦 Snowflake: schema not checked ({sf.get('reason') or sf.get('error') or 'unknown'})")
# Reconcile stages with session_logs — catches cases where the monitor bailed early
# but the pipeline actually completed (e.g. slow DDL that took >20 min)
result["stages"] = reconcile_stages_with_logs(result["stages"], diag)
main_stages = ("research", "ddl", "data", "thoughtspot")
if result["timed_out"] and all(result["stages"].get(s) == "complete" for s in main_stages):
result["late_complete"] = True
print(" ℹ️ Pipeline completed late — all stages confirmed via session_logs")
# Print diagnostics on timeout/error — skip for late_complete since pipeline did finish
if result["error"] or (result["timed_out"] and not result["late_complete"]):
print_diagnostics(diag, {}) # sf already printed above
# Stage scoring
sg = grade_stages(result["stages"], config)
result["stage_grading"] = sg
# AI grading
ag = {"data_points": 0.0, "liveboard_points": 0.0, "grading_errors": []}
if result["run_context"].get("model_guid"):
print(" 🔬 Running AI quality grading...")
ag = run_ai_grading(
result["run_context"],
result["company"], result["vertical"], result["line"], result["function"],
schema_override=known_schema,
)
else:
ag["grading_errors"].append("Skipped — no model GUID (pipeline did not complete)")
result["ai_grading"] = ag
result["liveboard_viz_count"] = ag.get("liveboard_viz_count")
total = sg["stage_total"] + ag.get("data_points", 0) + ag.get("liveboard_points", 0)
result["total_score"] = round(total, 1)
result["grade"] = compute_grade(total, config)
return result
# ---------------------------------------------------------------------------
# Results
# ---------------------------------------------------------------------------
def save_to_postgres(run: dict, env_name: str = "") -> None:
"""Write one row per test result into ts_quality_results in Supabase."""
try:
sys.path.insert(0, str(Path(__file__).parent.parent))
from supabase_client import SupabaseSettings
ss = SupabaseSettings()
rows = []
for t in run["tests"]:
ag = t.get("ai_grading", {})
sg = t.get("stage_grading", {})
diag = t.get("diagnostics", {})
ctx = t.get("run_context") or {}
sf = t.get("snowflake_check", {})
rows.append({
"run_id": run["run_id"],
"run_timestamp": run["timestamp"],
"environment": env_name or ("prod" if "test" not in run.get("target_url","") else "test"),
"target_url": run.get("target_url",""),
"run_avg_score": run["avg_score"],
"run_overall_grade": run["overall_grade"],
"company": t.get("company", t["name"]),
"vertical": t.get("vertical",""),
"line": t.get("line",""),
"function": t.get("function",""),
"test_type": t.get("type",""),
"total_score": t.get("total_score", 0),
"grade": t.get("grade","F"),
"stage_score": sg.get("stage_total", 0),
"data_score": ag.get("data_score"),
"data_points": ag.get("data_points"),
"liveboard_score": ag.get("liveboard_score"),
"liveboard_points": ag.get("liveboard_points"),
"timed_out": t.get("timed_out", False),
"late_complete": t.get("late_complete", False),
"error": t.get("error"),
"duration_seconds": t.get("duration_seconds"),
"session_id": diag.get("session_id",""),
"model_guid": diag.get("model_guid","") or ctx.get("model_guid",""),
"liveboard_guid": diag.get("liveboard_guid","") or ctx.get("liveboard_guid",""),
"ts_base_url": diag.get("ts_base_url","") or ctx.get("ts_base_url",""),
"snowflake_schema": sf.get("schema",""),
"total_rows": sf.get("total_rows"),
"tables_populated": diag.get("tables_populated"),
"liveboard_viz_count": t.get("liveboard_viz_count"),
"data_reasoning": ag.get("data_reasoning",""),
"liveboard_reasoning": ag.get("liveboard_reasoning",""),
})
ss.client.table("ts_quality_results").insert(rows).execute()
print(f"📊 Saved {len(rows)} rows to ts_quality_results")
except Exception as e:
print(f"⚠️ Postgres write failed (non-fatal): {e}")
def save_results(run: dict) -> Path:
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
path = RESULTS_DIR / f"{ts}_quality_run.json"
with open(path, "w") as f:
json.dump(run, f, indent=2, default=str)
print(f"\n💾 Results: {path}")
return path
def save_summary_md(run: dict, json_path: Path, env_name: str = "") -> Path:
"""
Save a compact markdown summary alongside the JSON.
Also writes to latest_{env_name}_summary.md (or latest_summary.md) for nightly use.
env_name: 'test' | 'prod' | '' (default)
"""
W = 32
lines = [
f"# DemoPrep Quality Run — {run['timestamp'][:16]}",
f"**Target:** {run.get('target_url', 'unknown')} | "
f"**Avg:** {run['avg_score']}/100 Grade: {run['overall_grade']}",
"",
"| Company | Use Case | Data | LB | Total | Note |",
"|---------|----------|------|----|-------|------|",
]
for r in run["tests"]:
ag = r.get("ai_grading", {})
ds = ag.get("data_score", "n/a")
ls = ag.get("liveboard_score", "n/a")
t_str = f"{r['total_score']}/{r['grade']}"
company = r.get("company", r["name"])
parts = [p for p in [r.get("vertical",""), r.get("line",""), r.get("function","")]
if p and p != "* CUSTOM *"]
uc = (" / ".join(parts) if parts else "Custom")[:W]
if r.get("error"): note = "❌ network err"
elif r.get("late_complete"): note = "⚠️ slow (complete)"
elif r.get("timed_out"): note = "⏰ timeout"
elif r["grade"] in ("A","B"): note = "🏆 great"
elif r["grade"] == "C": note = "✅ solid"
else: note = ""
ctx = r.get("run_context") or {}
lb_guid = ctx.get("liveboard_guid","")
lb_base = (ctx.get("ts_base_url","") or "").rstrip("/")
lb_link = f"[lb]({lb_base}/#/pinboard/{lb_guid})" if lb_guid and lb_base else "—"
lines.append(f"| {company} | {uc} | {ds} | {ls} | {t_str} {lb_link} | {note} |")
# Issues and errors
issues = []
for r in run["tests"]:
if r.get("timed_out") and not r.get("late_complete"):
last = r.get("diagnostics",{}).get("last_event","unknown")
issues.append(f"- **{r.get('company',r['name'])}**: TIMEOUT — last event: {last}")
for err in r.get("ai_grading",{}).get("grading_errors",[]):
if "skip" not in err.lower():
issues.append(f"- **{r.get('company',r['name'])}**: {err}")
if issues:
lines += ["", "## Issues", ""] + issues
# Top data weaknesses
weaknesses = []
for r in run["tests"]:
ag = r.get("ai_grading",{})
ww = ag.get("data_weaknesses",[])
if ww:
weaknesses.append(f"**{r.get('company',r['name'])}** (data={ag.get('data_score','?')}/100):")
for w in ww[:2]:
weaknesses.append(f" - {w[:120]}")
if weaknesses:
lines += ["", "## Data Quality Weaknesses", ""] + weaknesses
lines += ["", "---", f"*JSON: {json_path.name}*"]
md_text = "\n".join(lines) + "\n"
md_path = json_path.with_suffix(".md")
md_path.write_text(md_text)
latest_name = f"latest_{env_name}_summary.md" if env_name else "latest_summary.md"
latest = RESULTS_DIR / latest_name
latest.write_text(md_text)
print(f"📋 Summary: {md_path}")
print(f"📋 Latest: {latest}")
return md_path
def print_handoff_block(run: dict, json_path: Path, md_path: Path, env_name: str = ""):
latest_name = f"latest_{env_name}_summary.md" if env_name else "latest_summary.md"
latest_path = RESULTS_DIR / latest_name
timestamp = run.get("timestamp", "")
run_id = run.get("run_id", "")
target = run.get("target_url", "")
avg = run.get("avg_score", "")
grade = run.get("overall_grade", "")
print("\n📌 Agent handoff")
print(f" Run ID: {run_id}")
print(f" Timestamp: {timestamp}")
print(f" Target: {target}")
print(f" Results: {json_path}")
print(f" Summary: {md_path}")
print(f" Latest: {latest_path}")
print(
" Paste this: "
f"DemoPrep quality run {run_id} ({timestamp}) "
f"avg={avg}/{grade} target={target} "
f"results={json_path} summary={md_path}"
)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def run_quality_suite(max_tests: int = None, env_name: str = "", suite_override: list[dict] = None):
if not TEST_USER or not TEST_PASSWORD:
raise RuntimeError("TEST_USER and TEST_PASSWORD must be set in .env")
config = load_config()
suite = suite_override or build_test_suite(config)
if max_tests:
suite = suite[:max_tests]
run_id = str(uuid.uuid4())[:8]
print(f"\n{'='*62}")
print(f" DemoPrep Quality Run — {datetime.now().strftime('%Y-%m-%d %H:%M')}")
print(f" Run ID: {run_id}")
print(f" Target: {BASE_URL}")
print(f" {len(suite)} tests | "
f"{sum(1 for t in suite if t['type']=='fixed')} fixed "
f"{sum(1 for t in suite if t['type']=='random')} random "
f"{sum(1 for t in suite if t['type']=='ai_generated')} AI-generated "
f"{sum(1 for t in suite if t['type']=='custom')} custom")
print(f" Scoring: stages(25) + data(50) + liveboard(25) = 100 pts")
print(f"{'='*62}")
for i, tc in enumerate(suite, 1):
label = {"fixed": "🔒", "random": "🎲", "ai_generated": "🤖", "custom": "✏️"}[tc["type"]]
print(f" [{i}] {label} {tc['name']}")
results = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=not DRY_RUN)
ctx = browser.new_context(viewport={"width": 1280, "height": 900})
print(f"\n🔐 Logging in as {TEST_USER}...")
page = ctx.new_page()
page.goto(BASE_URL, timeout=90000)
page.wait_for_selector('input[type=password], button[role=tab]', timeout=90000)
if page.locator('input[type=password]').is_visible(timeout=2000):
_do_login(page)
print("✅ Logged in\n")
for i, test_case in enumerate(suite, 1):
label = {"fixed": "🔒", "random": "🎲", "ai_generated": "🤖", "custom": "✏️"}[test_case["type"]]
print(f"{'─'*62}")
print(f"[{i}/{len(suite)}] {label} {test_case['name']}")
result = run_single_test(page, test_case, config)
results.append(result)
ag = result["ai_grading"]
sg = result["stage_grading"]
print(f" Stages: {sg.get('stage_total', 0)}/25")
if ag.get("data_score") is not None:
print(f" Data: {ag['data_score']}/100 → {ag['data_points']} pts")
if ag.get("liveboard_score") is not None:
print(f" Board: {ag['liveboard_score']}/100 → {ag['liveboard_points']} pts")
for err in ag.get("grading_errors", []):
print(f" ⚠️ {err}")
ctx_r = result.get("run_context", {})
ts_url = (ctx_r.get("ts_base_url") or "").rstrip("/")
m_guid = ctx_r.get("model_guid", "")
l_guid = ctx_r.get("liveboard_guid", "")
if m_guid and ts_url:
print(f" Model: {ts_url}/#/data/tables/{m_guid}")
if l_guid and ts_url:
viz_n = result.get("liveboard_viz_count")
viz_note = f" ({viz_n} vizzes)" if viz_n is not None else ""
print(f" Liveboard: {ts_url}/#/pinboard/{l_guid}{viz_note}")
if result.get("late_complete"):
timeout_tag = " ⚠️ SLOW (completed late)"
elif result.get("timed_out"):
timeout_tag = " ⏰ TIMEOUT"
else:
timeout_tag = ""
print(f" TOTAL: {result['total_score']}/100 Grade: {result['grade']}"
f" ({result['duration_seconds']}s)"
f"{timeout_tag}"
f"{' ❌ ERROR' if result['error'] else ''}")
ctx.close()
browser.close()
avg = round(sum(r["total_score"] for r in results) / len(results), 1) if results else 0
grade = compute_grade(avg, config)
run = {
"run_id": run_id, "timestamp": datetime.now().isoformat(),
"target_url": BASE_URL,
"avg_score": avg, "overall_grade": grade,
"test_count": len(results), "tests": results,
}
path = save_results(run)
md_path = save_summary_md(run, path, env_name=env_name)
save_to_postgres(run, env_name=env_name)
print_handoff_block(run, path, md_path, env_name=env_name)
# --- Summary table ---
try:
W_CO, W_UC, W_DA, W_LB, W_TO, W_NO = 16, 22, 6, 6, 9, 14
B = "│"
def _link(url, label):
return f"\033]8;;{url}\033\\{label}\033]8;;\033\\"
def _row(co, uc, da, lb, to, lk, no):
return (f"{B} {co:<{W_CO}} {B} {uc:<{W_UC}} {B} {da:>{W_DA}} {B}"
f" {lb:>{W_LB}} {B} {to:>{W_TO}} {B} {lk} {B} {no:<{W_NO}} {B}")
def _div(l, m, r):
s = "─"
return (f"{l}{s*(W_CO+2)}{m}{s*(W_UC+2)}{m}{s*(W_DA+2)}{m}"
f"{s*(W_LB+2)}{m}{s*(W_TO+2)}{m}{s*6}{m}{s*(W_NO+2)}{r}")
print(f"\n{'='*62}")
print(f" COMPLETE — Avg: {avg}/100 Grade: {grade} | {BASE_URL}")
print()
print(_div("┌", "┬", "┐"))
print(_row("Company", "Use Case", " Data", " LB", " Total", " Link", "Note"))
print(_div("├", "┼", "┤"))
for r in results:
ag = r.get("ai_grading", {})
ds = ag.get("data_score")
ls = ag.get("liveboard_score")
d_str = str(ds) if ds is not None else "n/a"
l_str = str(ls) if ls is not None else "n/a"
t_str = f"{r['total_score']}/{r['grade']}"
ctx = r.get("run_context") or {}
lb_url, lb_base = ctx.get("liveboard_guid",""), ctx.get("ts_base_url","")
lk = _link(f"{lb_base}/#/pinboard/{lb_url}", " 📋 ") if lb_url and lb_base else " — "
parts = [p for p in [r.get("vertical",""), r.get("line",""), r.get("function","")] if p and p != "* CUSTOM *"]
uc = (" / ".join(parts) if parts else r.get("context","")[:W_UC] or "Custom")[:W_UC]
errs = ag.get("grading_errors", [])
if r.get("error"): note = "❌ network err"
elif r.get("late_complete"): note = "⚠️ slow"
elif r.get("timed_out"): note = "⏰ timeout"
elif any("auth failed" in (e or "").lower() for e in errs): note = "❌ auth fail"
elif any("parse" in (e or "").lower() for e in errs): note = "❌ parse fail"
elif ds == 0 and ls is not None: note = "⚠️ data fail"
elif r["grade"] in ("A","B"): note = "🏆 great"
elif r["grade"] == "C": note = "✅ solid"
else: note = ""
company = r.get("company", r["name"])[:W_CO]
print(_row(company, uc, d_str, l_str, t_str, lk, note))
print(_div("└", "┴", "┘"))
# Liveboard links — plain text for easy copy/click
print("\n Liveboards:")
for i, r in enumerate(results, 1):
ctx = r.get("run_context") or {}
lb_url = ctx.get("liveboard_guid", "")
lb_base = ctx.get("ts_base_url", "")
company = r.get("company", r["name"])
url = f"{lb_base}/#/pinboard/{lb_url}" if lb_url and lb_base else "— not created"
print(f" {i}. {company:<30} {url}")
print(f"\n{'='*62}\n")
except Exception as _table_err:
print(f"\n⚠️ Summary table failed: {_table_err}")
print(f"{'='*62}")
print(f" COMPLETE — Avg: {avg}/100 Grade: {grade}")
for r in results:
ag = r.get("ai_grading", {})
ds = ag.get("data_score")
ls = ag.get("liveboard_score")
print(f" {r['name']}: {r['total_score']}/100 {r['grade']} data={ds} lb={ls}")
print(f"{'='*62}\n")
return run
def test_quality_run():
run = run_quality_suite()
assert run["overall_grade"] != "F", f"Quality run averaged {run['avg_score']}% — too many failures."
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--count", type=int, default=0,
help="Run only N tests (default: all 8)")
parser.add_argument("--datadog-saas-sales", action="store_true",
help="Run one targeted Datadog Software as a Service / Sales test")
parser.add_argument("--dataset-first-five", action="store_true",
help="Run five curated dataset-first smoke tests: four defined flows plus one custom")
parser.add_argument("--dry-run", action="store_true",
help="Fill form but do not click GO — browser opens visibly for inspection")
parser.add_argument("--url", type=str, default="",
help="Override TEST_TARGET_URL (e.g. --url https://thoughtspot-dp-demoprep.hf.space)")
parser.add_argument("--env-name", type=str, default="",
help="Tag for summary filename: 'test' → latest_test_summary.md, 'prod' → latest_prod_summary.md")
parser.add_argument("--ts-environment", type=str, default="",
help="Override the TS Environment dropdown value for this run")
parser.add_argument("--test-user", type=str, default="",
help="Override TEST_USER for this run only")
parser.add_argument("--test-password", type=str, default="",
help="Override TEST_PASSWORD for this run only; prefer --test-password-env")
parser.add_argument("--test-password-env", type=str, default="",
help="Environment variable containing the password for --test-user")
args = parser.parse_args()
if args.test_user:
TEST_USER = args.test_user
if not args.test_password and not args.test_password_env:
raise SystemExit(
"--test-user requires --test-password or --test-password-env; "
"otherwise the runner would use the default TEST_PASSWORD for a different user."
)
if args.test_password_env:
TEST_PASSWORD = os.getenv(args.test_password_env, "")
elif args.test_password:
TEST_PASSWORD = args.test_password
if args.dry_run:
DRY_RUN = True
if args.url:
BASE_URL = args.url
if args.ts_environment:
RUN_SETTINGS["ts_environment"] = args.ts_environment
if not BASE_URL:
raise ValueError("No target URL — set TEST_TARGET_URL in .env or pass --url <url>")
suite_override = None
if args.datadog_saas_sales:
suite_override = [{
"name": "datadog_saas_sales_dataset_first",
"type": "fixed",
"company": "Datadog",
"company_url": "datadog.com",
"vertical": "Technology",
"line": "Software as a Service",
"function": "Sales",
}]
if args.dataset_first_five:
suite_override = [
{
"name": "ey_professional_services_dataset_first",
"type": "custom",
"company": "EY",
"company_url": "ey.com",
"vertical": "* CUSTOM *",
"line": "",
"function": "Custom",
"context": (
"Create a professional services analytics demo for EY. "
"Focus on client engagements, service lines, industries, consultants, billable hours, "
"utilization, realization, pipeline, project margin, delivery risk, and client satisfaction. "
"Use consulting and assurance terminology only. Do not create sports, venue, ticketing, "
"fan engagement, or entertainment analytics."
),
},
{
"name": "datadog_saas_sales_dataset_first",
"type": "fixed",
"company": "Datadog",
"company_url": "datadog.com",
"vertical": "Technology",
"line": "Software as a Service",
"function": "Sales",
},
{
"name": "nike_retail_sales_dataset_first",
"type": "fixed",
"company": "Nike",
"company_url": "nike.com",
"vertical": "Retail & Consumer Goods",
"line": "Fashion/Apparel",
"function": "Sales",
},
{
"name": "delta_airline_operations_dataset_first",
"type": "fixed",
"company": "Delta",
"company_url": "delta.com",
"vertical": "Transportation & Logistics",
"line": "Air Transport",
"function": "Sales",
},
{
"name": "wells_fargo_banking_marketing_dataset_first",
"type": "fixed",
"company": "Wells Fargo",
"company_url": "wellsfargo.com",
"vertical": "Financial Services",
"line": "Banking",
"function": "Marketing",
},
{
"name": "starbucks_custom_store_operations_dataset_first",
"type": "custom",
"company": "Starbucks",
"company_url": "starbucks.com",
"vertical": "* CUSTOM *",
"line": "",
"function": "Custom",
"context": (
"Create a store operations analytics demo for Starbucks. "
"Focus on store-day and daypart performance, transactions, net sales, "
"labor hours, order channel, product category, wait times, and customer satisfaction. "
"Keep values realistic for coffee retail: transactions must reconcile to sales, "
"refunds must stay below transactions, wait times should be measured in minutes, "
"and channels should be in-store, drive-thru, mobile order, or delivery."
),
},
]
run_quality_suite(
max_tests=args.count or (1 if args.dry_run else None),
env_name=args.env_name,
suite_override=suite_override,
)