"""Data Maturity Assessment API — Docling, Great Expectations, Soda Core.""" from __future__ import annotations import io import json import os import re import uuid import base64 from datetime import datetime, timezone from pathlib import Path from typing import Any import httpx import pandas as pd from fastapi import FastAPI, File, Form, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, JSONResponse, FileResponse from pydantic import BaseModel DOCLING_URL = os.getenv("DOCLING_URL", "http://docling-serve:5001").rstrip("/") RAG_URL = os.getenv("RAG_URL", "http://rag-api:5020").rstrip("/") RAG_COLLECTION = os.getenv("RAG_COLLECTION", "default") DATA_DIR = Path(os.getenv("DQ_DATA_DIR", "/data")) REPORTS_DIR = DATA_DIR / "reports" UPLOADS_DIR = DATA_DIR / "uploads" PARSES_DIR = DATA_DIR / "parses" IMAGES_DIR = DATA_DIR / "images" app = FastAPI(title="ATC Data Quality & Maturity API", version="2.0.0") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) DOC_EXTS = {".pdf", ".pptx", ".ppt", ".docx", ".doc", ".html", ".htm", ".png", ".jpg", ".jpeg", ".tiff", ".txt", ".md"} DATA_EXTS = {".csv", ".xlsx", ".xls", ".json", ".parquet", ".tsv"} class AssessRequest(BaseModel): filename: str | None = None report_id: str | None = None DIMENSIONS = [ ("completeness", "Completeness", "Missing values, null rates, required fields"), ("consistency", "Consistency", "Format uniformity, cross-field rules"), ("validity", "Validity", "Type checks, range constraints, regex patterns"), ("uniqueness", "Uniqueness", "Duplicate keys, primary key integrity"), ("timeliness", "Timeliness", "Freshness, date ranges, stale records"), ("accuracy", "Accuracy", "Statistical outliers, distribution anomalies"), ] MATURITY_LEVELS = [ (85, "Optimized", "Data is production-ready with monitoring in place"), (70, "Managed", "Good quality with minor gaps to address"), (55, "Defined", "Basic standards exist but enforcement is inconsistent"), (40, "Developing", "Significant quality issues require remediation"), (0, "Initial", "Ad-hoc data handling — high risk for analytics/AI"), ] def _ensure_dirs() -> None: for d in (REPORTS_DIR, UPLOADS_DIR, PARSES_DIR, IMAGES_DIR): d.mkdir(parents=True, exist_ok=True) def _maturity_label(score: float) -> tuple[str, str]: for threshold, label, desc in MATURITY_LEVELS: if score >= threshold: return label, desc return "Initial", MATURITY_LEVELS[-1][2] def _load_df(path: Path) -> pd.DataFrame: name = path.name.lower() if name.endswith(".csv"): return pd.read_csv(path) if name.endswith(".tsv"): return pd.read_csv(path, sep="\t") if name.endswith((".xlsx", ".xls")): return pd.read_excel(path) if name.endswith(".json"): return pd.read_json(path) if name.endswith(".parquet"): return pd.read_parquet(path) raise ValueError(f"Unsupported format: {path.suffix}") def _safe_sample(values: Any, n: int = 5) -> list[str]: out: list[str] = [] for v in values: if pd.isna(v): continue s = str(v) if len(s) > 120: s = s[:117] + "..." out.append(s) if len(out) >= n: break return out def profile_column(name: str, series: pd.Series, row_count: int) -> dict[str, Any]: null_count = int(series.isnull().sum()) null_pct = round(float(series.isnull().mean() * 100), 2) unique = int(series.nunique(dropna=True)) profile: dict[str, Any] = { "name": name, "dtype": str(series.dtype), "null_count": null_count, "null_pct": null_pct, "unique_count": unique, "unique_pct": round(unique / max(row_count, 1) * 100, 2), "sample_values": _safe_sample(series.dropna().head(20)), "quality_flags": [], } if null_pct > 20: profile["quality_flags"].append("high_null_rate") if unique == 1 and row_count > 1: profile["quality_flags"].append("constant_column") if unique == row_count and row_count > 10: profile["quality_flags"].append("possible_identifier") if pd.api.types.is_numeric_dtype(series): s = series.dropna() if len(s): q1, q3 = s.quantile(0.25), s.quantile(0.75) iqr = float(q3 - q1) outliers = int(((s < q1 - 3 * iqr) | (s > q3 + 3 * iqr)).sum()) if iqr else 0 profile["numeric"] = { "min": _json_num(s.min()), "max": _json_num(s.max()), "mean": round(float(s.mean()), 4), "median": _json_num(s.median()), "std": round(float(s.std()), 4) if len(s) > 1 else 0, "zeros": int((s == 0).sum()), "negatives": int((s < 0).sum()), "outliers": outliers, } if outliers > 0: profile["quality_flags"].append("has_outliers") else: s = series.dropna().astype(str) if len(s): lengths = s.str.len() empty = int((s.str.strip() == "").sum()) profile["text"] = { "min_length": int(lengths.min()), "max_length": int(lengths.max()), "avg_length": round(float(lengths.mean()), 1), "empty_strings": empty, } vc = s.value_counts().head(5) profile["top_values"] = [{"value": k, "count": int(v)} for k, v in vc.items()] if empty > 0: profile["quality_flags"].append("empty_strings") return profile def _json_num(v: Any) -> float | int | None: if pd.isna(v): return None f = float(v) return int(f) if f == int(f) else round(f, 4) def profile_dataset(df: pd.DataFrame, source: str) -> dict[str, Any]: n = len(df) dup = int(df.duplicated().sum()) complete_rows = int((~df.isnull().any(axis=1)).sum()) if n else 0 dtypes: dict[str, int] = {} for dt in df.dtypes.astype(str): dtypes[dt] = dtypes.get(dt, 0) + 1 columns = [profile_column(str(c), df[c], n) for c in df.columns] flagged = [c for c in columns if c["quality_flags"]] return { "source": source, "rows": n, "columns": len(df.columns), "column_names": list(df.columns[:50]), "memory_kb": round(float(df.memory_usage(deep=True).sum()) / 1024, 1), "duplicate_rows": dup, "duplicate_pct": round(dup / max(n, 1) * 100, 2), "complete_rows": complete_rows, "complete_pct": round(complete_rows / max(n, 1) * 100, 2), "dtype_breakdown": dtypes, "column_profiles": columns, "flagged_columns": len(flagged), "describe": df.describe(include="all").fillna("").astype(str).to_dict() if n else {}, } def _score_completeness(df: pd.DataFrame) -> tuple[float, list[str], list[str]]: if df.empty: return 0.0, ["Dataset is empty"], ["Load data before assessment"] null_pct = df.isnull().mean() worst = null_pct.sort_values(ascending=False).head(8) score = max(0, 100 - float(null_pct.mean() * 100)) findings = [f"{col}: {pct*100:.1f}% missing ({int(df[col].isnull().sum()):,} values)" for col, pct in worst.items() if pct > 0] actions = [f"Impute or source missing values for '{col}' ({pct*100:.0f}% null)" for col, pct in worst.items() if pct > 0.1][:5] if not findings: findings = ["No significant missing values detected across columns"] if not actions: actions = ["Maintain null monitoring on ingestion pipelines"] return round(score, 1), findings, actions def _score_uniqueness(df: pd.DataFrame) -> tuple[float, list[str], list[str]]: if df.empty: return 0.0, ["Dataset is empty"], [] n = len(df) dup = int(df.duplicated().sum()) dup_rate = dup / max(n, 1) score = max(0, 100 - dup_rate * 100) findings = [f"Duplicate rows: {dup:,} ({dup_rate*100:.1f}% of dataset)"] actions: list[str] = [] if dup > 0: actions.append(f"Deduplicate {dup:,} rows or define business key for uniqueness") for col in df.columns[:8]: u = df[col].nunique(dropna=True) if u < n * 0.01 and n > 100: findings.append(f"Low cardinality on '{col}': {u} unique / {n:,} rows") actions.append(f"Review '{col}' — near-constant column may be metadata or error") if not actions: actions = ["Define and enforce primary/business keys in source systems"] return round(score, 1), findings, actions def _score_validity(df: pd.DataFrame) -> tuple[float, list[str], list[str]]: findings: list[str] = [] actions: list[str] = [] issues = 0 total = max(len(df.columns), 1) for col in df.columns: s = df[col] if pd.api.types.is_numeric_dtype(s): inf_c = int((s == float("inf")).sum() + (s == float("-inf")).sum()) if inf_c: findings.append(f"'{col}': {inf_c} infinity values") actions.append(f"Replace inf values in '{col}' with NULL or capped bounds") issues += 1 elif pd.api.types.is_string_dtype(s) or s.dtype == object: empty_str = int((s.astype(str).str.strip() == "").sum()) if empty_str > 0: findings.append(f"'{col}': {empty_str} empty strings (distinct from NULL)") actions.append(f"Normalize empty strings to NULL in '{col}'") issues += 1 score = max(0, 100 - (issues / total) * 40) if not findings: findings = ["Basic type validity checks passed on all columns"] if not actions: actions = ["Add schema validation at ingestion (types, ranges, regex)"] return round(score, 1), findings, actions def _score_consistency(df: pd.DataFrame) -> tuple[float, list[str], list[str]]: findings: list[str] = [] actions: list[str] = [] issues = 0 for col in df.select_dtypes(include="object").columns[:12]: sample = df[col].dropna().astype(str).head(1000) if sample.empty: continue lengths = sample.str.len() if lengths.std() > lengths.mean() * 2 and lengths.mean() > 3: findings.append(f"'{col}': inconsistent string lengths (mean {lengths.mean():.0f}, std {lengths.std():.0f})") actions.append(f"Standardize format for '{col}' — mixed lengths suggest multiple sources") issues += 1 # mixed case patterns if sample.str.match(r"^[A-Z]+$").mean() > 0.3 and sample.str.match(r"^[a-z]+$").mean() > 0.3: findings.append(f"'{col}': mixed casing patterns detected") issues += 1 score = max(40, 100 - issues * 10) if not findings: findings = ["No major format inconsistencies detected"] if not actions: actions = ["Document canonical formats per column in data dictionary"] return round(score, 1), findings, actions def _score_timeliness(df: pd.DataFrame) -> tuple[float, list[str], list[str]]: date_cols = [c for c in df.columns if any(k in c.lower() for k in ("date", "time", "ts", "timestamp", "created", "updated", "modified"))] if not date_cols: return 65.0, ["No date columns detected — timeliness cannot be fully assessed"], ["Add created_at/updated_at columns for freshness monitoring"] findings: list[str] = [] actions: list[str] = [] score = 100.0 now = pd.Timestamp.now(tz="UTC") for col in date_cols[:5]: try: parsed = pd.to_datetime(df[col], errors="coerce", utc=True) null_rate = float(parsed.isnull().mean()) if null_rate > 0.05: findings.append(f"'{col}': {null_rate*100:.0f}% unparseable dates") actions.append(f"Fix date format in '{col}' — {null_rate*100:.0f}% fails parsing") score -= 12 if parsed.notna().any(): latest = parsed.max() age = (now - latest).days oldest = parsed.min() span = (latest - oldest).days if pd.notna(oldest) else 0 findings.append(f"'{col}': range {oldest.date() if pd.notna(oldest) else '?'} → {latest.date()} ({span}d span, latest {age}d ago)") if age > 365: actions.append(f"Data in '{col}' is stale — latest record {age} days old") score -= 20 elif age > 90: actions.append(f"Review refresh cadence for '{col}' — {age} days since last record") score -= 8 except Exception as exc: findings.append(f"'{col}': parse error — {exc}") score -= 10 return round(max(0, score), 1), findings, actions or ["Date columns parsed successfully"] def _score_accuracy(df: pd.DataFrame) -> tuple[float, list[str], list[str]]: findings: list[str] = [] actions: list[str] = [] score = 90.0 for col in df.select_dtypes(include="number").columns[:10]: s = df[col].dropna() if len(s) < 10: continue q1, q3 = s.quantile(0.25), s.quantile(0.75) iqr = float(q3 - q1) if iqr == 0: continue outliers = int(((s < q1 - 3 * iqr) | (s > q3 + 3 * iqr)).sum()) if outliers > 0: pct = outliers / len(s) * 100 if pct > 0.5: findings.append(f"'{col}': {outliers} statistical outliers ({pct:.1f}%) — IQR method") actions.append(f"Investigate outliers in '{col}' — may indicate unit errors or bad joins") score -= min(25, pct * 2) if not findings: findings = ["No significant statistical outliers in numeric columns"] if not actions: actions = ["Add cross-source reconciliation checks for key metrics"] return round(max(0, score), 1), findings, actions def run_maturity_assessment(df: pd.DataFrame, source: str) -> dict[str, Any]: scorers = { "completeness": _score_completeness, "consistency": _score_consistency, "validity": _score_validity, "uniqueness": _score_uniqueness, "timeliness": _score_timeliness, "accuracy": _score_accuracy, } dimensions = [] all_actions: list[dict[str, Any]] = [] for key, label, desc in DIMENSIONS: score, findings, dim_actions = scorers[key](df) level = "high" if score >= 80 else "medium" if score >= 60 else "low" dimensions.append({ "id": key, "label": label, "description": desc, "score": score, "level": level, "findings": findings, "recommended_actions": dim_actions, }) for act in dim_actions[:2]: all_actions.append({ "priority": "high" if score < 50 else "medium" if score < 70 else "low", "dimension": label, "score": score, "action": act, }) overall = round(sum(d["score"] for d in dimensions) / len(dimensions), 1) maturity, maturity_desc = _maturity_label(overall) profile = profile_dataset(df, source) return { **profile, "overall_score": overall, "maturity_level": maturity, "maturity_description": maturity_desc, "dimensions": dimensions, "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"], } def run_gx_checks(df: pd.DataFrame) -> list[dict[str, Any]]: results: list[dict[str, Any]] = [] n = len(df) def add(exp: str, success: bool, result: str, column: str | None = None, meta: dict | None = None): row: dict[str, Any] = {"suite": "great_expectations", "expectation": exp, "success": success, "result": result} if column: row["column"] = column if meta: row["meta"] = meta results.append(row) add("expect_table_row_count_to_be_between", n > 0, f"Row count: {n:,}", meta={"min": 1}) add("expect_table_column_count_to_be_between", len(df.columns) > 0, f"Columns: {len(df.columns)}", meta={"min": 1}) 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()) add("expect_table_row_count_to_equal", dup == 0, f"Duplicate rows: {dup}", meta={"duplicates": dup}) for col in df.columns: null_pct = float(df[col].isnull().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)}) s = df[col] if pd.api.types.is_numeric_dtype(s): s_clean = s.dropna() if len(s_clean) > 0: 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}) 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): lens = s.dropna().astype(str).str.len() if len(lens): add("expect_column_value_lengths_to_be_between", True, f"Length {int(lens.min())}-{int(lens.max())}", column=str(col)) u = int(s.nunique(dropna=True)) if u == n and n > 5: 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 def run_soda_checks(df: pd.DataFrame) -> list[dict[str, Any]]: checks: list[dict[str, Any]] = [] n = len(df) def soda(name: str, check: str, outcome: str, detail: str): checks.append({"suite": "soda_core", "name": name, "check": check, "outcome": outcome, "detail": detail}) 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") for col in df.columns[:15]: null_c = int(df[col].isnull().sum()) 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}%)") if pd.api.types.is_numeric_dtype(df[col]): s = df[col].dropna() 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}") u = df[col].nunique(dropna=True) if u < 5 and n > 20: vals = df[col].value_counts().head(3).to_dict() soda(f"cardinality_{col}", f"distinct_count({col}) < 5", "pass", f"Top values: {vals}") date_cols = [c for c in df.columns if "date" in c.lower() or "time" in c.lower()] for col in date_cols[:3]: parsed = pd.to_datetime(df[col], errors="coerce") bad = int(parsed.isnull().sum() - df[col].isnull().sum()) soda(f"freshness_{col}", f"invalid_percent({col}) < 5%", "pass" if bad < n * 0.05 else "fail", f"{bad} unparseable dates") return checks def _doc_stats(text: str) -> dict[str, Any]: lines = text.splitlines() if text else [] words = re.findall(r"\w+", text or "") tables = len(re.findall(r"\|.*\|", text or "")) headings = len(re.findall(r"^#{1,6}\s", text or "", re.M)) return { "characters": len(text or ""), "lines": len(lines), "words": len(words), "estimated_tables": max(tables // 3, 0), "headings": headings, } def extract_docling_structure(json_content: Any) -> dict[str, Any]: """Extract rich document structure from Docling JSON — pages, images, tables, outline.""" if not isinstance(json_content, dict): return { "pages": 0, "pictures": 0, "tables": 0, "text_blocks": 0, "headings": 0, "paragraphs": 0, "form_items": 0, "key_value_pairs": 0, "groups": 0, "table_details": [], "picture_details": [], "outline": [], } pages_raw = json_content.get("pages", {}) page_count = len(pages_raw) if isinstance(pages_raw, dict) else (int(pages_raw) if pages_raw else 0) pictures = json_content.get("pictures") or [] tables = json_content.get("tables") or [] texts = json_content.get("texts") or [] table_details: list[dict[str, Any]] = [] for i, tbl in enumerate(tables): data = (tbl or {}).get("data") or {} cells = data.get("table_cells") or [] table_details.append({ "index": i, "label": (tbl or {}).get("label", "table"), "rows": data.get("num_rows", 0), "cols": data.get("num_cols", 0), "cells": len(cells), "preview": " | ".join( str(c.get("text", ""))[:30] for c in cells[:6] if c.get("text") )[:200], }) picture_details: list[dict[str, Any]] = [] for i, pic in enumerate(pictures): img = (pic or {}).get("image") or {} size = img.get("size") or {} picture_details.append({ "index": i, "label": (pic or {}).get("label", "picture"), "has_image": bool(img), "captions": len((pic or {}).get("captions") or []), "annotations": len((pic or {}).get("annotations") or []), "width": size.get("width"), "height": size.get("height"), "mimetype": img.get("mimetype"), "dpi": img.get("dpi"), }) outline: list[dict[str, Any]] = [] for t in texts[:80]: label = (t or {}).get("label", "text") text = ((t or {}).get("text") or "")[:150] if text: outline.append({ "type": label, "text": text, "level": (t or {}).get("level"), }) label_counts: dict[str, int] = {} for t in texts: lb = (t or {}).get("label", "unknown") label_counts[lb] = label_counts.get(lb, 0) + 1 return { "pages": page_count, "pictures": len(pictures), "tables": len(tables), "text_blocks": len(texts), "headings": sum(1 for t in texts if (t or {}).get("label") in ("title", "section_header")), "paragraphs": sum(1 for t in texts if (t or {}).get("label") == "text"), "list_items": sum(1 for t in texts if (t or {}).get("label") == "list_item"), "form_items": len(json_content.get("form_items") or []), "key_value_pairs": len(json_content.get("key_value_items") or []), "groups": len(json_content.get("groups") or []), "label_counts": label_counts, "table_details": table_details, "picture_details": picture_details, "outline": outline, "document_name": json_content.get("name"), "schema_version": json_content.get("version"), } def save_docling_images(parse_id: str, json_content: Any) -> list[dict[str, Any]]: """Extract embedded images from Docling JSON and save to disk for gallery display.""" if not isinstance(json_content, dict): return [] out_dir = IMAGES_DIR / parse_id out_dir.mkdir(parents=True, exist_ok=True) saved: list[dict[str, Any]] = [] for i, pic in enumerate(json_content.get("pictures") or []): img = (pic or {}).get("image") or {} uri = str(img.get("uri") or "") size = img.get("size") or {} entry: dict[str, Any] = { "index": i, "label": (pic or {}).get("label", "picture"), "available": False, "width": size.get("width"), "height": size.get("height"), "mimetype": img.get("mimetype"), "dpi": img.get("dpi"), "captions": [(c or {}).get("text", "")[:120] for c in ((pic or {}).get("captions") or [])[:2]], } if uri.startswith("data:") and "," in uri: try: header, b64data = uri.split(",", 1) mime = header.split(":")[1].split(";")[0] if ":" in header else "image/png" ext = {"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg", "image/webp": "webp"}.get(mime, "png") raw = base64.b64decode(b64data) (out_dir / f"{i}.{ext}").write_bytes(raw) entry.update({ "available": True, "url": f"/dq/parse/{parse_id}/image/{i}", "bytes": len(raw), "mimetype": mime, }) except Exception: pass saved.append(entry) return saved def enrich_docling_response(raw: dict[str, Any], filename: str, *, include_full_json: bool = False, parse_id: str | None = None) -> dict[str, Any]: doc = raw.get("document") or {} md = doc.get("md_content") or "" html = doc.get("html_content") or "" json_content = doc.get("json_content") if isinstance(json_content, str): try: json_content = json.loads(json_content) except Exception: pass stats = _doc_stats(md or html or "") structure = extract_docling_structure(json_content) stats.update({ "pages": structure["pages"], "pictures": structure["pictures"], "tables": structure["tables"], "text_blocks": structure["text_blocks"], }) preview_md = md[:8000] + ("…" if len(md) > 8000 else "") preview_html = html[:12000] + ("…" if len(html) > 12000 else "") if html else "" table_lines = [ln for ln in md.splitlines() if "|" in ln and ln.strip().startswith("|")] content: dict[str, Any] = { "markdown": md, "html": html, "text": doc.get("text_content"), "preview_markdown": preview_md, "preview_html": preview_html, } if include_full_json and json_content: content["json"] = json_content images: list[dict[str, Any]] = [] if parse_id and json_content: images = save_docling_images(parse_id, json_content) for img in images: if img.get("available"): for pd in structure.get("picture_details", []): if pd.get("index") == img["index"]: pd["url"] = img["url"] pd["available"] = True return { "filename": filename, "status": raw.get("status", "unknown"), "processing_time_sec": raw.get("processing_time"), "errors": raw.get("errors", []), "formats_available": [k for k, v in { "markdown": md, "html": html, "json": json_content, "text": doc.get("text_content"), "doctags": doc.get("doctags_content"), }.items() if v], "content": content, "document_structure": structure, "images": images, "stats": stats, "table_preview": table_lines[:40], "confidence": raw.get("confidence"), "timings": raw.get("timings", {}), } async def call_docling(file_bytes: bytes, filename: str, to_formats: str | list[str] = "md") -> dict[str, Any]: if isinstance(to_formats, str): fmt_list = [f.strip() for f in to_formats.replace(";", ",").split(",") if f.strip()] else: fmt_list = list(to_formats) if not fmt_list: fmt_list = ["md"] form_data: dict[str, Any] = { "to_formats": fmt_list, "image_export_mode": "embedded", "do_ocr": "true", "table_mode": "accurate", } async with httpx.AsyncClient(timeout=300.0) as client: r = await client.post( f"{DOCLING_URL}/v1/convert/file", files={"files": (filename, file_bytes, "application/octet-stream")}, data=form_data, ) if r.status_code >= 400: return {"status": "error", "error": r.text, "http_status": r.status_code} return r.json() def render_report_html(report: dict[str, Any]) -> str: a = report.get("assessment", {}) dims = a.get("dimensions", []) profiles = a.get("column_profiles", [])[:20] def bar(score: float, color: str = "#38bdf8") -> str: return f'
' dim_rows = "".join( f"""{p['name']}{a.get('maturity_description','')}
{a.get('rows',0):,} rows · {a.get('columns',0)} columns
{a.get('memory_kb',0)} KB · {a.get('duplicate_pct',0)}% duplicates
GE: {gx_pass}/{gx_total} passed
Soda: {soda_warn} warnings
| Dimension | Score | Level | Findings |
|---|
| Column | Type | Null% | Unique | Flags | Samples |
|---|
{json.dumps(report.get('checks',{}).get('great_expectations',[]), indent=2)[:8000]}{json.dumps(report.get('checks',{}).get('soda_core',[]), indent=2)[:5000]}Use Command Center → Data Quality tab, of /docs
""") @app.get("/capabilities") async def capabilities(): health_data = await _health_payload() return { "maturity_dimensions": [{"id": d[0], "label": d[1], "description": d[2]} for d in DIMENSIONS], "maturity_levels": [{"min_score": t[0], "label": t[1], "description": t[2]} for t in MATURITY_LEVELS], "supported_data_formats": sorted(DATA_EXTS), "supported_document_formats": sorted(DOC_EXTS), "tools": health_data.get("tools", {}), "docling_online": health_data.get("docling", False), } @app.get("/reports") async def list_reports(): _ensure_dirs() items = [] for p in sorted(REPORTS_DIR.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True)[:50]: try: data = json.loads(p.read_text()) a = data.get("assessment", {}) items.append({ "id": data.get("id"), "filename": data.get("filename"), "ts": data.get("ts"), "overall_score": a.get("overall_score"), "maturity_level": a.get("maturity_level"), "rows": a.get("rows"), "columns": a.get("columns"), }) except Exception: continue return {"reports": items} @app.get("/parses") async def list_parses(): _ensure_dirs() items = [] for p in sorted(PARSES_DIR.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True)[:30]: try: data = json.loads(p.read_text()) items.append({ "id": data.get("id"), "filename": data.get("filename"), "ts": data.get("ts"), "stats": data.get("stats"), "formats_available": data.get("formats_available"), }) except Exception: continue return {"parses": items} @app.get("/report/{report_id}") async def get_report(report_id: str): path = REPORTS_DIR / f"{report_id}.json" if not path.exists(): return JSONResponse({"error": "not found"}, status_code=404) report = json.loads(path.read_text()) return HTMLResponse(render_report_html(report)) @app.get("/report/{report_id}/json") async def get_report_json(report_id: str): path = REPORTS_DIR / f"{report_id}.json" if not path.exists(): return JSONResponse({"error": "not found"}, status_code=404) return json.loads(path.read_text()) @app.get("/parse/{parse_id}/image/{image_idx}") async def get_parse_image(parse_id: str, image_idx: int): img_dir = IMAGES_DIR / parse_id if not img_dir.exists(): return JSONResponse({"error": "not found"}, status_code=404) for ext in ("png", "jpg", "jpeg", "webp", "gif"): path = img_dir / f"{image_idx}.{ext}" if path.exists(): media = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp", "gif": "image/gif"} return FileResponse(path, media_type=media.get(ext, "image/png")) return JSONResponse({"error": "not found"}, status_code=404) @app.get("/parse/{parse_id}/json") async def get_parse_json(parse_id: str): path = PARSES_DIR / f"{parse_id}.json" if not path.exists(): return JSONResponse({"error": "not found"}, status_code=404) return json.loads(path.read_text()) @app.post("/assess") async def assess_file(file: UploadFile = File(...)): _ensure_dirs() report_id = str(uuid.uuid4())[:8] safe = file.filename or "upload.csv" ext = Path(safe).suffix.lower() content = await file.read() dest = UPLOADS_DIR / f"{report_id}_{safe}" dest.write_bytes(content) docling_meta = None parse_id: str | None = None if ext in DOC_EXTS and ext not in DATA_EXTS: parse_id = str(uuid.uuid4())[:8] raw = await call_docling(content, safe) enriched = enrich_docling_response(raw, safe, include_full_json=True, parse_id=parse_id) docling_meta = enriched (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", "") if not md.strip(): return JSONResponse({"ok": False, "error": "Docling kon geen tekst extraheren uit dit document", "docling": enriched}, status_code=422) # Try CSV-like table from markdown lines = [ln for ln in md.splitlines() if "|" in ln and not ln.strip().startswith("|-")] if len(lines) >= 2: try: table_md = "\n".join(lines) df = pd.read_csv(io.StringIO(table_md), sep="|", skipinitialspace=True) df = df.loc[:, ~df.columns.str.contains("^Unnamed")] df.columns = [str(c).strip() for c in df.columns] df = df.dropna(how="all") except Exception: df = pd.DataFrame({"content": md.splitlines()[:500]}) else: df = pd.DataFrame({"line": md.splitlines()[:1000], "line_num": range(1, min(1001, len(md.splitlines()) + 1))}) else: df = _load_df(dest) assessment = run_maturity_assessment(df, safe) checks = { "great_expectations": run_gx_checks(df), "soda_core": run_soda_checks(df), } report = { "id": report_id, "filename": safe, "ts": datetime.now(timezone.utc).isoformat(), "assessment": assessment, "checks": checks, "docling": docling_meta, } (REPORTS_DIR / f"{report_id}.json").write_text(json.dumps(report, indent=2, default=str)) rag_ingest = await forward_to_rag(content, safe) return { "ok": True, "report_id": report_id, "overall_score": assessment["overall_score"], "maturity_level": assessment["maturity_level"], "maturity_description": assessment["maturity_description"], "rows": assessment["rows"], "columns": assessment["columns"], "dimensions": assessment["dimensions"], "column_profiles": assessment["column_profiles"], "action_items": assessment["action_items"], "checks": checks, "checks_summary": { "great_expectations": {"total": len(checks["great_expectations"]), "passed": sum(1 for c in checks["great_expectations"] if c["success"])}, "soda_core": {"total": len(checks["soda_core"]), "warnings": sum(1 for c in checks["soda_core"] if c["outcome"] != "pass")}, }, "docling": { "used": docling_meta is not None, "parse_id": parse_id if docling_meta else None, "document_structure": (docling_meta or {}).get("document_structure"), "stats": (docling_meta or {}).get("stats"), "images": (docling_meta or {}).get("images", []), } if docling_meta else None, "report_url": f"/dq/report/{report_id}", "report_json_url": f"/dq/report/{report_id}/json", "rag_ingest": rag_ingest, } @app.post("/parse") async def parse_document( file: UploadFile = File(...), to_formats: str = Form("md,html,json"), ): _ensure_dirs() parse_id = str(uuid.uuid4())[:8] safe = file.filename or "document.pdf" content = await file.read() raw = await call_docling(content, safe, to_formats) if raw.get("status") == "error": return JSONResponse({"ok": False, **raw}, status_code=502) enriched = enrich_docling_response(raw, safe, include_full_json=True, parse_id=parse_id) record = {"id": parse_id, "ts": datetime.now(timezone.utc).isoformat(), **enriched} (PARSES_DIR / f"{parse_id}.json").write_text(json.dumps(record, default=str)) (UPLOADS_DIR / f"{parse_id}_{safe}").write_bytes(content) api_payload = { k: v for k, v in enriched.items() if k not in ("content",) } api_payload["content"] = { "preview_markdown": enriched.get("content", {}).get("preview_markdown"), "preview_html": enriched.get("content", {}).get("preview_html"), } return {"ok": True, "parse_id": parse_id, **api_payload, "parse_json_url": f"/dq/parse/{parse_id}/json"}