agent-pack-haystack 0.0.2__tar.gz → 1.0.0__tar.gz
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-1.0.0/CHANGELOG.md +36 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/PKG-INFO +4 -3
- agent_pack_haystack-1.0.0/examples/advanced_rag_eval.py +306 -0
- agent_pack_haystack-1.0.0/examples/multihop_rag.py +309 -0
- agent_pack_haystack-1.0.0/examples/retrieval_eval.py +153 -0
- agent_pack_haystack-1.0.0/examples/util.py +41 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/pydoc/config_docusaurus.yml +6 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/pyproject.toml +7 -3
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/src/haystack_integrations/agent_pack/advanced_rag/agent.py +14 -31
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/src/haystack_integrations/agent_pack/advanced_rag/hooks.py +6 -2
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/src/haystack_integrations/agent_pack/advanced_rag/prompts.py +18 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/src/haystack_integrations/agent_pack/advanced_rag/tools.py +63 -27
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/src/haystack_integrations/agent_pack/deep_research/agent.py +49 -40
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/src/haystack_integrations/agent_pack/deep_research/tools.py +5 -5
- agent_pack_haystack-1.0.0/src/haystack_integrations/agent_pack/evaluation/__init__.py +20 -0
- agent_pack_haystack-1.0.0/src/haystack_integrations/agent_pack/evaluation/dataclasses.py +202 -0
- agent_pack_haystack-1.0.0/src/haystack_integrations/agent_pack/evaluation/harness_evaluator.py +58 -0
- agent_pack_haystack-1.0.0/src/haystack_integrations/agent_pack/evaluation/harness_log_collector.py +113 -0
- agent_pack_haystack-1.0.0/src/haystack_integrations/agent_pack/evaluation/retrieval_harness_evaluator.py +376 -0
- agent_pack_haystack-1.0.0/src/haystack_integrations/agent_pack/evaluation/tracer.py +261 -0
- agent_pack_haystack-1.0.0/src/haystack_integrations/agent_pack/optimization/__init__.py +29 -0
- agent_pack_haystack-1.0.0/src/haystack_integrations/agent_pack/optimization/agent.py +408 -0
- agent_pack_haystack-1.0.0/src/haystack_integrations/agent_pack/optimization/dataclasses.py +198 -0
- agent_pack_haystack-1.0.0/src/haystack_integrations/agent_pack/optimization/prompts.py +136 -0
- agent_pack_haystack-1.0.0/src/haystack_integrations/agent_pack/optimization/tools.py +331 -0
- agent_pack_haystack-1.0.0/src/haystack_integrations/agent_pack/optimization/utils.py +92 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/tests/advanced_rag/test_agent.py +12 -51
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/tests/advanced_rag/test_hooks.py +50 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/tests/advanced_rag/test_tools.py +3 -1
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/tests/deep_research/test_agent.py +47 -18
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/tests/deep_research/test_tools.py +1 -1
- agent_pack_haystack-1.0.0/tests/evaluation/__init__.py +3 -0
- agent_pack_haystack-1.0.0/tests/evaluation/test_dataclasses.py +65 -0
- agent_pack_haystack-1.0.0/tests/evaluation/test_harness_log_collector.py +83 -0
- agent_pack_haystack-1.0.0/tests/evaluation/test_retrieval_harness_evaluator.py +214 -0
- agent_pack_haystack-1.0.0/tests/evaluation/test_tracer.py +232 -0
- agent_pack_haystack-1.0.0/tests/optimization/__init__.py +3 -0
- agent_pack_haystack-1.0.0/tests/optimization/test_agent.py +355 -0
- agent_pack_haystack-1.0.0/tests/optimization/test_dataclasses.py +75 -0
- agent_pack_haystack-1.0.0/tests/optimization/test_tools.py +340 -0
- agent_pack_haystack-1.0.0/tests/optimization/test_utils.py +48 -0
- agent_pack_haystack-0.0.2/CHANGELOG.md +0 -14
- agent_pack_haystack-0.0.2/examples/advanced_rag_eval.py +0 -524
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/.gitignore +0 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/LICENSE.txt +0 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/README.md +0 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/src/haystack_integrations/agent_pack/__init__.py +0 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/src/haystack_integrations/agent_pack/advanced_rag/__init__.py +0 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/src/haystack_integrations/agent_pack/deep_research/__init__.py +0 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/src/haystack_integrations/agent_pack/deep_research/hooks.py +0 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/src/haystack_integrations/agent_pack/deep_research/prompts.py +0 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/src/haystack_integrations/agent_pack/py.typed +0 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/tests/__init__.py +0 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/tests/advanced_rag/__init__.py +0 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/tests/deep_research/__init__.py +0 -0
- {agent_pack_haystack-0.0.2 → agent_pack_haystack-1.0.0}/tests/deep_research/test_hooks.py +0 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [integrations/agent_pack-v0.1.0] - 2026-09-02
|
|
4
|
+
|
|
5
|
+
### 🚜 Refactor
|
|
6
|
+
|
|
7
|
+
- [**breaking**] Agent Pack - align/standardize Agents configuration (#3894)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [integrations/agent_pack-v0.0.2] - 2026-08-26
|
|
11
|
+
|
|
12
|
+
### 🚀 Features
|
|
13
|
+
|
|
14
|
+
- Use `AgentTool` in Deep Research Agent (#3850)
|
|
15
|
+
|
|
16
|
+
### 🐛 Bug Fixes
|
|
17
|
+
|
|
18
|
+
- Fix new issues raised by ruff 0.16.0 (#3670)
|
|
19
|
+
|
|
20
|
+
### 🧹 Chores
|
|
21
|
+
|
|
22
|
+
- Re-standardize READMEs (#3768)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## [integrations/agent_pack-v0.0.1] - 2026-07-20
|
|
26
|
+
|
|
27
|
+
### 🚀 Features
|
|
28
|
+
|
|
29
|
+
- Add advanced rag agent (#3600)
|
|
30
|
+
|
|
31
|
+
### 🧹 Chores
|
|
32
|
+
|
|
33
|
+
- Move agent pack to core integrations (#3591)
|
|
34
|
+
- Prepare Agent pack for 3.0 release (#3611)
|
|
35
|
+
|
|
36
|
+
<!-- generated by git-cliff -->
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agent-pack-haystack
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Pre-configured Haystack Agents
|
|
5
5
|
Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/agent_pack#readme
|
|
6
6
|
Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
|
|
@@ -12,15 +12,16 @@ Keywords: agents,haystack
|
|
|
12
12
|
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
14
|
Classifier: Programming Language :: Python
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
20
20
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
21
21
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
22
|
-
Requires-Python: >=3.
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
23
|
Requires-Dist: haystack-ai>=3.1.0
|
|
24
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
24
25
|
Description-Content-Type: text/markdown
|
|
25
26
|
|
|
26
27
|
# agent-pack-haystack
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
# Mini evaluation harness for the Advanced RAG agent, run against the MultiHopRAG corpus.
|
|
6
|
+
#
|
|
7
|
+
# The corpus and its labelled questions come from `multihop_rag`, which chunks the news articles and works out
|
|
8
|
+
# which chunks hold each question's quoted evidence. That gives exact ground truth: an eval case names the
|
|
9
|
+
# documents an answer needs, so retrieval is scored as recall over those IDs rather than a metadata predicate.
|
|
10
|
+
#
|
|
11
|
+
# Reported per eval case:
|
|
12
|
+
#
|
|
13
|
+
# - Retrieval: recall over the documents the question's evidence lives in, from the run's accumulated `documents`.
|
|
14
|
+
# - Process: whether metadata was inspected before any retrieval, per-tool call counts, how many retrievals used
|
|
15
|
+
# a filter, tool errors, steps, and wall-clock time. An eval case fails when it exceeds its tool budget.
|
|
16
|
+
# - Citations: the answer must cite every document the question's evidence lives in, and every
|
|
17
|
+
# `[doc <short-id>]` it carries must resolve to a document the run returned. Whether the answer is *right* is
|
|
18
|
+
# not checked; that needs a judge this harness does not have yet.
|
|
19
|
+
# - Token usage, per eval case and totalled, for comparing models and reasoning efforts.
|
|
20
|
+
#
|
|
21
|
+
# Run from `integrations/agent_pack` with `OPENAI_API_KEY` set. The corpus requires `datasets`:
|
|
22
|
+
#
|
|
23
|
+
# hatch run test:python examples/advanced_rag_eval.py
|
|
24
|
+
# hatch run test:python examples/advanced_rag_eval.py --max-cases 5
|
|
25
|
+
# hatch run test:python examples/advanced_rag_eval.py --store opensearch
|
|
26
|
+
#
|
|
27
|
+
# `--store opensearch` reuses a populated index across runs, so repeat runs skip re-indexing. Set `OPENSEARCH_URL`
|
|
28
|
+
# if it is not http://localhost:9200, and `OPENSEARCH_USERNAME` / `OPENSEARCH_PASSWORD` for a secured instance.
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import re
|
|
32
|
+
import time
|
|
33
|
+
from collections import Counter
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from haystack import Document
|
|
38
|
+
from haystack.components.agents import Agent
|
|
39
|
+
from haystack.dataclasses import ChatMessage
|
|
40
|
+
from multihop_rag import CORPUS_KEY, LabelledQuestion, build_eval_cases, prepare_corpus
|
|
41
|
+
from util import build_bm25_retriever, preview
|
|
42
|
+
|
|
43
|
+
from haystack_integrations.agent_pack.advanced_rag import create_advanced_rag_agent
|
|
44
|
+
|
|
45
|
+
RETRIEVAL_TOOLS = ("search_documents", "fetch_documents_by_filter")
|
|
46
|
+
METADATA_TOOLS = ("list_metadata_fields", "get_metadata_field_values", "get_metadata_field_range")
|
|
47
|
+
_CITATION_RE = re.compile(r"\[doc ([0-9a-f]{4,16})[^]]*\]")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class EvalCase:
|
|
52
|
+
"""
|
|
53
|
+
One eval case: a question, the documents that answer it, and the budgets the run may spend.
|
|
54
|
+
|
|
55
|
+
:param question: The question to put to the agent.
|
|
56
|
+
:param evidence: Ground truth, as `{chunk id: the quote found in that chunk}`. The keys are the documents the
|
|
57
|
+
answer needs, and the values are what the answer should be based on. For example,
|
|
58
|
+
{"a1b2c3...": "Tyreek Hill now needs to ...", "d4e5f6...": "The Dolphins went on to ..."}
|
|
59
|
+
:param tool_budgets: How many times the run may call a tool, or a group of tools sharing one allowance, as
|
|
60
|
+
`{tool name or names: limit}`. Exceeding any of them fails the eval case. Which tools these are depends
|
|
61
|
+
on the agent under evaluation, so the caller supplies them.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
question: str
|
|
65
|
+
evidence: dict[str, str]
|
|
66
|
+
tool_budgets: dict[str | tuple[str, ...], int]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def expected_document_ids(self) -> frozenset[str]:
|
|
70
|
+
"""The chunks an answer needs, which are the ones its evidence was found in."""
|
|
71
|
+
return frozenset(self.evidence)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ToolRunStats:
|
|
76
|
+
"""
|
|
77
|
+
The tool calls one agent run made, and what they add up to.
|
|
78
|
+
|
|
79
|
+
:param calls: Every call the run made, in the order it made them, as `(tool name, the arguments it passed)`:
|
|
80
|
+
|
|
81
|
+
[("list_metadata_fields", {}), ("search_documents", {"query": "CRISPR", "filters": None})]
|
|
82
|
+
|
|
83
|
+
:param errors: The calls that came back an error, as `(tool name, what it said)`:
|
|
84
|
+
|
|
85
|
+
[("get_metadata_field_values", "field 'nope' does not exist in the store")]
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
calls: list[tuple[str, dict[str, Any]]] = field(default_factory=list)
|
|
89
|
+
errors: list[tuple[str, str]] = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _named(tools: str | tuple[str, ...]) -> set[str]:
|
|
93
|
+
"""
|
|
94
|
+
Normalize one tool name or a group of them to a set.
|
|
95
|
+
|
|
96
|
+
:param tools: A tool name, or several of them.
|
|
97
|
+
:returns: The names as a set.
|
|
98
|
+
"""
|
|
99
|
+
return {tools} if isinstance(tools, str) else set(tools)
|
|
100
|
+
|
|
101
|
+
def calls_to(self, tools: str | tuple[str, ...]) -> int:
|
|
102
|
+
"""
|
|
103
|
+
Count the calls made to one tool, or to any of a group of them.
|
|
104
|
+
|
|
105
|
+
:param tools: A tool name, or several of them.
|
|
106
|
+
:returns: How many calls the run made to them.
|
|
107
|
+
"""
|
|
108
|
+
wanted = self._named(tools=tools)
|
|
109
|
+
return sum(1 for name, _ in self.calls if name in wanted)
|
|
110
|
+
|
|
111
|
+
def called_before(self, tools: str | tuple[str, ...], other: str | tuple[str, ...]) -> bool:
|
|
112
|
+
"""
|
|
113
|
+
Whether the run reached for one tool before it reached for another.
|
|
114
|
+
|
|
115
|
+
:param tools: The tool, or tools, that should come first.
|
|
116
|
+
:param other: The tool, or tools, they should come before.
|
|
117
|
+
:returns: True when one of `tools` was called and none of `other` was called before it. False when
|
|
118
|
+
`other` came first, and when neither was called at all.
|
|
119
|
+
"""
|
|
120
|
+
wanted, after = self._named(tools=tools), self._named(tools=other)
|
|
121
|
+
for name, _ in self.calls:
|
|
122
|
+
if name in wanted:
|
|
123
|
+
return True
|
|
124
|
+
if name in after:
|
|
125
|
+
return False
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def extract_tool_run_stats(messages: list[ChatMessage]) -> ToolRunStats:
|
|
130
|
+
"""
|
|
131
|
+
Extract tool calls and error results from an agent run.
|
|
132
|
+
|
|
133
|
+
:param messages: The messages returned by `agent.run(...)`.
|
|
134
|
+
:returns: The extracted statistics.
|
|
135
|
+
"""
|
|
136
|
+
return ToolRunStats(
|
|
137
|
+
calls=[(call.tool_name, call.arguments or {}) for message in messages for call in message.tool_calls],
|
|
138
|
+
errors=[
|
|
139
|
+
(result.origin.tool_name, result.result)
|
|
140
|
+
for message in messages
|
|
141
|
+
for result in message.tool_call_results
|
|
142
|
+
if result.error
|
|
143
|
+
],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _sum_usage(total: dict[str, int], usage: dict[str, Any]) -> dict[str, int]:
|
|
148
|
+
"""
|
|
149
|
+
Accumulate the numeric entries of a token-usage dict into a running total.
|
|
150
|
+
|
|
151
|
+
:param total: The running totals, updated in place.
|
|
152
|
+
:param usage: One run's `token_usage` dict (key names vary by generator).
|
|
153
|
+
:returns: The updated totals.
|
|
154
|
+
"""
|
|
155
|
+
for key, value in (usage or {}).items():
|
|
156
|
+
if isinstance(value, int):
|
|
157
|
+
total[key] = total.get(key, 0) + value
|
|
158
|
+
return total
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def run_eval_case(agent: Agent, case: EvalCase, position: int, total: int) -> dict[str, Any]:
|
|
162
|
+
"""
|
|
163
|
+
Run the agent on one eval case and print its report.
|
|
164
|
+
|
|
165
|
+
:param agent: The agent under evaluation.
|
|
166
|
+
:param case: The eval case to evaluate.
|
|
167
|
+
:param position: Which eval case this is, for the report heading.
|
|
168
|
+
:param total: How many eval cases there are, for the report heading.
|
|
169
|
+
:returns: A dict with `passed` (bool), `usage` (the run's token_usage dict), and `time` (s).
|
|
170
|
+
"""
|
|
171
|
+
started = time.perf_counter()
|
|
172
|
+
result = agent.run(messages=[ChatMessage.from_user(case.question)])
|
|
173
|
+
elapsed = time.perf_counter() - started
|
|
174
|
+
|
|
175
|
+
# Everything the report needs comes out of the one run: its messages, its answer and its token usage.
|
|
176
|
+
tool_run_stats = extract_tool_run_stats(messages=result["messages"])
|
|
177
|
+
answer = result["last_message"].text or ""
|
|
178
|
+
usage = result.get("token_usage") or {}
|
|
179
|
+
|
|
180
|
+
# Recall on the retrieved documents: how many of the expected documents the Agent found.
|
|
181
|
+
retrieved_docs = result.get("documents") or []
|
|
182
|
+
retrieved_ids = {document.id for document in retrieved_docs}
|
|
183
|
+
found = case.expected_document_ids & retrieved_ids
|
|
184
|
+
recall = len(found) / len(case.expected_document_ids)
|
|
185
|
+
|
|
186
|
+
# Resolve each [doc <short-id>] the answer uses against what the Agent found. A reference matching nothing
|
|
187
|
+
# is a fake citation, and one the answer doesn't use is an uncited document. Both are failures.
|
|
188
|
+
cited_refs = _CITATION_RE.findall(answer)
|
|
189
|
+
resolved = [ref for ref in cited_refs if any(document.id.startswith(ref) for document in retrieved_docs)]
|
|
190
|
+
cited_ids = {document.id for document in retrieved_docs if any(document.id.startswith(r) for r in cited_refs)}
|
|
191
|
+
citations_ok = len(resolved) == len(cited_refs)
|
|
192
|
+
uncited = case.expected_document_ids - cited_ids
|
|
193
|
+
|
|
194
|
+
# An eval case passes only on all four: it found every expected document, cited every one of them, made no
|
|
195
|
+
# citation that does not resolve, and stayed inside its tool budget.
|
|
196
|
+
spent = {tools: tool_run_stats.calls_to(tools=tools) for tools in case.tool_budgets}
|
|
197
|
+
within_budget = all(used <= case.tool_budgets[tools] for tools, used in spent.items())
|
|
198
|
+
passed = recall == 1.0 and not uncited and citations_ok and within_budget
|
|
199
|
+
|
|
200
|
+
inspected_first = tool_run_stats.called_before(tools="list_metadata_fields", other=RETRIEVAL_TOOLS)
|
|
201
|
+
counts = Counter(name for name, _ in tool_run_stats.calls)
|
|
202
|
+
filtered_retrievals = sum(
|
|
203
|
+
1 for name, args in tool_run_stats.calls if name in RETRIEVAL_TOOLS and args.get("filters")
|
|
204
|
+
)
|
|
205
|
+
needed = len(case.expected_document_ids)
|
|
206
|
+
print(f"\n=== eval case {position}/{total}: {'PASS' if passed else 'FAIL'} ===")
|
|
207
|
+
print(f" question: {case.question}")
|
|
208
|
+
print(f" tools called: {dict(counts)}")
|
|
209
|
+
print(
|
|
210
|
+
f" inspected metadata first: {inspected_first} retrievals with a filter: {filtered_retrievals} "
|
|
211
|
+
f"tool errors: {len(tool_run_stats.errors)} steps: {result['step_count']} time: {elapsed:.1f}s"
|
|
212
|
+
)
|
|
213
|
+
for tools, used in spent.items():
|
|
214
|
+
limit = case.tool_budgets[tools]
|
|
215
|
+
label = tools if isinstance(tools, str) else " + ".join(tools)
|
|
216
|
+
print(f" tool budget: {label} {used}/{limit} -> {'ok' if used <= limit else 'EXCEEDED'}")
|
|
217
|
+
print(f" retrieval: found {len(found)}/{needed} of the documents the answer needs, {len(retrieved_docs)} returned")
|
|
218
|
+
print(
|
|
219
|
+
f" citations: cited {needed - len(uncited)}/{needed} of them, "
|
|
220
|
+
f"and {len(resolved)}/{len(cited_refs)} of the answer's references point at a returned document"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Chunks of one article all carry its title, so they are grouped under it and told apart by chunk number.
|
|
224
|
+
# Articles keep the order they were first returned in, which is the order the run ranked them.
|
|
225
|
+
by_article: dict[str, list[Document]] = {}
|
|
226
|
+
for document in retrieved_docs:
|
|
227
|
+
by_article.setdefault(document.meta.get("title", ""), []).append(document)
|
|
228
|
+
|
|
229
|
+
print("\n documents returned, grouped by article. -> marks one the answer needs:")
|
|
230
|
+
for title, documents in by_article.items():
|
|
231
|
+
print(f" Title: {title}")
|
|
232
|
+
for document in sorted(documents, key=lambda chunk: chunk.meta.get("split_id", 0)):
|
|
233
|
+
needed_here = "-> " if document.id in case.expected_document_ids else " "
|
|
234
|
+
snippet = preview(text=document.content or "", limit=80)
|
|
235
|
+
print(f" {needed_here}chunk {document.meta.get('split_id'):>2} [doc {document.id[:8]}] {snippet}")
|
|
236
|
+
# Naming the quote, since the id alone says nothing about what the run failed to find or failed to use.
|
|
237
|
+
print()
|
|
238
|
+
for document_id in sorted(case.expected_document_ids - retrieved_ids):
|
|
239
|
+
print(
|
|
240
|
+
f" needed but never retrieved: [doc {document_id[:8]}] "
|
|
241
|
+
f"{preview(text=case.evidence[document_id], limit=96)}"
|
|
242
|
+
)
|
|
243
|
+
for document_id in sorted(uncited & retrieved_ids):
|
|
244
|
+
print(
|
|
245
|
+
f" needed and retrieved but not cited: [doc {document_id[:8]}] "
|
|
246
|
+
f"{preview(text=case.evidence[document_id], limit=96)}"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if usage:
|
|
250
|
+
print(f" tokens: { {k: v for k, v in usage.items() if isinstance(v, int)} }")
|
|
251
|
+
for tool_name, message in tool_run_stats.errors:
|
|
252
|
+
print(f" tool error: {tool_name} -> {preview(text=message, limit=120)}")
|
|
253
|
+
print(" answer:")
|
|
254
|
+
for line in answer.splitlines():
|
|
255
|
+
print(f" {line}")
|
|
256
|
+
return {"passed": passed, "usage": usage, "time": elapsed}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def main() -> None:
|
|
260
|
+
"""Run the eval set and print per-eval-case reports plus a summary."""
|
|
261
|
+
parser = argparse.ArgumentParser(description="Mini evaluation harness for the Advanced RAG agent.")
|
|
262
|
+
parser.add_argument("--store", choices=("in_memory", "opensearch"), default="in_memory")
|
|
263
|
+
parser.add_argument("--max-cases", type=int, default=10, help="How many labelled questions to evaluate.")
|
|
264
|
+
parser.add_argument("--case-seed", type=int, default=0, help="Selects which eval cases are drawn from the dataset.")
|
|
265
|
+
arguments = parser.parse_args()
|
|
266
|
+
|
|
267
|
+
store, articles = prepare_corpus(backend=arguments.store)
|
|
268
|
+
chunks = sum(len(article.chunks) for article in articles.values())
|
|
269
|
+
print(f"{CORPUS_KEY} on {arguments.store}: {chunks} chunks from {len(articles)} articles")
|
|
270
|
+
|
|
271
|
+
labelled: list[LabelledQuestion] = build_eval_cases(
|
|
272
|
+
articles=articles, limit=arguments.max_cases, seed=arguments.case_seed
|
|
273
|
+
)
|
|
274
|
+
# Budgets for the tools this agent has. Lenient on purpose: too many retrievals is better than too few.
|
|
275
|
+
# Generous on purpose: a MultiHopRAG question needs evidence from several articles, so several searches are
|
|
276
|
+
# the expected shape of a good run rather than a sign of floundering.
|
|
277
|
+
budgets: dict[str | tuple[str, ...], int] = {METADATA_TOOLS: 8, RETRIEVAL_TOOLS: 12}
|
|
278
|
+
eval_cases = [
|
|
279
|
+
EvalCase(question=question.question, evidence=question.evidence, tool_budgets=budgets) for question in labelled
|
|
280
|
+
]
|
|
281
|
+
print(f"eval cases: {len(eval_cases)} labelled from evidence")
|
|
282
|
+
|
|
283
|
+
# Build the advanced rag agent
|
|
284
|
+
agent = create_advanced_rag_agent(document_store=store, retriever=build_bm25_retriever(store=store))
|
|
285
|
+
|
|
286
|
+
# Run the eval cases
|
|
287
|
+
results = [
|
|
288
|
+
run_eval_case(agent=agent, case=case, position=position, total=len(eval_cases))
|
|
289
|
+
for position, case in enumerate(eval_cases, start=1)
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
# Calculate total usage
|
|
293
|
+
total_usage: dict[str, int] = {}
|
|
294
|
+
for result in results:
|
|
295
|
+
_sum_usage(total=total_usage, usage=result["usage"])
|
|
296
|
+
|
|
297
|
+
# Print the results
|
|
298
|
+
passed = sum(result["passed"] for result in results)
|
|
299
|
+
print(f"\n=== {passed}/{len(results)} eval cases passed ===")
|
|
300
|
+
print(f"total time: {sum(result['time'] for result in results):.1f}s")
|
|
301
|
+
if total_usage:
|
|
302
|
+
print(f"total tokens: {total_usage}")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
main()
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present deepset GmbH <info@deepset.ai>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
# Prepare the MultiHopRAG dataset as an evaluation set for harness optimization.
|
|
6
|
+
#
|
|
7
|
+
# The dataset pairs 609 news articles carrying real metadata — category, source, author, publication timestamp — with
|
|
8
|
+
# 2,556 labelled queries whose supporting evidence is quoted verbatim and attributed to its article.
|
|
9
|
+
#
|
|
10
|
+
# Articles are long — a median of 7,836 characters, up to 71,034. So articles are split by word with overlap.
|
|
11
|
+
#
|
|
12
|
+
# Splitting would normally blur the ground truth, since evidence is attributed to an article and not a chunk.
|
|
13
|
+
# So we ensure that a fact is contained wholly within a chunk. If a fact is split across two chunks, the query is
|
|
14
|
+
# excluded from the evaluation set.
|
|
15
|
+
#
|
|
16
|
+
# Run this module directly to build the corpus and report what it produced:
|
|
17
|
+
#
|
|
18
|
+
# hatch run test:python examples/multihop_rag.py
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import hashlib
|
|
22
|
+
import os
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any, Literal
|
|
25
|
+
|
|
26
|
+
from haystack import Document
|
|
27
|
+
from haystack.components.preprocessors import DocumentSplitter
|
|
28
|
+
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
|
29
|
+
from haystack.document_stores.types import DocumentStore, DuplicatePolicy
|
|
30
|
+
from haystack.lazy_imports import LazyImport
|
|
31
|
+
|
|
32
|
+
with LazyImport(message='Run "pip install datasets" to build the MultiHopRAG evaluation set.') as datasets_import:
|
|
33
|
+
from datasets import load_dataset
|
|
34
|
+
|
|
35
|
+
with LazyImport(message='Run "pip install opensearch-haystack" to use an OpenSearch store.') as opensearch_import:
|
|
36
|
+
from haystack_integrations.document_stores.opensearch import OpenSearchDocumentStore
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class LabelledQuestion:
|
|
41
|
+
"""
|
|
42
|
+
One question the dataset labels, with the evidence an answer needs.
|
|
43
|
+
|
|
44
|
+
:param question: The question as the dataset asks it.
|
|
45
|
+
:param evidence: The quoted evidence the answer is built from, by the chunk it was found in.
|
|
46
|
+
:param answer: The ground-truth answer, verbatim. How to check a reply against it is the harness's call: most
|
|
47
|
+
are a name or a number, but some are short words like "Yes", where a substring check may match for
|
|
48
|
+
reasons unrelated to the answer being right.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
question: str
|
|
52
|
+
evidence: dict[str, str]
|
|
53
|
+
answer: str = ""
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def expected_document_ids(self) -> frozenset[str]:
|
|
57
|
+
"""The chunks an answer needs, which are the ones its evidence was found in."""
|
|
58
|
+
return frozenset(self.evidence)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
DATASET_ID = "yixuantt/MultiHopRAG"
|
|
62
|
+
CORPUS_KEY = "multihop-rag"
|
|
63
|
+
|
|
64
|
+
# Word-based splitting with overlap. Measured over the whole dataset at this setting: no evidence fact is split
|
|
65
|
+
# across a chunk boundary, so every one of the 6,084 is contained wholly within a chunk. However, the overlap
|
|
66
|
+
# means 926 of them are present in two adjacent chunks, so a query can no longer retrieve just a single expected
|
|
67
|
+
# document, so the query is dropped rather than scored loosely. So we end up with 1,432 of the 2,255 queries as
|
|
68
|
+
# valid eval points.
|
|
69
|
+
SPLIT_BY = "word"
|
|
70
|
+
SPLIT_LENGTH = 350
|
|
71
|
+
SPLIT_OVERLAP = 90
|
|
72
|
+
|
|
73
|
+
# Carried from the article onto each of its chunks
|
|
74
|
+
ARTICLE_METADATA = ("title", "category", "source", "author", "published_at", "url")
|
|
75
|
+
|
|
76
|
+
# Queries whose evidence is present in the corpus. The dataset's fourth type, `null_query`, is deliberately
|
|
77
|
+
# excluded for now to focus on answerable question types.
|
|
78
|
+
ANSWERABLE_QUESTION_TYPES = ("comparison_query", "inference_query", "temporal_query")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Article:
|
|
83
|
+
"""One article's body and its chunks, in the order they appear in it."""
|
|
84
|
+
|
|
85
|
+
body: str
|
|
86
|
+
chunks: tuple[Document, ...]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_document_store(backend: str, index: str) -> DocumentStore:
|
|
90
|
+
"""
|
|
91
|
+
Build an empty in-memory or OpenSearch document store under a named index.
|
|
92
|
+
|
|
93
|
+
:param backend: Either "in_memory" or "opensearch".
|
|
94
|
+
:param index: Names the index to open or create.
|
|
95
|
+
:returns: The empty store.
|
|
96
|
+
"""
|
|
97
|
+
if backend == "opensearch":
|
|
98
|
+
opensearch_import.check()
|
|
99
|
+
url = os.environ.get("OPENSEARCH_URL", "http://localhost:9200")
|
|
100
|
+
return OpenSearchDocumentStore(
|
|
101
|
+
hosts=url,
|
|
102
|
+
index=index,
|
|
103
|
+
use_ssl=url.startswith("https"),
|
|
104
|
+
verify_certs=not url.startswith("https://localhost"),
|
|
105
|
+
)
|
|
106
|
+
return InMemoryDocumentStore(index=index)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def prepare_corpus(
|
|
110
|
+
backend: Literal["in_memory", "opensearch"] = "in_memory", index: str = CORPUS_KEY
|
|
111
|
+
) -> tuple[DocumentStore, dict[str, Article]]:
|
|
112
|
+
"""
|
|
113
|
+
Build the chunked corpus and index it, reusing an already-populated store.
|
|
114
|
+
|
|
115
|
+
:param backend: Either "in_memory" or "opensearch".
|
|
116
|
+
:param index: Names the index holding the corpus.
|
|
117
|
+
:returns: The populated store and every article by title, each with the chunks covering it.
|
|
118
|
+
"""
|
|
119
|
+
datasets_import.check()
|
|
120
|
+
store = build_document_store(backend=backend, index=index)
|
|
121
|
+
|
|
122
|
+
# Load the dataset, split the articles into chunks, and write them to the store.
|
|
123
|
+
documents = [
|
|
124
|
+
Document(content=row["body"], meta={field: row[field] for field in ARTICLE_METADATA})
|
|
125
|
+
for row in load_dataset(DATASET_ID, "corpus", split="train")
|
|
126
|
+
if (row["body"] or "").strip()
|
|
127
|
+
]
|
|
128
|
+
splitter = DocumentSplitter(split_by=SPLIT_BY, split_length=SPLIT_LENGTH, split_overlap=SPLIT_OVERLAP)
|
|
129
|
+
chunks = splitter.run(documents=documents)["documents"]
|
|
130
|
+
|
|
131
|
+
# Write only when the store is empty or the corpus changed, so a repeated run does not reindex.
|
|
132
|
+
if store.count_documents() != len(chunks):
|
|
133
|
+
store.write_documents(documents=chunks, policy=DuplicatePolicy.OVERWRITE)
|
|
134
|
+
|
|
135
|
+
# Group the chunks under the article they were split from, using the id the splitter records on each.
|
|
136
|
+
by_source: dict[str, list[Document]] = {}
|
|
137
|
+
for chunk in chunks:
|
|
138
|
+
by_source.setdefault(chunk.meta["source_id"], []).append(chunk)
|
|
139
|
+
articles = {
|
|
140
|
+
document.meta["title"]: Article(
|
|
141
|
+
body=document.content or "",
|
|
142
|
+
chunks=tuple(sorted(by_source.get(document.id, []), key=lambda chunk: chunk.meta["split_idx_start"])),
|
|
143
|
+
)
|
|
144
|
+
for document in documents
|
|
145
|
+
}
|
|
146
|
+
return store, articles
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _covering_chunks(article: Article, fact: str) -> list[Document] | None:
|
|
150
|
+
"""
|
|
151
|
+
Find the chunks a quoted evidence fact is contained in.
|
|
152
|
+
|
|
153
|
+
A quote appearing more than once is ambiguous evidence, so if this occurs an empty list is returned to indicate
|
|
154
|
+
that this datapoint should be excluded.
|
|
155
|
+
|
|
156
|
+
:param article: The article the fact is attributed to.
|
|
157
|
+
:param fact: The quoted evidence.
|
|
158
|
+
:returns: The chunks holding every occurrence of the fact, or None when the quote is not in the article at
|
|
159
|
+
all. An empty list when the quote is there but no single chunk holds all of it: it either spans a chunk boundary
|
|
160
|
+
or is repeated in multiple chunks.
|
|
161
|
+
"""
|
|
162
|
+
# Find all occurrences of the fact in the article.
|
|
163
|
+
occurrences = []
|
|
164
|
+
position = article.body.find(fact)
|
|
165
|
+
while position != -1:
|
|
166
|
+
occurrences.append(position)
|
|
167
|
+
position = article.body.find(fact, position + 1)
|
|
168
|
+
|
|
169
|
+
# If no occurrences we return None
|
|
170
|
+
if not occurrences:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
# Return all chunks that cover all occurrences of the fact. If a fact is split across two chunks, an empty list is
|
|
174
|
+
# returned.
|
|
175
|
+
return [
|
|
176
|
+
chunk
|
|
177
|
+
for chunk in article.chunks
|
|
178
|
+
if all(
|
|
179
|
+
chunk.meta["split_idx_start"] <= start
|
|
180
|
+
and start + len(fact) <= chunk.meta["split_idx_start"] + len(chunk.content or "")
|
|
181
|
+
for start in occurrences
|
|
182
|
+
)
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def exact_eval_case_candidates(articles: dict[str, Article]) -> dict[str, list[LabelledQuestion]]:
|
|
187
|
+
"""
|
|
188
|
+
Build every eval case whose expected documents can be stated exactly, grouped by question type.
|
|
189
|
+
|
|
190
|
+
A query is kept when each of its evidence facts is contained in exactly one chunk. Overlap puts some facts in
|
|
191
|
+
two adjacent chunks, and those queries are dropped.
|
|
192
|
+
|
|
193
|
+
:param articles: The corpus the eval cases will be scored against, by article title.
|
|
194
|
+
:returns: Eval cases per question type, ordered by question for reproducibility.
|
|
195
|
+
:raises ValueError: If any evidence fact is split across chunks, since no single chunk then holds it.
|
|
196
|
+
"""
|
|
197
|
+
datasets_import.check()
|
|
198
|
+
candidates: dict[str, list[LabelledQuestion]] = {name: [] for name in ANSWERABLE_QUESTION_TYPES}
|
|
199
|
+
split_across_chunks = 0
|
|
200
|
+
for row in load_dataset(DATASET_ID, "MultiHopRAG", split="train"):
|
|
201
|
+
if row["question_type"] not in candidates:
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
# Locate every fact this query cites. One chunk per fact keeps the query; anything else drops it.
|
|
205
|
+
evidence: dict[str, str] = {}
|
|
206
|
+
exact = True
|
|
207
|
+
for entry in row["evidence_list"]:
|
|
208
|
+
fact = entry["fact"]
|
|
209
|
+
holders = _covering_chunks(article=articles[entry["title"]], fact=fact)
|
|
210
|
+
# holders is [] when the fact is in the article but split across chunks, or present in several
|
|
211
|
+
split_across_chunks += holders == []
|
|
212
|
+
if holders is None or len(holders) != 1:
|
|
213
|
+
exact = False
|
|
214
|
+
continue
|
|
215
|
+
evidence[holders[0].id] = fact
|
|
216
|
+
|
|
217
|
+
if exact and evidence:
|
|
218
|
+
candidates[row["question_type"]].append(
|
|
219
|
+
LabelledQuestion(question=row["query"], evidence=evidence, answer=row["answer"])
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# The splitting settings decide this, so it is reachable by editing them.
|
|
223
|
+
if split_across_chunks:
|
|
224
|
+
msg = (
|
|
225
|
+
f"{split_across_chunks} evidence facts are present but split across chunks, so no single chunk holds "
|
|
226
|
+
f"them. Lower SPLIT_LENGTH or raise SPLIT_OVERLAP until every fact fits inside one chunk."
|
|
227
|
+
)
|
|
228
|
+
raise ValueError(msg)
|
|
229
|
+
return {name: sorted(cases, key=lambda case: case.question) for name, cases in candidates.items()}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def build_eval_cases(articles: dict[str, Article], limit: int, seed: int = 0) -> list[LabelledQuestion]:
|
|
233
|
+
"""
|
|
234
|
+
Build a reproducible set of eval cases, spread evenly across the question types.
|
|
235
|
+
|
|
236
|
+
:param articles: The corpus the eval cases will be scored against, by article title.
|
|
237
|
+
:param limit: How many eval cases to select. Each one costs an Agent run per candidate measured.
|
|
238
|
+
:param seed: Seed for the selection, so the same evaluation set is rebuilt every time.
|
|
239
|
+
:returns: The selected eval cases, interleaved by question type so a truncated set stays balanced.
|
|
240
|
+
:raises ValueError: If `limit` is below one.
|
|
241
|
+
"""
|
|
242
|
+
if limit < 1:
|
|
243
|
+
msg = "limit must be at least 1."
|
|
244
|
+
raise ValueError(msg)
|
|
245
|
+
candidates = exact_eval_case_candidates(articles=articles)
|
|
246
|
+
|
|
247
|
+
def rank(case: LabelledQuestion) -> str:
|
|
248
|
+
"""Order eval cases by a hash of the seed and the question, which the same seed reproduces exactly."""
|
|
249
|
+
return hashlib.sha256(f"{seed}:{case.question}".encode()).hexdigest()
|
|
250
|
+
|
|
251
|
+
# Sorted by a hash rather than seeded with `random`, whose ordering for a given seed can change between Python
|
|
252
|
+
# versions.
|
|
253
|
+
ordered = {name: sorted(cases, key=rank) for name, cases in candidates.items()}
|
|
254
|
+
|
|
255
|
+
# Take one eval case per question type in turn, so stopping at the limit still leaves the types balanced.
|
|
256
|
+
selected: list[LabelledQuestion] = []
|
|
257
|
+
for position in range(max(len(pool) for pool in ordered.values())):
|
|
258
|
+
for name in ANSWERABLE_QUESTION_TYPES:
|
|
259
|
+
if position < len(ordered[name]):
|
|
260
|
+
selected.append(ordered[name][position])
|
|
261
|
+
if len(selected) == limit:
|
|
262
|
+
return selected
|
|
263
|
+
return selected
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _report(store: DocumentStore, articles: dict[str, Article], cases: list[LabelledQuestion]) -> None:
|
|
267
|
+
"""Describe what was built, including the checks that make the eval cases scoreable."""
|
|
268
|
+
chunks = [chunk for article in articles.values() for chunk in article.chunks]
|
|
269
|
+
lengths = sorted(len(chunk.content or "") for chunk in chunks)
|
|
270
|
+
metadata: dict[str, Any] = {field: {chunk.meta.get(field) for chunk in chunks} for field in ARTICLE_METADATA}
|
|
271
|
+
print(f" articles: {len(articles)}")
|
|
272
|
+
print(f" chunks: {len(chunks)} indexed={store.count_documents()} (mean {sum(lengths) // len(lengths)} chars)")
|
|
273
|
+
print(
|
|
274
|
+
f" metadata: category={len(metadata['category'])} source={len(metadata['source'])} "
|
|
275
|
+
f"author={len(metadata['author'])} distinct values"
|
|
276
|
+
)
|
|
277
|
+
print(f" published: {min(metadata['published_at'])} .. {max(metadata['published_at'])}")
|
|
278
|
+
print(f"\n eval cases selected: {len(cases)}")
|
|
279
|
+
# Listed rather than given as a range, since the sizes present need not be contiguous.
|
|
280
|
+
sizes = sorted({len(case.expected_document_ids) for case in cases})
|
|
281
|
+
listed = str(sizes[0]) if len(sizes) == 1 else f"{', '.join(str(size) for size in sizes[:-1])} or {sizes[-1]}"
|
|
282
|
+
print(f" expected documents per eval case: {listed}")
|
|
283
|
+
print(f" with a ground-truth answer: {sum(1 for case in cases if case.answer)}")
|
|
284
|
+
print(" example questions:")
|
|
285
|
+
for case in cases[:3]:
|
|
286
|
+
print(f" - {case.question[:96]}")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def main() -> None:
|
|
290
|
+
"""Build the corpus and evaluation set, and report what came out."""
|
|
291
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
292
|
+
parser.add_argument("--store", choices=("in_memory", "opensearch"), default="in_memory")
|
|
293
|
+
parser.add_argument("--max-cases", type=int, default=20)
|
|
294
|
+
parser.add_argument("--case-seed", type=int, default=0)
|
|
295
|
+
arguments = parser.parse_args()
|
|
296
|
+
|
|
297
|
+
print(f"=== preparing {DATASET_ID} on {arguments.store} ===")
|
|
298
|
+
store, articles = prepare_corpus(backend=arguments.store)
|
|
299
|
+
cases = build_eval_cases(articles=articles, limit=arguments.max_cases, seed=arguments.case_seed)
|
|
300
|
+
_report(store=store, articles=articles, cases=cases)
|
|
301
|
+
|
|
302
|
+
available = exact_eval_case_candidates(articles=articles)
|
|
303
|
+
print(f"\n eval cases available in total: {sum(len(pool) for pool in available.values())}")
|
|
304
|
+
for name, pool in available.items():
|
|
305
|
+
print(f" {name:<20} {len(pool)}")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
if __name__ == "__main__":
|
|
309
|
+
main()
|