sofhiaazzhr Claude Opus 4.8 commited on
Commit
0f3d8cc
·
1 Parent(s): d998370

[NOTICKET] fix: aggregate trend charts + materialize analyze_* tables in the chart chain

Browse files

A "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 CHANGED
@@ -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
 
src/config/prompts/planner.md CHANGED
@@ -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
src/tools/analytics/visualization.py CHANGED
@@ -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:
src/tools/invoker.py CHANGED
@@ -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 != "table" or data.columns is None:
161
- return None, f"cannot materialize 'data' of kind {data.kind!r}"
162
- return _normalize_numeric(pd.DataFrame(data.rows or [], columns=data.columns)), None
 
 
 
 
 
 
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.