hyperforge-nucliadb 1.0.0.post22__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ from typing import List, Literal, Optional, Tuple
2
+
3
+ from hyperforge.context.agent import ContextAgentConfig
4
+ from hyperforge.utils import WidgetType
5
+ from pydantic import Field
6
+ from pydantic.config import ConfigDict
7
+
8
+
9
+ class BasicAskAgentConfig(ContextAgentConfig):
10
+ model_config = ConfigDict(title="Knowledge Box basic Ask")
11
+ module: Literal["basic_ask"] = "basic_ask"
12
+ sources: List[str] = Field(
13
+ default_factory=list,
14
+ json_schema_extra={
15
+ "show_in_node": True,
16
+ },
17
+ )
18
+ generative_model: str = Field(
19
+ default="chatgpt-azure-4o-mini",
20
+ title="Generative model",
21
+ description="Model used to generate answers",
22
+ json_schema_extra={"widget": WidgetType.MODEL_SELECT},
23
+ )
24
+ published_functions: Optional[Tuple[str, ...]] = Field(
25
+ default=(
26
+ "search_by_title",
27
+ "ask_labels_list",
28
+ "ask_by_title",
29
+ "ask_agent",
30
+ "ask_labels",
31
+ "facets_count",
32
+ "facets_search",
33
+ "catalog_search",
34
+ "all_images_by_title",
35
+ "search_images",
36
+ ),
37
+ title="Published functions",
38
+ description="List of functions published by this agent to be used by other agents in the chain",
39
+ json_schema_extra={
40
+ "widget": WidgetType.NOT_SHOWN,
41
+ },
42
+ )
@@ -0,0 +1,428 @@
1
+ import asyncio
2
+ from asyncio import gather
3
+ from functools import reduce
4
+ from typing import Dict, List, Optional
5
+
6
+ import httpx
7
+ from hyperforge.configure import driver
8
+ from hyperforge.driver import Driver
9
+ from hyperforge.models import Facets
10
+ from hyperforge.utils.http import SafeTransport
11
+ from nuclia.lib.nua_responses import StoredLearningConfiguration
12
+ from nucliadb_models.graph.requests import (
13
+ GraphNodesSearchRequest,
14
+ GraphRelationsSearchRequest,
15
+ GraphSearchRequest,
16
+ )
17
+ from nucliadb_models.graph.responses import (
18
+ GraphNodesSearchResponse,
19
+ GraphRelationsSearchResponse,
20
+ GraphSearchResponse,
21
+ )
22
+ from nucliadb_models.resource import Resource
23
+ from nucliadb_models.search import (
24
+ AskRequest,
25
+ CatalogFacetsPrefix,
26
+ CatalogFacetsRequest,
27
+ CatalogFacetsResponse,
28
+ CatalogRequest,
29
+ CatalogResponse,
30
+ FindOptions,
31
+ FindRequest,
32
+ KnowledgeboxFindResults,
33
+ MinScore,
34
+ SearchOptions,
35
+ SearchRequest,
36
+ SyncAskResponse,
37
+ )
38
+ from nucliadb_sdk.v2 import NucliaDBAsync
39
+
40
+ from hyperforge import logger
41
+ from hyperforge_nucliadb.driver_config import (
42
+ ManagerConnection,
43
+ NucliaDBConfig,
44
+ NucliaDBConnection,
45
+ )
46
+
47
+ DEFAULT_FIELD_FACETS = [
48
+ "/n/icon",
49
+ "/n/metadata.status",
50
+ "/metadata.language",
51
+ "/metadata.languages",
52
+ "/field",
53
+ "/f",
54
+ "/ml",
55
+ "/origin.tags",
56
+ "/origin.metadata",
57
+ "/origin.path",
58
+ "/origin.source-id",
59
+ ]
60
+ DEFAULT_CHUNK_LABELS = ["/k"]
61
+
62
+
63
+ async def connect(conn: NucliaDBConnection):
64
+ headers: Dict[str, str] = {}
65
+ if "http://localhost" in conn.url:
66
+ headers = {
67
+ "X-NUCLIADB-ROLES": "READER",
68
+ }
69
+ return NucliaDBAsync(
70
+ api_key=conn.key,
71
+ url=conn.url,
72
+ headers=headers,
73
+ _httpx_transport=SafeTransport(),
74
+ )
75
+
76
+
77
+ async def manager_connect(conn: NucliaDBConnection):
78
+ headers: Dict[str, str] = {}
79
+ return ManagerConnection(api_key=conn.key, url=conn.manager, headers=headers)
80
+
81
+
82
+ @driver(
83
+ id="nucliadb",
84
+ title="NucliaDB Driver",
85
+ description="Driver for interacting with the NucliaDB API.",
86
+ config_schema=NucliaDBConfig,
87
+ )
88
+ class NucliaDBDriver(Driver):
89
+ driver: NucliaDBAsync
90
+ manager: ManagerConnection
91
+ config: NucliaDBConnection
92
+ _synonyms: Optional[dict[str, list[str]]]
93
+
94
+ @classmethod
95
+ async def init(cls, driver: NucliaDBConfig):
96
+ return cls(
97
+ provider=driver.provider,
98
+ name=driver.name,
99
+ config=driver.config,
100
+ driver=await connect(driver.config),
101
+ manager=await manager_connect(driver.config),
102
+ _synonyms=None,
103
+ )
104
+
105
+ async def synonyms_raw(self) -> Dict[str, List[str]]:
106
+ synonyms_list = await self.driver.get_custom_synonyms(kbid=self.config.kbid)
107
+ return dict(
108
+ (k.lower(), [x.lower() for x in v])
109
+ for k, v in synonyms_list.synonyms.items()
110
+ )
111
+
112
+ async def labels(self) -> Dict[str, List[str]]:
113
+ labelsets = await self.driver.get_labelsets(kbid=self.config.kbid)
114
+ result = {}
115
+ for labelset in labelsets.labelsets:
116
+ labels = await self.driver.get_labelset(
117
+ kbid=self.config.kbid, labelset=labelset
118
+ )
119
+ result[labelset] = [x.title for x in labels.labels]
120
+
121
+ return result
122
+
123
+ async def field_facets(self) -> Dict[str, int]:
124
+ field_labels = {}
125
+ search_fulltext_classify = await self.driver.search(
126
+ kbid=self.config.kbid,
127
+ content=SearchRequest(
128
+ faceted=["/classification.labels"],
129
+ features=[SearchOptions.FULLTEXT],
130
+ ),
131
+ )
132
+
133
+ if (
134
+ search_fulltext_classify.fulltext is not None
135
+ and search_fulltext_classify.fulltext.facets is not None
136
+ and "/classification.labels" in search_fulltext_classify.fulltext.facets
137
+ ):
138
+ fulltext_labelsets = [
139
+ x
140
+ for x in search_fulltext_classify.fulltext.facets[
141
+ "/classification.labels"
142
+ ].keys()
143
+ ]
144
+ else:
145
+ fulltext_labelsets = []
146
+
147
+ paths = [path for path in DEFAULT_FIELD_FACETS]
148
+
149
+ paths.extend(fulltext_labelsets)
150
+
151
+ search_results_second = await self.driver.search(
152
+ kbid=self.config.kbid,
153
+ content=SearchRequest(faceted=paths, features=[SearchOptions.FULLTEXT]),
154
+ )
155
+ if (
156
+ search_results_second.fulltext is not None
157
+ and search_results_second.fulltext.facets is not None
158
+ ):
159
+ field_labels = search_results_second.fulltext.facets
160
+
161
+ field_labels = reduce(lambda a, b: {**a, **b}, field_labels.values())
162
+ return field_labels
163
+
164
+ async def field_labels(
165
+ self, labelsets: list[str] | None = None
166
+ ) -> tuple[dict[str, dict[str, int]], int]:
167
+ """
168
+ Returns a prettified and filtered list of the field labels facets.
169
+
170
+ Args:
171
+ labelsets (list[str] | None): List of labelsets to filter the results.
172
+
173
+ Returns:
174
+ dict[str, dict[str, int]]: Dictionary of labelsets with their labels and counts
175
+ int: Total number of resources for the knowledge box.
176
+ """
177
+ # Add the filter to the facets
178
+ prefixes = (
179
+ [f"/classification.labels/{ls}" for ls in labelsets]
180
+ if labelsets
181
+ else ["/classification.labels"]
182
+ )
183
+ catalog_result = await self.driver.catalog(
184
+ kbid=self.config.kbid,
185
+ faceted=prefixes,
186
+ page_size=0,
187
+ )
188
+ if not catalog_result.fulltext or not catalog_result.fulltext.facets:
189
+ raise Exception("Empty facets in catalog result")
190
+
191
+ # Remove /classification.labels/ prefix from the facets
192
+ result: dict[str, dict[str, int]] = {
193
+ labelset.split("/")[-1]: {
194
+ label.split(f"{labelset}/")[-1]: count
195
+ for label, count in facets.items()
196
+ }
197
+ for labelset, facets in catalog_result.fulltext.facets.items()
198
+ }
199
+ return result, catalog_result.fulltext.total
200
+
201
+ async def paragraph_facets(self) -> Dict[str, int]:
202
+ paragraphs_facets = {}
203
+
204
+ search_paragraph_classify = await self.driver.search(
205
+ kbid=self.config.kbid,
206
+ content=SearchRequest(
207
+ faceted=["/classification.labels"],
208
+ features=[SearchOptions.KEYWORD],
209
+ ),
210
+ )
211
+
212
+ if (
213
+ search_paragraph_classify.paragraphs is not None
214
+ and search_paragraph_classify.paragraphs.facets is not None
215
+ and "/classification.labels" in search_paragraph_classify.paragraphs.facets
216
+ ):
217
+ paragraph_labels = [
218
+ x
219
+ for x in search_paragraph_classify.paragraphs.facets[
220
+ "/classification.labels"
221
+ ].keys()
222
+ ]
223
+ else:
224
+ paragraph_labels = []
225
+
226
+ paths = [path for path in DEFAULT_CHUNK_LABELS]
227
+
228
+ if "/classification.labels" in paths:
229
+ paths.remove("/classification.labels")
230
+
231
+ paths.extend(paragraph_labels)
232
+
233
+ search_results_second = await self.driver.search(
234
+ kbid=self.config.kbid, content=SearchRequest(faceted=paths)
235
+ )
236
+ if (
237
+ search_results_second.paragraphs is not None
238
+ and search_results_second.paragraphs.facets is not None
239
+ ):
240
+ paragraphs_facets = search_results_second.paragraphs.facets
241
+
242
+ try:
243
+ paragraphs_facets = reduce(
244
+ lambda a, b: {**a, **b}, paragraphs_facets.values()
245
+ )
246
+ except Exception as e:
247
+ logger.error(f"Error reducing paragraph facets: {e}")
248
+ paragraphs_facets = {}
249
+
250
+ return paragraphs_facets
251
+
252
+ async def facets(self) -> Facets:
253
+ chunks, fields = await gather(self.paragraph_facets(), self.field_facets())
254
+ return Facets(chunks=chunks, fields=fields)
255
+
256
+ async def facets_native(self) -> CatalogFacetsResponse:
257
+ """
258
+ Get facets for the knowledge box.
259
+ This is a native implementation that returns the facets as a dictionary.
260
+
261
+ We restrict the types of facets to a predefined subset, as on big knowledge boxes, the response could be very large.
262
+ """
263
+ facets = CatalogFacetsResponse(facets={})
264
+
265
+ # Run requests in parallel as otherwise the facets endpoint can take too long on large knowledge boxes.
266
+ tasks = []
267
+ for prefix, depth in [
268
+ ("/n/i", 2), # content types
269
+ ("/s/p", 2), # primary language
270
+ ("/l", 2), # classification labels
271
+ ]:
272
+ request = CatalogFacetsRequest(
273
+ prefixes=[CatalogFacetsPrefix(prefix=prefix, depth=depth)]
274
+ )
275
+ tasks.append(
276
+ self.driver.catalog_facets(kbid=self.config.kbid, content=request)
277
+ )
278
+
279
+ responses = await asyncio.gather(*tasks, return_exceptions=True)
280
+ for facets_response in responses:
281
+ if isinstance(facets_response, CatalogFacetsResponse):
282
+ facets.facets.update(facets_response.facets)
283
+ else:
284
+ logger.error(f"Error getting facets: {facets_response}")
285
+ return facets
286
+
287
+ async def description(self):
288
+ kb_obj = await self.driver.get_knowledge_box(kbid=self.config.kbid)
289
+ return f"""{self.config.description} {kb_obj.config.description if kb_obj.config else ""}"""
290
+
291
+ async def get_learning_configuration(self):
292
+ configuration = await self.driver.get_configuration(kbid=self.config.kbid)
293
+ if configuration is None:
294
+ raise Exception("No configuration found for the knowledge box")
295
+
296
+ return StoredLearningConfiguration.model_validate(configuration)
297
+
298
+ async def synonyms(self, sentence: str) -> str:
299
+ synonyms_obj = await self.driver.get_custom_synonyms(kbid=self.config.kbid)
300
+ self._synonyms = synonyms_obj.model_dump()
301
+ words = sentence.split()
302
+ result = []
303
+ if self._synonyms is not None:
304
+ for word in words:
305
+ if word in self._synonyms:
306
+ word = " ".join(self._synonyms[word])
307
+ result.append(word)
308
+ return " ".join(result)
309
+
310
+ async def ask(self, item: AskRequest, headers={}) -> SyncAskResponse:
311
+ return await self.driver.ask(
312
+ kbid=self.config.kbid, content=item, headers=headers
313
+ )
314
+
315
+ async def find(
316
+ self, q: str, filters: List[str] = [], rids: List[str] = []
317
+ ) -> KnowledgeboxFindResults:
318
+ return await self.driver.find(
319
+ kbid=self.config.kbid,
320
+ content=FindRequest(
321
+ query=q,
322
+ resource_filters=rids,
323
+ features=[FindOptions.KEYWORD],
324
+ min_score=MinScore(bm25=1.0),
325
+ filters=list(set(self.config.filters + filters)),
326
+ ),
327
+ )
328
+
329
+ async def find_raw(self, q: FindRequest) -> KnowledgeboxFindResults:
330
+ return await self.driver.find(
331
+ kbid=self.config.kbid,
332
+ content=q,
333
+ )
334
+
335
+ async def graph_relations(
336
+ self, q: GraphRelationsSearchRequest
337
+ ) -> GraphRelationsSearchResponse:
338
+ return await self.driver.graph_relations(kbid=self.config.kbid, content=q)
339
+
340
+ async def graph_search(self, q: GraphSearchRequest) -> GraphSearchResponse:
341
+ return await self.driver.graph_search(kbid=self.config.kbid, content=q)
342
+
343
+ async def graph_nodes(self, q: GraphNodesSearchRequest) -> GraphNodesSearchResponse:
344
+ return await self.driver.graph_nodes(kbid=self.config.kbid, content=q)
345
+
346
+ async def catalog_search_raw(self, q: CatalogRequest) -> CatalogResponse:
347
+ return await self.driver.catalog(content=q, kbid=self.config.kbid)
348
+
349
+ async def get_resource_by_id(
350
+ self, rid: str, query_params: Optional[Dict[str, str]] = None
351
+ ) -> Optional[Resource]:
352
+ return await self.driver.get_resource_by_id(
353
+ kbid=self.config.kbid, rid=rid, query_params=query_params
354
+ )
355
+
356
+ async def get_ephemeral_token(self, path: Optional[str] = None) -> Optional[str]:
357
+ """Get an ephemeral token scoped to the knowledge box, optionally restricted to a specific resource path.
358
+
359
+ Args:
360
+ path: When provided, the token will only be valid for this specific path
361
+ (e.g. /kb/{kbid}/resource/{rid}). This prevents the token from
362
+ being used to access the entire knowledge box.
363
+ """
364
+ async with httpx.AsyncClient(transport=SafeTransport()) as client:
365
+ headers = {
366
+ "Authorization": f"Bearer {self.config.key}",
367
+ "Content-Type": "application/json",
368
+ "Accept": "*/*",
369
+ }
370
+ url = f"{self.manager.url}/v1/ephemeral_token"
371
+ body: dict = {"ttl": 600}
372
+ if path is not None:
373
+ body["path"] = path
374
+ response = await client.post(url, headers=headers, json=body)
375
+ if response.status_code != 201:
376
+ raise Exception(f"Error getting ephemeral token: {response.text}")
377
+ data = response.json()
378
+ return data.get("token", None)
379
+
380
+
381
+ def format_ndb_labels(
382
+ labels: Dict[str, List[str]],
383
+ max_examples: int = 5,
384
+ ) -> str:
385
+ """Format labels and their examples into a string representation."""
386
+ labels_str = ""
387
+ for label, examples in labels.items():
388
+ labels_str += f"- {label}: {', '.join(examples[:max_examples]) + (', ...' if len(examples) > max_examples else '')}\n"
389
+ return labels_str
390
+
391
+
392
+ def format_ndb_catalog(
393
+ catalog: CatalogResponse,
394
+ ) -> str:
395
+ """Format catalog results into a string representation."""
396
+ catalog_str = ""
397
+ # TODO: Consider converting to jinja2 template
398
+ for rid, resource in catalog.resources.items():
399
+ catalog_str += f"- Title: {resource.title}\n"
400
+ if resource.summary:
401
+ catalog_str += f" Summary: '{resource.summary[:100]}'\n"
402
+ catalog_str += f" Format: {resource.icon}\n"
403
+ if resource.metadata and resource.metadata.language:
404
+ catalog_str += f" Language: {resource.metadata.language}\n"
405
+
406
+ if resource.usermetadata and resource.usermetadata.classifications:
407
+ catalog_str += " User defined Labels: "
408
+ catalog_str += " | ".join(
409
+ f'{classification.labelset}:"{classification.label}"'
410
+ for classification in resource.usermetadata.classifications
411
+ # XXX: Don't know if this is needed
412
+ if not classification.cancelled_by_user
413
+ )
414
+ catalog_str += "\n"
415
+
416
+ if (
417
+ resource.computedmetadata
418
+ and resource.computedmetadata.field_classifications
419
+ ):
420
+ catalog_str += " Computed Labels: "
421
+ catalog_str += " | ".join(
422
+ f'{classification.labelset}:"{classification.label}"'
423
+ for field_classification in resource.computedmetadata.field_classifications
424
+ for classification in field_classification.classifications
425
+ )
426
+ catalog_str += "\n"
427
+ catalog_str += "\n"
428
+ return catalog_str
@@ -0,0 +1,42 @@
1
+ from typing import ClassVar, Dict, List, Literal, Optional
2
+
3
+ from hyperforge.driver import DriverConfig, EncryptedPayload
4
+ from nucliadb_models.filters import CatalogFilterExpression, FilterExpression
5
+ from pydantic import BaseModel, Field
6
+ from pydantic.config import ConfigDict
7
+
8
+
9
+ class ManagerConnection(BaseModel):
10
+ api_key: Optional[str]
11
+ url: str
12
+ headers: Dict[str, str]
13
+
14
+
15
+ class NucliaDBConnection(EncryptedPayload):
16
+ encrypted_fields: ClassVar[list[str]] = ["key"]
17
+ key: Optional[str] = None
18
+ url: str
19
+ manager: str
20
+ filters: List[str] = Field(
21
+ default_factory=list,
22
+ deprecated=True,
23
+ description="Use filter_expression instead",
24
+ )
25
+ filter_expression: Optional[FilterExpression] = Field(
26
+ default=None,
27
+ description="Expression to filter fields/paragraphs when retrieving context. This will be combined with any other filters that are provided at query time or chosen by the agent, using an AND operator.",
28
+ )
29
+ catalog_filter_expression: Optional[CatalogFilterExpression] = Field(
30
+ default=None,
31
+ description="Expression to filter catalog items when retrieving context. This will be combined with any other filters that are provided at query time or chosen by the agent, using an AND operator.",
32
+ )
33
+ description: str = Field(
34
+ description="Description of the knowledge box, used to give context to the agent and assist in selecting the right knowledge box for a given query."
35
+ )
36
+ kbid: str
37
+
38
+
39
+ class NucliaDBConfig(DriverConfig[NucliaDBConnection]):
40
+ model_config = ConfigDict(title="Knowledge Box")
41
+ provider: Literal["nucliadb"]
42
+ config: NucliaDBConnection
File without changes