Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import hashlib | |
| import io | |
| import json | |
| import logging | |
| import re | |
| from dataclasses import dataclass | |
| from typing import Any | |
| from urllib.parse import quote | |
| import numpy as np | |
| import pandas as pd | |
| import requests | |
| logger = logging.getLogger(__name__) | |
| TARGET_WORDS = { | |
| "target": 1.0, | |
| "label": 1.0, | |
| "outcome": 0.95, | |
| "class": 0.9, | |
| "churn": 0.95, | |
| "fraud": 0.95, | |
| "default": 0.9, | |
| "price": 0.8, | |
| "revenue": 0.75, | |
| "sales": 0.75, | |
| "diagnosis": 0.9, | |
| "status": 0.7, | |
| "response": 0.8, | |
| "converted": 0.9, | |
| } | |
| PII_PATTERN = re.compile(r"(email|phone|mobile|address|ssn|passport|account|name)", re.I) | |
| class DatasetBrief: | |
| fingerprint: str | |
| rows: int | |
| columns: int | |
| numeric: int | |
| categorical: int | |
| datetime: int | |
| missing_cells: int | |
| duplicate_rows: int | |
| memory_mb: float | |
| def inspect_dataset(frame: pd.DataFrame) -> dict[str, Any]: | |
| """Compute an immediate analyst profile without requiring a target.""" | |
| numeric = list(frame.select_dtypes(include=np.number).columns) | |
| datetime = list(frame.select_dtypes(include=["datetime", "datetimetz"]).columns) | |
| categorical = [c for c in frame.columns if c not in numeric and c not in datetime] | |
| missing = frame.isna().sum() | |
| brief = DatasetBrief( | |
| fingerprint=hashlib.sha256( | |
| pd.util.hash_pandas_object(frame, index=True).values.tobytes() | |
| ).hexdigest()[:12], | |
| rows=len(frame), | |
| columns=len(frame.columns), | |
| numeric=len(numeric), | |
| categorical=len(categorical), | |
| datetime=len(datetime), | |
| missing_cells=int(missing.sum()), | |
| duplicate_rows=int(frame.duplicated().sum()), | |
| memory_mb=round(float(frame.memory_usage(deep=True).sum() / 1_048_576), 2), | |
| ) | |
| dictionary = build_data_dictionary(frame) | |
| quality_score = max( | |
| 0, | |
| round( | |
| 100 | |
| - (brief.missing_cells / max(1, frame.size)) * 45 | |
| - (brief.duplicate_rows / max(1, brief.rows)) * 25 | |
| - sum(dictionary["issue_count"].clip(upper=3)) / max(1, len(dictionary)) * 4 | |
| ), | |
| ) | |
| return { | |
| "brief": brief, | |
| "dictionary": dictionary, | |
| "targets": rank_target_candidates(frame), | |
| "quality_score": quality_score, | |
| "missing": missing.sort_values(ascending=False), | |
| "numeric": numeric, | |
| "categorical": categorical, | |
| "datetime": datetime, | |
| "correlation": frame[numeric].corr(numeric_only=True) | |
| if len(numeric) > 1 | |
| else pd.DataFrame(), | |
| } | |
| def build_data_dictionary(frame: pd.DataFrame) -> pd.DataFrame: | |
| """Create an evidence-based data dictionary for every column.""" | |
| rows: list[dict[str, Any]] = [] | |
| for column in frame.columns: | |
| series = frame[column] | |
| unique, missing = int(series.nunique(dropna=True)), int(series.isna().sum()) | |
| issues: list[str] = [] | |
| if missing: | |
| issues.append("Missing values") | |
| if unique <= 1: | |
| issues.append("Constant") | |
| if len(frame) and unique / len(frame) > 0.98: | |
| issues.append("Identifier-like") | |
| if PII_PATTERN.search(str(column)): | |
| issues.append("Potential PII") | |
| role = "Numeric feature" if pd.api.types.is_numeric_dtype(series) else "Categorical feature" | |
| if pd.api.types.is_datetime64_any_dtype(series): | |
| role = "Datetime" | |
| elif unique == len(frame) and len(frame) > 10: | |
| role = "Identifier" | |
| rows.append( | |
| { | |
| "column": str(column), | |
| "type": str(series.dtype), | |
| "role": role, | |
| "unique": unique, | |
| "missing": missing, | |
| "missing_%": round(missing / max(1, len(frame)) * 100, 2), | |
| "example_values": ", ".join(map(str, series.dropna().astype(str).unique()[:3]))[ | |
| :90 | |
| ], | |
| "issues": ", ".join(issues) or "None detected", | |
| "issue_count": len(issues), | |
| } | |
| ) | |
| return pd.DataFrame(rows) | |
| def rank_target_candidates(frame: pd.DataFrame) -> list[dict[str, Any]]: | |
| """Rank targets while explicitly leaving confirmation to the user.""" | |
| ranked: list[dict[str, Any]] = [] | |
| for position, column in enumerate(frame.columns): | |
| series, name = frame[column], str(column).lower().strip() | |
| cardinality = int(series.nunique(dropna=True)) | |
| score = max((v for word, v in TARGET_WORDS.items() if word in name), default=0.0) | |
| if 2 <= cardinality <= max(20, int(len(frame) * 0.1)): | |
| score += 0.22 | |
| if position == len(frame.columns) - 1: | |
| score += 0.12 | |
| if cardinality >= max(10, int(len(frame) * 0.95)): | |
| score -= 0.45 | |
| if series.isna().mean() > 0.5 or cardinality < 2: | |
| score -= 0.5 | |
| task = "classification" if cardinality <= max(20, int(len(frame) * 0.05)) else "regression" | |
| if pd.api.types.is_numeric_dtype(series) and cardinality > 20: | |
| task = "regression" | |
| ranked.append( | |
| { | |
| "column": str(column), | |
| "task": task, | |
| "confidence": round(max(0, min(score, 0.99)), 2), | |
| "reason": f"{cardinality:,} distinct values; " | |
| + ("name/position signals detected" if score > 0.3 else "weak heuristic evidence"), | |
| } | |
| ) | |
| return sorted(ranked, key=lambda item: item["confidence"], reverse=True)[:5] | |
| def ai_context(frame: pd.DataFrame, profile: dict[str, Any], excluded: list[str]) -> dict[str, Any]: | |
| """Build a bounded, redacted payload suitable for AI interpretation.""" | |
| safe = frame.drop(columns=[c for c in excluded if c in frame], errors="ignore").copy() | |
| pii = [c for c in safe.columns if PII_PATTERN.search(str(c))] | |
| safe = safe.drop(columns=pii, errors="ignore") | |
| return { | |
| "shape": list(frame.shape), | |
| "columns": profile["dictionary"].drop(columns=["issue_count"]).to_dict(orient="records"), | |
| "numeric_summary": safe.select_dtypes(include=np.number).describe().round(3).to_dict(), | |
| "sample": safe.head(3).replace({np.nan: None}).to_dict(orient="records"), | |
| "excluded_columns": sorted(set(excluded + pii)), | |
| "quality_score": profile["quality_score"], | |
| "target_candidates": profile["targets"], | |
| } | |
| def gemini_dataset_summary( | |
| frame: pd.DataFrame, profile: dict[str, Any], api_key: str, model: str, excluded: list[str] | |
| ) -> str: | |
| """Ask Gemini for an evidence-bounded narrative through a bounded REST call.""" | |
| if not api_key: | |
| raise ValueError("Enter a Gemini API key to generate AI interpretation.") | |
| allowed_models = {"gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"} | |
| if model not in allowed_models: | |
| raise ValueError("The selected Gemini model is not supported.") | |
| evidence = json.dumps(ai_context(frame, profile, excluded), default=str) | |
| if len(evidence) > 30_000: | |
| raise ValueError( | |
| "The AI evidence package is too large. Exclude columns or use a smaller dataset." | |
| ) | |
| prompt = ( | |
| """You are DataPilot, a rigorous senior data analyst. Use ONLY the supplied JSON. | |
| Return concise Markdown with exactly these headings: Finding, Evidence, Interpretation, | |
| Limitation, Recommendation. Explain likely row grain and useful business questions, but label | |
| uncertain semantics as assumptions. Never invent values, origin, or causal claims. | |
| DATA:\n""" | |
| + evidence | |
| ) | |
| endpoint = ( | |
| "https://generativelanguage.googleapis.com/v1beta/models/" | |
| f"{quote(model, safe='')}:generateContent" | |
| ) | |
| payload = { | |
| "contents": [{"role": "user", "parts": [{"text": prompt}]}], | |
| "generationConfig": {"maxOutputTokens": 1200}, | |
| } | |
| try: | |
| logger.warning("gemini_request_started model=%s evidence_chars=%d", model, len(evidence)) | |
| response = requests.post( | |
| endpoint, | |
| headers={ | |
| "x-goog-api-key": api_key, | |
| "Content-Type": "application/json", | |
| "X-Server-Timeout": "30", | |
| }, | |
| json=payload, | |
| timeout=(3, 8), | |
| ) | |
| logger.warning("gemini_response_received status=%d", response.status_code) | |
| except requests.Timeout as exc: | |
| logger.warning("gemini_request_timed_out") | |
| raise ValueError("Gemini timed out after 8 seconds. Please try again.") from exc | |
| except requests.ConnectionError as exc: | |
| logger.warning("gemini_connection_failed") | |
| raise ValueError("DataPilot could not connect to Gemini. Please try again.") from exc | |
| except requests.RequestException as exc: | |
| logger.warning("gemini_request_failed category=%s", type(exc).__name__) | |
| raise ValueError("The Gemini request could not be completed.") from exc | |
| status_messages = { | |
| 400: "Gemini rejected the evidence request.", | |
| 401: "The Gemini API key was rejected.", | |
| 403: "Gemini access is not enabled for this API key or project.", | |
| 404: "The selected Gemini model is unavailable. Select Gemini Flash.", | |
| 429: "Gemini quota is temporarily exhausted. Please try again later.", | |
| } | |
| if response.status_code in status_messages: | |
| raise ValueError(status_messages[response.status_code]) | |
| if response.status_code >= 500: | |
| raise ValueError("Gemini is temporarily unavailable. Please try again later.") | |
| if not response.ok: | |
| raise ValueError(f"Gemini returned an unexpected response ({response.status_code}).") | |
| try: | |
| body = response.json() | |
| parts = body["candidates"][0]["content"]["parts"] | |
| text = "\n".join( | |
| part["text"].strip() for part in parts if isinstance(part, dict) and part.get("text") | |
| ) | |
| except (ValueError, KeyError, IndexError, TypeError) as exc: | |
| raise ValueError("Gemini returned an invalid or empty response.") from exc | |
| if not text: | |
| raise ValueError("Gemini returned no text. The request may have been blocked.") | |
| logger.warning("gemini_response_validated output_chars=%d", len(text)) | |
| return text | |
| def evidence_dataset_summary(frame: pd.DataFrame, profile: dict[str, Any]) -> str: | |
| """Create a deterministic, evidence-only brief for restricted hosted runtimes.""" | |
| rows, columns = frame.shape | |
| missing = int(frame.isna().sum().sum()) | |
| duplicates = int(frame.duplicated().sum()) | |
| numeric = len(frame.select_dtypes(include=np.number).columns) | |
| target_names = [str(item.get("column")) for item in profile.get("targets", [])[:3]] | |
| targets = ", ".join(target_names) if target_names else "No strong target candidate detected" | |
| return ( | |
| "### Finding\n" | |
| f"The dataset contains **{rows:,} rows and {columns:,} columns**, including " | |
| f"**{numeric:,} numeric features**. Its computed quality score is " | |
| f"**{profile.get('quality_score', 'not available')}/100**.\n\n" | |
| "### Evidence\n" | |
| f"The deterministic profile found **{missing:,} missing cells** and " | |
| f"**{duplicates:,} duplicate rows**. Leading analytical target candidates: {targets}.\n\n" | |
| "### Interpretation\n" | |
| "The dataset is suitable for exploratory analysis when its row grain and field " | |
| "definitions are confirmed. Target candidates are structural recommendations, not " | |
| "proof of business relevance.\n\n" | |
| "### Limitation\n" | |
| "This hosted brief is generated from computed evidence without an external LLM. " | |
| "Associations and model scores must not be interpreted as causal effects.\n\n" | |
| "### Recommendation\n" | |
| "Confirm the business objective and target meaning, review quality findings, then " | |
| "use Model Lab with an untouched test set for final evaluation." | |
| ) | |
| def dataframe_csv(frame: pd.DataFrame) -> bytes: | |
| buffer = io.StringIO() | |
| frame.to_csv(buffer, index=False) | |
| return buffer.getvalue().encode("utf-8") | |