/fix planner and report
#13
by rhbt6767 - opened
- DEV_PLAN.md +20 -0
- REPO_STATUS.md +1 -1
- src/agents/planner/examples.py +90 -0
- src/agents/planner/schemas.py +15 -0
- src/agents/planner/validator.py +9 -0
- src/agents/refusals.py +33 -0
- src/agents/slow_path/coordinator.py +33 -1
- src/catalog/sample_decode.py +119 -0
- src/catalog/store.py +10 -2
- src/config/prompts/planner.md +37 -3
- src/query/ir/validator.py +16 -0
- src/tools/analytics/merge.py +136 -0
- src/tools/analytics/temporal.py +65 -1
- src/tools/data_access.py +3 -1
- src/tools/invoker.py +15 -0
- src/tools/registry.py +17 -0
- src/traceability/scratchpad.py +5 -1
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 72 |
-
catalog can
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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] =
|
| 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 |
-
|
|
|
|
|
|
|
| 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({
|