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