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.
- haystack_integrations/common/nace/__init__.py +3 -0
- haystack_integrations/common/nace/client.py +171 -0
- haystack_integrations/common/nace/concurrency.py +49 -0
- haystack_integrations/common/nace/errors.py +138 -0
- haystack_integrations/common/nace/images.py +204 -0
- haystack_integrations/common/nace/request.py +144 -0
- haystack_integrations/common/py.typed +0 -0
- haystack_integrations/components/classifiers/nace/__init__.py +7 -0
- haystack_integrations/components/classifiers/nace/document_classifier.py +390 -0
- haystack_integrations/components/classifiers/py.typed +0 -0
- haystack_integrations/components/evaluators/nace/__init__.py +13 -0
- haystack_integrations/components/evaluators/nace/_base.py +247 -0
- haystack_integrations/components/evaluators/nace/context_relevance.py +139 -0
- haystack_integrations/components/evaluators/nace/faithfulness.py +169 -0
- haystack_integrations/components/evaluators/py.typed +0 -0
- haystack_integrations/components/routers/nace/__init__.py +7 -0
- haystack_integrations/components/routers/nace/text_router.py +316 -0
- haystack_integrations/components/routers/py.typed +0 -0
- nace_haystack-0.1.0.dist-info/METADATA +230 -0
- nace_haystack-0.1.0.dist-info/RECORD +22 -0
- nace_haystack-0.1.0.dist-info/WHEEL +4 -0
- nace_haystack-0.1.0.dist-info/licenses/LICENSE.txt +201 -0
|
@@ -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,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
|
+
]
|