nace-haystack 0.1.0__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.
@@ -0,0 +1,247 @@
1
+ # SPDX-FileCopyrightText: 2026-present Nace AI
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import asyncio
6
+ import math
7
+ from typing import Any
8
+
9
+ import nace_sdk
10
+ from haystack import default_to_dict, logging
11
+ from haystack.utils import Secret
12
+
13
+ from haystack_integrations.common.nace.client import (
14
+ client_options,
15
+ is_openrouter,
16
+ make_async_client,
17
+ make_client,
18
+ provider_name,
19
+ resolve_base_url,
20
+ )
21
+ from haystack_integrations.common.nace.concurrency import map_in_threads
22
+ from haystack_integrations.common.nace.errors import readable_error
23
+ from haystack_integrations.common.nace.images import ImageInput, image_types
24
+ from haystack_integrations.common.nace.request import DrexResponse, build_call
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _QUESTION_ID = "verdict"
29
+
30
+ Outcome = tuple[dict[str, Any], dict[str, Any]]
31
+
32
+
33
+ class _NaceEvaluator:
34
+ """
35
+ Shared client lifecycle and evaluation loop for the Drex evaluators.
36
+
37
+ Each item is judged in its own request with one `noul` question; its score is Drex's probability that the
38
+ statement in `instructions` is true. The outputs match Haystack's built-in LLM evaluators.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ instructions: str,
45
+ model: str,
46
+ api_key: Secret,
47
+ api_base_url: str | None,
48
+ timeout: float | None,
49
+ max_retries: int | None,
50
+ max_workers: int,
51
+ raise_on_failure: bool,
52
+ ) -> None:
53
+ if max_workers < 1:
54
+ msg = f"`max_workers` must be at least 1, got {max_workers}."
55
+ raise ValueError(msg)
56
+ self.instructions = instructions
57
+ self.model = model
58
+ self.api_key = api_key
59
+ self.api_base_url = api_base_url
60
+ self.timeout = timeout
61
+ self.max_retries = max_retries
62
+ self.max_workers = max_workers
63
+ self.raise_on_failure = raise_on_failure
64
+ self._client: nace_sdk.NaceClient | None = None
65
+ self._async_client: nace_sdk.AsyncNaceClient | None = None
66
+ self._async_client_loop: asyncio.AbstractEventLoop | None = None
67
+
68
+ def _client_options(self) -> dict[str, Any]:
69
+ return client_options(self.api_key, self.api_base_url, self.timeout, self.max_retries)
70
+
71
+ def warm_up(self) -> None:
72
+ """
73
+ Creates the synchronous Nace client.
74
+ """
75
+ if self._client is None:
76
+ self._client = make_client(self._client_options())
77
+
78
+ async def warm_up_async(self) -> None:
79
+ """
80
+ Creates the asynchronous Nace client for the running event loop.
81
+ """
82
+ loop = asyncio.get_running_loop()
83
+ if self._async_client_loop is not None and self._async_client_loop is not loop:
84
+ # An async HTTP client belongs to the event loop it first ran on, so a new `asyncio.run` (a second
85
+ # notebook cell, a second script run) needs a new client. The old one can't be closed from this loop.
86
+ self._async_client = None
87
+ if self._async_client is None:
88
+ self._async_client = make_async_client(self._client_options())
89
+ self._async_client_loop = loop
90
+
91
+ def close(self) -> None:
92
+ """
93
+ Releases the synchronous Nace client.
94
+ """
95
+ if self._client is not None:
96
+ self._client.close()
97
+ self._client = None
98
+
99
+ async def close_async(self) -> None:
100
+ """
101
+ Releases the asynchronous Nace client.
102
+ """
103
+ if self._async_client is not None:
104
+ await self._async_client.aclose()
105
+ self._async_client = None
106
+ self._async_client_loop = None
107
+
108
+ def to_dict(self) -> dict[str, Any]:
109
+ """
110
+ Serializes the component to a dictionary.
111
+
112
+ :returns:
113
+ Dictionary with serialized data.
114
+ """
115
+ return default_to_dict(
116
+ self,
117
+ instructions=self.instructions,
118
+ model=self.model,
119
+ api_key=self.api_key,
120
+ api_base_url=self.api_base_url,
121
+ timeout=self.timeout,
122
+ max_retries=self.max_retries,
123
+ max_workers=self.max_workers,
124
+ raise_on_failure=self.raise_on_failure,
125
+ )
126
+
127
+ @staticmethod
128
+ def _check_contexts(contexts: list[list[str]]) -> None:
129
+ for index, context in enumerate(contexts):
130
+ if isinstance(context, str):
131
+ msg = (
132
+ "`contexts` must be a list of lists of strings, one list per question; "
133
+ f"item {index} is a single string. Wrap it in a list: [[...]]."
134
+ )
135
+ raise ValueError(msg)
136
+
137
+ @staticmethod
138
+ def _check_images(images: list[list[ImageInput]] | None) -> None:
139
+ for index, item_images in enumerate(images or ()):
140
+ if not isinstance(item_images, (list, tuple)):
141
+ msg = (
142
+ "`images` must hold one list of images per item, such as [[image], []]; "
143
+ f"item {index} is a single {type(item_images).__name__}. Wrap it in a list."
144
+ )
145
+ raise ValueError(msg)
146
+
147
+ @staticmethod
148
+ def _check_lengths(inputs: dict[str, list[Any] | None]) -> None:
149
+ lengths = {name: len(values) for name, values in inputs.items() if values is not None}
150
+ if len(set(lengths.values())) > 1:
151
+ names = ", ".join(f"`{name}` ({length})" for name, length in lengths.items())
152
+ msg = f"The inputs must have the same length: {names}."
153
+ raise ValueError(msg)
154
+
155
+ def _calls(
156
+ self, states: list[dict[str, Any]], images: list[list[ImageInput]] | None
157
+ ) -> list[tuple[dict[str, Any], list[ImageInput]]]:
158
+ # Built for every item before any request, so a bad model, image or size fails before anything is billed
159
+ openrouter = is_openrouter(resolve_base_url(self.api_base_url))
160
+ question = {_QUESTION_ID: {"type": "noul", "instructions": self.instructions}}
161
+ calls = []
162
+ for index, state in enumerate(states):
163
+ item_images = list(images[index]) if images is not None else []
164
+ call = build_call(state, question, model=self.model, images=item_images or None, openrouter=openrouter)
165
+ calls.append((call, item_images))
166
+ return calls
167
+
168
+ @staticmethod
169
+ def _judged(response: DrexResponse) -> Outcome:
170
+ probability = response.answers[_QUESTION_ID]["noul"]
171
+ meta = {
172
+ "model": response.model,
173
+ "usage": response.usage,
174
+ "request_id": (response.model_extra or {}).get("request_id"),
175
+ }
176
+ return {"score": probability, "noul": probability, "status": "evaluated"}, meta
177
+
178
+ def _failed(self, error: nace_sdk.NaceError, item_images: list[ImageInput]) -> Outcome:
179
+ readable = readable_error(
180
+ error, provider_name(resolve_base_url(self.api_base_url)), image_types=image_types(item_images)
181
+ )
182
+ if self.raise_on_failure:
183
+ raise readable from error
184
+ meta = {"model": None, "usage": None, "request_id": getattr(readable, "request_id", None)}
185
+ return {"score": math.nan, "status": "error", "error": str(readable)}, meta
186
+
187
+ @staticmethod
188
+ def _summarize(outcomes: list[Outcome]) -> dict[str, Any]:
189
+ results = [result for result, _ in outcomes]
190
+ scores = [result["score"] for result in results]
191
+ valid = [score for score in scores if not math.isnan(score)]
192
+ skipped = len(scores) - len(valid)
193
+ if skipped:
194
+ logger.warning("{skipped} item(s) failed and were excluded from the score.", skipped=skipped)
195
+ return {
196
+ "score": sum(valid) / len(valid) if valid else math.nan,
197
+ "individual_scores": scores,
198
+ "results": results,
199
+ "meta": [meta for _, meta in outcomes],
200
+ }
201
+
202
+ def _evaluate(self, states: list[dict[str, Any]], images: list[list[ImageInput]] | None) -> dict[str, Any]:
203
+ calls = self._calls(states, images)
204
+ if not calls:
205
+ return self._summarize([])
206
+ self.warm_up()
207
+ assert self._client is not None # noqa: S101 # mypy: the client is created by warm_up above
208
+ client = self._client
209
+
210
+ def judge(call: dict[str, Any], item_images: list[ImageInput]) -> Outcome:
211
+ try:
212
+ return self._judged(client.system_one(**call))
213
+ except nace_sdk.NaceError as error:
214
+ return self._failed(error, item_images)
215
+
216
+ outcomes = map_in_threads(lambda pair: judge(*pair), calls, self.max_workers)
217
+ return self._summarize(outcomes)
218
+
219
+ async def _evaluate_async(
220
+ self, states: list[dict[str, Any]], images: list[list[ImageInput]] | None
221
+ ) -> dict[str, Any]:
222
+ calls = self._calls(states, images)
223
+ if not calls:
224
+ return self._summarize([])
225
+ await self.warm_up_async()
226
+ assert self._async_client is not None # noqa: S101 # mypy: the client is created by warm_up_async above
227
+ client = self._async_client
228
+ semaphore = asyncio.Semaphore(self.max_workers)
229
+
230
+ async def judge(call: dict[str, Any], item_images: list[ImageInput]) -> Outcome:
231
+ try:
232
+ async with semaphore:
233
+ response = await client.system_one(**call)
234
+ except nace_sdk.NaceError as error:
235
+ return self._failed(error, item_images)
236
+ return self._judged(response)
237
+
238
+ tasks = [asyncio.create_task(judge(call, item_images)) for call, item_images in calls]
239
+ try:
240
+ outcomes = await asyncio.gather(*tasks)
241
+ except Exception:
242
+ # Only reached when `raise_on_failure` is `True`: cancel the remaining requests before re-raising
243
+ for task in tasks:
244
+ task.cancel()
245
+ await asyncio.gather(*tasks, return_exceptions=True)
246
+ raise
247
+ return self._summarize(list(outcomes))
@@ -0,0 +1,139 @@
1
+ # SPDX-FileCopyrightText: 2026-present Nace AI
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from typing import Any
6
+
7
+ from haystack import component
8
+ from haystack.utils import Secret
9
+
10
+ from haystack_integrations.common.nace.client import DEFAULT_MODEL
11
+ from haystack_integrations.common.nace.images import ImageInput
12
+
13
+ from ._base import _NaceEvaluator
14
+
15
+ CONTEXT_RELEVANCE_INSTRUCTIONS = "Do the contexts contain information that helps answer the question?"
16
+
17
+
18
+ @component
19
+ class NaceContextRelevanceEvaluator(_NaceEvaluator):
20
+ """
21
+ Judges whether the retrieved contexts help answer each question, with a Drex decision model.
22
+
23
+ A drop-in for Haystack's `ContextRelevanceEvaluator`: same inputs and outputs, but each item is one Drex request
24
+ that returns the probability that the contexts help answer the question.
25
+
26
+ ### Usage example
27
+
28
+ ```python
29
+ from haystack_integrations.components.evaluators.nace import NaceContextRelevanceEvaluator
30
+
31
+ evaluator = NaceContextRelevanceEvaluator()
32
+ result = evaluator.run(questions=["Who won the race?"], contexts=[["Ada won the 2026 city race."]])
33
+ print(result["score"])
34
+ # 0.98
35
+ ```
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ instructions: str = CONTEXT_RELEVANCE_INSTRUCTIONS,
42
+ model: str = DEFAULT_MODEL,
43
+ api_key: Secret = Secret.from_env_var("NACE_API_KEY"),
44
+ api_base_url: str | None = None,
45
+ timeout: float | None = None,
46
+ max_retries: int | None = None,
47
+ max_workers: int = 3,
48
+ raise_on_failure: bool = True,
49
+ ) -> None:
50
+ """
51
+ Creates a NaceContextRelevanceEvaluator.
52
+
53
+ :param instructions:
54
+ The yes/no question Drex answers about each item.
55
+ :param model:
56
+ The Drex model to use. Defaults to the newest Drex model; pin it in production.
57
+ :param api_key:
58
+ The Nace API key.
59
+ :param api_base_url:
60
+ Base URL of the API. If `None`, the `NACE_BASE_URL` environment variable is used, or
61
+ `https://console.nace.ai` if it is unset.
62
+ :param timeout:
63
+ Timeout in seconds for each request attempt. If `None`, 60 seconds are used.
64
+ :param max_retries:
65
+ Maximum number of retries after a failed request attempt. If `None`, the SDK default of 2 is used.
66
+ :param max_workers:
67
+ Maximum number of requests running at once. Must be at least 1.
68
+ :param raise_on_failure:
69
+ If `True`, the first failed request raises its error. If `False`, a failed item scores NaN, is marked
70
+ with `status: "error"` in `results`, and is left out of the mean `score`.
71
+ """
72
+ # @component rebuilds the class, so zero-argument super() would point at the undecorated one
73
+ _NaceEvaluator.__init__(
74
+ self,
75
+ instructions=instructions,
76
+ model=model,
77
+ api_key=api_key,
78
+ api_base_url=api_base_url,
79
+ timeout=timeout,
80
+ max_retries=max_retries,
81
+ max_workers=max_workers,
82
+ raise_on_failure=raise_on_failure,
83
+ )
84
+
85
+ def _states(
86
+ self, questions: list[str], contexts: list[list[str]], images: list[list[ImageInput]] | None
87
+ ) -> list[dict[str, Any]]:
88
+ self._check_contexts(contexts)
89
+ self._check_images(images)
90
+ self._check_lengths({"questions": questions, "contexts": contexts, "images": images})
91
+ return [
92
+ {"question": question, "contexts": list(context)}
93
+ for question, context in zip(questions, contexts, strict=True)
94
+ ]
95
+
96
+ @component.output_types(
97
+ score=float, individual_scores=list[float], results=list[dict[str, Any]], meta=list[dict[str, Any]] | None
98
+ )
99
+ def run(
100
+ self, questions: list[str], contexts: list[list[str]], images: list[list[ImageInput]] | None = None
101
+ ) -> dict[str, Any]:
102
+ """
103
+ Judges whether each question's contexts help answer it.
104
+
105
+ :param questions:
106
+ The questions.
107
+ :param contexts:
108
+ The contexts retrieved for each question.
109
+ :param images:
110
+ Optional images for each item, which Drex Max reads as "Image 1", "Image 2" and so on.
111
+ :returns:
112
+ A dictionary with `score`, `individual_scores`, `results` and `meta`, as in Haystack's
113
+ `ContextRelevanceEvaluator`; failed items score NaN when `raise_on_failure` is `False`.
114
+ :raises ValueError:
115
+ If the inputs have different lengths, or images are passed to a model that reads text only.
116
+ """
117
+ return self._evaluate(self._states(questions, contexts, images), images)
118
+
119
+ @component.output_types(
120
+ score=float, individual_scores=list[float], results=list[dict[str, Any]], meta=list[dict[str, Any]] | None
121
+ )
122
+ async def run_async(
123
+ self, questions: list[str], contexts: list[list[str]], images: list[list[ImageInput]] | None = None
124
+ ) -> dict[str, Any]:
125
+ """
126
+ Asynchronously judges whether each question's contexts help answer it.
127
+
128
+ This is the asynchronous version of the `run` method with the same parameters and return values.
129
+
130
+ :param questions:
131
+ The questions.
132
+ :param contexts:
133
+ The contexts retrieved for each question.
134
+ :param images:
135
+ Optional images for each item.
136
+ :returns:
137
+ The same dictionary as `run`.
138
+ """
139
+ return await self._evaluate_async(self._states(questions, contexts, images), images)
@@ -0,0 +1,169 @@
1
+ # SPDX-FileCopyrightText: 2026-present Nace AI
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from typing import Any
6
+
7
+ from haystack import component
8
+ from haystack.utils import Secret
9
+
10
+ from haystack_integrations.common.nace.client import DEFAULT_MODEL
11
+ from haystack_integrations.common.nace.images import ImageInput
12
+
13
+ from ._base import _NaceEvaluator
14
+
15
+ FAITHFULNESS_INSTRUCTIONS = (
16
+ "Is every claim in the answer supported by the contexts? "
17
+ "Answer no if any claim is missing from the contexts or contradicts them."
18
+ )
19
+
20
+
21
+ @component
22
+ class NaceFaithfulnessEvaluator(_NaceEvaluator):
23
+ """
24
+ Judges whether each answer is supported by its contexts, with a Drex decision model.
25
+
26
+ A drop-in for Haystack's `FaithfulnessEvaluator`: same inputs and outputs, but each item is one Drex request
27
+ that returns the probability that every claim in the answer is supported by the contexts, instead of an LLM
28
+ splitting the answer into statements. Pass `images` to judge answers about images on Drex Max.
29
+
30
+ ### Usage example
31
+
32
+ ```python
33
+ from haystack_integrations.components.evaluators.nace import NaceFaithfulnessEvaluator
34
+
35
+ evaluator = NaceFaithfulnessEvaluator()
36
+ result = evaluator.run(
37
+ questions=["Who won the race?"],
38
+ contexts=[["Ada won the 2026 city race."]],
39
+ predicted_answers=["Ada won the race."],
40
+ )
41
+ print(result["individual_scores"])
42
+ # [0.97]
43
+ ```
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ *,
49
+ instructions: str = FAITHFULNESS_INSTRUCTIONS,
50
+ model: str = DEFAULT_MODEL,
51
+ api_key: Secret = Secret.from_env_var("NACE_API_KEY"),
52
+ api_base_url: str | None = None,
53
+ timeout: float | None = None,
54
+ max_retries: int | None = None,
55
+ max_workers: int = 3,
56
+ raise_on_failure: bool = True,
57
+ ) -> None:
58
+ """
59
+ Creates a NaceFaithfulnessEvaluator.
60
+
61
+ :param instructions:
62
+ The yes/no question Drex answers about each item. The default is strict: an answer with any unsupported
63
+ claim is unfaithful.
64
+ :param model:
65
+ The Drex model to use. Defaults to the newest Drex model; pin it in production.
66
+ :param api_key:
67
+ The Nace API key.
68
+ :param api_base_url:
69
+ Base URL of the API. If `None`, the `NACE_BASE_URL` environment variable is used, or
70
+ `https://console.nace.ai` if it is unset.
71
+ :param timeout:
72
+ Timeout in seconds for each request attempt. If `None`, 60 seconds are used.
73
+ :param max_retries:
74
+ Maximum number of retries after a failed request attempt. If `None`, the SDK default of 2 is used.
75
+ :param max_workers:
76
+ Maximum number of requests running at once. Must be at least 1.
77
+ :param raise_on_failure:
78
+ If `True`, the first failed request raises its error. If `False`, a failed item scores NaN, is marked
79
+ with `status: "error"` in `results`, and is left out of the mean `score`.
80
+ """
81
+ # @component rebuilds the class, so zero-argument super() would point at the undecorated one
82
+ _NaceEvaluator.__init__(
83
+ self,
84
+ instructions=instructions,
85
+ model=model,
86
+ api_key=api_key,
87
+ api_base_url=api_base_url,
88
+ timeout=timeout,
89
+ max_retries=max_retries,
90
+ max_workers=max_workers,
91
+ raise_on_failure=raise_on_failure,
92
+ )
93
+
94
+ def _states(
95
+ self,
96
+ questions: list[str],
97
+ contexts: list[list[str]],
98
+ predicted_answers: list[str],
99
+ images: list[list[ImageInput]] | None,
100
+ ) -> list[dict[str, Any]]:
101
+ self._check_contexts(contexts)
102
+ self._check_images(images)
103
+ self._check_lengths(
104
+ {"questions": questions, "contexts": contexts, "predicted_answers": predicted_answers, "images": images}
105
+ )
106
+ return [
107
+ {"question": question, "contexts": list(context), "answer": answer}
108
+ for question, context, answer in zip(questions, contexts, predicted_answers, strict=True)
109
+ ]
110
+
111
+ @component.output_types(
112
+ score=float, individual_scores=list[float], results=list[dict[str, Any]], meta=list[dict[str, Any]] | None
113
+ )
114
+ def run(
115
+ self,
116
+ questions: list[str],
117
+ contexts: list[list[str]],
118
+ predicted_answers: list[str],
119
+ images: list[list[ImageInput]] | None = None,
120
+ ) -> dict[str, Any]:
121
+ """
122
+ Judges each answer against its contexts.
123
+
124
+ :param questions:
125
+ The questions.
126
+ :param contexts:
127
+ The contexts for each question.
128
+ :param predicted_answers:
129
+ The answer to each question.
130
+ :param images:
131
+ Optional images for each item, which Drex Max reads as "Image 1", "Image 2" and so on.
132
+ :returns:
133
+ A dictionary with the following keys:
134
+ - `score`: The mean of the item scores, leaving out failed items.
135
+ - `individual_scores`: Drex's probability that each answer is faithful, NaN for a failed item.
136
+ - `results`: Per item, `score`, `noul` and `status`, or `status: "error"` and `error`.
137
+ - `meta`: Per item, the `model`, `usage` and `request_id`.
138
+ :raises ValueError:
139
+ If the inputs have different lengths, or images are passed to a model that reads text only.
140
+ """
141
+ return self._evaluate(self._states(questions, contexts, predicted_answers, images), images)
142
+
143
+ @component.output_types(
144
+ score=float, individual_scores=list[float], results=list[dict[str, Any]], meta=list[dict[str, Any]] | None
145
+ )
146
+ async def run_async(
147
+ self,
148
+ questions: list[str],
149
+ contexts: list[list[str]],
150
+ predicted_answers: list[str],
151
+ images: list[list[ImageInput]] | None = None,
152
+ ) -> dict[str, Any]:
153
+ """
154
+ Asynchronously judges each answer against its contexts.
155
+
156
+ This is the asynchronous version of the `run` method with the same parameters and return values.
157
+
158
+ :param questions:
159
+ The questions.
160
+ :param contexts:
161
+ The contexts for each question.
162
+ :param predicted_answers:
163
+ The answer to each question.
164
+ :param images:
165
+ Optional images for each item.
166
+ :returns:
167
+ The same dictionary as `run`.
168
+ """
169
+ return await self._evaluate_async(self._states(questions, contexts, predicted_answers, images), images)
File without changes
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2026-present Nace AI
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from .text_router import NaceTextRouter
6
+
7
+ __all__ = ["NaceTextRouter"]