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,1642 @@
1
+ import ast
2
+ import asyncio
3
+ import json
4
+ from time import time
5
+ from typing import Any, ClassVar, Dict, List, Literal, Optional, cast
6
+
7
+ from hyperforge import PROMPT_ENVIRONMENT, logger
8
+ from hyperforge.agent import Agent
9
+ from hyperforge.configure import agent
10
+ from hyperforge.context.agent import ContextAgent
11
+ from hyperforge.definition import FunctionDefinition
12
+ from hyperforge.manager import Manager
13
+ from hyperforge.memory import Chunk, Context, QuestionMemory, Source
14
+ from hyperforge.models import JSONObject
15
+ from hyperforge_nucliadb.ask.multi import choose_source
16
+ from hyperforge_nucliadb.ask_utils import (
17
+ combine_catalog_filter_expressions,
18
+ combine_filter_expressions,
19
+ to_field_filter_expression,
20
+ to_resource_filter_expression,
21
+ )
22
+ from hyperforge_nucliadb.driver import (
23
+ NucliaDBDriver,
24
+ format_ndb_catalog,
25
+ format_ndb_labels,
26
+ )
27
+ from nucliadb_models.filters import (
28
+ And,
29
+ CatalogFilterExpression,
30
+ FieldFilterExpression,
31
+ FilterExpression,
32
+ Keyword,
33
+ Or,
34
+ Resource,
35
+ ResourceFilterExpression,
36
+ )
37
+ from nucliadb_models.resource import Resource as ResourceResponse
38
+ from nucliadb_models.search import (
39
+ CatalogRequest,
40
+ Filter,
41
+ FindRequest,
42
+ KnowledgeboxFindResults,
43
+ NucliaDBClientType,
44
+ ResourceProperties,
45
+ )
46
+
47
+ from hyperforge_nucliadb_agentic.ask.model import (
48
+ AskRequest,
49
+ CitationsType,
50
+ FieldExtensionStrategy,
51
+ FullResourceStrategy,
52
+ MetadataExtensionStrategy,
53
+ NeighbouringParagraphsStrategy,
54
+ RagStrategies,
55
+ SyncAskResponse,
56
+ )
57
+ from hyperforge_nucliadb_agentic.ask.search.ask import ask
58
+ from hyperforge_nucliadb_agentic.config import NucliaDBAgentConfig
59
+
60
+ # Example filter expressions for catalog search
61
+ EXAMPLE_FILTER_EXP1 = [
62
+ {
63
+ "any": [
64
+ "/l/topic/technology",
65
+ "/l/topic/health",
66
+ ]
67
+ }
68
+ ]
69
+
70
+ EXAMPLE_FILTER_EXP2 = [
71
+ {
72
+ "all": [
73
+ "/l/category/research",
74
+ "/icon/application/pdf",
75
+ ]
76
+ }
77
+ ]
78
+
79
+ EXAMPLE_FILTER_EXP3 = [
80
+ {
81
+ "none": ["/l/topic"],
82
+ },
83
+ {
84
+ "any": [
85
+ "/icon/application/pdf",
86
+ "/icon/application/vnd.openxmlformats-officedocument.wordprocessingml.document",
87
+ ],
88
+ },
89
+ ]
90
+
91
+ KBFindResultsSchema = KnowledgeboxFindResults.model_json_schema()
92
+ JSON_OBJECT_ID = "nucliadb_find_results"
93
+
94
+
95
+ FACETS_LABEL_SEARCH_TEMPLATE = """
96
+ Given the question: '{{ question }}', select the most relevant label sets from the following list to list all options and choose the labels to filter:
97
+ {% for labelset, values in labels.items() %}
98
+ - {{ labelset }}:
99
+ {% for value in values %}
100
+ - {{ value }}
101
+ {% endfor %}
102
+ {% endfor %}
103
+
104
+ Example 1:
105
+ Question: 'Which companies compete with PepsiCo in the food sector?'
106
+ Available Labels:
107
+ hq_state:\n \n - IL\n \n - NY\n \n - TX\n \n - GA\n \n - PA\n \n\n- year:\n \n - 2024\n \n - 2015\n \n - 2020\n \n - 2022\n \n - 2018\n \n - 2016\n \n - 2017\n \n - 2023\n \n - 2019\n \n - 2021\n \n\n- type_of_doc:\n \n - Metadata\n \n - None\n \n - 10Q\n \n - 10K File\n \n\n- sector:\n \n - Foods\n \n - Snacks\n \n - Beverages\n \n - Breakfast\n \n\n- company:\n \n - Coca Cola\n \n - Kellanova\n \n - Keurig\n \n - UTZ Brands\n \n - Mondelez\n \n - PepsiCo\n \n
108
+ Answer:
109
+ {"labels_sets": ["company"], "labels": ["sector/Foods"], "reasoning": "The question is about companies in sector Foods, so we filter by the sector Foods and want all the companies."}
110
+ """
111
+ FACETS_LABEL_SEARCH_AGENT_TEMPLATE = PROMPT_ENVIRONMENT.from_string(
112
+ FACETS_LABEL_SEARCH_TEMPLATE
113
+ )
114
+
115
+
116
+ FACETS_SEARCH_ANSWER_GENERATION_TEMPLATE = """
117
+ Your task is to respond to the users question by leveraging the faceted search results obtained from the Knowledge Box. The faceted search has provided all the labels available with a filter extracted from the question as specific labels within the Knowledge Box.
118
+ Use this information to construct a comprehensive answer to the user's question.
119
+ Here is the user's question: '{{ question }}'.
120
+ Here are the details of the faceted search results. Please note that a single resource may have multiple labels and thus may be counted in multiple categories:
121
+ {{ facets }}
122
+ """
123
+
124
+ FACETS_SEARCH_ANSWER_GENERATION_AGENT_TEMPLATE = PROMPT_ENVIRONMENT.from_string(
125
+ FACETS_SEARCH_ANSWER_GENERATION_TEMPLATE
126
+ )
127
+
128
+ FACETS_LABEL_SELECTION_TEMPLATE = """
129
+ Given the question: '{{ question }}', select the most relevant label sets from the following list to perform a bucket aggregation. Each label set shows some label examples:
130
+ {{ labels_str }}
131
+ You can select multiple categories if needed. Provide your answer as a function call. If no category is relevant, leave the list empty.
132
+
133
+ Example 1:
134
+ Question: 'Show me the distribution of articles about sports and health.'
135
+ Available Labels:
136
+ {"topic": ["sports", "health", "technology"], "category": ["news", "blog", "research"]}
137
+ Answer:
138
+ {"labels_sets": ["topic"], "reasoning": "The question is about topics, so we select the 'topic' label category."}
139
+ """
140
+
141
+ FACETS_LABEL_SELECTION_AGENT_TEMPLATE = PROMPT_ENVIRONMENT.from_string(
142
+ FACETS_LABEL_SELECTION_TEMPLATE
143
+ )
144
+
145
+
146
+ FACETS_ANSWER_GENERATION_TEMPLATE = """
147
+ Your task is to respond to the users question by leveraging the faceted search results obtained from the Knowledge Box. The faceted search has provided you with counts of documents for specific labels within the Knowledge Box.
148
+ Use this information to construct a comprehensive answer to the user's question.
149
+ Here is the user's question: '{{ question }}'.
150
+ Here are the details of the faceted search results. Please note that a single document may have multiple labels and thus may be counted in multiple categories:
151
+ Document counts for labels {{ labels_selected }} in {{ source_id }} Knowledge Box for a total of {{ total }} documents: "
152
+ {{ facets }}
153
+ """
154
+
155
+ FACETS_ANSWER_GENERATION_AGENT_TEMPLATE = PROMPT_ENVIRONMENT.from_string(
156
+ FACETS_ANSWER_GENERATION_TEMPLATE
157
+ )
158
+
159
+
160
+ CATALOG_FILTER_SELECTION_TEMPLATE = """
161
+ The user wants to perform a catalog search in the Knowledge Box based on a question.
162
+
163
+ You need to identify the most relevant labels and provide a filter expression to perform the catalog search. The filter expression should be in the format used by NucliaDB.
164
+
165
+ The nucliadb format for filter expressions is as follows:
166
+ - Use "any" to indicate that any of the properties can match (logical OR).
167
+ - Use "all" to indicate that all of the listed labels must match (logical AND).
168
+ - Use "none" to indicate that the listed labels should not match (logical NOT).
169
+
170
+ Sequential filters are treated as AND conditions.
171
+
172
+ For filtering by labels, use the format "/l/{labelset}/{label}".
173
+
174
+ For filtering by label sets without specifying a label, use the format "/l/{labelset}/".
175
+
176
+ For filtering by file types, use the media type prefixed by "/icon/".
177
+
178
+ **Example 1:**
179
+ Question: 'Find all documents related to technology and health.'
180
+ Available Labels:
181
+ {"topic": ["technology", "health", "sports"], "category": ["research", "news", "blog"]}
182
+ Answer:
183
+ {"filters": {{ example_filter_exp1 }}, "reasoning": "The question is about documents related to technology and health, so we create a filter expresion to match any of the labels technology or health in the topic label set."}
184
+
185
+ **Example 2:**
186
+ Question: 'Get all PDFs that are research articles.'
187
+ Available Labels:
188
+ {"topic": ["technology", "health", "sports"], "category": ["research", "news", "blog"]}
189
+ Answer:
190
+ {"filters": {{ example_filter_exp2 }}, "reasoning": "The question specifies research articles in PDF format, so we create a filter expression that requires both the category to be research and the media type to be PDF."}
191
+
192
+ **Example 3:**
193
+ Question: 'Show me all documents without a set topic, that are either PDFs or Word format.'
194
+ Available Labels:
195
+ {"topic": ["technology", "health", "sports"], "category": ["research", "news", "blog"]}
196
+ Answer:
197
+ {"filters": {{ example_filter_exp3 }}, "reasoning": "The question asks for documents excluding sports, so we use a 'not' filter for the topic sports, and an 'any' filter for the media types PDF or Word."}
198
+
199
+ Your Task:
200
+ Based on the question and the available labels, provide a filter expression to perform the catalog search. The question is: '{{ question }}'.
201
+ Here are the available labels in the Knowledge Box:
202
+ {{ labels_str }}
203
+ """
204
+
205
+ CATALOG_FILTER_SELECTION_AGENT_TEMPLATE = PROMPT_ENVIRONMENT.from_string(
206
+ CATALOG_FILTER_SELECTION_TEMPLATE
207
+ )
208
+
209
+
210
+ def get_catalog_filter_prompt(question: str, labels_str: str) -> str:
211
+ """Helper function to render catalog filter selection prompt with pre-filled examples."""
212
+ return CATALOG_FILTER_SELECTION_AGENT_TEMPLATE.render(
213
+ question=question,
214
+ labels_str=labels_str,
215
+ example_filter_exp1=EXAMPLE_FILTER_EXP1,
216
+ example_filter_exp2=EXAMPLE_FILTER_EXP2,
217
+ example_filter_exp3=EXAMPLE_FILTER_EXP3,
218
+ )
219
+
220
+
221
+ CATALOG_ANSWER_GENERATION_TEMPLATE = """
222
+ Based on the following catalog search results, please answer the following user question: '{{ question }}'
223
+
224
+ {{ catalog_txt }}
225
+ """
226
+
227
+ CATALOG_ANSWER_GENERATION_AGENT_TEMPLATE = PROMPT_ENVIRONMENT.from_string(
228
+ CATALOG_ANSWER_GENERATION_TEMPLATE
229
+ )
230
+
231
+
232
+ def get_chunk_text(ask_response: SyncAskResponse, chunk_id: str) -> str:
233
+ ids = chunk_id.split("/")
234
+ resource_id = ids[0]
235
+ resource = ask_response.retrieval_results.resources[resource_id]
236
+ field_id = f"/{ids[1]}/{ids[2]}" if len(ids) > 2 else ""
237
+ try:
238
+ # Try to get the text from the main retrieval results first
239
+ return resource.fields[field_id].paragraphs[chunk_id].text
240
+ except KeyError:
241
+ # If not found, try to get it from the augmented context, as it may be a chunk that was augmented as part of the RAG strategies.
242
+ if ask_response.augmented_context is not None:
243
+ try:
244
+ return ask_response.augmented_context.paragraphs[chunk_id].text
245
+ except KeyError:
246
+ # If still not found, return an empty string
247
+ pass
248
+ return ""
249
+
250
+
251
+ @agent(
252
+ id="nucliadb_agent",
253
+ agent_type="context",
254
+ title="Knowledge Box Basic Ask",
255
+ description="Ask a question to the knowledge box and retrieve relevant information",
256
+ config_schema=NucliaDBAgentConfig,
257
+ )
258
+ class NucliaDBAgent(ContextAgent, Agent[NucliaDBAgentConfig]):
259
+ labelsets: Dict[str, List[str]]
260
+ synonyms: Dict[str, Dict[str, List[str]]]
261
+ agent_description: str = "Agent that queries a NucliaDB Knowledge Box to get context to answer questions. It is a retrieval agent, so questions should be in a format that makes sense for retrieval."
262
+
263
+ __published_functions__: ClassVar[Dict[str, FunctionDefinition]] = {
264
+ "search_by_title": FunctionDefinition(
265
+ name="search_by_title",
266
+ description="Search for context in the Knowledge Box by title. Useful for specific queries where the title is known.",
267
+ parameters={
268
+ "title": {
269
+ "type": "string",
270
+ "description": "The title to search for in the Knowledge Box.",
271
+ },
272
+ "filters": {
273
+ "type": "array",
274
+ "items": {"type": "string"},
275
+ "description": "Filters to apply when searching in the Knowledge Box.",
276
+ },
277
+ },
278
+ ),
279
+ "ask_labels_list": FunctionDefinition(
280
+ name="ask_labels_list",
281
+ description="Get the labels available in the Knowledge Box as a list of strings. Useful to filter searches by labels.",
282
+ parameters={
283
+ "labelset": {
284
+ "type": "string",
285
+ "description": "The label set to get the labels from the Knowledge Box.",
286
+ },
287
+ },
288
+ ),
289
+ "ask_agent": FunctionDefinition(
290
+ name="ask_agent",
291
+ description="Search for context in the Knowledge Box.",
292
+ parameters={
293
+ "question": {
294
+ "type": "string",
295
+ "description": "The question to search for in the Knowledge Box.",
296
+ },
297
+ },
298
+ ),
299
+ "ask_labels": FunctionDefinition(
300
+ name="ask_labels",
301
+ description="Get the labels available in the Knowledge Box. Useful to filter searches by labels.",
302
+ parameters={},
303
+ ),
304
+ "facets_count": FunctionDefinition(
305
+ name="facets_count",
306
+ description="Perform a faceted count in the Knowledge Box. Useful to get counts of different categories or facets within the data.",
307
+ parameters={
308
+ "question": {
309
+ "type": "string",
310
+ "description": "The question to perform the faceted count for.",
311
+ },
312
+ },
313
+ ),
314
+ "facets_search": FunctionDefinition(
315
+ name="facets_search",
316
+ description="Perform a faceted search in the Knowledge Box. Useful to get counts of different categories or facets within the data.",
317
+ parameters={
318
+ "question": {
319
+ "type": "string",
320
+ "description": "The question to perform the faceted search for.",
321
+ },
322
+ },
323
+ ),
324
+ "catalog_search": FunctionDefinition(
325
+ name="catalog_search",
326
+ description="Perform a catalog search in the Knowledge Box. Useful to find items based on specific criteria or attributes, including labels, file types, or other metadata.",
327
+ parameters={
328
+ "question": {
329
+ "type": "string",
330
+ "description": "The question to perform the catalog search for.",
331
+ },
332
+ },
333
+ ),
334
+ "all_images_by_title": FunctionDefinition(
335
+ name="all_images_by_title",
336
+ description="Get all image URLs from a resource in the Knowledge Box filtered by a specific title. Useful when the user wants to retrieve images from known documents.",
337
+ parameters={
338
+ "title": {
339
+ "type": "string",
340
+ "description": "The title to filter the image extraction operation in the Knowledge Box.",
341
+ },
342
+ },
343
+ ),
344
+ "search_images": FunctionDefinition(
345
+ name="search_images",
346
+ description="Search for images in a resource in the Knowledge Box filtered by a specific title and relevant image information. Useful when the user wants to retrieve selected images from known documents.",
347
+ parameters={
348
+ "title": {
349
+ "type": "string",
350
+ "description": "The title to filter the image search operation in the Knowledge Box.",
351
+ },
352
+ "image_info": {
353
+ "type": "array",
354
+ "items": {"type": "string"},
355
+ "description": "Relevant info to query images from the document.",
356
+ },
357
+ },
358
+ ),
359
+ }
360
+
361
+ def __init__(
362
+ self, config: NucliaDBAgentConfig, agent_id: Optional[str] = None
363
+ ) -> None:
364
+ super().__init__(config, agent_id)
365
+ self.labelsets: Dict[str, List[str]] = {}
366
+ self.synonyms: Dict[str, Dict[str, List[str]]] = {}
367
+
368
+ async def ask_labels_list(
369
+ self,
370
+ labelset: str,
371
+ memory: QuestionMemory,
372
+ manager: Manager,
373
+ **kwargs,
374
+ ) -> List[str]:
375
+ await self.ask_labels(memory=memory, manager=manager, **kwargs)
376
+ if labelset not in self.labelsets:
377
+ raise Exception(f"Label set {labelset} not found in Knowledge Box")
378
+ return self.labelsets[labelset]
379
+
380
+ async def ask_labels(
381
+ self,
382
+ memory: QuestionMemory,
383
+ manager: Manager,
384
+ **kwargs,
385
+ ) -> Dict[str, List[str]]:
386
+ sources = self.config.sources
387
+ if len(sources) > 1:
388
+ raise Exception("ask_labels can only be used with one source")
389
+ source = sources[0]
390
+ if source not in self.labelsets:
391
+ nucliadb_driver = get_ndb_driver(manager, source)
392
+ self.labelsets = await nucliadb_driver.labels()
393
+ return self.labelsets
394
+
395
+ async def retrieve(
396
+ self,
397
+ manager: Manager,
398
+ source_id: str,
399
+ question: str,
400
+ keyword_filters: Optional[List[str]] = None,
401
+ and_filters: Optional[List[str]] = None,
402
+ or_filters: Optional[List[str]] = None,
403
+ catalog_filter: Optional[FilterExpression] = None,
404
+ ) -> List[str]:
405
+ nucliadb_driver = get_ndb_driver(manager, source_id)
406
+
407
+ filter_expression = await self.build_filter_expression(
408
+ nucliadb_driver,
409
+ source_id,
410
+ keyword_filters=keyword_filters,
411
+ and_filters=and_filters,
412
+ or_filters=or_filters,
413
+ filter_expression=catalog_filter,
414
+ )
415
+
416
+ find_result = await nucliadb_driver.find_raw(
417
+ FindRequest(
418
+ query=question,
419
+ filter_expression=filter_expression,
420
+ )
421
+ )
422
+ return list(find_result.resources.keys())
423
+
424
+ async def search_by_title(
425
+ self,
426
+ memory: QuestionMemory,
427
+ manager: Manager,
428
+ title: str,
429
+ filters: Optional[List[str]] = None,
430
+ catalog_filter: Optional[CatalogFilterExpression] = None,
431
+ **kwargs,
432
+ ) -> Dict[str, List[str]]:
433
+ sources = self.config.sources
434
+ resources = {}
435
+ for source in sources:
436
+ nucliadb_driver = get_ndb_driver(manager, source)
437
+ filter_expression = await self.build_catalog_filter_expression(
438
+ nucliadb_driver,
439
+ filters=filters,
440
+ filter_expression=catalog_filter,
441
+ )
442
+ response = await nucliadb_driver.catalog_search_raw(
443
+ CatalogRequest(
444
+ query=title,
445
+ page_size=25,
446
+ page_number=0,
447
+ filter_expression=filter_expression,
448
+ )
449
+ )
450
+ resources[source] = list(response.resources.keys())
451
+ return resources
452
+
453
+ async def get_all_images(
454
+ self,
455
+ resource: ResourceResponse,
456
+ ) -> List[str]:
457
+ image_urls: List[str] = []
458
+ if resource.data is None or resource.data.files is None:
459
+ logger.info("No files found in resource")
460
+ return image_urls
461
+ for _, field in resource.data.files.items():
462
+ if (
463
+ field.extracted is None
464
+ or field.extracted.file is None
465
+ or field.extracted.file.nested_list_position is None
466
+ ):
467
+ continue
468
+ image_names = field.extracted.file.nested_list_position.keys()
469
+ for image_name in image_names:
470
+ if (
471
+ field.extracted.file.file_generated is None
472
+ or image_name not in field.extracted.file.file_generated
473
+ ):
474
+ logger.info("No generated file found for image: " + image_name)
475
+ continue
476
+ image_url = field.extracted.file.file_generated[image_name].uri
477
+ if image_url is not None:
478
+ image_urls.append(image_url)
479
+
480
+ return image_urls
481
+
482
+ async def get_search_image_urls(
483
+ self,
484
+ response: KnowledgeboxFindResults,
485
+ nucliadb_driver: NucliaDBDriver,
486
+ ) -> List[str]:
487
+ image_urls: List[str] = []
488
+ if response.resources is None:
489
+ logger.info("No resources found in search response")
490
+ return image_urls
491
+ for resource_id, resource_results in response.resources.items():
492
+ if resource_results is None:
493
+ logger.info(f"No results found for resource {resource_id}")
494
+ continue
495
+ if resource_results.fields is None:
496
+ logger.info(f"No fields found for resource {resource_id}")
497
+ continue
498
+ for field_id, field_results in resource_results.fields.items():
499
+ clean_field_id = field_id.replace("/f/", "")
500
+ base_url = f"/kb/{nucliadb_driver.config.kbid}/resource/{resource_id}/file/{clean_field_id}/download/extracted/generated/"
501
+ # Collect unique image references for this field
502
+ images = set(
503
+ paragraph.reference
504
+ for paragraph in field_results.paragraphs.values()
505
+ if paragraph.reference is not None
506
+ )
507
+ if not images:
508
+ continue
509
+ # Prepare all image api paths
510
+ image_api_paths = [f"api/v1{base_url}{image}" for image in images]
511
+ # Fetch all ephemeral tokens in parallel
512
+ tokens = await asyncio.gather(
513
+ *[
514
+ nucliadb_driver.get_ephemeral_token(path=api_path)
515
+ for api_path in image_api_paths
516
+ ]
517
+ )
518
+ # Build URLs with their corresponding tokens
519
+ for image, ephemeral_token in zip(images, tokens):
520
+ image_url = f"{nucliadb_driver.config.url}/v1{base_url}{image}?eph-token={ephemeral_token}"
521
+ image_urls.append(image_url)
522
+ return image_urls
523
+
524
+ async def search_images(
525
+ self,
526
+ memory: QuestionMemory,
527
+ manager: Manager,
528
+ title: str,
529
+ image_info: List[str],
530
+ **kwargs,
531
+ ) -> List[Context]:
532
+ # First, search by title to get specific matching resource IDs )
533
+ resources_by_source = await self.search_by_title(
534
+ memory=memory,
535
+ manager=manager,
536
+ title=title,
537
+ )
538
+ contexts: list[Context] = []
539
+ # Then do a search on the resource to find only paragraphs with images that match the image_info
540
+ for source_id, resource_ids in resources_by_source.items():
541
+ nucliadb_driver = get_ndb_driver(manager, source_id)
542
+
543
+ for resource_id in resource_ids:
544
+ filter_expression = {
545
+ "field": {"prop": "resource", "id": resource_id},
546
+ "operator": "and",
547
+ "paragraph": {
548
+ "or": [
549
+ {"prop": "kind", "kind": "OCR"},
550
+ {"prop": "kind", "kind": "INCEPTION"},
551
+ ]
552
+ },
553
+ }
554
+ images_urls: List[str] = []
555
+ for info in image_info:
556
+ logger.info("Searching images with info: " + info)
557
+ find_request = FindRequest(
558
+ query=info,
559
+ filter_expression=filter_expression, # type: ignore
560
+ )
561
+ response = await nucliadb_driver.find_raw(find_request)
562
+ images_urls.extend(
563
+ await self.get_search_image_urls(response, nucliadb_driver)
564
+ )
565
+
566
+ if len(images_urls) > 0:
567
+ context = Context(
568
+ agent_id=self.agent_id,
569
+ original_question_uuid=memory.original_question_uuid,
570
+ actual_question_uuid=None,
571
+ question="Search images by title. Title: "
572
+ + title
573
+ + " with image info: "
574
+ + ", ".join(image_info),
575
+ source=source_id,
576
+ agent="search_images",
577
+ title=self.config.title
578
+ if self.config.title
579
+ else f"Search images on {source_id} Knowledge Box",
580
+ image_urls=set(images_urls), # type: ignore
581
+ )
582
+ contexts.append(context)
583
+ else:
584
+ logger.info("No images found for resource " + resource_id)
585
+
586
+ return contexts
587
+
588
+ async def all_images_by_title(
589
+ self,
590
+ memory: QuestionMemory,
591
+ manager: Manager,
592
+ title: str,
593
+ **kwargs,
594
+ ) -> List[Context]:
595
+ # First, search by title to get specific matching resource IDs
596
+ resources_by_source = await self.search_by_title(
597
+ memory=memory,
598
+ manager=manager,
599
+ title=title,
600
+ )
601
+ contexts: list[Context] = []
602
+
603
+ # Then get all the image links from those resource IDs
604
+ for source_id, resource_ids in resources_by_source.items():
605
+ nucliadb_driver = get_ndb_driver(manager, source_id)
606
+
607
+ for resource_id in resource_ids:
608
+ resource = await nucliadb_driver.get_resource_by_id(
609
+ query_params={ # type: ignore
610
+ "show": ["basic", "extracted"],
611
+ "extracted": ["metadata", "file"],
612
+ },
613
+ rid=resource_id,
614
+ )
615
+ if resource is None:
616
+ logger.info("Resource not found: " + resource_id)
617
+ continue
618
+ images_urls = await self.get_all_images(resource=resource)
619
+ if len(images_urls) > 0:
620
+ context = Context(
621
+ agent_id=self.agent_id,
622
+ original_question_uuid=memory.original_question_uuid,
623
+ actual_question_uuid=None,
624
+ question="All images by title. Title: " + title,
625
+ source=source_id,
626
+ agent="all_images_by_title",
627
+ title=self.config.title
628
+ if self.config.title
629
+ else f"All images by title on {source_id} Knowledge Box",
630
+ image_urls=images_urls,
631
+ )
632
+ contexts.append(context)
633
+ else:
634
+ logger.info("No images found for resource " + resource_id)
635
+
636
+ return contexts
637
+
638
+ async def create_context_from_nucliadb_response(
639
+ self,
640
+ response: SyncAskResponse,
641
+ source_id: str,
642
+ memory: QuestionMemory,
643
+ question: str,
644
+ ) -> Context:
645
+ t0 = time()
646
+ context = Context(
647
+ agent_id=self.agent_id,
648
+ original_question_uuid=memory.original_question_uuid,
649
+ actual_question_uuid=None,
650
+ question=question,
651
+ source=source_id,
652
+ agent="nucliadb_agent_title",
653
+ title=self.config.title
654
+ if self.config.title
655
+ else f"Title search on {source_id} Knowledge Box",
656
+ )
657
+ answer = None
658
+ input_tokens = (
659
+ response.metadata.tokens.input_nuclia
660
+ if response.metadata and response.metadata.tokens
661
+ else 0
662
+ )
663
+ output_tokens = (
664
+ response.metadata.tokens.output_nuclia
665
+ if response.metadata and response.metadata.tokens
666
+ else 0
667
+ )
668
+
669
+ input_tokens = (
670
+ response.metadata.tokens.input_nuclia
671
+ if response.metadata and response.metadata.tokens
672
+ else 0
673
+ )
674
+ output_tokens = (
675
+ response.metadata.tokens.output_nuclia
676
+ if response.metadata and response.metadata.tokens
677
+ else 0
678
+ )
679
+ context.chunks = []
680
+ answer = response.answer if response.status == "success" else ""
681
+ if response.citations != {}:
682
+ result_chunks = list(response.citations.keys())
683
+ else:
684
+ result_chunks = response.retrieval_results.best_matches
685
+ for chunk_id in result_chunks:
686
+ ids = chunk_id.split("/")
687
+ resource_id = ids[0]
688
+ field_id = f"/{ids[1]}/{ids[2]}" if len(ids) > 2 else ""
689
+ resource = response.retrieval_results.resources[resource_id]
690
+ resource_title = resource.title
691
+ try:
692
+ text = (
693
+ resource.fields[field_id].paragraphs[chunk_id].text
694
+ if field_id in resource.fields
695
+ else ""
696
+ )
697
+ except Exception:
698
+ if (
699
+ response.augmented_context is not None
700
+ and chunk_id in response.augmented_context.paragraphs
701
+ ):
702
+ text = response.augmented_context.paragraphs[chunk_id].text
703
+ else:
704
+ text = ""
705
+ context.chunks.append(
706
+ Chunk(
707
+ chunk_id=chunk_id,
708
+ title=resource_title,
709
+ text=text,
710
+ source=source_id,
711
+ origin_agent=self.config.module,
712
+ )
713
+ )
714
+
715
+ if answer:
716
+ context.summary = answer
717
+ await memory.add_step(
718
+ step_module=self.config.module,
719
+ step_title=self.step_title("Ask by title"),
720
+ step_reason="Got answer" if answer else "No answer",
721
+ step_value=answer if answer else "No answer",
722
+ timeit=time() - t0,
723
+ input_nuclia_tokens=input_tokens,
724
+ output_nuclia_tokens=output_tokens,
725
+ step_agent_path=f"/context/{self.agent_id}",
726
+ )
727
+ return context
728
+
729
+ async def ask_agent(
730
+ self,
731
+ memory: QuestionMemory,
732
+ manager: Manager,
733
+ question: str,
734
+ **kwargs,
735
+ ) -> List[Context]:
736
+ sources = self.config.sources
737
+ keyword_filters = kwargs.get("keyword_filters", [])
738
+ full_resource = kwargs.get("full_resource", False)
739
+
740
+ and_filters = []
741
+ or_filters = []
742
+
743
+ labels_dict: Dict[str, List[str]] = {}
744
+
745
+ for label in kwargs.get("labels", []):
746
+ try:
747
+ labelset, label_value = label.split("/", 1)
748
+ except ValueError:
749
+ logger.warning(f"Invalid label format: {label}")
750
+ labels_dict.setdefault(labelset, []).append(label_value)
751
+
752
+ for labelset, label_values in labels_dict.items():
753
+ if len(label_values) == 1:
754
+ and_filters.append(f"/l/{labelset}/{label_values[0]}")
755
+ else:
756
+ or_filters = [f"/l/{labelset}/{lv}" for lv in label_values]
757
+
758
+ # Choose sources based on the question/s and the in
759
+ chosen_sources = await choose_source(
760
+ memory,
761
+ manager,
762
+ sources,
763
+ question,
764
+ ident=self.agent_id,
765
+ step_title=self.step_title("Choose sources"),
766
+ )
767
+
768
+ contexts: list[Context] = await asyncio.gather(
769
+ *[
770
+ self.inner_rag(
771
+ source_obj=source,
772
+ question=question,
773
+ memory=memory,
774
+ manager=manager,
775
+ keyword_filters=keyword_filters,
776
+ and_filters=and_filters,
777
+ or_filters=or_filters,
778
+ full_resource=full_resource,
779
+ )
780
+ for source in chosen_sources
781
+ ]
782
+ )
783
+ return contexts
784
+
785
+ async def _get_question_context(
786
+ self,
787
+ memory: QuestionMemory,
788
+ manager: Manager,
789
+ question_uuid: str,
790
+ question: str,
791
+ flow_id: str,
792
+ extra_context: Optional[Dict[str, Any]] = None,
793
+ ) -> List[tuple[str, str]]:
794
+ sources = self.config.sources
795
+ # Choose sources based on the question/s and the in
796
+ chosen_sources = await choose_source(
797
+ memory,
798
+ manager,
799
+ sources,
800
+ question,
801
+ ident=self.agent_id,
802
+ step_title=self.step_title("Choose sources"),
803
+ )
804
+
805
+ missing: list[tuple[str, str] | None] = await asyncio.gather(
806
+ *[
807
+ self.rag(
808
+ source_obj=source,
809
+ question_uuid=question_uuid,
810
+ question=question,
811
+ memory=memory,
812
+ manager=manager,
813
+ flow_id=flow_id,
814
+ )
815
+ for source in chosen_sources
816
+ ]
817
+ )
818
+ # We only want to fallback if all sources failed
819
+ if any(m is None for m in missing):
820
+ return []
821
+ else:
822
+ # XXX: We might be duplicating some results here if multiple sources return the same missing question
823
+ return [r for r in missing if r is not None]
824
+
825
+ async def build_filter_expression(
826
+ self,
827
+ nucliadb_driver: NucliaDBDriver,
828
+ source: str,
829
+ keyword_filters: Optional[List[str]] = None,
830
+ and_filters: Optional[List[str]] = None,
831
+ or_filters: Optional[List[str]] = None,
832
+ resource_filters: Optional[List[str]] = None,
833
+ filter_expression: Optional[FilterExpression] = None,
834
+ ) -> FilterExpression | None:
835
+ """
836
+ Apply all the different filters to create a single filter expression that can be used in a NucliaDB query.
837
+ In particular, it will merge whatever filters were passed by the user with the ones configured at the driver level.
838
+ """
839
+ and_operands: list[FieldFilterExpression] = []
840
+
841
+ # Parse keyword filters first
842
+ keyword_filter_result = []
843
+ if len(keyword_filters or []) > 0 and source not in self.synonyms:
844
+ self.synonyms[source] = await nucliadb_driver.synonyms_raw()
845
+
846
+ for keyword_filter in keyword_filters or []:
847
+ keyword_filter_result.append(keyword_filter)
848
+ if keyword_filter.lower() in self.synonyms[source]:
849
+ for synonym in self.synonyms[source][keyword_filter.lower()]:
850
+ keyword_filter_result.append(synonym)
851
+
852
+ if len(keyword_filter_result) == 1:
853
+ and_operands.append(Keyword(word=keyword_filter_result[0]))
854
+ elif len(keyword_filter_result) > 1:
855
+ and_operands.append(
856
+ Or(operands=[Keyword(word=word) for word in keyword_filter_result])
857
+ )
858
+
859
+ # Add resource filters if exists
860
+ if resource_filters is not None:
861
+ and_operands.append(
862
+ Or(operands=[Resource(id=rid) for rid in resource_filters])
863
+ )
864
+
865
+ # Add old format filters if exists (for bw compatibility)
866
+ if nucliadb_driver.config.filters is not None and len(
867
+ nucliadb_driver.config.filters
868
+ ):
869
+ operands = _to_field_filter_expression(nucliadb_driver.config.filters)
870
+ if len(operands) == 1:
871
+ and_operands.append(operands[0])
872
+ elif len(operands) > 1:
873
+ and_operands.append(And(operands=operands))
874
+
875
+ # Now add the and/or filters from the function call
876
+ if and_filters is not None:
877
+ operands = _to_field_filter_expression(and_filters)
878
+ if len(operands) == 1:
879
+ and_operands.append(operands[0])
880
+ elif len(operands) > 1:
881
+ and_operands.append(And(operands=operands))
882
+
883
+ if or_filters is not None:
884
+ operands = _to_field_filter_expression(or_filters)
885
+ if len(operands) == 1:
886
+ and_operands.append(operands[0])
887
+ elif len(operands) > 1:
888
+ and_operands.append(Or(operands=operands))
889
+
890
+ # Now combine all filter expressions
891
+ expressions_to_combine = []
892
+ if len(and_operands) == 1:
893
+ expressions_to_combine.append(FilterExpression(field=and_operands[0]))
894
+ elif len(and_operands) > 1:
895
+ expressions_to_combine.append(
896
+ FilterExpression(field=And(operands=and_operands))
897
+ )
898
+ if filter_expression is not None:
899
+ expressions_to_combine.append(filter_expression)
900
+ if nucliadb_driver.config.filter_expression is not None:
901
+ expressions_to_combine.append(nucliadb_driver.config.filter_expression)
902
+
903
+ if len(expressions_to_combine) == 0:
904
+ # Nothing to filter from
905
+ return None
906
+ if len(expressions_to_combine) == 1:
907
+ # Nothing to combine, return the only expression
908
+ return expressions_to_combine[0]
909
+ else:
910
+ # Combine all filter expressions with AND operator
911
+ return combine_filter_expressions(expressions_to_combine, operator="and")
912
+
913
+ async def build_catalog_filter_expression(
914
+ self,
915
+ nucliadb_driver: NucliaDBDriver,
916
+ filters: Optional[List[str]] = None,
917
+ classification_labels: Optional[List[str]] = None,
918
+ classification_labels_operand: Literal["and", "or"] = "and",
919
+ filter_expression: Optional[CatalogFilterExpression] = None,
920
+ ) -> CatalogFilterExpression | None:
921
+ """
922
+ Apply all the different filters to create a single catalog filter expression that can be used in a NucliaDB catalog query.
923
+ In particular, it will merge whatever filters were passed by the user with the ones configured at the driver level.
924
+ """
925
+
926
+ # First off, possibly create a filter expression from the user-provided filters and classification labels
927
+ and_operands: list[ResourceFilterExpression] = []
928
+ if classification_labels is not None and len(classification_labels) > 0:
929
+ operands = _to_resource_filter_expression(classification_labels)
930
+ if len(operands) == 1:
931
+ and_operands.append(operands[0])
932
+ elif len(operands) > 1:
933
+ if classification_labels_operand == "and":
934
+ and_operands.append(And(operands=operands))
935
+ else:
936
+ and_operands.append(Or(operands=operands))
937
+ if filters is not None and len(filters) > 0:
938
+ operands = _to_resource_filter_expression(filters)
939
+ if len(operands) == 1:
940
+ and_operands.append(operands[0])
941
+ elif len(operands) > 1:
942
+ and_operands.append(And(operands=operands))
943
+
944
+ to_combine: list[CatalogFilterExpression] = []
945
+ if len(and_operands) == 1:
946
+ to_combine.append(CatalogFilterExpression(resource=and_operands[0]))
947
+ elif len(and_operands) > 1:
948
+ to_combine.append(
949
+ CatalogFilterExpression(resource=And(operands=and_operands))
950
+ )
951
+ if filter_expression is not None:
952
+ to_combine.append(filter_expression)
953
+ if nucliadb_driver.config.catalog_filter_expression is not None:
954
+ to_combine.append(nucliadb_driver.config.catalog_filter_expression)
955
+ if len(to_combine) == 0:
956
+ return None
957
+ elif len(to_combine) == 1:
958
+ return to_combine[0]
959
+ else:
960
+ return combine_catalog_filter_expressions(to_combine, operator="and")
961
+
962
+ async def inner_rag(
963
+ self,
964
+ source_obj: Source,
965
+ manager: Manager,
966
+ memory: QuestionMemory,
967
+ question: str,
968
+ question_uuid: Optional[str] = None,
969
+ keyword_filters: List[str] = [],
970
+ and_filters: Optional[List[str]] = None,
971
+ or_filters: Optional[List[str]] = None,
972
+ full_resource: bool = False,
973
+ resource_filters: Optional[List[str]] = None,
974
+ ) -> Context:
975
+ source = source_obj.id
976
+
977
+ nucliadb_driver = get_ndb_driver(manager, source)
978
+
979
+ context = Context(
980
+ agent_id=self.agent_id,
981
+ original_question_uuid=memory.original_question_uuid,
982
+ actual_question_uuid=question_uuid,
983
+ question=question,
984
+ source=source,
985
+ agent="nucliadb_agent",
986
+ title=self.config.title
987
+ if self.config.title
988
+ else f"Retrieval on {source} Knowledge Box",
989
+ )
990
+
991
+ rag_strategies: list[RagStrategies]
992
+ if full_resource:
993
+ rag_strategies = [
994
+ FullResourceStrategy(count=1),
995
+ MetadataExtensionStrategy(types=["classification_labels", "origin"]), # type: ignore
996
+ ]
997
+ else:
998
+ rag_strategies = [
999
+ FieldExtensionStrategy(fields=["a/title", "a/summary"]),
1000
+ NeighbouringParagraphsStrategy(before=5, after=5),
1001
+ MetadataExtensionStrategy(types=["classification_labels", "origin"]), # type: ignore
1002
+ ]
1003
+
1004
+ filter_expression = await self.build_filter_expression(
1005
+ nucliadb_driver,
1006
+ source,
1007
+ keyword_filters=keyword_filters,
1008
+ and_filters=and_filters,
1009
+ or_filters=or_filters,
1010
+ resource_filters=resource_filters,
1011
+ )
1012
+ t0 = time()
1013
+
1014
+ ask_request = AskRequest(
1015
+ query=question,
1016
+ show=[ResourceProperties.BASIC, ResourceProperties.ORIGIN],
1017
+ citations=CitationsType.LLM_FOOTNOTES,
1018
+ generative_model=self.config.generative_model,
1019
+ filter_expression=filter_expression,
1020
+ rag_strategies=rag_strategies,
1021
+ generate_answer=self.config.generate_inner_answer,
1022
+ )
1023
+
1024
+ paragraphs_result = await ask(
1025
+ search_sdk=nucliadb_driver.driver,
1026
+ reader_sdk=nucliadb_driver.driver,
1027
+ kbid=nucliadb_driver.config.kbid,
1028
+ ask_request=ask_request,
1029
+ user_id=memory.original_question_uuid,
1030
+ client_type=NucliaDBClientType.API,
1031
+ origin=memory.arguments.get("origin", ""),
1032
+ resource=None,
1033
+ extra_predict_headers={},
1034
+ )
1035
+
1036
+ await memory.add_step(
1037
+ step_module="nucliadb_agent",
1038
+ step_title=self.step_title("Preparing RAG"),
1039
+ step_reason="",
1040
+ step_value=ask_request.model_dump_json(
1041
+ exclude_none=True, exclude_unset=True
1042
+ ),
1043
+ timeit=0.0,
1044
+ input_nuclia_tokens=0.0,
1045
+ output_nuclia_tokens=0.0,
1046
+ step_agent_path=f"/context/{self.agent_id}",
1047
+ metadata={"learning_id": paragraphs_result.nuclia_learning_id},
1048
+ )
1049
+
1050
+ # Hack to send the find results on the agent context
1051
+ context.json_objects.append(
1052
+ JSONObject(
1053
+ json_schema=KBFindResultsSchema,
1054
+ json_object=paragraphs_result.main_results.model_dump(),
1055
+ id=JSON_OBJECT_ID,
1056
+ )
1057
+ )
1058
+
1059
+ paragraphs = await paragraphs_result.model_dump()
1060
+ answer = None
1061
+ input_tokens = (
1062
+ paragraphs.consumption.normalized_tokens.input
1063
+ if paragraphs.consumption and paragraphs.consumption.normalized_tokens
1064
+ else 0
1065
+ )
1066
+ output_tokens = (
1067
+ paragraphs.consumption.normalized_tokens.output
1068
+ if paragraphs.consumption and paragraphs.consumption.normalized_tokens
1069
+ else 0
1070
+ )
1071
+ context.chunks = []
1072
+ answer = paragraphs.answer if paragraphs.status == "success" else ""
1073
+ if paragraphs.citation_footnote_to_context != {}:
1074
+ result_chunks = list(paragraphs.citation_footnote_to_context.values())
1075
+ answer = clean_citation_footnotes_from_answer(
1076
+ answer, paragraphs.citation_footnote_to_context
1077
+ )
1078
+ else:
1079
+ result_chunks = paragraphs.retrieval_results.best_matches
1080
+ for chunk_id in result_chunks:
1081
+ resource_id = chunk_id.split("/")[0]
1082
+ resource = paragraphs.retrieval_results.resources[resource_id]
1083
+ text = get_chunk_text(paragraphs, chunk_id)
1084
+ context.chunks.append(
1085
+ Chunk(
1086
+ chunk_id=chunk_id,
1087
+ title=resource.title,
1088
+ text=text,
1089
+ source=source,
1090
+ origin_url=resource.origin.url if resource.origin else None,
1091
+ origin_agent=self.config.module,
1092
+ )
1093
+ )
1094
+ # TODO: Save citations properly
1095
+
1096
+ # XXX: This answer will be overriden by any call to save_ctx_and_return_missing below
1097
+ if answer and paragraphs.status == "success":
1098
+ context.summary = answer
1099
+ elif paragraphs.status in ["no_context", "no_retrieval_data"]:
1100
+ context.missing = question
1101
+
1102
+ await memory.add_step(
1103
+ step_module=self.config.module,
1104
+ step_title=self.step_title("RAG retrieval"),
1105
+ step_reason="Got answer" if answer else "No answer",
1106
+ step_value=answer if answer else "No answer",
1107
+ timeit=time() - t0,
1108
+ input_nuclia_tokens=input_tokens if input_tokens else 0,
1109
+ output_nuclia_tokens=output_tokens if output_tokens else 0,
1110
+ step_agent_path=f"/context/{self.agent_id}",
1111
+ )
1112
+ return context
1113
+
1114
+ async def rag(
1115
+ self,
1116
+ source_obj: Source,
1117
+ question_uuid: str,
1118
+ question: str,
1119
+ memory: QuestionMemory,
1120
+ manager: Manager,
1121
+ flow_id: str,
1122
+ ):
1123
+ context = await self.inner_rag(
1124
+ source_obj=source_obj,
1125
+ question_uuid=question_uuid,
1126
+ question=question,
1127
+ memory=memory,
1128
+ manager=manager,
1129
+ )
1130
+
1131
+ if self.fallback is None:
1132
+ if context.summary is not None and context.summary != "":
1133
+ missing = await self.save_ctx_and_return_missing(
1134
+ context=context,
1135
+ question=question,
1136
+ memory=memory,
1137
+ manager=manager,
1138
+ flow_id=flow_id,
1139
+ )
1140
+ else:
1141
+ missing = (question_uuid, question)
1142
+ logger.info(
1143
+ f"No context found for question {question} in source {source_obj.id}, skipping"
1144
+ )
1145
+
1146
+ return missing
1147
+ missing = await self.save_ctx_and_return_missing(
1148
+ context=context,
1149
+ question=question,
1150
+ memory=memory,
1151
+ manager=manager,
1152
+ flow_id=flow_id,
1153
+ )
1154
+ return missing
1155
+
1156
+ async def facets_search(
1157
+ self,
1158
+ memory: QuestionMemory,
1159
+ manager: Manager,
1160
+ question: str,
1161
+ **kwargs,
1162
+ ) -> List[Context]:
1163
+ sources = self.config.sources
1164
+ # Perform catalog faceted search
1165
+ chosen_sources = await choose_source(
1166
+ memory,
1167
+ manager,
1168
+ sources,
1169
+ question,
1170
+ ident=self.agent_id,
1171
+ step_title=self.step_title("Choose sources"),
1172
+ )
1173
+ contexts: list[Context] = await asyncio.gather(
1174
+ *[
1175
+ self.inner_facets_search(memory, manager, question, source, i)
1176
+ for i, source in enumerate(chosen_sources)
1177
+ ]
1178
+ )
1179
+
1180
+ return contexts
1181
+
1182
+ async def inner_facets_search(
1183
+ self,
1184
+ memory: QuestionMemory,
1185
+ manager: Manager,
1186
+ question: str,
1187
+ source: Source,
1188
+ idx: int,
1189
+ ) -> Context:
1190
+ t0 = time()
1191
+ ndb = get_ndb_driver(manager, source.id)
1192
+ labels = await ndb.labels()
1193
+
1194
+ # Select any or all relevant labels
1195
+ # XXX: Could be expanded to select not only labelset but also specific labels within the labelset since faceted search works with prefix
1196
+ prompt = FACETS_LABEL_SEARCH_AGENT_TEMPLATE.render(
1197
+ question=question, labels=labels
1198
+ )
1199
+ selected_labels, input_nt, output_nt = await manager.execute_json(
1200
+ prompt,
1201
+ user_id="rao-facets-search-label-selection",
1202
+ schema={
1203
+ "type": "object",
1204
+ "title": "Relevant Label Set Selection",
1205
+ "description": "Response schema for selected label sets.",
1206
+ "properties": {
1207
+ "reasoning": {
1208
+ "type": "string",
1209
+ "description": "Reasoning behind the selection of label sets.",
1210
+ },
1211
+ "label_sets": {
1212
+ "type": "array",
1213
+ "items": {"type": "string"},
1214
+ "description": "List of selected label sets to show all possible values.",
1215
+ },
1216
+ "labels": {
1217
+ "type": "array",
1218
+ "items": {"type": "string"},
1219
+ "description": "List of selected labels to filter data in format labelset/label",
1220
+ },
1221
+ },
1222
+ "required": ["label_sets", "labels"],
1223
+ "additionalProperties": False,
1224
+ },
1225
+ model=self.config.generative_model,
1226
+ tracking=memory.get_tracking_info(),
1227
+ )
1228
+ labelsets_selected = selected_labels.get("label_sets", [])
1229
+ labels_selected: List[str] = selected_labels.get("labels", [])
1230
+
1231
+ if len(labelsets_selected) == 0:
1232
+ logger.info(
1233
+ f"No labels selected for faceted search on question {question}, skipping."
1234
+ )
1235
+ await memory.add_step(
1236
+ step_module=self.config.module,
1237
+ step_title=self.step_title("Faceted search"),
1238
+ step_reason="Faceted search skipped",
1239
+ step_value=selected_labels.get("reasoning", ""),
1240
+ timeit=time() - t0,
1241
+ input_nuclia_tokens=input_nt,
1242
+ output_nuclia_tokens=output_nt,
1243
+ step_agent_path=f"/context/{self.agent_id}/facets_search",
1244
+ )
1245
+ return Context(
1246
+ agent_id=self.agent_id,
1247
+ original_question_uuid=memory.original_question_uuid,
1248
+ actual_question_uuid=None,
1249
+ missing=question,
1250
+ question=question,
1251
+ source=source.id,
1252
+ chunks=[],
1253
+ agent="nucliadb_agent_facets",
1254
+ title=f"Faceted search on {source.id} Knowledge Box",
1255
+ )
1256
+ catalog_request = CatalogRequest(
1257
+ faceted=[f"/l/{labelset}" for labelset in labelsets_selected],
1258
+ )
1259
+ filter_expression = await self.build_catalog_filter_expression(
1260
+ ndb,
1261
+ classification_labels=labels_selected,
1262
+ classification_labels_operand="or",
1263
+ )
1264
+ catalog_request.filter_expression = filter_expression
1265
+
1266
+ facets_result = await ndb.catalog_search_raw(catalog_request)
1267
+
1268
+ if (
1269
+ facets_result.fulltext is not None
1270
+ and facets_result.fulltext.facets is not None
1271
+ ):
1272
+ prompt = FACETS_SEARCH_ANSWER_GENERATION_AGENT_TEMPLATE.render(
1273
+ question=question,
1274
+ facets=json.dumps(facets_result.fulltext.facets),
1275
+ )
1276
+ answer, input_nt2, output_nt2, code = await manager.execute(
1277
+ prompt,
1278
+ user_id="rao-facets-answer-generation",
1279
+ model=self.config.generative_model,
1280
+ tracking=memory.get_tracking_info(),
1281
+ )
1282
+ # TODO: Error if code is not success
1283
+ input_nt += input_nt2
1284
+ output_nt += output_nt2
1285
+ context = Context(
1286
+ agent_id=self.agent_id,
1287
+ original_question_uuid=memory.original_question_uuid,
1288
+ actual_question_uuid=None,
1289
+ question=question,
1290
+ source=source.id,
1291
+ summary=answer,
1292
+ chunks=[
1293
+ Chunk(
1294
+ chunk_id=f"facets_search_result-{idx}",
1295
+ text=answer,
1296
+ origin_agent=self.config.module,
1297
+ )
1298
+ ],
1299
+ agent="nucliadb_agent_facets",
1300
+ title=f"Faceted search on {source.id} Knowledge Box",
1301
+ )
1302
+
1303
+ await memory.add_step(
1304
+ step_module=self.config.module,
1305
+ step_title=self.step_title("Faceted search"),
1306
+ step_reason="Faceted search performed",
1307
+ step_value=selected_labels.get("reasoning", ""),
1308
+ timeit=time() - t0,
1309
+ input_nuclia_tokens=input_nt,
1310
+ output_nuclia_tokens=output_nt,
1311
+ step_agent_path=f"/context/{self.agent_id}/facets_search",
1312
+ )
1313
+ return context
1314
+
1315
+ async def facets_count(
1316
+ self,
1317
+ memory: QuestionMemory,
1318
+ manager: Manager,
1319
+ question: str,
1320
+ **kwargs,
1321
+ ) -> List[Context]:
1322
+ sources = self.config.sources
1323
+ # Perform catalog faceted search
1324
+ chosen_sources = await choose_source(
1325
+ memory,
1326
+ manager,
1327
+ sources,
1328
+ question,
1329
+ ident=self.agent_id,
1330
+ step_title=self.step_title("Choose sources"),
1331
+ )
1332
+ contexts: list[Context] = await asyncio.gather(
1333
+ *[
1334
+ self.inner_facets(memory, manager, question, source, i)
1335
+ for i, source in enumerate(chosen_sources)
1336
+ ]
1337
+ )
1338
+ return contexts
1339
+
1340
+ async def inner_facets(
1341
+ self,
1342
+ memory: QuestionMemory,
1343
+ manager: Manager,
1344
+ question: str,
1345
+ source: Source,
1346
+ idx: int,
1347
+ ) -> Context:
1348
+ t0 = time()
1349
+ ndb = get_ndb_driver(manager, source.id)
1350
+ labels = await ndb.labels()
1351
+ labels_str = format_ndb_labels(labels, max_examples=5)
1352
+
1353
+ # Select any or all relevant labels
1354
+ # XXX: Could be expanded to select not only labelset but also specific labels within the labelset since faceted search works with prefix
1355
+ prompt = FACETS_LABEL_SELECTION_AGENT_TEMPLATE.render(
1356
+ question=question, labels_str=labels_str
1357
+ )
1358
+ selected_labels, input_nt, output_nt = await manager.execute_json(
1359
+ prompt,
1360
+ user_id="rao-facets-label-selection",
1361
+ schema={
1362
+ "type": "object",
1363
+ "title": "Relevant Label Set Selection",
1364
+ "description": "Response schema for selected label sets.",
1365
+ "properties": {
1366
+ "reasoning": {
1367
+ "type": "string",
1368
+ "description": "Reasoning behind the selection of label sets.",
1369
+ },
1370
+ "label_sets": {
1371
+ "type": "array",
1372
+ "items": {"type": "string"},
1373
+ "description": "List of selected label sets.",
1374
+ },
1375
+ },
1376
+ "required": ["label_sets"],
1377
+ "additionalProperties": False,
1378
+ },
1379
+ model=self.config.generative_model,
1380
+ tracking=memory.get_tracking_info(),
1381
+ )
1382
+ labels_selected = selected_labels.get("label_sets", [])
1383
+ if len(labels_selected) == 0:
1384
+ logger.info(
1385
+ f"No labels selected for faceted search on question {question}, skipping."
1386
+ )
1387
+ else:
1388
+ # We are assuming resources equals fields here
1389
+ facets, total = await ndb.field_labels(labelsets=labels_selected)
1390
+ prompt = FACETS_ANSWER_GENERATION_AGENT_TEMPLATE.render(
1391
+ question=question,
1392
+ labels_selected=labels_selected,
1393
+ source_id=source.id,
1394
+ total=total,
1395
+ facets=json.dumps(facets),
1396
+ )
1397
+ answer, input_nt2, output_nt2, code = await manager.execute(
1398
+ prompt,
1399
+ user_id="rao-facets-answer-generation",
1400
+ model=self.config.generative_model,
1401
+ tracking=memory.get_tracking_info(),
1402
+ )
1403
+ # TODO: Error if code is not success
1404
+ input_nt += input_nt2
1405
+ output_nt += output_nt2
1406
+ context = Context(
1407
+ agent_id=self.agent_id,
1408
+ original_question_uuid=memory.original_question_uuid,
1409
+ actual_question_uuid=None,
1410
+ question=question,
1411
+ source=source.id,
1412
+ chunks=[
1413
+ Chunk(
1414
+ chunk_id=f"facets_count_result-{idx}",
1415
+ text=answer,
1416
+ origin_agent=self.config.module,
1417
+ )
1418
+ ],
1419
+ agent="nucliadb_agent_facets",
1420
+ title=f"Faceted search on {source.id} Knowledge Box",
1421
+ )
1422
+
1423
+ await memory.add_step(
1424
+ step_module=self.config.module,
1425
+ step_title=self.step_title("Faceted count"),
1426
+ step_reason="Faceted search performed",
1427
+ step_value=selected_labels.get("reasoning", ""),
1428
+ timeit=time() - t0,
1429
+ input_nuclia_tokens=input_nt,
1430
+ output_nuclia_tokens=output_nt,
1431
+ step_agent_path=f"/context/{self.agent_id}/facets_count",
1432
+ )
1433
+ return context
1434
+
1435
+ async def catalog_search(
1436
+ self,
1437
+ memory: QuestionMemory,
1438
+ manager: Manager,
1439
+ question: str,
1440
+ **kwargs,
1441
+ ) -> List[Context]:
1442
+ sources = self.config.sources
1443
+ # Perform catalog search
1444
+ chosen_sources = await choose_source(
1445
+ memory,
1446
+ manager,
1447
+ sources,
1448
+ question,
1449
+ ident=self.agent_id,
1450
+ step_title=self.step_title("Choose sources"),
1451
+ )
1452
+ contexts: list[Context] = await asyncio.gather(
1453
+ *[
1454
+ self.inner_catalog_search(memory, manager, question, source, i)
1455
+ for i, source in enumerate(chosen_sources)
1456
+ ]
1457
+ )
1458
+
1459
+ return contexts
1460
+
1461
+ async def inner_catalog_search(
1462
+ self,
1463
+ memory: QuestionMemory,
1464
+ manager: Manager,
1465
+ question: str,
1466
+ source: Source,
1467
+ idx: int,
1468
+ ) -> Context:
1469
+ t0 = time()
1470
+ ndb = get_ndb_driver(manager, source.id)
1471
+ labels = await ndb.labels()
1472
+ labels_str = format_ndb_labels(labels, max_examples=100)
1473
+
1474
+ prompt = get_catalog_filter_prompt(question=question, labels_str=labels_str)
1475
+ selected_filters, input_nt, output_nt = await manager.execute_json(
1476
+ prompt,
1477
+ user_id="rao-catalog-label-selection",
1478
+ schema={
1479
+ "type": "object",
1480
+ "title": "Catalog Filter Expression Selection",
1481
+ "description": "Response schema for selected filter expression.",
1482
+ "properties": {
1483
+ "reasoning": {
1484
+ "type": "string",
1485
+ "description": "Reasoning behind the selection of the filter expression.",
1486
+ },
1487
+ # I don't want to constrain to the schema because filter expressions can be complex, but also the providers don't let me set a freeform object, so we set a string and then parse it
1488
+ "filters": {
1489
+ "type": "string",
1490
+ "description": "The filter expression to perform the catalog search as a JSON array.",
1491
+ },
1492
+ },
1493
+ "required": ["filters"],
1494
+ },
1495
+ model=self.config.generative_model,
1496
+ tracking=memory.get_tracking_info(),
1497
+ )
1498
+ filters = self.parse_selected_filters(question, source, selected_filters)
1499
+
1500
+ # TODO: Consider pagination if we want to be able to show more results
1501
+ resp = await ndb.catalog_search_raw(
1502
+ q=CatalogRequest(
1503
+ query="",
1504
+ page_number=0,
1505
+ page_size=100,
1506
+ filters=filters,
1507
+ )
1508
+ )
1509
+ catalog_str = format_ndb_catalog(resp)
1510
+ catalog_txt = (
1511
+ f"Catalog search results for filters {[f.model_dump(exclude_none=True) for f in filters]} in {source.id} Knowledge Box. "
1512
+ + "The results contain titles, formats, languages, and labels assigned to the resources for each label set. "
1513
+ + "If a result does not have a label for a specific label set, it means no label was assigned for that label set. "
1514
+ + f"The Knowledge Box contains the following label sets (with some example labels): {format_ndb_labels(labels, max_examples=5)}.\n"
1515
+ )
1516
+ if resp.fulltext and resp.fulltext.total > len(resp.resources):
1517
+ catalog_txt += f"The search matched {resp.fulltext.total}, of which, only the first {len(resp.resources)} are shown"
1518
+ catalog_txt += ":\n\nCatalog Results\n\n" + catalog_str
1519
+ prompt = CATALOG_ANSWER_GENERATION_AGENT_TEMPLATE.render(
1520
+ question=question, catalog_txt=catalog_txt
1521
+ )
1522
+ answer, answer_nt_input, answer_nt_output, code = await manager.execute(
1523
+ prompt,
1524
+ user_id="rao-catalog-search-answer",
1525
+ model=self.config.generative_model,
1526
+ tracking=memory.get_tracking_info(),
1527
+ )
1528
+ # TODO: Error if code is not success
1529
+ context = Context(
1530
+ agent_id=self.agent_id,
1531
+ original_question_uuid=memory.original_question_uuid,
1532
+ actual_question_uuid=None,
1533
+ question=question,
1534
+ source=source.id,
1535
+ chunks=[
1536
+ Chunk(
1537
+ chunk_id=f"catalog_search_result-{idx}",
1538
+ text=answer,
1539
+ origin_agent=self.config.module,
1540
+ )
1541
+ ],
1542
+ agent="nucliadb_agent_catalog",
1543
+ title=f"Catalog search on {source.id} Knowledge Box",
1544
+ )
1545
+ await memory.add_step(
1546
+ step_module=self.config.module,
1547
+ step_title=self.step_title("Catalog search"),
1548
+ step_reason="Catalog search performed",
1549
+ step_value=selected_filters.get("reasoning", ""),
1550
+ timeit=time() - t0,
1551
+ input_nuclia_tokens=input_nt + answer_nt_input,
1552
+ output_nuclia_tokens=output_nt + answer_nt_output,
1553
+ step_agent_path=f"/context/{self.agent_id}/catalog_search",
1554
+ )
1555
+ return context
1556
+
1557
+ def parse_selected_filters(self, question, source_id, selected_filters):
1558
+ filters_str = selected_filters.get("filters", "[]")
1559
+ error = None
1560
+ try:
1561
+ filters_dict = json.loads(filters_str)
1562
+ except json.JSONDecodeError as e:
1563
+ if "Expecting property name enclosed in double quotes" in str(e):
1564
+ try:
1565
+ filters_dict = ast.literal_eval(filters_str)
1566
+ except Exception as ast_error:
1567
+ error = f"Error parsing filter expression {filters_str} from catalog search for question {question} in source {source_id}: {ast_error}"
1568
+
1569
+ else:
1570
+ error = f"Error parsing filter expression {filters_str} from catalog search for question {question} in source {source_id}"
1571
+ except Exception as e:
1572
+ error = f"Error parsing filter expression {filters_str} from catalog search for question {question} in source {source_id}: {e}"
1573
+ try:
1574
+ filters = (
1575
+ [Filter.model_validate(f) for f in filters_dict] if not error else []
1576
+ )
1577
+ except Exception as e:
1578
+ error = f"Error validating filters {filters_str} from catalog search for question {question} in source {source_id}: {e}"
1579
+ if error:
1580
+ logger.error(error)
1581
+ return filters if not error else []
1582
+
1583
+
1584
+ def get_ndb_driver(manager: Manager, source: str) -> NucliaDBDriver:
1585
+ driver = manager.drivers.get(source)
1586
+ if driver is None:
1587
+ raise Exception("No NDB available")
1588
+ return cast(NucliaDBDriver, driver)
1589
+
1590
+
1591
+ def clean_citation_footnotes_from_answer(
1592
+ answer: str, citation_footnote_to_context: dict[str, str]
1593
+ ) -> str:
1594
+ """
1595
+ Remove the llm footnote citation markers from the answer text.
1596
+
1597
+ Example:
1598
+
1599
+ >>> answer = "Joseph earns a total of 2,466.67 per month[1], which includes both salary and other compensations[2].\n\n[1]: block-AA\n[2]: block-AC"
1600
+ >>> citation_footnote_to_context = {
1601
+ "block-AA": "resource1/field1/chunk1",
1602
+ "block-AC": "resource2/field2/chunk2",
1603
+ }
1604
+ >>> clean_citation_footnotes_from_answer(answer, citation_footnote_to_context)
1605
+ 'Joseph earns a total of 2,466.67 per month, which includes both salary and other compensations.'
1606
+ """
1607
+ # First, remove the footnote definitions at the end of the answer text
1608
+ answer = answer.split("\n\n[1]: ")[0]
1609
+
1610
+ # First remove the '[n]' markers from the answer text, which can be in any part of the text.
1611
+ # Adding some extra range to cover possible missing footnotes
1612
+ for i in range(1, len(citation_footnote_to_context) + 5):
1613
+ answer = answer.replace(f"[{i}]", "")
1614
+ return answer
1615
+
1616
+
1617
+ def _to_field_filter_expression(
1618
+ legacy_filters: list[str],
1619
+ ) -> list[FieldFilterExpression]:
1620
+ """
1621
+ Converts a list of legacy filter strings to FieldFilterExpression objects.
1622
+ """
1623
+ field_filter_expressions = []
1624
+ for f in legacy_filters:
1625
+ fe = to_field_filter_expression(f)
1626
+ if fe is not None:
1627
+ field_filter_expressions.append(fe)
1628
+ return field_filter_expressions
1629
+
1630
+
1631
+ def _to_resource_filter_expression(
1632
+ legacy_filters: list[str],
1633
+ ) -> list[ResourceFilterExpression]:
1634
+ """
1635
+ Converts a list of legacy filter strings to ResourceFilterExpression objects.
1636
+ """
1637
+ resource_filter_expressions = []
1638
+ for f in legacy_filters:
1639
+ fe = to_resource_filter_expression(f)
1640
+ if fe is not None:
1641
+ resource_filter_expressions.append(fe)
1642
+ return resource_filter_expressions