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,477 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import itertools
|
|
3
|
+
from typing import Dict, List, Optional, cast
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from hyperforge.configure import agent
|
|
7
|
+
from hyperforge.interaction import (
|
|
8
|
+
Feedback,
|
|
9
|
+
OAuthAuthenticateURL,
|
|
10
|
+
OAuthFeedbackReturnSchema,
|
|
11
|
+
)
|
|
12
|
+
from hyperforge.manager import Manager
|
|
13
|
+
from hyperforge.memory import QuestionMemory
|
|
14
|
+
from hyperforge.models import Context, Source
|
|
15
|
+
from hyperforge.settings import OAuthSettings
|
|
16
|
+
from nucliadb_models import SyncMetadata
|
|
17
|
+
from nucliadb_models.filters import (
|
|
18
|
+
And,
|
|
19
|
+
CatalogFilterExpression,
|
|
20
|
+
FilterExpression,
|
|
21
|
+
Or,
|
|
22
|
+
OriginSource,
|
|
23
|
+
)
|
|
24
|
+
from nucliadb_models.resource import Resource as NucliaDBResource
|
|
25
|
+
from nucliadb_models.search import (
|
|
26
|
+
ResourceProperties,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from hyperforge_nucliadb.basic_ask_agent import (
|
|
30
|
+
BasicAskAgent,
|
|
31
|
+
get_ndb_driver,
|
|
32
|
+
)
|
|
33
|
+
from hyperforge_nucliadb.sync.config import SyncAskAgentConfig
|
|
34
|
+
from hyperforge_nucliadb.sync.driver import SyncDriver
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@agent(
|
|
38
|
+
id="sync",
|
|
39
|
+
agent_type="context",
|
|
40
|
+
title="Sync Service Ask Agent",
|
|
41
|
+
description="Provide answer questions from synched resources. This agent is responsible for handling the authentication flow and resource validation for synched resources, and then providing the valid resources to the Basic Ask Agent to answer questions.",
|
|
42
|
+
config_schema=SyncAskAgentConfig,
|
|
43
|
+
)
|
|
44
|
+
class SyncAskAgent(BasicAskAgent): # type: ignore
|
|
45
|
+
sources: Dict[str, SyncDriver]
|
|
46
|
+
settings: OAuthSettings = OAuthSettings()
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self, config: SyncAskAgentConfig, agent_id: Optional[str] = None
|
|
50
|
+
) -> None:
|
|
51
|
+
super().__init__(config, agent_id)
|
|
52
|
+
self.sources = {}
|
|
53
|
+
|
|
54
|
+
def get_connections(self, manager: Manager) -> Dict[str, SyncDriver]:
|
|
55
|
+
for source in self.config.sources:
|
|
56
|
+
driver = manager.drivers.get(source)
|
|
57
|
+
driver = cast(SyncDriver, driver)
|
|
58
|
+
self.sources[source] = driver
|
|
59
|
+
return self.sources
|
|
60
|
+
|
|
61
|
+
async def get_oauth_url(
|
|
62
|
+
self,
|
|
63
|
+
manager: Manager,
|
|
64
|
+
connection_ids: List[str],
|
|
65
|
+
rao_redirect_url: str,
|
|
66
|
+
oauth_uuid: str,
|
|
67
|
+
kb_source_id: str,
|
|
68
|
+
question_id: str,
|
|
69
|
+
) -> Dict[str, str]:
|
|
70
|
+
urls = {}
|
|
71
|
+
driver = cast(SyncDriver, manager.drivers.get(kb_source_id))
|
|
72
|
+
for connection_id in connection_ids:
|
|
73
|
+
if (
|
|
74
|
+
connection_id in driver.sync_configs
|
|
75
|
+
and len(driver.sync_configs[connection_id]) > 0
|
|
76
|
+
):
|
|
77
|
+
real_connection_id = driver.sync_configs[connection_id][0]
|
|
78
|
+
url = await driver.get_oauth_url(
|
|
79
|
+
connection_id=real_connection_id,
|
|
80
|
+
rao_redirect_url=rao_redirect_url,
|
|
81
|
+
oauth_uuid=oauth_uuid,
|
|
82
|
+
sync_config=connection_id,
|
|
83
|
+
question_id=question_id,
|
|
84
|
+
)
|
|
85
|
+
urls[connection_id] = url
|
|
86
|
+
return urls
|
|
87
|
+
|
|
88
|
+
async def validate_resources_by_connection(
|
|
89
|
+
self,
|
|
90
|
+
credentials: str,
|
|
91
|
+
resource_ids: List[str],
|
|
92
|
+
connection: SyncDriver,
|
|
93
|
+
connection_id: str,
|
|
94
|
+
sync_config_id: str,
|
|
95
|
+
sync_metadata_by_resource: Dict[str, SyncMetadata],
|
|
96
|
+
) -> List[str]:
|
|
97
|
+
# Validate resources
|
|
98
|
+
validated_resources = await connection.validate_resources(
|
|
99
|
+
resource_ids,
|
|
100
|
+
credentials=credentials,
|
|
101
|
+
connection_id=connection_id,
|
|
102
|
+
sync_config_id=sync_config_id,
|
|
103
|
+
sync_metadata_by_resource=sync_metadata_by_resource,
|
|
104
|
+
)
|
|
105
|
+
return validated_resources
|
|
106
|
+
|
|
107
|
+
async def post_filter_resources_by_connection(
|
|
108
|
+
self,
|
|
109
|
+
memory: QuestionMemory,
|
|
110
|
+
manager: Manager,
|
|
111
|
+
resources: Dict[str, List[str]],
|
|
112
|
+
) -> Dict[str, Dict[str, List[str]]]:
|
|
113
|
+
"""We have a list of ARAG resources per KB source. Now we need to filter them by connection"""
|
|
114
|
+
filtered_resources: Dict[str, Dict[str, List[str]]] = {}
|
|
115
|
+
connections_by_resource: Dict[str, List[str]] = {}
|
|
116
|
+
sync_metadata_by_resource: Dict[str, SyncMetadata] = {}
|
|
117
|
+
connections = self.get_connections(manager)
|
|
118
|
+
for kb_source_id, resource_ids in resources.items():
|
|
119
|
+
# Get resource source connection for each resource
|
|
120
|
+
ndb = get_ndb_driver(manager, kb_source_id)
|
|
121
|
+
get_resource_ids_tasks = []
|
|
122
|
+
for resource in resource_ids:
|
|
123
|
+
get_resource_ids_tasks.append(
|
|
124
|
+
ndb.driver.get_resource_by_id(
|
|
125
|
+
kbid=ndb.config.kbid,
|
|
126
|
+
rid=resource,
|
|
127
|
+
query_params={"show": [ResourceProperties.ORIGIN.value]},
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
resource_objs: List[NucliaDBResource] = await asyncio.gather(
|
|
131
|
+
*get_resource_ids_tasks
|
|
132
|
+
)
|
|
133
|
+
for resource_obj in resource_objs:
|
|
134
|
+
if (
|
|
135
|
+
resource_obj.origin is not None
|
|
136
|
+
and resource_obj.origin.source_id is not None
|
|
137
|
+
):
|
|
138
|
+
source_id = resource_obj.origin.source_id.replace(
|
|
139
|
+
"sync_config_", ""
|
|
140
|
+
)
|
|
141
|
+
connections_by_resource.setdefault(source_id, []).append(
|
|
142
|
+
resource_obj.id
|
|
143
|
+
)
|
|
144
|
+
if resource_obj.origin.sync_metadata is not None:
|
|
145
|
+
sync_metadata_by_resource[resource_obj.id] = (
|
|
146
|
+
resource_obj.origin.sync_metadata
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Get providers needed for the resources allocated
|
|
150
|
+
needed_providers_ids = list(connections_by_resource.keys())
|
|
151
|
+
driver = self.sources[kb_source_id]
|
|
152
|
+
|
|
153
|
+
creds_providers = {}
|
|
154
|
+
for sync_config_id in needed_providers_ids:
|
|
155
|
+
if (
|
|
156
|
+
sync_config_id not in driver.sync_configs
|
|
157
|
+
or len(driver.sync_configs[sync_config_id]) == 0
|
|
158
|
+
):
|
|
159
|
+
raise Exception(
|
|
160
|
+
f"Connection ID {sync_config_id} not found in driver sync configs"
|
|
161
|
+
)
|
|
162
|
+
inner_connection_id = driver.sync_configs[sync_config_id][0]
|
|
163
|
+
creds_providers[sync_config_id] = driver.information[
|
|
164
|
+
inner_connection_id
|
|
165
|
+
].provider
|
|
166
|
+
|
|
167
|
+
# First message, ask for OAuth credentials
|
|
168
|
+
oauth_credentials_feedback = Feedback(
|
|
169
|
+
question="Get credentials",
|
|
170
|
+
data=None,
|
|
171
|
+
get_credentials=creds_providers,
|
|
172
|
+
module="oauth",
|
|
173
|
+
request_id=memory.get_session_id(),
|
|
174
|
+
agent_id=self.agent_id,
|
|
175
|
+
response_schema=OAuthFeedbackReturnSchema.model_json_schema(),
|
|
176
|
+
)
|
|
177
|
+
answer = await memory.send_feedback(oauth_credentials_feedback)
|
|
178
|
+
if answer is None or answer.request_id is None:
|
|
179
|
+
raise Exception("No answer received for OAuth initiation")
|
|
180
|
+
assert answer.request_id == memory.get_session_id()
|
|
181
|
+
# Validate answer schema
|
|
182
|
+
try:
|
|
183
|
+
oauth_feedback_return = OAuthFeedbackReturnSchema.model_validate_json(
|
|
184
|
+
answer.response
|
|
185
|
+
)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
raise Exception(f"Invalid OAuth feedback response: {e}")
|
|
188
|
+
|
|
189
|
+
existing_credentials: Dict[str, Dict[str, str]] = {}
|
|
190
|
+
if (
|
|
191
|
+
oauth_feedback_return.existing_credentials is None
|
|
192
|
+
or len(oauth_feedback_return.existing_credentials) == 0
|
|
193
|
+
):
|
|
194
|
+
# The frontend does not have credentials yet, we need to perform the OAuth flow
|
|
195
|
+
|
|
196
|
+
oauth_uuid = uuid4().hex
|
|
197
|
+
rao_redirect_url = self.settings.rao_redirect_url.format(
|
|
198
|
+
agent_id=memory.get_agent_id(),
|
|
199
|
+
session_id=memory.get_session_id(),
|
|
200
|
+
oauth_uuid=oauth_uuid,
|
|
201
|
+
workflow_id=memory.get_workflow_id(),
|
|
202
|
+
)
|
|
203
|
+
# Get all the authorize URLs from all connections on this KB
|
|
204
|
+
urls = await self.get_oauth_url(
|
|
205
|
+
manager,
|
|
206
|
+
needed_providers_ids,
|
|
207
|
+
rao_redirect_url=rao_redirect_url,
|
|
208
|
+
oauth_uuid=oauth_uuid,
|
|
209
|
+
kb_source_id=kb_source_id,
|
|
210
|
+
question_id=memory.original_question_uuid,
|
|
211
|
+
)
|
|
212
|
+
for connection_id, url in urls.items():
|
|
213
|
+
# For each connection send the OAuth URL to the frontend
|
|
214
|
+
oauth_authenticate_url = OAuthAuthenticateURL(oauth_url=url)
|
|
215
|
+
await memory.send_oauth(oauth=oauth_authenticate_url)
|
|
216
|
+
|
|
217
|
+
credential = await memory.recv_oauth_callback(
|
|
218
|
+
question_id=memory.original_question_uuid, oauth_uuid=oauth_uuid
|
|
219
|
+
)
|
|
220
|
+
if credential is not None:
|
|
221
|
+
inner_conn_id = driver.sync_configs[connection_id][0]
|
|
222
|
+
existing_credentials[connection_id] = {
|
|
223
|
+
inner_conn_id: credential
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Send OAuth initiation feedback
|
|
227
|
+
oauth_credentials_feedback = Feedback(
|
|
228
|
+
question="Send credentials",
|
|
229
|
+
data=None,
|
|
230
|
+
get_credentials=None,
|
|
231
|
+
credentials=existing_credentials,
|
|
232
|
+
module="oauth",
|
|
233
|
+
request_id=memory.get_session_id(),
|
|
234
|
+
agent_id=self.agent_id,
|
|
235
|
+
response_schema=OAuthFeedbackReturnSchema.model_json_schema(),
|
|
236
|
+
)
|
|
237
|
+
answer = await memory.send_feedback(oauth_credentials_feedback)
|
|
238
|
+
if answer is None or answer.request_id is None:
|
|
239
|
+
raise Exception("No answer received for OAuth initiation")
|
|
240
|
+
assert answer.request_id == memory.get_session_id()
|
|
241
|
+
elif oauth_feedback_return.existing_credentials is not None:
|
|
242
|
+
existing_credentials = oauth_feedback_return.existing_credentials
|
|
243
|
+
else:
|
|
244
|
+
raise Exception("No credentials provided")
|
|
245
|
+
|
|
246
|
+
for (
|
|
247
|
+
connection_id,
|
|
248
|
+
connection_credentials,
|
|
249
|
+
) in existing_credentials.items():
|
|
250
|
+
source = connections[kb_source_id]
|
|
251
|
+
if (
|
|
252
|
+
connection_id not in source.sync_configs
|
|
253
|
+
or len(source.sync_configs[connection_id]) == 0
|
|
254
|
+
):
|
|
255
|
+
raise Exception("Connection / source not found")
|
|
256
|
+
inner_connection_id = source.sync_configs[connection_id][0]
|
|
257
|
+
if inner_connection_id is None or connection_credentials is None:
|
|
258
|
+
raise Exception("Connection not found")
|
|
259
|
+
|
|
260
|
+
credential = connection_credentials[inner_connection_id]
|
|
261
|
+
filtered_resource_ids = await self.validate_resources_by_connection(
|
|
262
|
+
connection=source,
|
|
263
|
+
credentials=credential,
|
|
264
|
+
resource_ids=resource_ids,
|
|
265
|
+
connection_id=inner_connection_id,
|
|
266
|
+
sync_config_id=connection_id,
|
|
267
|
+
sync_metadata_by_resource=sync_metadata_by_resource,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
filtered_resources.setdefault(kb_source_id, {})[connection_id] = (
|
|
271
|
+
filtered_resource_ids
|
|
272
|
+
)
|
|
273
|
+
return filtered_resources
|
|
274
|
+
|
|
275
|
+
def enrich_filter(self) -> FilterExpression:
|
|
276
|
+
return FilterExpression()
|
|
277
|
+
|
|
278
|
+
def enrich_catalog_filter(
|
|
279
|
+
self,
|
|
280
|
+
catalog_filter: Optional[CatalogFilterExpression] = None,
|
|
281
|
+
):
|
|
282
|
+
if catalog_filter is None:
|
|
283
|
+
catalog_filter = CatalogFilterExpression(
|
|
284
|
+
resource=Or(
|
|
285
|
+
operands=[
|
|
286
|
+
OriginSource(id=f"sync_config_{connection_id}")
|
|
287
|
+
for source in self.config.sources
|
|
288
|
+
for connection_id in self.sources[source].sync_configs.keys()
|
|
289
|
+
]
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
else:
|
|
293
|
+
catalog_filter.resource = And(
|
|
294
|
+
operands=[
|
|
295
|
+
catalog_filter.resource,
|
|
296
|
+
Or(
|
|
297
|
+
operands=[
|
|
298
|
+
OriginSource(id=f"sync_config_{connection_id}")
|
|
299
|
+
for source in self.config.sources
|
|
300
|
+
for connection_id in self.sources[
|
|
301
|
+
source
|
|
302
|
+
].sync_configs.keys()
|
|
303
|
+
]
|
|
304
|
+
),
|
|
305
|
+
]
|
|
306
|
+
)
|
|
307
|
+
return catalog_filter
|
|
308
|
+
|
|
309
|
+
# PUBLIC METHODS USED BY THE BASIC ASK AGENT FRAMEWORK
|
|
310
|
+
async def search_by_title(
|
|
311
|
+
self,
|
|
312
|
+
memory: QuestionMemory,
|
|
313
|
+
manager: Manager,
|
|
314
|
+
title: str,
|
|
315
|
+
filters: Optional[List[str]] = None,
|
|
316
|
+
catalog_filter: Optional[CatalogFilterExpression] = None,
|
|
317
|
+
**kwargs,
|
|
318
|
+
) -> Dict[str, List[str]]:
|
|
319
|
+
catalog_filter = self.enrich_catalog_filter(catalog_filter)
|
|
320
|
+
resources = await super().search_by_title(
|
|
321
|
+
memory=memory,
|
|
322
|
+
manager=manager,
|
|
323
|
+
title=title,
|
|
324
|
+
filters=filters,
|
|
325
|
+
catalog_filter=catalog_filter,
|
|
326
|
+
**kwargs,
|
|
327
|
+
)
|
|
328
|
+
resources_by_connection = await self.post_filter_resources_by_connection(
|
|
329
|
+
memory=memory,
|
|
330
|
+
manager=manager,
|
|
331
|
+
resources=resources,
|
|
332
|
+
)
|
|
333
|
+
final_resources: Dict[str, List[str]] = {}
|
|
334
|
+
for source_id, connections in resources_by_connection.items():
|
|
335
|
+
for _, resource_ids in connections.items():
|
|
336
|
+
final_resources.setdefault(source_id, []).extend(resource_ids)
|
|
337
|
+
return final_resources
|
|
338
|
+
|
|
339
|
+
async def inner_ask_by_title(
|
|
340
|
+
self,
|
|
341
|
+
source_id: str,
|
|
342
|
+
manager: Manager,
|
|
343
|
+
memory: QuestionMemory,
|
|
344
|
+
question: str,
|
|
345
|
+
resource_ids: List[str],
|
|
346
|
+
) -> Context:
|
|
347
|
+
if len(resource_ids) == 0:
|
|
348
|
+
resource_ids = await self.retrieve(manager, source_id, question)
|
|
349
|
+
|
|
350
|
+
resources_by_connection = await self.post_filter_resources_by_connection(
|
|
351
|
+
memory=memory,
|
|
352
|
+
manager=manager,
|
|
353
|
+
resources={source_id: resource_ids},
|
|
354
|
+
)
|
|
355
|
+
resources_by_connection_for_source = resources_by_connection.get(
|
|
356
|
+
source_id, {}
|
|
357
|
+
)
|
|
358
|
+
resource_ids = list(
|
|
359
|
+
itertools.chain(*resources_by_connection_for_source.values())
|
|
360
|
+
)
|
|
361
|
+
if resource_ids is None or len(resource_ids) == 0:
|
|
362
|
+
raise Exception("No valid resources found after OAuth validation")
|
|
363
|
+
|
|
364
|
+
return await super().inner_ask_by_title(
|
|
365
|
+
source_id=source_id,
|
|
366
|
+
manager=manager,
|
|
367
|
+
memory=memory,
|
|
368
|
+
question=question,
|
|
369
|
+
resource_ids=resource_ids,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
async def inner_rag(
|
|
373
|
+
self,
|
|
374
|
+
source_obj: Source,
|
|
375
|
+
manager: Manager,
|
|
376
|
+
memory: QuestionMemory,
|
|
377
|
+
question: str,
|
|
378
|
+
question_uuid: Optional[str] = None,
|
|
379
|
+
keyword_filters: List[str] = [],
|
|
380
|
+
and_filters: Optional[List[str]] = None,
|
|
381
|
+
or_filters: Optional[List[str]] = None,
|
|
382
|
+
full_resource: bool = False,
|
|
383
|
+
resource_filters: Optional[List[str]] = None,
|
|
384
|
+
) -> Context:
|
|
385
|
+
# Retrieve resources as usual, but then filter them by connection and validate them with the SyncDriver
|
|
386
|
+
catalog_filter = self.enrich_filter()
|
|
387
|
+
resources = await self.retrieve(
|
|
388
|
+
manager=manager,
|
|
389
|
+
source_id=source_obj.id,
|
|
390
|
+
question=question,
|
|
391
|
+
keyword_filters=keyword_filters,
|
|
392
|
+
and_filters=and_filters,
|
|
393
|
+
or_filters=or_filters,
|
|
394
|
+
catalog_filter=catalog_filter,
|
|
395
|
+
)
|
|
396
|
+
resources_by_connection = await self.post_filter_resources_by_connection(
|
|
397
|
+
memory=memory,
|
|
398
|
+
manager=manager,
|
|
399
|
+
resources={source_obj.id: resources},
|
|
400
|
+
)
|
|
401
|
+
resources_by_connection_for_source = resources_by_connection.get(
|
|
402
|
+
source_obj.id, {}
|
|
403
|
+
)
|
|
404
|
+
resource_ids = list(
|
|
405
|
+
itertools.chain(*resources_by_connection_for_source.values())
|
|
406
|
+
)
|
|
407
|
+
if resource_ids is None or len(resource_ids) == 0:
|
|
408
|
+
raise Exception("No valid resources found after OAuth validation")
|
|
409
|
+
|
|
410
|
+
context = await super().inner_rag(
|
|
411
|
+
source_obj=source_obj,
|
|
412
|
+
manager=manager,
|
|
413
|
+
memory=memory,
|
|
414
|
+
question=question,
|
|
415
|
+
question_uuid=question_uuid,
|
|
416
|
+
keyword_filters=keyword_filters,
|
|
417
|
+
and_filters=and_filters,
|
|
418
|
+
or_filters=or_filters,
|
|
419
|
+
full_resource=full_resource,
|
|
420
|
+
resource_filters=resource_ids,
|
|
421
|
+
)
|
|
422
|
+
return context
|
|
423
|
+
|
|
424
|
+
async def rag(
|
|
425
|
+
self,
|
|
426
|
+
source_obj: Source,
|
|
427
|
+
question_uuid: str,
|
|
428
|
+
question: str,
|
|
429
|
+
memory: QuestionMemory,
|
|
430
|
+
manager: Manager,
|
|
431
|
+
flow_id: str,
|
|
432
|
+
) -> tuple[str, str] | None:
|
|
433
|
+
context = await self.inner_rag(
|
|
434
|
+
source_obj=source_obj,
|
|
435
|
+
manager=manager,
|
|
436
|
+
memory=memory,
|
|
437
|
+
question=question,
|
|
438
|
+
question_uuid=question_uuid,
|
|
439
|
+
)
|
|
440
|
+
missing = await self.save_ctx_and_return_missing(
|
|
441
|
+
context=context,
|
|
442
|
+
question=question,
|
|
443
|
+
memory=memory,
|
|
444
|
+
manager=manager,
|
|
445
|
+
flow_id=flow_id,
|
|
446
|
+
)
|
|
447
|
+
return missing
|
|
448
|
+
|
|
449
|
+
async def inner_facets_search(
|
|
450
|
+
self,
|
|
451
|
+
memory: QuestionMemory,
|
|
452
|
+
manager: Manager,
|
|
453
|
+
question: str,
|
|
454
|
+
source: Source,
|
|
455
|
+
idx: int,
|
|
456
|
+
) -> Context:
|
|
457
|
+
raise NotImplementedError("Facets search not implemented for SyncAskAgent")
|
|
458
|
+
|
|
459
|
+
async def inner_facets(
|
|
460
|
+
self,
|
|
461
|
+
memory: QuestionMemory,
|
|
462
|
+
manager: Manager,
|
|
463
|
+
question: str,
|
|
464
|
+
source: Source,
|
|
465
|
+
idx: int,
|
|
466
|
+
) -> Context:
|
|
467
|
+
raise NotImplementedError("Facets search not implemented for SyncAskAgent")
|
|
468
|
+
|
|
469
|
+
async def inner_catalog_search(
|
|
470
|
+
self,
|
|
471
|
+
memory: QuestionMemory,
|
|
472
|
+
manager: Manager,
|
|
473
|
+
question: str,
|
|
474
|
+
source: Source,
|
|
475
|
+
idx: int,
|
|
476
|
+
) -> Context:
|
|
477
|
+
raise NotImplementedError("Facets search not implemented for SyncAskAgent")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import ConfigDict
|
|
4
|
+
|
|
5
|
+
from hyperforge_nucliadb.basic_ask_config import (
|
|
6
|
+
BasicAskAgentConfig,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SyncAskAgentConfig(BasicAskAgentConfig):
|
|
11
|
+
model_config = ConfigDict(title="Knowledge Box Sync Service")
|
|
12
|
+
module: Literal["sync"] = "sync" # type: ignore[assignment]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from hyperforge.driver import DriverConfig
|
|
4
|
+
from pydantic.config import ConfigDict
|
|
5
|
+
|
|
6
|
+
from hyperforge_nucliadb.driver_config import (
|
|
7
|
+
NucliaDBConnection,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SyncConnection(NucliaDBConnection):
|
|
12
|
+
connection_ids: list[str] # type: ignore
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def kb_url(self) -> str:
|
|
16
|
+
return f"{self.url}/v1/kb/{self.kbid}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SyncDriverConfig(DriverConfig[SyncConnection]):
|
|
20
|
+
model_config = ConfigDict(title="Knowledge Box Sync Service connection")
|
|
21
|
+
provider: Literal["sync"]
|
|
22
|
+
config: SyncConnection
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from typing import Any, Dict, List, cast
|
|
3
|
+
from urllib.parse import urlencode
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from httpx import AsyncClient
|
|
7
|
+
from hyperforge.configure import driver
|
|
8
|
+
from hyperforge.interaction import Provider
|
|
9
|
+
from hyperforge.utils.http import SafeTransport
|
|
10
|
+
from nucliadb_models import SyncMetadata
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from hyperforge_nucliadb.driver import (
|
|
14
|
+
NucliaDBDriver,
|
|
15
|
+
connect,
|
|
16
|
+
manager_connect,
|
|
17
|
+
)
|
|
18
|
+
from hyperforge_nucliadb.driver_config import (
|
|
19
|
+
NucliaDBConnection,
|
|
20
|
+
)
|
|
21
|
+
from hyperforge_nucliadb.sync.config_driver import (
|
|
22
|
+
SyncConnection,
|
|
23
|
+
SyncDriverConfig,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def sync_connect(conn: SyncConnection):
|
|
28
|
+
headers: Dict[str, str] = {}
|
|
29
|
+
if "http://localhost" in conn.kb_url:
|
|
30
|
+
headers = {
|
|
31
|
+
"X-NUCLIADB-ROLES": "READER",
|
|
32
|
+
}
|
|
33
|
+
else:
|
|
34
|
+
headers = {"AUTHORIZATION": "Bearer " + conn.key} # type: ignore
|
|
35
|
+
return AsyncClient(
|
|
36
|
+
headers=headers,
|
|
37
|
+
base_url=conn.kb_url,
|
|
38
|
+
transport=SafeTransport(),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ExternalConnectionOutput(BaseModel):
|
|
43
|
+
"""External connection output without sensitive credential data."""
|
|
44
|
+
|
|
45
|
+
id: UUID
|
|
46
|
+
kb_id: UUID
|
|
47
|
+
created_by: UUID
|
|
48
|
+
created_at: datetime.datetime
|
|
49
|
+
updated_at: datetime.datetime
|
|
50
|
+
provider: Provider
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@driver(
|
|
54
|
+
id="sync",
|
|
55
|
+
title="Sync Driver",
|
|
56
|
+
description="Driver for interacting with the Sync API.",
|
|
57
|
+
config_schema=SyncDriverConfig,
|
|
58
|
+
)
|
|
59
|
+
class SyncDriver(NucliaDBDriver):
|
|
60
|
+
async_driver: AsyncClient
|
|
61
|
+
config: SyncConnection # type: ignore
|
|
62
|
+
information: Dict[str, ExternalConnectionOutput]
|
|
63
|
+
sync_configs: Dict[str, list[str]]
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
async def init(cls, driver: Any) -> "SyncDriver":
|
|
67
|
+
sync_driver = cast(SyncDriverConfig, driver)
|
|
68
|
+
client = await sync_connect(sync_driver.config)
|
|
69
|
+
information = {}
|
|
70
|
+
sync_configs: Dict[str, list[str]] = {}
|
|
71
|
+
for sync_config_id in sync_driver.config.connection_ids:
|
|
72
|
+
response = await client.get(f"/sync_config/{sync_config_id}")
|
|
73
|
+
response.raise_for_status()
|
|
74
|
+
data = response.json()
|
|
75
|
+
connection_id = data["external_connection"]["id"]
|
|
76
|
+
response = await client.get(f"/external_connection/{connection_id}")
|
|
77
|
+
response.raise_for_status()
|
|
78
|
+
sync_configs.setdefault(sync_config_id, []).append(connection_id)
|
|
79
|
+
data = response.json()
|
|
80
|
+
information[connection_id] = ExternalConnectionOutput(**data)
|
|
81
|
+
return cls(
|
|
82
|
+
provider=sync_driver.provider,
|
|
83
|
+
name=sync_driver.name,
|
|
84
|
+
async_driver=client,
|
|
85
|
+
information=information,
|
|
86
|
+
sync_configs=sync_configs,
|
|
87
|
+
config=sync_driver.config,
|
|
88
|
+
driver=await connect(cast(NucliaDBConnection, sync_driver.config)),
|
|
89
|
+
manager=await manager_connect(cast(NucliaDBConnection, sync_driver.config)),
|
|
90
|
+
_synonyms=None,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def get_oauth_url(
|
|
94
|
+
self,
|
|
95
|
+
sync_config: str,
|
|
96
|
+
rao_redirect_url: str,
|
|
97
|
+
oauth_uuid: str,
|
|
98
|
+
connection_id: str,
|
|
99
|
+
question_id: str,
|
|
100
|
+
) -> str:
|
|
101
|
+
sep = "&" if "?" in rao_redirect_url else "?"
|
|
102
|
+
rao_redirect_url = (
|
|
103
|
+
f"{rao_redirect_url}{sep}{urlencode({'question_id': question_id})}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
response = await self.async_driver.post(
|
|
107
|
+
f"{self.config.kb_url}/sync_config/{sync_config}/authorize",
|
|
108
|
+
json={
|
|
109
|
+
"connection_id": connection_id,
|
|
110
|
+
"rao_redirect_url": rao_redirect_url,
|
|
111
|
+
"oauth_uuid": oauth_uuid,
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
response.raise_for_status()
|
|
115
|
+
data = response.json()
|
|
116
|
+
return data["authorize_url"]
|
|
117
|
+
|
|
118
|
+
async def validate_resources(
|
|
119
|
+
self,
|
|
120
|
+
resource_ids: List[str],
|
|
121
|
+
credentials: str,
|
|
122
|
+
connection_id: str,
|
|
123
|
+
sync_config_id: str,
|
|
124
|
+
sync_metadata_by_resource: Dict[str, SyncMetadata],
|
|
125
|
+
) -> List[str]:
|
|
126
|
+
response = await self.async_driver.post(
|
|
127
|
+
f"/sync_config/{sync_config_id}/validate_resources",
|
|
128
|
+
json={
|
|
129
|
+
"resources": [
|
|
130
|
+
x.model_dump() for x in sync_metadata_by_resource.values()
|
|
131
|
+
],
|
|
132
|
+
"credentials": credentials,
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
response.raise_for_status()
|
|
136
|
+
data = response.json()
|
|
137
|
+
# The API validates by file_id (origin storage identifier) and returns those back,
|
|
138
|
+
# but resource_filters in AskRequest requires NucliaDB resource UUIDs, which are
|
|
139
|
+
# the keys of sync_metadata_by_resource.
|
|
140
|
+
file_id_to_resource_uuid = {
|
|
141
|
+
metadata.file_id: resource_uuid
|
|
142
|
+
for resource_uuid, metadata in sync_metadata_by_resource.items()
|
|
143
|
+
}
|
|
144
|
+
return [
|
|
145
|
+
file_id_to_resource_uuid[resource["file_id"]]
|
|
146
|
+
for resource in data.get("valid_resources", [])
|
|
147
|
+
if resource["file_id"] in file_id_to_resource_uuid
|
|
148
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hyperforge_nucliadb
|
|
3
|
+
Version: 1.0.0.post22
|
|
4
|
+
Summary: NucliaDB Hyperforge agent
|
|
5
|
+
Author-email: Nuclia <nucliadb@nuclia.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://progress.com
|
|
8
|
+
Project-URL: Repository, https://github.com/nuclia/forge
|
|
9
|
+
Classifier: Programming Language :: Python
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: <4,>=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: hyperforge
|
|
18
|
+
Requires-Dist: lingua-language-detector
|
|
19
|
+
Requires-Dist: nucliadb_sdk
|
|
20
|
+
|
|
21
|
+
# NucliaDB Hyperforge agents
|
|
22
|
+
|
|
23
|
+
Basic ASK - Basic initial agent
|
|
24
|
+
Advanced ASK (deprecated)
|
|
25
|
+
ASK (experimental)
|
|
26
|
+
Sync (Sync Service oauth linked)
|