File size: 19,712 Bytes
81e5fe7
 
 
 
 
 
 
 
 
 
 
 
 
54070ac
 
81e5fe7
 
0e5fdb5
 
81e5fe7
 
0e5fdb5
81e5fe7
b9dfc76
81e5fe7
 
 
 
 
0e5fdb5
 
81e5fe7
 
 
0e5fdb5
 
 
 
 
 
 
 
 
81e5fe7
 
 
 
 
0e5fdb5
 
 
 
 
81e5fe7
0e5fdb5
81e5fe7
 
 
 
 
 
 
 
 
 
49b0848
 
 
 
 
 
 
 
 
81e5fe7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b9dfc76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17e31c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81e5fe7
 
 
 
 
 
 
 
 
0721bb4
 
81e5fe7
 
0e5fdb5
 
 
 
 
81e5fe7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0721bb4
81e5fe7
 
 
 
 
 
0721bb4
81e5fe7
0e5fdb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81e5fe7
 
 
 
0721bb4
81e5fe7
 
0e5fdb5
 
 
 
17e31c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e5fdb5
17e31c6
 
 
0e5fdb5
49b0848
17e31c6
49b0848
17e31c6
 
 
 
 
 
 
 
 
 
 
 
 
49b0848
 
 
 
 
 
 
 
 
 
 
 
 
87cfcf8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e5fdb5
81e5fe7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54070ac
 
 
 
 
81e5fe7
 
54070ac
 
 
81e5fe7
 
 
54070ac
 
 
 
 
 
 
 
 
 
 
 
 
 
81e5fe7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
"""PlannerValidator β€” checks a TaskList before it reaches the TaskRunner.

Runs the 8 checks from AGENT_ARCHITECTURE_CONTEXT_new.md Β§7.3. On failure it
raises `PlannerValidationError` with a message specific enough that the planner
can be re-prompted to self-correct (the retry loop lives in service.py).

Check #1 (Pydantic parse) is enforced at the structured-output boundary β€” by the
time a `TaskList` reaches here it has already parsed; this validator additionally
rejects structurally-invalid plans (duplicate ids, dangling edges, cycles).
"""

from __future__ import annotations

from collections.abc import Iterator

from pydantic import ValidationError

from src.middlewares.logging import get_logger

from ...catalog.models import Catalog
from ...query.ir.models import QueryIR
from ...query.ir.repair import IRRepairer
from ...query.ir.validator import IRValidationError, IRValidator
from ...tools.analytics.aggregation import SUPPORTED_AGGS
from .contracts import ToolRegistry
from .errors import PlannerValidationError
from .inputs import Constraints
from .schemas import PLACEHOLDER_RE, TaskList

logger = get_logger("ir_repair")

# Heuristic: a checkable success_criteria mentions a measurable signal.
_CHECKABLE_TOKENS = ("rate", "count", "match", "produced", "above", "below", "equal")

# Tool categories whose output is NOT analyzable data rows: `catalog.introspection`
# (check_data/check_knowledge β†’ catalog metadata) and `retrieval.documents`
# (retrieve_knowledge β†’ prose chunks). An analyze_* `data` handoff must not come from
# one of these β€” their output is kind="table"/documents so the structural checks pass,
# but feeding metadata rows to analyze_* makes it fail to find the requested columns.
# A denylist (not an allowlist) so any data/table-producing tool β€” retrieve_data AND a
# table-producing analyze_* chained into another analyze_* β€” stays valid.
_NON_DATA_SOURCE_CATEGORIES = frozenset({"catalog.introspection", "retrieval.documents"})

# DFS colors for cycle detection.
_WHITE, _GREY, _BLACK = 0, 1, 2


class PlannerValidator:
    def __init__(
        self,
        ir_validator: IRValidator | None = None,
        ir_repairer: IRRepairer | None = None,
    ) -> None:
        self._ir_validator = ir_validator or IRValidator()
        self._ir_repairer = ir_repairer or IRRepairer()

    def validate(
        self,
        task_list: TaskList,
        registry: ToolRegistry,
        catalog: Catalog,
        constraints: Constraints,
    ) -> None:
        tasks = task_list.tasks

        # Infeasible sentinel (planner.md "When the catalog cannot answer"): an
        # empty plan carrying `infeasible_reason` is a VALID outcome β€” the
        # coordinator renders it as an honest data-gap answer instead of the
        # planner force-mapping the question onto unrelated columns. A non-empty
        # plan keeps normal validation and the reason is ignored (a real plan
        # wins over a hedge).
        if task_list.infeasible_reason and not tasks:
            return

        # Check 6 β€” plan non-empty and within the task cap.
        if not tasks:
            raise PlannerValidationError("plan is empty: at least one task is required")
        if len(tasks) > constraints.max_tasks:
            raise PlannerValidationError(
                f"plan has {len(tasks)} tasks, exceeds max_tasks={constraints.max_tasks}"
            )

        ids = [t.id for t in tasks]
        if len(set(ids)) != len(ids):
            dupes = sorted({i for i in ids if ids.count(i) > 1})
            raise PlannerValidationError(f"duplicate task id(s): {dupes}")
        id_set = set(ids)
        tasks_by_id = {t.id: t for t in tasks}

        known_tools = registry.names()
        known_sources = {s.source_id for s in catalog.sources}

        for task in tasks:
            for call in task.tool_calls:
                # Check 2 β€” every tool exists in the registry.
                if call.tool not in known_tools:
                    raise PlannerValidationError(
                        f"task {task.id}: tool {call.tool!r} not in registry "
                        f"(known: {sorted(known_tools)})"
                    )
                spec = registry.get(call.tool)
                assert spec is not None  # guaranteed by the membership check above

                # Check 8a β€” args carry the required keys and no unknown keys.
                required = set(spec.input_schema.get("required", []))
                allowed = set(spec.input_schema.get("properties", {}).keys()) | required
                missing = required - set(call.args.keys())
                if missing:
                    raise PlannerValidationError(
                        f"task {task.id}: tool {call.tool!r} missing required arg(s): "
                        f"{sorted(missing)}"
                    )
                unknown = set(call.args.keys()) - allowed
                if unknown:
                    raise PlannerValidationError(
                        f"task {task.id}: tool {call.tool!r} has unknown arg(s): "
                        f"{sorted(unknown)} (allowed: {sorted(allowed)})"
                    )

                # Check 8c β€” analyze_aggregate: every aggregation FUNCTION must be one
                # the tool supports. Check 8a only validates arg *names* (`aggregations`
                # is allowed); it never looks at the function *values* inside the dict,
                # so an unsupported func like `std` otherwise passes validation and only
                # fails at execution β€” too late for a corrective retry, so the task
                # reaches the Assembler as a silent failure. Catch it here so the planner
                # is re-prompted to degrade to a supported function (e.g. `mean`).
                if call.tool == "analyze_aggregate":
                    aggs = call.args.get("aggregations")
                    if isinstance(aggs, dict):
                        bad = sorted(
                            {
                                f
                                for funcs in aggs.values()
                                for f in ([funcs] if isinstance(funcs, str) else funcs or [])
                                if f not in SUPPORTED_AGGS
                            }
                        )
                        if bad:
                            raise PlannerValidationError(
                                f"task {task.id}: analyze_aggregate has unsupported "
                                f"aggregation function(s) {bad} (supported: "
                                f"{sorted(SUPPORTED_AGGS)}). Use a supported function "
                                "(e.g. mean/median); for the spread of a whole column "
                                "use analyze_descriptive instead."
                            )

                    # Check 8d β€” group_by keys must be plain column-name strings. A
                    # derived grouping (a CASE/binning expression emitted as a dict)
                    # is unhashable and crashes df.groupby at execution ("unhashable
                    # type: 'dict'"). Reject it here so the planner is re-prompted;
                    # bucketing a numeric column into ranges belongs to
                    # analyze_segment, not smuggled through analyze_aggregate.
                    group_by = call.args.get("group_by")
                    if isinstance(group_by, list):
                        bad_keys = [g for g in group_by if not isinstance(g, str)]
                        if bad_keys:
                            raise PlannerValidationError(
                                f"task {task.id}: analyze_aggregate group_by must be "
                                f"column names (strings); got non-string entr(ies) "
                                f"{bad_keys}. Derived groupings (CASE/binning) are not "
                                "supported β€” group by an existing column."
                            )

                # Check 3 β€” concrete source_id args must exist in the catalog.
                src = call.args.get("source_id")
                if isinstance(src, str) and not _is_placeholder(src):
                    if src not in known_sources:
                        raise PlannerValidationError(
                            f"task {task.id}: tool {call.tool!r} references unknown "
                            f"source_id {src!r} (known: {sorted(known_sources)})"
                        )

                # Check 8b β€” inline retrieve_data IR validates against the catalog.
                if call.tool == "retrieve_data":
                    self._validate_inline_ir(task.id, call.args, catalog)

                # Check 9 β€” a `data` handoff (Pattern A) must reference a task that
                # produces analyzable data rows, not one producing catalog metadata
                # (check_data/check_knowledge) or documents (retrieve_knowledge).
                self._validate_data_source(task.id, call, tasks_by_id, registry)

            # Check 7 β€” success_criteria is checkable.
            if not _is_checkable(task.success_criteria):
                raise PlannerValidationError(
                    f"task {task.id}: success_criteria is not checkable β€” include a "
                    f"measurable signal (one of {list(_CHECKABLE_TOKENS)}); "
                    f"got {task.success_criteria!r}"
                )

        # Check 4 β€” DAG: edges resolve, placeholders resolve, no cycles.
        self._validate_dag(tasks_by_id, id_set)

    def _validate_inline_ir(self, task_id: str, args: dict, catalog: Catalog) -> None:
        raw_ir = args.get("ir")
        if not isinstance(raw_ir, dict):
            raise PlannerValidationError(
                f"task {task_id}: retrieve_data.args.ir must be an inline QueryIR "
                f"object, got {type(raw_ir).__name__}"
            )
        try:
            ir = QueryIR.model_validate(raw_ir)
        except ValidationError as e:
            raise PlannerValidationError(
                f"task {task_id}: retrieve_data.args.ir is not a valid QueryIR: {e}"
            ) from e

        # Canonicalize near-miss ids (LLM dropped/mutated a char in an opaque
        # catalog id) before validating. On a successful repair, write the fixed
        # IR back into the tool call so the downstream executor runs the
        # corrected IR β€” not just the validator.
        ir, repairs = self._ir_repairer.repair(ir, catalog)
        if repairs:
            args["ir"] = ir.model_dump()
            for r in repairs:
                logger.info(
                    "repaired ir id",
                    task_id=task_id,
                    where=r.where,
                    from_id=r.from_id,
                    to_id=r.to_id,
                )

        try:
            self._ir_validator.validate(ir, catalog)
        except IRValidationError as e:
            raise PlannerValidationError(
                f"task {task_id}: retrieve_data IR failed catalog validation: {e}"
            ) from e

    @staticmethod
    def _validate_data_source(
        task_id: str, call, tasks_by_id: dict, registry: ToolRegistry
    ) -> None:
        """The `data`/`data_right` handoff (Pattern A) must be a '${t<id>}'
        placeholder pointing at an upstream data-producing task.

        Two failure modes are rejected here:

        1. The arg is present but is NOT a placeholder β€” e.g. the planner inlined a
           {table_id, source_id} table reference (or any literal) instead of
           chaining a retrieve_data output. analyze_* tools never self-fetch by
           source_id; they only consume an already-retrieved table, so such a plan
           can only blow up at execution ("unsupported 'data' type: dict"). Reject
           it here so the retry loop re-prompts the planner to add a retrieve_data
           task and pass its placeholder.
        2. The placeholder resolves to a metadata (check_data/check_knowledge) or
           documents (retrieve_knowledge) task. Those pass the structural checks
           (check_* also returns kind="table"), but their rows are catalog schema,
           so a downstream analyze_* fails to find the requested columns. Resolving
           points at the referenced task's representative output β€” its last tool
           call (matches TaskRunner's `outputs[-1]`).

        `data_right` is analyze_merge's second table input (KM-703) β€” same Pattern A
        handoff, so it gets the same guard. An arg that is absent is left to Check 8a
        (required-arg presence).
        """
        for arg_name in ("data", "data_right"):
            if arg_name not in call.args:
                continue
            data_arg = call.args[arg_name]
            match = (
                PLACEHOLDER_RE.fullmatch(data_arg.strip())
                if isinstance(data_arg, str)
                else None
            )
            if match is None:
                raise PlannerValidationError(
                    f"task {task_id}: tool {call.tool!r} arg {arg_name!r} must be a "
                    f"'${{t<id>}}' placeholder referencing a retrieve_data output "
                    f"(Pattern A), got {data_arg!r}. analyze_* tools do not fetch data "
                    "themselves β€” add a retrieve_data task and pass its output."
                )
            ref_task = tasks_by_id.get(match.group(1))
            if ref_task is None or not ref_task.tool_calls:
                continue  # a dangling placeholder is reported by the DAG check
            ref_tool = ref_task.tool_calls[-1].tool
            ref_spec = registry.get(ref_tool)
            if ref_spec is not None and ref_spec.category in _NON_DATA_SOURCE_CATEGORIES:
                raise PlannerValidationError(
                    f"task {task_id}: tool {call.tool!r} takes its {arg_name!r} from "
                    f"task {match.group(1)} ({ref_tool!r}, category "
                    f"{ref_spec.category!r}), which produces metadata/documents β€” not "
                    "analyzable data rows. Feed analyze_* from a data-producing tool "
                    "(e.g. retrieve_data)."
                )
            # Check 10 β€” render_chart path shape (SPINE_V2_PLAN Β§4.3): the chart's
            # `data` must come from a task whose tool yields a TABLE. A stats- or
            # series-kind upstream passes the category denylist above but cannot
            # be materialized into a DataFrame at execution, so the chart could
            # only fail late; reject pre-run so the retry re-prompts toward a
            # grouped retrieve_data or a table-producing analyze_*.
            if (
                call.tool == "render_chart"
                and ref_spec is not None
                and ref_spec.output_kind != "table"
            ):
                raise PlannerValidationError(
                    f"task {task_id}: render_chart takes its {arg_name!r} from task "
                    f"{match.group(1)} ({ref_tool!r}), which yields "
                    f"{ref_spec.output_kind!r} output β€” a chart is drawn from a TABLE. "
                    "Feed it a table-producing task (a grouped retrieve_data, "
                    "analyze_aggregate, analyze_merge, ...)."
                )

    @staticmethod
    def _validate_dag(tasks_by_id: dict, id_set: set[str]) -> None:
        for task in tasks_by_id.values():
            for dep in task.depends_on:
                if dep not in id_set:
                    raise PlannerValidationError(
                        f"task {task.id}: depends_on references unknown task {dep!r}"
                    )
                if dep == task.id:
                    raise PlannerValidationError(
                        f"task {task.id}: depends_on includes itself"
                    )

        cycle = _find_cycle(tasks_by_id)
        if cycle:
            raise PlannerValidationError(f"cycle detected in depends_on: {' -> '.join(cycle)}")

        # On an acyclic graph, a placeholder is safe iff its target is a
        # transitive ancestor β€” i.e. guaranteed to have completed before this
        # task runs. Requiring a *direct* depends_on would wrongly reject valid
        # plans that depend on the target through an intermediate task.
        ancestors = _all_ancestors(tasks_by_id)
        for task in tasks_by_id.values():
            for ref in _placeholder_refs(task):
                if ref not in id_set:
                    raise PlannerValidationError(
                        f"task {task.id}: placeholder '${{{ref}}}' references unknown task"
                    )
                if ref not in ancestors[task.id]:
                    raise PlannerValidationError(
                        f"task {task.id}: placeholder '${{{ref}}}' used but {ref!r} is "
                        f"not a (transitive) dependency β€” add it to depends_on"
                    )


def _is_placeholder(value: str) -> bool:
    return bool(PLACEHOLDER_RE.fullmatch(value.strip()))


def _placeholder_refs(task) -> set[str]:
    """Task ids referenced by any placeholder in a task's args β€” including ones
    NESTED inside a retrieve_data IR (e.g. a '${t2.customer_id}' value-handoff
    filter). A '${t<id>.<col>}' ref contributes its TASK id ('t2'); the column
    suffix is stripped so the DAG check still enforces the dependency (task ids
    contain no '.')."""
    refs: set[str] = set()
    for call in task.tool_calls:
        for value in _iter_arg_strings(call.args):
            for ref in PLACEHOLDER_RE.findall(value):
                refs.add(ref.split(".", 1)[0])
    return refs


def _iter_arg_strings(value: object) -> Iterator[str]:
    """Yield every string reachable in a tool-arg value, recursing into dicts and
    lists so placeholders nested inside an IR are seen β€” not just top-level string
    args (which is all `data`/`data_right` Pattern-A handoffs used to be)."""
    if isinstance(value, str):
        yield value
    elif isinstance(value, dict):
        for v in value.values():
            yield from _iter_arg_strings(v)
    elif isinstance(value, list):
        for v in value:
            yield from _iter_arg_strings(v)


def _is_checkable(text: str) -> bool:
    low = text.lower()
    return any(tok in low for tok in _CHECKABLE_TOKENS)


def _find_cycle(tasks_by_id: dict) -> list[str] | None:
    color = {tid: _WHITE for tid in tasks_by_id}
    stack: list[str] = []

    def dfs(node: str) -> list[str] | None:
        color[node] = _GREY
        stack.append(node)
        for dep in tasks_by_id[node].depends_on:
            if color.get(dep) == _GREY:
                idx = stack.index(dep)
                return stack[idx:] + [dep]
            if color.get(dep) == _WHITE:
                found = dfs(dep)
                if found:
                    return found
        stack.pop()
        color[node] = _BLACK
        return None

    for tid in tasks_by_id:
        if color[tid] == _WHITE:
            found = dfs(tid)
            if found:
                return found
    return None


def _all_ancestors(tasks_by_id: dict) -> dict[str, set[str]]:
    """ancestors[id] = all tasks reachable by following depends_on edges."""
    cache: dict[str, set[str]] = {}

    def visit(node: str, seen: set[str]) -> set[str]:
        if node in cache:
            return cache[node]
        acc: set[str] = set()
        for dep in tasks_by_id[node].depends_on:
            if dep in seen or dep not in tasks_by_id:
                continue
            acc.add(dep)
            acc |= visit(dep, seen | {dep})
        cache[node] = acc
        return acc

    return {tid: visit(tid, {tid}) for tid in tasks_by_id}