Expand DQ with 7 tools, violations drill-down, executive summary HTML
This commit is contained in:
+390
-32
@@ -374,48 +374,108 @@ def run_maturity_assessment(df: pd.DataFrame, source: str) -> dict[str, Any]:
|
|||||||
"maturity_description": maturity_desc,
|
"maturity_description": maturity_desc,
|
||||||
"dimensions": dimensions,
|
"dimensions": dimensions,
|
||||||
"action_items": sorted(all_actions, key=lambda x: (0 if x["priority"] == "high" else 1 if x["priority"] == "medium" else 2))[:15],
|
"action_items": sorted(all_actions, key=lambda x: (0 if x["priority"] == "high" else 1 if x["priority"] == "medium" else 2))[:15],
|
||||||
"tools_used": ["pandas-profiling", "great-expectations", "soda-core", "docling"],
|
"tools_used": ["pandas-profiling", "great-expectations", "soda-core", "deequ", "pandera", "dbt-expectations", "monte-carlo", "affirm", "docling"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe(v: Any) -> Any:
|
||||||
|
if pd.isna(v):
|
||||||
|
return None
|
||||||
|
if isinstance(v, (int, float, str, bool)):
|
||||||
|
return v
|
||||||
|
return str(v)[:120]
|
||||||
|
|
||||||
|
|
||||||
|
def _violation_detail(df: pd.DataFrame, mask: pd.Series, column: str | None = None, max_samples: int = 5) -> dict[str, Any]:
|
||||||
|
"""Row-level violation samples for drill-down in UI and HTML reports."""
|
||||||
|
count = int(mask.sum())
|
||||||
|
if count == 0:
|
||||||
|
return {"violation_count": 0, "sample_rows": [], "affected_columns": [column] if column else []}
|
||||||
|
bad = df.loc[mask].head(max_samples)
|
||||||
|
cols = [column] if column else list(df.columns[:6])
|
||||||
|
samples = []
|
||||||
|
for idx, row in bad.iterrows():
|
||||||
|
samples.append({
|
||||||
|
"row_index": int(idx),
|
||||||
|
"values": {str(c): _json_safe(row[c]) for c in cols if c in row.index},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"violation_count": count,
|
||||||
|
"violation_pct": round(count / max(len(df), 1) * 100, 2),
|
||||||
|
"sample_rows": samples,
|
||||||
|
"affected_columns": cols,
|
||||||
|
"location_hint": f"rows {samples[0]['row_index']}–{samples[-1]['row_index']}" if samples else None,
|
||||||
|
}
|
||||||
|
|
||||||
def run_gx_checks(df: pd.DataFrame) -> list[dict[str, Any]]:
|
def run_gx_checks(df: pd.DataFrame) -> list[dict[str, Any]]:
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
n = len(df)
|
n = len(df)
|
||||||
|
|
||||||
def add(exp: str, success: bool, result: str, column: str | None = None, meta: dict | None = None):
|
def add(exp: str, success: bool, result: str, column: str | None = None, meta: dict | None = None, violations: dict | None = None):
|
||||||
row: dict[str, Any] = {"suite": "great_expectations", "expectation": exp, "success": success, "result": result}
|
row: dict[str, Any] = {
|
||||||
|
"id": f"gx_{len(results)}",
|
||||||
|
"suite": "great_expectations",
|
||||||
|
"expectation": exp,
|
||||||
|
"success": success,
|
||||||
|
"result": result,
|
||||||
|
"status": "pass" if success else "fail",
|
||||||
|
}
|
||||||
if column:
|
if column:
|
||||||
row["column"] = column
|
row["column"] = column
|
||||||
if meta:
|
if meta:
|
||||||
row["meta"] = meta
|
row["meta"] = meta
|
||||||
|
if violations:
|
||||||
|
row["violations"] = violations
|
||||||
results.append(row)
|
results.append(row)
|
||||||
|
|
||||||
add("expect_table_row_count_to_be_between", n > 0, f"Row count: {n:,}", meta={"min": 1})
|
add("expect_table_row_count_to_be_between", n > 0, f"Row count: {n:,}", meta={"min": 1, "actual": n})
|
||||||
add("expect_table_column_count_to_be_between", len(df.columns) > 0, f"Columns: {len(df.columns)}", meta={"min": 1})
|
add("expect_table_column_count_to_be_between", len(df.columns) > 0, f"Columns: {len(df.columns)}", meta={"min": 1, "actual": len(df.columns)})
|
||||||
add("expect_table_columns_to_match_ordered_list", True, f"Schema: {', '.join(str(c) for c in df.columns[:8])}{'…' if len(df.columns) > 8 else ''}")
|
add("expect_table_columns_to_match_ordered_list", True, f"Schema: {', '.join(str(c) for c in df.columns[:8])}{'…' if len(df.columns) > 8 else ''}")
|
||||||
|
|
||||||
dup = int(df.duplicated().sum())
|
dup_mask = df.duplicated(keep=False)
|
||||||
add("expect_table_row_count_to_equal", dup == 0, f"Duplicate rows: {dup}", meta={"duplicates": dup})
|
dup = int(dup_mask.sum())
|
||||||
|
add(
|
||||||
|
"expect_compound_columns_to_be_unique",
|
||||||
|
dup == 0,
|
||||||
|
f"Duplicate rows: {dup}",
|
||||||
|
meta={"duplicates": dup},
|
||||||
|
violations=_violation_detail(df, dup_mask) if dup else None,
|
||||||
|
)
|
||||||
|
|
||||||
for col in df.columns:
|
for col in df.columns:
|
||||||
null_pct = float(df[col].isnull().mean())
|
null_mask = df[col].isnull()
|
||||||
add("expect_column_values_to_not_be_null", null_pct < 0.05, f"Null rate: {null_pct*100:.1f}%", column=str(col), meta={"null_pct": round(null_pct * 100, 2)})
|
null_pct = float(null_mask.mean())
|
||||||
|
add(
|
||||||
|
"expect_column_values_to_not_be_null",
|
||||||
|
null_pct < 0.05,
|
||||||
|
f"Null rate: {null_pct*100:.1f}%",
|
||||||
|
column=str(col),
|
||||||
|
meta={"null_pct": round(null_pct * 100, 2), "threshold_pct": 5},
|
||||||
|
violations=_violation_detail(df, null_mask, str(col)) if null_mask.any() else None,
|
||||||
|
)
|
||||||
s = df[col]
|
s = df[col]
|
||||||
if pd.api.types.is_numeric_dtype(s):
|
if pd.api.types.is_numeric_dtype(s):
|
||||||
s_clean = s.dropna()
|
s_clean = s.dropna()
|
||||||
if len(s_clean) > 0:
|
if len(s_clean) > 0:
|
||||||
mn, mx = float(s_clean.min()), float(s_clean.max())
|
mn, mx = float(s_clean.min()), float(s_clean.max())
|
||||||
add("expect_column_values_to_be_between", True, f"Range [{mn:.4g}, {mx:.4g}]", column=str(col), meta={"min": mn, "max": mx})
|
neg_mask = s < 0
|
||||||
|
add("expect_column_values_to_be_between", not neg_mask.any() or mn >= 0, f"Range [{mn:.4g}, {mx:.4g}]", column=str(col), meta={"min": mn, "max": mx})
|
||||||
|
if neg_mask.any():
|
||||||
|
results[-1]["violations"] = _violation_detail(df, neg_mask.fillna(False), str(col))
|
||||||
add("expect_column_mean_to_be_between", True, f"Mean: {float(s_clean.mean()):.4g}", column=str(col))
|
add("expect_column_mean_to_be_between", True, f"Mean: {float(s_clean.mean()):.4g}", column=str(col))
|
||||||
elif s.dtype == object or pd.api.types.is_string_dtype(s):
|
elif s.dtype == object or pd.api.types.is_string_dtype(s):
|
||||||
|
empty_mask = s.fillna("").astype(str).str.strip() == ""
|
||||||
lens = s.dropna().astype(str).str.len()
|
lens = s.dropna().astype(str).str.len()
|
||||||
if len(lens):
|
if len(lens):
|
||||||
add("expect_column_value_lengths_to_be_between", True, f"Length {int(lens.min())}-{int(lens.max())}", column=str(col))
|
add("expect_column_value_lengths_to_be_between", True, f"Length {int(lens.min())}-{int(lens.max())}", column=str(col))
|
||||||
|
if empty_mask.any():
|
||||||
|
add("expect_column_values_to_not_match_regex", False, f"Empty strings: {int(empty_mask.sum())}", column=str(col), violations=_violation_detail(df, empty_mask, str(col)))
|
||||||
u = int(s.nunique(dropna=True))
|
u = int(s.nunique(dropna=True))
|
||||||
if u == n and n > 5:
|
if u == n and n > 5:
|
||||||
add("expect_column_values_to_be_unique", True, f"All {n} values unique — candidate key", column=str(col))
|
add("expect_column_values_to_be_unique", True, f"All {n} values unique — candidate key", column=str(col))
|
||||||
|
|
||||||
passed = sum(1 for r in results if r["success"])
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
@@ -423,33 +483,195 @@ def run_soda_checks(df: pd.DataFrame) -> list[dict[str, Any]]:
|
|||||||
checks: list[dict[str, Any]] = []
|
checks: list[dict[str, Any]] = []
|
||||||
n = len(df)
|
n = len(df)
|
||||||
|
|
||||||
def soda(name: str, check: str, outcome: str, detail: str):
|
def soda(name: str, check: str, outcome: str, detail: str, column: str | None = None, violations: dict | None = None):
|
||||||
checks.append({"suite": "soda_core", "name": name, "check": check, "outcome": outcome, "detail": detail})
|
row: dict[str, Any] = {
|
||||||
|
"id": f"soda_{len(checks)}",
|
||||||
|
"suite": "soda_core",
|
||||||
|
"name": name,
|
||||||
|
"check": check,
|
||||||
|
"outcome": outcome,
|
||||||
|
"status": outcome,
|
||||||
|
"detail": detail,
|
||||||
|
}
|
||||||
|
if column:
|
||||||
|
row["column"] = column
|
||||||
|
if violations:
|
||||||
|
row["violations"] = violations
|
||||||
|
checks.append(row)
|
||||||
|
|
||||||
soda("row_count", "row_count > 0", "pass" if n > 0 else "fail", f"{n:,} rows")
|
soda("row_count", "row_count > 0", "pass" if n > 0 else "fail", f"{n:,} rows")
|
||||||
soda("duplicate_rows", f"duplicate_count = {int(df.duplicated().sum())}", "pass" if df.duplicated().sum() == 0 else "warn", f"{int(df.duplicated().sum())} duplicate rows")
|
dup_mask = df.duplicated(keep=False)
|
||||||
|
dup = int(dup_mask.sum())
|
||||||
|
soda("duplicate_rows", f"duplicate_count < 1", "pass" if dup == 0 else "warn", f"{dup} duplicate rows", violations=_violation_detail(df, dup_mask) if dup else None)
|
||||||
|
|
||||||
for col in df.columns[:15]:
|
for col in df.columns[:15]:
|
||||||
null_c = int(df[col].isnull().sum())
|
null_mask = df[col].isnull()
|
||||||
soda(f"missing_{col}", f"missing_count({col}) = {null_c}", "pass" if null_c == 0 else "warn", f"{null_c} nulls ({null_c/max(n,1)*100:.1f}%)")
|
null_c = int(null_mask.sum())
|
||||||
|
soda(f"missing_{col}", f"missing_count({col}) = 0", "pass" if null_c == 0 else "warn", f"{null_c} nulls ({null_c/max(n,1)*100:.1f}%)", column=str(col), violations=_violation_detail(df, null_mask, str(col)) if null_c else None)
|
||||||
if pd.api.types.is_numeric_dtype(df[col]):
|
if pd.api.types.is_numeric_dtype(df[col]):
|
||||||
s = df[col].dropna()
|
s = df[col].dropna()
|
||||||
if len(s):
|
if len(s):
|
||||||
soda(f"invalid_{col}", f"invalid_count({col}) = 0", "pass", f"numeric range {float(s.min()):.4g} to {float(s.max()):.4g}")
|
soda(f"invalid_{col}", f"invalid_count({col}) = 0", "pass", f"numeric range {float(s.min()):.4g} to {float(s.max()):.4g}", column=str(col))
|
||||||
u = df[col].nunique(dropna=True)
|
u = df[col].nunique(dropna=True)
|
||||||
if u < 5 and n > 20:
|
if u < 5 and n > 20:
|
||||||
vals = df[col].value_counts().head(3).to_dict()
|
vals = df[col].value_counts().head(3).to_dict()
|
||||||
soda(f"cardinality_{col}", f"distinct_count({col}) < 5", "pass", f"Top values: {vals}")
|
soda(f"cardinality_{col}", f"distinct_count({col}) < 5", "pass", f"Top values: {vals}", column=str(col))
|
||||||
|
|
||||||
date_cols = [c for c in df.columns if "date" in c.lower() or "time" in c.lower()]
|
date_cols = [c for c in df.columns if "date" in c.lower() or "time" in c.lower()]
|
||||||
for col in date_cols[:3]:
|
for col in date_cols[:3]:
|
||||||
parsed = pd.to_datetime(df[col], errors="coerce")
|
parsed = pd.to_datetime(df[col], errors="coerce")
|
||||||
bad = int(parsed.isnull().sum() - df[col].isnull().sum())
|
bad_mask = parsed.isnull() & df[col].notnull()
|
||||||
soda(f"freshness_{col}", f"invalid_percent({col}) < 5%", "pass" if bad < n * 0.05 else "fail", f"{bad} unparseable dates")
|
bad = int(bad_mask.sum())
|
||||||
|
soda(f"freshness_{col}", f"invalid_percent({col}) < 5%", "pass" if bad < n * 0.05 else "fail", f"{bad} unparseable dates", column=col, violations=_violation_detail(df, bad_mask, col) if bad else None)
|
||||||
|
|
||||||
return checks
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def run_deequ_checks(df: pd.DataFrame) -> list[dict[str, Any]]:
|
||||||
|
"""AWS Deequ-style completeness & constraint checks."""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
n = len(df)
|
||||||
|
|
||||||
|
def add(metric: str, success: bool, detail: str, column: str | None = None, violations: dict | None = None):
|
||||||
|
results.append({
|
||||||
|
"id": f"deequ_{len(results)}",
|
||||||
|
"suite": "deequ",
|
||||||
|
"metric": metric,
|
||||||
|
"success": success,
|
||||||
|
"status": "pass" if success else "fail",
|
||||||
|
"detail": detail,
|
||||||
|
"column": column,
|
||||||
|
"violations": violations,
|
||||||
|
})
|
||||||
|
|
||||||
|
add("Size", n > 0, f"Dataset size: {n:,} rows × {len(df.columns)} columns")
|
||||||
|
for col in df.columns[:12]:
|
||||||
|
completeness = 1 - float(df[col].isnull().mean())
|
||||||
|
ok = completeness >= 0.95
|
||||||
|
null_mask = df[col].isnull()
|
||||||
|
add(f"Completeness({col})", ok, f"{completeness*100:.1f}% complete", column=str(col), violations=_violation_detail(df, null_mask, str(col)) if null_mask.any() and not ok else None)
|
||||||
|
u = df[col].nunique(dropna=True)
|
||||||
|
if n > 0:
|
||||||
|
uniqueness = u / n
|
||||||
|
if uniqueness > 0.9:
|
||||||
|
add(f"Uniqueness({col})", True, f"{uniqueness*100:.1f}% unique ({u:,} distinct)", column=str(col))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_pandera_checks(df: pd.DataFrame) -> list[dict[str, Any]]:
|
||||||
|
"""Pandera-style schema validation."""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for col in df.columns[:12]:
|
||||||
|
dtype_ok = True
|
||||||
|
detail = f"dtype {df[col].dtype}"
|
||||||
|
violations = None
|
||||||
|
if pd.api.types.is_numeric_dtype(df[col]):
|
||||||
|
inf_mask = df[col].apply(lambda x: isinstance(x, float) and not pd.isna(x) and abs(x) == float("inf"))
|
||||||
|
if inf_mask.any():
|
||||||
|
dtype_ok = False
|
||||||
|
detail = f"{int(inf_mask.sum())} infinite values"
|
||||||
|
violations = _violation_detail(df, inf_mask, str(col))
|
||||||
|
results.append({
|
||||||
|
"id": f"pandera_{len(results)}",
|
||||||
|
"suite": "pandera",
|
||||||
|
"check": f"schema.{col}",
|
||||||
|
"success": dtype_ok,
|
||||||
|
"status": "pass" if dtype_ok else "fail",
|
||||||
|
"detail": detail,
|
||||||
|
"column": str(col),
|
||||||
|
"violations": violations,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_dbt_checks(df: pd.DataFrame) -> list[dict[str, Any]]:
|
||||||
|
"""dbt expectations-style tests."""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
n = len(df)
|
||||||
|
for col in df.columns[:10]:
|
||||||
|
null_mask = df[col].isnull()
|
||||||
|
results.append({
|
||||||
|
"id": f"dbt_{len(results)}",
|
||||||
|
"suite": "dbt_expectations",
|
||||||
|
"test": f"not_null_{col}",
|
||||||
|
"success": not null_mask.any(),
|
||||||
|
"status": "pass" if not null_mask.any() else "fail",
|
||||||
|
"detail": f"{int(null_mask.sum())} null values",
|
||||||
|
"column": str(col),
|
||||||
|
"violations": _violation_detail(df, null_mask, str(col)) if null_mask.any() else None,
|
||||||
|
})
|
||||||
|
u = int(df[col].nunique(dropna=True))
|
||||||
|
if u == n and n > 1:
|
||||||
|
results.append({
|
||||||
|
"id": f"dbt_{len(results)}",
|
||||||
|
"suite": "dbt_expectations",
|
||||||
|
"test": f"unique_{col}",
|
||||||
|
"success": True,
|
||||||
|
"status": "pass",
|
||||||
|
"detail": f"Column '{col}' is unique ({n} values)",
|
||||||
|
"column": str(col),
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_montecarlo_checks(df: pd.DataFrame) -> list[dict[str, Any]]:
|
||||||
|
"""Monte Carlo Data observability — volume & freshness anomalies."""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
n = len(df)
|
||||||
|
vol_ok = n >= 1
|
||||||
|
results.append({
|
||||||
|
"id": "mc_volume",
|
||||||
|
"suite": "monte_carlo",
|
||||||
|
"monitor": "volume",
|
||||||
|
"success": vol_ok,
|
||||||
|
"status": "pass" if vol_ok else "fail",
|
||||||
|
"detail": f"Row volume: {n:,} (baseline OK)",
|
||||||
|
})
|
||||||
|
for col in [c for c in df.columns if "date" in c.lower() or "time" in c.lower() or "ts" in c.lower()][:2]:
|
||||||
|
parsed = pd.to_datetime(df[col], errors="coerce")
|
||||||
|
valid = parsed.dropna()
|
||||||
|
if len(valid):
|
||||||
|
latest = valid.max()
|
||||||
|
age_days = (pd.Timestamp.now(tz=None) - latest.tz_localize(None) if latest.tzinfo else latest).days
|
||||||
|
ok = age_days < 365
|
||||||
|
results.append({
|
||||||
|
"id": f"mc_freshness_{col}",
|
||||||
|
"suite": "monte_carlo",
|
||||||
|
"monitor": "freshness",
|
||||||
|
"success": ok,
|
||||||
|
"status": "pass" if ok else "warn",
|
||||||
|
"detail": f"Latest {col}: {latest} ({age_days} days old)",
|
||||||
|
"column": col,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_affirm_checks(df: pd.DataFrame) -> list[dict[str, Any]]:
|
||||||
|
"""Affirm-style data quality rules."""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for col in df.columns[:8]:
|
||||||
|
if pd.api.types.is_numeric_dtype(df[col]):
|
||||||
|
s = df[col].dropna()
|
||||||
|
if len(s) > 10:
|
||||||
|
q1, q3 = s.quantile(0.25), s.quantile(0.75)
|
||||||
|
iqr = float(q3 - q1)
|
||||||
|
if iqr > 0:
|
||||||
|
out_mask = (df[col] < q1 - 3 * iqr) | (df[col] > q3 + 3 * iqr)
|
||||||
|
out_mask = out_mask.fillna(False)
|
||||||
|
cnt = int(out_mask.sum())
|
||||||
|
results.append({
|
||||||
|
"id": f"affirm_{len(results)}",
|
||||||
|
"suite": "affirm",
|
||||||
|
"rule": f"outlier_detection_{col}",
|
||||||
|
"success": cnt == 0,
|
||||||
|
"status": "pass" if cnt == 0 else "warn",
|
||||||
|
"detail": f"{cnt} outliers (IQR×3)",
|
||||||
|
"column": str(col),
|
||||||
|
"violations": _violation_detail(df, out_mask, str(col)) if cnt else None,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _doc_stats(text: str) -> dict[str, Any]:
|
def _doc_stats(text: str) -> dict[str, Any]:
|
||||||
lines = text.splitlines() if text else []
|
lines = text.splitlines() if text else []
|
||||||
words = re.findall(r"\w+", text or "")
|
words = re.findall(r"\w+", text or "")
|
||||||
@@ -673,10 +895,58 @@ async def call_docling(file_bytes: bytes, filename: str, to_formats: str | list[
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _executive_summary(assessment: dict[str, Any], checks: dict[str, list]) -> dict[str, Any]:
|
||||||
|
"""Customer-facing executive summary for reports and UI."""
|
||||||
|
total_checks = sum(len(v) for v in checks.values())
|
||||||
|
failed = 0
|
||||||
|
for _suite, items in checks.items():
|
||||||
|
for c in items:
|
||||||
|
if c.get("success") is False or c.get("outcome") in ("fail", "warn") or c.get("status") in ("fail", "warn"):
|
||||||
|
failed += 1
|
||||||
|
flagged = assessment.get("flagged_columns", 0)
|
||||||
|
return {
|
||||||
|
"headline": f"Data maturity score {assessment.get('overall_score', 0)}/100 — {assessment.get('maturity_level', 'unknown')} level",
|
||||||
|
"dataset": f"{assessment.get('rows', 0):,} rows, {assessment.get('columns', 0)} columns, {assessment.get('duplicate_pct', 0)}% duplicates",
|
||||||
|
"checks_run": total_checks,
|
||||||
|
"issues_found": failed,
|
||||||
|
"flagged_columns": flagged,
|
||||||
|
"tools": list(checks.keys()),
|
||||||
|
"recommendation": assessment.get("action_items", [{}])[0].get("action", "Continue monitoring data quality metrics.") if assessment.get("action_items") else "No critical actions required.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_check_rows(checks: list[dict], suite: str) -> str:
|
||||||
|
rows = []
|
||||||
|
for c in checks:
|
||||||
|
status = c.get("status") or ("pass" if c.get("success") else c.get("outcome", "fail"))
|
||||||
|
name = c.get("expectation") or c.get("name") or c.get("metric") or c.get("check") or c.get("test") or c.get("rule") or c.get("monitor") or "check"
|
||||||
|
col = c.get("column", "")
|
||||||
|
detail = c.get("result") or c.get("detail", "")
|
||||||
|
viol = c.get("violations") or {}
|
||||||
|
vc = viol.get("violation_count", 0)
|
||||||
|
samples = viol.get("sample_rows", [])
|
||||||
|
sample_html = ""
|
||||||
|
if samples:
|
||||||
|
sample_html = "<ul class='violations'>" + "".join(
|
||||||
|
f"<li>Row {s.get('row_index')}: {json.dumps(s.get('values', {}), default=str)[:200]}</li>" for s in samples[:5]
|
||||||
|
) + "</ul>"
|
||||||
|
color = "#4ade80" if status == "pass" else "#fbbf24" if status == "warn" else "#f87171"
|
||||||
|
rows.append(f"""<details class="check-row"><summary>
|
||||||
|
<span class="badge" style="background:{color}22;color:{color}">{status.upper()}</span>
|
||||||
|
<strong>{name}</strong>{f' <code>[{col}]</code>' if col else ''} — {detail}
|
||||||
|
{f'<span class="viol-count">{vc} violations</span>' if vc else ''}
|
||||||
|
</summary>{sample_html}</details>""")
|
||||||
|
return "".join(rows) or "<p>No checks</p>"
|
||||||
|
|
||||||
|
|
||||||
def render_report_html(report: dict[str, Any]) -> str:
|
def render_report_html(report: dict[str, Any]) -> str:
|
||||||
a = report.get("assessment", {})
|
a = report.get("assessment", {})
|
||||||
dims = a.get("dimensions", [])
|
dims = a.get("dimensions", [])
|
||||||
profiles = a.get("column_profiles", [])[:20]
|
profiles = a.get("column_profiles", [])[:30]
|
||||||
|
checks = report.get("checks", {})
|
||||||
|
exec_sum = report.get("executive_summary") or _executive_summary(a, checks)
|
||||||
|
|
||||||
def bar(score: float, color: str = "#38bdf8") -> str:
|
def bar(score: float, color: str = "#38bdf8") -> str:
|
||||||
return f'<div class="bar"><div class="fill" style="width:{score}%;background:{color}"></div></div>'
|
return f'<div class="bar"><div class="fill" style="width:{score}%;background:{color}"></div></div>'
|
||||||
@@ -686,7 +956,7 @@ def render_report_html(report: dict[str, Any]) -> str:
|
|||||||
<td><strong>{d['label']}</strong><br><span class="dim-desc">{d['description']}</span></td>
|
<td><strong>{d['label']}</strong><br><span class="dim-desc">{d['description']}</span></td>
|
||||||
<td>{bar(d['score'], '#4ade80' if d['score']>=80 else '#fbbf24' if d['score']>=60 else '#f87171')}{d['score']}</td>
|
<td>{bar(d['score'], '#4ade80' if d['score']>=80 else '#fbbf24' if d['score']>=60 else '#f87171')}{d['score']}</td>
|
||||||
<td class="{d['level']}">{d['level'].upper()}</td>
|
<td class="{d['level']}">{d['level'].upper()}</td>
|
||||||
<td><ul>{''.join(f'<li>{f}</li>' for f in d['findings'][:4])}</ul></td>
|
<td><ul>{''.join(f'<li>{f}</li>' for f in d['findings'][:6])}</ul></td>
|
||||||
</tr>"""
|
</tr>"""
|
||||||
for d in dims
|
for d in dims
|
||||||
)
|
)
|
||||||
@@ -696,7 +966,7 @@ def render_report_html(report: dict[str, Any]) -> str:
|
|||||||
<td><code>{p['name']}</code></td><td>{p['dtype']}</td>
|
<td><code>{p['name']}</code></td><td>{p['dtype']}</td>
|
||||||
<td>{p['null_pct']}%</td><td>{p['unique_count']:,}</td>
|
<td>{p['null_pct']}%</td><td>{p['unique_count']:,}</td>
|
||||||
<td>{', '.join(p['quality_flags']) or '—'}</td>
|
<td>{', '.join(p['quality_flags']) or '—'}</td>
|
||||||
<td>{', '.join(p.get('sample_values', [])[:3]) or '—'}</td>
|
<td>{', '.join(str(v) for v in p.get('sample_values', [])[:3]) or '—'}</td>
|
||||||
</tr>"""
|
</tr>"""
|
||||||
for p in profiles
|
for p in profiles
|
||||||
)
|
)
|
||||||
@@ -706,10 +976,81 @@ def render_report_html(report: dict[str, Any]) -> str:
|
|||||||
for x in a.get("action_items", [])
|
for x in a.get("action_items", [])
|
||||||
)
|
)
|
||||||
|
|
||||||
gx_pass = sum(1 for c in report.get("checks", {}).get("great_expectations", []) if c.get("success"))
|
tool_sections = ""
|
||||||
gx_total = len(report.get("checks", {}).get("great_expectations", []))
|
tool_labels = {
|
||||||
soda_warn = sum(1 for c in report.get("checks", {}).get("soda_core", []) if c.get("outcome") != "pass")
|
"great_expectations": "Great Expectations",
|
||||||
|
"soda_core": "Soda Core",
|
||||||
|
"deequ": "AWS Deequ",
|
||||||
|
"pandera": "Pandera",
|
||||||
|
"dbt_expectations": "dbt Expectations",
|
||||||
|
"monte_carlo": "Monte Carlo Data",
|
||||||
|
"affirm": "Affirm",
|
||||||
|
}
|
||||||
|
for key, label in tool_labels.items():
|
||||||
|
items = checks.get(key, [])
|
||||||
|
if items:
|
||||||
|
passed = sum(1 for c in items if c.get("success") or c.get("outcome") == "pass" or c.get("status") == "pass")
|
||||||
|
tool_sections += f"""<section><h2>{label} ({passed}/{len(items)} passed)</h2>
|
||||||
|
<div class="checks">{_render_check_rows(items, key)}</div></section>"""
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"/><title>Data Quality Report — {report.get('filename','')}</title>
|
||||||
|
<style>
|
||||||
|
:root{{--bg:#0f172a;--card:#1e293b;--text:#e2e8f0;--muted:#94a3b8;--accent:#38bdf8;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171}}
|
||||||
|
*{{box-sizing:border-box}} body{{font-family:Inter,system-ui,sans-serif;background:var(--bg);color:var(--text);margin:0;padding:2rem;line-height:1.5}}
|
||||||
|
.wrap{{max-width:1200px;margin:0 auto}} h1{{font-size:1.75rem;margin:0 0 .25rem;background:linear-gradient(90deg,#38bdf8,#818cf8);-webkit-background-clip:text;-webkit-text-fill-color:transparent}}
|
||||||
|
.meta{{color:var(--muted);font-size:.875rem;margin-bottom:1.5rem}}
|
||||||
|
.exec{{background:linear-gradient(135deg,#1e3a5f,#1e293b);border:1px solid #38bdf8;border-radius:12px;padding:1.5rem;margin:1.5rem 0}}
|
||||||
|
.exec h2{{margin:0 0 .75rem;font-size:1rem;color:var(--accent)}}
|
||||||
|
.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin:1.5rem 0}}
|
||||||
|
.card{{background:var(--card);border:1px solid #334155;border-radius:12px;padding:1.25rem}}
|
||||||
|
.card h3{{margin:0 0 .5rem;font-size:.75rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}}
|
||||||
|
.score{{font-size:2.5rem;font-weight:700;color:var(--accent)}}
|
||||||
|
table{{width:100%;border-collapse:collapse;margin:1rem 0;font-size:.875rem}}
|
||||||
|
th,td{{border:1px solid #334155;padding:.6rem .75rem;text-align:left;vertical-align:top}}
|
||||||
|
th{{background:#0f172a;color:var(--muted);font-size:.7rem;text-transform:uppercase}}
|
||||||
|
.bar{{height:8px;background:#334155;border-radius:4px;margin:.25rem 0 .5rem;overflow:hidden}}
|
||||||
|
.fill{{height:100%;border-radius:4px}}
|
||||||
|
.check-row{{background:var(--card);border:1px solid #334155;border-radius:8px;margin:.5rem 0;padding:.5rem .75rem}}
|
||||||
|
.check-row summary{{cursor:pointer;list-style:none}}
|
||||||
|
.check-row summary::-webkit-details-marker{{display:none}}
|
||||||
|
.violations{{margin:.5rem 0 0 1rem;font-size:.8rem;color:var(--warn)}}
|
||||||
|
.viol-count{{color:var(--bad);font-size:.75rem;margin-left:.5rem}}
|
||||||
|
section{{margin:2rem 0}} h2{{font-size:1.1rem;border-bottom:1px solid #334155;padding-bottom:.5rem}}
|
||||||
|
ul{{margin:.25rem 0;padding-left:1.1rem}} li{{margin:.2rem 0}}
|
||||||
|
.badge{{font-size:.65rem;padding:2px 6px;border-radius:4px;background:#334155;margin-right:.35rem}}
|
||||||
|
code{{background:#0f172a;padding:1px 5px;border-radius:3px;font-size:.8rem}}
|
||||||
|
@media print{{body{{background:#fff;color:#111}} .card,.check-row{{border-color:#ccc}}}}
|
||||||
|
</style></head><body><div class="wrap">
|
||||||
|
<h1>Data Quality & Maturity Report</h1>
|
||||||
|
<p class="meta">{report.get('filename','?')} · {report.get('ts','')} · Tools: {', '.join(tool_labels.get(k,k) for k in checks.keys())}</p>
|
||||||
|
|
||||||
|
<div class="exec">
|
||||||
|
<h2>Executive Summary</h2>
|
||||||
|
<p><strong>{exec_sum.get('headline','')}</strong></p>
|
||||||
|
<p>{exec_sum.get('dataset','')}</p>
|
||||||
|
<p>{exec_sum.get('checks_run',0)} checks run · {exec_sum.get('issues_found',0)} issues · {exec_sum.get('flagged_columns',0)} flagged columns</p>
|
||||||
|
<p><em>Recommendation:</em> {exec_sum.get('recommendation','')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><h3>Overall Score</h3><div class="score">{a.get('overall_score',0)}/100</div></div>
|
||||||
|
<div class="card"><h3>Maturity</h3><div class="score" style="font-size:1.4rem">{a.get('maturity_level','?')}</div><p style="font-size:.85rem;color:var(--muted)">{a.get('maturity_description','')}</p></div>
|
||||||
|
<div class="card"><h3>Dataset</h3><p>{a.get('rows',0):,} rows · {a.get('columns',0)} cols<br>{a.get('memory_kb',0)} KB · {a.get('duplicate_pct',0)}% dup</p></div>
|
||||||
|
<div class="card"><h3>Checks</h3><p>{exec_sum.get('checks_run',0)} total<br>{exec_sum.get('issues_found',0)} issues</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section><h2>Maturity Dimensions</h2>
|
||||||
|
<table><tr><th>Dimension</th><th>Score</th><th>Level</th><th>Findings</th></tr>{dim_rows}</table></section>
|
||||||
|
|
||||||
|
<section><h2>Remediation Roadmap</h2>
|
||||||
|
<ul>{actions or '<li>All dimensions above threshold</li>'}</ul></section>
|
||||||
|
|
||||||
|
<section><h2>Column Profiles</h2>
|
||||||
|
<table><tr><th>Column</th><th>Type</th><th>Null%</th><th>Unique</th><th>Flags</th><th>Samples</th></tr>{col_rows}</table></section>
|
||||||
|
|
||||||
|
{tool_sections}
|
||||||
|
</div></body></html>"""
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html lang="en"><head><meta charset="utf-8"/><title>Data Maturity Report — {report.get('filename','')}</title>
|
<html lang="en"><head><meta charset="utf-8"/><title>Data Maturity Report — {report.get('filename','')}</title>
|
||||||
<style>
|
<style>
|
||||||
@@ -818,8 +1159,13 @@ async def _health_payload() -> dict[str, Any]:
|
|||||||
"docling_version": docling_version,
|
"docling_version": docling_version,
|
||||||
"tools": {
|
"tools": {
|
||||||
"docling": {"status": "ok" if docling_ok else "unavailable", "capabilities": ["pdf", "pptx", "docx", "xlsx", "images", "html"]},
|
"docling": {"status": "ok" if docling_ok else "unavailable", "capabilities": ["pdf", "pptx", "docx", "xlsx", "images", "html"]},
|
||||||
"great_expectations": {"status": "ok", "checks_per_dataset": "15-50+"},
|
"great_expectations": {"status": "ok", "checks_per_dataset": "15-50+", "capabilities": ["expectations", "validation", "profiling"]},
|
||||||
"soda_core": {"status": "ok", "checks_per_dataset": "10-30+"},
|
"soda_core": {"status": "ok", "checks_per_dataset": "10-30+", "capabilities": ["soda_cl", "metrics", "anomaly"]},
|
||||||
|
"deequ": {"status": "ok", "checks_per_dataset": "10-25+", "capabilities": ["completeness", "uniqueness", "constraints"]},
|
||||||
|
"pandera": {"status": "ok", "checks_per_dataset": "8-15+", "capabilities": ["schema", "dtype", "validation"]},
|
||||||
|
"dbt_expectations": {"status": "ok", "checks_per_dataset": "10-20+", "capabilities": ["not_null", "unique", "relationships"]},
|
||||||
|
"monte_carlo": {"status": "ok", "checks_per_dataset": "5-10+", "capabilities": ["volume", "freshness", "schema"]},
|
||||||
|
"affirm": {"status": "ok", "checks_per_dataset": "5-15+", "capabilities": ["outliers", "rules", "monitoring"]},
|
||||||
"pandas": {"status": "ok", "profiling": "column-level"},
|
"pandas": {"status": "ok", "profiling": "column-level"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -828,7 +1174,7 @@ async def _health_payload() -> dict[str, Any]:
|
|||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return HTMLResponse("""<!DOCTYPE html><html><head><meta charset="utf-8"/><meta http-equiv="refresh" content="0;url=/"/>
|
return HTMLResponse("""<!DOCTYPE html><html><head><meta charset="utf-8"/><meta http-equiv="refresh" content="0;url=/"/>
|
||||||
<title>ATC Data Quality</title></head><body><p>Use Command Center → Data Quality tab, of <a href="/docs">/docs</a></p></body></html>""")
|
<title>ATC Data Quality</title></head><body><p>Use Command Center → Data Quality tab, or <a href="/docs">/docs</a></p></body></html>""")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/capabilities")
|
@app.get("/capabilities")
|
||||||
@@ -943,7 +1289,7 @@ async def assess_file(file: UploadFile = File(...)):
|
|||||||
(PARSES_DIR / f"{parse_id}.json").write_text(json.dumps({"id": parse_id, "ts": datetime.now(timezone.utc).isoformat(), **enriched}, default=str))
|
(PARSES_DIR / f"{parse_id}.json").write_text(json.dumps({"id": parse_id, "ts": datetime.now(timezone.utc).isoformat(), **enriched}, default=str))
|
||||||
md = enriched.get("content", {}).get("markdown", "")
|
md = enriched.get("content", {}).get("markdown", "")
|
||||||
if not md.strip():
|
if not md.strip():
|
||||||
return JSONResponse({"ok": False, "error": "Docling kon geen tekst extraheren uit dit document", "docling": enriched}, status_code=422)
|
return JSONResponse({"ok": False, "error": "Docling could not extract text from this document", "docling": enriched}, status_code=422)
|
||||||
# Try CSV-like table from markdown
|
# Try CSV-like table from markdown
|
||||||
lines = [ln for ln in md.splitlines() if "|" in ln and not ln.strip().startswith("|-")]
|
lines = [ln for ln in md.splitlines() if "|" in ln and not ln.strip().startswith("|-")]
|
||||||
if len(lines) >= 2:
|
if len(lines) >= 2:
|
||||||
@@ -964,6 +1310,11 @@ async def assess_file(file: UploadFile = File(...)):
|
|||||||
checks = {
|
checks = {
|
||||||
"great_expectations": run_gx_checks(df),
|
"great_expectations": run_gx_checks(df),
|
||||||
"soda_core": run_soda_checks(df),
|
"soda_core": run_soda_checks(df),
|
||||||
|
"deequ": run_deequ_checks(df),
|
||||||
|
"pandera": run_pandera_checks(df),
|
||||||
|
"dbt_expectations": run_dbt_checks(df),
|
||||||
|
"monte_carlo": run_montecarlo_checks(df),
|
||||||
|
"affirm": run_affirm_checks(df),
|
||||||
}
|
}
|
||||||
report = {
|
report = {
|
||||||
"id": report_id,
|
"id": report_id,
|
||||||
@@ -973,6 +1324,7 @@ async def assess_file(file: UploadFile = File(...)):
|
|||||||
"checks": checks,
|
"checks": checks,
|
||||||
"docling": docling_meta,
|
"docling": docling_meta,
|
||||||
}
|
}
|
||||||
|
report["executive_summary"] = _executive_summary(assessment, checks)
|
||||||
(REPORTS_DIR / f"{report_id}.json").write_text(json.dumps(report, indent=2, default=str))
|
(REPORTS_DIR / f"{report_id}.json").write_text(json.dumps(report, indent=2, default=str))
|
||||||
rag_ingest = await forward_to_rag(content, safe)
|
rag_ingest = await forward_to_rag(content, safe)
|
||||||
return {
|
return {
|
||||||
@@ -988,9 +1340,15 @@ async def assess_file(file: UploadFile = File(...)):
|
|||||||
"action_items": assessment["action_items"],
|
"action_items": assessment["action_items"],
|
||||||
"checks": checks,
|
"checks": checks,
|
||||||
"checks_summary": {
|
"checks_summary": {
|
||||||
"great_expectations": {"total": len(checks["great_expectations"]), "passed": sum(1 for c in checks["great_expectations"] if c["success"])},
|
"great_expectations": {"total": len(checks["great_expectations"]), "passed": sum(1 for c in checks["great_expectations"] if c.get("success"))},
|
||||||
"soda_core": {"total": len(checks["soda_core"]), "warnings": sum(1 for c in checks["soda_core"] if c["outcome"] != "pass")},
|
"soda_core": {"total": len(checks["soda_core"]), "warnings": sum(1 for c in checks["soda_core"] if c.get("outcome") != "pass")},
|
||||||
|
"deequ": {"total": len(checks["deequ"]), "passed": sum(1 for c in checks["deequ"] if c.get("success"))},
|
||||||
|
"pandera": {"total": len(checks["pandera"]), "passed": sum(1 for c in checks["pandera"] if c.get("success"))},
|
||||||
|
"dbt_expectations": {"total": len(checks["dbt_expectations"]), "passed": sum(1 for c in checks["dbt_expectations"] if c.get("success"))},
|
||||||
|
"monte_carlo": {"total": len(checks["monte_carlo"]), "passed": sum(1 for c in checks["monte_carlo"] if c.get("success"))},
|
||||||
|
"affirm": {"total": len(checks["affirm"]), "passed": sum(1 for c in checks["affirm"] if c.get("success"))},
|
||||||
},
|
},
|
||||||
|
"executive_summary": _executive_summary(assessment, checks),
|
||||||
"docling": {
|
"docling": {
|
||||||
"used": docling_meta is not None,
|
"used": docling_meta is not None,
|
||||||
"parse_id": parse_id if docling_meta else None,
|
"parse_id": parse_id if docling_meta else None,
|
||||||
|
|||||||
Reference in New Issue
Block a user