Rifqi Hafizuddin Claude Fable 5 commited on
Commit
089b69d
·
1 Parent(s): b9dfc76

[KM-644] planner: infeasible plan outcome + entity-ranking few-shots

Browse files

TaskList.infeasible_reason lets the planner decline questions no catalog
column can answer (was force-mapping, e.g. pa AS revenue); coordinator
short-circuits to a deterministic EN/ID data-gap reply and a
non-substantive record. Few-shots add top-N entity ranking (G) and an
infeasible example (H); planner.md gains hard rule 6 + 'When the catalog
cannot answer'. Live-verified 2026-07-08 on the PA analysis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/agents/planner/examples.py CHANGED
@@ -524,6 +524,94 @@ _EXAMPLE_F = TaskList(
524
  )
525
 
526
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  EXAMPLES: list[tuple[str, TaskList]] = [
528
  ("Which product categories drove last quarter's revenue?", _EXAMPLE_A),
529
  ("How has monthly revenue trended by region this year, and what's unusual?", _EXAMPLE_B),
@@ -531,6 +619,8 @@ EXAMPLES: list[tuple[str, TaskList]] = [
531
  ("What is the average and total order value per region?", _EXAMPLE_D),
532
  ("Total revenue for the East and West regions, counting orders of at least 100.", _EXAMPLE_E),
533
  ("Give me the summary statistics for order revenue and quantity.", _EXAMPLE_F),
 
 
534
  ]
535
 
536
 
 
524
  )
525
 
526
 
527
+ # --------------------------------------------------------------------------- #
528
+ # Example G — top-N ranking.
529
+ # "Top 3 product categories by total revenue."
530
+ # Shows: top-N is ONE retrieve_data query — group by the entity, aggregate the
531
+ # measure with an alias, order by that alias, limit N. NEVER a bare
532
+ # order-by-measure + limit (that ranks raw rows, so the same entity can appear
533
+ # twice — observed in production: "top 3 models" returned one model twice).
534
+ # --------------------------------------------------------------------------- #
535
+
536
+ _EXAMPLE_G = TaskList(
537
+ plan_id="example_g",
538
+ goal_restated="Rank product categories by total revenue and return the top 3.",
539
+ assumptions=[],
540
+ open_questions=[],
541
+ tasks=[
542
+ Task(
543
+ id="t1",
544
+ stage="data_understanding",
545
+ objective="Confirm the sales source exposes category and revenue.",
546
+ tool_calls=[ToolCall(tool="check_data", args={"source_id": "src_sales"})],
547
+ expected_output="source_shape",
548
+ success_criteria=(
549
+ "Produced the orders table schema; category and revenue columns "
550
+ "are present."
551
+ ),
552
+ depends_on=[],
553
+ estimated_cost="low",
554
+ ),
555
+ Task(
556
+ id="t2",
557
+ stage="data_preparation",
558
+ objective="Aggregate revenue per category, rank descending, keep the top 3.",
559
+ tool_calls=[
560
+ ToolCall(
561
+ tool="retrieve_data",
562
+ args={
563
+ "ir": {
564
+ "source_id": "src_sales",
565
+ "table_id": "t_orders",
566
+ "select": [
567
+ {"kind": "column", "column_id": "c_category", "alias": "category"},
568
+ {
569
+ "kind": "agg",
570
+ "fn": "sum",
571
+ "column_id": "c_revenue",
572
+ "alias": "total_revenue",
573
+ },
574
+ ],
575
+ "group_by": ["c_category"],
576
+ "order_by": [{"column_id": "total_revenue", "dir": "desc"}],
577
+ "limit": 3,
578
+ }
579
+ },
580
+ )
581
+ ],
582
+ expected_output="top3_categories",
583
+ success_criteria=(
584
+ "Produced at most 3 rows, one distinct category each, ranked by "
585
+ "total revenue."
586
+ ),
587
+ depends_on=["t1"],
588
+ estimated_cost="low",
589
+ ),
590
+ ],
591
+ )
592
+
593
+ # --------------------------------------------------------------------------- #
594
+ # Example H — infeasible question (see planner.md "When the catalog cannot
595
+ # answer"). "What is our customer churn rate?" against a sales catalog with no
596
+ # subscription/churn data: no task list is forced onto unrelated columns;
597
+ # instead `infeasible_reason` states the gap + the nearest available data.
598
+ # --------------------------------------------------------------------------- #
599
+
600
+ _EXAMPLE_H = TaskList(
601
+ plan_id="example_h",
602
+ goal_restated="Measure the customer churn rate.",
603
+ assumptions=[],
604
+ open_questions=[],
605
+ tasks=[],
606
+ infeasible_reason=(
607
+ "The connected source has no churn or subscription-status data — the "
608
+ "orders table only carries order-level category, revenue, quantity, and "
609
+ "dates. Nearest available analyses: repeat-purchase behaviour or revenue "
610
+ "per customer over time."
611
+ ),
612
+ )
613
+
614
+
615
  EXAMPLES: list[tuple[str, TaskList]] = [
616
  ("Which product categories drove last quarter's revenue?", _EXAMPLE_A),
617
  ("How has monthly revenue trended by region this year, and what's unusual?", _EXAMPLE_B),
 
619
  ("What is the average and total order value per region?", _EXAMPLE_D),
620
  ("Total revenue for the East and West regions, counting orders of at least 100.", _EXAMPLE_E),
621
  ("Give me the summary statistics for order revenue and quantity.", _EXAMPLE_F),
622
+ ("Which 3 product categories have the best revenue performance?", _EXAMPLE_G),
623
+ ("What is our customer churn rate?", _EXAMPLE_H),
624
  ]
625
 
626
 
src/agents/planner/schemas.py CHANGED
@@ -58,3 +58,18 @@ class TaskList(BaseModel):
58
  assumptions: list[str] = Field(default_factory=list)
59
  open_questions: list[str] = Field(default_factory=list)
60
  tasks: list[Task] = Field(default_factory=list)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  assumptions: list[str] = Field(default_factory=list)
59
  open_questions: list[str] = Field(default_factory=list)
60
  tasks: list[Task] = Field(default_factory=list)
61
+ # Infeasible sentinel (planner.md "When the catalog cannot answer"): set with
62
+ # an EMPTY `tasks` list when no catalog column plausibly holds the requested
63
+ # measure/entity. Explains what is missing and names the nearest available
64
+ # data. The coordinator renders it as an honest data-gap answer instead of
65
+ # running the pipeline — the alternative was the planner force-mapping
66
+ # unrelated columns (observed: `pa` aliased as "revenue").
67
+ infeasible_reason: str | None = Field(
68
+ None,
69
+ description=(
70
+ "Set ONLY when the question cannot be answered from the catalog: no "
71
+ "column plausibly holds the requested measure or entity. State what "
72
+ "is missing and the nearest data that IS available. Leave tasks "
73
+ "empty when set."
74
+ ),
75
+ )
src/agents/planner/validator.py CHANGED
@@ -61,6 +61,15 @@ class PlannerValidator:
61
  ) -> None:
62
  tasks = task_list.tasks
63
 
 
 
 
 
 
 
 
 
 
64
  # Check 6 — plan non-empty and within the task cap.
65
  if not tasks:
66
  raise PlannerValidationError("plan is empty: at least one task is required")
 
61
  ) -> None:
62
  tasks = task_list.tasks
63
 
64
+ # Infeasible sentinel (planner.md "When the catalog cannot answer"): an
65
+ # empty plan carrying `infeasible_reason` is a VALID outcome — the
66
+ # coordinator renders it as an honest data-gap answer instead of the
67
+ # planner force-mapping the question onto unrelated columns. A non-empty
68
+ # plan keeps normal validation and the reason is ignored (a real plan
69
+ # wins over a hedge).
70
+ if task_list.infeasible_reason and not tasks:
71
+ return
72
+
73
  # Check 6 — plan non-empty and within the task cap.
74
  if not tasks:
75
  raise PlannerValidationError("plan is empty: at least one task is required")
src/agents/refusals.py CHANGED
@@ -56,6 +56,39 @@ _BLOCKED = {
56
  }
57
 
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  def out_of_scope_message(message: str) -> str:
60
  """Refusal for a benign but out-of-scope request (the `out_of_scope` intent)."""
61
  return _OUT_OF_SCOPE["id" if _is_indonesian(message) else "en"]
 
56
  }
57
 
58
 
59
+ # Data-gap: the planner judged the bound sources cannot answer the question
60
+ # (planner.md "When the catalog cannot answer"). Deterministic wrapper on
61
+ # purpose — the model that declined to plan is not re-asked to prose it up.
62
+ # Keyed on the pipeline's reply_language ("Indonesian"/"English"), not marker
63
+ # detection: the upstream language decision is authoritative here.
64
+ _DATA_GAP = {
65
+ "en": (
66
+ "I can't answer that from the data sources connected to this analysis. "
67
+ "{reason}You can bind a source that holds this data, or ask me what's "
68
+ "available (try /help or \"what data do I have?\")."
69
+ ),
70
+ "id": (
71
+ "Saya tidak bisa menjawab itu dari sumber data yang terhubung ke "
72
+ "analisis ini. {reason}Anda bisa menambahkan sumber yang memuat data "
73
+ "tersebut, atau tanyakan data apa yang tersedia (coba /help atau "
74
+ "\"data apa yang saya punya?\")."
75
+ ),
76
+ }
77
+
78
+
79
+ def data_gap_message(reason: str | None, reply_language: str | None = None) -> str:
80
+ """Answer for an infeasible analysis: the bound sources lack the asked-for data.
81
+
82
+ `reason` is the planner's `infeasible_reason` (may be None/empty);
83
+ `reply_language` is the pipeline's detected language ("Indonesian"/"English").
84
+ """
85
+ detail = (reason or "").strip()
86
+ if detail and not detail.endswith((".", "!", "?")):
87
+ detail += "."
88
+ lang = "id" if reply_language == "Indonesian" else "en"
89
+ return _DATA_GAP[lang].format(reason=f"{detail} " if detail else "")
90
+
91
+
92
  def out_of_scope_message(message: str) -> str:
93
  """Refusal for a benign but out-of-scope request (the `out_of_scope` intent)."""
94
  return _OUT_OF_SCOPE["id" if _is_indonesian(message) else "en"]
src/agents/slow_path/coordinator.py CHANGED
@@ -11,13 +11,16 @@ See AGENT_ARCHITECTURE_CONTEXT_new.md §5.2 / §6.1.
11
  from __future__ import annotations
12
 
13
  from collections.abc import Awaitable, Callable
 
14
 
15
  from ...catalog.models import Catalog
16
  from ..planner.contracts import BusinessContext, ToolRegistry
17
  from ..planner.inputs import Constraints
 
18
  from ..planner.service import PlannerService
 
19
  from .assembler import Assembler
20
- from .schemas import AssembledOutput
21
  from .task_runner import TaskRunner
22
 
23
 
@@ -54,6 +57,13 @@ class SlowPathCoordinator:
54
  task_list = await self._planner.plan(
55
  context, catalog, self._registry, query, constraints, **plan_kw
56
  )
 
 
 
 
 
 
 
57
  if progress:
58
  await progress(f"Running {len(task_list.tasks)} analysis steps…")
59
  run_state = await self._task_runner.run(
@@ -65,3 +75,25 @@ class SlowPathCoordinator:
65
  return await self._assembler.assemble(
66
  run_state, context, question=query, reply_language=reply_language, **asm_kw
67
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  from __future__ import annotations
12
 
13
  from collections.abc import Awaitable, Callable
14
+ from datetime import UTC, datetime
15
 
16
  from ...catalog.models import Catalog
17
  from ..planner.contracts import BusinessContext, ToolRegistry
18
  from ..planner.inputs import Constraints
19
+ from ..planner.schemas import TaskList
20
  from ..planner.service import PlannerService
21
+ from ..refusals import data_gap_message
22
  from .assembler import Assembler
23
+ from .schemas import AnalysisRecord, AssembledOutput
24
  from .task_runner import TaskRunner
25
 
26
 
 
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
62
+ # answer"): nothing to execute, and the refusal is deliberately
63
+ # deterministic — not LLM-prosed. The record carries no tasks, so it
64
+ # is non-substantive: it can never satisfy the report floor or leak
65
+ # into a report.
66
+ return _infeasible_output(task_list, context, reply_language)
67
  if progress:
68
  await progress(f"Running {len(task_list.tasks)} analysis steps…")
69
  run_state = await self._task_runner.run(
 
75
  return await self._assembler.assemble(
76
  run_state, context, question=query, reply_language=reply_language, **asm_kw
77
  )
78
+
79
+
80
+ def _infeasible_output(
81
+ task_list: TaskList, context: BusinessContext, reply_language: str | None
82
+ ) -> AssembledOutput:
83
+ """Build the data-gap answer + a faithful (non-substantive) record."""
84
+ reason = task_list.infeasible_reason or ""
85
+ return AssembledOutput(
86
+ chat_answer=data_gap_message(reason, reply_language),
87
+ analysis_record=AnalysisRecord(
88
+ goal_restated=task_list.goal_restated,
89
+ findings=[],
90
+ caveats=[reason] if reason else [],
91
+ data_used=[],
92
+ open_questions=list(task_list.open_questions),
93
+ tasks_run=[],
94
+ results_snapshot={},
95
+ plan_id=task_list.plan_id,
96
+ business_context_id=context.project_id,
97
+ created_at=datetime.now(UTC),
98
+ ),
99
+ )
src/config/prompts/planner.md CHANGED
@@ -21,6 +21,12 @@ only a `TaskList` object that conforms to the provided schema.
21
  id lookup, so a paraphrased name fails.
22
  5. **No modeling in v1.** There are no modeling tools. Do not emit `modeling`
23
  tasks. The product is descriptive/diagnostic only — no predictions, no charts.
 
 
 
 
 
 
24
 
25
  # How to plan
26
 
@@ -68,9 +74,29 @@ only a `TaskList` object that conforms to the provided schema.
68
  - **success_criteria is a reporting signal**, not a control trigger. State, in
69
  checkable terms (counts, rates, "produced", "above"/"below"), what a good
70
  result looks like. It never causes a retry.
71
- - **Surface uncertainty, don't guess.** If the question is ambiguous or the
72
- catalog can't fully answer it, record it in `open_questions` and plan the best
73
- defensible analysis anyway. Record interpretation choices in `assumptions`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  # Writing a retrieve_data QueryIR
76
 
@@ -109,6 +135,14 @@ only a `TaskList` object that conforms to the provided schema.
109
  select the product column + `sum(revenue)` aliased `total_revenue`, with
110
  `group_by: ["<product_col_id>"]`,
111
  `order_by: [{"column_id": "total_revenue", "dir": "desc"}]`, `limit: 3`.
 
 
 
 
 
 
 
 
112
 
113
  # Output
114
 
 
21
  id lookup, so a paraphrased name fails.
22
  5. **No modeling in v1.** There are no modeling tools. Do not emit `modeling`
23
  tasks. The product is descriptive/diagnostic only — no predictions, no charts.
24
+ 6. **Never re-purpose a column as a different business measure.** A column means
25
+ what the catalog says it means — do not alias one concept as another to force
26
+ an answer (e.g. selecting an availability percentage AS "revenue", or a
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
 
 
74
  - **success_criteria is a reporting signal**, not a control trigger. State, in
75
  checkable terms (counts, rates, "produced", "above"/"below"), what a good
76
  result looks like. It never causes a retry.
77
+ - **Surface uncertainty, don't guess.** If the question is *ambiguous* the
78
+ catalog can answer it but a term needs interpreting (which period, which
79
+ metric variant) record the interpretation in `assumptions`, anything
80
+ unresolved in `open_questions`, and plan the best defensible analysis. This
81
+ never licenses re-purposing columns: when the requested measure itself is
82
+ absent from the catalog, the question is not ambiguous, it is **infeasible**
83
+ (next section).
84
+
85
+ # When the catalog cannot answer
86
+
87
+ Some questions ask for a measure or entity the connected sources simply do not
88
+ hold (e.g. "sales revenue" against a maintenance database, "churn rate" with no
89
+ subscription data). For those:
90
+
91
+ - Return `tasks: []` and set **`infeasible_reason`**: one short paragraph naming
92
+ (a) what the question needs that no column provides, and (b) the nearest
93
+ analyses the catalog CAN support, so the user knows what to ask instead.
94
+ - Do NOT emit a plan that maps the question onto semantically unrelated columns
95
+ just because their types fit — a confidently wrong number is worse than an
96
+ honest gap.
97
+ - The test: could you point at a specific catalog column whose *meaning* (name,
98
+ sample values, table context) matches the requested measure? If not,
99
+ it is infeasible.
100
 
101
  # Writing a retrieve_data QueryIR
102
 
 
135
  select the product column + `sum(revenue)` aliased `total_revenue`, with
136
  `group_by: ["<product_col_id>"]`,
137
  `order_by: [{"column_id": "total_revenue", "dir": "desc"}]`, `limit: 3`.
138
+ This applies to EVERY entity-ranking phrasing — "top/best/worst/highest/lowest
139
+ N <entities>", "<entities> with the best <measure> performance", "which
140
+ <entities> perform best" — the unit being ranked is the ENTITY, so the measure
141
+ MUST be aggregated per entity first (`group_by` the entity column). Ranking
142
+ raw rows can return the same entity twice, which is never a valid entity
143
+ ranking. Choose the aggregate by measure type: additive measures (revenue,
144
+ counts, backlog) → `sum`; ratio/percentage/rate metrics (availability,
145
+ utilization, scores) → `avg`. Record the choice in `assumptions`.
146
 
147
  # Output
148