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,1298 @@
1
+ import copy
2
+ from collections.abc import Sequence
3
+ from dataclasses import dataclass
4
+ from typing import cast
5
+
6
+ import nucliadb_models
7
+ import yaml
8
+ from nucliadb_models.augment import (
9
+ AugmentedConversationField,
10
+ AugmentedField,
11
+ AugmentedFileField,
12
+ AugmentFields,
13
+ AugmentParagraph,
14
+ AugmentParagraphs,
15
+ AugmentRequest,
16
+ AugmentResourceFields,
17
+ AugmentResources,
18
+ )
19
+ from nucliadb_models.common import FieldTypeName
20
+ from nucliadb_models.labels import translate_alias_to_system_label
21
+ from nucliadb_models.search import SCORE_TYPE, FindParagraph
22
+ from nucliadb_protos.resources_pb2 import FieldComputedMetadata
23
+ from nucliadb_sdk import NucliaDBAsync
24
+ from pydantic import BaseModel
25
+
26
+ from hyperforge_nucliadb_agentic.ask import logger
27
+ from hyperforge_nucliadb_agentic.ask.model import (
28
+ AugmentedContext,
29
+ AugmentedTextBlock,
30
+ ConversationalStrategy,
31
+ FieldExtensionStrategy,
32
+ FullResourceStrategy,
33
+ HierarchyResourceStrategy,
34
+ Image,
35
+ ImageRagStrategy,
36
+ ImageRagStrategyName,
37
+ MetadataExtensionStrategy,
38
+ MetadataExtensionType,
39
+ NeighbouringParagraphsStrategy,
40
+ PageImageStrategy,
41
+ ParagraphImageStrategy,
42
+ PromptContext,
43
+ PromptContextImages,
44
+ PromptContextOrder,
45
+ RagStrategy,
46
+ RagStrategyName,
47
+ TableImageStrategy,
48
+ TextBlockAugmentationType,
49
+ TextPosition,
50
+ )
51
+ from hyperforge_nucliadb_agentic.ask.search import rpc
52
+ from hyperforge_nucliadb_agentic.ask.search.metrics import Metrics
53
+ from hyperforge_nucliadb_agentic.ask.utils.ids import (
54
+ FIELD_TYPE_STR_TO_NAME,
55
+ FieldId,
56
+ ParagraphId,
57
+ )
58
+
59
+ TextBlockId = ParagraphId | FieldId
60
+
61
+
62
+ class CappedPromptContext:
63
+ """
64
+ Class to keep track of the size (in number of characters) of the prompt context
65
+ and automatically trim data that exceeds the limit when it's being set on the dictionary.
66
+ """
67
+
68
+ def __init__(self, max_size: int | None):
69
+ self.output: PromptContext = {}
70
+ self.images: PromptContextImages = {}
71
+ self.max_size = max_size
72
+
73
+ def __setitem__(self, key: str, value: str) -> None:
74
+ self.output.__setitem__(key, value)
75
+
76
+ def __getitem__(self, key: str) -> str:
77
+ return self.output.__getitem__(key)
78
+
79
+ def __contains__(self, key: str) -> bool:
80
+ return key in self.output
81
+
82
+ def __delitem__(self, key: str) -> None:
83
+ try:
84
+ self.output.__delitem__(key)
85
+ except KeyError:
86
+ pass
87
+
88
+ def text_block_ids(self) -> list[str]:
89
+ return list(self.output.keys())
90
+
91
+ @property
92
+ def size(self) -> int:
93
+ """
94
+ Returns the total size of the context in characters.
95
+ """
96
+ return sum(len(text) for text in self.output.values())
97
+
98
+ def cap(self) -> dict[str, str]:
99
+ """
100
+ This method will trim the context to the maximum size if it exceeds it.
101
+ It will remove text from the most recent entries first, until the size is below the limit.
102
+ """
103
+ if self.max_size is None:
104
+ return self.output
105
+
106
+ if self.size <= self.max_size:
107
+ return self.output
108
+
109
+ logger.info("Removing text from context to fit within the max size limit")
110
+ # Iterate the dictionary in reverse order of insertion
111
+ for key in reversed(list(self.output.keys())):
112
+ current_size = self.size
113
+ if current_size <= self.max_size:
114
+ break
115
+ # Remove text from the value
116
+ text = self.output[key]
117
+ # If removing the whole text still keeps the total size above the limit, remove it
118
+ if current_size - len(text) >= self.max_size:
119
+ del self.output[key]
120
+ else:
121
+ # Otherwise, trim the text to fit within the limit
122
+ excess_size = current_size - self.max_size
123
+ if excess_size > 0:
124
+ trimmed_text = text[:-excess_size]
125
+ self.output[key] = trimmed_text
126
+ return self.output
127
+
128
+
129
+ async def default_prompt_context(
130
+ search_sdk: NucliaDBAsync,
131
+ context: CappedPromptContext,
132
+ kbid: str,
133
+ ordered_paragraphs: list[FindParagraph],
134
+ ) -> None:
135
+ """
136
+ - Updates context (which is an ordered dict of text_block_id -> context_text).
137
+ - text_block_id is typically the paragraph id, but has a special value for the
138
+ user context. (USER_CONTEXT_0, USER_CONTEXT_1, ...)
139
+ - Paragraphs are inserted in order of relevance, by increasing `order` field
140
+ of the find result paragraphs.
141
+ - User context is inserted first, in order of appearance.
142
+ - Using an dict prevents from duplicates pulled in through conversation expansion.
143
+ """
144
+
145
+ conversations = []
146
+
147
+ for paragraph in ordered_paragraphs:
148
+ context[paragraph.id] = _clean_paragraph_text(paragraph)
149
+
150
+ # If the paragraph is a conversation and it matches semantically, we
151
+ # assume we have matched with the question, therefore try to include the
152
+ # answer to the context by pulling the next few messages of the
153
+ # conversation field
154
+ rid, field_type, field_id, mident = paragraph.id.split("/")[:4]
155
+ # FIXME: a semantic paragraph can have reranker score. Once we
156
+ # refactor and have access to the score history, we can fix this
157
+ if field_type == "c" and paragraph.score_type in (
158
+ SCORE_TYPE.VECTOR,
159
+ SCORE_TYPE.BOTH,
160
+ ):
161
+ conversations.append(f"{rid}/{field_type}/{field_id}/{mident}")
162
+
163
+ augment = AugmentRequest(
164
+ fields=[
165
+ AugmentFields(
166
+ given=[id for id in conversations],
167
+ conversation_answer_or_messages_after=True,
168
+ ),
169
+ ]
170
+ )
171
+ augmented = await rpc.augment(search_sdk, kbid, augment)
172
+
173
+ for id in conversations:
174
+ conversation_id = FieldId.from_string(id)
175
+
176
+ augmented_field = augmented.fields.get(conversation_id.full_without_subfield())
177
+ if augmented_field is None or not isinstance(
178
+ augmented_field, AugmentedConversationField
179
+ ):
180
+ continue
181
+
182
+ for message in augmented_field.messages or []:
183
+ if message.text is None:
184
+ continue
185
+
186
+ message_id = copy.copy(conversation_id)
187
+ message_id.subfield_id = message.ident
188
+ pid = ParagraphId(
189
+ field_id=message_id, paragraph_start=0, paragraph_end=len(message.text)
190
+ ).full()
191
+ context[pid] = message.text
192
+
193
+
194
+ async def full_resource_prompt_context(
195
+ search_sdk: NucliaDBAsync,
196
+ context: CappedPromptContext,
197
+ kbid: str,
198
+ ordered_paragraphs: list[FindParagraph],
199
+ rid: str | None,
200
+ strategy: FullResourceStrategy,
201
+ metrics: Metrics,
202
+ augmented_context: AugmentedContext,
203
+ ) -> None:
204
+ """
205
+ Algorithm steps:
206
+ - Collect the list of resources in the results (in order of relevance).
207
+ - For each resource, collect the extracted text from all its fields and craft the context.
208
+ Arguments:
209
+ context: The context to be updated.
210
+ kbid: The knowledge box id.
211
+ ordered_paragraphs: The results of the retrieval (find) operation.
212
+ resource: The resource to be included in the context. This is used only when chatting with a specific resource with no retrieval.
213
+ strategy: strategy instance containing, for example, the number of full resources to include in the context.
214
+ """
215
+ if rid is not None:
216
+ # The user has specified a resource to be included in the context.
217
+ ordered_resources = [rid]
218
+ else:
219
+ # Collect the list of resources in the results (in order of relevance).
220
+ ordered_resources = []
221
+ for paragraph in ordered_paragraphs:
222
+ rid = parse_text_block_id(paragraph.id).rid
223
+ if rid not in ordered_resources:
224
+ skip = False
225
+ if strategy.apply_to is not None:
226
+ # decide whether the resource should be extended or not
227
+ for label in strategy.apply_to.exclude:
228
+ skip = skip or (
229
+ translate_alias_to_system_label(label)
230
+ in (paragraph.labels or [])
231
+ )
232
+
233
+ if not skip:
234
+ ordered_resources.append(rid)
235
+ # skip when we have enough resource ids
236
+ if (
237
+ strategy.count is not None
238
+ and len(ordered_resources) > strategy.count
239
+ ):
240
+ break
241
+
242
+ ordered_resources = ordered_resources[: strategy.count]
243
+
244
+ # For each resource, collect the extracted text from all its fields and
245
+ # include the title and summary as well
246
+ augmented = await rpc.augment(
247
+ search_sdk,
248
+ kbid,
249
+ AugmentRequest(
250
+ resources=[
251
+ AugmentResources(
252
+ given=ordered_resources,
253
+ title=True,
254
+ summary=True,
255
+ fields=AugmentResourceFields(
256
+ text=True,
257
+ filters=[],
258
+ ),
259
+ )
260
+ ]
261
+ ),
262
+ )
263
+
264
+ extracted_texts = {}
265
+ for rid, resource in augmented.resources.items():
266
+ if resource.title is not None:
267
+ field_id = FieldId(rid=rid, type="a", key="title").full()
268
+ extracted_texts[field_id] = resource.title
269
+ if resource.summary is not None:
270
+ field_id = FieldId(rid=rid, type="a", key="summary").full()
271
+ extracted_texts[field_id] = resource.summary
272
+
273
+ for field_id, field in augmented.fields.items():
274
+ field = cast(AugmentedField, field)
275
+ if field.text is not None:
276
+ extracted_texts[field_id] = field.text
277
+
278
+ added_fields = set()
279
+ for field_id, extracted_text in extracted_texts.items():
280
+ # First off, remove the text block ids from paragraphs that belong to
281
+ # the same field, as otherwise the context will be duplicated.
282
+ for tb_id in context.text_block_ids():
283
+ if tb_id.startswith(field_id):
284
+ del context[tb_id]
285
+ # Add the extracted text of each field to the context.
286
+ context[field_id] = extracted_text
287
+ augmented_context.fields[field_id] = AugmentedTextBlock(
288
+ id=field_id,
289
+ text=extracted_text,
290
+ augmentation_type=TextBlockAugmentationType.FULL_RESOURCE,
291
+ )
292
+
293
+ added_fields.add(field_id)
294
+
295
+ metrics.set("full_resource_ops", len(added_fields))
296
+
297
+ if strategy.include_remaining_text_blocks:
298
+ for paragraph in ordered_paragraphs:
299
+ pid = cast(ParagraphId, parse_text_block_id(paragraph.id))
300
+ if pid.field_id.full() not in added_fields:
301
+ context[paragraph.id] = _clean_paragraph_text(paragraph)
302
+
303
+
304
+ async def extend_prompt_context_with_metadata(
305
+ search_sdk: NucliaDBAsync,
306
+ context: CappedPromptContext,
307
+ kbid: str,
308
+ strategy: MetadataExtensionStrategy,
309
+ metrics: Metrics,
310
+ augmented_context: AugmentedContext,
311
+ ) -> None:
312
+ rids: list[str] = []
313
+ field_ids: list[str] = []
314
+ text_block_ids: list[TextBlockId] = []
315
+ for text_block_id in context.text_block_ids():
316
+ try:
317
+ tb_id = parse_text_block_id(text_block_id)
318
+ except ValueError: # pragma: no cover
319
+ # Some text block ids are not paragraphs nor fields, so they are skipped
320
+ # (e.g. USER_CONTEXT_0, when the user provides extra context)
321
+ continue
322
+
323
+ field_id = tb_id if isinstance(tb_id, FieldId) else tb_id.field_id
324
+
325
+ text_block_ids.append(tb_id)
326
+ field_ids.append(field_id.full())
327
+ rids.append(tb_id.rid)
328
+
329
+ if len(text_block_ids) == 0: # pragma: no cover
330
+ return
331
+
332
+ resource_origin = False
333
+ resource_extra = False
334
+ classification_labels = False
335
+ field_entities = False
336
+
337
+ ops = 0
338
+ if MetadataExtensionType.ORIGIN in strategy.types:
339
+ ops += 1
340
+ resource_origin = True
341
+
342
+ if MetadataExtensionType.CLASSIFICATION_LABELS in strategy.types:
343
+ ops += 1
344
+ classification_labels = True
345
+
346
+ if MetadataExtensionType.NERS in strategy.types:
347
+ ops += 1
348
+ field_entities = True
349
+
350
+ if MetadataExtensionType.EXTRA_METADATA in strategy.types:
351
+ ops += 1
352
+ resource_extra = True
353
+
354
+ metrics.set("metadata_extension_ops", ops * len(text_block_ids))
355
+
356
+ augment_req = AugmentRequest()
357
+ if resource_origin or resource_extra or classification_labels:
358
+ augment_req.resources = [
359
+ AugmentResources(
360
+ given=rids,
361
+ origin=resource_origin,
362
+ extra=resource_extra,
363
+ classification_labels=classification_labels,
364
+ )
365
+ ]
366
+ if classification_labels or field_entities:
367
+ augment_req.fields = [
368
+ AugmentFields(
369
+ given=field_ids,
370
+ classification_labels=classification_labels,
371
+ entities=field_entities,
372
+ )
373
+ ]
374
+
375
+ if augment_req.resources is None and augment_req.fields is None:
376
+ # nothing to augment
377
+ return
378
+
379
+ augmented = await rpc.augment(search_sdk, kbid, augment_req)
380
+
381
+ for tb_id in text_block_ids:
382
+ field_id = tb_id if isinstance(tb_id, FieldId) else tb_id.field_id
383
+
384
+ resource = augmented.resources.get(tb_id.rid)
385
+ field = augmented.fields.get(field_id.full())
386
+
387
+ if resource is not None:
388
+ if resource.origin is not None:
389
+ text = context.output.pop(tb_id.full())
390
+ extended_text = (
391
+ text
392
+ + f"\n\nDOCUMENT METADATA AT ORIGIN:\n{to_yaml(resource.origin)}"
393
+ )
394
+ context[tb_id.full()] = extended_text
395
+ augmented_context.paragraphs[tb_id.full()] = AugmentedTextBlock(
396
+ id=tb_id.full(),
397
+ text=extended_text,
398
+ parent=tb_id.full(),
399
+ augmentation_type=TextBlockAugmentationType.METADATA_EXTENSION,
400
+ )
401
+
402
+ if resource.extra is not None:
403
+ text = context.output.pop(tb_id.full())
404
+ extended_text = (
405
+ text + f"\n\nDOCUMENT EXTRA METADATA:\n{to_yaml(resource.extra)}"
406
+ )
407
+ context[tb_id.full()] = extended_text
408
+ augmented_context.paragraphs[tb_id.full()] = AugmentedTextBlock(
409
+ id=tb_id.full(),
410
+ text=extended_text,
411
+ parent=tb_id.full(),
412
+ augmentation_type=TextBlockAugmentationType.METADATA_EXTENSION,
413
+ )
414
+
415
+ if tb_id.full() in context:
416
+ if (resource is not None and resource.classification_labels) or (
417
+ field is not None and field.classification_labels
418
+ ):
419
+ text = context.output.pop(tb_id.full())
420
+
421
+ labels_text = "DOCUMENT CLASSIFICATION LABELS:"
422
+ if resource is not None and resource.classification_labels:
423
+ for labelset, labels in resource.classification_labels.items():
424
+ for label in labels:
425
+ labels_text += f"\n - {label} ({labelset})"
426
+
427
+ if field is not None and field.classification_labels:
428
+ for labelset, labels in field.classification_labels.items():
429
+ for label in labels:
430
+ labels_text += f"\n - {label} ({labelset})"
431
+
432
+ extended_text = text + "\n\n" + labels_text
433
+
434
+ context[tb_id.full()] = extended_text
435
+ augmented_context.paragraphs[tb_id.full()] = AugmentedTextBlock(
436
+ id=tb_id.full(),
437
+ text=extended_text,
438
+ parent=tb_id.full(),
439
+ augmentation_type=TextBlockAugmentationType.METADATA_EXTENSION,
440
+ )
441
+
442
+ if field is not None and field.entities:
443
+ ners = field.entities
444
+
445
+ text = context.output.pop(tb_id.full())
446
+
447
+ ners_text = "DOCUMENT NAMED ENTITIES (NERs):"
448
+ for family, tokens in ners.items():
449
+ ners_text += f"\n - {family}:"
450
+ for token in sorted(list(tokens)):
451
+ ners_text += f"\n - {token}"
452
+
453
+ extended_text = text + "\n\n" + ners_text
454
+
455
+ context[tb_id.full()] = extended_text
456
+ augmented_context.paragraphs[tb_id.full()] = AugmentedTextBlock(
457
+ id=tb_id.full(),
458
+ text=extended_text,
459
+ parent=tb_id.full(),
460
+ augmentation_type=TextBlockAugmentationType.METADATA_EXTENSION,
461
+ )
462
+
463
+
464
+ def parse_text_block_id(text_block_id: str) -> TextBlockId:
465
+ try:
466
+ # Typically, the text block id is a paragraph id
467
+ return ParagraphId.from_string(text_block_id)
468
+ except ValueError:
469
+ # When we're doing `full_resource` or `hierarchy` strategies,the text block id
470
+ # is a field id
471
+ return FieldId.from_string(text_block_id)
472
+
473
+
474
+ def to_yaml(obj: BaseModel) -> str:
475
+ # FIXME: this dumps enums REALLY poorly, e.g.,
476
+ # `!!python/object/apply:nucliadb_models.metadata.Source\n- WEB` for
477
+ # Source.WEB instead of `WEB`
478
+ return yaml.dump(
479
+ obj.model_dump(exclude_none=True, exclude_defaults=True, exclude_unset=True),
480
+ default_flow_style=False,
481
+ indent=2,
482
+ sort_keys=True,
483
+ )
484
+
485
+
486
+ async def field_extension_prompt_context(
487
+ search_sdk: NucliaDBAsync,
488
+ context: CappedPromptContext,
489
+ kbid: str,
490
+ ordered_paragraphs: list[FindParagraph],
491
+ strategy: FieldExtensionStrategy,
492
+ metrics: Metrics,
493
+ augmented_context: AugmentedContext,
494
+ ) -> None:
495
+ """
496
+ Algorithm steps:
497
+ - Collect the list of resources in the results (in order of relevance).
498
+ - For each resource, collect the extracted text from all its fields.
499
+ - Add the extracted text of each field to the beginning of the context.
500
+ - Add the extracted text of each paragraph to the end of the context.
501
+ """
502
+ ordered_resources = []
503
+ for paragraph in ordered_paragraphs:
504
+ resource_uuid = ParagraphId.from_string(paragraph.id).rid
505
+ if resource_uuid not in ordered_resources:
506
+ ordered_resources.append(resource_uuid)
507
+
508
+ resource_title = False
509
+ resource_summary = False
510
+ filters: list[
511
+ nucliadb_models.filters.Field | nucliadb_models.filters.Generated # type: ignore
512
+ ] = []
513
+ # this strategy exposes a way to access resource title and summary using a
514
+ # field id. However, as they are resource properties, we must request it as
515
+ # that
516
+ for name in strategy.fields:
517
+ if name == "a/title":
518
+ resource_title = True
519
+ elif name == "a/summary":
520
+ resource_summary = True
521
+ else:
522
+ # model already enforces type/name format
523
+ field_type, field_name = name.split("/")
524
+ filters.append(
525
+ nucliadb_models.filters.Field( # type: ignore
526
+ type=FIELD_TYPE_STR_TO_NAME[field_type], name=field_name or None
527
+ )
528
+ )
529
+
530
+ for da_prefix in strategy.data_augmentation_field_prefixes:
531
+ filters.append(
532
+ nucliadb_models.filters.Generated(by="data-augmentation", da_task=da_prefix) # type: ignore
533
+ )
534
+
535
+ augmented = await rpc.augment(
536
+ search_sdk,
537
+ kbid,
538
+ AugmentRequest(
539
+ resources=[
540
+ AugmentResources(
541
+ given=ordered_resources,
542
+ title=resource_title,
543
+ summary=resource_summary,
544
+ fields=AugmentResourceFields(
545
+ text=True,
546
+ filters=filters,
547
+ )
548
+ if len(filters) > 0
549
+ else None,
550
+ )
551
+ ]
552
+ ),
553
+ )
554
+
555
+ # REVIEW(decoupled-ask): we don't have the field count anymore, is this good enough?
556
+ metrics.set("field_extension_ops", len(ordered_resources))
557
+
558
+ extracted_texts = {}
559
+ # now we need to expose title and summary as fields again, so it gets
560
+ # consistent with the view we are providing in the API
561
+ for rid, augmented_resource in augmented.resources.items():
562
+ if augmented_resource.title:
563
+ extracted_texts[f"{rid}/a/title"] = augmented_resource.title
564
+ if augmented_resource.summary:
565
+ extracted_texts[f"{rid}/a/summary"] = augmented_resource.summary
566
+
567
+ for fid, augmented_field in augmented.fields.items():
568
+ if augmented_field is None or augmented_field.text is None: # pragma: no cover
569
+ continue
570
+ extracted_texts[fid] = augmented_field.text
571
+
572
+ for fid, extracted_text in extracted_texts.items():
573
+ # First off, remove the text block ids from paragraphs that belong to
574
+ # the same field, as otherwise the context will be duplicated.
575
+ for tb_id in context.text_block_ids():
576
+ if tb_id.startswith(fid):
577
+ del context[tb_id]
578
+ # Add the extracted text of each field to the beginning of the context.
579
+ if fid not in context:
580
+ context[fid] = extracted_text
581
+ augmented_context.fields[fid] = AugmentedTextBlock(
582
+ id=fid,
583
+ text=extracted_text,
584
+ augmentation_type=TextBlockAugmentationType.FIELD_EXTENSION,
585
+ )
586
+
587
+ # Add the extracted text of each paragraph to the end of the context.
588
+ for paragraph in ordered_paragraphs:
589
+ if paragraph.id not in context:
590
+ context[paragraph.id] = _clean_paragraph_text(paragraph)
591
+
592
+
593
+ async def neighbouring_paragraphs_prompt_context(
594
+ search_sdk: NucliaDBAsync,
595
+ context: CappedPromptContext,
596
+ kbid: str,
597
+ ordered_text_blocks: list[FindParagraph],
598
+ strategy: NeighbouringParagraphsStrategy,
599
+ metrics: Metrics,
600
+ augmented_context: AugmentedContext,
601
+ ) -> None:
602
+ """
603
+ This function will get the paragraph texts and then craft a context with the neighbouring paragraphs of the
604
+ paragraphs in the ordered_paragraphs list.
605
+ """
606
+ retrieved_paragraphs_ids = [
607
+ ParagraphId.from_string(text_block.id) for text_block in ordered_text_blocks
608
+ ]
609
+
610
+ augmented = await rpc.augment(
611
+ search_sdk,
612
+ kbid,
613
+ AugmentRequest(
614
+ paragraphs=[
615
+ AugmentParagraphs(
616
+ given=[
617
+ AugmentParagraph(id=pid.full())
618
+ for pid in retrieved_paragraphs_ids
619
+ ],
620
+ text=True,
621
+ neighbours_before=strategy.before,
622
+ neighbours_after=strategy.after,
623
+ )
624
+ ]
625
+ ),
626
+ )
627
+
628
+ for pid in retrieved_paragraphs_ids:
629
+ paragraph = augmented.paragraphs.get(pid.full())
630
+ if paragraph is None:
631
+ continue
632
+
633
+ ptext = paragraph.text or ""
634
+ if ptext and pid.full() not in context:
635
+ context[pid.full()] = ptext
636
+
637
+ # Now add the neighbouring paragraphs
638
+ neighbour_ids = [
639
+ *(paragraph.neighbours_before or []),
640
+ *(paragraph.neighbours_after or []),
641
+ ]
642
+ for npid in neighbour_ids:
643
+ neighbour = augmented.paragraphs.get(npid)
644
+ assert neighbour is not None, (
645
+ "augment should never return dangling paragraph references"
646
+ )
647
+
648
+ if (
649
+ ParagraphId.from_string(npid) in retrieved_paragraphs_ids
650
+ or npid in context
651
+ ):
652
+ # already added
653
+ continue
654
+
655
+ ntext = neighbour.text
656
+ if not ntext:
657
+ continue
658
+
659
+ context[npid] = ntext
660
+ augmented_context.paragraphs[npid] = AugmentedTextBlock(
661
+ id=npid,
662
+ text=ntext,
663
+ position=neighbour.position,
664
+ parent=pid.full(),
665
+ augmentation_type=TextBlockAugmentationType.NEIGHBOURING_PARAGRAPHS,
666
+ )
667
+
668
+ metrics.set("neighbouring_paragraphs_ops", len(augmented_context.paragraphs))
669
+
670
+
671
+ def get_text_position(
672
+ paragraph_id: ParagraphId, index: int, field_metadata: FieldComputedMetadata
673
+ ) -> TextPosition | None:
674
+ if paragraph_id.field_id.subfield_id:
675
+ metadata = field_metadata.split_metadata[paragraph_id.field_id.subfield_id]
676
+ else:
677
+ metadata = field_metadata.metadata
678
+ try:
679
+ pmetadata = metadata.paragraphs[index]
680
+ except IndexError:
681
+ return None
682
+ page_number = None
683
+ if pmetadata.HasField("page"):
684
+ page_number = pmetadata.page.page
685
+ return TextPosition(
686
+ page_number=page_number,
687
+ index=index,
688
+ start=pmetadata.start,
689
+ end=pmetadata.end,
690
+ start_seconds=list(pmetadata.start_seconds),
691
+ end_seconds=list(pmetadata.end_seconds),
692
+ )
693
+
694
+
695
+ def get_neighbouring_indices(
696
+ index: int, before: int, after: int, field_pids: list[ParagraphId]
697
+ ) -> list[int]:
698
+ lb_index = max(0, index - before)
699
+ ub_index = min(len(field_pids), index + after + 1)
700
+ return list(range(lb_index, index)) + list(range(index + 1, ub_index))
701
+
702
+
703
+ async def conversation_prompt_context(
704
+ search_sdk: NucliaDBAsync,
705
+ reader_sdk: NucliaDBAsync,
706
+ context: CappedPromptContext,
707
+ kbid: str,
708
+ ordered_paragraphs: list[FindParagraph],
709
+ strategy: ConversationalStrategy,
710
+ visual_llm: bool,
711
+ metrics: Metrics,
712
+ augmented_context: AugmentedContext,
713
+ ):
714
+ analyzed_fields: set[str] = set()
715
+ ops = 0
716
+
717
+ conversation_paragraphs = []
718
+ for paragraph in ordered_paragraphs:
719
+ if paragraph.id not in context:
720
+ context[paragraph.id] = _clean_paragraph_text(paragraph)
721
+
722
+ parent_paragraph_id = ParagraphId.from_string(paragraph.id)
723
+
724
+ if (
725
+ parent_paragraph_id.field_id.type
726
+ != FieldTypeName.CONVERSATION.abbreviation()
727
+ ):
728
+ # conversational strategy only applies to conversation fields
729
+ continue
730
+
731
+ field_unique_id = parent_paragraph_id.field_id.full_without_subfield()
732
+ if field_unique_id in analyzed_fields:
733
+ continue
734
+
735
+ conversation_paragraphs.append((parent_paragraph_id, paragraph))
736
+ analyzed_fields.add(field_unique_id)
737
+
738
+ # augment conversation paragraphs
739
+
740
+ if strategy.full:
741
+ full_conversation = True
742
+ max_conversation_messages = None
743
+ else:
744
+ full_conversation = False
745
+ max_conversation_messages = strategy.max_messages
746
+
747
+ augment = AugmentRequest(
748
+ fields=[
749
+ AugmentFields(
750
+ given=[
751
+ paragraph_id.field_id.full()
752
+ for paragraph_id, _ in conversation_paragraphs
753
+ ],
754
+ full_conversation=full_conversation,
755
+ max_conversation_messages=max_conversation_messages,
756
+ conversation_text_attachments=strategy.attachments_text,
757
+ conversation_image_attachments=strategy.attachments_images,
758
+ )
759
+ ]
760
+ )
761
+ augmented = await rpc.augment(search_sdk, kbid, augment)
762
+
763
+ attachments: dict[ParagraphId, list[FieldId]] = {}
764
+ for parent_paragraph_id, paragraph in conversation_paragraphs:
765
+ fid = parent_paragraph_id.field_id
766
+ field = augmented.fields.get(fid.full_without_subfield())
767
+ if field is not None:
768
+ field = cast(AugmentedConversationField, field)
769
+ for _message in field.messages or []:
770
+ ops += 1
771
+ if not _message.text:
772
+ continue
773
+
774
+ text = _message.text
775
+ pid = ParagraphId(
776
+ field_id=FieldId(
777
+ rid=fid.rid,
778
+ type=fid.type,
779
+ key=fid.key,
780
+ subfield_id=_message.ident,
781
+ ),
782
+ paragraph_start=0,
783
+ paragraph_end=len(text),
784
+ ).full()
785
+ if pid in context:
786
+ continue
787
+ context[pid] = text
788
+
789
+ attachments.setdefault(parent_paragraph_id, []).extend(
790
+ [
791
+ FieldId.from_string(attachment_id)
792
+ for attachment_id in field.attachments or []
793
+ ]
794
+ )
795
+ augmented_context.paragraphs[pid] = AugmentedTextBlock(
796
+ id=pid,
797
+ text=text,
798
+ parent=paragraph.id,
799
+ augmentation_type=TextBlockAugmentationType.CONVERSATION,
800
+ )
801
+
802
+ # augment attachments
803
+
804
+ if strategy.attachments_text or (
805
+ (strategy.attachments_images and visual_llm) and len(attachments) > 0
806
+ ):
807
+ augment = AugmentRequest(
808
+ fields=[
809
+ AugmentFields(
810
+ given=[
811
+ id.full()
812
+ for paragraph_attachments in attachments.values()
813
+ for id in paragraph_attachments
814
+ ],
815
+ text=strategy.attachments_text,
816
+ file_thumbnail=(strategy.attachments_images and visual_llm),
817
+ )
818
+ ]
819
+ )
820
+ augmented = await rpc.augment(search_sdk, kbid, augment)
821
+
822
+ for parent_paragraph_id, paragraph_attachments in attachments.items():
823
+ for attachment_id in paragraph_attachments:
824
+ attachment_field = augmented.fields.get(attachment_id.full())
825
+
826
+ if attachment_field is None:
827
+ continue
828
+
829
+ if strategy.attachments_text and attachment_field.text:
830
+ ops += 1
831
+
832
+ pid = f"{attachment_id.full_without_subfield()}/0-{len(attachment_field.text)}"
833
+ if pid not in context:
834
+ text = f"Attachment {attachment_id.key}: {attachment_field.text}\n\n"
835
+ context[pid] = text
836
+ augmented_context.paragraphs[pid] = AugmentedTextBlock(
837
+ id=pid,
838
+ text=text,
839
+ parent=parent_paragraph_id.full(),
840
+ augmentation_type=TextBlockAugmentationType.CONVERSATION,
841
+ )
842
+
843
+ if (
844
+ (strategy.attachments_images and visual_llm)
845
+ and isinstance(attachment_field, AugmentedFileField)
846
+ and attachment_field.thumbnail_image
847
+ ):
848
+ ops += 1
849
+
850
+ image = await rpc.download_image(
851
+ reader_sdk,
852
+ kbid,
853
+ attachment_id,
854
+ attachment_field.thumbnail_image,
855
+ # We assume the thumbnail is always generated as JPEG by Nuclia processing
856
+ mime_type="image/jpeg",
857
+ )
858
+ if image is not None:
859
+ pid = f"{attachment_id.rid}/f/{attachment_id.key}/0-0"
860
+ context.images[pid] = image
861
+
862
+ metrics.set("conversation_ops", ops)
863
+
864
+
865
+ async def hierarchy_prompt_context(
866
+ search_sdk: NucliaDBAsync,
867
+ context: CappedPromptContext,
868
+ kbid: str,
869
+ ordered_paragraphs: list[FindParagraph],
870
+ strategy: HierarchyResourceStrategy,
871
+ metrics: Metrics,
872
+ augmented_context: AugmentedContext,
873
+ ) -> None:
874
+ """
875
+ This function will get the paragraph texts (possibly with extra characters, if extra_characters > 0) and then
876
+ craft a context with all paragraphs of the same resource grouped together. Moreover, on each group of paragraphs,
877
+ it includes the resource title and summary so that the LLM can have a better understanding of the context.
878
+ """
879
+ paragraphs_extra_characters = max(strategy.count, 0)
880
+ # Make a copy of the ordered paragraphs to avoid modifying the original list, which is returned
881
+ # in the response to the user
882
+ ordered_paragraphs_copy = copy.deepcopy(ordered_paragraphs)
883
+ resources: dict[str, ExtraCharsParagraph] = {}
884
+
885
+ # Iterate paragraphs to get extended text
886
+ paragraphs_to_augment = []
887
+ for paragraph in ordered_paragraphs_copy:
888
+ paragraph_id = ParagraphId.from_string(paragraph.id)
889
+ rid = paragraph_id.rid
890
+
891
+ if paragraphs_extra_characters > 0:
892
+ paragraph_id.paragraph_end += paragraphs_extra_characters
893
+
894
+ paragraphs_to_augment.append(paragraph_id)
895
+
896
+ if rid not in resources:
897
+ # Get the title and the summary of the resource
898
+ title_paragraph_id = ParagraphId(
899
+ field_id=FieldId(
900
+ rid=rid,
901
+ type="a",
902
+ key="title",
903
+ ),
904
+ paragraph_start=0,
905
+ paragraph_end=500,
906
+ )
907
+ summary_paragraph_id = ParagraphId(
908
+ field_id=FieldId(
909
+ rid=rid,
910
+ type="a",
911
+ key="summary",
912
+ ),
913
+ paragraph_start=0,
914
+ paragraph_end=1000,
915
+ )
916
+ paragraphs_to_augment.append(title_paragraph_id)
917
+ paragraphs_to_augment.append(summary_paragraph_id)
918
+
919
+ resources[rid] = ExtraCharsParagraph(
920
+ title=title_paragraph_id,
921
+ summary=summary_paragraph_id,
922
+ paragraphs=[(paragraph, paragraph_id)],
923
+ )
924
+ else:
925
+ resources[rid].paragraphs.append((paragraph, paragraph_id))
926
+
927
+ metrics.set("hierarchy_ops", len(resources))
928
+
929
+ augmented = await rpc.augment(
930
+ search_sdk,
931
+ kbid,
932
+ AugmentRequest(
933
+ paragraphs=[
934
+ AugmentParagraphs(
935
+ given=[
936
+ AugmentParagraph(id=paragraph_id.full())
937
+ for paragraph_id in paragraphs_to_augment
938
+ ],
939
+ text=True,
940
+ )
941
+ ]
942
+ ),
943
+ )
944
+
945
+ augmented_paragraphs = set()
946
+
947
+ # Modify the first paragraph of each resource to include the title and summary of the resource, as well as the
948
+ # extended paragraph text of all the paragraphs in the resource.
949
+ for values in resources.values():
950
+ augmented_title = augmented.paragraphs.get(values.title.full())
951
+ if augmented_title:
952
+ title_text = augmented_title.text or ""
953
+ else:
954
+ title_text = ""
955
+
956
+ augmented_summary = augmented.paragraphs.get(values.summary.full())
957
+ if augmented_summary:
958
+ summary_text = augmented_summary.text or ""
959
+ else:
960
+ summary_text = ""
961
+
962
+ first_paragraph = None
963
+ text_with_hierarchy = ""
964
+ for paragraph, paragraph_id in values.paragraphs:
965
+ augmented_paragraph = augmented.paragraphs.get(paragraph_id.full())
966
+ if augmented_paragraph:
967
+ extended_paragraph_text = augmented_paragraph.text or ""
968
+ else:
969
+ extended_paragraph_text = ""
970
+
971
+ if first_paragraph is None:
972
+ first_paragraph = paragraph
973
+ text_with_hierarchy += (
974
+ "\n EXTRACTED BLOCK: \n " + extended_paragraph_text + " \n\n "
975
+ )
976
+ # All paragraphs of the resource are cleared except the first one, which will be the
977
+ # one containing the whole hierarchy information
978
+ paragraph.text = ""
979
+
980
+ if first_paragraph is not None:
981
+ # The first paragraph is the only one holding the hierarchy information
982
+ first_paragraph.text = f"DOCUMENT: {title_text} \n SUMMARY: {summary_text} \n RESOURCE CONTENT: {text_with_hierarchy}"
983
+ augmented_paragraphs.add(first_paragraph.id)
984
+
985
+ # Now that the paragraphs have been modified, we can add them to the context
986
+ for paragraph in ordered_paragraphs_copy:
987
+ if paragraph.text == "":
988
+ # Skip paragraphs that were cleared in the hierarchy expansion
989
+ continue
990
+ paragraph_text = _clean_paragraph_text(paragraph)
991
+ context[paragraph.id] = paragraph_text
992
+ if paragraph.id in augmented_paragraphs:
993
+ pid = ParagraphId.from_string(paragraph.id)
994
+ augmented_context.paragraphs[pid.full()] = AugmentedTextBlock(
995
+ id=pid.full(),
996
+ text=paragraph_text,
997
+ augmentation_type=TextBlockAugmentationType.HIERARCHY,
998
+ )
999
+ return
1000
+
1001
+
1002
+ class PromptContextBuilder:
1003
+ """
1004
+ Builds the context for the LLM prompt.
1005
+ """
1006
+
1007
+ def __init__(
1008
+ self,
1009
+ search_sdk: NucliaDBAsync,
1010
+ reader_sdk: NucliaDBAsync,
1011
+ kbid: str,
1012
+ ordered_paragraphs: list[FindParagraph],
1013
+ resource: str | None = None,
1014
+ user_context: list[str] | None = None,
1015
+ user_image_context: list[Image] | None = None,
1016
+ strategies: Sequence[RagStrategy] | None = None,
1017
+ image_strategies: Sequence[ImageRagStrategy] | None = None,
1018
+ max_context_characters: int | None = None,
1019
+ visual_llm: bool = False,
1020
+ query_image: Image | None = None,
1021
+ metrics: Metrics = Metrics("prompt_context_builder"),
1022
+ ):
1023
+ self.search_sdk = search_sdk
1024
+ self.reader_sdk = reader_sdk
1025
+ self.kbid = kbid
1026
+ self.ordered_paragraphs = ordered_paragraphs
1027
+ self.resource = resource
1028
+ self.user_context = user_context
1029
+ self.user_image_context = user_image_context
1030
+ self.strategies = strategies
1031
+ self.image_strategies = image_strategies
1032
+ self.max_context_characters = max_context_characters
1033
+ self.visual_llm = visual_llm
1034
+ self.metrics = metrics
1035
+ self.query_image = query_image
1036
+ self.augmented_context = AugmentedContext(paragraphs={}, fields={})
1037
+
1038
+ def prepend_user_context(self, context: CappedPromptContext):
1039
+ # Chat extra context passed by the user is the most important, therefore
1040
+ # it is added first, followed by the found text blocks in order of relevance
1041
+ for i, text_block in enumerate(self.user_context or []):
1042
+ context[f"USER_CONTEXT_{i}"] = text_block
1043
+ # Add the query image as part of the image context
1044
+ if self.query_image is not None:
1045
+ context.images["QUERY_IMAGE"] = self.query_image
1046
+ else:
1047
+ for i, image in enumerate(self.user_image_context or []):
1048
+ context.images[f"USER_IMAGE_CONTEXT_{i}"] = image
1049
+
1050
+ async def build(
1051
+ self,
1052
+ ) -> tuple[
1053
+ PromptContext, PromptContextOrder, PromptContextImages, AugmentedContext
1054
+ ]:
1055
+ ccontext = CappedPromptContext(max_size=self.max_context_characters)
1056
+ self.prepend_user_context(ccontext)
1057
+ await self._build_context(ccontext)
1058
+ if self.visual_llm and not self.query_image:
1059
+ await self._build_context_images(ccontext)
1060
+ context = ccontext.cap()
1061
+ context_images = ccontext.images
1062
+ context_order = {
1063
+ text_block_id: order for order, text_block_id in enumerate(context.keys())
1064
+ }
1065
+ return context, context_order, context_images, self.augmented_context
1066
+
1067
+ async def _build_context_images(self, context: CappedPromptContext) -> None:
1068
+ ops = 0
1069
+ if self.image_strategies is None or len(self.image_strategies) == 0:
1070
+ # Nothing to do
1071
+ return
1072
+ page_image_strategy: PageImageStrategy | None = None
1073
+ max_page_images = 5
1074
+ table_image_strategy: TableImageStrategy | None = None
1075
+ paragraph_image_strategy: ParagraphImageStrategy | None = None
1076
+ for strategy in self.image_strategies:
1077
+ if strategy.name == ImageRagStrategyName.PAGE_IMAGE:
1078
+ if page_image_strategy is None:
1079
+ page_image_strategy = cast(PageImageStrategy, strategy)
1080
+ if page_image_strategy.count is not None:
1081
+ max_page_images = page_image_strategy.count
1082
+ elif strategy.name == ImageRagStrategyName.TABLES:
1083
+ if table_image_strategy is None:
1084
+ table_image_strategy = cast(TableImageStrategy, strategy)
1085
+ elif strategy.name == ImageRagStrategyName.PARAGRAPH_IMAGE:
1086
+ if paragraph_image_strategy is None:
1087
+ paragraph_image_strategy = cast(ParagraphImageStrategy, strategy)
1088
+ else: # pragma: no cover
1089
+ logger.warning(
1090
+ "Unknown image strategy",
1091
+ extra={"strategy": strategy.name, "kbid": self.kbid},
1092
+ )
1093
+ page_images_added = 0
1094
+ for paragraph in self.ordered_paragraphs:
1095
+ pid = ParagraphId.from_string(paragraph.id)
1096
+ paragraph_page_number = get_paragraph_page_number(paragraph)
1097
+ if (
1098
+ page_image_strategy is not None
1099
+ and page_images_added < max_page_images
1100
+ and paragraph_page_number is not None
1101
+ ):
1102
+ # page_image_id: rid/f/myfield/0
1103
+ page_image_id = "/".join(
1104
+ [pid.field_id.full(), str(paragraph_page_number)]
1105
+ )
1106
+ if page_image_id not in context.images:
1107
+ image = await rpc.download_image(
1108
+ self.reader_sdk,
1109
+ self.kbid,
1110
+ pid.field_id,
1111
+ f"generated/extracted_images_{paragraph_page_number}.png",
1112
+ mime_type="image/png",
1113
+ )
1114
+ if image is not None:
1115
+ ops += 1
1116
+ context.images[page_image_id] = image
1117
+ page_images_added += 1
1118
+ else:
1119
+ logger.warning(
1120
+ "Could not retrieve image for paragraph from storage",
1121
+ extra={
1122
+ "kbid": self.kbid,
1123
+ "paragraph": pid.full(),
1124
+ "page_number": paragraph_page_number,
1125
+ },
1126
+ )
1127
+
1128
+ add_table = table_image_strategy is not None and paragraph.is_a_table
1129
+ add_paragraph = (
1130
+ paragraph_image_strategy is not None and not paragraph.is_a_table
1131
+ )
1132
+ if (add_table or add_paragraph) and (
1133
+ paragraph.reference is not None and paragraph.reference != ""
1134
+ ):
1135
+ pimage = await rpc.download_image(
1136
+ self.reader_sdk,
1137
+ self.kbid,
1138
+ pid.field_id,
1139
+ f"generated/{paragraph.reference}",
1140
+ mime_type="image/png",
1141
+ )
1142
+ if pimage is not None:
1143
+ ops += 1
1144
+ context.images[paragraph.id] = pimage
1145
+ else:
1146
+ logger.warning(
1147
+ "Could not retrieve image for paragraph from storage",
1148
+ extra={
1149
+ "kbid": self.kbid,
1150
+ "paragraph": pid.full(),
1151
+ "reference": paragraph.reference,
1152
+ },
1153
+ )
1154
+ self.metrics.set("image_ops", ops)
1155
+
1156
+ async def _build_context(self, context: CappedPromptContext) -> None:
1157
+ if self.strategies is None or len(self.strategies) == 0:
1158
+ # When no strategy is specified, use the default one
1159
+ await default_prompt_context(
1160
+ self.search_sdk, context, self.kbid, self.ordered_paragraphs
1161
+ )
1162
+ return
1163
+ else:
1164
+ # Add the paragraphs to the context and then apply the strategies
1165
+ for paragraph in self.ordered_paragraphs:
1166
+ context[paragraph.id] = _clean_paragraph_text(paragraph)
1167
+
1168
+ strategies_not_handled_here = [
1169
+ RagStrategyName.PREQUERIES,
1170
+ RagStrategyName.GRAPH,
1171
+ ]
1172
+
1173
+ full_resource: FullResourceStrategy | None = None
1174
+ hierarchy: HierarchyResourceStrategy | None = None
1175
+ neighbouring_paragraphs: NeighbouringParagraphsStrategy | None = None
1176
+ field_extension: FieldExtensionStrategy | None = None
1177
+ metadata_extension: MetadataExtensionStrategy | None = None
1178
+ conversational_strategy: ConversationalStrategy | None = None
1179
+ for strategy in self.strategies:
1180
+ if strategy.name == RagStrategyName.FIELD_EXTENSION:
1181
+ field_extension = cast(FieldExtensionStrategy, strategy)
1182
+ elif strategy.name == RagStrategyName.CONVERSATION:
1183
+ conversational_strategy = cast(ConversationalStrategy, strategy)
1184
+ elif strategy.name == RagStrategyName.FULL_RESOURCE:
1185
+ full_resource = cast(FullResourceStrategy, strategy)
1186
+ if self.resource: # pragma: no cover
1187
+ # When the retrieval is scoped to a specific resource
1188
+ # the full resource strategy only includes that resource
1189
+ full_resource.count = 1
1190
+ elif strategy.name == RagStrategyName.HIERARCHY:
1191
+ hierarchy = cast(HierarchyResourceStrategy, strategy)
1192
+ elif strategy.name == RagStrategyName.NEIGHBOURING_PARAGRAPHS:
1193
+ neighbouring_paragraphs = cast(NeighbouringParagraphsStrategy, strategy)
1194
+ elif strategy.name == RagStrategyName.METADATA_EXTENSION:
1195
+ metadata_extension = cast(MetadataExtensionStrategy, strategy)
1196
+ elif strategy.name not in strategies_not_handled_here: # pragma: no cover
1197
+ # Prequeries and graph are not handled here
1198
+ logger.warning(
1199
+ "Unknown rag strategy",
1200
+ extra={"strategy": strategy.name, "kbid": self.kbid},
1201
+ )
1202
+
1203
+ if full_resource:
1204
+ # When full resoure is enabled, only metadata extension is allowed.
1205
+ await full_resource_prompt_context(
1206
+ self.search_sdk,
1207
+ context,
1208
+ self.kbid,
1209
+ self.ordered_paragraphs,
1210
+ self.resource,
1211
+ full_resource,
1212
+ self.metrics,
1213
+ self.augmented_context,
1214
+ )
1215
+ if metadata_extension:
1216
+ await extend_prompt_context_with_metadata(
1217
+ self.search_sdk,
1218
+ context,
1219
+ self.kbid,
1220
+ metadata_extension,
1221
+ self.metrics,
1222
+ self.augmented_context,
1223
+ )
1224
+ return
1225
+
1226
+ if hierarchy:
1227
+ await hierarchy_prompt_context(
1228
+ self.search_sdk,
1229
+ context,
1230
+ self.kbid,
1231
+ self.ordered_paragraphs,
1232
+ hierarchy,
1233
+ self.metrics,
1234
+ self.augmented_context,
1235
+ )
1236
+ if neighbouring_paragraphs:
1237
+ await neighbouring_paragraphs_prompt_context(
1238
+ self.search_sdk,
1239
+ context,
1240
+ self.kbid,
1241
+ self.ordered_paragraphs,
1242
+ neighbouring_paragraphs,
1243
+ self.metrics,
1244
+ self.augmented_context,
1245
+ )
1246
+ if field_extension:
1247
+ await field_extension_prompt_context(
1248
+ self.search_sdk,
1249
+ context,
1250
+ self.kbid,
1251
+ self.ordered_paragraphs,
1252
+ field_extension,
1253
+ self.metrics,
1254
+ self.augmented_context,
1255
+ )
1256
+ if conversational_strategy:
1257
+ await conversation_prompt_context(
1258
+ self.search_sdk,
1259
+ self.reader_sdk,
1260
+ context,
1261
+ self.kbid,
1262
+ self.ordered_paragraphs,
1263
+ conversational_strategy,
1264
+ self.visual_llm,
1265
+ self.metrics,
1266
+ self.augmented_context,
1267
+ )
1268
+ if metadata_extension:
1269
+ await extend_prompt_context_with_metadata(
1270
+ self.search_sdk,
1271
+ context,
1272
+ self.kbid,
1273
+ metadata_extension,
1274
+ self.metrics,
1275
+ self.augmented_context,
1276
+ )
1277
+
1278
+
1279
+ def get_paragraph_page_number(paragraph: FindParagraph) -> int | None:
1280
+ if not paragraph.page_with_visual:
1281
+ return None
1282
+ if paragraph.position is None:
1283
+ return None
1284
+ return paragraph.position.page_number
1285
+
1286
+
1287
+ @dataclass
1288
+ class ExtraCharsParagraph:
1289
+ title: ParagraphId
1290
+ summary: ParagraphId
1291
+ paragraphs: list[tuple[FindParagraph, ParagraphId]]
1292
+
1293
+
1294
+ def _clean_paragraph_text(paragraph: FindParagraph) -> str:
1295
+ text = paragraph.text.strip()
1296
+ # Do not send highlight marks on prompt context
1297
+ text = text.replace("<mark>", "").replace("</mark>", "")
1298
+ return text