File size: 7,343 Bytes
e201ae4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""analyze_data_quality β€” per-column data-quality profile (Β§0.9, renamed from analyze_profile).

Rename + activate of the former `analyze_profile` (`quality.py`): profiles each
column's health β€” dtype, inferred type, completeness (null count/rate), cardinality
(distinct count/rate, constant flag), and β€” for numeric columns β€” min/max/mean plus
an IQR-based outlier count; for non-numeric columns the most frequent value. Now
also returns an issues summary and ships a **missing-rate bar chart** as a package.

Follows docs/tools/ANALYTICAL_TOOLS_CONVENTIONS.md: pure synchronous compute (no
model fit β†’ stays sync), Pattern A `data`, errors escape to the invoker's never-throw
seam. Deterministic.
"""

from __future__ import annotations

import pandas as pd
from pydantic import BaseModel, Field

from src.tools.analytics.charts import LAYOUT_PRESET, _axis, envelope
from src.tools.analytics.descriptive import ColumnNotFoundError
from src.tools.spec_builder import build_description


def _clean(value: object) -> object:
    """Convert numpy/pandas scalars to plain Python so the output is JSON-clean.

    `top_value` (most frequent value) can be a `pandas.Timestamp` when profiling a
    datetime column β€” neither `Timestamp` nor numpy scalars are JSON-safe.
    """
    if isinstance(value, pd.Timestamp):
        return value.isoformat()
    if hasattr(value, "item"):
        return value.item()
    return value


def _profile_one(series: pd.Series) -> dict[str, object]:
    """Build the quality profile for a single column."""
    total = len(series)
    non_null = series.dropna()
    nn = len(non_null)
    distinct = int(series.nunique(dropna=True))

    is_bool = pd.api.types.is_bool_dtype(series)
    is_datetime = pd.api.types.is_datetime64_any_dtype(series)
    # bool is technically numeric in pandas; treat it as its own type.
    is_numeric = pd.api.types.is_numeric_dtype(series) and not is_bool

    if is_bool:
        inferred = "boolean"
    elif is_datetime:
        inferred = "datetime"
    elif is_numeric:
        inferred = "numeric"
    else:
        inferred = "categorical"

    out: dict[str, object] = {
        "dtype": str(series.dtype),
        "inferred_type": inferred,
        "count": int(total),
        "null_count": int(series.isna().sum()),
        "null_rate": float(series.isna().mean()) if total else 0.0,
        "distinct_count": distinct,
        "distinct_rate": (distinct / nn) if nn else 0.0,  # over non-null values
        "is_constant": distinct <= 1,
    }

    if is_numeric and nn > 0:
        out["min"] = _clean(non_null.min())
        out["max"] = _clean(non_null.max())
        out["mean"] = _clean(non_null.mean())
        # IQR rule: values outside [Q1 - 1.5*IQR, Q3 + 1.5*IQR] are outliers.
        # Needs enough points for stable quartiles.
        if nn >= 4:
            q1 = non_null.quantile(0.25)
            q3 = non_null.quantile(0.75)
            iqr = q3 - q1
            lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr
            out["outlier_count"] = int(((non_null < lower) | (non_null > upper)).sum())
        else:
            out["outlier_count"] = None
    elif not is_numeric and nn > 0:
        counts = non_null.value_counts()
        out["top_value"] = _clean(counts.index[0])
        out["top_freq"] = int(counts.iloc[0])

    return out


class DataQualityInput(BaseModel):
    # `data` FIRST β€” property order follows field order (planner-prompt readability).
    data: str = Field(..., description="Placeholder ${t<id>} of the upstream table (Pattern A).")
    column_ids: list[str] | None = Field(
        None, description="Columns to profile; omit to profile every column."
    )


DESCRIPTION = build_description(
    summary="Per-column data-quality profile β€” dtype, completeness (nulls), cardinality, "
    "constants, and IQR outliers for numeric columns.",
    use_when="the question is about the HEALTH of the data, not its statistics β€” missing "
    "values, data types, duplicates/unique, constant columns, outliers, 'is this clean "
    "enough to analyze' β€” 'kualitas', 'data kosong', 'tipe data', 'duplikat', 'outlier'.",
    dont_use_when=[
        "the user wants statistics like mean/median/std/skew -> analyze_descriptive",
        "it groups or compares -> analyze_aggregate / analyze_ab_test",
    ],
    output="a per-column profile (types, null rate, distinct, outliers), an issues summary "
    "(constant + high-null columns), and a bar chart of missing rate per column.",
    examples=[
        "is this dataset clean enough to analyze?",
        "which columns have a lot of missing values?",
        "data types and unique counts per column?",
    ],
)

# Columns with at least this null rate are surfaced in the issues summary.
_HIGH_NULL_THRESHOLD = 0.2


def _missing_rate_chart(columns: dict[str, dict[str, object]]) -> dict[str, object]:
    """Bar chart of null rate per column (the headline data-quality signal)."""
    cols = list(columns)
    null_rates = [round(float(columns[c]["null_rate"]), 4) for c in cols]
    title = "Missing rate by column"
    layout = {
        **LAYOUT_PRESET,
        "title": {"text": title},
        "showlegend": False,
        "xaxis": _axis("column"),
        "yaxis": _axis("null rate"),
    }
    traces = [{"type": "bar", "x": cols, "y": null_rates, "name": "null rate"}]
    return envelope("bar", title, traces, layout)


def analyze_data_quality(
    df: pd.DataFrame,
    column_ids: list[str] | None = None,
) -> dict[str, object]:
    """Per-column data-quality profile.

    `data` is materialised upstream (Pattern A) and arrives as `df`.

    Returns a dict with: n_rows, n_columns, `columns` ({column: profile}),
    `duplicate_rows` ({count, rate} β€” full duplicate rows over the profiled columns),
    `issues` (constant_columns + high_null_columns + duplicate_row_count), and `charts`
    (the reserved auto-chart key).

    Raises (wrapped by the invoker's never-throw seam):
        ColumnNotFoundError: if any column_id is absent from df.
    """
    cols = list(column_ids) if column_ids is not None else list(df.columns)
    missing = [c for c in cols if c not in df.columns]
    if missing:
        raise ColumnNotFoundError(f"columns not found: {missing}")

    columns = {col: _profile_one(df[col]) for col in cols}

    # Full-row duplicates over the profiled columns β€” `use_when` advertises "duplikat",
    # so it must be computed, not implied by distinct_rate. `keep="first"` counts the
    # redundant copies (rows beyond the first of each group); over a column subset this
    # is a candidate-key duplicate check. Always reported (0 when none), never absent.
    n_rows = int(len(df))
    dup_count = int(df[cols].duplicated().sum())
    duplicate_rows = {"count": dup_count, "rate": (dup_count / n_rows) if n_rows else 0.0}

    issues = {
        "constant_columns": [c for c, p in columns.items() if p["is_constant"]],
        "high_null_columns": [
            c for c, p in columns.items() if p["null_rate"] >= _HIGH_NULL_THRESHOLD
        ],
        "duplicate_row_count": dup_count,
    }

    return {
        "n_rows": n_rows,
        "n_columns": len(cols),
        "columns": columns,
        "duplicate_rows": duplicate_rows,
        "issues": issues,
        "charts": [_missing_rate_chart(columns)],
    }