[NOTICKET] fix: aggregate trend charts + materialize analyze_* tables in the chart chain
Browse filesA "visualize the trend of X" question produced a broken chart in two ways:
render_chart errored ("cannot materialize 'data' of kind 'table'"), and once
that was worked around it plotted 9,729 raw rows (a vertical smear) of the wrong
column (Plan_PA_Percent aliased "pa_percent") instead of a per-day mean trend.
Tools (materialize + render):
- _materialize (invoker.py) now accepts the analyze_* table shape: a ToolOutput
of kind "table" whose data is `value` = list-of-dicts (columns/rows None), as
analyze_aggregate emits. Previously only the retrieve_data columns+rows shape
was handled, so ANY analyze_* -> analyze_*/render_chart chain failed. Class fix.
- render_chart (visualization.py) resolves a requested x/y column to its
aggregated name when the exact name is absent (pa_percent -> pa_percent_mean),
so a retrieve -> analyze_aggregate -> render_chart chain doesn't 404 on columns.
Planner (planner.md + examples.py):
- A line/trend chart over time must produce ONE ROW PER PERIOD: group_by the date
+ aggregate the measure in the retrieve_data IR (or chain analyze_trend), never
feed render_chart raw per-record rows. Added Example M (trend line chart).
- Pick the EXACT measure the user named, not a planned/target/adjusted/_2 sibling
(generic guidance, no dataset-specific column names in the global prompt).
Verified live: "visualisasi trend PA" now plans retrieve(group_by From_Date +
avg PA_Percent) -> render_chart, a clean 20-point daily trend on the right column.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- src/agents/planner/examples.py +80 -0
- src/config/prompts/planner.md +11 -0
- src/tools/analytics/visualization.py +19 -0
- src/tools/invoker.py +13 -3
|
@@ -905,6 +905,85 @@ _EXAMPLE_L = TaskList(
|
|
| 905 |
)
|
| 906 |
|
| 907 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 908 |
EXAMPLES: list[tuple[str, TaskList]] = [
|
| 909 |
("Which product categories drove last quarter's revenue?", _EXAMPLE_A),
|
| 910 |
("How has monthly revenue trended by region this year, and what's unusual?", _EXAMPLE_B),
|
|
@@ -922,6 +1001,7 @@ EXAMPLES: list[tuple[str, TaskList]] = [
|
|
| 922 |
("Show me a bar chart of total revenue per product category.", _EXAMPLE_J),
|
| 923 |
("Plot the customer churn rate by month as a line chart.", _EXAMPLE_K),
|
| 924 |
("How many orders have zero revenue?", _EXAMPLE_L),
|
|
|
|
| 925 |
]
|
| 926 |
|
| 927 |
|
|
|
|
| 905 |
)
|
| 906 |
|
| 907 |
|
| 908 |
+
# --------------------------------------------------------------------------- #
|
| 909 |
+
# Example M — line/TREND chart over time (viz tail on a time series).
|
| 910 |
+
# "Show me a line chart of average revenue over time."
|
| 911 |
+
# Shows: a trend line chart aggregates PER TIME PERIOD first — group_by the date
|
| 912 |
+
# column + aggregate the measure in the retrieve_data IR (one row per date), THEN
|
| 913 |
+
# tail render_chart. Never chart raw per-record rows over time (many rows per date
|
| 914 |
+
# = an unreadable vertical smear). Mirrors Example J (bar) but grouping by the date
|
| 915 |
+
# instead of a category. (analyze_trend -> render_chart is an equally valid shape.)
|
| 916 |
+
# --------------------------------------------------------------------------- #
|
| 917 |
+
|
| 918 |
+
_EXAMPLE_M = TaskList(
|
| 919 |
+
plan_id="example_m",
|
| 920 |
+
goal_restated="Chart average order revenue per day as a line chart over time.",
|
| 921 |
+
assumptions=["The date and revenue columns exist in the catalog (c_order_date, c_revenue)."],
|
| 922 |
+
open_questions=[],
|
| 923 |
+
tasks=[
|
| 924 |
+
Task(
|
| 925 |
+
id="t1",
|
| 926 |
+
stage="data_understanding",
|
| 927 |
+
objective="Confirm the sales source exposes order date and revenue.",
|
| 928 |
+
tool_calls=[ToolCall(tool="check_data", args={"source_id": "src_sales"})],
|
| 929 |
+
expected_output="source_shape",
|
| 930 |
+
success_criteria="Produced the orders table schema; order date and revenue present.",
|
| 931 |
+
depends_on=[],
|
| 932 |
+
estimated_cost="low",
|
| 933 |
+
),
|
| 934 |
+
Task(
|
| 935 |
+
id="t2",
|
| 936 |
+
stage="data_preparation",
|
| 937 |
+
objective="Aggregate average revenue per day (one row per date).",
|
| 938 |
+
tool_calls=[
|
| 939 |
+
ToolCall(
|
| 940 |
+
tool="retrieve_data",
|
| 941 |
+
args={
|
| 942 |
+
"ir": {
|
| 943 |
+
"source_id": "src_sales",
|
| 944 |
+
"table_id": "t_orders",
|
| 945 |
+
"select": [
|
| 946 |
+
{"kind": "column", "column_id": "c_order_date", "alias": "order_date"},
|
| 947 |
+
{"kind": "agg", "fn": "avg", "column_id": "c_revenue", "alias": "avg_revenue"},
|
| 948 |
+
],
|
| 949 |
+
"group_by": ["c_order_date"],
|
| 950 |
+
"order_by": [{"column_id": "c_order_date", "dir": "asc"}],
|
| 951 |
+
}
|
| 952 |
+
},
|
| 953 |
+
)
|
| 954 |
+
],
|
| 955 |
+
expected_output="daily_revenue",
|
| 956 |
+
success_criteria="Produced one average-revenue row per date, ordered by date.",
|
| 957 |
+
depends_on=["t1"],
|
| 958 |
+
estimated_cost="low",
|
| 959 |
+
),
|
| 960 |
+
Task(
|
| 961 |
+
id="t3",
|
| 962 |
+
stage="evaluation",
|
| 963 |
+
objective="Render the daily average-revenue series as a line chart.",
|
| 964 |
+
tool_calls=[
|
| 965 |
+
ToolCall(
|
| 966 |
+
tool="render_chart",
|
| 967 |
+
args={
|
| 968 |
+
# `data` is the AGGREGATED daily table (t2) — one point per date,
|
| 969 |
+
# never the raw per-order rows.
|
| 970 |
+
"data": "${t2}",
|
| 971 |
+
"chart_type": "line",
|
| 972 |
+
"x": "order_date",
|
| 973 |
+
"y": "avg_revenue",
|
| 974 |
+
"title": "Average revenue over time",
|
| 975 |
+
},
|
| 976 |
+
)
|
| 977 |
+
],
|
| 978 |
+
expected_output="revenue_line_chart",
|
| 979 |
+
success_criteria="Produced a line-chart spec with one point per date.",
|
| 980 |
+
depends_on=["t2"],
|
| 981 |
+
estimated_cost="low",
|
| 982 |
+
),
|
| 983 |
+
],
|
| 984 |
+
)
|
| 985 |
+
|
| 986 |
+
|
| 987 |
EXAMPLES: list[tuple[str, TaskList]] = [
|
| 988 |
("Which product categories drove last quarter's revenue?", _EXAMPLE_A),
|
| 989 |
("How has monthly revenue trended by region this year, and what's unusual?", _EXAMPLE_B),
|
|
|
|
| 1001 |
("Show me a bar chart of total revenue per product category.", _EXAMPLE_J),
|
| 1002 |
("Plot the customer churn rate by month as a line chart.", _EXAMPLE_K),
|
| 1003 |
("How many orders have zero revenue?", _EXAMPLE_L),
|
| 1004 |
+
("Show me a line chart of average revenue over time.", _EXAMPLE_M),
|
| 1005 |
]
|
| 1006 |
|
| 1007 |
|
|
@@ -137,6 +137,17 @@ recipe verbatim; a genuinely multi-part question composes recipes.
|
|
| 137 |
already-aggregated table (one row per category/period), not raw rows. Pick
|
| 138 |
`chart_type` by the question: `bar` (magnitude per category), `line` (over
|
| 139 |
time), `pie` (share of a small whole), `scatter` (two numeric columns).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
A chart ask NEVER relaxes feasibility (rule 6): if the asked-for dimension or
|
| 141 |
measure has no catalog column, the question is **infeasible** — never chart a
|
| 142 |
stand-in column aliased under the asked-for name (e.g. never select a status
|
|
|
|
| 137 |
already-aggregated table (one row per category/period), not raw rows. Pick
|
| 138 |
`chart_type` by the question: `bar` (magnitude per category), `line` (over
|
| 139 |
time), `pie` (share of a small whole), `scatter` (two numeric columns).
|
| 140 |
+
**A `line`/trend chart over time needs ONE ROW PER TIME PERIOD** — either
|
| 141 |
+
`group_by` the date column + aggregate the measure in the `retrieve_data` IR
|
| 142 |
+
(like the per-category bar chart, but grouping by the date), or chain
|
| 143 |
+
`analyze_trend` first, then tail `render_chart` on that. NEVER feed the chart
|
| 144 |
+
raw per-record rows over time: with many records per date it draws an
|
| 145 |
+
unreadable vertical smear, not a trend. And pick the EXACT measure the user
|
| 146 |
+
named: when several catalog columns share a name stem — an actual metric vs a
|
| 147 |
+
`planned`/`target`/`adjusted`/`_2`-style variant — choose the one that matches
|
| 148 |
+
the user's term exactly, not a qualified sibling, unless the user asked for the
|
| 149 |
+
variant. Aliasing a near-miss column to the asked-for name is the same mistake
|
| 150 |
+
as rule 6's stand-in column.
|
| 151 |
A chart ask NEVER relaxes feasibility (rule 6): if the asked-for dimension or
|
| 152 |
measure has no catalog column, the question is **infeasible** — never chart a
|
| 153 |
stand-in column aliased under the asked-for name (e.g. never select a status
|
|
@@ -153,6 +153,19 @@ Example questions:
|
|
| 153 |
"""
|
| 154 |
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
def render_chart(
|
| 157 |
df: pd.DataFrame,
|
| 158 |
chart_type: str,
|
|
@@ -182,6 +195,12 @@ def render_chart(
|
|
| 182 |
raise UnsupportedChartTypeError(
|
| 183 |
f"unsupported chart_type '{chart_type}'; supported: {list(CHART_TYPES)}"
|
| 184 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
needed = [x, y] if chart_type == "pie" or series is None else [x, y, series]
|
| 186 |
missing = [c for c in needed if c not in df.columns]
|
| 187 |
if missing:
|
|
|
|
| 153 |
"""
|
| 154 |
|
| 155 |
|
| 156 |
+
# analyze_aggregate renames a measure column to "<col>_<fn>" (e.g. pa_percent ->
|
| 157 |
+
# pa_percent_mean). A chart planned before that rename references the original
|
| 158 |
+
# name, so resolve it to the aggregated column when the exact name is absent.
|
| 159 |
+
_AGG_SUFFIXES = ("mean", "sum", "count", "min", "max", "median", "nunique", "avg")
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _resolve_col(name: str, columns) -> str:
|
| 163 |
+
if name in columns:
|
| 164 |
+
return name
|
| 165 |
+
hits = [c for c in columns if c in {f"{name}_{s}" for s in _AGG_SUFFIXES}]
|
| 166 |
+
return hits[0] if len(hits) == 1 else name
|
| 167 |
+
|
| 168 |
+
|
| 169 |
def render_chart(
|
| 170 |
df: pd.DataFrame,
|
| 171 |
chart_type: str,
|
|
|
|
| 195 |
raise UnsupportedChartTypeError(
|
| 196 |
f"unsupported chart_type '{chart_type}'; supported: {list(CHART_TYPES)}"
|
| 197 |
)
|
| 198 |
+
# Resolve chart columns to their aggregated names (pa_percent -> pa_percent_mean)
|
| 199 |
+
# so a retrieve_data -> analyze_aggregate -> render_chart chain works.
|
| 200 |
+
x = _resolve_col(x, df.columns)
|
| 201 |
+
y = _resolve_col(y, df.columns)
|
| 202 |
+
if series is not None:
|
| 203 |
+
series = _resolve_col(series, df.columns)
|
| 204 |
needed = [x, y] if chart_type == "pie" or series is None else [x, y, series]
|
| 205 |
missing = [c for c in needed if c not in df.columns]
|
| 206 |
if missing:
|
|
@@ -157,14 +157,24 @@ def _materialize(data: Any) -> tuple[pd.DataFrame, None] | tuple[None, str]:
|
|
| 157 |
if isinstance(data, ToolOutput):
|
| 158 |
if data.kind == "error":
|
| 159 |
return None, f"upstream data unavailable: {data.error}"
|
| 160 |
-
if data.kind
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
if isinstance(data, dict) and "columns" in data:
|
| 165 |
df = pd.DataFrame(data.get("rows") or [], columns=data["columns"])
|
| 166 |
return _normalize_numeric(df), None
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
# A {table_id/source_id} dict is a raw catalog reference the planner inlined
|
| 169 |
# instead of chaining a retrieve_data output (Pattern A). analyze_* tools never
|
| 170 |
# self-fetch, so give an actionable message rather than the opaque type name.
|
|
|
|
| 157 |
if isinstance(data, ToolOutput):
|
| 158 |
if data.kind == "error":
|
| 159 |
return None, f"upstream data unavailable: {data.error}"
|
| 160 |
+
if data.kind == "table":
|
| 161 |
+
# retrieve_data shape: columns + rows (list-of-lists).
|
| 162 |
+
if data.columns is not None:
|
| 163 |
+
return _normalize_numeric(pd.DataFrame(data.rows or [], columns=data.columns)), None
|
| 164 |
+
# analyze_* shape: `value` is a list-of-dicts (e.g. analyze_aggregate),
|
| 165 |
+
# so a chained analyze_* / render_chart can consume an aggregate output.
|
| 166 |
+
if isinstance(data.value, list):
|
| 167 |
+
return _normalize_numeric(pd.DataFrame(data.value)), None
|
| 168 |
+
return None, f"cannot materialize 'data' of kind {data.kind!r}"
|
| 169 |
|
| 170 |
if isinstance(data, dict) and "columns" in data:
|
| 171 |
df = pd.DataFrame(data.get("rows") or [], columns=data["columns"])
|
| 172 |
return _normalize_numeric(df), None
|
| 173 |
|
| 174 |
+
# Serialized analyze_* table: {"kind": "table", "value": [ {...}, ... ]}.
|
| 175 |
+
if isinstance(data, dict) and isinstance(data.get("value"), list):
|
| 176 |
+
return _normalize_numeric(pd.DataFrame(data["value"])), None
|
| 177 |
+
|
| 178 |
# A {table_id/source_id} dict is a raw catalog reference the planner inlined
|
| 179 |
# instead of chaining a retrieve_data output (Pattern A). analyze_* tools never
|
| 180 |
# self-fetch, so give an actionable message rather than the opaque type name.
|