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.
- hyperforge_nucliadb/__init__.py +13 -0
- hyperforge_nucliadb/advanced_ask_agent.py +267 -0
- hyperforge_nucliadb/advanced_ask_config.py +241 -0
- hyperforge_nucliadb/ask/__init__.py +3 -0
- hyperforge_nucliadb/ask/analysis.py +310 -0
- hyperforge_nucliadb/ask/ask.py +326 -0
- hyperforge_nucliadb/ask/config.py +52 -0
- hyperforge_nucliadb/ask/hydrate.py +485 -0
- hyperforge_nucliadb/ask/kb_analysis.py +48 -0
- hyperforge_nucliadb/ask/knowledge_scan.py +150 -0
- hyperforge_nucliadb/ask/models.py +59 -0
- hyperforge_nucliadb/ask/multi.py +146 -0
- hyperforge_nucliadb/ask/nucliadb.py +238 -0
- hyperforge_nucliadb/ask/prompt_analysis.py +50 -0
- hyperforge_nucliadb/ask/query_analysis.py +83 -0
- hyperforge_nucliadb/ask/rerank.py +45 -0
- hyperforge_nucliadb/ask/utils.py +36 -0
- hyperforge_nucliadb/ask_utils.py +235 -0
- hyperforge_nucliadb/basic_ask_agent.py +1812 -0
- hyperforge_nucliadb/basic_ask_config.py +42 -0
- hyperforge_nucliadb/driver.py +428 -0
- hyperforge_nucliadb/driver_config.py +42 -0
- hyperforge_nucliadb/sync/__init__.py +0 -0
- hyperforge_nucliadb/sync/agent.py +477 -0
- hyperforge_nucliadb/sync/config.py +12 -0
- hyperforge_nucliadb/sync/config_driver.py +22 -0
- hyperforge_nucliadb/sync/driver.py +148 -0
- hyperforge_nucliadb-1.0.0.post22.dist-info/METADATA +26 -0
- hyperforge_nucliadb-1.0.0.post22.dist-info/RECORD +31 -0
- hyperforge_nucliadb-1.0.0.post22.dist-info/WHEEL +5 -0
- hyperforge_nucliadb-1.0.0.post22.dist-info/top_level.txt +1 -0
|
@@ -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
|