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.
- hyperforge_nucliadb_agentic/__init__.py +3 -0
- hyperforge_nucliadb_agentic/agent.py +1642 -0
- hyperforge_nucliadb_agentic/ask/__init__.py +5 -0
- hyperforge_nucliadb_agentic/ask/audit.py +439 -0
- hyperforge_nucliadb_agentic/ask/exceptions.py +50 -0
- hyperforge_nucliadb_agentic/ask/lifespan.py +21 -0
- hyperforge_nucliadb_agentic/ask/model.py +1299 -0
- hyperforge_nucliadb_agentic/ask/predict.py +431 -0
- hyperforge_nucliadb_agentic/ask/predict_models.py +78 -0
- hyperforge_nucliadb_agentic/ask/search/__init__.py +0 -0
- hyperforge_nucliadb_agentic/ask/search/ask.py +1182 -0
- hyperforge_nucliadb_agentic/ask/search/graph_strategy.py +1138 -0
- hyperforge_nucliadb_agentic/ask/search/highlight.py +93 -0
- hyperforge_nucliadb_agentic/ask/search/hydrator.py +29 -0
- hyperforge_nucliadb_agentic/ask/search/metrics.py +112 -0
- hyperforge_nucliadb_agentic/ask/search/parsers/__init__.py +0 -0
- hyperforge_nucliadb_agentic/ask/search/parsers/ask.py +70 -0
- hyperforge_nucliadb_agentic/ask/search/parsers/fetcher.py +192 -0
- hyperforge_nucliadb_agentic/ask/search/parsers/find.py +729 -0
- hyperforge_nucliadb_agentic/ask/search/prompt.py +1298 -0
- hyperforge_nucliadb_agentic/ask/search/rank_fusion.py +157 -0
- hyperforge_nucliadb_agentic/ask/search/rerankers.py +161 -0
- hyperforge_nucliadb_agentic/ask/search/retrieval.py +750 -0
- hyperforge_nucliadb_agentic/ask/search/rpc.py +192 -0
- hyperforge_nucliadb_agentic/ask/settings.py +9 -0
- hyperforge_nucliadb_agentic/ask/utils/ids.py +188 -0
- hyperforge_nucliadb_agentic/ask/utils/proto.py +6 -0
- hyperforge_nucliadb_agentic/ask/utils/responses.py +6 -0
- hyperforge_nucliadb_agentic/ask/utils/text_blocks.py +51 -0
- hyperforge_nucliadb_agentic/config.py +47 -0
- hyperforge_nucliadb_agentic/internal_driver.py +87 -0
- hyperforge_nucliadb_agentic/py.typed +0 -0
- hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/METADATA +24 -0
- hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/RECORD +35 -0
- 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
|