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,144 @@
1
+ # SPDX-FileCopyrightText: 2026-present Nace AI
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import json
6
+ from collections.abc import Mapping, Sequence
7
+ from typing import Any, TypeAlias
8
+
9
+ import nace_sdk
10
+ from pydantic import BaseModel, ConfigDict
11
+
12
+ from haystack_integrations.common.nace.client import is_text_only
13
+ from haystack_integrations.common.nace.images import MAX_REQUEST_BYTES, ImageInput, fit_images, to_wire_image
14
+
15
+ Question: TypeAlias = Mapping[str, Any] | nace_sdk.Choice | nace_sdk.Score | nace_sdk.Noul
16
+
17
+ # Room for the commas and quotes around each image in the request body.
18
+ _JSON_HEADROOM = 64 * 1024
19
+
20
+
21
+ class DrexResponse(BaseModel):
22
+ """
23
+ A Drex response read as plain data.
24
+
25
+ nace-sdk's own response classes drop fields and answer types they don't know, so answers are kept as the dicts
26
+ Drex sends: new answer types and fields reach the caller without a new release of this package.
27
+ """
28
+
29
+ model_config = ConfigDict(extra="allow")
30
+
31
+ model: str | None = None
32
+ answers: dict[str, dict[str, Any]]
33
+ usage: dict[str, Any] | None = None
34
+
35
+
36
+ def _as_dict(question: Any) -> Any:
37
+ # nace-sdk question objects, and those of compatible SDKs such as typesafe_sdk, are pydantic models
38
+ dump = getattr(question, "model_dump", None)
39
+ return dump() if callable(dump) and not isinstance(question, Mapping) else question
40
+
41
+
42
+ def validate_questions(questions: Mapping[str, Question]) -> None:
43
+ """
44
+ Checks the structure of the questions. Their content is checked by Drex.
45
+
46
+ Question types other than `choice`, `score` and `noul` are accepted, so new Drex question types work without a
47
+ new release of this package.
48
+
49
+ :param questions: Questions keyed by question ID.
50
+ :raises ValueError: If `questions` is empty, a question has no `type`, or a `choice` or `score` question has no
51
+ `criteria`.
52
+ """
53
+ if not questions:
54
+ msg = "`questions` must contain at least one question."
55
+ raise ValueError(msg)
56
+ for question_id, question in questions.items():
57
+ # SDK question objects are validated by the SDK when they are created
58
+ if isinstance(question, (nace_sdk.Choice, nace_sdk.Score, nace_sdk.Noul)):
59
+ continue
60
+ fields = _as_dict(question)
61
+ if not isinstance(fields, Mapping):
62
+ msg = f"Question '{question_id}' must be a dict or a question object, got {type(fields).__name__}."
63
+ raise ValueError(msg)
64
+ question_type = fields.get("type")
65
+ if not isinstance(question_type, str) or not question_type:
66
+ msg = f"Question '{question_id}' needs a `type`, such as 'choice', 'score' or 'noul'."
67
+ raise ValueError(msg)
68
+ if question_type in ("choice", "score") and not fields.get("criteria"):
69
+ msg = f"Question '{question_id}' of type '{question_type}' needs `criteria`."
70
+ raise ValueError(msg)
71
+
72
+
73
+ def to_wire(question: Question) -> dict[str, Any]:
74
+ """
75
+ Returns a question as Drex reads it.
76
+
77
+ `instructions` is always a string (`""` when omitted), because Drex rejects `null` and OpenRouter requires the
78
+ key. `None` choice descriptions are kept. Questions of other types are sent as given.
79
+
80
+ :param question: A question dict, or an SDK `Choice`, `Score` or `Noul`.
81
+ :returns: The question as a JSON-ready dict.
82
+ """
83
+ wire = dict(_as_dict(question))
84
+ instructions = wire.get("instructions")
85
+ if instructions is None:
86
+ wire["instructions"] = ""
87
+ elif not isinstance(instructions, str):
88
+ wire["instructions"] = json.dumps(instructions)
89
+ if wire.get("type") == "noul" and "criteria" in wire:
90
+ criteria = {side: text for side, text in (wire["criteria"] or {}).items() if text is not None}
91
+ if criteria:
92
+ wire["criteria"] = criteria
93
+ else:
94
+ del wire["criteria"]
95
+ return wire
96
+
97
+
98
+ def build_call(
99
+ state: Any,
100
+ questions: Mapping[str, Question],
101
+ *,
102
+ model: str,
103
+ images: Sequence[ImageInput] | None,
104
+ openrouter: bool,
105
+ ) -> dict[str, Any]:
106
+ """
107
+ Builds the keyword arguments for `system_one` on a nace-sdk client.
108
+
109
+ :param state: The text or JSON the questions are about.
110
+ :param questions: Questions keyed by question ID.
111
+ :param model: The model ID, sent as given.
112
+ :param images: Images for models that read them, in the order the prompt refers to them as "Image 1", "Image 2".
113
+ :param openrouter: Whether the request goes to OpenRouter, which takes images inside `state`.
114
+ :returns: The `system_one` keyword arguments, including `response_model=DrexResponse`.
115
+ :raises ValueError: If images are sent to a text-only model, or an OpenRouter model ID has no provider prefix.
116
+ """
117
+ if openrouter and "/" not in model:
118
+ msg = f"OpenRouter names models with a provider prefix: use model='nace-ai/{model}'."
119
+ raise ValueError(msg)
120
+ if images and is_text_only(model):
121
+ msg = f"{model} reads text only, so it can't take images. Use an image model such as model='drex-max-v1.1'."
122
+ raise ValueError(msg)
123
+
124
+ wire_questions = {question_id: to_wire(question) for question_id, question in questions.items()}
125
+ wire_images = [to_wire_image(image, openrouter=openrouter) for image in images or ()]
126
+ if wire_images:
127
+ body = json.dumps({"model": model, "state": state, "questions": wire_questions, "images": []})
128
+ wire_images = fit_images(wire_images, MAX_REQUEST_BYTES - len(body) - _JSON_HEADROOM)
129
+ if openrouter and wire_images:
130
+ state, wire_images = _openrouter_state(state, wire_images), []
131
+ return {
132
+ "state": state,
133
+ "questions": wire_questions,
134
+ "model": model,
135
+ "images": wire_images or None,
136
+ "response_model": DrexResponse,
137
+ }
138
+
139
+
140
+ def _openrouter_state(state: Any, images: list[str | dict[str, str]]) -> list[dict[str, Any]]:
141
+ # OpenRouter rejects a top-level `images` field and takes images as `image_url` parts of a `state` array
142
+ parts: list[dict[str, Any]] = [{"type": "text", "text": state if isinstance(state, str) else json.dumps(state)}]
143
+ parts.extend({"type": "image_url", "image_url": {"url": image}} for image in images)
144
+ return parts
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 .document_classifier import NaceDocumentClassifier
6
+
7
+ __all__ = ["NaceDocumentClassifier"]
@@ -0,0 +1,390 @@
1
+ # SPDX-FileCopyrightText: 2026-present Nace AI
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # Mirrors deepset's TypeSafeDocumentClassifier (typesafe-haystack) so a Jev pipeline switches to Drex by changing the
6
+ # import, the class name and the API key.
7
+
8
+ import asyncio
9
+ from dataclasses import replace
10
+ from typing import Any
11
+
12
+ import nace_sdk
13
+ from haystack import Document, component, default_to_dict, logging
14
+ from haystack.utils import Secret
15
+
16
+ from haystack_integrations.common.nace.client import (
17
+ DEFAULT_MODEL,
18
+ client_options,
19
+ is_openrouter,
20
+ make_async_client,
21
+ make_client,
22
+ provider_name,
23
+ resolve_base_url,
24
+ )
25
+ from haystack_integrations.common.nace.concurrency import map_in_threads
26
+ from haystack_integrations.common.nace.errors import readable_error
27
+ from haystack_integrations.common.nace.request import DrexResponse, Question, build_call, validate_questions
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ _ERROR_FIELD = "classification_error"
32
+
33
+
34
+ @component
35
+ class NaceDocumentClassifier:
36
+ """
37
+ Answers typed questions about each document with a Drex decision model and stores the answers in its metadata.
38
+
39
+ Drex models don't generate text: they answer every question in one request and return calibrated probabilities.
40
+ Each question has a type, such as:
41
+ - `choice`: picks one label from `criteria`, a dict of label to description (or `None`).
42
+ - `score`: rates the text on the ordered levels in `criteria`, a list of level descriptions.
43
+ - `noul`: returns the probability that the yes/no question in `instructions` is true.
44
+
45
+ Answers are stored under `meta[metadata_field]`, keyed by question ID, exactly as Drex returns them, with every
46
+ probability. Questions can be dicts or nace-sdk `Choice`, `Score` and `Noul` objects. See the
47
+ [Drex documentation](https://console.nace.ai/docs) for the full question and answer format.
48
+
49
+ Documents without text to classify are returned in `failed_documents` with the reason in
50
+ `meta["classification_error"]`. So are documents whose request failed, unless `raise_on_failure` is `True`.
51
+
52
+ The component also works with OpenRouter and other TypeSafe-compatible hosts: set `api_base_url` to the server
53
+ and `model` to one of its models.
54
+
55
+ ### Usage example
56
+
57
+ ```python
58
+ from haystack import Document
59
+
60
+ from haystack_integrations.components.classifiers.nace import NaceDocumentClassifier
61
+
62
+ classifier = NaceDocumentClassifier(
63
+ questions={
64
+ "department": {
65
+ "type": "choice",
66
+ "instructions": "Which department should handle this ticket?",
67
+ "criteria": {"billing": None, "technical": None, "sales": None},
68
+ },
69
+ "urgency": {
70
+ "type": "score",
71
+ "instructions": "How urgent is this ticket?",
72
+ "criteria": ["can wait", "this week", "today"],
73
+ },
74
+ "refund": {"type": "noul", "instructions": "Is the customer asking for a refund?"},
75
+ }
76
+ )
77
+
78
+ result = classifier.run(documents=[Document(content="I was charged twice, please refund me today.")])
79
+ print(result["documents"][0].meta["nace"])
80
+ # {'department': {'type': 'choice', 'choice': 'billing', 'confidence': 0.97,
81
+ # 'probabilities': {'billing': 0.98, 'technical': 0.01, 'sales': 0.01}},
82
+ # 'urgency': {'type': 'score', 'score': 1.93, 'confidence': 0.94,
83
+ # 'legend': {'0': 'can wait', '1': 'this week', '2': 'today'},
84
+ # 'probabilities': {'0': 0.01, '1': 0.05, '2': 0.94}},
85
+ # 'refund': {'type': 'noul', 'noul': 0.96}}
86
+ print(result["failed_documents"])
87
+ # []
88
+ ```
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ questions: dict[str, Question],
94
+ *,
95
+ model: str = DEFAULT_MODEL,
96
+ api_key: Secret = Secret.from_env_var("NACE_API_KEY"),
97
+ api_base_url: str | None = None,
98
+ classification_field: str | None = None,
99
+ metadata_field: str = "nace",
100
+ timeout: float | None = None,
101
+ max_retries: int | None = None,
102
+ max_workers: int = 3,
103
+ raise_on_failure: bool = False,
104
+ ) -> None:
105
+ """
106
+ Creates a NaceDocumentClassifier.
107
+
108
+ :param questions:
109
+ Questions to answer for every document, keyed by question ID. Each value is a dict or a nace-sdk
110
+ `Choice`, `Score` or `Noul` object with a `type`, `instructions`, and for `choice` and `score` questions,
111
+ `criteria`. Question types Drex adds later can be passed as dicts.
112
+ :param model:
113
+ The Drex model to use. Defaults to the newest Drex model; pin a model such as `"drex-max-v1.1"` or
114
+ `"drex-v2.0"` in production. `drex-v2.0` reads text only and bills fewer tokens on short texts.
115
+ :param api_key:
116
+ The Nace API key.
117
+ :param api_base_url:
118
+ Base URL of the API. If `None`, the `NACE_BASE_URL` environment variable is used, or
119
+ `https://console.nace.ai` if it is unset.
120
+ :param classification_field:
121
+ Name of the document's metadata field to classify. If `None`, `Document.content` is classified.
122
+ :param metadata_field:
123
+ Name of the metadata field the answers are written to.
124
+ :param timeout:
125
+ Timeout in seconds for each request attempt. If `None`, 60 seconds are used, because Drex may take up to
126
+ 55 seconds to answer.
127
+ :param max_retries:
128
+ Maximum number of retries after a failed request attempt. If `None`, the SDK default of 2 is used.
129
+ :param max_workers:
130
+ Maximum number of worker threads in `run` and of concurrent requests in `run_async`. Must be at least 1.
131
+ Free accounts allow 8 requests in flight and paid accounts 16.
132
+ :param raise_on_failure:
133
+ If `True`, the first failed request raises its error. If `False`, documents whose request failed are
134
+ returned in `failed_documents`.
135
+ :raises ValueError:
136
+ If `questions` is empty, a question has no `type`, a `choice` or `score` question has no `criteria`, or
137
+ `max_workers` is smaller than 1.
138
+ """
139
+ validate_questions(questions)
140
+ if max_workers < 1:
141
+ msg = f"`max_workers` must be at least 1, got {max_workers}."
142
+ raise ValueError(msg)
143
+
144
+ self.questions = questions
145
+ self.model = model
146
+ self.api_key = api_key
147
+ self.api_base_url = api_base_url
148
+ self.classification_field = classification_field
149
+ self.metadata_field = metadata_field
150
+ self.timeout = timeout
151
+ self.max_retries = max_retries
152
+ self.max_workers = max_workers
153
+ self.raise_on_failure = raise_on_failure
154
+ self._client: nace_sdk.NaceClient | None = None
155
+ self._async_client: nace_sdk.AsyncNaceClient | None = None
156
+ self._async_client_loop: asyncio.AbstractEventLoop | None = None
157
+
158
+ def _client_options(self) -> dict[str, Any]:
159
+ return client_options(self.api_key, self.api_base_url, self.timeout, self.max_retries)
160
+
161
+ def warm_up(self) -> None:
162
+ """
163
+ Creates the synchronous Nace client.
164
+ """
165
+ if self._client is None:
166
+ self._client = make_client(self._client_options())
167
+
168
+ async def warm_up_async(self) -> None:
169
+ """
170
+ Creates the asynchronous Nace client for the running event loop.
171
+ """
172
+ loop = asyncio.get_running_loop()
173
+ if self._async_client_loop is not None and self._async_client_loop is not loop:
174
+ # An async HTTP client belongs to the event loop it first ran on, so a new `asyncio.run` (a second
175
+ # notebook cell, a second script run) needs a new client. The old one can't be closed from this loop.
176
+ self._async_client = None
177
+ if self._async_client is None:
178
+ self._async_client = make_async_client(self._client_options())
179
+ self._async_client_loop = loop
180
+
181
+ def close(self) -> None:
182
+ """
183
+ Releases the synchronous Nace client.
184
+ """
185
+ if self._client is not None:
186
+ self._client.close()
187
+ self._client = None
188
+
189
+ async def close_async(self) -> None:
190
+ """
191
+ Releases the asynchronous Nace client.
192
+ """
193
+ if self._async_client is not None:
194
+ await self._async_client.aclose()
195
+ self._async_client = None
196
+ self._async_client_loop = None
197
+
198
+ def to_dict(self) -> dict[str, Any]:
199
+ """
200
+ Serializes the component to a dictionary.
201
+
202
+ :returns:
203
+ Dictionary with serialized data.
204
+ """
205
+ return default_to_dict(
206
+ self,
207
+ questions={
208
+ question_id: question.model_dump() if hasattr(question, "model_dump") else question
209
+ for question_id, question in self.questions.items()
210
+ },
211
+ model=self.model,
212
+ api_key=self.api_key,
213
+ api_base_url=self.api_base_url,
214
+ classification_field=self.classification_field,
215
+ metadata_field=self.metadata_field,
216
+ timeout=self.timeout,
217
+ max_retries=self.max_retries,
218
+ max_workers=self.max_workers,
219
+ raise_on_failure=self.raise_on_failure,
220
+ )
221
+
222
+ def _text(self, document: Document) -> Any:
223
+ return document.content if self.classification_field is None else document.meta.get(self.classification_field)
224
+
225
+ def _call(self, text: Any) -> dict[str, Any]:
226
+ base_url = resolve_base_url(self.api_base_url)
227
+ return build_call(text, self.questions, model=self.model, images=None, openrouter=is_openrouter(base_url))
228
+
229
+ def _succeed(self, document: Document, response: DrexResponse) -> tuple[Document, bool]:
230
+ """
231
+ Adds the answers from a Drex response to a copy of the document.
232
+
233
+ :param document:
234
+ The classified document.
235
+ :param response:
236
+ The Drex response for the document.
237
+ :returns:
238
+ A copy of the document with the answers under `meta[metadata_field]` and no `classification_error`, and
239
+ `True` to mark it as classified.
240
+ """
241
+ # Answers are the JSON Drex sent, so they stay serializable and keep fields added in later Drex releases
242
+ meta = {key: value for key, value in document.meta.items() if key != _ERROR_FIELD}
243
+ return replace(document, meta={**meta, self.metadata_field: response.answers}), True
244
+
245
+ def _fail(self, document: Document, error: nace_sdk.NaceError) -> tuple[Document, bool]:
246
+ """
247
+ Records a failed request on a copy of the document, or raises it.
248
+
249
+ :param document:
250
+ The document whose request failed.
251
+ :param error:
252
+ The error nace-sdk raised.
253
+ :returns:
254
+ A copy of the document with the readable error under `meta["classification_error"]`, and `False`.
255
+ :raises nace_sdk.NaceError:
256
+ The readable error, if `raise_on_failure` is `True`.
257
+ """
258
+ readable = readable_error(error, provider_name(resolve_base_url(self.api_base_url)))
259
+ if self.raise_on_failure:
260
+ raise readable from error
261
+ logger.warning("Classifying document {document_id} failed: {error}", document_id=document.id, error=readable)
262
+ return replace(document, meta={**document.meta, _ERROR_FIELD: str(readable)}), False
263
+
264
+ def _classify(self, client: nace_sdk.NaceClient, document: Document) -> tuple[Document, bool]:
265
+ """
266
+ Sends one document to Drex and adds the answers to a copy of it.
267
+
268
+ :param client:
269
+ The synchronous Nace client.
270
+ :param document:
271
+ The document to classify.
272
+ :returns:
273
+ A copy of the document with the answers under `meta[metadata_field]` and `True`, or a copy with the reason
274
+ under `meta["classification_error"]` and `False` if the document has no text or its request failed.
275
+ :raises nace_sdk.NaceError:
276
+ If the request fails and `raise_on_failure` is `True`.
277
+ """
278
+ text = self._text(document)
279
+ if not text:
280
+ return replace(document, meta={**document.meta, _ERROR_FIELD: "Document has no text to classify."}), False
281
+ try:
282
+ response = client.system_one(**self._call(text))
283
+ except nace_sdk.NaceError as error:
284
+ return self._fail(document, error)
285
+ return self._succeed(document=document, response=response)
286
+
287
+ async def _classify_async(
288
+ self, client: nace_sdk.AsyncNaceClient, document: Document, semaphore: asyncio.Semaphore
289
+ ) -> tuple[Document, bool]:
290
+ """
291
+ Asynchronously sends one document to Drex and adds the answers to a copy of it.
292
+
293
+ :param client:
294
+ The asynchronous Nace client.
295
+ :param document:
296
+ The document to classify.
297
+ :param semaphore:
298
+ Limits how many requests run at once.
299
+ :returns:
300
+ A copy of the document with the answers under `meta[metadata_field]` and `True`, or a copy with the reason
301
+ under `meta["classification_error"]` and `False` if the document has no text or its request failed.
302
+ :raises nace_sdk.NaceError:
303
+ If the request fails and `raise_on_failure` is `True`.
304
+ """
305
+ text = self._text(document)
306
+ if not text:
307
+ return replace(document, meta={**document.meta, _ERROR_FIELD: "Document has no text to classify."}), False
308
+ try:
309
+ async with semaphore:
310
+ response = await client.system_one(**self._call(text))
311
+ except nace_sdk.NaceError as error:
312
+ return self._fail(document, error)
313
+ return self._succeed(document=document, response=response)
314
+
315
+ @component.output_types(documents=list[Document], failed_documents=list[Document])
316
+ def run(self, documents: list[Document]) -> dict[str, list[Document]]:
317
+ """
318
+ Answers the questions for each document and adds the answers to its metadata.
319
+
320
+ Each document is sent as its own request carrying every question, with up to `max_workers` requests running
321
+ at once.
322
+
323
+ :param documents:
324
+ Documents to classify.
325
+ :returns:
326
+ A dictionary with the following keys:
327
+ - `documents`: The classified documents. Each has a `metadata_field` entry mapping each question ID to its
328
+ answer.
329
+ - `failed_documents`: The documents that have no text to classify or whose request failed, with the
330
+ reason in `meta["classification_error"]`.
331
+ :raises nace_sdk.NaceError:
332
+ If a request fails and `raise_on_failure` is `True`.
333
+ """
334
+ if not documents:
335
+ return {"documents": [], "failed_documents": []}
336
+ self.warm_up()
337
+ assert self._client is not None # noqa: S101 # mypy: the client is created by warm_up above
338
+
339
+ client = self._client
340
+ results = map_in_threads(
341
+ lambda document: self._classify(client=client, document=document), documents, self.max_workers
342
+ )
343
+ return {
344
+ "documents": [document for document, succeeded in results if succeeded],
345
+ "failed_documents": [document for document, succeeded in results if not succeeded],
346
+ }
347
+
348
+ @component.output_types(documents=list[Document], failed_documents=list[Document])
349
+ async def run_async(self, documents: list[Document]) -> dict[str, list[Document]]:
350
+ """
351
+ Asynchronously answers the questions for each document and adds the answers to its metadata.
352
+
353
+ This is the asynchronous version of the `run` method with the same parameters and return values. Up to
354
+ `max_workers` requests run concurrently.
355
+
356
+ :param documents:
357
+ Documents to classify.
358
+ :returns:
359
+ A dictionary with the following keys:
360
+ - `documents`: The classified documents. Each has a `metadata_field` entry mapping each question ID to its
361
+ answer.
362
+ - `failed_documents`: The documents that have no text to classify or whose request failed, with the
363
+ reason in `meta["classification_error"]`.
364
+ :raises nace_sdk.NaceError:
365
+ If a request fails and `raise_on_failure` is `True`.
366
+ """
367
+ if not documents:
368
+ return {"documents": [], "failed_documents": []}
369
+ await self.warm_up_async()
370
+ assert self._async_client is not None # noqa: S101 # mypy: the client is created by warm_up_async above
371
+
372
+ # Bound concurrency to max_workers, mirroring the ThreadPoolExecutor in the sync `run`
373
+ semaphore = asyncio.Semaphore(self.max_workers)
374
+ tasks = [
375
+ asyncio.create_task(self._classify_async(client=self._async_client, document=document, semaphore=semaphore))
376
+ for document in documents
377
+ ]
378
+ try:
379
+ results = await asyncio.gather(*tasks)
380
+ except Exception:
381
+ # We only expect to get here when `raise_on_failure` is `True`, since `_classify_async` otherwise records a
382
+ # failed request in `failed_documents` instead of raising. Cancel the remaining requests before re-raising.
383
+ for task in tasks:
384
+ task.cancel()
385
+ await asyncio.gather(*tasks, return_exceptions=True)
386
+ raise
387
+ return {
388
+ "documents": [document for document, succeeded in results if succeeded],
389
+ "failed_documents": [document for document, succeeded in results if not succeeded],
390
+ }
File without changes
@@ -0,0 +1,13 @@
1
+ # SPDX-FileCopyrightText: 2026-present Nace AI
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from .context_relevance import CONTEXT_RELEVANCE_INSTRUCTIONS, NaceContextRelevanceEvaluator
6
+ from .faithfulness import FAITHFULNESS_INSTRUCTIONS, NaceFaithfulnessEvaluator
7
+
8
+ __all__ = [
9
+ "CONTEXT_RELEVANCE_INSTRUCTIONS",
10
+ "FAITHFULNESS_INSTRUCTIONS",
11
+ "NaceContextRelevanceEvaluator",
12
+ "NaceFaithfulnessEvaluator",
13
+ ]