hyperforge-nucliadb-agentic 1.0.0.post64__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.
Files changed (35) hide show
  1. hyperforge_nucliadb_agentic/__init__.py +3 -0
  2. hyperforge_nucliadb_agentic/agent.py +1642 -0
  3. hyperforge_nucliadb_agentic/ask/__init__.py +5 -0
  4. hyperforge_nucliadb_agentic/ask/audit.py +439 -0
  5. hyperforge_nucliadb_agentic/ask/exceptions.py +50 -0
  6. hyperforge_nucliadb_agentic/ask/lifespan.py +21 -0
  7. hyperforge_nucliadb_agentic/ask/model.py +1299 -0
  8. hyperforge_nucliadb_agentic/ask/predict.py +431 -0
  9. hyperforge_nucliadb_agentic/ask/predict_models.py +78 -0
  10. hyperforge_nucliadb_agentic/ask/search/__init__.py +0 -0
  11. hyperforge_nucliadb_agentic/ask/search/ask.py +1182 -0
  12. hyperforge_nucliadb_agentic/ask/search/graph_strategy.py +1138 -0
  13. hyperforge_nucliadb_agentic/ask/search/highlight.py +93 -0
  14. hyperforge_nucliadb_agentic/ask/search/hydrator.py +29 -0
  15. hyperforge_nucliadb_agentic/ask/search/metrics.py +112 -0
  16. hyperforge_nucliadb_agentic/ask/search/parsers/__init__.py +0 -0
  17. hyperforge_nucliadb_agentic/ask/search/parsers/ask.py +70 -0
  18. hyperforge_nucliadb_agentic/ask/search/parsers/fetcher.py +192 -0
  19. hyperforge_nucliadb_agentic/ask/search/parsers/find.py +729 -0
  20. hyperforge_nucliadb_agentic/ask/search/prompt.py +1298 -0
  21. hyperforge_nucliadb_agentic/ask/search/rank_fusion.py +157 -0
  22. hyperforge_nucliadb_agentic/ask/search/rerankers.py +161 -0
  23. hyperforge_nucliadb_agentic/ask/search/retrieval.py +750 -0
  24. hyperforge_nucliadb_agentic/ask/search/rpc.py +192 -0
  25. hyperforge_nucliadb_agentic/ask/settings.py +9 -0
  26. hyperforge_nucliadb_agentic/ask/utils/ids.py +188 -0
  27. hyperforge_nucliadb_agentic/ask/utils/proto.py +6 -0
  28. hyperforge_nucliadb_agentic/ask/utils/responses.py +6 -0
  29. hyperforge_nucliadb_agentic/ask/utils/text_blocks.py +51 -0
  30. hyperforge_nucliadb_agentic/config.py +47 -0
  31. hyperforge_nucliadb_agentic/internal_driver.py +87 -0
  32. hyperforge_nucliadb_agentic/py.typed +0 -0
  33. hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/METADATA +24 -0
  34. hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/RECORD +35 -0
  35. hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/WHEEL +4 -0
@@ -0,0 +1,431 @@
1
+ import json
2
+ import logging
3
+ from collections.abc import AsyncGenerator
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+
7
+ import backoff
8
+ import httpx
9
+ from nuclia_models.predict.generative_responses import GenerativeChunk
10
+ from nucliadb_models.internal.predict import (
11
+ QueryInfo,
12
+ RerankModel,
13
+ RerankResponse,
14
+ )
15
+ from nucliadb_protos.utils_pb2 import RelationNode
16
+ from nucliadb_telemetry import errors, metrics
17
+ from nucliadb_utils.exceptions import LimitsExceededError
18
+ from nucliadb_utils.settings import nuclia_settings
19
+ from nucliadb_utils.utilities import Utility, clean_utility, get_utility, set_utility
20
+ from pydantic import ValidationError
21
+
22
+ from hyperforge_nucliadb_agentic.ask import logger
23
+ from hyperforge_nucliadb_agentic.ask.model import ChatModel, RephraseModel
24
+ from hyperforge_nucliadb_agentic.ask.predict_models import QueryModel
25
+
26
+
27
+ class SendToPredictError(Exception):
28
+ pass
29
+
30
+
31
+ class ProxiedPredictAPIError(Exception):
32
+ def __init__(self, status: int, detail: str = ""):
33
+ self.status = status
34
+ self.detail = detail
35
+
36
+
37
+ class NUAKeyMissingError(Exception):
38
+ pass
39
+
40
+
41
+ class RephraseError(Exception):
42
+ pass
43
+
44
+
45
+ class RephraseMissingContextError(Exception):
46
+ pass
47
+
48
+
49
+ PUBLIC_PREDICT = "/api/v1/predict"
50
+ PRIVATE_PREDICT = "/api/internal/predict"
51
+ SENTENCE = "/sentence"
52
+ TOKENS = "/tokens"
53
+ QUERY = "/query"
54
+ SUMMARIZE = "/summarize"
55
+ CHAT = "/chat"
56
+ REPHRASE = "/rephrase"
57
+ FEEDBACK = "/feedback"
58
+ RERANK = "/rerank"
59
+
60
+ NUCLIA_LEARNING_ID_HEADER = "NUCLIA-LEARNING-ID"
61
+ NUCLIA_LEARNING_MODEL_HEADER = "NUCLIA-LEARNING-MODEL"
62
+ NUCLIA_LEARNING_CHAT_HISTORY_HEADER = "NUCLIA-LEARNING-CHAT-HISTORY"
63
+
64
+ predict_observer = metrics.Observer(
65
+ "predict_engine",
66
+ labels={"type": ""},
67
+ error_mappings={
68
+ "over_limits": LimitsExceededError,
69
+ "predict_api_error": SendToPredictError,
70
+ },
71
+ )
72
+
73
+
74
+ RETRIABLE_EXCEPTIONS = (httpx.ConnectError, httpx.ConnectTimeout)
75
+ MAX_TRIES = 2
76
+
77
+
78
+ class AnswerStatusCode(str, Enum):
79
+ SUCCESS = "0"
80
+ ERROR = "-1"
81
+ NO_CONTEXT = "-2"
82
+ NO_RETRIEVAL_DATA = "-3"
83
+
84
+ def prettify(self) -> str:
85
+ return {
86
+ AnswerStatusCode.SUCCESS: "success",
87
+ AnswerStatusCode.ERROR: "error",
88
+ AnswerStatusCode.NO_CONTEXT: "no_context",
89
+ AnswerStatusCode.NO_RETRIEVAL_DATA: "no_retrieval_data",
90
+ }[self]
91
+
92
+
93
+ @dataclass
94
+ class RephraseResponse:
95
+ rephrased_query: str
96
+ use_chat_history: bool | None
97
+
98
+
99
+ def get_predict() -> "PredictEngine":
100
+ return get_utility(Utility.PREDICT)
101
+
102
+
103
+ async def start_predict_engine():
104
+ predict_util = PredictEngine(
105
+ nuclia_settings.nuclia_inner_predict_url,
106
+ nuclia_settings.nuclia_public_url,
107
+ nuclia_settings.nuclia_service_account,
108
+ nuclia_settings.nuclia_zone,
109
+ nuclia_settings.onprem,
110
+ nuclia_settings.local_predict,
111
+ nuclia_settings.local_predict_headers,
112
+ )
113
+ await predict_util.initialize()
114
+ set_utility(Utility.PREDICT, predict_util)
115
+
116
+
117
+ async def stop_predict_engine():
118
+ predict_util = get_utility(Utility.PREDICT)
119
+ if predict_util is None:
120
+ return
121
+
122
+ clean_utility(Utility.PREDICT)
123
+ await predict_util.finalize()
124
+
125
+
126
+ def convert_relations(data: dict[str, list[dict[str, str]]]) -> list[RelationNode]:
127
+ result = []
128
+ for token in data["tokens"]:
129
+ text = token["text"]
130
+ klass = token["ner"]
131
+ result.append(
132
+ RelationNode(value=text, ntype=RelationNode.NodeType.ENTITY, subtype=klass)
133
+ )
134
+ return result
135
+
136
+
137
+ class PredictEngine:
138
+ def __init__(
139
+ self,
140
+ cluster_url: str | None = None,
141
+ public_url: str | None = None,
142
+ nuclia_service_account: str | None = None,
143
+ zone: str | None = None,
144
+ onprem: bool = False,
145
+ local_predict: bool = False,
146
+ local_predict_headers: dict[str, str] | None = None,
147
+ ):
148
+ self.nuclia_service_account = nuclia_service_account
149
+ self.cluster_url = cluster_url
150
+ if public_url is not None:
151
+ self.public_url: str | None = public_url.format(zone=zone)
152
+ else:
153
+ self.public_url = None
154
+ self.zone = zone
155
+ self.onprem = onprem
156
+ self.local_predict = local_predict
157
+ self.local_predict_headers = local_predict_headers
158
+ self.session: httpx.AsyncClient | None = None
159
+
160
+ async def initialize(self):
161
+ limits = httpx.Limits(max_connections=150, max_keepalive_connections=50)
162
+ timeout = httpx.Timeout(30.0)
163
+ self.session = httpx.AsyncClient(
164
+ limits=limits,
165
+ timeout=timeout,
166
+ )
167
+
168
+ async def finalize(self):
169
+ if self.session:
170
+ await self.session.aclose()
171
+
172
+ def check_nua_key_is_configured_for_onprem(self):
173
+ if self.onprem and (
174
+ self.nuclia_service_account is None and self.local_predict is False
175
+ ):
176
+ raise NUAKeyMissingError()
177
+
178
+ def get_predict_url(self, endpoint: str, kbid: str) -> str:
179
+ if not endpoint.startswith("/"):
180
+ endpoint = "/" + endpoint
181
+ if self.onprem:
182
+ # On-prem NucliaDB uses the public URL for the predict API. Examples:
183
+ # /api/v1/predict/chat/{kbid}
184
+ # /api/v1/predict/rephrase/{kbid}
185
+ return f"{self.public_url}{PUBLIC_PREDICT}{endpoint}/{kbid}"
186
+ else:
187
+ return f"{self.cluster_url}{PRIVATE_PREDICT}{endpoint}"
188
+
189
+ def get_predict_headers(self, kbid: str) -> dict[str, str]:
190
+ if self.onprem:
191
+ headers = {"X-STF-NUAKEY": f"Bearer {self.nuclia_service_account}"}
192
+ if self.local_predict_headers is not None:
193
+ headers.update(self.local_predict_headers)
194
+ return headers
195
+ else:
196
+ return {"X-STF-KBID": kbid}
197
+
198
+ async def check_response(
199
+ self, kbid: str, resp: httpx.Response, expected_status: int = 200
200
+ ) -> None:
201
+ if resp.status_code == expected_status:
202
+ return
203
+
204
+ # Ensure the body is loaded before reading it (needed for streaming responses)
205
+ if not resp.is_stream_consumed:
206
+ await resp.aread()
207
+
208
+ if resp.status_code == 402:
209
+ data = resp.json()
210
+ raise LimitsExceededError(402, data["detail"])
211
+
212
+ try:
213
+ data = resp.json()
214
+ try:
215
+ detail = data["detail"]
216
+ except (KeyError, TypeError):
217
+ detail = data
218
+ except (json.decoder.JSONDecodeError, ValueError):
219
+ detail = resp.text
220
+
221
+ is_5xx_error = resp.status_code > 499
222
+ # NOTE: 512 is a special status code sent by learning predict api indicating that the error
223
+ # is related to an external generative model, so we don't want to log it as an error
224
+ is_external_generative_error = resp.status_code == 512
225
+ log_level = (
226
+ logging.ERROR
227
+ if is_5xx_error and not is_external_generative_error
228
+ else logging.INFO
229
+ )
230
+ logger.log(
231
+ log_level,
232
+ "Predict API error",
233
+ extra=dict(
234
+ kbid=kbid,
235
+ url=str(resp.url),
236
+ status_code=resp.status_code,
237
+ detail=detail,
238
+ ),
239
+ )
240
+ raise ProxiedPredictAPIError(status=resp.status_code, detail=detail)
241
+
242
+ @backoff.on_exception(
243
+ backoff.expo,
244
+ RETRIABLE_EXCEPTIONS,
245
+ jitter=backoff.random_jitter,
246
+ max_tries=MAX_TRIES,
247
+ )
248
+ async def make_request(self, method: str, **request_args) -> httpx.Response:
249
+ if not self.session:
250
+ raise RuntimeError("PredictEngine session is not initialized")
251
+ func = getattr(self.session, method.lower())
252
+ return await func(**request_args)
253
+
254
+ async def make_stream_request(self, method: str, **request_args) -> httpx.Response:
255
+ """Open a streaming request and return the response without reading the body.
256
+ The caller is responsible for consuming and closing the response."""
257
+ if not self.session:
258
+ raise RuntimeError("PredictEngine session is not initialized")
259
+ request = self.session.build_request(method.upper(), **request_args)
260
+ return await self.session.send(request, stream=True)
261
+
262
+ @predict_observer.wrap({"type": "rephrase"})
263
+ async def rephrase_query(self, kbid: str, item: RephraseModel) -> RephraseResponse:
264
+ try:
265
+ self.check_nua_key_is_configured_for_onprem()
266
+ except NUAKeyMissingError:
267
+ error = "Nuclia Service account is not defined so could not rephrase query"
268
+ logger.warning(error)
269
+ raise SendToPredictError(error)
270
+
271
+ resp = await self.make_request(
272
+ "POST",
273
+ url=self.get_predict_url(REPHRASE, kbid),
274
+ json=item.model_dump(),
275
+ headers=self.get_predict_headers(kbid),
276
+ )
277
+ await self.check_response(kbid, resp, expected_status=200)
278
+ return _parse_rephrase_response(resp)
279
+
280
+ @predict_observer.wrap({"type": "chat_ndjson"})
281
+ async def chat_query_ndjson(
282
+ self, kbid: str, item: ChatModel, extra_headers: dict[str, str] | None = None
283
+ ) -> tuple[str, str, AsyncGenerator[GenerativeChunk, None]]:
284
+ """
285
+ Chat query using the new stream format
286
+ Format specs: https://github.com/ndjson/ndjson-spec
287
+ """
288
+ try:
289
+ self.check_nua_key_is_configured_for_onprem()
290
+ except NUAKeyMissingError:
291
+ error = "Nuclia Service account is not defined so the chat operation could not be performed"
292
+ logger.warning(error)
293
+ raise SendToPredictError(error)
294
+
295
+ # The ndjson format is triggered by the Accept header
296
+ headers = self.get_predict_headers(kbid)
297
+ headers["Accept"] = "application/x-ndjson"
298
+
299
+ resp = await self.make_stream_request(
300
+ "POST",
301
+ url=self.get_predict_url(CHAT, kbid),
302
+ json=item.model_dump(),
303
+ headers={**headers, **(extra_headers or {})},
304
+ timeout=httpx.Timeout(180.0, read=120.0),
305
+ )
306
+ await self.check_response(kbid, resp, expected_status=200)
307
+ ident = resp.headers.get(NUCLIA_LEARNING_ID_HEADER) or "unknown"
308
+ model = resp.headers.get(NUCLIA_LEARNING_MODEL_HEADER) or "unknown"
309
+ return ident, model, get_chat_ndjson_generator(resp)
310
+
311
+ @predict_observer.wrap({"type": "query"})
312
+ async def query(
313
+ self,
314
+ kbid: str,
315
+ item: QueryModel,
316
+ ) -> QueryInfo:
317
+ """
318
+ Query endpoint: returns information to be used by NucliaDB at retrieval time, for instance:
319
+ - The embeddings
320
+ - The entities
321
+ - The stop words
322
+ - The semantic threshold
323
+ - etc.
324
+
325
+ :param kbid: KnowledgeBox ID
326
+ :param sentence: The query sentence
327
+ :param semantic_model: The semantic model to use to generate the embeddings
328
+ :param generative_model: The generative model that will be used to generate the answer
329
+ :param rephrase: If the query should be rephrased before calculating the embeddings for a better retrieval
330
+ :param rephrase_prompt: Custom prompt to use for rephrasing
331
+ """
332
+ try:
333
+ self.check_nua_key_is_configured_for_onprem()
334
+ except NUAKeyMissingError:
335
+ error = (
336
+ "Nuclia Service account is not defined so could not ask query endpoint"
337
+ )
338
+ logger.warning(error)
339
+ raise SendToPredictError(error)
340
+
341
+ resp = await self.make_request(
342
+ "POST",
343
+ url=self.get_predict_url(QUERY, kbid),
344
+ json=item.model_dump(),
345
+ headers=self.get_predict_headers(kbid),
346
+ )
347
+ await self.check_response(kbid, resp, expected_status=200)
348
+ data = resp.json()
349
+ return QueryInfo(**data)
350
+
351
+ @predict_observer.wrap({"type": "entities"})
352
+ async def detect_entities(self, kbid: str, sentence: str) -> list[RelationNode]:
353
+ try:
354
+ self.check_nua_key_is_configured_for_onprem()
355
+ except NUAKeyMissingError:
356
+ logger.warning(
357
+ "Nuclia Service account is not defined so could not retrieve entities from the query"
358
+ )
359
+ return []
360
+
361
+ resp = await self.make_request(
362
+ "GET",
363
+ url=self.get_predict_url(TOKENS, kbid),
364
+ params={"text": sentence},
365
+ headers=self.get_predict_headers(kbid),
366
+ )
367
+ await self.check_response(kbid, resp, expected_status=200)
368
+ data = resp.json()
369
+ return convert_relations(data)
370
+
371
+ @predict_observer.wrap({"type": "rerank"})
372
+ async def rerank(self, kbid: str, item: RerankModel) -> RerankResponse:
373
+ try:
374
+ self.check_nua_key_is_configured_for_onprem()
375
+ except NUAKeyMissingError:
376
+ error = "Nuclia Service account is not defined. Rerank operation could not be performed"
377
+ logger.warning(error)
378
+ raise SendToPredictError(error)
379
+ resp = await self.make_request(
380
+ "POST",
381
+ url=self.get_predict_url(RERANK, kbid),
382
+ json=item.model_dump(),
383
+ headers=self.get_predict_headers(kbid),
384
+ )
385
+ await self.check_response(kbid, resp, expected_status=200)
386
+ data = resp.json()
387
+ return RerankResponse.model_validate(data)
388
+
389
+
390
+ def get_chat_ndjson_generator(
391
+ response: httpx.Response,
392
+ ) -> AsyncGenerator[GenerativeChunk, None]:
393
+ async def _parse_generative_chunks(response: httpx.Response):
394
+ try:
395
+ async for line in response.aiter_lines():
396
+ if not line.strip():
397
+ continue
398
+ try:
399
+ yield GenerativeChunk.model_validate_json(line.strip())
400
+ except ValidationError as ex:
401
+ errors.capture_exception(ex)
402
+ logger.error(f"Invalid chunk received: {line}")
403
+ continue
404
+ finally:
405
+ await response.aclose()
406
+
407
+ return _parse_generative_chunks(response)
408
+
409
+
410
+ def _parse_rephrase_response(
411
+ resp: httpx.Response,
412
+ ) -> RephraseResponse:
413
+ """
414
+ Predict api is returning a json payload that is a string with the following format:
415
+ <rephrased_query><status_code>
416
+ where status_code is "0" for success, "-1" for error and "-2" for no context
417
+ it will raise an exception if the status code is not 0
418
+ """
419
+ content = resp.json()
420
+
421
+ if content.endswith("0"):
422
+ content = content[:-1]
423
+ elif content.endswith("-1"):
424
+ raise RephraseError(content[:-2])
425
+ elif content.endswith("-2"):
426
+ raise RephraseMissingContextError(content[:-2])
427
+
428
+ use_chat_history = None
429
+ if NUCLIA_LEARNING_CHAT_HISTORY_HEADER in resp.headers:
430
+ use_chat_history = resp.headers[NUCLIA_LEARNING_CHAT_HISTORY_HEADER] == "true"
431
+ return RephraseResponse(rephrased_query=content, use_chat_history=use_chat_history)
@@ -0,0 +1,78 @@
1
+ from enum import Enum
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from hyperforge_nucliadb_agentic.ask.model import Image
6
+
7
+
8
+ class FieldInfo(BaseModel):
9
+ """
10
+ Model to represent the field information required
11
+ """
12
+
13
+ text: str = Field(..., title="The text of the field")
14
+ metadata: str = Field(
15
+ title="The metadata of the field as a base64 string serialized nucliadb_protos.resources.FieldMetadata protobuf",
16
+ )
17
+ field_id: str = Field(
18
+ ...,
19
+ title="The field ID of the field (rid/field_type/field[/split]) or any unique identifier",
20
+ )
21
+
22
+
23
+ class OperationType(str, Enum):
24
+ graph = "graph"
25
+ label = "label"
26
+ ask = "ask"
27
+ qa = "qa"
28
+ extract = "extract"
29
+ prompt_guard = "prompt_guard"
30
+ llama_guard = "llama_guard"
31
+
32
+
33
+ class NameOperationFilter(BaseModel):
34
+ operation_type: OperationType = Field(..., description="Type of the operation")
35
+ task_names: list[str] = Field(
36
+ default_factory=list,
37
+ description="list of task names. If None or empty, all tasks for that operation are applied.",
38
+ )
39
+
40
+
41
+ class QueryModel(BaseModel):
42
+ """
43
+ Model to represent a query request
44
+ """
45
+
46
+ text: str | None = Field(default=None, description="The query text to be processed")
47
+ query_image: Image | None = Field(
48
+ default=None,
49
+ description="Image to be considered as part of the query. Even if the `rephrase` parameter is set to `false`, the rephrasing process will occur, combining the provided text with the image's visual features in the rephrased query.",
50
+ )
51
+ rephrase: bool = Field(
52
+ default=False,
53
+ description="If true, the model will rephrase the input text before processing",
54
+ )
55
+ rephrase_prompt: str | None = Field(
56
+ default=None,
57
+ description="Custom prompt for rephrasing the input text",
58
+ examples=[
59
+ """Rephrase this question so its better for retrieval, and keep the rephrased question in the same language as the original.
60
+ QUESTION: {question}
61
+ Please return ONLY the question without any explanation. Just the rephrased question.""",
62
+ """Rephrase this question so its better for retrieval, if in the image there are any machinery components with numeric identifiers, append them to the end of the question separated by a commas.
63
+ QUESTION: {question}
64
+ Please return ONLY the question without any explanation.""",
65
+ ],
66
+ )
67
+ generative_model: str | None = Field(
68
+ default=None,
69
+ description="The generative model to use for rephrasing",
70
+ )
71
+ semantic_models: list[str] | None = Field(
72
+ default=None,
73
+ description="Semantic models to compute the sentence vector for, if not provided, it will only compute the sentence vector for default semantic model in the Knowledge box's configuration.",
74
+ )
75
+ agentic_entities: bool = Field(
76
+ default=False,
77
+ description="If true, the model will return the entities detected in the sentence guided by an already defined Graph Extraction Agent in the Knowledge Box.",
78
+ )
File without changes