Rifqi Hafizuddin Claude Fable 5 commited on
Commit
a029a84
·
1 Parent(s): 8abf635

[KM-567] planner support for analyze_merge: data_right guard + two-retrieve merge few-shot

Browse files
src/agents/planner/examples.py CHANGED
@@ -612,6 +612,124 @@ _EXAMPLE_H = TaskList(
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),
@@ -621,6 +739,11 @@ EXAMPLES: list[tuple[str, TaskList]] = [
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
 
 
612
  )
613
 
614
 
615
+ # --------------------------------------------------------------------------- #
616
+ # Example I — combine two measures per entity (KM-703).
617
+ # "Which category has both the highest revenue and the highest average order
618
+ # quantity?" Shows: each measure is computed in its OWN grouped retrieve_data
619
+ # task (a "${t<id>}" placeholder resolves to a task's LAST output, so the two
620
+ # retrievals must be separate tasks), then analyze_merge aligns them on the
621
+ # shared entity alias. The merged table answers "both A and B" questions that
622
+ # a single query cannot express.
623
+ # --------------------------------------------------------------------------- #
624
+
625
+ _EXAMPLE_I = TaskList(
626
+ plan_id="example_i",
627
+ goal_restated=(
628
+ "Identify the product category with both the highest total revenue and the "
629
+ "highest average order quantity."
630
+ ),
631
+ assumptions=[],
632
+ open_questions=[],
633
+ tasks=[
634
+ Task(
635
+ id="t1",
636
+ stage="data_understanding",
637
+ objective="Confirm the sales source exposes category, revenue, and quantity.",
638
+ tool_calls=[ToolCall(tool="check_data", args={"source_id": "src_sales"})],
639
+ expected_output="source_shape",
640
+ success_criteria=(
641
+ "Produced the orders table schema; category, revenue, and quantity "
642
+ "columns are present."
643
+ ),
644
+ depends_on=[],
645
+ estimated_cost="low",
646
+ ),
647
+ Task(
648
+ id="t2",
649
+ stage="data_preparation",
650
+ objective="Total revenue per category.",
651
+ tool_calls=[
652
+ ToolCall(
653
+ tool="retrieve_data",
654
+ args={
655
+ "ir": {
656
+ "source_id": "src_sales",
657
+ "table_id": "t_orders",
658
+ "select": [
659
+ {"kind": "column", "column_id": "c_category", "alias": "category"},
660
+ {
661
+ "kind": "agg",
662
+ "fn": "sum",
663
+ "column_id": "c_revenue",
664
+ "alias": "total_revenue",
665
+ },
666
+ ],
667
+ "group_by": ["c_category"],
668
+ }
669
+ },
670
+ )
671
+ ],
672
+ expected_output="revenue_per_category",
673
+ success_criteria="Produced one total-revenue row per category.",
674
+ depends_on=["t1"],
675
+ estimated_cost="low",
676
+ ),
677
+ Task(
678
+ id="t3",
679
+ stage="data_preparation",
680
+ objective="Average order quantity per category.",
681
+ tool_calls=[
682
+ ToolCall(
683
+ tool="retrieve_data",
684
+ args={
685
+ "ir": {
686
+ "source_id": "src_sales",
687
+ "table_id": "t_orders",
688
+ "select": [
689
+ {"kind": "column", "column_id": "c_category", "alias": "category"},
690
+ {
691
+ "kind": "agg",
692
+ "fn": "avg",
693
+ "column_id": "c_quantity",
694
+ "alias": "avg_quantity",
695
+ },
696
+ ],
697
+ "group_by": ["c_category"],
698
+ }
699
+ },
700
+ )
701
+ ],
702
+ expected_output="quantity_per_category",
703
+ success_criteria="Produced one average-quantity row per category.",
704
+ depends_on=["t1"],
705
+ estimated_cost="low",
706
+ ),
707
+ Task(
708
+ id="t4",
709
+ stage="evaluation",
710
+ objective="Align both measures per category to find the category leading on both.",
711
+ tool_calls=[
712
+ ToolCall(
713
+ tool="analyze_merge",
714
+ args={
715
+ "data": "${t2}",
716
+ "data_right": "${t3}",
717
+ "on": ["category"],
718
+ },
719
+ )
720
+ ],
721
+ expected_output="combined_measures",
722
+ success_criteria=(
723
+ "Produced one row per category carrying both total_revenue and "
724
+ "avg_quantity."
725
+ ),
726
+ depends_on=["t2", "t3"],
727
+ estimated_cost="low",
728
+ ),
729
+ ],
730
+ )
731
+
732
+
733
  EXAMPLES: list[tuple[str, TaskList]] = [
734
  ("Which product categories drove last quarter's revenue?", _EXAMPLE_A),
735
  ("How has monthly revenue trended by region this year, and what's unusual?", _EXAMPLE_B),
 
739
  ("Give me the summary statistics for order revenue and quantity.", _EXAMPLE_F),
740
  ("Which 3 product categories have the best revenue performance?", _EXAMPLE_G),
741
  ("What is our customer churn rate?", _EXAMPLE_H),
742
+ (
743
+ "Which product category has both the highest revenue and the highest average "
744
+ "order quantity?",
745
+ _EXAMPLE_I,
746
+ ),
747
  ]
748
 
749
 
src/agents/planner/validator.py CHANGED
@@ -220,24 +220,28 @@ class PlannerValidator:
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_arg = call.args.get("data")
224
- if not isinstance(data_arg, str):
225
- return
226
- match = PLACEHOLDER_RE.fullmatch(data_arg.strip())
227
- if not match:
228
- return
229
- ref_task = tasks_by_id.get(match.group(1))
230
- if ref_task is None or not ref_task.tool_calls:
231
- return # a dangling placeholder is reported by the DAG check
232
- ref_tool = ref_task.tool_calls[-1].tool
233
- ref_spec = registry.get(ref_tool)
234
- if ref_spec is not None and ref_spec.category in _NON_DATA_SOURCE_CATEGORIES:
235
- raise PlannerValidationError(
236
- f"task {task_id}: tool {call.tool!r} takes its 'data' from task "
237
- f"{match.group(1)} ({ref_tool!r}, category {ref_spec.category!r}), "
238
- "which produces metadata/documents — not analyzable data rows. Feed "
239
- "analyze_* from a data-producing tool (e.g. retrieve_data)."
240
- )
 
 
 
 
241
 
242
  @staticmethod
243
  def _validate_dag(tasks_by_id: dict, id_set: set[str]) -> None:
 
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
235
+ ref_tool = ref_task.tool_calls[-1].tool
236
+ ref_spec = registry.get(ref_tool)
237
+ if ref_spec is not None and ref_spec.category in _NON_DATA_SOURCE_CATEGORIES:
238
+ raise PlannerValidationError(
239
+ f"task {task_id}: tool {call.tool!r} takes its {arg_name!r} from "
240
+ f"task {match.group(1)} ({ref_tool!r}, category "
241
+ f"{ref_spec.category!r}), which produces metadata/documents — not "
242
+ "analyzable data rows. Feed analyze_* from a data-producing tool "
243
+ "(e.g. retrieve_data)."
244
+ )
245
 
246
  @staticmethod
247
  def _validate_dag(tasks_by_id: dict, id_set: set[str]) -> None:
src/config/prompts/planner.md CHANGED
@@ -67,6 +67,14 @@ only a `TaskList` object that conforms to the provided schema.
67
  `orders.total_amount`); if they genuinely aren't linked, say the data isn't
68
  connected rather than guessing. Prefer an existing measure column over
69
  recomputing. Joins are database-only — not available for tabular/file sources.
 
 
 
 
 
 
 
 
70
  - **Mixing structured + unstructured.** If qualitative context helps, add a
71
  `retrieve_knowledge` task against an unstructured source listed in the catalog.
72
  - **CRISP-DM stages.** Tag each task with the stage it serves:
 
67
  `orders.total_amount`); if they genuinely aren't linked, say the data isn't
68
  connected rather than guessing. Prefer an existing measure column over
69
  recomputing. Joins are database-only — not available for tabular/file sources.
70
+ - **Two measures per entity ("which X has both the worst A and the biggest B").**
71
+ Compute each measure in its OWN grouped `retrieve_data` task (one aggregate per
72
+ entity each), then align them with `analyze_merge`:
73
+ `{"data": "${tA}", "data_right": "${tB}", "on": ["<entity alias>"]}`.
74
+ The two retrievals MUST be separate tasks — a `"${t<id>}"` placeholder resolves
75
+ to a task's LAST output, so two retrievals inside one task lose the first
76
+ table. The merged table (one row per entity, both measures) answers the
77
+ question, or feeds a further `analyze_*` step.
78
  - **Mixing structured + unstructured.** If qualitative context helps, add a
79
  `retrieve_knowledge` task against an unstructured source listed in the catalog.
80
  - **CRISP-DM stages.** Tag each task with the stage it serves: