agent-pack-haystack 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_pack_haystack-0.0.1.dist-info/METADATA +39 -0
- agent_pack_haystack-0.0.1.dist-info/RECORD +16 -0
- agent_pack_haystack-0.0.1.dist-info/WHEEL +4 -0
- agent_pack_haystack-0.0.1.dist-info/licenses/LICENSE.txt +201 -0
- haystack_integrations/agent_pack/__init__.py +8 -0
- haystack_integrations/agent_pack/advanced_rag/__init__.py +21 -0
- haystack_integrations/agent_pack/advanced_rag/agent.py +170 -0
- haystack_integrations/agent_pack/advanced_rag/hooks.py +89 -0
- haystack_integrations/agent_pack/advanced_rag/prompts.py +91 -0
- haystack_integrations/agent_pack/advanced_rag/tools.py +721 -0
- haystack_integrations/agent_pack/deep_research/__init__.py +7 -0
- haystack_integrations/agent_pack/deep_research/agent.py +192 -0
- haystack_integrations/agent_pack/deep_research/hooks.py +96 -0
- haystack_integrations/agent_pack/deep_research/prompts.py +104 -0
- haystack_integrations/agent_pack/deep_research/tools.py +113 -0
- haystack_integrations/agent_pack/py.typed +0 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from haystack import Document, Pipeline
|
|
11
|
+
from haystack.components.rankers import MetaFieldGroupingRanker
|
|
12
|
+
from haystack.components.retrievers.types import TextRetriever
|
|
13
|
+
from haystack.core.serialization import generate_qualified_class_name
|
|
14
|
+
from haystack.document_stores.types import DocumentStore
|
|
15
|
+
from haystack.tools import ComponentTool, PipelineTool, Tool, Toolset
|
|
16
|
+
from haystack.utils.deserialization import deserialize_component_inplace
|
|
17
|
+
|
|
18
|
+
from haystack_integrations.agent_pack.advanced_rag import prompts
|
|
19
|
+
|
|
20
|
+
# Shared input schema for the retrieval tool. The property names MUST match the retriever's input sockets (component
|
|
21
|
+
# path) / the input_mapping keys (pipeline path). The filter grammar is placed in the `filters` description so the LLM
|
|
22
|
+
# can learn it from the tool spec.
|
|
23
|
+
_RETRIEVAL_TOOL_PARAMS = {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": {
|
|
26
|
+
"query": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"description": ("The search query: a short phrase or a few keywords describing the documents to retrieve."),
|
|
29
|
+
},
|
|
30
|
+
"filters": {"type": "object", "description": prompts.FILTER_GRAMMAR, "additionalProperties": True},
|
|
31
|
+
},
|
|
32
|
+
"required": ["query"],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _accumulate_documents(current: list[Document] | None, new: list[Document]) -> list[Document]:
|
|
37
|
+
"""
|
|
38
|
+
State handler: merge newly retrieved documents into the accumulated list, deduplicated by id.
|
|
39
|
+
|
|
40
|
+
:param current: The documents accumulated so far (insertion order preserved).
|
|
41
|
+
:param new: The documents from the latest retrieval call.
|
|
42
|
+
:returns: The merged, deduplicated list.
|
|
43
|
+
"""
|
|
44
|
+
current = current or []
|
|
45
|
+
seen = {d.id for d in current}
|
|
46
|
+
merged = list(current)
|
|
47
|
+
for doc in new:
|
|
48
|
+
if doc.id not in seen:
|
|
49
|
+
seen.add(doc.id)
|
|
50
|
+
merged.append(doc)
|
|
51
|
+
return merged
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _format_retrieved_documents(documents: list[Document]) -> str:
|
|
55
|
+
"""
|
|
56
|
+
Format retrieved documents as the tool-result string: short doc reference + metadata + content.
|
|
57
|
+
|
|
58
|
+
:param documents: The retrieved documents.
|
|
59
|
+
:returns: One block per document, or a message nudging the agent to relax the filters when empty.
|
|
60
|
+
"""
|
|
61
|
+
# Length of the short document reference the LLM sees and cites (a prefix of `Document.id`).
|
|
62
|
+
doc_ref_len = 8
|
|
63
|
+
|
|
64
|
+
if not documents:
|
|
65
|
+
return (
|
|
66
|
+
"No documents matched. Try relaxing or removing the filters, or verify the filter "
|
|
67
|
+
"values with the metadata tools."
|
|
68
|
+
)
|
|
69
|
+
blocks = []
|
|
70
|
+
for doc in documents:
|
|
71
|
+
content = (doc.content or "").strip()
|
|
72
|
+
blocks.append(f"[doc {doc.id[:doc_ref_len]}]\n meta: {json.dumps(doc.meta, default=str)}\n {content}")
|
|
73
|
+
return "\n".join(blocks)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _make_retriever_tool(
|
|
77
|
+
*, retriever: TextRetriever, name: str = "search_documents", description: str | None = None
|
|
78
|
+
) -> ComponentTool:
|
|
79
|
+
"""
|
|
80
|
+
`search_documents` = ComponentTool over a retriever component that accepts `query` and `filters`.
|
|
81
|
+
|
|
82
|
+
The tool exposes `query` plus an optional `filters` parameter whose description teaches the LLM the Haystack
|
|
83
|
+
filter grammar, and accumulates every retrieved document into the agent's `documents` state key.
|
|
84
|
+
|
|
85
|
+
:param retriever: A standalone retriever component (not added to a `Pipeline`) following the `TextRetriever`
|
|
86
|
+
protocol, i.e. its `run` method accepts `query` and `filters` and it returns a `documents` output.
|
|
87
|
+
:param name: The tool name the LLM sees.
|
|
88
|
+
:param description: The tool description the LLM sees. Defaults to a pre-made description of relevance
|
|
89
|
+
search with optional metadata filtering.
|
|
90
|
+
:returns: The retrieval tool.
|
|
91
|
+
"""
|
|
92
|
+
input_sockets = getattr(retriever, "__haystack_input__", None)
|
|
93
|
+
input_names = set(input_sockets._sockets_dict.keys()) if input_sockets is not None else set()
|
|
94
|
+
missing = {"query", "filters"} - input_names
|
|
95
|
+
if missing:
|
|
96
|
+
msg = (
|
|
97
|
+
f"{type(retriever).__name__} does not accept the required inputs {sorted(missing)}. "
|
|
98
|
+
f"The retriever must take `query` and `filters`. Wrap embedding retrievers that take a "
|
|
99
|
+
f"`query_embedding` in haystack's `TextEmbeddingRetriever(retriever=..., text_embedder=...)`, "
|
|
100
|
+
f"or pass a retrieval `Pipeline` as the `retriever` instead."
|
|
101
|
+
)
|
|
102
|
+
raise ValueError(msg)
|
|
103
|
+
output_sockets = getattr(retriever, "__haystack_output__", None)
|
|
104
|
+
output_names = set(output_sockets._sockets_dict.keys()) if output_sockets is not None else set()
|
|
105
|
+
if "documents" not in output_names:
|
|
106
|
+
msg = (
|
|
107
|
+
f"{type(retriever).__name__} does not return a `documents` output, which the tool needs to "
|
|
108
|
+
f"collect the retrieved documents."
|
|
109
|
+
)
|
|
110
|
+
raise ValueError(msg)
|
|
111
|
+
return ComponentTool(
|
|
112
|
+
component=retriever,
|
|
113
|
+
name=name,
|
|
114
|
+
description=description or prompts.RETRIEVAL_TOOL_DESCRIPTION,
|
|
115
|
+
parameters=_RETRIEVAL_TOOL_PARAMS,
|
|
116
|
+
outputs_to_string={"source": "documents", "handler": _format_retrieved_documents},
|
|
117
|
+
outputs_to_state={"documents": {"source": "documents", "handler": _accumulate_documents}},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _make_retrieval_pipeline_tool(
|
|
122
|
+
*,
|
|
123
|
+
pipeline: Pipeline,
|
|
124
|
+
input_mapping: dict[str, list[str]],
|
|
125
|
+
output_mapping: dict[str, str] | None = None,
|
|
126
|
+
name: str = "search_documents",
|
|
127
|
+
description: str | None = None,
|
|
128
|
+
) -> PipelineTool:
|
|
129
|
+
"""
|
|
130
|
+
`search_documents` = PipelineTool over a custom retrieval pipeline.
|
|
131
|
+
|
|
132
|
+
The tool exposes `query` plus an optional `filters` parameter whose description teaches the LLM the Haystack
|
|
133
|
+
filter grammar, and accumulates every retrieved document into the agent's `documents` state key. The wrapped
|
|
134
|
+
pipeline must therefore end up with a `documents` tool output — either through a pipeline output socket named
|
|
135
|
+
`documents`, or through `output_mapping`.
|
|
136
|
+
|
|
137
|
+
:param pipeline: The retrieval pipeline to wrap (e.g. embedder -> retriever, or hybrid retrieval).
|
|
138
|
+
:param input_mapping: Maps the tool inputs to pipeline input sockets. Must have exactly the keys "query" and
|
|
139
|
+
"filters", e.g. `{"query": ["embedder.text"], "filters": ["retriever.filters"]}`.
|
|
140
|
+
:param output_mapping: Optional map of pipeline output sockets to tool outputs,
|
|
141
|
+
e.g. `{"retriever.documents": "documents"}`. Must map one output to "documents" when provided. Can stay unset
|
|
142
|
+
when the pipeline itself exposes a `documents` output socket (each unmapped pipeline output becomes a tool
|
|
143
|
+
output named after its socket).
|
|
144
|
+
:param name: The tool name the LLM sees.
|
|
145
|
+
:param description: The tool description the LLM sees. Defaults to a pre-made description of relevance
|
|
146
|
+
search with optional metadata filtering.
|
|
147
|
+
:returns: The retrieval tool.
|
|
148
|
+
"""
|
|
149
|
+
if set(input_mapping) != {"query", "filters"}:
|
|
150
|
+
msg = (
|
|
151
|
+
'`input_mapping` must have exactly the keys "query" and "filters", '
|
|
152
|
+
'e.g. {"query": ["embedder.text"], "filters": ["retriever.filters"]}.'
|
|
153
|
+
)
|
|
154
|
+
raise ValueError(msg)
|
|
155
|
+
if output_mapping is not None:
|
|
156
|
+
if "documents" not in output_mapping.values():
|
|
157
|
+
msg = (
|
|
158
|
+
'`output_mapping` must map one of the pipeline outputs to "documents", '
|
|
159
|
+
'e.g. {"retriever.documents": "documents"} — the tool needs it to collect the retrieved documents.'
|
|
160
|
+
)
|
|
161
|
+
raise ValueError(msg)
|
|
162
|
+
else:
|
|
163
|
+
leaf_socket_names = {socket for sockets in pipeline.outputs().values() for socket in sockets}
|
|
164
|
+
if "documents" not in leaf_socket_names:
|
|
165
|
+
msg = (
|
|
166
|
+
"The pipeline does not expose a `documents` output socket. Provide an `output_mapping` that maps "
|
|
167
|
+
'one of the pipeline outputs to "documents", e.g. {"retriever.documents": "documents"} — the tool '
|
|
168
|
+
"needs it to collect the retrieved documents."
|
|
169
|
+
)
|
|
170
|
+
raise ValueError(msg)
|
|
171
|
+
return PipelineTool(
|
|
172
|
+
pipeline=pipeline,
|
|
173
|
+
name=name,
|
|
174
|
+
description=description or prompts.RETRIEVAL_TOOL_DESCRIPTION,
|
|
175
|
+
input_mapping=input_mapping,
|
|
176
|
+
output_mapping=output_mapping,
|
|
177
|
+
parameters=_RETRIEVAL_TOOL_PARAMS,
|
|
178
|
+
outputs_to_string={"source": "documents", "handler": _format_retrieved_documents},
|
|
179
|
+
outputs_to_state={"documents": {"source": "documents", "handler": _accumulate_documents}},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _require_store_method(document_store: DocumentStore, method: str) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Raise if `document_store` does not implement `method` (the metadata methods are opt-in per store).
|
|
186
|
+
|
|
187
|
+
:param document_store: The document store to check.
|
|
188
|
+
:param method: The name of the method the store must implement.
|
|
189
|
+
:raises ValueError: If the store does not implement the method.
|
|
190
|
+
"""
|
|
191
|
+
if not hasattr(document_store, method):
|
|
192
|
+
msg = (
|
|
193
|
+
f"{type(document_store).__name__} does not implement `{method}`, which this tool requires. "
|
|
194
|
+
f"Use a document store that supports metadata introspection."
|
|
195
|
+
)
|
|
196
|
+
raise ValueError(msg)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ListMetadataFieldsTool(Tool):
|
|
200
|
+
"""Tool that lists all metadata fields and their types from a document store."""
|
|
201
|
+
|
|
202
|
+
def __init__(self, document_store: DocumentStore) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Create the tool.
|
|
205
|
+
|
|
206
|
+
:param document_store: The document store to inspect. Must implement `get_metadata_fields_info`.
|
|
207
|
+
:raises ValueError: If the store does not implement `get_metadata_fields_info`.
|
|
208
|
+
"""
|
|
209
|
+
_require_store_method(document_store, "get_metadata_fields_info")
|
|
210
|
+
self.document_store = document_store
|
|
211
|
+
super().__init__(
|
|
212
|
+
name="list_metadata_fields",
|
|
213
|
+
description=(
|
|
214
|
+
"Returns all metadata fields available on the documents and their types "
|
|
215
|
+
"(e.g. keyword, int, float). Call this FIRST to learn what fields you can filter on. "
|
|
216
|
+
"Returned field names do NOT include the 'meta.' prefix — add it when building a "
|
|
217
|
+
"filter (field 'year' becomes 'meta.year')."
|
|
218
|
+
),
|
|
219
|
+
parameters={"type": "object", "properties": {}},
|
|
220
|
+
function=self._list_metadata_fields,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def _list_metadata_fields(self) -> str:
|
|
224
|
+
"""
|
|
225
|
+
List the store's metadata fields and their types.
|
|
226
|
+
|
|
227
|
+
:returns: One line per field with its type, or a note that no fields were found.
|
|
228
|
+
"""
|
|
229
|
+
fields_info = self.document_store.get_metadata_fields_info() # type: ignore[attr-defined]
|
|
230
|
+
if not fields_info:
|
|
231
|
+
return "No metadata fields found (the document store may be empty)."
|
|
232
|
+
lines = [f"- {field} ({info.get('type', 'unknown')})" for field, info in sorted(fields_info.items())]
|
|
233
|
+
return "Metadata fields (add the 'meta.' prefix when filtering):\n" + "\n".join(lines)
|
|
234
|
+
|
|
235
|
+
def to_dict(self) -> dict[str, Any]:
|
|
236
|
+
"""Serialize the tool to a dictionary."""
|
|
237
|
+
return {
|
|
238
|
+
"type": generate_qualified_class_name(type(self)),
|
|
239
|
+
"data": {"document_store": self.document_store.to_dict()},
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def from_dict(cls, data: dict[str, Any]) -> "ListMetadataFieldsTool":
|
|
244
|
+
"""
|
|
245
|
+
Deserialize the tool from a dictionary.
|
|
246
|
+
|
|
247
|
+
:param data: The dictionary produced by `to_dict`.
|
|
248
|
+
:returns: The deserialized tool.
|
|
249
|
+
"""
|
|
250
|
+
inner_data = data["data"]
|
|
251
|
+
deserialize_component_inplace(inner_data, key="document_store")
|
|
252
|
+
return cls(**inner_data)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class GetMetadataFieldValuesTool(Tool):
|
|
256
|
+
"""Tool that returns the distinct values of a metadata field from a document store."""
|
|
257
|
+
|
|
258
|
+
# Cap on the number of values listed per call, protecting the agent's context window from high-cardinality fields
|
|
259
|
+
# (the total count is reported alongside when the store provides one).
|
|
260
|
+
_MAX_LISTED_VALUES = 100
|
|
261
|
+
|
|
262
|
+
def __init__(self, document_store: DocumentStore) -> None:
|
|
263
|
+
"""
|
|
264
|
+
Create the tool.
|
|
265
|
+
|
|
266
|
+
:param document_store: The document store to inspect. Must implement `get_metadata_field_unique_values`.
|
|
267
|
+
:raises ValueError: If the store does not implement `get_metadata_field_unique_values`.
|
|
268
|
+
"""
|
|
269
|
+
_require_store_method(document_store, "get_metadata_field_unique_values")
|
|
270
|
+
self.document_store = document_store
|
|
271
|
+
super().__init__(
|
|
272
|
+
name="get_metadata_field_values",
|
|
273
|
+
description=(
|
|
274
|
+
"Returns the distinct values of a metadata field. Use it before filtering on a "
|
|
275
|
+
"keyword or boolean field, so your filter uses values that actually exist "
|
|
276
|
+
"(filter values are matched exactly)."
|
|
277
|
+
),
|
|
278
|
+
parameters={
|
|
279
|
+
"type": "object",
|
|
280
|
+
"properties": {
|
|
281
|
+
"field": {
|
|
282
|
+
"type": "string",
|
|
283
|
+
"description": "The metadata field name, as returned by list_metadata_fields.",
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
"required": ["field"],
|
|
287
|
+
},
|
|
288
|
+
function=self._get_metadata_field_values,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def _get_metadata_field_values(self, field: str) -> str:
|
|
292
|
+
"""
|
|
293
|
+
List the distinct values of a metadata field.
|
|
294
|
+
|
|
295
|
+
:param field: The metadata field name, with or without the 'meta.' prefix (normalized by the document store).
|
|
296
|
+
:returns: The unique values of the field (listing capped for high-cardinality fields).
|
|
297
|
+
"""
|
|
298
|
+
method = self.document_store.get_metadata_field_unique_values # type: ignore[attr-defined]
|
|
299
|
+
signature_params = inspect.signature(method).parameters
|
|
300
|
+
|
|
301
|
+
kwargs: dict[str, Any] = {}
|
|
302
|
+
# pgvector declares `search_term` and `from_` without defaults, so pass their neutral values when present.
|
|
303
|
+
if "search_term" in signature_params:
|
|
304
|
+
kwargs["search_term"] = None
|
|
305
|
+
if "from_" in signature_params:
|
|
306
|
+
kwargs["from_"] = 0
|
|
307
|
+
# Fetch up to the listing cap: without an explicit size, several stores return as few as 10 values by
|
|
308
|
+
# default, while others would fetch thousands only for us to drop them. Most paginating stores call the
|
|
309
|
+
# parameter `size` (Chroma, Weaviate, OpenSearch, Elasticsearch, ...); Qdrant calls it `limit`; some take
|
|
310
|
+
# neither and return everything (FAISS, AlloyDB, IBM DB).
|
|
311
|
+
requested_size: int | None = None
|
|
312
|
+
if "size" in signature_params:
|
|
313
|
+
kwargs["size"] = requested_size = self._MAX_LISTED_VALUES
|
|
314
|
+
elif "limit" in signature_params:
|
|
315
|
+
kwargs["limit"] = requested_size = self._MAX_LISTED_VALUES
|
|
316
|
+
|
|
317
|
+
result = method(field, **kwargs)
|
|
318
|
+
values, total = self._normalize_unique_values_result(result, requested_size=requested_size)
|
|
319
|
+
return self._format_field_values(field, values, total)
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def _normalize_unique_values_result(result: Any, *, requested_size: int | None) -> tuple[list[Any], int | None]:
|
|
323
|
+
"""
|
|
324
|
+
Normalize the return shapes of `get_metadata_field_unique_values` across document stores.
|
|
325
|
+
|
|
326
|
+
The method is not standardized; three dialects exist:
|
|
327
|
+
|
|
328
|
+
- A `(values, total)` tuple (InMemory, Chroma, Weaviate, pgvector, Pinecone, Astra, ...): the int is the
|
|
329
|
+
total number of unique values, computed by the store independently of the requested page size.
|
|
330
|
+
- A `(values, cursor)` tuple from cursor-paginating stores (OpenSearch, Elasticsearch, FalkorDB): a dict
|
|
331
|
+
pointing at the next page sits in place of the count. A non-None cursor means more values exist and the
|
|
332
|
+
total is unknown; None means the returned values are the complete set.
|
|
333
|
+
- A bare list of values without any count. FAISS, AlloyDB and IBM DB return the complete value set, but
|
|
334
|
+
Qdrant silently truncates to its `limit` parameter — so a full page (`len(values) == requested_size`)
|
|
335
|
+
may be cut off and the total counts as unknown then.
|
|
336
|
+
|
|
337
|
+
:param result: The raw return value of the store method.
|
|
338
|
+
:param requested_size: The page size passed to the store's `size`/`limit` parameter, or None when the
|
|
339
|
+
store takes neither and returns everything.
|
|
340
|
+
:returns: The values, plus the total number of unique values — None when the store did not report a
|
|
341
|
+
total and more values may exist beyond the returned ones.
|
|
342
|
+
"""
|
|
343
|
+
if isinstance(result, tuple):
|
|
344
|
+
values, second = list(result[0]), result[1]
|
|
345
|
+
if isinstance(second, int):
|
|
346
|
+
return values, second
|
|
347
|
+
return values, len(values) if second is None else None
|
|
348
|
+
values = list(result)
|
|
349
|
+
if requested_size is not None and len(values) >= requested_size:
|
|
350
|
+
return values, None
|
|
351
|
+
return values, len(values)
|
|
352
|
+
|
|
353
|
+
def _format_field_values(self, field: str, values: list[Any], total: int | None) -> str:
|
|
354
|
+
"""
|
|
355
|
+
Format the unique values of a field as the tool-result string.
|
|
356
|
+
|
|
357
|
+
:param field: The metadata field name (without the 'meta.' prefix).
|
|
358
|
+
:param values: The unique values returned by the store.
|
|
359
|
+
:param total: The total number of unique values (may exceed `len(values)`), or None when the store did
|
|
360
|
+
not report a total and more values may exist.
|
|
361
|
+
:returns: The listing (capped at `_MAX_LISTED_VALUES`), or a note when the field has no values.
|
|
362
|
+
"""
|
|
363
|
+
if not values:
|
|
364
|
+
return f"Field '{field}' has no values in the document store."
|
|
365
|
+
shown = values[: self._MAX_LISTED_VALUES]
|
|
366
|
+
listing = ", ".join(str(v) for v in shown)
|
|
367
|
+
if total is None:
|
|
368
|
+
return f"Field '{field}' has at least {len(shown)} unique values: {listing} … more values may exist"
|
|
369
|
+
suffix = f" … and {total - len(shown)} more" if total > len(shown) else ""
|
|
370
|
+
return f"Field '{field}' has {total} unique values: {listing}{suffix}"
|
|
371
|
+
|
|
372
|
+
def to_dict(self) -> dict[str, Any]:
|
|
373
|
+
"""Serialize the tool to a dictionary."""
|
|
374
|
+
return {
|
|
375
|
+
"type": generate_qualified_class_name(type(self)),
|
|
376
|
+
"data": {"document_store": self.document_store.to_dict()},
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
@classmethod
|
|
380
|
+
def from_dict(cls, data: dict[str, Any]) -> "GetMetadataFieldValuesTool":
|
|
381
|
+
"""
|
|
382
|
+
Deserialize the tool from a dictionary.
|
|
383
|
+
|
|
384
|
+
:param data: The dictionary produced by `to_dict`.
|
|
385
|
+
:returns: The deserialized tool.
|
|
386
|
+
"""
|
|
387
|
+
inner_data = data["data"]
|
|
388
|
+
deserialize_component_inplace(inner_data, key="document_store")
|
|
389
|
+
return cls(**inner_data)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class GetMetadataFieldRangeTool(Tool):
|
|
393
|
+
"""Tool that returns the minimum and maximum values of a metadata field from a document store."""
|
|
394
|
+
|
|
395
|
+
def __init__(self, document_store: DocumentStore) -> None:
|
|
396
|
+
"""
|
|
397
|
+
Create the tool.
|
|
398
|
+
|
|
399
|
+
:param document_store: The document store to inspect. Must implement `get_metadata_field_min_max`.
|
|
400
|
+
:raises ValueError: If the store does not implement `get_metadata_field_min_max`.
|
|
401
|
+
"""
|
|
402
|
+
_require_store_method(document_store, "get_metadata_field_min_max")
|
|
403
|
+
self.document_store = document_store
|
|
404
|
+
super().__init__(
|
|
405
|
+
name="get_metadata_field_range",
|
|
406
|
+
description=(
|
|
407
|
+
"Returns the minimum and maximum values of a metadata field. Use it before "
|
|
408
|
+
"filtering on numeric fields (int, float) or orderable ones such as ISO dates."
|
|
409
|
+
),
|
|
410
|
+
parameters={
|
|
411
|
+
"type": "object",
|
|
412
|
+
"properties": {
|
|
413
|
+
"field": {
|
|
414
|
+
"type": "string",
|
|
415
|
+
"description": "The metadata field name, as returned by list_metadata_fields.",
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
"required": ["field"],
|
|
419
|
+
},
|
|
420
|
+
function=self._get_metadata_field_range,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def _get_metadata_field_range(self, field: str) -> str:
|
|
424
|
+
"""
|
|
425
|
+
Get the minimum and maximum values of a metadata field.
|
|
426
|
+
|
|
427
|
+
:param field: The metadata field name, with or without the 'meta.' prefix (normalized by the document store).
|
|
428
|
+
:returns: The min and max of the field, or a note that the field has no orderable values.
|
|
429
|
+
"""
|
|
430
|
+
result = self.document_store.get_metadata_field_min_max(field) # type: ignore[attr-defined]
|
|
431
|
+
if result.get("min") is None and result.get("max") is None:
|
|
432
|
+
return f"Field '{field}' has no numeric or orderable values in the document store."
|
|
433
|
+
return f"Field '{field}': min={result.get('min')}, max={result.get('max')}"
|
|
434
|
+
|
|
435
|
+
def to_dict(self) -> dict[str, Any]:
|
|
436
|
+
"""Serialize the tool to a dictionary."""
|
|
437
|
+
return {
|
|
438
|
+
"type": generate_qualified_class_name(type(self)),
|
|
439
|
+
"data": {"document_store": self.document_store.to_dict()},
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
@classmethod
|
|
443
|
+
def from_dict(cls, data: dict[str, Any]) -> "GetMetadataFieldRangeTool":
|
|
444
|
+
"""
|
|
445
|
+
Deserialize the tool from a dictionary.
|
|
446
|
+
|
|
447
|
+
:param data: The dictionary produced by `to_dict`.
|
|
448
|
+
:returns: The deserialized tool.
|
|
449
|
+
"""
|
|
450
|
+
inner_data = data["data"]
|
|
451
|
+
deserialize_component_inplace(inner_data, key="document_store")
|
|
452
|
+
return cls(**inner_data)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# Fields used to put filter-fetched documents into a sensible reading order: group the splits of the same parent file
|
|
456
|
+
# together, then sort them by their position in the file. From each tuple, the first field present in all fetched
|
|
457
|
+
# documents wins; when no field covers every document, the first present in any of them (see `_pick_order_field`).
|
|
458
|
+
# Haystack's document splitters auto-add `split_id` (ordinal) and `split_idx_start` (character offset), some add
|
|
459
|
+
# `page_number` and `source_id`; converters add the file name/path.
|
|
460
|
+
_GROUP_FIELDS = ("file_name", "file_path", "source_id")
|
|
461
|
+
_SORT_FIELDS = ("split_id", "split_idx_start", "page_number")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _pick_order_field(candidates: tuple[str, ...], documents: list[Document]) -> str | None:
|
|
465
|
+
"""
|
|
466
|
+
Pick the first candidate present in all documents, falling back to the first present in any of them.
|
|
467
|
+
|
|
468
|
+
Preferring full coverage keeps one document with an odd extra field (e.g. a stray `file_name`) from outranking a
|
|
469
|
+
field every document carries; the any-fallback still orders mixed corpora as well as their partial metadata allows
|
|
470
|
+
(documents missing the field are placed last).
|
|
471
|
+
|
|
472
|
+
:param candidates: The candidate field names, in priority order.
|
|
473
|
+
:param documents: The fetched documents.
|
|
474
|
+
:returns: The field to use, or None when no candidate is present in any document.
|
|
475
|
+
"""
|
|
476
|
+
fallback = None
|
|
477
|
+
for field in candidates:
|
|
478
|
+
docs_with_field = sum(1 for doc in documents if field in doc.meta)
|
|
479
|
+
if docs_with_field == len(documents) and docs_with_field > 0:
|
|
480
|
+
return field
|
|
481
|
+
if docs_with_field > 0 and fallback is None:
|
|
482
|
+
fallback = field
|
|
483
|
+
return fallback
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _order_documents_for_reading(documents: list[Document]) -> list[Document]:
|
|
487
|
+
"""
|
|
488
|
+
Put filter-fetched documents into reading order.
|
|
489
|
+
|
|
490
|
+
Filter-fetched documents carry no relevance order, so they are grouped by their parent file (a `_GROUP_FIELDS`
|
|
491
|
+
field, via `MetaFieldGroupingRanker`) and sorted by their position within it (a `_SORT_FIELDS` field), each
|
|
492
|
+
selected by `_pick_order_field`.
|
|
493
|
+
|
|
494
|
+
:param documents: The fetched documents.
|
|
495
|
+
:returns: The documents, grouped and sorted when the meta fields allow it.
|
|
496
|
+
"""
|
|
497
|
+
group_by = _pick_order_field(_GROUP_FIELDS, documents)
|
|
498
|
+
sort_by = _pick_order_field(_SORT_FIELDS, documents)
|
|
499
|
+
# Both ordering paths sort documents by the raw values of `sort_by`, which raises a TypeError when the field
|
|
500
|
+
# holds mixed, non-comparable types (e.g. int in one document, str in another) — the ranker sorts internally
|
|
501
|
+
# via `list.sort` with the same kind of key. Suppress it and keep the original order then.
|
|
502
|
+
if group_by:
|
|
503
|
+
with contextlib.suppress(TypeError):
|
|
504
|
+
ranker = MetaFieldGroupingRanker(group_by=group_by, sort_docs_by=sort_by)
|
|
505
|
+
return ranker.run(documents=documents)["documents"]
|
|
506
|
+
elif sort_by:
|
|
507
|
+
# Sort by the field value, placing documents with a missing value last.
|
|
508
|
+
with contextlib.suppress(TypeError):
|
|
509
|
+
return sorted(documents, key=lambda d: (sort_by not in d.meta, d.meta.get(sort_by)))
|
|
510
|
+
return documents
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _format_fetch_result(result: dict[str, Any]) -> str:
|
|
514
|
+
"""
|
|
515
|
+
Format a fetch-by-filter result: the documents plus paging guidance when more matched than were shown.
|
|
516
|
+
|
|
517
|
+
:param result: The result from `FetchDocumentsByFilterTool._fetch_documents`, containing `documents`,
|
|
518
|
+
`total_matched` and `offset`.
|
|
519
|
+
:returns: The formatted documents, or a message nudging the agent to adjust the filter or page onwards.
|
|
520
|
+
"""
|
|
521
|
+
documents: list[Document] = result["documents"]
|
|
522
|
+
total_matched: int = result["total_matched"]
|
|
523
|
+
offset: int = result["offset"]
|
|
524
|
+
if not documents:
|
|
525
|
+
if total_matched > 0:
|
|
526
|
+
return f"No documents at offset {offset}: only {total_matched} documents match the filter."
|
|
527
|
+
return "No documents matched the filter. Verify the field names and values with the metadata tools."
|
|
528
|
+
formatted = _format_retrieved_documents(documents)
|
|
529
|
+
remaining = total_matched - offset - len(documents)
|
|
530
|
+
if remaining > 0:
|
|
531
|
+
formatted += (
|
|
532
|
+
f"\n… and {remaining} more documents matched; call again with offset={offset + len(documents)} "
|
|
533
|
+
f"to continue, or narrow the filter."
|
|
534
|
+
)
|
|
535
|
+
return formatted
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _fetch_documents_tool_params(max_docs: int) -> dict[str, Any]:
|
|
539
|
+
"""
|
|
540
|
+
Build the input schema for the fetch-by-filter tool: a (required) filter, an optional doc cap and a page offset.
|
|
541
|
+
|
|
542
|
+
:param max_docs: The configured ceiling for `max_docs`, embedded in the schema.
|
|
543
|
+
:returns: The JSON schema.
|
|
544
|
+
"""
|
|
545
|
+
return {
|
|
546
|
+
"type": "object",
|
|
547
|
+
"properties": {
|
|
548
|
+
"filters": {"type": "object", "description": prompts.FETCH_FILTER_GRAMMAR, "additionalProperties": True},
|
|
549
|
+
"max_docs": {
|
|
550
|
+
"type": "integer",
|
|
551
|
+
"minimum": 1,
|
|
552
|
+
"maximum": max_docs,
|
|
553
|
+
"description": f"How many documents to return at most. Defaults to the maximum, {max_docs}.",
|
|
554
|
+
},
|
|
555
|
+
"offset": {
|
|
556
|
+
"type": "integer",
|
|
557
|
+
"minimum": 0,
|
|
558
|
+
"description": (
|
|
559
|
+
"How many matching documents to skip before returning results. "
|
|
560
|
+
"Use it to page through match sets larger than max_docs. Defaults to 0."
|
|
561
|
+
),
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
"required": ["filters"],
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
class FetchDocumentsByFilterTool(Tool):
|
|
569
|
+
"""
|
|
570
|
+
Tool that fetches documents directly from a document store by metadata filter.
|
|
571
|
+
|
|
572
|
+
Unlike a scored retrieval tool, this fetches without any relevance ranking, so an agent can grab specific documents
|
|
573
|
+
(e.g. a known title or source file) without going through a relevance search. The fetched documents are put into
|
|
574
|
+
reading order first: grouped by their parent file (`file_name`/`file_path`/`source_id`) and sorted by their
|
|
575
|
+
position within it (`split_id`/`split_idx_start`/`page_number`), using whichever of those metadata fields the
|
|
576
|
+
documents carry. Match sets larger than `max_docs` are paged: each call returns one page plus the total match
|
|
577
|
+
count, and the tool's `offset` input continues where the previous page ended.
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
def __init__(self, document_store: DocumentStore, max_docs: int = 10, max_fetch_factor: int = 10) -> None:
|
|
581
|
+
"""
|
|
582
|
+
Create the tool.
|
|
583
|
+
|
|
584
|
+
:param document_store: The document store to fetch documents from.
|
|
585
|
+
:param max_docs: Ceiling on the number of documents shown to the agent per fetch. Unlike scored retrieval, a
|
|
586
|
+
filter fetch is not bounded by a retriever's `top_k`, so this caps the tool result instead. The LLM can
|
|
587
|
+
request fewer via the tool's optional `max_docs` input, but never more.
|
|
588
|
+
:param max_fetch_factor: How many times the `max_docs` ceiling a filter may match before the fetch is
|
|
589
|
+
refused outright (when the store supports `count_documents_by_filter`) — the refusal is surfaced to the
|
|
590
|
+
LLM as an error it can recover from by narrowing the filter.
|
|
591
|
+
"""
|
|
592
|
+
self.document_store = document_store
|
|
593
|
+
self.max_docs = max_docs
|
|
594
|
+
self.max_fetch_factor = max_fetch_factor
|
|
595
|
+
super().__init__(
|
|
596
|
+
name="fetch_documents_by_filter",
|
|
597
|
+
description=prompts.FILTER_RETRIEVER_TOOL_DESCRIPTION,
|
|
598
|
+
parameters=_fetch_documents_tool_params(max_docs),
|
|
599
|
+
# We purposefully return a dict with `documents`, `total_matched` and `offset`
|
|
600
|
+
function=self._fetch_documents,
|
|
601
|
+
# This converts the dict into a single string and expects all three keys to be present
|
|
602
|
+
outputs_to_string={"handler": _format_fetch_result},
|
|
603
|
+
# This accumulates the documents into the agent's state
|
|
604
|
+
outputs_to_state={"documents": {"source": "documents", "handler": _accumulate_documents}},
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
def _fetch_documents(self, filters: dict[str, Any], max_docs: int | None = None, offset: int = 0) -> dict[str, Any]:
|
|
608
|
+
"""
|
|
609
|
+
Fetch the documents matching a metadata filter, in reading order.
|
|
610
|
+
|
|
611
|
+
:param filters: The metadata filter, in Haystack filter syntax.
|
|
612
|
+
:param max_docs: How many documents to return at most; clamped to the configured ceiling (`self.max_docs`),
|
|
613
|
+
which is also the default.
|
|
614
|
+
:param offset: How many matching documents to skip before returning results, for paging through match sets
|
|
615
|
+
larger than `max_docs`. Pages line up across calls when the documents carry the reading-order metadata
|
|
616
|
+
fields or the store returns matches in a consistent order.
|
|
617
|
+
:returns: One page of the ordered documents, the page's offset, plus how many documents matched the filter
|
|
618
|
+
overall. Formatted for the LLM by `_format_fetch_result`.
|
|
619
|
+
:raises ValueError: If the filter matches far more documents than can be shown (see below). The `Agent`
|
|
620
|
+
surfaces this to the LLM as an error tool message, so it can recover by narrowing the filter.
|
|
621
|
+
"""
|
|
622
|
+
effective_max = max(1, min(max_docs, self.max_docs)) if max_docs is not None else self.max_docs
|
|
623
|
+
offset = max(0, offset)
|
|
624
|
+
# The threshold is based on the configured `self.max_docs` ceiling, not the per-call `effective_max`: it
|
|
625
|
+
# bounds the cost of fetching the whole match set, which is the same however few documents the LLM asked
|
|
626
|
+
# to see — requesting a smaller page must not make the fetch more likely to be refused.
|
|
627
|
+
fetch_limit = self.max_fetch_factor * self.max_docs
|
|
628
|
+
counter = getattr(self.document_store, "count_documents_by_filter", None)
|
|
629
|
+
if counter is not None:
|
|
630
|
+
count = counter(filters=filters)
|
|
631
|
+
if count == 0:
|
|
632
|
+
return {"documents": [], "total_matched": 0, "offset": offset}
|
|
633
|
+
# `filter_documents` has no limit parameter, so on a broad filter a db could return thousands of docs only
|
|
634
|
+
# for us to show `max_docs` of them. When the store supports `count_documents_by_filter`, refuse over-broad
|
|
635
|
+
# filters without fetching anything.
|
|
636
|
+
if count > fetch_limit:
|
|
637
|
+
msg = (
|
|
638
|
+
f"Filter matches {count} documents, but at most {self.max_docs} can be shown per fetch — retrieving"
|
|
639
|
+
f" that many would be slow and the result would be mostly truncated anyway, so nothing was fetched."
|
|
640
|
+
f" This tool is meant for grabbing a small, specific set of documents. Add more conditions to "
|
|
641
|
+
f"narrow the filter, or use a relevance-based search to get only the most relevant documents."
|
|
642
|
+
)
|
|
643
|
+
raise ValueError(msg)
|
|
644
|
+
documents = _order_documents_for_reading(self.document_store.filter_documents(filters=filters))
|
|
645
|
+
return {
|
|
646
|
+
"documents": documents[offset : offset + effective_max],
|
|
647
|
+
"total_matched": len(documents),
|
|
648
|
+
"offset": offset,
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
def to_dict(self) -> dict[str, Any]:
|
|
652
|
+
"""Serialize the tool to a dictionary."""
|
|
653
|
+
return {
|
|
654
|
+
"type": generate_qualified_class_name(type(self)),
|
|
655
|
+
"data": {
|
|
656
|
+
"document_store": self.document_store.to_dict(),
|
|
657
|
+
"max_docs": self.max_docs,
|
|
658
|
+
"max_fetch_factor": self.max_fetch_factor,
|
|
659
|
+
},
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
@classmethod
|
|
663
|
+
def from_dict(cls, data: dict[str, Any]) -> "FetchDocumentsByFilterTool":
|
|
664
|
+
"""
|
|
665
|
+
Deserialize the tool from a dictionary.
|
|
666
|
+
|
|
667
|
+
:param data: The dictionary produced by `to_dict`.
|
|
668
|
+
:returns: The deserialized tool.
|
|
669
|
+
"""
|
|
670
|
+
inner_data = data["data"]
|
|
671
|
+
deserialize_component_inplace(inner_data, key="document_store")
|
|
672
|
+
return cls(**inner_data)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
class DocumentStoreToolset(Toolset):
|
|
676
|
+
"""
|
|
677
|
+
All document-store-backed tools as one unit.
|
|
678
|
+
|
|
679
|
+
Bundles the three metadata inspection tools (`ListMetadataFieldsTool`, `GetMetadataFieldValuesTool`,
|
|
680
|
+
`GetMetadataFieldRangeTool`) and the direct `FetchDocumentsByFilterTool`, so they can be handed to an `Agent`
|
|
681
|
+
(or combined with a retrieval tool) as a single object.
|
|
682
|
+
"""
|
|
683
|
+
|
|
684
|
+
def __init__(self, document_store: DocumentStore, max_fetched_docs: int = 10) -> None:
|
|
685
|
+
"""
|
|
686
|
+
Create the toolset.
|
|
687
|
+
|
|
688
|
+
:param document_store: The document store all tools run against. Must implement the metadata introspection
|
|
689
|
+
methods (`get_metadata_fields_info`, `get_metadata_field_unique_values`, `get_metadata_field_min_max`).
|
|
690
|
+
:param max_fetched_docs: Maximum number of documents `fetch_documents_by_filter` shows per fetch (see
|
|
691
|
+
`FetchDocumentsByFilterTool.max_docs`).
|
|
692
|
+
"""
|
|
693
|
+
self.document_store = document_store
|
|
694
|
+
self.max_fetched_docs = max_fetched_docs
|
|
695
|
+
super().__init__(
|
|
696
|
+
tools=[
|
|
697
|
+
ListMetadataFieldsTool(document_store),
|
|
698
|
+
GetMetadataFieldValuesTool(document_store),
|
|
699
|
+
GetMetadataFieldRangeTool(document_store),
|
|
700
|
+
FetchDocumentsByFilterTool(document_store, max_docs=max_fetched_docs),
|
|
701
|
+
]
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
def to_dict(self) -> dict[str, Any]:
|
|
705
|
+
"""Serialize the toolset to a dictionary."""
|
|
706
|
+
return {
|
|
707
|
+
"type": generate_qualified_class_name(type(self)),
|
|
708
|
+
"data": {"document_store": self.document_store.to_dict(), "max_fetched_docs": self.max_fetched_docs},
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
@classmethod
|
|
712
|
+
def from_dict(cls, data: dict[str, Any]) -> "DocumentStoreToolset":
|
|
713
|
+
"""
|
|
714
|
+
Deserialize the toolset from a dictionary.
|
|
715
|
+
|
|
716
|
+
:param data: The dictionary produced by `to_dict`.
|
|
717
|
+
:returns: The deserialized toolset.
|
|
718
|
+
"""
|
|
719
|
+
inner_data = data["data"]
|
|
720
|
+
deserialize_component_inplace(inner_data, key="document_store")
|
|
721
|
+
return cls(**inner_data)
|