File size: 12,046 Bytes
f8e45ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f26fd6b
f8e45ae
 
 
 
f26fd6b
f8e45ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
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)


@dataclass
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")