Spaces:
Running
fix: stabilize e2e test suite — session resilience + LegitData API call reduction
Browse filesTests 2-6 (page load timeout): add _navigate_and_ensure_logged_in() to
submit_job — waits for tabs OR login form, re-logs in on session expiry,
retries up to 5min if space is rebuilding after a push.
Test 1 (45-min hang): generate_entity_batch was making N/20 API calls per
dimension table (10 calls × 5 tables = 50+ calls for Banking/Marketing standard
preset). Changed to one seed call (≤50 entities) then cycle — cuts API calls
from 50+ down to ~5, well within the 15-min POP_TIMEOUT.
Monitoring loop: now detects pipeline error text in page body and stage stuck
>20min to bail early instead of waiting the full 45min timeout.
Also includes ★ CUSTOM ★ vertical rename (demo_personas.py + chat_interface.py).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- chat_interface.py +7 -7
- demo_personas.py +1 -1
- legitdata_project/legitdata/sourcer/ai_generator.py +50 -52
- tests/e2e_quality.py +76 -11
|
@@ -5411,15 +5411,15 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
|
|
| 5411 |
# App tab: vertical → line + function cascade
|
| 5412 |
def update_line_on_vertical(vertical):
|
| 5413 |
lines = VERTICAL_LINES.get(vertical, [])
|
| 5414 |
-
if vertical == "
|
| 5415 |
return (
|
| 5416 |
-
gr.Dropdown(choices=[], value=
|
| 5417 |
-
gr.Dropdown(choices=[], value=
|
| 5418 |
gr.Textbox(label="Context *", placeholder="Describe your use case, industry, and key metrics...", interactive=True),
|
| 5419 |
)
|
| 5420 |
return (
|
| 5421 |
-
gr.Dropdown(choices=lines, value=lines[0], interactive=True),
|
| 5422 |
-
gr.Dropdown(choices=DEMO_FUNCTIONS, value=DEMO_FUNCTIONS[0], interactive=True),
|
| 5423 |
gr.Textbox(label="Context", placeholder="Any extra context for the demo...", interactive=True),
|
| 5424 |
)
|
| 5425 |
|
|
@@ -5457,7 +5457,7 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
|
|
| 5457 |
else:
|
| 5458 |
raw_company = f"{line or vertical} Demo"
|
| 5459 |
|
| 5460 |
-
is_custom = (vertical == "
|
| 5461 |
hide_welcome = gr.update(visible=False)
|
| 5462 |
|
| 5463 |
if is_custom:
|
|
@@ -5762,7 +5762,7 @@ def create_settings_tab():
|
|
| 5762 |
# Build use case choices from VERTICAL_LINES × DEMO_FUNCTIONS
|
| 5763 |
use_case_choices = []
|
| 5764 |
for v_name, lines in VERTICAL_LINES.items():
|
| 5765 |
-
if v_name == "
|
| 5766 |
continue
|
| 5767 |
for line in lines:
|
| 5768 |
for f_name in DEMO_FUNCTIONS:
|
|
|
|
| 5411 |
# App tab: vertical → line + function cascade
|
| 5412 |
def update_line_on_vertical(vertical):
|
| 5413 |
lines = VERTICAL_LINES.get(vertical, [])
|
| 5414 |
+
if vertical == "★ CUSTOM ★" or not lines:
|
| 5415 |
return (
|
| 5416 |
+
gr.Dropdown(choices=["— not used —"], value="— not used —", interactive=False, label="Line (n/a for custom)"),
|
| 5417 |
+
gr.Dropdown(choices=["— not used —"], value="— not used —", interactive=False, label="Function (n/a for custom)"),
|
| 5418 |
gr.Textbox(label="Context *", placeholder="Describe your use case, industry, and key metrics...", interactive=True),
|
| 5419 |
)
|
| 5420 |
return (
|
| 5421 |
+
gr.Dropdown(choices=lines, value=lines[0], interactive=True, label="Line"),
|
| 5422 |
+
gr.Dropdown(choices=DEMO_FUNCTIONS, value=DEMO_FUNCTIONS[0], interactive=True, label="Function"),
|
| 5423 |
gr.Textbox(label="Context", placeholder="Any extra context for the demo...", interactive=True),
|
| 5424 |
)
|
| 5425 |
|
|
|
|
| 5457 |
else:
|
| 5458 |
raw_company = f"{line or vertical} Demo"
|
| 5459 |
|
| 5460 |
+
is_custom = (vertical == "★ CUSTOM ★")
|
| 5461 |
hide_welcome = gr.update(visible=False)
|
| 5462 |
|
| 5463 |
if is_custom:
|
|
|
|
| 5762 |
# Build use case choices from VERTICAL_LINES × DEMO_FUNCTIONS
|
| 5763 |
use_case_choices = []
|
| 5764 |
for v_name, lines in VERTICAL_LINES.items():
|
| 5765 |
+
if v_name == "★ CUSTOM ★":
|
| 5766 |
continue
|
| 5767 |
for line in lines:
|
| 5768 |
for f_name in DEMO_FUNCTIONS:
|
|
@@ -115,7 +115,7 @@ VERTICAL_LINES = {
|
|
| 115 |
"Agriculture": [
|
| 116 |
"Forestry", "Fishing", "Hunting", "Cultivation", "Raw Materials",
|
| 117 |
],
|
| 118 |
-
"
|
| 119 |
}
|
| 120 |
|
| 121 |
DEMO_FUNCTIONS = ["Sales", "Marketing", "Finance", "HR", "IT", "Legal"]
|
|
|
|
| 115 |
"Agriculture": [
|
| 116 |
"Forestry", "Fishing", "Hunting", "Cultivation", "Raw Materials",
|
| 117 |
],
|
| 118 |
+
"★ CUSTOM ★": [],
|
| 119 |
}
|
| 120 |
|
| 121 |
DEMO_FUNCTIONS = ["Sales", "Marketing", "Finance", "HR", "IT", "Legal"]
|
|
@@ -221,15 +221,12 @@ Return ONLY a JSON array of strings. Example: ["value1", "value2", "value3"]"""
|
|
| 221 |
columns_desc = "\n".join(column_specs)
|
| 222 |
column_names = [col['name'] for col in columns]
|
| 223 |
|
| 224 |
-
# Generate
|
|
|
|
|
|
|
| 225 |
all_entities = []
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
while len(all_entities) < num_entities:
|
| 229 |
-
remaining = num_entities - len(all_entities)
|
| 230 |
-
current_batch = min(batch_size, remaining)
|
| 231 |
-
|
| 232 |
-
prompt = f"""Generate {current_batch} realistic, coherent {table_name} records for a database.
|
| 233 |
|
| 234 |
Company: {context.company_name}
|
| 235 |
Industry: {context.industry}
|
|
@@ -243,7 +240,7 @@ CRITICAL: The values in each row must be COHERENT with each other. For example:
|
|
| 243 |
- If product_category is "Books", product_description should describe a book
|
| 244 |
- If brand_name is "Nike", the product should be sports/athletic related
|
| 245 |
|
| 246 |
-
Generate {
|
| 247 |
|
| 248 |
Return ONLY a JSON array of objects. Example format:
|
| 249 |
[
|
|
@@ -251,54 +248,55 @@ Return ONLY a JSON array of objects. Example format:
|
|
| 251 |
...
|
| 252 |
]"""
|
| 253 |
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
|
|
|
| 296 |
if len(all_entities) < num_entities:
|
| 297 |
fallback = self._generate_entity_fallback(
|
| 298 |
table_name, columns, num_entities - len(all_entities)
|
| 299 |
)
|
| 300 |
all_entities.extend(fallback)
|
| 301 |
-
|
| 302 |
return all_entities[:num_entities]
|
| 303 |
|
| 304 |
def _generate_entity_fallback(
|
|
|
|
| 221 |
columns_desc = "\n".join(column_specs)
|
| 222 |
column_names = [col['name'] for col in columns]
|
| 223 |
|
| 224 |
+
# Generate a "seed" batch via a single API call, then cycle for remaining rows.
|
| 225 |
+
# Avoids making N/20 API calls when num_entities is large (e.g. 200 dim rows).
|
| 226 |
+
seed_target = min(50, num_entities)
|
| 227 |
all_entities = []
|
| 228 |
+
|
| 229 |
+
prompt = f"""Generate {seed_target} realistic, coherent {table_name} records for a database.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
Company: {context.company_name}
|
| 232 |
Industry: {context.industry}
|
|
|
|
| 240 |
- If product_category is "Books", product_description should describe a book
|
| 241 |
- If brand_name is "Nike", the product should be sports/athletic related
|
| 242 |
|
| 243 |
+
Generate {seed_target} diverse, realistic records. Each record should tell a coherent story.
|
| 244 |
|
| 245 |
Return ONLY a JSON array of objects. Example format:
|
| 246 |
[
|
|
|
|
| 248 |
...
|
| 249 |
]"""
|
| 250 |
|
| 251 |
+
try:
|
| 252 |
+
response = self.anthropic_client.messages.create(
|
| 253 |
+
model="claude-sonnet-4-20250514",
|
| 254 |
+
max_tokens=4000,
|
| 255 |
+
messages=[{"role": "user", "content": prompt}]
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
json_str = response.content[0].text.strip()
|
| 259 |
+
# Handle markdown
|
| 260 |
+
if json_str.startswith('```'):
|
| 261 |
+
json_str = re.sub(r'^```\w*\n?', '', json_str)
|
| 262 |
+
json_str = re.sub(r'\n?```$', '', json_str)
|
| 263 |
+
|
| 264 |
+
entities = json.loads(json_str)
|
| 265 |
+
|
| 266 |
+
if isinstance(entities, list):
|
| 267 |
+
for entity in entities:
|
| 268 |
+
if isinstance(entity, dict):
|
| 269 |
+
# Normalize keys to match column names
|
| 270 |
+
normalized = {}
|
| 271 |
+
for col in columns:
|
| 272 |
+
col_name = col['name']
|
| 273 |
+
# Try exact match first, then case-insensitive
|
| 274 |
+
if col_name in entity:
|
| 275 |
+
normalized[col_name] = str(entity[col_name])
|
| 276 |
+
else:
|
| 277 |
+
for key in entity:
|
| 278 |
+
if key.lower() == col_name.lower():
|
| 279 |
+
normalized[col_name] = str(entity[key])
|
| 280 |
+
break
|
| 281 |
+
if normalized:
|
| 282 |
+
all_entities.append(normalized)
|
| 283 |
+
|
| 284 |
+
except Exception as e:
|
| 285 |
+
print(f"Warning: Entity batch generation failed for {table_name}: {e}")
|
| 286 |
+
|
| 287 |
+
# If seed generation produced results, cycle them to fill remaining rows
|
| 288 |
+
if all_entities and len(all_entities) < num_entities:
|
| 289 |
+
seed = list(all_entities)
|
| 290 |
+
while len(all_entities) < num_entities:
|
| 291 |
+
all_entities.append(seed[len(all_entities) % len(seed)].copy())
|
| 292 |
+
|
| 293 |
+
# If we still don't have enough (seed generation failed), fall back
|
| 294 |
if len(all_entities) < num_entities:
|
| 295 |
fallback = self._generate_entity_fallback(
|
| 296 |
table_name, columns, num_entities - len(all_entities)
|
| 297 |
)
|
| 298 |
all_entities.extend(fallback)
|
| 299 |
+
|
| 300 |
return all_entities[:num_entities]
|
| 301 |
|
| 302 |
def _generate_entity_fallback(
|
|
@@ -185,11 +185,55 @@ def select_gradio_dropdown(page: Page, label: str, value: str):
|
|
| 185 |
page.wait_for_timeout(300)
|
| 186 |
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
def submit_job(page: Page, test_case: dict):
|
| 189 |
"""Fill the form and click GO."""
|
| 190 |
-
# Navigate
|
| 191 |
-
|
| 192 |
-
page.wait_for_selector('button[role=tab]', timeout=60000)
|
| 193 |
page.wait_for_timeout(2000)
|
| 194 |
|
| 195 |
page.click('button[role=tab]:has-text("📱 App")', timeout=10000)
|
|
@@ -914,8 +958,17 @@ def run_single_test(page: Page, test_case: dict, config: dict) -> dict:
|
|
| 914 |
submit_job(page, test_case)
|
| 915 |
print(f" ⏳ Monitoring pipeline (timeout: {config['grading']['timeout_minutes']}min)...")
|
| 916 |
|
| 917 |
-
poll_interval
|
| 918 |
-
last_stages
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 919 |
while time.time() - start < timeout_sec:
|
| 920 |
time.sleep(poll_interval)
|
| 921 |
stages = read_progress(page)
|
|
@@ -923,10 +976,26 @@ def run_single_test(page: Page, test_case: dict, config: dict) -> dict:
|
|
| 923 |
done = [k for k, v in stages.items() if v == "complete"]
|
| 924 |
running = [k for k, v in stages.items() if v == "running"]
|
| 925 |
print(f" ✓ {done} ▶ {running}")
|
| 926 |
-
last_stages
|
|
|
|
| 927 |
if pipeline_finished(stages):
|
| 928 |
print(" ✅ Pipeline complete")
|
| 929 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 930 |
else:
|
| 931 |
result["timed_out"] = True
|
| 932 |
print(f" ⏰ Timed out after {config['grading']['timeout_minutes']} min")
|
|
@@ -1039,11 +1108,7 @@ def run_quality_suite(max_tests: int = None):
|
|
| 1039 |
page = ctx.new_page()
|
| 1040 |
page.goto(BASE_URL, timeout=90000)
|
| 1041 |
page.wait_for_selector('input[placeholder="Type here..."]', timeout=90000)
|
| 1042 |
-
|
| 1043 |
-
page.fill('input[type=password]', TEST_PASSWORD)
|
| 1044 |
-
page.click('button:has-text("Login")')
|
| 1045 |
-
page.wait_for_selector('button[role=tab]', timeout=90000)
|
| 1046 |
-
page.wait_for_timeout(3000)
|
| 1047 |
print("✅ Logged in\n")
|
| 1048 |
|
| 1049 |
for i, test_case in enumerate(suite, 1):
|
|
|
|
| 185 |
page.wait_for_timeout(300)
|
| 186 |
|
| 187 |
|
| 188 |
+
def _do_login(page: Page):
|
| 189 |
+
"""Fill and submit the login form, then wait for tabs."""
|
| 190 |
+
page.fill('input[type=text]', TEST_USER)
|
| 191 |
+
page.fill('input[type=password]', TEST_PASSWORD)
|
| 192 |
+
page.click('button:has-text("Login")')
|
| 193 |
+
page.wait_for_selector('button[role=tab]', timeout=90000)
|
| 194 |
+
page.wait_for_timeout(3000)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _navigate_and_ensure_logged_in(page: Page, max_wait_secs: int = 300):
|
| 198 |
+
"""
|
| 199 |
+
Navigate to BASE_URL and ensure we're on the logged-in app.
|
| 200 |
+
Handles: HF space sleeping/rebuilding after a long test, session expiry.
|
| 201 |
+
Retries for up to max_wait_secs before raising.
|
| 202 |
+
"""
|
| 203 |
+
deadline = time.time() + max_wait_secs
|
| 204 |
+
attempt = 0
|
| 205 |
+
while True:
|
| 206 |
+
attempt += 1
|
| 207 |
+
try:
|
| 208 |
+
page.goto(BASE_URL, timeout=90000)
|
| 209 |
+
# Wait for either the logged-in app (tabs) or the login form
|
| 210 |
+
page.wait_for_selector(
|
| 211 |
+
'button[role=tab], input[type=password]',
|
| 212 |
+
timeout=60000,
|
| 213 |
+
)
|
| 214 |
+
break
|
| 215 |
+
except Exception as nav_err:
|
| 216 |
+
remaining = int(deadline - time.time())
|
| 217 |
+
if remaining <= 0:
|
| 218 |
+
raise RuntimeError(
|
| 219 |
+
f"Space not reachable after {max_wait_secs}s: {nav_err}"
|
| 220 |
+
) from nav_err
|
| 221 |
+
print(f" ⚠️ Space not ready (attempt {attempt}) — retrying in 30s ({remaining}s left)...")
|
| 222 |
+
time.sleep(30)
|
| 223 |
+
|
| 224 |
+
# If we landed on the login page (session expired or space rebuilt), re-login
|
| 225 |
+
try:
|
| 226 |
+
if page.locator('input[type=password]').is_visible(timeout=2000):
|
| 227 |
+
print(" 🔑 Session expired — re-logging in...")
|
| 228 |
+
_do_login(page)
|
| 229 |
+
except Exception:
|
| 230 |
+
pass # Already on the app — no login needed
|
| 231 |
+
|
| 232 |
+
|
| 233 |
def submit_job(page: Page, test_case: dict):
|
| 234 |
"""Fill the form and click GO."""
|
| 235 |
+
# Navigate, re-logging in if the session expired (e.g. after a long prior test)
|
| 236 |
+
_navigate_and_ensure_logged_in(page)
|
|
|
|
| 237 |
page.wait_for_timeout(2000)
|
| 238 |
|
| 239 |
page.click('button[role=tab]:has-text("📱 App")', timeout=10000)
|
|
|
|
| 958 |
submit_job(page, test_case)
|
| 959 |
print(f" ⏳ Monitoring pipeline (timeout: {config['grading']['timeout_minutes']}min)...")
|
| 960 |
|
| 961 |
+
poll_interval = 15
|
| 962 |
+
last_stages = {}
|
| 963 |
+
last_change_time = time.time()
|
| 964 |
+
STUCK_THRESHOLD = 20 * 60 # 20 min with no stage change → bail early
|
| 965 |
+
PIPELINE_ERROR_INDICATORS = [
|
| 966 |
+
"pipeline has been interrupted",
|
| 967 |
+
"An unexpected error occurred",
|
| 968 |
+
"Population failed",
|
| 969 |
+
"Pipeline failed",
|
| 970 |
+
"Something went wrong during the pipeline",
|
| 971 |
+
]
|
| 972 |
while time.time() - start < timeout_sec:
|
| 973 |
time.sleep(poll_interval)
|
| 974 |
stages = read_progress(page)
|
|
|
|
| 976 |
done = [k for k, v in stages.items() if v == "complete"]
|
| 977 |
running = [k for k, v in stages.items() if v == "running"]
|
| 978 |
print(f" ✓ {done} ▶ {running}")
|
| 979 |
+
last_stages = stages
|
| 980 |
+
last_change_time = time.time()
|
| 981 |
if pipeline_finished(stages):
|
| 982 |
print(" ✅ Pipeline complete")
|
| 983 |
break
|
| 984 |
+
# Detect hard pipeline failure in the chat output
|
| 985 |
+
try:
|
| 986 |
+
page_text = page.inner_text('body')
|
| 987 |
+
if any(ind in page_text for ind in PIPELINE_ERROR_INDICATORS):
|
| 988 |
+
result["timed_out"] = True
|
| 989 |
+
print(" ❌ Pipeline error detected in page — stopping early")
|
| 990 |
+
break
|
| 991 |
+
except Exception:
|
| 992 |
+
pass
|
| 993 |
+
# Bail if stages have been stuck for too long
|
| 994 |
+
stuck_secs = time.time() - last_change_time
|
| 995 |
+
if stuck_secs > STUCK_THRESHOLD and any(v == "running" for v in stages.values()):
|
| 996 |
+
result["timed_out"] = True
|
| 997 |
+
print(f" ⏰ Stage stuck for {int(stuck_secs/60)}min — treating as failure")
|
| 998 |
+
break
|
| 999 |
else:
|
| 1000 |
result["timed_out"] = True
|
| 1001 |
print(f" ⏰ Timed out after {config['grading']['timeout_minutes']} min")
|
|
|
|
| 1108 |
page = ctx.new_page()
|
| 1109 |
page.goto(BASE_URL, timeout=90000)
|
| 1110 |
page.wait_for_selector('input[placeholder="Type here..."]', timeout=90000)
|
| 1111 |
+
_do_login(page)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1112 |
print("✅ Logged in\n")
|
| 1113 |
|
| 1114 |
for i, test_case in enumerate(suite, 1):
|