File size: 5,332 Bytes
81e5fe7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""analyze_profile — per-column data-quality profile (KM-608).

An analytical "family" tool: in ONE call it 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.
Answers "is this data clean enough to analyze?" and surfaces issues (lots of
nulls, a constant column, outliers) before deeper analysis.

STATUS: compute layer only — the function takes an already-materialized
DataFrame. The wrapper layer (fetching data from the catalog via source_id,
the ToolOutput envelope, ToolSpec registration) is added once the Planner
seam (KM-418) is settled. Keeping compute separate from data-fetching makes
this function easy to unit-test in isolation and stable when wrapped.
"""

from __future__ import annotations

import pandas as pd

from src.tools.analytics.descriptive import ColumnNotFoundError


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


# Prompt-style description read by the Planner to decide WHEN to pick this tool.
# Final destination is ToolSpec.description once the wrapper layer is built.
DESCRIPTION = """\
Summary: Per-column data-quality profile. For each column reports 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.

USE WHEN the question is about the HEALTH of the data, not its statistics: \
missing values, duplicates, data types, outliers, "is this clean enough to \
analyze". Trigger words: "quality" (kualitas), "missing/nulls" (data kosong), \
"data type" (tipe data), "duplicates/unique" (duplikat/unik), "outliers".

DON'T USE WHEN:
  - the user wants statistics like mean/median/std/skew -> analyze_descriptive
  - it groups or compares -> analyze_aggregate / analyze_comparison

Example questions:
  - "is this dataset clean enough to analyze?"
  - "which columns have a lot of missing values?"
  - "what are the data types and unique counts per column?"
  - "are there outliers in the amount column?"
"""


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

    Args:
        df: already-materialized data (in the real system the wrapper fetches
            this from a source_id).
        column_ids: columns to profile. If None, every column is profiled.

    Returns:
        dict: { column_id: { profile fields, ... }, ... }

    Raises:
        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}")

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