/fix help and tools analyze

#14
API_CONTRACT_BE_PYTHON.md CHANGED
@@ -143,24 +143,22 @@ Response `200`:
143
 
144
  ```json
145
  {
146
- "count": 2,
147
  "tools": [
148
  {
149
  "command": "/help",
150
  "name": "help",
151
  "type": "skill",
152
  "description": "Show what the assistant can do and guide your next step."
153
- },
154
- {
155
- "command": "/report",
156
- "name": "report",
157
- "type": "skill",
158
- "description": "Generate a versioned analysis report with background, EDA, key findings, and insights."
159
  }
160
  ]
161
  }
162
  ```
163
 
 
 
 
 
164
  Tool item shape:
165
 
166
  ```json
 
143
 
144
  ```json
145
  {
146
+ "count": 1,
147
  "tools": [
148
  {
149
  "command": "/help",
150
  "name": "help",
151
  "type": "skill",
152
  "description": "Show what the assistant can do and guide your next step."
 
 
 
 
 
 
153
  }
154
  ]
155
  }
156
  ```
157
 
158
+ > The catalog is `/help` only (KM-711). `/report` was removed as a slash command —
159
+ > report generation is a right-side **Generate** button, not a `/` command. The report
160
+ > HTTP endpoint (`POST /api/v1/tools/report`) still exists; the button calls it.
161
+
162
  Tool item shape:
163
 
164
  ```json
API_ENDPOINTS_RESTRUCTURE.md CHANGED
@@ -107,19 +107,18 @@ Static, deterministic, safe for Go to cache. (Was `GET /api/v1/tools`.)
107
 
108
  ```json
109
  {
110
- "count": 2,
111
  "tools": [
112
  { "command": "/help", "name": "help", "type": "skill",
113
- "description": "Show what the assistant can do and guide your next step." },
114
- { "command": "/report", "name": "report", "type": "skill",
115
- "description": "Generate a versioned analysis report (background, EDA, key findings, insights)." }
116
  ]
117
  }
118
  ```
119
 
120
  `CommandResponse = { command, name, type, description }`, `type ∈ {skill, analytics, data_access}`.
121
- Catalog is `/help` + `/report` only; the `analyze_*` / `check_*` / `retrieve_*` and retired
122
- `/problem-statement` entries are commented out (kept for restorability), not deleted.
 
123
 
124
  **FE behavior:** the `/` slash menu surfaces **`/help` only**. **Report is a right-side button, not
125
  a slash command** (it fires only when an analysis is finished — saves tokens).
 
107
 
108
  ```json
109
  {
110
+ "count": 1,
111
  "tools": [
112
  { "command": "/help", "name": "help", "type": "skill",
113
+ "description": "Show what the assistant can do and guide your next step." }
 
 
114
  ]
115
  }
116
  ```
117
 
118
  `CommandResponse = { command, name, type, description }`, `type ∈ {skill, analytics, data_access}`.
119
+ Catalog is `/help` only; the `/report`, `analyze_*` / `check_*` / `retrieve_*`, and retired
120
+ `/problem-statement` entries are commented out (kept for restorability), not deleted. `/report`
121
+ was retired as a slash command (KM-711) — report generation is the right-side button only.
122
 
123
  **FE behavior:** the `/` slash menu surfaces **`/help` only**. **Report is a right-side button, not
124
  a slash command** (it fires only when an analysis is finished — saves tokens).
eval/help/help_dataset.json CHANGED
@@ -89,8 +89,11 @@
89
  "report_ready": { "ready": true, "missing": [] },
90
  "history": [{ "role": "human", "content": "what should I do next?" }],
91
  "message": null,
92
- "asserts": [{ "type": "must_contain_any", "patterns": ["/report", "report"] }],
93
- "note": "MIRRORS help.md example help_ex_guard_ready. Enough analysis done — SHOULD nudge toward the report (mention /report or the report option)."
 
 
 
94
  },
95
  {
96
  "id": "guard_03", "group": "report_guard", "carried_over": false, "manual_review": false,
@@ -108,10 +111,11 @@
108
  "history": [{ "role": "human", "content": "selanjutnya aku ngapain?" }],
109
  "message": null,
110
  "asserts": [
111
- { "type": "must_contain_any", "patterns": ["/report", "laporan", "report"] },
 
112
  { "type": "language_match", "expected": "Indonesian" }
113
  ],
114
- "note": "Ready + Indonesian conversation — should nudge toward the report AND stay in Indonesian (two rules at once)."
115
  },
116
  {
117
  "id": "guard_05", "group": "report_guard", "carried_over": false, "manual_review": false,
 
89
  "report_ready": { "ready": true, "missing": [] },
90
  "history": [{ "role": "human", "content": "what should I do next?" }],
91
  "message": null,
92
+ "asserts": [
93
+ { "type": "must_contain_any", "patterns": ["Generate", "generate", "report"] },
94
+ { "type": "must_not_contain_any", "patterns": ["/report"] }
95
+ ],
96
+ "note": "MIRRORS help.md example help_ex_guard_ready. Enough analysis done — SHOULD nudge toward the report by pointing at the Generate button; must NOT offer /report (removed in v6)."
97
  },
98
  {
99
  "id": "guard_03", "group": "report_guard", "carried_over": false, "manual_review": false,
 
111
  "history": [{ "role": "human", "content": "selanjutnya aku ngapain?" }],
112
  "message": null,
113
  "asserts": [
114
+ { "type": "must_contain_any", "patterns": ["Generate", "generate", "laporan", "report"] },
115
+ { "type": "must_not_contain_any", "patterns": ["/report"] },
116
  { "type": "language_match", "expected": "Indonesian" }
117
  ],
118
+ "note": "Ready + Indonesian conversation — should nudge toward the report (Generate button) AND stay in Indonesian; must NOT offer /report (removed in v6)."
119
  },
120
  {
121
  "id": "guard_05", "group": "report_guard", "carried_over": false, "manual_review": false,
src/agents/planner/prompt.py CHANGED
@@ -95,13 +95,16 @@ def build_planner_prompt(
95
  query: str,
96
  constraints: Constraints,
97
  previous_errors: list[str] | None = None,
 
98
  ) -> str:
99
  """Return the human-message content for the planner LLM.
100
 
101
  The system prompt (`config/prompts/planner.md`) is loaded separately by
102
  `PlannerService`. `previous_errors` is the full history of prior validation
103
  failures (oldest first) so a retry fixes ALL of them at once instead of fixing
104
- one and reintroducing an earlier one.
 
 
105
  """
106
  sections = [
107
  f"# Business context\n\n{render_business_context(context)}",
@@ -111,6 +114,13 @@ def build_planner_prompt(
111
  f"# Examples\n\n{render_examples()}",
112
  f"# Question\n\n{query}",
113
  ]
 
 
 
 
 
 
 
114
  if previous_errors:
115
  joined = "\n".join(f"- {err}" for err in previous_errors)
116
  sections.append(
 
95
  query: str,
96
  constraints: Constraints,
97
  previous_errors: list[str] | None = None,
98
+ reply_language: str | None = None,
99
  ) -> str:
100
  """Return the human-message content for the planner LLM.
101
 
102
  The system prompt (`config/prompts/planner.md`) is loaded separately by
103
  `PlannerService`. `previous_errors` is the full history of prior validation
104
  failures (oldest first) so a retry fixes ALL of them at once instead of fixing
105
+ one and reintroducing an earlier one. `reply_language` (the pipeline's detected
106
+ user language) is surfaced so the only user-facing free text the planner emits
107
+ — `infeasible_reason` — comes back in that language instead of always English.
108
  """
109
  sections = [
110
  f"# Business context\n\n{render_business_context(context)}",
 
114
  f"# Examples\n\n{render_examples()}",
115
  f"# Question\n\n{query}",
116
  ]
117
+ if reply_language:
118
+ sections.append(
119
+ f"# Reply language\n\n"
120
+ f"The user writes in {reply_language}. If (and only if) you return an "
121
+ f"infeasible plan, write `infeasible_reason` in {reply_language} — it is "
122
+ f"shown to the user verbatim. The plan structure itself is unaffected."
123
+ )
124
  if previous_errors:
125
  joined = "\n".join(f"- {err}" for err in previous_errors)
126
  sections.append(
src/agents/planner/service.py CHANGED
@@ -102,6 +102,7 @@ class PlannerService:
102
  query: str,
103
  constraints: Constraints,
104
  callbacks: list | None = None,
 
105
  ) -> TaskList:
106
  summary = CatalogSummary.from_catalog(catalog)
107
  chain = self._ensure_chain()
@@ -109,7 +110,13 @@ class PlannerService:
109
 
110
  for attempt in range(1, self._max_retries + 1):
111
  human_content = build_planner_prompt(
112
- context, summary, tools, query, constraints, previous_errors
 
 
 
 
 
 
113
  )
114
  # All retry attempts share `callbacks`, so each shows up under the same
115
  # trace — that is how retry token cost becomes visible.
@@ -176,6 +183,9 @@ async def plan_analysis(
176
  tools: ToolRegistry,
177
  query: str,
178
  constraints: Constraints,
 
179
  ) -> TaskList:
180
  """Convenience entry point using the default chain + validator."""
181
- return await PlannerService().plan(context, catalog, tools, query, constraints)
 
 
 
102
  query: str,
103
  constraints: Constraints,
104
  callbacks: list | None = None,
105
+ reply_language: str | None = None,
106
  ) -> TaskList:
107
  summary = CatalogSummary.from_catalog(catalog)
108
  chain = self._ensure_chain()
 
110
 
111
  for attempt in range(1, self._max_retries + 1):
112
  human_content = build_planner_prompt(
113
+ context,
114
+ summary,
115
+ tools,
116
+ query,
117
+ constraints,
118
+ previous_errors,
119
+ reply_language=reply_language,
120
  )
121
  # All retry attempts share `callbacks`, so each shows up under the same
122
  # trace — that is how retry token cost becomes visible.
 
183
  tools: ToolRegistry,
184
  query: str,
185
  constraints: Constraints,
186
+ reply_language: str | None = None,
187
  ) -> TaskList:
188
  """Convenience entry point using the default chain + validator."""
189
+ return await PlannerService().plan(
190
+ context, catalog, tools, query, constraints, reply_language=reply_language
191
+ )
src/agents/planner/validator.py CHANGED
@@ -142,6 +142,23 @@ class PlannerValidator:
142
  "use analyze_descriptive instead."
143
  )
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  # Check 3 — concrete source_id args must exist in the catalog.
146
  src = call.args.get("source_id")
147
  if isinstance(src, str) and not _is_placeholder(src):
@@ -212,23 +229,45 @@ class PlannerValidator:
212
  def _validate_data_source(
213
  task_id: str, call, tasks_by_id: dict, registry: ToolRegistry
214
  ) -> None:
215
- """A `data` placeholder must reference a data-producing task, not a
216
- metadata (check_data/check_knowledge) or documents (retrieve_knowledge) one.
217
-
218
- Those pass the structural checks (check_* also returns kind="table"), but
219
- their rows are catalog schema, so a downstream analyze_* fails to find the
220
- requested columns. Resolving points at the referenced task's representative
221
- output its last tool call (matches TaskRunner's `outputs[-1]`).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  """
223
- # `data_right` is analyze_merge's second table input (KM-703) — same
224
- # Pattern A handoff, so it gets the same guard.
225
  for arg_name in ("data", "data_right"):
226
- data_arg = call.args.get(arg_name)
227
- if not isinstance(data_arg, str):
228
- continue
229
- match = PLACEHOLDER_RE.fullmatch(data_arg.strip())
230
- if not match:
231
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  ref_task = tasks_by_id.get(match.group(1))
233
  if ref_task is None or not ref_task.tool_calls:
234
  continue # a dangling placeholder is reported by the DAG check
 
142
  "use analyze_descriptive instead."
143
  )
144
 
145
+ # Check 8d — group_by keys must be plain column-name strings. A
146
+ # derived grouping (a CASE/binning expression emitted as a dict)
147
+ # is unhashable and crashes df.groupby at execution ("unhashable
148
+ # type: 'dict'"). Reject it here so the planner is re-prompted;
149
+ # bucketing a numeric column into ranges belongs to
150
+ # analyze_segment, not smuggled through analyze_aggregate.
151
+ group_by = call.args.get("group_by")
152
+ if isinstance(group_by, list):
153
+ bad_keys = [g for g in group_by if not isinstance(g, str)]
154
+ if bad_keys:
155
+ raise PlannerValidationError(
156
+ f"task {task.id}: analyze_aggregate group_by must be "
157
+ f"column names (strings); got non-string entr(ies) "
158
+ f"{bad_keys}. Derived groupings (CASE/binning) are not "
159
+ "supported — group by an existing column."
160
+ )
161
+
162
  # Check 3 — concrete source_id args must exist in the catalog.
163
  src = call.args.get("source_id")
164
  if isinstance(src, str) and not _is_placeholder(src):
 
229
  def _validate_data_source(
230
  task_id: str, call, tasks_by_id: dict, registry: ToolRegistry
231
  ) -> None:
232
+ """The `data`/`data_right` handoff (Pattern A) must be a '${t<id>}'
233
+ placeholder pointing at an upstream data-producing task.
234
+
235
+ Two failure modes are rejected here:
236
+
237
+ 1. The arg is present but is NOT a placeholder — e.g. the planner inlined a
238
+ {table_id, source_id} table reference (or any literal) instead of
239
+ chaining a retrieve_data output. analyze_* tools never self-fetch by
240
+ source_id; they only consume an already-retrieved table, so such a plan
241
+ can only blow up at execution ("unsupported 'data' type: dict"). Reject
242
+ it here so the retry loop re-prompts the planner to add a retrieve_data
243
+ task and pass its placeholder.
244
+ 2. The placeholder resolves to a metadata (check_data/check_knowledge) or
245
+ documents (retrieve_knowledge) task. Those pass the structural checks
246
+ (check_* also returns kind="table"), but their rows are catalog schema,
247
+ so a downstream analyze_* fails to find the requested columns. Resolving
248
+ points at the referenced task's representative output — its last tool
249
+ call (matches TaskRunner's `outputs[-1]`).
250
+
251
+ `data_right` is analyze_merge's second table input (KM-703) — same Pattern A
252
+ handoff, so it gets the same guard. An arg that is absent is left to Check 8a
253
+ (required-arg presence).
254
  """
 
 
255
  for arg_name in ("data", "data_right"):
256
+ if arg_name not in call.args:
 
 
 
 
257
  continue
258
+ data_arg = call.args[arg_name]
259
+ match = (
260
+ PLACEHOLDER_RE.fullmatch(data_arg.strip())
261
+ if isinstance(data_arg, str)
262
+ else None
263
+ )
264
+ if match is None:
265
+ raise PlannerValidationError(
266
+ f"task {task_id}: tool {call.tool!r} arg {arg_name!r} must be a "
267
+ f"'${{t<id>}}' placeholder referencing a retrieve_data output "
268
+ f"(Pattern A), got {data_arg!r}. analyze_* tools do not fetch data "
269
+ "themselves — add a retrieve_data task and pass its output."
270
+ )
271
  ref_task = tasks_by_id.get(match.group(1))
272
  if ref_task is None or not ref_task.tool_calls:
273
  continue # a dangling placeholder is reported by the DAG check
src/agents/slow_path/coordinator.py CHANGED
@@ -55,7 +55,8 @@ class SlowPathCoordinator:
55
  await progress("Planning the analysis…")
56
  plan_kw = {"callbacks": planner_callbacks} if planner_callbacks else {}
57
  task_list = await self._planner.plan(
58
- context, catalog, self._registry, query, constraints, **plan_kw
 
59
  )
60
  if task_list.infeasible_reason and not task_list.tasks:
61
  # Honest data-gap outcome (planner.md "When the catalog cannot
 
55
  await progress("Planning the analysis…")
56
  plan_kw = {"callbacks": planner_callbacks} if planner_callbacks else {}
57
  task_list = await self._planner.plan(
58
+ context, catalog, self._registry, query, constraints,
59
+ reply_language=reply_language, **plan_kw
60
  )
61
  if task_list.infeasible_reason and not task_list.tasks:
62
  # Honest data-gap outcome (planner.md "When the catalog cannot
src/api/v1/tools.py CHANGED
@@ -4,8 +4,10 @@ Exposes the agent's user-invocable slash-command catalog so the Golang backend
4
  can cache it and the frontend can render its "/" command menu WITHOUT calling the
5
  AI agent for every list (Golang GETs + caches `list_tools`).
6
 
7
- Scope (2026-06-24, KM-674): the FE slash-command catalog is now just the two
8
- FE-callable skills `/help` and `/report`. Naming: verb-first, kebab-case, `/`
 
 
9
  prefix; each command maps 1:1 to a real internal tool/intent `name` (the dispatch
10
  key).
11
 
@@ -49,8 +51,12 @@ class ListToolsResponse(BaseModel):
49
  # Keep `command` in Harry's convention (verb-first, kebab-case, `/`); `name` is the
50
  # internal route/tool name used by the orchestrator.
51
  #
52
- # 2026-06-24 (KM-674 batch): the FE-callable skills are ONLY /help + /report. The rest
53
- # below are COMMENTED OUT — NOT deleted — on purpose:
 
 
 
 
54
  # - /problem-statement is retired (objective + business_questions now live in the
55
  # New-Analysis form, not a slash skill).
56
  # - check_data / check_knowledge stay available but are served by Golang, not exposed
@@ -65,13 +71,13 @@ _COMMAND_CATALOG: list[CommandResponse] = [
65
  type="skill",
66
  description="Show what the assistant can do and guide your next step.",
67
  ),
68
- CommandResponse(
69
- command="/report",
70
- name="report",
71
- type="skill",
72
- description="Generate a versioned analysis report (background, EDA, "
73
- "key findings, insights).",
74
- ),
75
  # CommandResponse(
76
  # command="/problem-statement",
77
  # name="problem_statement",
 
4
  can cache it and the frontend can render its "/" command menu WITHOUT calling the
5
  AI agent for every list (Golang GETs + caches `list_tools`).
6
 
7
+ Scope (2026-07-09): the FE slash-command catalog is now just `/help`. `/report` was
8
+ removed — report generation is button-only (the Generate button in the Report panel);
9
+ typing `/report` no longer creates a report (the router sends it to `check`), so
10
+ surfacing it as a slash command would mislead. Naming: verb-first, kebab-case, `/`
11
  prefix; each command maps 1:1 to a real internal tool/intent `name` (the dispatch
12
  key).
13
 
 
51
  # Keep `command` in Harry's convention (verb-first, kebab-case, `/`); `name` is the
52
  # internal route/tool name used by the orchestrator.
53
  #
54
+ # 2026-07-09: the only FE-callable slash skill is now /help. The rest below are
55
+ # COMMENTED OUT — NOT deleted — on purpose:
56
+ # - /report is retired as a slash command: report generation is button-only (Generate
57
+ # button in the Report panel). Typing /report no longer generates a report (the router
58
+ # routes it to `check`), so exposing it here would misdirect users. The report HTTP
59
+ # endpoint (POST /api/v1/tools/report in report.py) stays — the Generate button calls it.
60
  # - /problem-statement is retired (objective + business_questions now live in the
61
  # New-Analysis form, not a slash skill).
62
  # - check_data / check_knowledge stay available but are served by Golang, not exposed
 
71
  type="skill",
72
  description="Show what the assistant can do and guide your next step.",
73
  ),
74
+ # CommandResponse(
75
+ # command="/report",
76
+ # name="report",
77
+ # type="skill",
78
+ # description="Generate a versioned analysis report (background, EDA, "
79
+ # "key findings, insights).",
80
+ # ),
81
  # CommandResponse(
82
  # command="/problem-statement",
83
  # name="problem_statement",
src/config/prompts/help.md CHANGED
@@ -1,4 +1,10 @@
1
- <!-- help.md · v5 · Help skill prompt.
 
 
 
 
 
 
2
  v5 (2026-07-07): added the "Capability boundary" section — Help now only suggests
3
  analyses the live tools can actually deliver (descriptive, group-by, correlation, trend)
4
  and must NOT suggest significance tests, forecasting/modeling, causal claims, clustering/
@@ -72,9 +78,11 @@ recommend the specific next questions that would fill those gaps (phrase them as
72
  the user can ask), still anchored to the objective and business questions.
73
 
74
  ### C. `report_ready.ready == true` → nudge toward the report
75
- There's enough to report. Encourage them to generate it. Report can be triggered **two ways**:
76
- the **`/report`** skill **or** the report button mention both so it feels natural.
77
- Do not over-promise the report's depth.
 
 
78
 
79
  > Edge case: if `objective` looks empty (unusual — it's required at onboarding), don't push a
80
  > chat skill to fix it; gently suggest they set the objective + business questions in the New
@@ -111,8 +119,9 @@ the gap — rather than promising the unsupported analysis.
111
 
112
  ## How-to phrasing (degrade gracefully)
113
 
114
- - **Via chat / skills** — write these **accurately and specifically**; they are stable (e.g. "type your question in the chat", "run `/report`").
115
- - **Via the UI (buttons/menus)** — the frontend isn't final yet. Describe UI steps **generically** ("use the Generate Report option") rather than naming exact buttons/positions you're unsure of. Prefer the chat/skill path when unsure. *(A later version of this file will fill in the real UI steps.)*
 
116
  - If a field in `analysis_state` is missing or the state looks unwired, **fall back to generic guidance** rather than guessing specifics.
117
 
118
  ## Tone
@@ -158,6 +167,6 @@ State: report_ready.ready=false, missing=["a new analysis since the last report"
158
 
159
  <!-- id: help_ex_guard_ready -->
160
  State: report_ready.ready=true
161
- → "You've covered enough to summarize. You can generate your report now — run /report
162
- or use the report option to create it."
163
  ```
 
1
+ <!-- help.md · v6 · Help skill prompt.
2
+ v6 (2026-07-09): report trigger changed — the `/report` skill and the old report button
3
+ are GONE. A report is now generated ONE way only: the **Generate** button on the Report
4
+ panel (single source of truth). Help must no longer tell users to run `/report` or "use
5
+ the report skill"; it points them to the Generate button — anchored on the button LABEL,
6
+ not an exact corner/position (layout isn't final). Updated Section C, the How-to phrasing
7
+ note, and the help_ex_guard_ready example accordingly.
8
  v5 (2026-07-07): added the "Capability boundary" section — Help now only suggests
9
  analyses the live tools can actually deliver (descriptive, group-by, correlation, trend)
10
  and must NOT suggest significance tests, forecasting/modeling, causal claims, clustering/
 
78
  the user can ask), still anchored to the objective and business questions.
79
 
80
  ### C. `report_ready.ready == true` → nudge toward the report
81
+ There's enough to report. Encourage them to generate it. A report is generated **one way**:
82
+ by clicking the **Generate** button on the Report panel. Anchor on the button's label
83
+ (**Generate**) that's the unambiguous, layout-stable cue — rather than an exact
84
+ position/corner. There is no `/report` skill or manual command anymore — do **not** mention
85
+ `/report`. Do not over-promise the report's depth.
86
 
87
  > Edge case: if `objective` looks empty (unusual — it's required at onboarding), don't push a
88
  > chat skill to fix it; gently suggest they set the objective + business questions in the New
 
119
 
120
  ## How-to phrasing (degrade gracefully)
121
 
122
+ - **Via chat / skills** — write these **accurately and specifically**; they are stable (e.g. "type your question in the chat"). Note: report is **not** a chat skill — never write `/report`.
123
+ - **Generating a report** — the only way is the **Generate** button on the Report panel. Name the button by its label; don't pin an exact corner/position (the layout isn't final). Don't offer `/report` or a "report skill" as an alternative.
124
+ - **Other UI steps (buttons/menus)** — the rest of the frontend isn't final yet. Describe those steps **generically** rather than naming exact buttons/positions you're unsure of.
125
  - If a field in `analysis_state` is missing or the state looks unwired, **fall back to generic guidance** rather than guessing specifics.
126
 
127
  ## Tone
 
167
 
168
  <!-- id: help_ex_guard_ready -->
169
  State: report_ready.ready=true
170
+ → "You've covered enough to summarize. You can generate your report now — click the
171
+ Generate button on the Report panel to create it."
172
  ```
src/config/prompts/planner.md CHANGED
@@ -27,6 +27,13 @@ only a `TaskList` object that conforms to the provided schema.
27
  0-1 ratio AS a percentage metric). If no column plausibly holds the measure
28
  or entity the question asks about, the plan is **infeasible** — see "When the
29
  catalog cannot answer".
 
 
 
 
 
 
 
30
 
31
  # How to plan
32
 
@@ -99,6 +106,9 @@ subscription data). For those:
99
  - Return `tasks: []` and set **`infeasible_reason`**: one short paragraph naming
100
  (a) what the question needs that no column provides, and (b) the nearest
101
  analyses the catalog CAN support, so the user knows what to ask instead.
 
 
 
102
  - Do NOT emit a plan that maps the question onto semantically unrelated columns
103
  just because their types fit — a confidently wrong number is worse than an
104
  honest gap.
 
27
  0-1 ratio AS a percentage metric). If no column plausibly holds the measure
28
  or entity the question asks about, the plan is **infeasible** — see "When the
29
  catalog cannot answer".
30
+ 7. **Answer ONLY the asked question.** Plan the smallest analysis that fully
31
+ answers what the user actually asked — nothing more. Do NOT add tasks for
32
+ adjacent, "might be useful", or unasked questions (e.g. an extra breakdown by
33
+ a category the question never mentioned, or a count the user did not request),
34
+ even when the data would support them. Extra breadth the user did not ask for
35
+ is noise, not helpfulness. A multi-part task list is correct ONLY when the
36
+ question itself has multiple parts (e.g. "trend by region AND what's unusual").
37
 
38
  # How to plan
39
 
 
106
  - Return `tasks: []` and set **`infeasible_reason`**: one short paragraph naming
107
  (a) what the question needs that no column provides, and (b) the nearest
108
  analyses the catalog CAN support, so the user knows what to ask instead.
109
+ Write this paragraph in the user's reply language (see the "Reply language"
110
+ note in the human message, when present) — it is shown to the user verbatim, so
111
+ a mismatched language reads as broken.
112
  - Do NOT emit a plan that maps the question onto semantically unrelated columns
113
  just because their types fit — a confidently wrong number is worse than an
114
  honest gap.
src/tools/analytics/aggregation.py CHANGED
@@ -27,6 +27,17 @@ class UnsupportedAggregationError(ValueError):
27
  """Requested aggregation is not in SUPPORTED_AGGS (maps to UNSUPPORTED_AGG)."""
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
30
  def _clean(value: object) -> object:
31
  """Convert numpy/pandas scalars to plain Python so the output is JSON-clean.
32
 
@@ -103,6 +114,17 @@ def analyze_aggregate(
103
  """
104
  group_by = group_by or []
105
 
 
 
 
 
 
 
 
 
 
 
 
106
  # Validate columns first (fail-fast on caller mistakes).
107
  referenced = list(group_by) + list(aggregations.keys())
108
  missing = [c for c in referenced if c not in df.columns]
 
27
  """Requested aggregation is not in SUPPORTED_AGGS (maps to UNSUPPORTED_AGG)."""
28
 
29
 
30
+ class UnsupportedGroupByError(ValueError):
31
+ """A group_by entry is not a plain column name (maps to UNSUPPORTED_GROUP_BY).
32
+
33
+ group_by keys must be column-name strings. A derived grouping — e.g. a CASE /
34
+ binning expression the planner emits as a dict — is unhashable and would crash
35
+ `df.groupby` with a bare "unhashable type: 'dict'"; raising here turns it into a
36
+ clear, actionable error instead. Bucketing a numeric column into ranges belongs
37
+ to analyze_segment, not analyze_aggregate.
38
+ """
39
+
40
+
41
  def _clean(value: object) -> object:
42
  """Convert numpy/pandas scalars to plain Python so the output is JSON-clean.
43
 
 
114
  """
115
  group_by = group_by or []
116
 
117
+ # group_by keys must be plain column names. A non-string entry (e.g. a CASE /
118
+ # binning dict) is unhashable and would otherwise crash the membership check or
119
+ # df.groupby with an opaque "unhashable type: 'dict'".
120
+ non_str = [g for g in group_by if not isinstance(g, str)]
121
+ if non_str:
122
+ raise UnsupportedGroupByError(
123
+ f"group_by entries must be column names (strings); got non-string "
124
+ f"entr(ies) {non_str}. Derived groupings (e.g. CASE/binning expressions) "
125
+ "are not supported by analyze_aggregate — group by an existing column."
126
+ )
127
+
128
  # Validate columns first (fail-fast on caller mistakes).
129
  referenced = list(group_by) + list(aggregations.keys())
130
  missing = [c for c in referenced if c not in df.columns]
src/tools/analytics/temporal.py CHANGED
@@ -197,10 +197,15 @@ def analyze_trend(
197
  )
198
 
199
  # Build a clean datetime-indexed series, then resample into periods.
200
- s = df[[date_column, value_column]].copy()
201
- s[date_column] = _parse_date_column(df, date_column)
202
- s = s.dropna(subset=[date_column]).set_index(date_column).sort_index()
203
- resampled = s[value_column].resample(FREQ_MAP[freq]).agg(agg)
 
 
 
 
 
204
 
205
  points = [
206
  {"period": _period_label(ts, freq), "value": _clean(val)}
 
197
  )
198
 
199
  # Build a clean datetime-indexed series, then resample into periods.
200
+ # date_column may equal value_column — e.g. "count of events per month",
201
+ # where the value being aggregated IS the date itself. Selecting
202
+ # df[[col, col]] then yields a duplicate-named 2-col frame whose set_index
203
+ # key is 2-D ("Index data must be 1-dimensional"), so build the date and
204
+ # value series positionally instead of via column selection.
205
+ dates = pd.Series(_parse_date_column(df, date_column).to_numpy(), name="_date")
206
+ values = pd.Series(df[value_column].to_numpy(), name="_value")
207
+ s = pd.concat([dates, values], axis=1).dropna(subset=["_date"]).set_index("_date")
208
+ resampled = s["_value"].sort_index().resample(FREQ_MAP[freq]).agg(agg)
209
 
210
  points = [
211
  {"period": _period_label(ts, freq), "value": _clean(val)}
src/tools/invoker.py CHANGED
@@ -163,6 +163,18 @@ def _materialize(data: Any) -> tuple[pd.DataFrame, None] | tuple[None, str]:
163
  df = pd.DataFrame(data.get("rows") or [], columns=data["columns"])
164
  return _normalize_numeric(df), None
165
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  return None, f"unsupported 'data' type: {type(data).__name__}"
167
 
168
 
 
163
  df = pd.DataFrame(data.get("rows") or [], columns=data["columns"])
164
  return _normalize_numeric(df), None
165
 
166
+ # A {table_id/source_id} dict is a raw catalog reference the planner inlined
167
+ # instead of chaining a retrieve_data output (Pattern A). analyze_* tools never
168
+ # self-fetch, so give an actionable message rather than the opaque type name.
169
+ # The planner validator should reject this upstream (Check 9); this is the
170
+ # defensive net if a bad plan still reaches execution.
171
+ if isinstance(data, dict) and ("table_id" in data or "source_id" in data):
172
+ return None, (
173
+ "'data' is a table reference (table_id/source_id), not a retrieved "
174
+ "table — analyze_* consumes the output of retrieve_data, it does not "
175
+ "fetch data by id (Pattern A)"
176
+ )
177
+
178
  return None, f"unsupported 'data' type: {type(data).__name__}"
179
 
180