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,1182 @@
1
+ import dataclasses
2
+ import functools
3
+ import json
4
+ from collections.abc import AsyncGenerator
5
+ from typing import cast
6
+
7
+ from nuclia_models.common.consumption import Consumption
8
+ from nuclia_models.predict.generative_responses import (
9
+ CitationsGenerativeResponse,
10
+ FootnoteCitationsGenerativeResponse,
11
+ GenerativeChunk,
12
+ JSONGenerativeResponse,
13
+ MetaGenerativeResponse,
14
+ ReasoningGenerativeResponse,
15
+ StatusGenerativeResponse,
16
+ TextGenerativeResponse,
17
+ )
18
+ from nucliadb_models.retrieval import (
19
+ GraphScore,
20
+ KeywordScore,
21
+ RerankerScore,
22
+ RrfScore,
23
+ SemanticScore,
24
+ )
25
+ from nucliadb_models.search import (
26
+ SCORE_TYPE,
27
+ FindOptions,
28
+ FindParagraph,
29
+ NucliaDBClientType,
30
+ )
31
+ from nucliadb_sdk.v2 import NucliaDBAsync
32
+ from nucliadb_telemetry import errors
33
+ from nucliadb_utils.exceptions import LimitsExceededError
34
+ from pydantic_core import ValidationError
35
+ from typing_extensions import assert_never
36
+
37
+ from hyperforge_nucliadb_agentic.ask import logger, predict
38
+ from hyperforge_nucliadb_agentic.ask.audit import ChatAuditor
39
+ from hyperforge_nucliadb_agentic.ask.exceptions import (
40
+ AnswerJsonSchemaTooLong,
41
+ IncompleteFindResultsError,
42
+ InvalidQueryError,
43
+ KnowledgeBoxNotFound,
44
+ NoRetrievalResultsError,
45
+ NucliaDBError,
46
+ )
47
+ from hyperforge_nucliadb_agentic.ask.model import (
48
+ AnswerAskResponseItem,
49
+ AskRequest,
50
+ AskResponseItem,
51
+ AskResponseItemType,
52
+ AskRetrievalMatch,
53
+ AskTimings,
54
+ AskTokens,
55
+ AugmentedContext,
56
+ AugmentedContextResponseItem,
57
+ ChatContextMessage,
58
+ ChatModel,
59
+ ChatOptions,
60
+ CitationsAskResponseItem,
61
+ ConsumptionResponseItem,
62
+ DebugAskResponseItem,
63
+ ErrorAskResponseItem,
64
+ FindRequest,
65
+ FootnoteCitationsAskResponseItem,
66
+ GraphStrategy,
67
+ JSONAskResponseItem,
68
+ KnowledgeboxFindResults,
69
+ MetadataAskResponseItem,
70
+ PrequeriesAskResponseItem,
71
+ PreQueriesStrategy,
72
+ PreQuery,
73
+ PreQueryResult,
74
+ PromptContext,
75
+ PromptContextOrder,
76
+ RagStrategyName,
77
+ ReasoningAskResponseItem,
78
+ Relations,
79
+ RelationsAskResponseItem,
80
+ RephraseModel,
81
+ RetrievalAskResponseItem,
82
+ StatusAskResponseItem,
83
+ SyncAskMetadata,
84
+ SyncAskResponse,
85
+ TokensDetail,
86
+ UserPrompt,
87
+ parse_custom_prompt,
88
+ parse_rephrase_prompt,
89
+ )
90
+ from hyperforge_nucliadb_agentic.ask.predict import (
91
+ AnswerStatusCode,
92
+ RephraseMissingContextError,
93
+ RephraseResponse,
94
+ get_predict,
95
+ )
96
+ from hyperforge_nucliadb_agentic.ask.search.graph_strategy import (
97
+ get_graph_results,
98
+ )
99
+ from hyperforge_nucliadb_agentic.ask.search.metrics import (
100
+ AskMetrics,
101
+ Metrics,
102
+ )
103
+ from hyperforge_nucliadb_agentic.ask.search.parsers.ask import (
104
+ fetcher_for_ask,
105
+ parse_ask,
106
+ )
107
+ from hyperforge_nucliadb_agentic.ask.search.parsers.fetcher import (
108
+ Fetcher,
109
+ )
110
+ from hyperforge_nucliadb_agentic.ask.search.prompt import (
111
+ PromptContextBuilder,
112
+ )
113
+ from hyperforge_nucliadb_agentic.ask.search.rank_fusion import (
114
+ WeightedCombSum,
115
+ )
116
+ from hyperforge_nucliadb_agentic.ask.search.retrieval import (
117
+ NOT_ENOUGH_CONTEXT_ANSWER,
118
+ add_resource_filter,
119
+ get_answer_stream,
120
+ get_find_results,
121
+ get_relations_results,
122
+ sorted_prompt_context_list,
123
+ )
124
+ from hyperforge_nucliadb_agentic.ask.utils.ids import ParagraphId
125
+ from hyperforge_nucliadb_agentic.ask.utils.responses import (
126
+ HTTPClientError,
127
+ )
128
+ from hyperforge_nucliadb_agentic.ask.utils.text_blocks import (
129
+ ScoredTextBlock,
130
+ )
131
+
132
+
133
+ @dataclasses.dataclass
134
+ class RetrievalMatch:
135
+ paragraph: FindParagraph
136
+ weighted_score: float
137
+
138
+
139
+ @dataclasses.dataclass
140
+ class RetrievalResults:
141
+ main_query: KnowledgeboxFindResults
142
+ fetcher: Fetcher
143
+ main_query_weight: float
144
+ prequeries: list[PreQueryResult] | None = None
145
+ best_matches: list[RetrievalMatch] = dataclasses.field(default_factory=list)
146
+
147
+
148
+ class AskResult:
149
+ def __init__(
150
+ self,
151
+ *,
152
+ kbid: str,
153
+ ask_request: AskRequest,
154
+ main_results: KnowledgeboxFindResults,
155
+ prequeries_results: list[PreQueryResult] | None,
156
+ nuclia_learning_id: str | None,
157
+ predict_answer_stream: AsyncGenerator[GenerativeChunk, None] | None,
158
+ prompt_context: PromptContext,
159
+ prompt_context_order: PromptContextOrder,
160
+ auditor: ChatAuditor,
161
+ metrics: AskMetrics,
162
+ best_matches: list[RetrievalMatch],
163
+ debug_chat_model: ChatModel | None,
164
+ augmented_context: AugmentedContext,
165
+ search_sdk: NucliaDBAsync,
166
+ ):
167
+ # Initial attributes
168
+ self.kbid = kbid
169
+ self.ask_request = ask_request
170
+ self.main_results = main_results
171
+ self.prequeries_results = prequeries_results or []
172
+ self.nuclia_learning_id = nuclia_learning_id
173
+ self.predict_answer_stream = predict_answer_stream
174
+ self.prompt_context = prompt_context
175
+ self.debug_chat_model = debug_chat_model
176
+ self.prompt_context_order = prompt_context_order
177
+ self.auditor: ChatAuditor = auditor
178
+ self.metrics: AskMetrics = metrics
179
+ self.best_matches: list[RetrievalMatch] = best_matches
180
+ self.augmented_context = augmented_context
181
+ self.search_sdk = search_sdk
182
+
183
+ # Computed from the predict chat answer stream
184
+ self._answer_text = ""
185
+ self._reasoning_text: str | None = None
186
+ self._object: JSONGenerativeResponse | None = None
187
+ self._status: StatusGenerativeResponse | None = None
188
+ self._citations: CitationsGenerativeResponse | None = None
189
+ self._footnote_citations: FootnoteCitationsGenerativeResponse | None = None
190
+ self._metadata: MetaGenerativeResponse | None = None
191
+ self._relations: Relations | None = None
192
+ self._consumption: Consumption | None = None
193
+
194
+ @property
195
+ def status_code(self) -> AnswerStatusCode:
196
+ if self._status is None:
197
+ return AnswerStatusCode.SUCCESS
198
+ return AnswerStatusCode(self._status.code)
199
+
200
+ @property
201
+ def status_error_details(self) -> str | None:
202
+ if self._status is None: # pragma: no cover
203
+ return None
204
+ return self._status.details
205
+
206
+ @property
207
+ def ask_request_with_relations(self) -> bool:
208
+ return ChatOptions.RELATIONS in self.ask_request.features
209
+
210
+ @property
211
+ def ask_request_with_debug_flag(self) -> bool:
212
+ return self.ask_request.debug
213
+
214
+ async def ndjson_stream(self) -> AsyncGenerator[str, None]:
215
+ try:
216
+ async for item in self._stream():
217
+ yield self._ndjson_encode(item)
218
+ except Exception as exc:
219
+ # Handle any unexpected error that might happen
220
+ # during the streaming and halt the stream
221
+ errors.capture_exception(exc)
222
+ logger.error(
223
+ f"Unexpected error while generating the answer: {exc}",
224
+ extra={"kbid": self.kbid},
225
+ )
226
+ error_message = (
227
+ "Unexpected error while generating the answer. Please try again later."
228
+ )
229
+ if self.ask_request_with_debug_flag:
230
+ error_message += f" Error: {exc}"
231
+ item = ErrorAskResponseItem(error=error_message)
232
+ yield self._ndjson_encode(item)
233
+ return
234
+
235
+ def _ndjson_encode(self, item: AskResponseItemType) -> str:
236
+ result_item = AskResponseItem(item=item)
237
+ return result_item.model_dump_json(exclude_none=True, by_alias=True) + "\n"
238
+
239
+ async def _stream(self) -> AsyncGenerator[AskResponseItemType, None]:
240
+ # First, stream out the predict answer
241
+ first_chunk_yielded = False
242
+ first_reasoning_chunk_yielded = False
243
+ with self.metrics.time("stream_predict_answer"):
244
+ async for answer_chunk in self._stream_predict_answer_text():
245
+ if isinstance(answer_chunk, TextGenerativeResponse):
246
+ yield AnswerAskResponseItem(text=answer_chunk.text)
247
+ if not first_chunk_yielded:
248
+ self.metrics.record_first_chunk_yielded()
249
+ first_chunk_yielded = True
250
+ elif isinstance(answer_chunk, ReasoningGenerativeResponse):
251
+ yield ReasoningAskResponseItem(text=answer_chunk.text)
252
+ if not first_reasoning_chunk_yielded:
253
+ self.metrics.record_first_reasoning_chunk_yielded()
254
+ first_reasoning_chunk_yielded = True
255
+ else:
256
+ assert_never(answer_chunk)
257
+
258
+ if self._object is not None:
259
+ yield JSONAskResponseItem(object=self._object.object)
260
+ if not first_chunk_yielded:
261
+ # When there is a JSON generative response, we consider the first chunk yielded
262
+ # to be the moment when the JSON object is yielded, not the text
263
+ self.metrics.record_first_chunk_yielded()
264
+ first_chunk_yielded = True
265
+
266
+ yield RetrievalAskResponseItem(
267
+ results=self.main_results,
268
+ best_matches=[
269
+ AskRetrievalMatch(
270
+ id=match.paragraph.id,
271
+ )
272
+ for match in self.best_matches
273
+ ],
274
+ )
275
+
276
+ if len(self.prequeries_results) > 0:
277
+ item = PrequeriesAskResponseItem()
278
+ for index, (prequery, result) in enumerate(self.prequeries_results):
279
+ prequery_id = prequery.id or f"prequery_{index}"
280
+ item.results[prequery_id] = result
281
+ yield item
282
+
283
+ # Then the status
284
+ if self.status_code == AnswerStatusCode.ERROR:
285
+ # If predict yielded an error status, we yield it too and halt the stream immediately
286
+ yield StatusAskResponseItem(
287
+ code=self.status_code.value,
288
+ status=self.status_code.prettify(),
289
+ details=self.status_error_details or "Unknown error",
290
+ )
291
+ return
292
+
293
+ yield StatusAskResponseItem(
294
+ code=self.status_code.value,
295
+ status=self.status_code.prettify(),
296
+ )
297
+
298
+ # Audit the answer
299
+ if self._object is None:
300
+ audit_answer = self._answer_text.encode("utf-8")
301
+ else:
302
+ audit_answer = json.dumps(self._object.object).encode("utf-8")
303
+ self.auditor.audit(
304
+ text_answer=audit_answer,
305
+ text_reasoning=self._reasoning_text,
306
+ generative_answer_time=self.metrics["stream_predict_answer"],
307
+ generative_answer_first_chunk_time=self.metrics.get_first_chunk_time() or 0,
308
+ generative_reasoning_first_chunk_time=self.metrics.get_first_reasoning_chunk_time(),
309
+ rephrase_time=self.metrics.get("rephrase"),
310
+ status_code=self.status_code,
311
+ )
312
+
313
+ yield AugmentedContextResponseItem(augmented=self.augmented_context)
314
+
315
+ # Stream out the citations
316
+ if self._citations is not None:
317
+ yield CitationsAskResponseItem(
318
+ citations=self._citations.citations,
319
+ )
320
+ # Stream out the footnote citations mapping
321
+ if self._footnote_citations is not None:
322
+ yield FootnoteCitationsAskResponseItem(
323
+ footnote_to_context=self._footnote_citations.footnote_to_context,
324
+ )
325
+
326
+ # Stream out generic metadata about the answer
327
+ if self._metadata is not None:
328
+ yield MetadataAskResponseItem(
329
+ tokens=AskTokens(
330
+ input=self._metadata.input_tokens,
331
+ output=self._metadata.output_tokens,
332
+ input_nuclia=self._metadata.input_nuclia_tokens,
333
+ output_nuclia=self._metadata.output_nuclia_tokens,
334
+ ),
335
+ timings=AskTimings(
336
+ generative_first_chunk=self._metadata.timings.get(
337
+ "generative_first_chunk"
338
+ ),
339
+ generative_total=self._metadata.timings.get("generative"),
340
+ ),
341
+ )
342
+
343
+ if self._consumption is not None:
344
+ yield ConsumptionResponseItem(
345
+ normalized_tokens=TokensDetail(
346
+ input=self._consumption.normalized_tokens.input,
347
+ output=self._consumption.normalized_tokens.output,
348
+ image=self._consumption.normalized_tokens.image,
349
+ ),
350
+ customer_key_tokens=TokensDetail(
351
+ input=self._consumption.customer_key_tokens.input,
352
+ output=self._consumption.customer_key_tokens.output,
353
+ image=self._consumption.customer_key_tokens.image,
354
+ ),
355
+ )
356
+
357
+ # Stream out the relations results
358
+ should_query_relations = (
359
+ self.ask_request_with_relations
360
+ and self.status_code == AnswerStatusCode.SUCCESS
361
+ )
362
+ if should_query_relations:
363
+ relations = await self.get_relations_results()
364
+ yield RelationsAskResponseItem(relations=relations)
365
+
366
+ # Stream out debug information
367
+ if self.ask_request_with_debug_flag:
368
+ predict_request = None
369
+ if self.debug_chat_model:
370
+ predict_request = self.debug_chat_model.model_dump(mode="json")
371
+ yield DebugAskResponseItem(
372
+ metadata={
373
+ "prompt_context": sorted_prompt_context_list(
374
+ self.prompt_context, self.prompt_context_order
375
+ ),
376
+ "predict_request": predict_request,
377
+ },
378
+ metrics=self.metrics.dump(),
379
+ )
380
+
381
+ async def model_dump(self) -> SyncAskResponse:
382
+ # Run the stream to completion to ensure all data is available in memory
383
+ # First, run the stream in memory to get all the data in memory
384
+ async for _ in self._stream():
385
+ ...
386
+
387
+ metadata = None
388
+ if self._metadata is not None:
389
+ metadata = SyncAskMetadata(
390
+ tokens=AskTokens(
391
+ input=self._metadata.input_tokens,
392
+ output=self._metadata.output_tokens,
393
+ input_nuclia=self._metadata.input_nuclia_tokens,
394
+ output_nuclia=self._metadata.output_nuclia_tokens,
395
+ ),
396
+ timings=AskTimings(
397
+ generative_first_chunk=self._metadata.timings.get(
398
+ "generative_first_chunk"
399
+ ),
400
+ generative_total=self._metadata.timings.get("generative"),
401
+ ),
402
+ )
403
+
404
+ citations = {}
405
+ if self._citations is not None:
406
+ citations = self._citations.citations
407
+
408
+ footnote_citations = {}
409
+ if self._footnote_citations is not None:
410
+ footnote_citations = self._footnote_citations.footnote_to_context
411
+
412
+ answer_json = None
413
+ if self._object is not None:
414
+ answer_json = self._object.object
415
+
416
+ prequeries_results: dict[str, KnowledgeboxFindResults] | None = None
417
+ if self.prequeries_results:
418
+ prequeries_results = {}
419
+ for index, (prequery, result) in enumerate(self.prequeries_results):
420
+ prequery_id = prequery.id or f"prequery_{index}"
421
+ prequeries_results[prequery_id] = result
422
+
423
+ best_matches = [
424
+ AskRetrievalMatch(
425
+ id=match.paragraph.id,
426
+ )
427
+ for match in self.best_matches
428
+ ]
429
+
430
+ response = SyncAskResponse(
431
+ answer=self._answer_text,
432
+ reasoning=self._reasoning_text,
433
+ answer_json=answer_json,
434
+ status=self.status_code.prettify(),
435
+ relations=self._relations,
436
+ retrieval_results=self.main_results,
437
+ retrieval_best_matches=best_matches,
438
+ prequeries=prequeries_results,
439
+ citations=citations,
440
+ citation_footnote_to_context=footnote_citations,
441
+ metadata=metadata,
442
+ consumption=self._consumption,
443
+ learning_id=self.nuclia_learning_id or "",
444
+ augmented_context=self.augmented_context,
445
+ )
446
+ if self.status_code == AnswerStatusCode.ERROR and self.status_error_details:
447
+ response.error_details = self.status_error_details
448
+ if self.ask_request_with_debug_flag:
449
+ sorted_prompt_context = sorted_prompt_context_list(
450
+ self.prompt_context, self.prompt_context_order
451
+ )
452
+ response.prompt_context = sorted_prompt_context
453
+ if self.debug_chat_model:
454
+ response.predict_request = self.debug_chat_model.model_dump(mode="json")
455
+ response.debug = {
456
+ "metrics": self.metrics.dump(),
457
+ }
458
+ return response
459
+
460
+ async def json(self) -> str:
461
+ response = await self.model_dump()
462
+ return response.model_dump_json(exclude_none=True, by_alias=True)
463
+
464
+ async def get_relations_results(self) -> Relations:
465
+ if self._relations is None:
466
+ with self.metrics.time("relations"):
467
+ self._relations = await get_relations_results(
468
+ search_sdk=self.search_sdk,
469
+ kbid=self.kbid,
470
+ text_answer=self._answer_text,
471
+ timeout=5.0,
472
+ )
473
+ return self._relations
474
+
475
+ async def _stream_predict_answer_text(
476
+ self,
477
+ ) -> AsyncGenerator[TextGenerativeResponse | ReasoningGenerativeResponse, None]:
478
+ """
479
+ Reads the stream of the generative model, yielding the answer text but also parsing
480
+ other items like status codes, citations and miscellaneous metadata.
481
+
482
+ This method does not assume any order in the stream of items, but it assumes that at least
483
+ the answer text is streamed in order.
484
+ """
485
+ if self.predict_answer_stream is None:
486
+ # In some cases, clients may want to skip the answer generation step
487
+ return
488
+ async for generative_chunk in self.predict_answer_stream:
489
+ item = generative_chunk.chunk
490
+ if isinstance(item, TextGenerativeResponse):
491
+ self._answer_text += item.text
492
+ yield item
493
+ elif isinstance(item, ReasoningGenerativeResponse):
494
+ if self._reasoning_text is None:
495
+ self._reasoning_text = item.text
496
+ else:
497
+ self._reasoning_text += item.text
498
+ yield item
499
+ elif isinstance(item, JSONGenerativeResponse):
500
+ self._object = item
501
+ elif isinstance(item, StatusGenerativeResponse):
502
+ self._status = item
503
+ elif isinstance(item, CitationsGenerativeResponse):
504
+ self._citations = item
505
+ elif isinstance(item, FootnoteCitationsGenerativeResponse):
506
+ self._footnote_citations = item
507
+ elif isinstance(item, MetaGenerativeResponse):
508
+ self._metadata = item
509
+ elif isinstance(item, Consumption):
510
+ self._consumption = item
511
+ else:
512
+ logger.warning(
513
+ f"Unexpected item in predict answer stream: {item}",
514
+ extra={"kbid": self.kbid},
515
+ )
516
+
517
+
518
+ class NotEnoughContextAskResult(AskResult):
519
+ def __init__(
520
+ self,
521
+ main_results: KnowledgeboxFindResults | None = None,
522
+ prequeries_results: list[PreQueryResult] | None = None,
523
+ ):
524
+ self.main_results = main_results or KnowledgeboxFindResults(
525
+ resources={}, min_score=None
526
+ )
527
+ self.prequeries_results = prequeries_results or []
528
+ self.nuclia_learning_id = None
529
+
530
+ async def ndjson_stream(self) -> AsyncGenerator[str, None]:
531
+ """
532
+ In the case where there are no results in the retrieval phase, we simply
533
+ return the find results and the messages indicating that there is not enough
534
+ context in the corpus to answer.
535
+ """
536
+ status = AnswerStatusCode.NO_RETRIEVAL_DATA
537
+ yield self._ndjson_encode(
538
+ StatusAskResponseItem(code=status.value, status=status.prettify())
539
+ )
540
+ yield self._ndjson_encode(AnswerAskResponseItem(text=NOT_ENOUGH_CONTEXT_ANSWER))
541
+ yield self._ndjson_encode(RetrievalAskResponseItem(results=self.main_results))
542
+ if self.prequeries_results:
543
+ yield self._ndjson_encode(
544
+ PrequeriesAskResponseItem(
545
+ results={
546
+ prequery.id or f"prequery_{index}": prequery_result
547
+ for index, (prequery, prequery_result) in enumerate(
548
+ self.prequeries_results
549
+ )
550
+ }
551
+ )
552
+ )
553
+
554
+ async def json(self) -> str:
555
+ prequeries = (
556
+ {
557
+ prequery.id or f"prequery_{index}": prequery_result
558
+ for index, (prequery, prequery_result) in enumerate(
559
+ self.prequeries_results
560
+ )
561
+ }
562
+ if self.prequeries_results
563
+ else None
564
+ )
565
+ return SyncAskResponse(
566
+ answer=NOT_ENOUGH_CONTEXT_ANSWER,
567
+ retrieval_results=self.main_results,
568
+ prequeries=prequeries,
569
+ status=AnswerStatusCode.NO_RETRIEVAL_DATA.prettify(),
570
+ ).model_dump_json()
571
+
572
+
573
+ async def rephrase_query(
574
+ kbid: str,
575
+ chat_history: list[ChatContextMessage],
576
+ query: str,
577
+ user_id: str,
578
+ user_context: list[str],
579
+ generative_model: str | None = None,
580
+ chat_history_relevance_threshold: float | None = None,
581
+ ) -> RephraseResponse:
582
+ # NOTE: When moving /ask to RAO, this will need to change to whatever client/utility is used
583
+ # to call NUA predict (internally or externally in the case of onprem).
584
+ predict = get_predict()
585
+ req = RephraseModel(
586
+ question=query,
587
+ chat_history=chat_history,
588
+ user_id=user_id,
589
+ user_context=user_context,
590
+ generative_model=generative_model,
591
+ chat_history_relevance_threshold=chat_history_relevance_threshold,
592
+ )
593
+ return await predict.rephrase_query(kbid, req)
594
+
595
+
596
+ async def ask(
597
+ *,
598
+ search_sdk: NucliaDBAsync,
599
+ reader_sdk: NucliaDBAsync,
600
+ kbid: str,
601
+ ask_request: AskRequest,
602
+ user_id: str,
603
+ client_type: NucliaDBClientType,
604
+ origin: str,
605
+ resource: str | None = None,
606
+ extra_predict_headers: dict[str, str] | None = None,
607
+ ) -> AskResult:
608
+ metrics = AskMetrics()
609
+ chat_history = ask_request.chat_history or []
610
+ user_context = ask_request.extra_context or []
611
+ user_query = ask_request.query
612
+
613
+ # Maybe rephrase the query
614
+ rephrased_query = None
615
+ if len(chat_history) > 0 or len(user_context) > 0:
616
+ try:
617
+ with metrics.time("rephrase"):
618
+ rephrase_response = await rephrase_query(
619
+ kbid,
620
+ chat_history=chat_history,
621
+ query=user_query,
622
+ user_id=user_id,
623
+ user_context=user_context,
624
+ generative_model=ask_request.generative_model,
625
+ chat_history_relevance_threshold=ask_request.chat_history_relevance_threshold,
626
+ )
627
+ rephrased_query = rephrase_response.rephrased_query
628
+ if rephrase_response.use_chat_history is False:
629
+ # Ignored if the question is not relevant enough with the chat history
630
+ logger.info("Chat history was ignored for this request")
631
+ chat_history = []
632
+
633
+ except RephraseMissingContextError:
634
+ logger.info("Failed to rephrase ask query, using original")
635
+
636
+ try:
637
+ with metrics.time("retrieval"):
638
+ retrieval_results = await retrieval_step(
639
+ reader_sdk=reader_sdk,
640
+ search_sdk=search_sdk,
641
+ kbid=kbid,
642
+ # Prefer the rephrased query for retrieval if available
643
+ main_query=rephrased_query or user_query,
644
+ ask_request=ask_request,
645
+ client_type=client_type,
646
+ user_id=user_id,
647
+ origin=origin,
648
+ metrics=metrics,
649
+ resource=resource,
650
+ )
651
+ except NoRetrievalResultsError as err:
652
+ # TODO: maybe audit chat (see nucliadb for more context)
653
+
654
+ # If a retrieval was attempted but no results were found,
655
+ # early return the ask endpoint without querying the generative model
656
+ return NotEnoughContextAskResult(
657
+ main_results=err.main_query,
658
+ prequeries_results=err.prequeries, # type: ignore
659
+ )
660
+
661
+ # parse ask request generation parameters reusing the same fetcher as
662
+ # retrieval, to avoid multiple round trips to Predict API
663
+ generation = await parse_ask(kbid, ask_request, fetcher=retrieval_results.fetcher)
664
+
665
+ # Now we build the prompt context
666
+ with metrics.time("context_building"):
667
+ prompt_context_builder = PromptContextBuilder(
668
+ search_sdk=search_sdk,
669
+ reader_sdk=reader_sdk,
670
+ kbid=kbid,
671
+ ordered_paragraphs=[
672
+ match.paragraph for match in retrieval_results.best_matches
673
+ ],
674
+ resource=resource,
675
+ user_context=user_context,
676
+ user_image_context=ask_request.extra_context_images,
677
+ strategies=ask_request.rag_strategies,
678
+ image_strategies=ask_request.rag_images_strategies,
679
+ max_context_characters=tokens_to_chars(generation.max_context_tokens),
680
+ visual_llm=generation.use_visual_llm,
681
+ query_image=ask_request.query_image,
682
+ metrics=metrics.child_span("context_building"),
683
+ )
684
+
685
+ (
686
+ prompt_context,
687
+ prompt_context_order,
688
+ prompt_context_images,
689
+ augmented_context,
690
+ ) = await prompt_context_builder.build()
691
+
692
+ # Make the chat request to the predict API
693
+ custom_prompt = parse_custom_prompt(ask_request)
694
+ chat_model = ChatModel(
695
+ user_id=user_id,
696
+ system=custom_prompt.system,
697
+ user_prompt=UserPrompt(prompt=custom_prompt.user)
698
+ if custom_prompt.user
699
+ else None,
700
+ query_context=prompt_context,
701
+ query_context_order=prompt_context_order,
702
+ chat_history=chat_history,
703
+ question=user_query,
704
+ truncate=True,
705
+ citations=ask_request.citations,
706
+ citation_threshold=ask_request.citation_threshold,
707
+ generative_model=ask_request.generative_model,
708
+ seed=ask_request.generative_model_seed,
709
+ max_tokens=generation.max_answer_tokens,
710
+ query_context_images=prompt_context_images,
711
+ json_schema=ask_request.answer_json_schema,
712
+ rerank_context=False,
713
+ top_k=ask_request.top_k,
714
+ reasoning=ask_request.reasoning,
715
+ )
716
+ nuclia_learning_id = None
717
+ nuclia_learning_model = None
718
+ predict_answer_stream = None
719
+ if ask_request.generate_answer:
720
+ with metrics.time("stream_start"):
721
+ (
722
+ nuclia_learning_id,
723
+ nuclia_learning_model,
724
+ predict_answer_stream,
725
+ ) = await get_answer_stream(
726
+ kbid=kbid, item=chat_model, extra_headers=extra_predict_headers
727
+ )
728
+
729
+ auditor = ChatAuditor(
730
+ kbid=kbid,
731
+ user_id=user_id,
732
+ client_type=client_type,
733
+ origin=origin,
734
+ ask_request=ask_request,
735
+ user_query=user_query,
736
+ rephrased_query=rephrased_query,
737
+ retrieval_rephrased_query=retrieval_results.main_query.rephrased_query,
738
+ chat_history=chat_history,
739
+ learning_id=nuclia_learning_id,
740
+ query_context=prompt_context,
741
+ query_context_order=prompt_context_order,
742
+ model=nuclia_learning_model,
743
+ )
744
+ return AskResult(
745
+ kbid=kbid,
746
+ ask_request=ask_request,
747
+ main_results=retrieval_results.main_query,
748
+ prequeries_results=retrieval_results.prequeries,
749
+ nuclia_learning_id=nuclia_learning_id,
750
+ predict_answer_stream=predict_answer_stream,
751
+ prompt_context=prompt_context,
752
+ prompt_context_order=prompt_context_order,
753
+ auditor=auditor,
754
+ metrics=metrics,
755
+ best_matches=retrieval_results.best_matches,
756
+ debug_chat_model=chat_model,
757
+ augmented_context=augmented_context,
758
+ search_sdk=search_sdk,
759
+ )
760
+
761
+
762
+ def handled_ask_exceptions(func):
763
+ @functools.wraps(func)
764
+ async def wrapper(*args, **kwargs):
765
+ try:
766
+ return await func(*args, **kwargs)
767
+ except KnowledgeBoxNotFound:
768
+ return HTTPClientError(
769
+ status_code=404,
770
+ detail="Knowledge Box not found.",
771
+ )
772
+ except LimitsExceededError as exc:
773
+ return HTTPClientError(status_code=exc.status_code, detail=exc.detail)
774
+ except predict.ProxiedPredictAPIError as err:
775
+ return HTTPClientError(
776
+ status_code=err.status,
777
+ detail=err.detail,
778
+ )
779
+ except (IncompleteFindResultsError, NucliaDBError):
780
+ msg = "Temporary error on information retrieval. Please try again."
781
+ logger.exception(msg)
782
+ return HTTPClientError(
783
+ status_code=530,
784
+ detail=msg,
785
+ )
786
+ except predict.RephraseMissingContextError:
787
+ return HTTPClientError(
788
+ status_code=412,
789
+ detail="Unable to rephrase the query with the provided context.",
790
+ )
791
+ except predict.RephraseError as err:
792
+ msg = f"Temporary error while rephrasing the query. Please try again later. Error: {err}"
793
+ logger.info(msg)
794
+ return HTTPClientError(
795
+ status_code=529,
796
+ detail=msg,
797
+ )
798
+ except InvalidQueryError as exc:
799
+ return HTTPClientError(status_code=412, detail=str(exc))
800
+
801
+ return wrapper
802
+
803
+
804
+ def parse_prequeries(ask_request: AskRequest) -> PreQueriesStrategy | None:
805
+ query_ids = []
806
+ for rag_strategy in ask_request.rag_strategies:
807
+ if rag_strategy.name == RagStrategyName.PREQUERIES:
808
+ prequeries = cast(PreQueriesStrategy, rag_strategy)
809
+ # Give each query a unique id if they don't have one
810
+ for index, query in enumerate(prequeries.queries):
811
+ if query.id is None:
812
+ query.id = f"prequery_{index}"
813
+ if query.id in query_ids:
814
+ raise InvalidQueryError(
815
+ "rag_strategies",
816
+ "Prequeries must have unique ids",
817
+ )
818
+ query_ids.append(query.id)
819
+ return prequeries
820
+ return None
821
+
822
+
823
+ def parse_graph_strategy(ask_request: AskRequest) -> GraphStrategy | None:
824
+ for rag_strategy in ask_request.rag_strategies:
825
+ if rag_strategy.name == RagStrategyName.GRAPH:
826
+ return cast(GraphStrategy, rag_strategy)
827
+ return None
828
+
829
+
830
+ async def retrieval_step(
831
+ *,
832
+ reader_sdk: NucliaDBAsync,
833
+ search_sdk: NucliaDBAsync,
834
+ kbid: str,
835
+ main_query: str,
836
+ ask_request: AskRequest,
837
+ client_type: NucliaDBClientType,
838
+ user_id: str,
839
+ origin: str,
840
+ metrics: Metrics,
841
+ resource: str | None = None,
842
+ ) -> RetrievalResults:
843
+ """
844
+ This function encapsulates all the logic related to retrieval in the ask endpoint.
845
+ """
846
+ if resource is None:
847
+ return await retrieval_in_kb(
848
+ kbid,
849
+ main_query,
850
+ ask_request,
851
+ client_type,
852
+ user_id,
853
+ origin,
854
+ metrics,
855
+ reader_sdk=reader_sdk,
856
+ search_sdk=search_sdk,
857
+ )
858
+ else:
859
+ return await retrieval_in_resource(
860
+ kbid,
861
+ resource,
862
+ main_query,
863
+ ask_request,
864
+ client_type,
865
+ user_id,
866
+ origin,
867
+ metrics,
868
+ reader_sdk=reader_sdk,
869
+ search_sdk=search_sdk,
870
+ )
871
+
872
+
873
+ async def retrieval_in_kb(
874
+ kbid: str,
875
+ main_query: str,
876
+ ask_request: AskRequest,
877
+ client_type: NucliaDBClientType,
878
+ user_id: str,
879
+ origin: str,
880
+ metrics: Metrics,
881
+ *,
882
+ reader_sdk: NucliaDBAsync,
883
+ search_sdk: NucliaDBAsync,
884
+ ) -> RetrievalResults:
885
+ prequeries = parse_prequeries(ask_request)
886
+ graph_strategy = parse_graph_strategy(ask_request)
887
+ main_results, prequeries_results, fetcher, reranker = await get_find_results(
888
+ kbid=kbid,
889
+ query=main_query,
890
+ item=ask_request,
891
+ ndb_client=client_type,
892
+ user=user_id,
893
+ origin=origin,
894
+ metrics=metrics.child_span("hybrid_retrieval"),
895
+ reader_sdk=reader_sdk,
896
+ search_sdk=search_sdk,
897
+ prequeries_strategy=prequeries,
898
+ )
899
+
900
+ if graph_strategy is not None:
901
+ graph_results, graph_request = await get_graph_results(
902
+ search_sdk=search_sdk,
903
+ kbid=kbid,
904
+ query=main_query,
905
+ item=ask_request,
906
+ ndb_client=client_type,
907
+ user=user_id,
908
+ origin=origin,
909
+ graph_strategy=graph_strategy,
910
+ metrics=metrics.child_span("graph_retrieval"),
911
+ text_block_reranker=reranker,
912
+ )
913
+
914
+ if prequeries_results is None:
915
+ prequeries_results = []
916
+
917
+ prequery = PreQuery(
918
+ id="graph", request=graph_request, weight=graph_strategy.weight
919
+ )
920
+ prequeries_results.append((prequery, graph_results))
921
+
922
+ if len(main_results.resources) == 0 and all(
923
+ len(prequery_result.resources) == 0
924
+ for (_, prequery_result) in prequeries_results or []
925
+ ):
926
+ raise NoRetrievalResultsError(main_results, prequeries_results) # type: ignore
927
+
928
+ main_query_weight = prequeries.main_query_weight if prequeries is not None else 1.0
929
+ best_matches = compute_best_matches(
930
+ main_results=main_results,
931
+ prequeries_results=prequeries_results,
932
+ main_query_weight=main_query_weight,
933
+ )
934
+ return RetrievalResults(
935
+ main_query=main_results,
936
+ prequeries=prequeries_results,
937
+ fetcher=fetcher,
938
+ main_query_weight=main_query_weight,
939
+ best_matches=best_matches,
940
+ )
941
+
942
+
943
+ async def retrieval_in_resource(
944
+ kbid: str,
945
+ resource: str,
946
+ main_query: str,
947
+ ask_request: AskRequest,
948
+ client_type: NucliaDBClientType,
949
+ user_id: str,
950
+ origin: str,
951
+ metrics: Metrics,
952
+ *,
953
+ reader_sdk: NucliaDBAsync,
954
+ search_sdk: NucliaDBAsync,
955
+ ) -> RetrievalResults:
956
+ if any(strategy.name == "full_resource" for strategy in ask_request.rag_strategies):
957
+ # Retrieval is not needed if we are chatting on a specific resource and the full_resource strategy is enabled
958
+ return RetrievalResults(
959
+ main_query=KnowledgeboxFindResults(resources={}, min_score=None),
960
+ prequeries=None,
961
+ fetcher=fetcher_for_ask(kbid, ask_request),
962
+ main_query_weight=1.0,
963
+ )
964
+
965
+ prequeries = parse_prequeries(ask_request)
966
+ if (
967
+ prequeries is None
968
+ and ask_request.answer_json_schema is not None
969
+ and main_query == ""
970
+ ):
971
+ prequeries = calculate_prequeries_for_json_schema(ask_request)
972
+
973
+ # Make sure the retrieval is scoped to the resource if provided
974
+ add_resource_filter(ask_request, [resource])
975
+ if prequeries is not None:
976
+ for prequery in prequeries.queries:
977
+ if prequery.prefilter is True:
978
+ raise InvalidQueryError(
979
+ "rag_strategies",
980
+ "Prequeries with prefilter are not supported when asking on a resource",
981
+ )
982
+ add_resource_filter(prequery.request, [resource])
983
+
984
+ main_results, prequeries_results, fetcher, _ = await get_find_results(
985
+ kbid=kbid,
986
+ query=main_query,
987
+ item=ask_request,
988
+ ndb_client=client_type,
989
+ user=user_id,
990
+ origin=origin,
991
+ metrics=metrics.child_span("hybrid_retrieval"),
992
+ reader_sdk=reader_sdk,
993
+ search_sdk=search_sdk,
994
+ prequeries_strategy=prequeries,
995
+ )
996
+ if len(main_results.resources) == 0 and all(
997
+ len(prequery_result.resources) == 0
998
+ for (_, prequery_result) in prequeries_results or []
999
+ ):
1000
+ raise NoRetrievalResultsError(main_results, prequeries_results) # type: ignore
1001
+ main_query_weight = prequeries.main_query_weight if prequeries is not None else 1.0
1002
+ best_matches = compute_best_matches(
1003
+ main_results=main_results,
1004
+ prequeries_results=prequeries_results,
1005
+ main_query_weight=main_query_weight,
1006
+ )
1007
+ return RetrievalResults(
1008
+ main_query=main_results,
1009
+ prequeries=prequeries_results,
1010
+ fetcher=fetcher,
1011
+ main_query_weight=main_query_weight,
1012
+ best_matches=best_matches,
1013
+ )
1014
+
1015
+
1016
+ class _FindParagraph(ScoredTextBlock):
1017
+ original: FindParagraph
1018
+
1019
+
1020
+ def compute_best_matches(
1021
+ main_results: KnowledgeboxFindResults,
1022
+ prequeries_results: list[PreQueryResult] | None = None,
1023
+ main_query_weight: float = 1.0,
1024
+ ) -> list[RetrievalMatch]:
1025
+ """
1026
+ Returns the list of matches of the retrieval results, ordered by relevance (descending weighted score).
1027
+
1028
+ If prequeries_results is provided, the paragraphs of the prequeries are weighted according to the
1029
+ normalized weight of the prequery. The paragraph score is not modified, but it is used to determine the order in which they
1030
+ are presented in the LLM prompt context.
1031
+
1032
+ If a paragraph is matched in various prequeries, the final weighted score is the sum of the weighted scores for each prequery.
1033
+
1034
+ `main_query_weight` is the weight given to the paragraphs matching the main query when calculating the final score.
1035
+ """
1036
+
1037
+ score_type_map = {
1038
+ SCORE_TYPE.VECTOR: SemanticScore,
1039
+ SCORE_TYPE.BM25: KeywordScore,
1040
+ SCORE_TYPE.BOTH: RrfScore, # /find only exposes RRF as rank fusion algorithm
1041
+ SCORE_TYPE.RERANKER: RerankerScore,
1042
+ SCORE_TYPE.RELATION_RELEVANCE: GraphScore,
1043
+ }
1044
+
1045
+ def extract_paragraphs(results: KnowledgeboxFindResults) -> list[_FindParagraph]:
1046
+ paragraphs = []
1047
+ for resource in results.resources.values():
1048
+ for field in resource.fields.values():
1049
+ for paragraph in field.paragraphs.values():
1050
+ # TODO(decoupled-ask): we don't know the score history, as
1051
+ # we are using find results. Once we move boolean queries
1052
+ # inside the new retrieval flow we'll move this and have the
1053
+ # proper information to do this rank fusion
1054
+ paragraphs.append(
1055
+ _FindParagraph(
1056
+ paragraph_id=ParagraphId.from_string(paragraph.id),
1057
+ scores=[
1058
+ score_type_map[paragraph.score_type](
1059
+ score=paragraph.score
1060
+ )
1061
+ ],
1062
+ score_type=paragraph.score_type,
1063
+ original=paragraph,
1064
+ )
1065
+ )
1066
+ return paragraphs
1067
+
1068
+ weights = {
1069
+ "main": main_query_weight,
1070
+ }
1071
+ total_weight = main_query_weight
1072
+ find_results = {
1073
+ "main": extract_paragraphs(main_results),
1074
+ }
1075
+ for i, (prequery, prequery_results) in enumerate(prequeries_results or []):
1076
+ weights[f"prequery-{i}"] = prequery.weight
1077
+ total_weight += prequery.weight
1078
+ find_results[f"prequery-{i}"] = extract_paragraphs(prequery_results)
1079
+
1080
+ normalized_weights = {key: value / total_weight for key, value in weights.items()}
1081
+
1082
+ # window does nothing here
1083
+ rank_fusion = WeightedCombSum(window=0, weights=normalized_weights)
1084
+
1085
+ merged = []
1086
+ for item in rank_fusion.fuse(find_results):
1087
+ match = RetrievalMatch(
1088
+ paragraph=item.original,
1089
+ weighted_score=item.score,
1090
+ )
1091
+ merged.append(match)
1092
+ return merged
1093
+
1094
+
1095
+ def calculate_prequeries_for_json_schema(
1096
+ ask_request: AskRequest,
1097
+ ) -> PreQueriesStrategy | None:
1098
+ """
1099
+ This function generates a PreQueriesStrategy with a query for each property in the JSON schema
1100
+ found in ask_request.answer_json_schema.
1101
+
1102
+ This is useful for the use-case where the user is asking for a structured answer on a corpus
1103
+ that is too big to send to the generative model.
1104
+
1105
+ For instance, a JSON schema like this:
1106
+ {
1107
+ "name": "book_ordering",
1108
+ "description": "Structured answer for a book to order",
1109
+ "parameters": {
1110
+ "type": "object",
1111
+ "properties": {
1112
+ "title": {
1113
+ "type": "string",
1114
+ "description": "The title of the book"
1115
+ },
1116
+ "author": {
1117
+ "type": "string",
1118
+ "description": "The author of the book"
1119
+ },
1120
+ },
1121
+ "required": ["title", "author"]
1122
+ }
1123
+ }
1124
+ Will generate a PreQueriesStrategy with 2 queries, one for each property in the JSON schema, with equal weights
1125
+ [
1126
+ PreQuery(request=FindRequest(query="The title of the book", ...), weight=1.0),
1127
+ PreQuery(request=FindRequest(query="The author of the book", ...), weight=1.0),
1128
+ ]
1129
+ """
1130
+ prequeries: list[PreQuery] = []
1131
+ json_schema = ask_request.answer_json_schema or {}
1132
+ features = []
1133
+ if ChatOptions.SEMANTIC in ask_request.features:
1134
+ features.append(FindOptions.SEMANTIC)
1135
+ if ChatOptions.KEYWORD in ask_request.features:
1136
+ features.append(FindOptions.KEYWORD)
1137
+
1138
+ properties = json_schema.get("parameters", {}).get("properties", {})
1139
+ if len(properties) == 0: # pragma: no cover
1140
+ return None
1141
+ for prop_name, prop_def in properties.items():
1142
+ query = prop_name
1143
+ if prop_def.get("description"):
1144
+ query += f": {prop_def['description']}"
1145
+ req = FindRequest(
1146
+ query=query,
1147
+ features=features,
1148
+ filters=[],
1149
+ keyword_filters=[],
1150
+ top_k=10,
1151
+ min_score=ask_request.min_score,
1152
+ vectorset=ask_request.vectorset,
1153
+ highlight=False,
1154
+ debug=False,
1155
+ show=[],
1156
+ with_duplicates=False,
1157
+ with_synonyms=False,
1158
+ resource_filters=[], # to be filled with the resource filter
1159
+ rephrase=ask_request.rephrase,
1160
+ rephrase_prompt=parse_rephrase_prompt(ask_request),
1161
+ security=ask_request.security,
1162
+ )
1163
+ prequery = PreQuery(
1164
+ request=req,
1165
+ weight=1.0,
1166
+ )
1167
+ prequeries.append(prequery)
1168
+ try:
1169
+ strategy = PreQueriesStrategy(queries=prequeries)
1170
+ except ValidationError:
1171
+ raise AnswerJsonSchemaTooLong(
1172
+ "Answer JSON schema with too many properties generated too many prequeries"
1173
+ )
1174
+
1175
+ ask_request.rag_strategies = [strategy]
1176
+ return strategy
1177
+
1178
+
1179
+ def tokens_to_chars(n_tokens: int) -> int:
1180
+ # Multiply by 3 to have a good margin and guess between characters and tokens.
1181
+ # This will be properly cut at the NUA predict API.
1182
+ return n_tokens * 3