/fix planner and report

#13
by rhbt6767 - opened
DEV_PLAN.md CHANGED
@@ -46,6 +46,26 @@ minter, stream-only). The **Phase 3 traceability build** — scratchpad + `GET /
46
 
47
  ---
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  ## 1. The direction change (locked decisions from 2026-06-24)
50
 
51
  1. **"Problem statement" is replaced by two user-entered fields: `objective` + `business_questions`.**
 
46
 
47
  ---
48
 
49
+ ## 0.5. pr/13 sprint — agent-quality fixes (2026-07-08 live-test review)
50
+
51
+ Findings from the scoped live sessions (mining analysis, 2026-07-07/08 traces): the planner
52
+ force-mapped absent measures (`pa` aliased as "revenue"), top-N ranked raw rows (duplicate models),
53
+ `analyze_trend` collapsed integer months into a single 1970-01 bucket, an invalid grouped IR reached
54
+ Postgres, failed retrievals wrote all-null traceability sources, and numeric catalog samples arrive
55
+ base64-mangled from Go. Fix tasks (same status legend as §0):
56
+
57
+ | # | Task | Owner | Status | Note |
58
+ |---|---|---|---|---|
59
+ | Q1 | IR validator: reject bare selects under `group_by` (planner retry self-corrects) | Rifqi | ✅ | `query/ir/validator.py` |
60
+ | Q2 | Planner **infeasible** path: `TaskList.infeasible_reason` + deterministic EN/ID data-gap reply | Rifqi | ✅ | schemas/validator/coordinator/refusals + planner.md "When the catalog cannot answer"; refusal wording → Rifqi to review |
61
+ | Q3 | `analyze_trend`: integer year/month handling (epoch-parse bug) | Rifqi | ✅ | `temporal.py` + 5 local tests |
62
+ | Q4 | Planner few-shots: top-N (Example G) + infeasible (Example H) + entity-vs-row ranking rule | Rifqi | ✅ | live-tested 2026-07-08: backlog top-3 correct via single-IR group+sum; "best PA performance" correct in-process (avg-per-model, assumption recorded). Stale-server trace was a false alarm |
63
+ | Q5 | Catalog numeric `sample_values` base64-decode stopgap (`catalog/sample_decode.py`) | Rifqi | ✅ | self-disabling; **primary fix = Go marshaling — DDL-free handoff to Harry** |
64
+ | Q6 | Traceability null-source suppression + `check_data` `-1` row-count hiding | Rifqi | ✅ | `scratchpad.py` / `data_access.py` |
65
+ | Q7 | `analyze_merge` two-table combine tool (unblocks "worst A + biggest B" questions) | tool owner | ⬜ | flagged out of this sprint; request brief sent by Rifqi |
66
+ | Q8 | Report v2: business-question answer section, unresolved/excluded sections, evidence tables from `results_snapshot`, caveat dedupe, single language | Rifqi/Sofhia | ⬜ | next up; adds one LLM call (button-triggered); new prompt → eval per §7B |
67
+ | Q9 | Record-curation endpoint (`GET …/records` + `exclude_record_ids`) + readiness GET for the FE delta guard | Rifqi ↔ FE | ⬜ | contract addition → API_CONTRACT_BE_PYTHON.md |
68
+
69
  ## 1. The direction change (locked decisions from 2026-06-24)
70
 
71
  1. **"Problem statement" is replaced by two user-entered fields: `objective` + `business_questions`.**
REPO_STATUS.md CHANGED
@@ -2,7 +2,7 @@
2
 
3
  **Audience:** teammates onboarding onto the Python repo (`Agentic-Service-Data-Eyond-Catalog`).
4
  **Scope:** what the code does **right now** (branch `pr/4`, ticket KM-652). Describes current state only — no roadmap or to-dos.
5
- **Snapshot date:** 2026-06-25. **Data-layer reconcile 2026-07-01:** §8/§12 updated — dedorch cutover done, `data_catalog` model reconciled. **Query-path fix 2026-07-02:** §8/§13 — dedorch catalogs ship no FKs → Python infers them (`fk_inference.py`); shared-Fernet-key gotcha documented. **Cross-repo update 2026-06-29:** §2/§8/§11/§12 re-verified against
6
  the **Go source** (`Orchestrator-Agent-Service`), not its docs. The Go service has moved well past its
7
  own (uncommitted, stale) design docs: it now hosts the **dedorch SQL migrations** in-repo and a full
8
  **`/api/v1/analyses` + `/api/v1/skills`** REST surface. Go does **not** call Python yet — those skills
 
2
 
3
  **Audience:** teammates onboarding onto the Python repo (`Agentic-Service-Data-Eyond-Catalog`).
4
  **Scope:** what the code does **right now** (branch `pr/4`, ticket KM-652). Describes current state only — no roadmap or to-dos.
5
+ **Snapshot date:** 2026-06-25. **Data-layer reconcile 2026-07-01:** §8/§12 updated — dedorch cutover done, `data_catalog` model reconciled. **Query-path fix 2026-07-02:** §8/§13 — dedorch catalogs ship no FKs → Python infers them (`fk_inference.py`); shared-Fernet-key gotcha documented. **Agent-quality fixes 2026-07-08 (pr/13):** from the scoped live-test review — the planner gains an explicit **infeasible** outcome (`TaskList.infeasible_reason` → deterministic EN/ID data-gap reply via `refusals.data_gap_message`; no more force-mapping absent measures like `pa` AS "revenue"), the IR validator rejects bare selects under `group_by` (self-corrects via the planner retry), `analyze_trend` handles integer year/month columns (was collapsing every row into one 1970-01 bucket), planner few-shots add top-N (Example G) + infeasible (Example H), numeric catalog `sample_values` are base64-decoded at read (`catalog/sample_decode.py` — stopgap for Go's byte-marshaling; primary fix is Go-side), traceability no longer emits null source rows for failed retrievals, and `check_data` hides `-1` row counts. **Cross-repo update 2026-06-29:** §2/§8/§11/§12 re-verified against
6
  the **Go source** (`Orchestrator-Agent-Service`), not its docs. The Go service has moved well past its
7
  own (uncommitted, stale) design docs: it now hosts the **dedorch SQL migrations** in-repo and a full
8
  **`/api/v1/analyses` + `/api/v1/skills`** REST surface. Go does **not** call Python yet — those skills
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/catalog/sample_decode.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base64-sample-value decoding for catalogs affected by a dedorch bug.
2
+
3
+ Go's introspection JSON-marshals numeric sample bytes as base64 (a `[]byte`
4
+ serialization quirk), so every decimal/int-typed column's `sample_values`
5
+ currently arrive as base64 strings (e.g. ``'OTUuMA=='`` for ``"95.0"``)
6
+ instead of the plain numeric text the planner LLM expects. The planner then
7
+ sees gibberish instead of value ranges for exactly the columns it filters and
8
+ aggregates on. Until Go fixes the marshaling, decode these at catalog read
9
+ time.
10
+
11
+ Conservative by design (a wrong decode silently corrupts planner context):
12
+ - only numeric-typed columns are considered
13
+ - every non-null sample in the column must pass a strict base64 gate
14
+ (valid base64, decodes to printable ASCII, parses as a float) — a single
15
+ non-conforming entry leaves the WHOLE column untouched (mixed content is
16
+ suspicious, never guessed)
17
+ - columns with `sample_values is None` (e.g. PII-flagged columns, which
18
+ carry no samples by design) are skipped cleanly
19
+ - self-disabling: once Go ships real numeric samples (plain ``"95.0"`` or
20
+ actual numbers), the gate fails — plain digit strings are either not
21
+ base64-padded correctly or don't decode to printable numeric text — so
22
+ the pass becomes a no-op with no further changes needed here
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import base64
28
+ import binascii
29
+
30
+ from src.middlewares.logging import get_logger
31
+
32
+ from .models import Catalog
33
+
34
+ logger = get_logger("sample_decode")
35
+
36
+ _NUMERIC_TYPES = {
37
+ "int",
38
+ "integer",
39
+ "bigint",
40
+ "decimal",
41
+ "numeric",
42
+ "float",
43
+ "double",
44
+ "number",
45
+ }
46
+
47
+
48
+ def _decode_one(value: str) -> str | None:
49
+ """Return the decoded numeric text for `value`, or None if it fails the gate."""
50
+ if len(value) < 2 or len(value) % 4 != 0:
51
+ return None
52
+ try:
53
+ decoded = base64.b64decode(value, validate=True)
54
+ except (binascii.Error, ValueError):
55
+ return None
56
+ try:
57
+ text = decoded.decode("ascii")
58
+ except UnicodeDecodeError:
59
+ return None
60
+ if not text.isprintable():
61
+ return None
62
+ try:
63
+ float(text)
64
+ except ValueError:
65
+ return None
66
+ return text
67
+
68
+
69
+ def _decode_column_samples(samples: list) -> tuple[list, int] | None:
70
+ """Return (decoded list, count decoded) if every non-null entry passes the gate.
71
+
72
+ Returns None if any entry fails the gate (mixed content is left untouched).
73
+ """
74
+ decoded_values = []
75
+ count = 0
76
+ for entry in samples:
77
+ if entry is None:
78
+ decoded_values.append(None)
79
+ continue
80
+ if not isinstance(entry, str):
81
+ return None
82
+ decoded = _decode_one(entry)
83
+ if decoded is None:
84
+ return None
85
+ count += 1
86
+ decoded_values.append(decoded)
87
+ if not count:
88
+ return None
89
+ return decoded_values, count
90
+
91
+
92
+ def decode_sample_values(catalog: Catalog) -> int:
93
+ """Decode base64-encoded numeric sample_values in place. Returns count decoded.
94
+
95
+ Never raises: any unexpected shape (wrong types, malformed entries) leaves
96
+ the offending column's values untouched.
97
+ """
98
+ total = 0
99
+ try:
100
+ for source in catalog.sources:
101
+ for table in source.tables:
102
+ for col in table.columns:
103
+ if col.data_type.lower() not in _NUMERIC_TYPES:
104
+ continue
105
+ samples = col.sample_values
106
+ if not samples:
107
+ continue
108
+ result = _decode_column_samples(samples)
109
+ if result is None:
110
+ continue
111
+ decoded_values, count = result
112
+ col.sample_values = decoded_values
113
+ total += count
114
+ except Exception as e:
115
+ logger.error("sample decode failed", error=repr(e))
116
+ return total
117
+ if total:
118
+ logger.info("decoded base64 sample values", user_id=catalog.user_id, count=total)
119
+ return total
src/catalog/store.py CHANGED
@@ -15,6 +15,7 @@ from src.middlewares.logging import get_logger
15
 
16
  from .fk_inference import infer_foreign_keys
17
  from .models import Catalog
 
18
 
19
  logger = get_logger("catalog_store")
20
 
@@ -41,7 +42,12 @@ class CatalogStore:
41
  # dedorch catalogs ship no foreign_keys (Go introspection drops them),
42
  # but the IR validator only allows FK-backed joins. Infer the obvious
43
  # edges so the planner and validator agree. No-op once Go emits real FKs.
44
- return infer_foreign_keys(Catalog.model_validate(row))
 
 
 
 
 
45
 
46
  async def get_by_analysis(self, analysis_id: str) -> Catalog | None:
47
  """Read the `scope_type='analysis'` catalog row for an analysis.
@@ -63,7 +69,9 @@ class CatalogStore:
63
  row = result.scalar_one_or_none()
64
  if row is None:
65
  return None
66
- return infer_foreign_keys(Catalog.model_validate(row))
 
 
67
 
68
  async def upsert(self, catalog: Catalog) -> None:
69
  # Legacy: Go's catalog.Service owns catalog writes now. Kept working (and
 
15
 
16
  from .fk_inference import infer_foreign_keys
17
  from .models import Catalog
18
+ from .sample_decode import decode_sample_values
19
 
20
  logger = get_logger("catalog_store")
21
 
 
42
  # dedorch catalogs ship no foreign_keys (Go introspection drops them),
43
  # but the IR validator only allows FK-backed joins. Infer the obvious
44
  # edges so the planner and validator agree. No-op once Go emits real FKs.
45
+ catalog = infer_foreign_keys(Catalog.model_validate(row))
46
+ # dedorch also JSON-marshals numeric sample bytes as base64 (Go bug) —
47
+ # decode them so the planner sees value ranges, not gibberish.
48
+ # No-op once Go emits plain numeric samples.
49
+ decode_sample_values(catalog)
50
+ return catalog
51
 
52
  async def get_by_analysis(self, analysis_id: str) -> Catalog | None:
53
  """Read the `scope_type='analysis'` catalog row for an analysis.
 
69
  row = result.scalar_one_or_none()
70
  if row is None:
71
  return None
72
+ catalog = infer_foreign_keys(Catalog.model_validate(row))
73
+ decode_sample_values(catalog)
74
+ return catalog
75
 
76
  async def upsert(self, catalog: Catalog) -> None:
77
  # Legacy: Go's catalog.Service owns catalog writes now. Kept working (and
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
 
src/query/ir/validator.py CHANGED
@@ -87,6 +87,22 @@ class IRValidator:
87
  for i, col_id in enumerate(ir.group_by):
88
  self._require_column(columns_by_id, col_id, f"group_by[{i}]")
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  for i, ob in enumerate(ir.order_by):
91
  if ob.column_id not in columns_by_id and ob.column_id not in select_aliases:
92
  raise IRValidationError(
 
87
  for i, col_id in enumerate(ir.group_by):
88
  self._require_column(columns_by_id, col_id, f"group_by[{i}]")
89
 
90
+ # A grouped query must not select bare columns that aren't in group_by —
91
+ # the database rejects it only at execution ("must appear in the GROUP BY
92
+ # clause"), which is past the planner's corrective-retry window. Catching
93
+ # it here turns a failed turn into a self-correcting re-prompt.
94
+ if ir.group_by:
95
+ grouped = set(ir.group_by)
96
+ for i, item in enumerate(ir.select):
97
+ if item.kind == "column" and item.column_id not in grouped:
98
+ raise IRValidationError(
99
+ f"select[{i}].column_id {item.column_id!r} is selected bare "
100
+ "while group_by is present — every selected column must "
101
+ "either appear in group_by or be wrapped in an aggregate "
102
+ f'(e.g. {{"kind": "agg", "fn": "sum", '
103
+ f'"column_id": {item.column_id!r}}})'
104
+ )
105
+
106
  for i, ob in enumerate(ir.order_by):
107
  if ob.column_id not in columns_by_id and ob.column_id not in select_aliases:
108
  raise IRValidationError(
src/tools/analytics/merge.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """analyze_merge — combine TWO upstream tables on shared keys (KM-608).
2
+
3
+ The only analytics "family" tool with a SECOND data input. In ONE call it joins
4
+ two already-materialized tables (`data` = LEFT, `data_right` = RIGHT) on one or
5
+ more shared key columns and returns the combined rows. This is what unlocks the
6
+ "which X has BOTH the worst A and the biggest B" question shape: A and B come
7
+ from two separate `retrieve_data` pulls (e.g. PA-by-section and backlog-by-section)
8
+ that must be aligned per X before either can be judged against the other. Without
9
+ a two-input combine the run dies with ColumnNotFoundError because no single tool
10
+ ever sees both metrics.
11
+
12
+ Pattern A, extended: it takes TWO `"${t<id>}"` placeholders. The invoker
13
+ materializes BOTH into DataFrames before calling this function (no self-fetch);
14
+ the `on` key(s) reference the column aliases the upstream queries produced.
15
+
16
+ STATUS: compute layer only — takes two already-materialized DataFrames. The
17
+ wrapper layer (the ToolOutput envelope, dual-arg materialization, ToolSpec
18
+ registration) lives in src/tools/invoker.py + registry.py. Keeping compute
19
+ separate from data-fetching keeps this easy to unit-test and stable when wrapped.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import pandas as pd
25
+
26
+ from src.tools.analytics.descriptive import ColumnNotFoundError
27
+
28
+ # Join types the tool understands. Whitelisted so an unknown `how` fails loudly
29
+ # instead of silently doing the wrong thing. "cross" is deliberately excluded —
30
+ # it ignores `on` and is never the right tool for the "align two metrics" shape.
31
+ SUPPORTED_HOWS = ("inner", "left", "right", "outer")
32
+
33
+
34
+ class UnsupportedJoinError(ValueError):
35
+ """Requested join type is not in SUPPORTED_HOWS (maps to error_code UNSUPPORTED_JOIN)."""
36
+
37
+
38
+ def _clean(value: object) -> object:
39
+ """Coerce a scalar to a JSON-clean Python value.
40
+
41
+ An outer/left/right join introduces `NaN` for non-matching rows, and numpy /
42
+ pandas scalars (numpy.int64, pandas.Timestamp) are not JSON-serializable —
43
+ normalise all three so the returned rows are clean.
44
+ """
45
+ if isinstance(value, pd.Timestamp):
46
+ return value.isoformat()
47
+ if value is None:
48
+ return None
49
+ try:
50
+ if pd.isna(value):
51
+ return None
52
+ except (TypeError, ValueError):
53
+ pass # non-scalar / unhashable — leave as-is
54
+ if hasattr(value, "item"):
55
+ return value.item()
56
+ return value
57
+
58
+
59
+ # Prompt-style description read by the Planner to decide WHEN to pick this tool.
60
+ DESCRIPTION = """\
61
+ Summary: Combine TWO upstream tables into one by joining on shared key column(s) \
62
+ (a pandas merge). `data` is the LEFT table, `data_right` is the RIGHT table; `on` \
63
+ is the shared column alias(es) present in BOTH. Returns the combined rows, one \
64
+ per matched key (join type controlled by `how`, default inner).
65
+
66
+ USE WHEN a question needs TWO different metrics per the SAME entity and those \
67
+ metrics come from two separate pulls — the tell-tale shape is "which X has BOTH \
68
+ A and B" (e.g. "which section has the worst PA AND the biggest backlog", "top \
69
+ customers by revenue that also have the most complaints"). Plan it as two \
70
+ retrieve_data tasks (one per metric, each keyed by X), then analyze_merge on X.
71
+
72
+ SETTING KEYS: `on` must be column alias(es) that exist in BOTH tables (the entity \
73
+ you align on, e.g. section_id). Use `suffixes` (default ["_left","_right"]) to \
74
+ disambiguate non-key columns that share a name across the two tables. `how`: \
75
+ inner (only matched keys), left/right (keep one side), outer (keep all).
76
+
77
+ DON'T USE WHEN:
78
+ - both metrics can be pulled in ONE retrieve_data query -> just retrieve_data
79
+ - it groups/aggregates a single table -> analyze_aggregate
80
+
81
+ Example questions:
82
+ - "which section has the worst PA and the biggest maintenance backlog"
83
+ - "regions in the top 10 for sales that are also bottom 10 for margin"
84
+ - "products low on stock that also have high demand"
85
+ """
86
+
87
+
88
+ def analyze_merge(
89
+ df: pd.DataFrame,
90
+ data_right: pd.DataFrame,
91
+ on: list[str] | str,
92
+ how: str = "inner",
93
+ suffixes: tuple[str, str] | list[str] = ("_left", "_right"),
94
+ ) -> list[dict[str, object]]:
95
+ """Join two already-materialized tables on shared key column(s).
96
+
97
+ Args:
98
+ df: LEFT table (in the real system the invoker materializes this from the
99
+ `data` placeholder).
100
+ data_right: RIGHT table (materialized from the `data_right` placeholder).
101
+ on: shared key column alias(es) present in BOTH tables. A bare string is
102
+ treated as a single key.
103
+ how: join type — one of SUPPORTED_HOWS (default "inner").
104
+ suffixes: 2-element (left, right) suffixes applied to non-key columns that
105
+ collide by name across the two tables.
106
+
107
+ Returns:
108
+ list[dict]: one row per merged record, values JSON-clean (NaN -> None).
109
+
110
+ Raises:
111
+ ColumnNotFoundError: if `on` is empty or a key is absent from either side.
112
+ UnsupportedJoinError: if `how` is not supported.
113
+ ValueError: if `suffixes` is not a 2-element sequence.
114
+ """
115
+ keys = [on] if isinstance(on, str) else list(on)
116
+ if not keys:
117
+ raise ColumnNotFoundError("merge 'on' must name at least one shared key column")
118
+ if how not in SUPPORTED_HOWS:
119
+ raise UnsupportedJoinError(
120
+ f"unsupported join '{how}'; supported: {list(SUPPORTED_HOWS)}"
121
+ )
122
+
123
+ missing_left = [c for c in keys if c not in df.columns]
124
+ missing_right = [c for c in keys if c not in data_right.columns]
125
+ if missing_left or missing_right:
126
+ raise ColumnNotFoundError(
127
+ f"join key(s) not found — left missing {missing_left}, "
128
+ f"right missing {missing_right}"
129
+ )
130
+
131
+ suf = tuple(suffixes)
132
+ if len(suf) != 2:
133
+ raise ValueError(f"suffixes must be a 2-element (left, right) sequence, got {suffixes!r}")
134
+
135
+ merged = df.merge(data_right, on=keys, how=how, suffixes=suf)
136
+ return [{k: _clean(v) for k, v in rec.items()} for rec in merged.to_dict("records")]
src/tools/analytics/temporal.py CHANGED
@@ -41,6 +41,11 @@ class UnsupportedAggregationError(ValueError):
41
  """The requested aggregation is not supported (maps to error_code UNSUPPORTED_AGG)."""
42
 
43
 
 
 
 
 
 
44
  def _clean(value: object) -> object:
45
  """Convert numpy scalars to plain Python; NaN -> None for JSON-clean output."""
46
  if value is None:
@@ -53,6 +58,63 @@ def _clean(value: object) -> object:
53
  return value
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  def _period_label(ts: pd.Timestamp, freq: str) -> str:
57
  """Human-readable period label keyed off the friendly frequency name."""
58
  if freq == "month":
@@ -119,6 +181,8 @@ def analyze_trend(
119
  ColumnNotFoundError: if date_column or value_column is absent.
120
  InvalidFrequencyError: if freq is not a known period.
121
  UnsupportedAggregationError: if agg is not supported.
 
 
122
  """
123
  missing = [c for c in (date_column, value_column) if c not in df.columns]
124
  if missing:
@@ -134,7 +198,7 @@ def analyze_trend(
134
 
135
  # Build a clean datetime-indexed series, then resample into periods.
136
  s = df[[date_column, value_column]].copy()
137
- s[date_column] = pd.to_datetime(s[date_column])
138
  s = s.dropna(subset=[date_column]).set_index(date_column).sort_index()
139
  resampled = s[value_column].resample(FREQ_MAP[freq]).agg(agg)
140
 
 
41
  """The requested aggregation is not supported (maps to error_code UNSUPPORTED_AGG)."""
42
 
43
 
44
+ class InvalidDateColumnError(ValueError):
45
+ """date_column holds numeric values that aren't a recognizable date/year/month
46
+ (maps to error_code INVALID_DATE_COLUMN)."""
47
+
48
+
49
  def _clean(value: object) -> object:
50
  """Convert numpy scalars to plain Python; NaN -> None for JSON-clean output."""
51
  if value is None:
 
58
  return value
59
 
60
 
61
+ def _parse_date_column(df: pd.DataFrame, date_column: str) -> pd.Series:
62
+ """Parse date_column into datetimes, guarding against numeric epoch misparsing.
63
+
64
+ pd.to_datetime() treats bare numeric input as epoch-nanoseconds, so bare
65
+ month numbers (1-12) or calendar years (e.g. 2025) silently collapse to a
66
+ single 1970 timestamp instead of raising. Numeric columns are resolved
67
+ explicitly here rather than falling through to pd.to_datetime().
68
+ """
69
+ col = df[date_column]
70
+ if not pd.api.types.is_numeric_dtype(col):
71
+ return pd.to_datetime(col)
72
+
73
+ non_null = col.dropna()
74
+ is_whole = non_null.empty or (non_null == non_null.astype(int)).all()
75
+
76
+ if is_whole and non_null.between(1, 12).all():
77
+ year_col = next((c for c in df.columns if c.lower() == "year"), None)
78
+ year_series = df[year_col] if year_col is not None else None
79
+ year_non_null = year_series.dropna() if year_series is not None else pd.Series(dtype=float)
80
+ year_ok = (
81
+ year_series is not None
82
+ and pd.api.types.is_numeric_dtype(year_series)
83
+ and not year_non_null.empty
84
+ and (year_non_null == year_non_null.astype(int)).all()
85
+ and year_non_null.between(1900, 2100).all()
86
+ )
87
+ if not year_ok:
88
+ raise InvalidDateColumnError(
89
+ f"date_column '{date_column}' holds bare month numbers (1-12) and no "
90
+ "'year' column is present in the data — retrieve a year column "
91
+ "alongside month, or use a real date column."
92
+ )
93
+ valid = col.notna() & year_series.notna()
94
+ result = pd.Series(pd.NaT, index=col.index, dtype="datetime64[ns]")
95
+ result.loc[valid] = pd.to_datetime(
96
+ {
97
+ "year": year_series.loc[valid].astype(int),
98
+ "month": col.loc[valid].astype(int),
99
+ "day": 1,
100
+ }
101
+ )
102
+ return result
103
+
104
+ if is_whole and non_null.between(1900, 2100).all():
105
+ result = pd.Series(pd.NaT, index=col.index, dtype="datetime64[ns]")
106
+ valid = col.notna()
107
+ result.loc[valid] = pd.to_datetime(
108
+ col.loc[valid].astype(int).astype(str), format="%Y"
109
+ )
110
+ return result
111
+
112
+ raise InvalidDateColumnError(
113
+ f"date_column '{date_column}' is numeric but is not a recognizable date, "
114
+ "year, or month column."
115
+ )
116
+
117
+
118
  def _period_label(ts: pd.Timestamp, freq: str) -> str:
119
  """Human-readable period label keyed off the friendly frequency name."""
120
  if freq == "month":
 
181
  ColumnNotFoundError: if date_column or value_column is absent.
182
  InvalidFrequencyError: if freq is not a known period.
183
  UnsupportedAggregationError: if agg is not supported.
184
+ InvalidDateColumnError: if date_column is numeric but not a recognizable
185
+ date, year, or bare month number (needing a companion 'year' column).
186
  """
187
  missing = [c for c in (date_column, value_column) if c not in df.columns]
188
  if missing:
 
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
 
src/tools/data_access.py CHANGED
@@ -154,7 +154,9 @@ class DataAccessToolInvoker:
154
  [
155
  t.table_id,
156
  t.name,
157
- t.row_count,
 
 
158
  c.column_id,
159
  c.name,
160
  c.data_type,
 
154
  [
155
  t.table_id,
156
  t.name,
157
+ # dedorch catalogs mark an uncounted table as -1; surface None so
158
+ # the planner prompt never sees a nonsensical "-1 rows".
159
+ t.row_count if (t.row_count or 0) >= 0 else None,
160
  c.column_id,
161
  c.name,
162
  c.data_type,
src/tools/invoker.py CHANGED
@@ -31,6 +31,7 @@ from src.tools.analytics import (
31
  comparison,
32
  decomposition,
33
  descriptive,
 
34
  quality,
35
  relationship,
36
  segmentation,
@@ -52,6 +53,7 @@ _DISPATCH: dict[str, tuple[Callable[..., Any], str]] = {
52
  "analyze_correlation": (relationship.analyze_correlation, "stats"),
53
  "analyze_segment": (segmentation.analyze_segment, "table"),
54
  "analyze_trend": (temporal.analyze_trend, "series"),
 
55
  }
56
 
57
 
@@ -73,6 +75,19 @@ class AnalyticsToolInvoker:
73
  return ToolOutput(tool=tool_name, kind="error", error=err)
74
 
75
  kwargs = {k: v for k, v in args.items() if k != "data"}
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  try:
77
  result = fn(df, **kwargs)
78
  except Exception as exc: # noqa: BLE001 — never-throw seam (§8.4)
 
31
  comparison,
32
  decomposition,
33
  descriptive,
34
+ merge,
35
  quality,
36
  relationship,
37
  segmentation,
 
53
  "analyze_correlation": (relationship.analyze_correlation, "stats"),
54
  "analyze_segment": (segmentation.analyze_segment, "table"),
55
  "analyze_trend": (temporal.analyze_trend, "series"),
56
+ "analyze_merge": (merge.analyze_merge, "table"),
57
  }
58
 
59
 
 
75
  return ToolOutput(tool=tool_name, kind="error", error=err)
76
 
77
  kwargs = {k: v for k, v in args.items() if k != "data"}
78
+
79
+ # Second data input (Pattern A, extended): analyze_merge takes a
80
+ # `data_right` placeholder the TaskRunner has resolved to another upstream
81
+ # ToolOutput. Materialize it the same way as `data` and hand the compute fn
82
+ # a DataFrame, not the raw envelope.
83
+ if "data_right" in kwargs:
84
+ df_right, err = _materialize(kwargs["data_right"])
85
+ if err is not None:
86
+ err = f"data_right: {err}"
87
+ logger.warning("tool returned error", tool=tool_name, error=err)
88
+ return ToolOutput(tool=tool_name, kind="error", error=err)
89
+ kwargs["data_right"] = df_right
90
+
91
  try:
92
  result = fn(df, **kwargs)
93
  except Exception as exc: # noqa: BLE001 — never-throw seam (§8.4)
src/tools/registry.py CHANGED
@@ -29,6 +29,7 @@ from src.tools.analytics import (
29
  comparison,
30
  decomposition,
31
  descriptive,
 
32
  quality,
33
  relationship,
34
  segmentation,
@@ -96,6 +97,22 @@ ACTIVE_ANALYTICS_TOOLS: list[ToolSpec] = [
96
  output_kind="series",
97
  description=temporal.DESCRIPTION,
98
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  ]
100
 
101
  # Deferred this round — specs kept intact for easy re-activation, NOT exposed to
 
29
  comparison,
30
  decomposition,
31
  descriptive,
32
+ merge,
33
  quality,
34
  relationship,
35
  segmentation,
 
97
  output_kind="series",
98
  description=temporal.DESCRIPTION,
99
  ),
100
+ ToolSpec(
101
+ name="analyze_merge",
102
+ category="analytics.combine",
103
+ input_schema={
104
+ "required": ["data", "data_right", "on"],
105
+ "properties": {
106
+ "data": {"type": "string"},
107
+ "data_right": {"type": "string"},
108
+ "on": {"type": "array"},
109
+ "how": {"type": "string"},
110
+ "suffixes": {"type": "array"},
111
+ },
112
+ },
113
+ output_kind="table",
114
+ description=merge.DESCRIPTION,
115
+ ),
116
  ]
117
 
118
  # Deferred this round — specs kept intact for easy re-activation, NOT exposed to
src/traceability/scratchpad.py CHANGED
@@ -124,13 +124,17 @@ class TraceabilityScratchpad:
124
  error=out_dict.get("error"),
125
  )
126
  )
127
- if name == "retrieve_data":
128
  self._record_db_source(output)
129
 
130
  def _record_db_source(self, output: Any) -> None:
131
  # retrieve_data's args are {"ir": ...}; the reliable source_id/table/query
132
  # live on the tool OUTPUT meta (see tools/data_access.py::_retrieve_data).
133
  meta = _meta_of(output)
 
 
 
 
134
  query = meta.get("query")
135
  table = meta.get("table_name") or meta.get("table_id")
136
  self._db_sources.append({
 
124
  error=out_dict.get("error"),
125
  )
126
  )
127
+ if name == "retrieve_data" and status == "success":
128
  self._record_db_source(output)
129
 
130
  def _record_db_source(self, output: Any) -> None:
131
  # retrieve_data's args are {"ir": ...}; the reliable source_id/table/query
132
  # live on the tool OUTPUT meta (see tools/data_access.py::_retrieve_data).
133
  meta = _meta_of(output)
134
+ if not meta.get("source_id"):
135
+ # A failed/aborted retrieval carries no provenance meta — emitting it
136
+ # anyway produced all-null source rows in the payload.
137
+ return
138
  query = meta.get("query")
139
  table = meta.get("table_name") or meta.get("table_id")
140
  self._db_sources.append({