sofhiaazzhr Claude Opus 4.7 commited on
Commit
148e33b
·
1 Parent(s): c08395e

[KM-630] Data-Access Tools

Browse files

DataAccessToolInvoker (src/tools/data_access.py) — never-throwing invoker for
the data-access family, constructed per-request with the authenticated user_id
and a CatalogReader (dependency injection; INV-7 keeps the agent layer
tool-agnostic). Implements all four tools:
- list_sources — user's data sources (id, name, type, table count).
- describe_source — tables/columns of one source (metadata only; exposes
pii_flag, never sample_values).
- query_structured — runs a pre-built QueryIR (validate -> dispatch -> execute,
skipping the planner) and returns ToolOutput(kind="table");
this is the Pattern A handoff the analyze_* tools consume.
- retrieve_documents — dense retrieval over unstructured sources; optional
source_id is a best-effort metadata post-filter (see TODO).

CompositeToolInvoker (src/tools/invoker.py) — one invoke(tool_name, args)
dispatching the whole tool surface: routes the four data-access tools to the
stateful DataAccessToolInvoker, everything else (analyze_*) to the stateless
AnalyticsToolInvoker. The TaskRunner only ever calls this one method.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Files changed (2) hide show
  1. src/tools/data_access.py +302 -0
  2. src/tools/invoker.py +41 -0
src/tools/data_access.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DataAccessToolInvoker — catalog-introspection tools (KM-465).
2
+
3
+ Implements the `ToolInvoker` Protocol (src/agents/slow_path/invoker.py) for the
4
+ data-access / catalog-introspection family. Unlike the stateless
5
+ `AnalyticsToolInvoker`, these tools read the user's catalog, so the invoker is
6
+ constructed per-request with the authenticated `user_id` and a `CatalogReader`
7
+ (dependency injection — the runtime/Coordinator supplies them; INV-7 keeps the
8
+ agent layer tool-agnostic).
9
+
10
+ Tools implemented here:
11
+ - `list_sources` — the user's data sources (id, name, type, table count).
12
+ - `describe_source` — tables/columns of one source (schema, one row per column).
13
+
14
+ Frozen guarantee (§8.4): **never throws.** Any failure returns
15
+ `ToolOutput(kind="error", error=...)`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Callable
21
+ from typing import Any, Protocol
22
+
23
+ from pydantic import ValidationError
24
+
25
+ from src.catalog.models import Catalog
26
+ from src.catalog.reader import CatalogReader
27
+ from src.query.executor.dispatcher import ExecutorDispatcher
28
+ from src.query.ir.models import QueryIR
29
+ from src.query.ir.validator import IRValidationError, IRValidator
30
+ from src.retrieval.base import RetrievalResult
31
+ from src.tools.contracts import ToolOutput
32
+
33
+ DispatcherFactory = Callable[[Catalog], ExecutorDispatcher]
34
+
35
+
36
+ class Retriever(Protocol):
37
+ """Minimal interface this invoker needs from the retrieval layer."""
38
+
39
+ async def retrieve(
40
+ self, query: str, user_id: str, k: int = 5
41
+ ) -> list[RetrievalResult]: ...
42
+
43
+
44
+ class DataAccessToolInvoker:
45
+ """Never-throwing invoker for catalog-introspection tools (implements ToolInvoker)."""
46
+
47
+ def __init__(
48
+ self,
49
+ user_id: str,
50
+ catalog_reader: CatalogReader,
51
+ *,
52
+ ir_validator: IRValidator | None = None,
53
+ dispatcher_factory: DispatcherFactory | None = None,
54
+ document_retriever: Retriever | None = None,
55
+ ) -> None:
56
+ self._user_id = user_id
57
+ self._reader = catalog_reader
58
+ # query_structured deps — injectable so tests need no real LLM/DB. The
59
+ # validator is stateless; the dispatcher is built per-call from the
60
+ # request's catalog (executors are picked by source_type).
61
+ self._validator = ir_validator or IRValidator()
62
+ self._dispatcher_factory: DispatcherFactory = (
63
+ dispatcher_factory or ExecutorDispatcher
64
+ )
65
+ # retrieve_documents dep — the module singleton by default, injectable
66
+ # for tests (the real one pulls PGVector + Redis). Lazy-imported on first
67
+ # use so importing this module stays cheap.
68
+ self._retriever = document_retriever
69
+
70
+ async def invoke(self, tool_name: str, args: dict[str, Any]) -> ToolOutput:
71
+ try:
72
+ if tool_name == "list_sources":
73
+ return await self._list_sources()
74
+ if tool_name == "describe_source":
75
+ return await self._describe_source(args)
76
+ if tool_name == "query_structured":
77
+ return await self._query_structured(args)
78
+ if tool_name == "retrieve_documents":
79
+ return await self._retrieve_documents(args)
80
+ return ToolOutput(
81
+ tool=tool_name, kind="error", error=f"unknown tool {tool_name!r}"
82
+ )
83
+ except Exception as exc: # noqa: BLE001 — never-throw seam (§8.4)
84
+ return ToolOutput(
85
+ tool=tool_name, kind="error", error=f"{type(exc).__name__}: {exc}"
86
+ )
87
+
88
+ async def _list_sources(self) -> ToolOutput:
89
+ """List the user's data sources (structured + unstructured)."""
90
+ structured = await self._reader.read(self._user_id, "structured")
91
+ unstructured = await self._reader.read(self._user_id, "unstructured")
92
+ sources = list(structured.sources) + list(unstructured.sources)
93
+
94
+ rows = [
95
+ [s.source_id, s.name, s.source_type, len(s.tables)] for s in sources
96
+ ]
97
+ return ToolOutput(
98
+ tool="list_sources",
99
+ kind="table",
100
+ columns=["source_id", "name", "source_type", "table_count"],
101
+ rows=rows,
102
+ meta={"source_count": len(sources)},
103
+ )
104
+
105
+ async def _describe_source(self, args: dict[str, Any]) -> ToolOutput:
106
+ """Describe one source: one row per column across its tables.
107
+
108
+ Pattern A note: this is catalog metadata only — never returns row
109
+ data or PII sample values (only the `pii_flag` boolean per column).
110
+ """
111
+ source_id = args.get("source_id")
112
+ if not source_id:
113
+ return ToolOutput(
114
+ tool="describe_source",
115
+ kind="error",
116
+ error="missing 'source_id' argument",
117
+ )
118
+
119
+ structured = await self._reader.read(self._user_id, "structured")
120
+ unstructured = await self._reader.read(self._user_id, "unstructured")
121
+ sources = list(structured.sources) + list(unstructured.sources)
122
+
123
+ source = next((s for s in sources if s.source_id == source_id), None)
124
+ if source is None:
125
+ return ToolOutput(
126
+ tool="describe_source",
127
+ kind="error",
128
+ error=f"source {source_id!r} not found",
129
+ )
130
+
131
+ rows = [
132
+ [
133
+ t.table_id,
134
+ t.name,
135
+ c.column_id,
136
+ c.name,
137
+ c.data_type,
138
+ c.nullable,
139
+ c.pii_flag,
140
+ ]
141
+ for t in source.tables
142
+ for c in t.columns
143
+ ]
144
+ return ToolOutput(
145
+ tool="describe_source",
146
+ kind="table",
147
+ columns=[
148
+ "table_id",
149
+ "table_name",
150
+ "column_id",
151
+ "column_name",
152
+ "data_type",
153
+ "nullable",
154
+ "pii_flag",
155
+ ],
156
+ rows=rows,
157
+ meta={
158
+ "source_id": source.source_id,
159
+ "source_name": source.name,
160
+ "source_type": source.source_type,
161
+ "table_count": len(source.tables),
162
+ "column_count": len(rows),
163
+ },
164
+ )
165
+
166
+ async def _query_structured(self, args: dict[str, Any]) -> ToolOutput:
167
+ """Run one validated, single-table QueryIR and return rows as a table.
168
+
169
+ This is the spine of the slow path (Pattern A): the `analyze_*` tools
170
+ take this output as their `data` arg. We receive an already-built `ir`
171
+ from the Planner (never SQL, never an NL question), so we skip the
172
+ planner and run validate -> dispatch -> execute directly (the tail of
173
+ QueryService.run). Output is `kind="table"` with `columns` + `rows`
174
+ (rows are list[list], converted from the executor's list[dict]).
175
+ """
176
+ raw = args.get("ir")
177
+ if raw is None:
178
+ return ToolOutput(
179
+ tool="query_structured", kind="error", error="missing 'ir' argument"
180
+ )
181
+
182
+ try:
183
+ ir = raw if isinstance(raw, QueryIR) else QueryIR.model_validate(raw)
184
+ except ValidationError as exc:
185
+ return ToolOutput(
186
+ tool="query_structured", kind="error", error=f"invalid IR: {exc}"
187
+ )
188
+
189
+ catalog = await self._reader.read(self._user_id, "structured")
190
+
191
+ try:
192
+ self._validator.validate(ir, catalog)
193
+ except IRValidationError as exc:
194
+ return ToolOutput(
195
+ tool="query_structured",
196
+ kind="error",
197
+ error=f"IR validation failed: {exc}",
198
+ )
199
+
200
+ dispatcher = self._dispatcher_factory(catalog)
201
+ executor = dispatcher.pick(ir)
202
+ result = await executor.run(ir)
203
+
204
+ if result.error:
205
+ return ToolOutput(
206
+ tool="query_structured", kind="error", error=result.error
207
+ )
208
+
209
+ # QueryResult.rows is list[dict]; ToolOutput.rows is list[list] ordered
210
+ # by `columns` so downstream materialization is positional.
211
+ rows = [[row.get(c) for c in result.columns] for row in result.rows]
212
+ return ToolOutput(
213
+ tool="query_structured",
214
+ kind="table",
215
+ columns=result.columns,
216
+ rows=rows,
217
+ meta={
218
+ "source_id": result.source_id,
219
+ "source_name": result.source_name,
220
+ "table_id": result.table_id,
221
+ "table_name": result.table_name,
222
+ "backend": result.backend,
223
+ "row_count": result.row_count,
224
+ "truncated": result.truncated,
225
+ "elapsed_ms": result.elapsed_ms,
226
+ },
227
+ )
228
+
229
+ async def _retrieve_documents(self, args: dict[str, Any]) -> ToolOutput:
230
+ """Dense-retrieve relevant chunks from the user's unstructured sources.
231
+
232
+ Pulls qualitative context (PDF/DOCX/TXT) for a natural-language `query`
233
+ via the retrieval router. `top_k` caps the number of chunks; optional
234
+ `source_id` scopes to one source (best-effort metadata filter — the
235
+ router itself does not yet scope by source, so this prunes the results).
236
+
237
+ TODO(retrieval scoping): the Planner few-shot has no `retrieve_documents`
238
+ example, so `source_id` is rarely emitted today and this post-filter is
239
+ adequate. If source-scoped retrieval becomes common, push scoping down
240
+ into RetrievalRouter.retrieve()/DocumentRetriever (WHERE
241
+ cmetadata->>'source_id' = :source_id) and drop this post-filter — more
242
+ correct than pruning an already-top_k'd unscoped result set.
243
+ """
244
+ query = args.get("query")
245
+ if not isinstance(query, str) or not query.strip():
246
+ return ToolOutput(
247
+ tool="retrieve_documents",
248
+ kind="error",
249
+ error="missing 'query' argument",
250
+ )
251
+
252
+ top_k = args.get("top_k", 5)
253
+ source_id = args.get("source_id")
254
+
255
+ retriever = self._retriever
256
+ if retriever is None:
257
+ from src.retrieval.router import retrieval_router
258
+
259
+ retriever = retrieval_router
260
+
261
+ results = await retriever.retrieve(query, self._user_id, top_k)
262
+ if source_id:
263
+ results = [r for r in results if _result_source_id(r) == source_id]
264
+
265
+ documents = [
266
+ {
267
+ "content": r.content,
268
+ "score": r.score,
269
+ "source_type": r.source_type,
270
+ "metadata": r.metadata,
271
+ }
272
+ for r in results
273
+ ]
274
+ return ToolOutput(
275
+ tool="retrieve_documents",
276
+ kind="documents",
277
+ value=documents,
278
+ meta={
279
+ "count": len(documents),
280
+ "query": query,
281
+ "top_k": top_k,
282
+ "source_id": source_id,
283
+ },
284
+ )
285
+
286
+
287
+ def _result_source_id(result: RetrievalResult) -> str | None:
288
+ """Best-effort extraction of a source_id from a retrieval result's metadata.
289
+
290
+ The chunk metadata schema is owned by the Go ingestion service; the key may
291
+ live at the top level or nested under "data". Returns None if absent.
292
+ """
293
+ meta = result.metadata or {}
294
+ top = meta.get("source_id")
295
+ if isinstance(top, str):
296
+ return top
297
+ data = meta.get("data")
298
+ if isinstance(data, dict):
299
+ nested = data.get("source_id")
300
+ if isinstance(nested, str):
301
+ return nested
302
+ return None
src/tools/invoker.py CHANGED
@@ -36,6 +36,7 @@ from src.tools.analytics import (
36
  temporal,
37
  )
38
  from src.tools.contracts import ToolOutput
 
39
 
40
  # tool name -> (compute callable, ToolOutput.kind it produces). Kept in lockstep
41
  # with src/tools/registry.py output_kind values.
@@ -79,6 +80,46 @@ class AnalyticsToolInvoker:
79
  return ToolOutput(tool=tool_name, kind=kind, value=result)
80
 
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  def _materialize(data: Any) -> tuple[pd.DataFrame, None] | tuple[None, str]:
83
  """Turn the resolved `data` argument into a DataFrame.
84
 
 
36
  temporal,
37
  )
38
  from src.tools.contracts import ToolOutput
39
+ from src.tools.data_access import DataAccessToolInvoker
40
 
41
  # tool name -> (compute callable, ToolOutput.kind it produces). Kept in lockstep
42
  # with src/tools/registry.py output_kind values.
 
80
  return ToolOutput(tool=tool_name, kind=kind, value=result)
81
 
82
 
83
+ # Tool names served by the stateful data-access invoker (catalog + query +
84
+ # retrieval). Everything else is an analyze_* tool and goes to the analytics
85
+ # invoker. Kept in lockstep with _DATA_ACCESS_TOOLS in planner/registry.py.
86
+ _DATA_ACCESS_TOOLS: frozenset[str] = frozenset(
87
+ {"query_structured", "retrieve_documents", "list_sources", "describe_source"}
88
+ )
89
+
90
+
91
+ class CompositeToolInvoker:
92
+ """One `invoke()` for the whole tool surface (KM-465 #4).
93
+
94
+ The TaskRunner only ever calls one `ToolInvoker`. This composes the two
95
+ families behind a single dispatch: the stateless `AnalyticsToolInvoker`
96
+ (`analyze_*`) and the per-request stateful `DataAccessToolInvoker`
97
+ (catalog/query/retrieval, which need the authenticated `user_id`). Routing
98
+ is by tool name; an unknown name falls through to the analytics invoker,
99
+ which returns the standard unknown-tool error envelope.
100
+
101
+ Constructed per-request — the Coordinator injects the request's `user_id`
102
+ and `CatalogReader` into the data-access invoker (INV-7: the agent layer
103
+ stays tool-agnostic).
104
+
105
+ Frozen guarantee (§8.4): **never throws** — both sub-invokers return
106
+ `ToolOutput(kind="error", ...)` on any failure.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ data_access: DataAccessToolInvoker,
112
+ analytics: AnalyticsToolInvoker | None = None,
113
+ ) -> None:
114
+ self._data_access = data_access
115
+ self._analytics = analytics or AnalyticsToolInvoker()
116
+
117
+ async def invoke(self, tool_name: str, args: dict[str, Any]) -> ToolOutput:
118
+ if tool_name in _DATA_ACCESS_TOOLS:
119
+ return await self._data_access.invoke(tool_name, args)
120
+ return await self._analytics.invoke(tool_name, args)
121
+
122
+
123
  def _materialize(data: Any) -> tuple[pd.DataFrame, None] | tuple[None, str]:
124
  """Turn the resolved `data` argument into a DataFrame.
125