Spaces:
Running
fix: prevent NULL inserts for NOT NULL columns (AIRCRAFT/SEAT_CAPACITY_TOTAL pattern)
Browse filesTwo root-cause fixes in generator.py:
1. FK block: `continue` now only fires when fk_ref found AND get_fk_value returns non-None;
previously always fired, leaving unset columns as None if referenced table was empty
2. Coherent entity block: if AI returns null for any column, fall through to generic
generation instead of assigning None (was only handling numeric columns, not type-unknown)
Bridge safety net: convert_value now returns 0.0 instead of None for numeric columns
that receive unparseable strings β satisfies NOT NULL without crashing
Also includes e2e_quality.py improvements: late_complete status, liveboard links
section, --env-name arg, save_summary_md, company name in table display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- legitdata_bridge.py +4 -4
- legitdata_project/legitdata/generator.py +37 -25
- tests/e2e_quality.py +162 -16
|
@@ -264,12 +264,12 @@ class KeyPairSnowflakeWriter:
|
|
| 264 |
import re as _re
|
| 265 |
cleaned = _re.sub(r'[^\d.\-+eE]', '', value.replace(',', ''))
|
| 266 |
try:
|
| 267 |
-
value = float(cleaned) if cleaned else
|
| 268 |
except (ValueError, TypeError):
|
| 269 |
-
|
| 270 |
elif not isinstance(value, (int, float)):
|
| 271 |
-
# dict, list, or other non-numeric type β
|
| 272 |
-
|
| 273 |
precision = info.get('precision', 38)
|
| 274 |
scale = info.get('scale', 0)
|
| 275 |
if isinstance(value, (int, float)):
|
|
|
|
| 264 |
import re as _re
|
| 265 |
cleaned = _re.sub(r'[^\d.\-+eE]', '', value.replace(',', ''))
|
| 266 |
try:
|
| 267 |
+
value = float(cleaned) if cleaned else 0.0
|
| 268 |
except (ValueError, TypeError):
|
| 269 |
+
value = 0.0 # Can't coerce β use 0 rather than NULL to satisfy NOT NULL
|
| 270 |
elif not isinstance(value, (int, float)):
|
| 271 |
+
# dict, list, or other non-numeric type β use 0 rather than NULL
|
| 272 |
+
value = 0.0
|
| 273 |
precision = info.get('precision', 38)
|
| 274 |
scale = info.get('scale', 0)
|
| 275 |
if isinstance(value, (int, float)):
|
|
@@ -991,41 +991,53 @@ class LegitGenerator:
|
|
| 991 |
fk_ref = fk
|
| 992 |
break
|
| 993 |
if fk_ref:
|
| 994 |
-
|
| 995 |
fk_ref.references_table,
|
| 996 |
fk_ref.references_column,
|
| 997 |
distribution="pareto" if table.is_fact_table else "uniform"
|
| 998 |
)
|
| 999 |
-
|
| 1000 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1001 |
# Coherent entity columns - get from pre-generated entities
|
| 1002 |
if coherent_entities and i < len(coherent_entities):
|
| 1003 |
entity = coherent_entities[i]
|
| 1004 |
if col_name in entity:
|
| 1005 |
entity_value = entity[col_name]
|
| 1006 |
-
#
|
| 1007 |
-
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
))
|
| 1012 |
-
|
| 1013 |
-
if is_numeric_col:
|
| 1014 |
-
# Coerce to float and store as Python numeric, not string.
|
| 1015 |
-
# Strip currency symbols / units before trying.
|
| 1016 |
-
import re as _re
|
| 1017 |
-
cleaned = _re.sub(r'[^\d.\-+eE]', '', str(entity_value).replace(',', ''))
|
| 1018 |
-
try:
|
| 1019 |
-
row[col_name] = float(cleaned)
|
| 1020 |
-
continue
|
| 1021 |
-
except (ValueError, TypeError):
|
| 1022 |
-
# AI gave us unparseable text β fall through to generic
|
| 1023 |
-
if i == 0:
|
| 1024 |
-
print(f" [WARN] {col_name}: AI gave '{entity_value}' for numeric column, using inferred instead")
|
| 1025 |
-
pass # Fall through to generic generation below
|
| 1026 |
else:
|
| 1027 |
-
|
| 1028 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1029 |
|
| 1030 |
# GENERIC columns - use classification strategy if set, otherwise infer
|
| 1031 |
col_class = table_class.get(column.name, {})
|
|
|
|
| 991 |
fk_ref = fk
|
| 992 |
break
|
| 993 |
if fk_ref:
|
| 994 |
+
fk_value = self.fk_manager.get_fk_value(
|
| 995 |
fk_ref.references_table,
|
| 996 |
fk_ref.references_column,
|
| 997 |
distribution="pareto" if table.is_fact_table else "uniform"
|
| 998 |
)
|
| 999 |
+
if fk_value is not None:
|
| 1000 |
+
row[col_name] = fk_value
|
| 1001 |
+
continue
|
| 1002 |
+
# FK lookup returned None (referenced table empty) β fall through to generic
|
| 1003 |
+
if i == 0:
|
| 1004 |
+
print(f" [WARN] {col_name}: FK lookup returned None for {fk_ref.references_table}.{fk_ref.references_column}, using generic fallback")
|
| 1005 |
+
# fk_ref not found or FK returned None β fall through to generic
|
| 1006 |
+
|
| 1007 |
# Coherent entity columns - get from pre-generated entities
|
| 1008 |
if coherent_entities and i < len(coherent_entities):
|
| 1009 |
entity = coherent_entities[i]
|
| 1010 |
if col_name in entity:
|
| 1011 |
entity_value = entity[col_name]
|
| 1012 |
+
# If AI returned null for this column, fall through to generic generation
|
| 1013 |
+
if entity_value is None:
|
| 1014 |
+
if i == 0:
|
| 1015 |
+
print(f" [WARN] {col_name}: AI returned null, using generic fallback")
|
| 1016 |
+
# Fall through to generic generation below
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1017 |
else:
|
| 1018 |
+
# Check if column is numeric but AI gave us text
|
| 1019 |
+
data_type_upper = (column.data_type or '').upper()
|
| 1020 |
+
is_numeric_col = any(t in data_type_upper for t in (
|
| 1021 |
+
'INT', 'NUMBER', 'NUMERIC', 'DECIMAL', 'BIGINT', 'SMALLINT',
|
| 1022 |
+
'FLOAT', 'REAL', 'DOUBLE', 'FIXED', 'MONEY',
|
| 1023 |
+
))
|
| 1024 |
+
|
| 1025 |
+
if is_numeric_col:
|
| 1026 |
+
# Coerce to float and store as Python numeric, not string.
|
| 1027 |
+
# Strip currency symbols / units before trying.
|
| 1028 |
+
import re as _re
|
| 1029 |
+
cleaned = _re.sub(r'[^\d.\-+eE]', '', str(entity_value).replace(',', ''))
|
| 1030 |
+
try:
|
| 1031 |
+
row[col_name] = float(cleaned)
|
| 1032 |
+
continue
|
| 1033 |
+
except (ValueError, TypeError):
|
| 1034 |
+
# AI gave us unparseable text β fall through to generic
|
| 1035 |
+
if i == 0:
|
| 1036 |
+
print(f" [WARN] {col_name}: AI gave '{entity_value}' for numeric column, using inferred instead")
|
| 1037 |
+
# Fall through to generic generation below
|
| 1038 |
+
else:
|
| 1039 |
+
row[col_name] = entity_value
|
| 1040 |
+
continue
|
| 1041 |
|
| 1042 |
# GENERIC columns - use classification strategy if set, otherwise infer
|
| 1043 |
col_class = table_class.get(column.name, {})
|
|
@@ -1085,6 +1085,41 @@ def grade_stages(stages: dict, config: dict) -> dict:
|
|
| 1085 |
return {"stage_total": total, "breakdown": breakdown}
|
| 1086 |
|
| 1087 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1088 |
def compute_grade(score: float, config: dict) -> str:
|
| 1089 |
grade = "F"
|
| 1090 |
for letter, threshold in sorted(config["grading"]["thresholds"].items(), key=lambda x: -x[1]):
|
|
@@ -1300,7 +1335,7 @@ def run_single_test(page: Page, test_case: dict, config: dict) -> dict:
|
|
| 1300 |
"function": test_case.get("function", ""), "company_url": test_case.get("company_url", ""),
|
| 1301 |
"stages": {}, "run_context": {}, "stage_grading": {}, "ai_grading": {},
|
| 1302 |
"total_score": 0.0, "grade": "F",
|
| 1303 |
-
"error": None, "timed_out": False, "duration_seconds": 0,
|
| 1304 |
"diagnostics": {}, "snowflake_check": {},
|
| 1305 |
"liveboard_viz_count": None,
|
| 1306 |
}
|
|
@@ -1402,8 +1437,16 @@ def run_single_test(page: Page, test_case: dict, config: dict) -> dict:
|
|
| 1402 |
else:
|
| 1403 |
print(f" π¦ Snowflake: schema not found (logged as: {known_schema or 'not in logs'})")
|
| 1404 |
|
| 1405 |
-
#
|
| 1406 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1407 |
print_diagnostics(diag, {}) # sf already printed above
|
| 1408 |
|
| 1409 |
# Stage scoring
|
|
@@ -1441,10 +1484,85 @@ def save_results(run: dict) -> Path:
|
|
| 1441 |
return path
|
| 1442 |
|
| 1443 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1444 |
# ---------------------------------------------------------------------------
|
| 1445 |
# Main
|
| 1446 |
# ---------------------------------------------------------------------------
|
| 1447 |
-
def run_quality_suite(max_tests: int = None):
|
| 1448 |
if not TEST_USER or not TEST_PASSWORD:
|
| 1449 |
raise RuntimeError("TEST_USER and TEST_PASSWORD must be set in .env")
|
| 1450 |
|
|
@@ -1508,9 +1626,15 @@ def run_quality_suite(max_tests: int = None):
|
|
| 1508 |
viz_n = result.get("liveboard_viz_count")
|
| 1509 |
viz_note = f" ({viz_n} vizzes)" if viz_n is not None else ""
|
| 1510 |
print(f" Liveboard: {ts_url}/#/pinboard/{l_guid}{viz_note}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1511 |
print(f" TOTAL: {result['total_score']}/100 Grade: {result['grade']}"
|
| 1512 |
f" ({result['duration_seconds']}s)"
|
| 1513 |
-
f"{
|
| 1514 |
f"{' β ERROR' if result['error'] else ''}")
|
| 1515 |
|
| 1516 |
ctx.close()
|
|
@@ -1521,10 +1645,12 @@ def run_quality_suite(max_tests: int = None):
|
|
| 1521 |
|
| 1522 |
run = {
|
| 1523 |
"run_id": run_id, "timestamp": datetime.now().isoformat(),
|
|
|
|
| 1524 |
"avg_score": avg, "overall_grade": grade,
|
| 1525 |
"test_count": len(results), "tests": results,
|
| 1526 |
}
|
| 1527 |
-
save_results(run)
|
|
|
|
| 1528 |
|
| 1529 |
# --- Summary table ---
|
| 1530 |
try:
|
|
@@ -1561,18 +1687,33 @@ def run_quality_suite(max_tests: int = None):
|
|
| 1561 |
lb_url, lb_base = ctx.get("liveboard_guid",""), ctx.get("ts_base_url","")
|
| 1562 |
lk = _link(f"{lb_base}/#/pinboard/{lb_url}", " π ") if lb_url and lb_base else " β "
|
| 1563 |
parts = [p for p in [r.get("vertical",""), r.get("line",""), r.get("function","")] if p and p != "* CUSTOM *"]
|
| 1564 |
-
uc = (" / ".join(parts) if parts else r.get("
|
| 1565 |
errs = ag.get("grading_errors", [])
|
| 1566 |
-
if r.get("
|
| 1567 |
-
elif
|
| 1568 |
-
elif
|
| 1569 |
-
elif
|
| 1570 |
-
elif
|
| 1571 |
-
|
| 1572 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1573 |
|
| 1574 |
print(_div("β", "β΄", "β"))
|
| 1575 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1576 |
|
| 1577 |
except Exception as _table_err:
|
| 1578 |
print(f"\nβ οΈ Summary table failed: {_table_err}")
|
|
@@ -1601,6 +1742,8 @@ if __name__ == "__main__":
|
|
| 1601 |
help="Fill form but do not click GO β browser opens visibly for inspection")
|
| 1602 |
parser.add_argument("--url", type=str, default="",
|
| 1603 |
help="Override TEST_TARGET_URL (e.g. --url https://thoughtspot-dp-demoprep.hf.space)")
|
|
|
|
|
|
|
| 1604 |
args = parser.parse_args()
|
| 1605 |
if args.dry_run:
|
| 1606 |
DRY_RUN = True
|
|
@@ -1608,4 +1751,7 @@ if __name__ == "__main__":
|
|
| 1608 |
BASE_URL = args.url
|
| 1609 |
if not BASE_URL:
|
| 1610 |
raise ValueError("No target URL β set TEST_TARGET_URL in .env or pass --url <url>")
|
| 1611 |
-
run_quality_suite(
|
|
|
|
|
|
|
|
|
|
|
|
| 1085 |
return {"stage_total": total, "breakdown": breakdown}
|
| 1086 |
|
| 1087 |
|
| 1088 |
+
def reconcile_stages_with_logs(stages: dict, diag: dict) -> dict:
|
| 1089 |
+
"""
|
| 1090 |
+
If session_logs confirms stages completed that the UI monitor missed
|
| 1091 |
+
(e.g. slow DDL that triggered the 20-min bail-out), upgrade those stages to 'complete'.
|
| 1092 |
+
Returns a new dict β does not mutate the original.
|
| 1093 |
+
"""
|
| 1094 |
+
if not diag.get("found"):
|
| 1095 |
+
return stages
|
| 1096 |
+
|
| 1097 |
+
completed_in_logs = set(diag.get("stages_completed", []))
|
| 1098 |
+
|
| 1099 |
+
# Map session_log stage names β UI progress keys
|
| 1100 |
+
log_to_ui = {
|
| 1101 |
+
"research": "research",
|
| 1102 |
+
"ddl": "ddl",
|
| 1103 |
+
"populate": "data", # session calls it 'populate', UI shows 'Data'
|
| 1104 |
+
"data": "data",
|
| 1105 |
+
"thoughtspot": "thoughtspot",
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
stages = dict(stages) # copy β don't mutate
|
| 1109 |
+
for log_stage, ui_key in log_to_ui.items():
|
| 1110 |
+
if log_stage in completed_in_logs and stages.get(ui_key) != "complete":
|
| 1111 |
+
old = stages.get(ui_key, "unknown")
|
| 1112 |
+
stages[ui_key] = "complete"
|
| 1113 |
+
print(f" βΉοΈ Stage '{ui_key}' upgraded to complete via session_logs (monitor saw: {old})")
|
| 1114 |
+
|
| 1115 |
+
# Synthetic 'complete' key β set if all main stages now complete
|
| 1116 |
+
main = ("research", "ddl", "data", "thoughtspot")
|
| 1117 |
+
if all(stages.get(s) == "complete" for s in main):
|
| 1118 |
+
stages["complete"] = "complete"
|
| 1119 |
+
|
| 1120 |
+
return stages
|
| 1121 |
+
|
| 1122 |
+
|
| 1123 |
def compute_grade(score: float, config: dict) -> str:
|
| 1124 |
grade = "F"
|
| 1125 |
for letter, threshold in sorted(config["grading"]["thresholds"].items(), key=lambda x: -x[1]):
|
|
|
|
| 1335 |
"function": test_case.get("function", ""), "company_url": test_case.get("company_url", ""),
|
| 1336 |
"stages": {}, "run_context": {}, "stage_grading": {}, "ai_grading": {},
|
| 1337 |
"total_score": 0.0, "grade": "F",
|
| 1338 |
+
"error": None, "timed_out": False, "late_complete": False, "duration_seconds": 0,
|
| 1339 |
"diagnostics": {}, "snowflake_check": {},
|
| 1340 |
"liveboard_viz_count": None,
|
| 1341 |
}
|
|
|
|
| 1437 |
else:
|
| 1438 |
print(f" π¦ Snowflake: schema not found (logged as: {known_schema or 'not in logs'})")
|
| 1439 |
|
| 1440 |
+
# Reconcile stages with session_logs β catches cases where the monitor bailed early
|
| 1441 |
+
# but the pipeline actually completed (e.g. slow DDL that took >20 min)
|
| 1442 |
+
result["stages"] = reconcile_stages_with_logs(result["stages"], diag)
|
| 1443 |
+
main_stages = ("research", "ddl", "data", "thoughtspot")
|
| 1444 |
+
if result["timed_out"] and all(result["stages"].get(s) == "complete" for s in main_stages):
|
| 1445 |
+
result["late_complete"] = True
|
| 1446 |
+
print(" βΉοΈ Pipeline completed late β all stages confirmed via session_logs")
|
| 1447 |
+
|
| 1448 |
+
# Print diagnostics on timeout/error β skip for late_complete since pipeline did finish
|
| 1449 |
+
if result["error"] or (result["timed_out"] and not result["late_complete"]):
|
| 1450 |
print_diagnostics(diag, {}) # sf already printed above
|
| 1451 |
|
| 1452 |
# Stage scoring
|
|
|
|
| 1484 |
return path
|
| 1485 |
|
| 1486 |
|
| 1487 |
+
def save_summary_md(run: dict, json_path: Path, env_name: str = "") -> Path:
|
| 1488 |
+
"""
|
| 1489 |
+
Save a compact markdown summary alongside the JSON.
|
| 1490 |
+
Also writes to latest_{env_name}_summary.md (or latest_summary.md) for nightly use.
|
| 1491 |
+
env_name: 'test' | 'prod' | '' (default)
|
| 1492 |
+
"""
|
| 1493 |
+
W = 32
|
| 1494 |
+
lines = [
|
| 1495 |
+
f"# DemoPrep Quality Run β {run['timestamp'][:16]}",
|
| 1496 |
+
f"**Target:** {run.get('target_url', 'unknown')} | "
|
| 1497 |
+
f"**Avg:** {run['avg_score']}/100 Grade: {run['overall_grade']}",
|
| 1498 |
+
"",
|
| 1499 |
+
"| Company | Use Case | Data | LB | Total | Note |",
|
| 1500 |
+
"|---------|----------|------|----|-------|------|",
|
| 1501 |
+
]
|
| 1502 |
+
for r in run["tests"]:
|
| 1503 |
+
ag = r.get("ai_grading", {})
|
| 1504 |
+
ds = ag.get("data_score", "n/a")
|
| 1505 |
+
ls = ag.get("liveboard_score", "n/a")
|
| 1506 |
+
t_str = f"{r['total_score']}/{r['grade']}"
|
| 1507 |
+
company = r.get("company", r["name"])
|
| 1508 |
+
parts = [p for p in [r.get("vertical",""), r.get("line",""), r.get("function","")]
|
| 1509 |
+
if p and p != "* CUSTOM *"]
|
| 1510 |
+
uc = (" / ".join(parts) if parts else "Custom")[:W]
|
| 1511 |
+
if r.get("error"): note = "β network err"
|
| 1512 |
+
elif r.get("late_complete"): note = "β οΈ slow (complete)"
|
| 1513 |
+
elif r.get("timed_out"): note = "β° timeout"
|
| 1514 |
+
elif r["grade"] in ("A","B"): note = "π great"
|
| 1515 |
+
elif r["grade"] == "C": note = "β
solid"
|
| 1516 |
+
else: note = ""
|
| 1517 |
+
ctx = r.get("run_context") or {}
|
| 1518 |
+
lb_guid = ctx.get("liveboard_guid","")
|
| 1519 |
+
lb_base = (ctx.get("ts_base_url","") or "").rstrip("/")
|
| 1520 |
+
lb_link = f"[lb]({lb_base}/#/pinboard/{lb_guid})" if lb_guid and lb_base else "β"
|
| 1521 |
+
lines.append(f"| {company} | {uc} | {ds} | {ls} | {t_str} {lb_link} | {note} |")
|
| 1522 |
+
|
| 1523 |
+
# Issues and errors
|
| 1524 |
+
issues = []
|
| 1525 |
+
for r in run["tests"]:
|
| 1526 |
+
if r.get("timed_out") and not r.get("late_complete"):
|
| 1527 |
+
last = r.get("diagnostics",{}).get("last_event","unknown")
|
| 1528 |
+
issues.append(f"- **{r.get('company',r['name'])}**: TIMEOUT β last event: {last}")
|
| 1529 |
+
for err in r.get("ai_grading",{}).get("grading_errors",[]):
|
| 1530 |
+
if "skip" not in err.lower():
|
| 1531 |
+
issues.append(f"- **{r.get('company',r['name'])}**: {err}")
|
| 1532 |
+
if issues:
|
| 1533 |
+
lines += ["", "## Issues", ""] + issues
|
| 1534 |
+
|
| 1535 |
+
# Top data weaknesses
|
| 1536 |
+
weaknesses = []
|
| 1537 |
+
for r in run["tests"]:
|
| 1538 |
+
ag = r.get("ai_grading",{})
|
| 1539 |
+
ww = ag.get("data_weaknesses",[])
|
| 1540 |
+
if ww:
|
| 1541 |
+
weaknesses.append(f"**{r.get('company',r['name'])}** (data={ag.get('data_score','?')}/100):")
|
| 1542 |
+
for w in ww[:2]:
|
| 1543 |
+
weaknesses.append(f" - {w[:120]}")
|
| 1544 |
+
if weaknesses:
|
| 1545 |
+
lines += ["", "## Data Quality Weaknesses", ""] + weaknesses
|
| 1546 |
+
|
| 1547 |
+
lines += ["", "---", f"*JSON: {json_path.name}*"]
|
| 1548 |
+
|
| 1549 |
+
md_text = "\n".join(lines) + "\n"
|
| 1550 |
+
md_path = json_path.with_suffix(".md")
|
| 1551 |
+
md_path.write_text(md_text)
|
| 1552 |
+
|
| 1553 |
+
latest_name = f"latest_{env_name}_summary.md" if env_name else "latest_summary.md"
|
| 1554 |
+
latest = RESULTS_DIR / latest_name
|
| 1555 |
+
latest.write_text(md_text)
|
| 1556 |
+
|
| 1557 |
+
print(f"π Summary: {md_path}")
|
| 1558 |
+
print(f"π Latest: {latest}")
|
| 1559 |
+
return md_path
|
| 1560 |
+
|
| 1561 |
+
|
| 1562 |
# ---------------------------------------------------------------------------
|
| 1563 |
# Main
|
| 1564 |
# ---------------------------------------------------------------------------
|
| 1565 |
+
def run_quality_suite(max_tests: int = None, env_name: str = ""):
|
| 1566 |
if not TEST_USER or not TEST_PASSWORD:
|
| 1567 |
raise RuntimeError("TEST_USER and TEST_PASSWORD must be set in .env")
|
| 1568 |
|
|
|
|
| 1626 |
viz_n = result.get("liveboard_viz_count")
|
| 1627 |
viz_note = f" ({viz_n} vizzes)" if viz_n is not None else ""
|
| 1628 |
print(f" Liveboard: {ts_url}/#/pinboard/{l_guid}{viz_note}")
|
| 1629 |
+
if result.get("late_complete"):
|
| 1630 |
+
timeout_tag = " β οΈ SLOW (completed late)"
|
| 1631 |
+
elif result.get("timed_out"):
|
| 1632 |
+
timeout_tag = " β° TIMEOUT"
|
| 1633 |
+
else:
|
| 1634 |
+
timeout_tag = ""
|
| 1635 |
print(f" TOTAL: {result['total_score']}/100 Grade: {result['grade']}"
|
| 1636 |
f" ({result['duration_seconds']}s)"
|
| 1637 |
+
f"{timeout_tag}"
|
| 1638 |
f"{' β ERROR' if result['error'] else ''}")
|
| 1639 |
|
| 1640 |
ctx.close()
|
|
|
|
| 1645 |
|
| 1646 |
run = {
|
| 1647 |
"run_id": run_id, "timestamp": datetime.now().isoformat(),
|
| 1648 |
+
"target_url": BASE_URL,
|
| 1649 |
"avg_score": avg, "overall_grade": grade,
|
| 1650 |
"test_count": len(results), "tests": results,
|
| 1651 |
}
|
| 1652 |
+
path = save_results(run)
|
| 1653 |
+
save_summary_md(run, path, env_name=env_name)
|
| 1654 |
|
| 1655 |
# --- Summary table ---
|
| 1656 |
try:
|
|
|
|
| 1687 |
lb_url, lb_base = ctx.get("liveboard_guid",""), ctx.get("ts_base_url","")
|
| 1688 |
lk = _link(f"{lb_base}/#/pinboard/{lb_url}", " π ") if lb_url and lb_base else " β "
|
| 1689 |
parts = [p for p in [r.get("vertical",""), r.get("line",""), r.get("function","")] if p and p != "* CUSTOM *"]
|
| 1690 |
+
uc = (" / ".join(parts) if parts else r.get("context","")[:W_UC] or "Custom")[:W_UC]
|
| 1691 |
errs = ag.get("grading_errors", [])
|
| 1692 |
+
if r.get("error"): note = "β network err"
|
| 1693 |
+
elif r.get("late_complete"): note = "β οΈ slow"
|
| 1694 |
+
elif r.get("timed_out"): note = "β° timeout"
|
| 1695 |
+
elif any("auth failed" in (e or "").lower() for e in errs): note = "β auth fail"
|
| 1696 |
+
elif any("parse" in (e or "").lower() for e in errs): note = "β parse fail"
|
| 1697 |
+
elif ds == 0 and ls is not None: note = "β οΈ data fail"
|
| 1698 |
+
elif r["grade"] in ("A","B"): note = "π great"
|
| 1699 |
+
elif r["grade"] == "C": note = "β
solid"
|
| 1700 |
+
else: note = ""
|
| 1701 |
+
company = r.get("company", r["name"])[:W_CO]
|
| 1702 |
+
print(_row(company, uc, d_str, l_str, t_str, lk, note))
|
| 1703 |
|
| 1704 |
print(_div("β", "β΄", "β"))
|
| 1705 |
+
|
| 1706 |
+
# Liveboard links β plain text for easy copy/click
|
| 1707 |
+
print("\n Liveboards:")
|
| 1708 |
+
for i, r in enumerate(results, 1):
|
| 1709 |
+
ctx = r.get("run_context") or {}
|
| 1710 |
+
lb_url = ctx.get("liveboard_guid", "")
|
| 1711 |
+
lb_base = ctx.get("ts_base_url", "")
|
| 1712 |
+
company = r.get("company", r["name"])
|
| 1713 |
+
url = f"{lb_base}/#/pinboard/{lb_url}" if lb_url and lb_base else "β not created"
|
| 1714 |
+
print(f" {i}. {company:<30} {url}")
|
| 1715 |
+
|
| 1716 |
+
print(f"\n{'='*62}\n")
|
| 1717 |
|
| 1718 |
except Exception as _table_err:
|
| 1719 |
print(f"\nβ οΈ Summary table failed: {_table_err}")
|
|
|
|
| 1742 |
help="Fill form but do not click GO β browser opens visibly for inspection")
|
| 1743 |
parser.add_argument("--url", type=str, default="",
|
| 1744 |
help="Override TEST_TARGET_URL (e.g. --url https://thoughtspot-dp-demoprep.hf.space)")
|
| 1745 |
+
parser.add_argument("--env-name", type=str, default="",
|
| 1746 |
+
help="Tag for summary filename: 'test' β latest_test_summary.md, 'prod' β latest_prod_summary.md")
|
| 1747 |
args = parser.parse_args()
|
| 1748 |
if args.dry_run:
|
| 1749 |
DRY_RUN = True
|
|
|
|
| 1751 |
BASE_URL = args.url
|
| 1752 |
if not BASE_URL:
|
| 1753 |
raise ValueError("No target URL β set TEST_TARGET_URL in .env or pass --url <url>")
|
| 1754 |
+
run_quality_suite(
|
| 1755 |
+
max_tests=args.count or (1 if args.dry_run else None),
|
| 1756 |
+
env_name=args.env_name,
|
| 1757 |
+
)
|