Rifqi Hafizuddin
Claude Opus 5
[NOTICKET] tools: one schema mechanism and a description on every input
c0040e4 Download src/tools/analytics/temporal.py from DataEyond/Agentic-Service-Data-Eyond-Catalog: direct link, hf CLI and curl.
- Browser
- Download file 13 kB
-
https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/refs%2Fpr%2F22/src/tools/analytics/temporal.py
- Command line
-
hf download hf://spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog@refs/pr/22/src/tools/analytics/temporal.py
-
curl -L -o temporal.py https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/refs%2Fpr%2F22/src/tools/analytics/temporal.py
13 kB
| """analyze_trend β time-series trend over a period (KM-608). | |
| An analytical "family" tool: in ONE call it buckets rows into time periods | |
| (day/week/month/quarter/year), aggregates a value per period, and summarizes | |
| the movement (first vs last, absolute & percent change, direction, linear | |
| slope). Answers questions like "how did revenue trend month over month?". | |
| 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 numpy as np | |
| import pandas as pd | |
| from pydantic import BaseModel, Field | |
| from src.tools.analytics.agg_vocab import normalise_agg | |
| from src.tools.analytics.descriptive import ColumnNotFoundError | |
| # Friendly period name -> pandas resample rule. Using the non-deprecated | |
| # pandas 2.2 codes (ME/QE/YE) avoids FutureWarnings. | |
| FREQ_MAP = { | |
| "day": "D", | |
| "week": "W", | |
| "month": "ME", | |
| "quarter": "QE", | |
| "year": "YE", | |
| } | |
| # How to aggregate the value within each period. | |
| SUPPORTED_AGGS = ("sum", "mean", "count", "min", "max", "median") | |
| # Below this R^2, the straight-line trend explains little of the movement β the | |
| # reported direction is then flagged as weakly supported (D-7). | |
| _WEAK_TREND_R2 = 0.3 | |
| class InvalidFrequencyError(ValueError): | |
| """The requested period is not in FREQ_MAP (maps to error_code INVALID_FREQUENCY).""" | |
| class UnsupportedAggregationError(ValueError): | |
| """The requested aggregation is not supported (maps to error_code UNSUPPORTED_AGG).""" | |
| class InvalidDateColumnError(ValueError): | |
| """date_column holds numeric values that aren't a recognizable date/year/month | |
| (maps to error_code INVALID_DATE_COLUMN).""" | |
| def _clean(value: object) -> object: | |
| """Convert numpy scalars to plain Python; NaN -> None for JSON-clean output.""" | |
| if value is None: | |
| return None | |
| if isinstance(value, float) and np.isnan(value): | |
| return None | |
| if hasattr(value, "item"): | |
| value = value.item() | |
| return None if isinstance(value, float) and np.isnan(value) else value | |
| return value | |
| def _parse_date_column(df: pd.DataFrame, date_column: str) -> pd.Series: | |
| """Parse date_column into datetimes, guarding against numeric epoch misparsing. | |
| pd.to_datetime() treats bare numeric input as epoch-nanoseconds, so bare | |
| month numbers (1-12) or calendar years (e.g. 2025) silently collapse to a | |
| single 1970 timestamp instead of raising. Numeric columns are resolved | |
| explicitly here rather than falling through to pd.to_datetime(). | |
| """ | |
| col = df[date_column] | |
| if not pd.api.types.is_numeric_dtype(col): | |
| return pd.to_datetime(col) | |
| non_null = col.dropna() | |
| is_whole = non_null.empty or (non_null == non_null.astype(int)).all() | |
| if is_whole and non_null.between(1, 12).all(): | |
| year_col = next((c for c in df.columns if c.lower() == "year"), None) | |
| year_series = df[year_col] if year_col is not None else None | |
| year_non_null = year_series.dropna() if year_series is not None else pd.Series(dtype=float) | |
| year_ok = ( | |
| year_series is not None | |
| and pd.api.types.is_numeric_dtype(year_series) | |
| and not year_non_null.empty | |
| and (year_non_null == year_non_null.astype(int)).all() | |
| and year_non_null.between(1900, 2100).all() | |
| ) | |
| if not year_ok: | |
| raise InvalidDateColumnError( | |
| f"date_column '{date_column}' holds bare month numbers (1-12) and no " | |
| "'year' column is present in the data β retrieve a year column " | |
| "alongside month, or use a real date column." | |
| ) | |
| valid = col.notna() & year_series.notna() | |
| result = pd.Series(pd.NaT, index=col.index, dtype="datetime64[ns]") | |
| result.loc[valid] = pd.to_datetime( | |
| { | |
| "year": year_series.loc[valid].astype(int), | |
| "month": col.loc[valid].astype(int), | |
| "day": 1, | |
| } | |
| ) | |
| return result | |
| if is_whole and non_null.between(1900, 2100).all(): | |
| result = pd.Series(pd.NaT, index=col.index, dtype="datetime64[ns]") | |
| valid = col.notna() | |
| result.loc[valid] = pd.to_datetime( | |
| col.loc[valid].astype(int).astype(str), format="%Y" | |
| ) | |
| return result | |
| raise InvalidDateColumnError( | |
| f"date_column '{date_column}' is numeric but is not a recognizable date, " | |
| "year, or month column." | |
| ) | |
| def _period_label(ts: pd.Timestamp, freq: str) -> str: | |
| """Human-readable period label keyed off the friendly frequency name.""" | |
| if freq == "month": | |
| return str(ts.strftime("%Y-%m")) | |
| if freq == "quarter": | |
| return f"{ts.year}-Q{ts.quarter}" | |
| if freq == "year": | |
| return str(ts.strftime("%Y")) | |
| return str(ts.strftime("%Y-%m-%d")) # day / week | |
| # 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. | |
| class TrendInput(BaseModel): | |
| """Planner-facing inputs for `analyze_trend` (spec_builder contract).""" | |
| data: str = Field(..., description="Placeholder ${t<id>} of the upstream table (Pattern A).") | |
| date_column: str = Field( | |
| ..., | |
| description="Column holding dates to bucket on. May be bare month numbers (1-12) " | |
| "only if a `year` column was retrieved alongside it.", | |
| ) | |
| value_column: str = Field(..., description="Numeric column to track over time.") | |
| freq: str = Field( | |
| "month", description="Period granularity: day, week, month, quarter, year." | |
| ) | |
| agg: str = Field( | |
| "sum", | |
| description="How to aggregate the value within each period (sum, mean, count, " | |
| "min, max, median; `avg` accepted as `mean`).", | |
| ) | |
| DESCRIPTION = """\ | |
| Summary: Time-series trend of one metric over evenly-spaced periods (day, week, \ | |
| month, quarter, year). Reports per-period points plus direction, absolute and \ | |
| percent change, and a linear slope. | |
| USE WHEN the question is about movement over time β growth, decline, trend, \ | |
| seasonality. Trigger words: "over time" (dari waktu ke waktu), "trend" (tren), \ | |
| "monthly/yearly" (bulanan/tahunan), "growth" (pertumbuhan), "since/last N months". | |
| DON'T USE WHEN: | |
| - it groups by a non-time category -> analyze_aggregate | |
| - it compares two specific groups (A vs B) -> analyze_ab_test | |
| - it summarizes a column with no time axis -> analyze_descriptive | |
| Example questions: | |
| - "how did monthly revenue change this year?" | |
| - "show the sales trend over the last 12 months" | |
| - "is the number of signups growing quarter over quarter?" | |
| - "yearly profit from 2019 to 2024" | |
| """ | |
| def analyze_trend( | |
| df: pd.DataFrame, | |
| date_column: str, | |
| value_column: str, | |
| freq: str = "month", | |
| agg: str = "sum", | |
| ) -> dict[str, object]: | |
| """Time-series trend of one value over evenly-spaced periods. | |
| Args: | |
| df: already-materialized data (in the real system the wrapper fetches | |
| this from a source_id). | |
| date_column: column holding dates/timestamps. | |
| value_column: numeric column to aggregate per period. | |
| freq: period granularity β one of FREQ_MAP keys (default "month"). | |
| agg: how to aggregate within a period β one of SUPPORTED_AGGS. | |
| Returns: | |
| dict with: | |
| freq, agg β echo of the chosen settings | |
| points β [{"period": str, "value": number|None}, ...] | |
| first, last β value of the first/last non-empty period | |
| change_abs β last - first | |
| change_pct β (last - first) / first, or None if first == 0 | |
| direction β "up" | "down" | "flat" (endpoints: last vs first) | |
| slope β linear slope across periods, or None if < 2 points | |
| r_squared β R^2 of that linear fit, or None (D-7) | |
| caveats β notes when direction and slope disagree, or the | |
| trend is weak (D-7) | |
| Raises: | |
| ColumnNotFoundError: if date_column or value_column is absent. | |
| InvalidFrequencyError: if freq is not a known period. | |
| UnsupportedAggregationError: if agg is not supported. | |
| InvalidDateColumnError: if date_column is numeric but not a recognizable | |
| date, year, or bare month number (needing a companion 'year' column). | |
| """ | |
| missing = [c for c in (date_column, value_column) if c not in df.columns] | |
| if missing: | |
| raise ColumnNotFoundError(f"columns not found: {missing}") | |
| if freq not in FREQ_MAP: | |
| raise InvalidFrequencyError( | |
| f"unknown frequency '{freq}'; supported: {list(FREQ_MAP)}" | |
| ) | |
| # `avg` is the QueryIR's word for this and planner.md teaches it; the tools | |
| # speak pandas. Accept both, and report whatever the caller actually wrote. | |
| # (A23 Bug 2 -- see agg_vocab.) | |
| requested, agg = agg, normalise_agg(agg) | |
| if agg not in SUPPORTED_AGGS: | |
| raise UnsupportedAggregationError( | |
| f"unsupported aggregation '{requested}'; supported: {list(SUPPORTED_AGGS)}" | |
| ) | |
| # Build a clean datetime-indexed series, then resample into periods. | |
| # date_column may equal value_column β e.g. "count of events per month", | |
| # where the value being aggregated IS the date itself. Selecting | |
| # df[[col, col]] then yields a duplicate-named 2-col frame whose set_index | |
| # key is 2-D ("Index data must be 1-dimensional"), so build the date and | |
| # value series positionally instead of via column selection. | |
| dates = pd.Series(_parse_date_column(df, date_column).to_numpy(), name="_date") | |
| values = pd.Series(df[value_column].to_numpy(), name="_value") | |
| s = pd.concat([dates, values], axis=1).dropna(subset=["_date"]).set_index("_date") | |
| resampled = s["_value"].sort_index().resample(FREQ_MAP[freq]).agg(agg) | |
| points = [ | |
| {"period": _period_label(ts, freq), "value": _clean(val)} | |
| for ts, val in resampled.items() | |
| ] | |
| # Summary stats are computed over non-empty periods only. | |
| non_null = resampled.dropna() | |
| first: float | None | |
| last: float | None | |
| change_abs: float | None | |
| change_pct: float | None | |
| slope: float | None | |
| r_squared: float | None | |
| if non_null.empty: | |
| first = last = change_abs = change_pct = slope = r_squared = None | |
| direction = "flat" | |
| else: | |
| first = float(non_null.iloc[0]) | |
| last = float(non_null.iloc[-1]) | |
| change_abs = last - first | |
| change_pct = (change_abs / first) if first != 0 else None | |
| if change_abs > 0: | |
| direction = "up" | |
| elif change_abs < 0: | |
| direction = "down" | |
| else: | |
| direction = "flat" | |
| if non_null.shape[0] > 1: | |
| x = np.arange(non_null.shape[0]) | |
| y = non_null.to_numpy(dtype=float) | |
| slope = float(np.polyfit(x, y, 1)[0]) | |
| # R^2 of the straight-line fit = squared Pearson r; undefined for a constant | |
| # series (zero variance), so guard rather than divide by zero. | |
| if float(np.std(y)) > 0: | |
| r = np.corrcoef(x, y)[0, 1] | |
| r_squared = None if np.isnan(r) else round(float(r * r), 4) | |
| else: | |
| r_squared = None | |
| else: | |
| slope = None | |
| r_squared = None | |
| # D-7: `direction` is endpoints-only (last vs first); `slope` is OLS over all points. | |
| # On a noisy series they can disagree, and neither is wrong β so say so rather than | |
| # letting the assembler assert one. R^2 also lets it qualify a weak trend. | |
| caveats: list[str] = [] | |
| if slope is not None and change_abs is not None and change_abs != 0 and slope != 0: | |
| if (change_abs > 0) != (slope > 0): | |
| caveats.append( | |
| f"Mixed signal: first-vs-last goes {direction}, but the overall trend " | |
| f"(slope {slope:+.4g}) points the other way β the series is noisy, so " | |
| f"{direction!r} reflects only the endpoints, not a consistent move." | |
| ) | |
| if r_squared is not None and r_squared < _WEAK_TREND_R2: | |
| caveats.append( | |
| f"The straight-line trend is weak (R^2 = {r_squared:.2f}) β the direction is " | |
| "not well supported by a consistent slope." | |
| ) | |
| return { | |
| "freq": freq, | |
| "agg": agg, | |
| "points": points, | |
| "first": first, | |
| "last": last, | |
| "change_abs": change_abs, | |
| "change_pct": change_pct, | |
| "direction": direction, | |
| "slope": slope, | |
| "r_squared": r_squared, | |
| "caveats": caveats, | |
| } | |