hyperforge-nucliadb-agentic 1.0.0.post64__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. hyperforge_nucliadb_agentic/__init__.py +3 -0
  2. hyperforge_nucliadb_agentic/agent.py +1642 -0
  3. hyperforge_nucliadb_agentic/ask/__init__.py +5 -0
  4. hyperforge_nucliadb_agentic/ask/audit.py +439 -0
  5. hyperforge_nucliadb_agentic/ask/exceptions.py +50 -0
  6. hyperforge_nucliadb_agentic/ask/lifespan.py +21 -0
  7. hyperforge_nucliadb_agentic/ask/model.py +1299 -0
  8. hyperforge_nucliadb_agentic/ask/predict.py +431 -0
  9. hyperforge_nucliadb_agentic/ask/predict_models.py +78 -0
  10. hyperforge_nucliadb_agentic/ask/search/__init__.py +0 -0
  11. hyperforge_nucliadb_agentic/ask/search/ask.py +1182 -0
  12. hyperforge_nucliadb_agentic/ask/search/graph_strategy.py +1138 -0
  13. hyperforge_nucliadb_agentic/ask/search/highlight.py +93 -0
  14. hyperforge_nucliadb_agentic/ask/search/hydrator.py +29 -0
  15. hyperforge_nucliadb_agentic/ask/search/metrics.py +112 -0
  16. hyperforge_nucliadb_agentic/ask/search/parsers/__init__.py +0 -0
  17. hyperforge_nucliadb_agentic/ask/search/parsers/ask.py +70 -0
  18. hyperforge_nucliadb_agentic/ask/search/parsers/fetcher.py +192 -0
  19. hyperforge_nucliadb_agentic/ask/search/parsers/find.py +729 -0
  20. hyperforge_nucliadb_agentic/ask/search/prompt.py +1298 -0
  21. hyperforge_nucliadb_agentic/ask/search/rank_fusion.py +157 -0
  22. hyperforge_nucliadb_agentic/ask/search/rerankers.py +161 -0
  23. hyperforge_nucliadb_agentic/ask/search/retrieval.py +750 -0
  24. hyperforge_nucliadb_agentic/ask/search/rpc.py +192 -0
  25. hyperforge_nucliadb_agentic/ask/settings.py +9 -0
  26. hyperforge_nucliadb_agentic/ask/utils/ids.py +188 -0
  27. hyperforge_nucliadb_agentic/ask/utils/proto.py +6 -0
  28. hyperforge_nucliadb_agentic/ask/utils/responses.py +6 -0
  29. hyperforge_nucliadb_agentic/ask/utils/text_blocks.py +51 -0
  30. hyperforge_nucliadb_agentic/config.py +47 -0
  31. hyperforge_nucliadb_agentic/internal_driver.py +87 -0
  32. hyperforge_nucliadb_agentic/py.typed +0 -0
  33. hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/METADATA +24 -0
  34. hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/RECORD +35 -0
  35. hyperforge_nucliadb_agentic-1.0.0.post64.dist-info/WHEEL +4 -0
@@ -0,0 +1,1138 @@
1
+ import heapq
2
+ import json
3
+ from collections import defaultdict
4
+ from collections.abc import Collection, Iterable
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from nuclia_models.predict.generative_responses import (
9
+ JSONGenerativeResponse,
10
+ MetaGenerativeResponse,
11
+ StatusGenerativeResponse,
12
+ )
13
+ from nucliadb_models.common import FieldTypeName
14
+ from nucliadb_models.graph.requests import (
15
+ And,
16
+ AnyNode,
17
+ Generated,
18
+ Generator,
19
+ GraphNode,
20
+ GraphNodesSearchRequest,
21
+ GraphPath,
22
+ GraphPathQuery,
23
+ GraphSearchRequest,
24
+ NodeMatchKindName,
25
+ Not,
26
+ Or,
27
+ Relation,
28
+ RelationNodeType,
29
+ RelationType,
30
+ )
31
+ from nucliadb_models.graph.responses import GraphSearchResponse
32
+ from nucliadb_models.internal.predict import RerankModel
33
+ from nucliadb_models.metadata import RelationMetadata
34
+ from nucliadb_models.resource import ExtractedDataTypeName
35
+ from nucliadb_models.retrieval import GraphScore
36
+ from nucliadb_models.search import (
37
+ SCORE_TYPE,
38
+ DirectionalRelation,
39
+ EntitySubgraph,
40
+ NucliaDBClientType,
41
+ RelatedEntities,
42
+ RelatedEntity,
43
+ RelationDirection,
44
+ )
45
+ from nucliadb_protos import utils_pb2
46
+ from nucliadb_protos.utils_pb2 import RelationNode
47
+ from nucliadb_sdk.v2 import NucliaDBAsync
48
+ from pydantic import BaseModel
49
+ from sentry_sdk import capture_exception
50
+
51
+ from hyperforge_nucliadb_agentic.ask import logger
52
+ from hyperforge_nucliadb_agentic.ask.model import (
53
+ AskRequest,
54
+ ChatModel,
55
+ FindRequest,
56
+ GraphStrategy,
57
+ KnowledgeboxFindResults,
58
+ QueryEntityDetection,
59
+ RelationRanking,
60
+ Relations,
61
+ ResourceProperties,
62
+ TextPosition,
63
+ UserPrompt,
64
+ )
65
+ from hyperforge_nucliadb_agentic.ask.predict import get_predict
66
+ from hyperforge_nucliadb_agentic.ask.search import rpc
67
+ from hyperforge_nucliadb_agentic.ask.search.hydrator import (
68
+ ResourceHydrationOptions,
69
+ TextBlockHydrationOptions,
70
+ )
71
+ from hyperforge_nucliadb_agentic.ask.search.metrics import Metrics
72
+ from hyperforge_nucliadb_agentic.ask.search.rerankers import (
73
+ Reranker,
74
+ RerankingOptions,
75
+ )
76
+ from hyperforge_nucliadb_agentic.ask.search.retrieval import (
77
+ compose_find_resources,
78
+ find_request_from_ask_request,
79
+ hydrate_and_rerank,
80
+ )
81
+ from hyperforge_nucliadb_agentic.ask.utils.ids import (
82
+ FieldId,
83
+ ParagraphId,
84
+ )
85
+ from hyperforge_nucliadb_agentic.ask.utils.text_blocks import (
86
+ TextBlockMatch,
87
+ )
88
+
89
+ RelationNodeTypeMap: dict[
90
+ RelationNodeType, utils_pb2.RelationNode.NodeType.ValueType
91
+ ] = {
92
+ RelationNodeType.ENTITY: utils_pb2.RelationNode.NodeType.ENTITY,
93
+ RelationNodeType.LABEL: utils_pb2.RelationNode.NodeType.LABEL,
94
+ RelationNodeType.RESOURCE: utils_pb2.RelationNode.NodeType.RESOURCE,
95
+ RelationNodeType.USER: utils_pb2.RelationNode.NodeType.USER,
96
+ }
97
+
98
+ RelationNodeTypePbMap: dict[
99
+ utils_pb2.RelationNode.NodeType.ValueType, RelationNodeType
100
+ ] = {
101
+ utils_pb2.RelationNode.NodeType.ENTITY: RelationNodeType.ENTITY,
102
+ utils_pb2.RelationNode.NodeType.LABEL: RelationNodeType.LABEL,
103
+ utils_pb2.RelationNode.NodeType.RESOURCE: RelationNodeType.RESOURCE,
104
+ utils_pb2.RelationNode.NodeType.USER: RelationNodeType.USER,
105
+ }
106
+
107
+ SCHEMA = {
108
+ "title": "score_triplets",
109
+ "description": "Return a list of triplets and their relevance scores (0-10) for the supplied question.",
110
+ "type": "object",
111
+ "properties": {
112
+ "triplets": {
113
+ "type": "array",
114
+ "description": "A list of triplets with their relevance scores.",
115
+ "items": {
116
+ "type": "object",
117
+ "properties": {
118
+ "head_entity": {
119
+ "type": "string",
120
+ "description": "The first entity in the triplet.",
121
+ },
122
+ "relationship": {
123
+ "type": "string",
124
+ "description": "The relationship between the two entities.",
125
+ },
126
+ "tail_entity": {
127
+ "type": "string",
128
+ "description": "The second entity in the triplet.",
129
+ },
130
+ "score": {
131
+ "type": "integer",
132
+ "description": "A relevance score in the range 0 to 10.",
133
+ "minimum": 0,
134
+ "maximum": 10,
135
+ },
136
+ },
137
+ "required": ["head_entity", "relationship", "tail_entity", "score"],
138
+ },
139
+ }
140
+ },
141
+ "required": ["triplets"],
142
+ }
143
+
144
+ PROMPT = """\
145
+ You are an advanced language model assisting in scoring relationships (edges) between two entities in a knowledge graph, given a user's question.
146
+
147
+ For each provided **(head_entity, relationship, tail_entity)**, you must:
148
+ 1. Assign a **relevance score** between **0** and **10**.
149
+ 2. **0** means “this relationship can't be relevant at all to the question.”
150
+ 3. **10** means “this relationship is extremely relevant to the question.”
151
+ 4. You may use **any integer** between 0 and 10 (e.g., 3, 7, etc.) based on how relevant you deem the relationship to be.
152
+ 5. **Language Agnosticism**: The question and the relationships may be in different languages. The relevance scoring should still work and be agnostic of the language.
153
+ 6. Relationships that may not answer the question directly but expand knowledge in a relevant way, should also be scored positively.
154
+
155
+ Once you have decided the best score for each triplet, return these results **using a function call** in JSON format with the following rules:
156
+
157
+ - The function name should be `score_triplets`.
158
+ - The first argument should be the list of triplets.
159
+ - Each triplet should have the following keys:
160
+ - `head_entity`: The first entity in the triplet.
161
+ - `relationship`: The relationship between the two entities.
162
+ - `tail_entity`: The second entity in the triplet.
163
+ - `score`: The relevance score in the range 0 to 10.
164
+
165
+ You **must** comply with the provided JSON Schema to ensure a well-structured response and mantain the order of the triplets.
166
+
167
+
168
+ ## Examples:
169
+
170
+ ### Example 1:
171
+
172
+ **Input**
173
+
174
+ {
175
+ "question": "Who is the mayor of the capital city of Australia?",
176
+ "triplets": [
177
+ {
178
+ "head_entity": "Australia",
179
+ "relationship": "has prime minister",
180
+ "tail_entity": "Scott Morrison"
181
+ },
182
+ {
183
+ "head_entity": "Canberra",
184
+ "relationship": "is capital of",
185
+ "tail_entity": "Australia"
186
+ },
187
+ {
188
+ "head_entity": "Scott Knowles",
189
+ "relationship": "holds position",
190
+ "tail_entity": "Mayor"
191
+ },
192
+ {
193
+ "head_entity": "Barbera Smith",
194
+ "relationship": "tiene cargo",
195
+ "tail_entity": "Alcalde"
196
+ },
197
+ {
198
+ "head_entity": "Austria",
199
+ "relationship": "has capital",
200
+ "tail_entity": "Vienna"
201
+ }
202
+ ]
203
+ }
204
+
205
+ **Output**
206
+
207
+ {
208
+ "triplets": [
209
+ {
210
+ "head_entity": "Australia",
211
+ "relationship": "has prime minister",
212
+ "tail_entity": "Scott Morrison",
213
+ "score": 4
214
+ },
215
+ {
216
+ "head_entity": "Canberra",
217
+ "relationship": "is capital of",
218
+ "tail_entity": "Australia",
219
+ "score": 8
220
+ },
221
+ {
222
+ "head_entity": "Scott Knowles",
223
+ "relationship": "holds position",
224
+ "tail_entity": "Mayor",
225
+ "score": 8
226
+ },
227
+ {
228
+ "head_entity": "Barbera Smith",
229
+ "relationship": "tiene cargo",
230
+ "tail_entity": "Alcalde",
231
+ "score": 8
232
+ },
233
+ {
234
+ "head_entity": "Austria",
235
+ "relationship": "has capital",
236
+ "tail_entity": "Vienna",
237
+ "score": 0
238
+ }
239
+ ]
240
+ }
241
+
242
+
243
+
244
+ ### Example 2:
245
+
246
+ **Input**
247
+
248
+ {
249
+ "question": "How many products does John Adams Roofing Inc. offer?",
250
+ "triplets": [
251
+ {
252
+ "head_entity": "John Adams Roofing Inc.",
253
+ "relationship": "has product",
254
+ "tail_entity": "Titanium Grade 3 Roofing Nails"
255
+ },
256
+ {
257
+ "head_entity": "John Adams Roofing Inc.",
258
+ "relationship": "is located in",
259
+ "tail_entity": "New York"
260
+ },
261
+ {
262
+ "head_entity": "John Adams Roofing Inc.",
263
+ "relationship": "was founded by",
264
+ "tail_entity": "John Adams"
265
+ },
266
+ {
267
+ "head_entity": "John Adams Roofing Inc.",
268
+ "relationship": "tiene stock",
269
+ "tail_entity": "Baldosas solares"
270
+ },
271
+ {
272
+ "head_entity": "John Adams Roofing Inc.",
273
+ "relationship": "has product",
274
+ "tail_entity": "Mercerized Cotton Thread"
275
+ }
276
+ ]
277
+ }
278
+
279
+ **Output**
280
+
281
+ {
282
+ "triplets": [
283
+ {
284
+ "head_entity": "John Adams Roofing Inc.",
285
+ "relationship": "has product",
286
+ "tail_entity": "Titanium Grade 3 Roofing Nails",
287
+ "score": 10
288
+ },
289
+ {
290
+ "head_entity": "John Adams Roofing Inc.",
291
+ "relationship": "is located in",
292
+ "tail_entity": "New York",
293
+ "score": 6
294
+ },
295
+ {
296
+ "head_entity": "John Adams Roofing Inc.",
297
+ "relationship": "was founded by",
298
+ "tail_entity": "John Adams",
299
+ "score": 5
300
+ },
301
+ {
302
+ "head_entity": "John Adams Roofing Inc.",
303
+ "relationship": "tiene stock",
304
+ "tail_entity": "Baldosas solares",
305
+ "score": 10
306
+ },
307
+ {
308
+ "head_entity": "John Adams Roofing Inc.",
309
+ "relationship": "has product",
310
+ "tail_entity": "Mercerized Cotton Thread",
311
+ "score": 10
312
+ }
313
+ ]
314
+ }
315
+
316
+ Now, let's get started! Here are the triplets you need to score:
317
+
318
+ **Input**
319
+
320
+ """
321
+
322
+
323
+ @dataclass(frozen=True)
324
+ class FrozenRelationNode:
325
+ ntype: RelationNode.NodeType.ValueType
326
+ subtype: str
327
+ value: str
328
+
329
+
330
+ def freeze_node(r: RelationNode) -> FrozenRelationNode:
331
+ return FrozenRelationNode(ntype=r.ntype, subtype=r.subtype, value=r.value)
332
+
333
+
334
+ class RelationsParagraphMatch(BaseModel):
335
+ paragraph_id: ParagraphId
336
+ score: float
337
+ relations: Relations
338
+
339
+
340
+ async def get_graph_results(
341
+ *,
342
+ search_sdk: NucliaDBAsync,
343
+ kbid: str,
344
+ query: str,
345
+ item: AskRequest,
346
+ ndb_client: NucliaDBClientType,
347
+ user: str,
348
+ origin: str,
349
+ graph_strategy: GraphStrategy,
350
+ text_block_reranker: Reranker,
351
+ metrics: Metrics,
352
+ generative_model: str | None = None,
353
+ shards: list[str] | None = None,
354
+ ) -> tuple[KnowledgeboxFindResults, FindRequest]:
355
+ relations = Relations(entities={})
356
+ explored_entities: set[FrozenRelationNode] = set()
357
+ scores: dict[str, list[float]] = {}
358
+ predict = get_predict()
359
+ entities_to_explore: list[RelationNode] = []
360
+
361
+ for hop in range(graph_strategy.hops):
362
+ if hop == 0:
363
+ # Get the entities from the query
364
+ with metrics.time("graph_strat_query_entities"):
365
+ if (
366
+ graph_strategy.query_entity_detection
367
+ == QueryEntityDetection.SUGGEST
368
+ ):
369
+ relation_result = await fuzzy_search_entities(
370
+ search_sdk,
371
+ kbid=kbid,
372
+ query=query,
373
+ )
374
+ if relation_result is not None:
375
+ entities_to_explore = [
376
+ RelationNode(
377
+ ntype=RelationNode.NodeType.ENTITY,
378
+ value=result.value,
379
+ subtype=result.family,
380
+ )
381
+ for result in relation_result.entities
382
+ ]
383
+ elif (
384
+ not entities_to_explore
385
+ or graph_strategy.query_entity_detection
386
+ == QueryEntityDetection.PREDICT
387
+ ):
388
+ try:
389
+ # Purposely ignore the entity subtype. This is done so we find all entities that match
390
+ # the entity by name. e.g: in a query like "2000", predict might detect the number as
391
+ # a year entity or as a currency entity. We want graph results for both, so we ignore the
392
+ # subtype just in this case.
393
+ entities_to_explore = [
394
+ RelationNode(ntype=r.ntype, value=r.value, subtype="")
395
+ for r in await predict.detect_entities(kbid, query)
396
+ ]
397
+ except Exception as e:
398
+ capture_exception(e)
399
+ logger.exception(
400
+ "Error in detecting entities for graph strategy"
401
+ )
402
+ entities_to_explore = []
403
+ else:
404
+ # Find neighbors of the current relations and remove the ones already explored
405
+ entities_to_explore = [
406
+ RelationNode(
407
+ ntype=RelationNode.NodeType.ENTITY,
408
+ value=relation.entity,
409
+ subtype=relation.entity_subtype,
410
+ )
411
+ for subgraph in relations.entities.values()
412
+ for relation in subgraph.related_to
413
+ if FrozenRelationNode(
414
+ ntype=RelationNodeTypeMap[relation.entity_type],
415
+ subtype=relation.entity_subtype,
416
+ value=relation.entity,
417
+ )
418
+ not in explored_entities
419
+ ]
420
+
421
+ if not entities_to_explore:
422
+ break
423
+
424
+ # Get the relations for the new entities
425
+ with metrics.time("graph_strat_neighbor_relations"):
426
+ try:
427
+ graph_response = await find_graph_neighbours(
428
+ search_sdk,
429
+ kbid,
430
+ entities_to_explore,
431
+ explored_entities,
432
+ exclude_processor_relations=graph_strategy.exclude_processor_relations,
433
+ )
434
+ new_relations = merge_relations_results(
435
+ graph_response,
436
+ entities_to_explore,
437
+ only_with_metadata=not graph_strategy.relation_text_as_paragraphs,
438
+ )
439
+ except Exception as e:
440
+ capture_exception(e)
441
+ logger.exception("Error in getting query relations for graph strategy")
442
+ new_relations = Relations(entities={})
443
+
444
+ relations.entities.update(new_relations.entities)
445
+ discovered_entities = []
446
+
447
+ for path in graph_response.paths:
448
+ for path_node in [path.source, path.destination]:
449
+ node = RelationNode(
450
+ value=path_node.value,
451
+ ntype=RelationNodeTypeMap[path_node.type],
452
+ subtype=path_node.group,
453
+ )
454
+ if (
455
+ node not in entities_to_explore
456
+ and freeze_node(node) not in explored_entities
457
+ ):
458
+ discovered_entities.append(node)
459
+
460
+ if not discovered_entities:
461
+ break
462
+
463
+ explored_entities.update([freeze_node(n) for n in entities_to_explore])
464
+ entities_to_explore = discovered_entities
465
+
466
+ # Rank the relevance of the relations
467
+ with metrics.time("graph_strat_rank_relations"):
468
+ try:
469
+ if graph_strategy.relation_ranking == RelationRanking.RERANKER:
470
+ relations, scores = await rank_relations_reranker(
471
+ relations,
472
+ query,
473
+ kbid,
474
+ user,
475
+ top_k=graph_strategy.top_k,
476
+ )
477
+ elif graph_strategy.relation_ranking == RelationRanking.GENERATIVE:
478
+ relations, scores = await rank_relations_generative(
479
+ relations,
480
+ query,
481
+ kbid,
482
+ user,
483
+ top_k=graph_strategy.top_k,
484
+ generative_model=generative_model,
485
+ )
486
+ except Exception as e:
487
+ capture_exception(e)
488
+ logger.exception("Error in ranking relations for graph strategy")
489
+ relations = Relations(entities={})
490
+ scores = {}
491
+ break
492
+
493
+ # Get the text blocks of the paragraphs that contain the top relations
494
+ with metrics.time("graph_strat_build_response"):
495
+ find_request = find_request_from_ask_request(item, query)
496
+ find_results = await build_graph_response(
497
+ search_sdk=search_sdk,
498
+ kbid=kbid,
499
+ query=query,
500
+ final_relations=relations,
501
+ scores=scores,
502
+ top_k=graph_strategy.top_k,
503
+ reranker=text_block_reranker,
504
+ show=item.show,
505
+ extracted=item.extracted,
506
+ field_type_filter=item.field_type_filter,
507
+ relation_text_as_paragraphs=graph_strategy.relation_text_as_paragraphs,
508
+ )
509
+ return find_results, find_request
510
+
511
+
512
+ async def fuzzy_search_entities(
513
+ search_sdk: NucliaDBAsync,
514
+ kbid: str,
515
+ query: str,
516
+ ) -> RelatedEntities | None:
517
+ """Fuzzy find entities in KB given a query using the same methodology as /suggest, but split by words."""
518
+
519
+ # Build an OR for each word in the query matching with fuzzy any word in any
520
+ # node in any position. I.e., for the query "Rose Hamiltn", it'll match
521
+ # "Rosa Parks" and "Margaret Hamilton"
522
+ operands = []
523
+ for word in query.split():
524
+ if len(word) >= 3:
525
+ subquery = AnyNode(value=word, match=NodeMatchKindName.FUZZY_WORDS)
526
+ else:
527
+ # words smaller than 3 characters are too noisy and thus, forbidden in the API
528
+ subquery = AnyNode(value=word, match=NodeMatchKindName.EXACT)
529
+ operands.append(subquery)
530
+
531
+ if len(operands) == 0:
532
+ return RelatedEntities(entities=[], total=0)
533
+
534
+ # XXX: are those enough results? Too many?
535
+ top_k = 50
536
+ if len(operands) == 1:
537
+ request = GraphNodesSearchRequest(query=operands[0], top_k=top_k)
538
+ else:
539
+ request = GraphNodesSearchRequest(query=Or(operands=operands), top_k=top_k)
540
+
541
+ try:
542
+ response = await rpc.graph_nodes(search_sdk, kbid, request)
543
+ except Exception as exc:
544
+ capture_exception(exc)
545
+ logger.exception("Error in finding entities in query for graph strategy")
546
+ return None
547
+
548
+ # merge shard results while deduplicating repeated entities across shards
549
+ unique_entities: set[RelatedEntity] = {
550
+ RelatedEntity(family=e.group, value=e.value) for e in response.nodes
551
+ }
552
+
553
+ return RelatedEntities(entities=list(unique_entities), total=len(unique_entities))
554
+
555
+
556
+ async def rank_relations_reranker(
557
+ relations: Relations,
558
+ query: str,
559
+ kbid: str,
560
+ user: str,
561
+ top_k: int,
562
+ score_threshold: float = 0.02,
563
+ ) -> tuple[Relations, dict[str, list[float]]]:
564
+ # Store the index for keeping track after scoring
565
+ flat_rels: list[tuple[str, int, DirectionalRelation]] = [
566
+ (ent, idx, rel)
567
+ for (ent, rels) in relations.entities.items()
568
+ for (idx, rel) in enumerate(rels.related_to)
569
+ ]
570
+ # Build triplets (dict) from each relation for use in reranker
571
+ triplets: list[dict[str, str]] = [
572
+ {
573
+ "head_entity": ent,
574
+ "relationship": rel.relation_label,
575
+ "tail_entity": rel.entity,
576
+ }
577
+ if rel.direction == RelationDirection.OUT
578
+ else {
579
+ "head_entity": rel.entity,
580
+ "relationship": rel.relation_label,
581
+ "tail_entity": ent,
582
+ }
583
+ for (ent, _, rel) in flat_rels
584
+ ]
585
+
586
+ # Dedupe triplets so that they get evaluated once; map triplet -> [orig_indices]
587
+ triplet_to_orig_indices: dict[tuple[str, str, str], list[int]] = {}
588
+ unique_triplets: list[dict[str, str]] = []
589
+
590
+ for i, t in enumerate(triplets):
591
+ key = (t["head_entity"], t["relationship"], t["tail_entity"])
592
+ if key not in triplet_to_orig_indices:
593
+ triplet_to_orig_indices[key] = []
594
+ unique_triplets.append(t)
595
+ triplet_to_orig_indices[key].append(i)
596
+
597
+ # Build the reranker model input
598
+ predict = get_predict()
599
+ rerank_model = RerankModel(
600
+ question=query,
601
+ user_id=user,
602
+ context={
603
+ str(idx): f"{t['head_entity']} {t['relationship']} {t['tail_entity']}"
604
+ for idx, t in enumerate(unique_triplets)
605
+ },
606
+ )
607
+ # Get the rerank scores
608
+ res = await predict.rerank(kbid, rerank_model)
609
+
610
+ # Convert returned scores to a list of (int_idx, score)
611
+ # where int_idx corresponds to indices in unique_triplets
612
+ reranked_indices_scores = [
613
+ (int(idx), score) for idx, score in res.context_scores.items()
614
+ ]
615
+
616
+ return _scores_to_ranked_rels(
617
+ unique_triplets,
618
+ reranked_indices_scores,
619
+ triplet_to_orig_indices,
620
+ flat_rels,
621
+ top_k,
622
+ score_threshold,
623
+ )
624
+
625
+
626
+ async def rank_relations_generative(
627
+ relations: Relations,
628
+ query: str,
629
+ kbid: str,
630
+ user: str,
631
+ top_k: int,
632
+ generative_model: str | None = None,
633
+ score_threshold: float = 2,
634
+ max_rels_to_eval: int = 100,
635
+ ) -> tuple[Relations, dict[str, list[float]]]:
636
+ # Store the index for keeping track after scoring
637
+ flat_rels: list[tuple[str, int, DirectionalRelation]] = [
638
+ (ent, idx, rel)
639
+ for (ent, rels) in relations.entities.items()
640
+ for (idx, rel) in enumerate(rels.related_to)
641
+ ]
642
+ triplets: list[dict[str, str]] = [
643
+ {
644
+ "head_entity": ent,
645
+ "relationship": rel.relation_label,
646
+ "tail_entity": rel.entity,
647
+ }
648
+ if rel.direction == RelationDirection.OUT
649
+ else {
650
+ "head_entity": rel.entity,
651
+ "relationship": rel.relation_label,
652
+ "tail_entity": ent,
653
+ }
654
+ for (ent, _, rel) in flat_rels
655
+ ]
656
+
657
+ # Dedupe triplets so that they get evaluated once, we will re-associate the scores later
658
+ triplet_to_orig_indices: dict[tuple[str, str, str], list[int]] = {}
659
+ unique_triplets: list[dict[str, str]] = []
660
+
661
+ for i, t in enumerate(triplets):
662
+ key = (t["head_entity"], t["relationship"], t["tail_entity"])
663
+ if key not in triplet_to_orig_indices:
664
+ triplet_to_orig_indices[key] = []
665
+ unique_triplets.append(t)
666
+ triplet_to_orig_indices[key].append(i)
667
+
668
+ if len(flat_rels) > max_rels_to_eval:
669
+ logger.warning(
670
+ f"Too many relations to evaluate ({len(flat_rels)}), using reranker to reduce"
671
+ )
672
+ return await rank_relations_reranker(
673
+ relations, query, kbid, user, top_k=max_rels_to_eval
674
+ )
675
+
676
+ data = {
677
+ "question": query,
678
+ "triplets": unique_triplets,
679
+ }
680
+ prompt = PROMPT + json.dumps(data, indent=4)
681
+
682
+ predict = get_predict()
683
+ chat_model = ChatModel(
684
+ question=prompt,
685
+ user_id=user,
686
+ json_schema=SCHEMA,
687
+ format_prompt=False, # We supply our own prompt
688
+ query_context_order={},
689
+ query_context={},
690
+ user_prompt=UserPrompt(prompt=prompt),
691
+ max_tokens=4096,
692
+ generative_model=generative_model,
693
+ )
694
+
695
+ ident, model, answer_stream = await predict.chat_query_ndjson(kbid, chat_model)
696
+ response_json = None
697
+ status = None
698
+ _ = None
699
+
700
+ async for generative_chunk in answer_stream:
701
+ item = generative_chunk.chunk
702
+ if isinstance(item, JSONGenerativeResponse):
703
+ response_json = item
704
+ elif isinstance(item, StatusGenerativeResponse):
705
+ status = item
706
+ elif isinstance(item, MetaGenerativeResponse):
707
+ _ = item
708
+ else:
709
+ raise ValueError(f"Unknown generative chunk type: {item}")
710
+
711
+ if response_json is None or status is None or status.code != "0":
712
+ raise ValueError("No JSON response found")
713
+
714
+ scored_unique_triplets: list[dict[str, str | Any]] = response_json.object[
715
+ "triplets"
716
+ ]
717
+
718
+ if len(scored_unique_triplets) != len(unique_triplets):
719
+ raise ValueError("Mismatch between input and output triplets")
720
+
721
+ unique_indices_scores = (
722
+ (idx, float(t["score"])) for (idx, t) in enumerate(scored_unique_triplets)
723
+ )
724
+
725
+ return _scores_to_ranked_rels(
726
+ unique_triplets,
727
+ unique_indices_scores,
728
+ triplet_to_orig_indices,
729
+ flat_rels,
730
+ top_k,
731
+ score_threshold,
732
+ )
733
+
734
+
735
+ def _scores_to_ranked_rels(
736
+ unique_triplets: list[dict[str, str]],
737
+ unique_indices_scores: Iterable[tuple[int, float]],
738
+ triplet_to_orig_indices: dict[tuple[str, str, str], list[int]],
739
+ flat_rels: list[tuple[str, int, DirectionalRelation]],
740
+ top_k: int,
741
+ score_threshold: float,
742
+ ) -> tuple[Relations, dict[str, list[float]]]:
743
+ """
744
+ Helper function to convert unique scores assigned by a model back to the original relations while taking
745
+ care of threshold
746
+ """
747
+ top_k_indices_scores = heapq.nlargest(
748
+ top_k, unique_indices_scores, key=lambda x: x[1]
749
+ )
750
+
751
+ # Prepare a new Relations object + a dict of top scores by entity
752
+ top_k_rels: dict[str, EntitySubgraph] = defaultdict(
753
+ lambda: EntitySubgraph(related_to=[])
754
+ )
755
+ top_k_scores_by_ent: dict[str, list[float]] = defaultdict(list)
756
+ # Re-expand model scores to the original triplets
757
+ for idx, score in top_k_indices_scores:
758
+ # If the model's score is below threshold, skip
759
+ if score <= score_threshold:
760
+ continue
761
+
762
+ # Identify which original triplets (in flat_rels) this corresponds to
763
+ t = unique_triplets[idx]
764
+ key = (t["head_entity"], t["relationship"], t["tail_entity"])
765
+ orig_indices = triplet_to_orig_indices[key]
766
+
767
+ for orig_i in orig_indices:
768
+ ent, rel_idx, rel = flat_rels[orig_i]
769
+ # Insert the relation into top_k_rels
770
+ top_k_rels[ent].related_to.append(rel)
771
+
772
+ # Keep track of which indices were chosen per entity
773
+ top_k_scores_by_ent[ent].append(score)
774
+
775
+ return Relations(entities=top_k_rels), dict(top_k_scores_by_ent)
776
+
777
+
778
+ def build_text_blocks_from_relations(
779
+ relations: Relations,
780
+ scores: dict[str, list[float]],
781
+ ) -> list[TextBlockMatch]:
782
+ """
783
+ The goal of this function is to generate TextBlockMatch with custom text for each unique relation in the graph.
784
+
785
+ This is a hacky way to generate paragraphs from relations, and it is not the intended use of TextBlockMatch.
786
+ """
787
+ # Build a set of unique triplets with their scores
788
+ triplets: dict[
789
+ tuple[str, str, str], tuple[float, Relations, ParagraphId | None]
790
+ ] = defaultdict(lambda: (0.0, Relations(entities={}), None))
791
+ paragraph_count = 0
792
+ for ent, subgraph in relations.entities.items():
793
+ if ent not in scores:
794
+ continue
795
+ for rel, score in zip(subgraph.related_to, scores[ent]):
796
+ key = (
797
+ (
798
+ ent,
799
+ rel.relation_label,
800
+ rel.entity,
801
+ )
802
+ if rel.direction == RelationDirection.OUT
803
+ else (rel.entity, rel.relation_label, ent)
804
+ )
805
+ existing_score, existing_relations, p_id = triplets[key]
806
+ if ent not in existing_relations.entities:
807
+ existing_relations.entities[ent] = EntitySubgraph(related_to=[])
808
+
809
+ # XXX: Since relations with the same triplet can point to different paragraphs,
810
+ # we keep the first one, but we lose the other ones
811
+ if p_id is None and rel.metadata and rel.metadata.paragraph_id:
812
+ p_id = ParagraphId.from_string(rel.metadata.paragraph_id)
813
+ else:
814
+ # No paragraph ID set, fake it so we can hydrate the resource
815
+ p_id = ParagraphId(
816
+ field_id=FieldId(rel.resource_id, "a", "usermetadata"),
817
+ paragraph_start=paragraph_count,
818
+ paragraph_end=paragraph_count + 1,
819
+ )
820
+ paragraph_count += 1
821
+ existing_relations.entities[ent].related_to.append(rel)
822
+ # XXX: Here we use the max even though all relations with same triplet should have same score
823
+ triplets[key] = (max(existing_score, score), existing_relations, p_id)
824
+
825
+ # Build the text blocks
826
+ text_blocks = [
827
+ TextBlockMatch(
828
+ # XXX: Even though we are setting a paragraph_id, the text is not coming from the paragraph
829
+ paragraph_id=p_id,
830
+ scores=[GraphScore(score=score)],
831
+ score_type=SCORE_TYPE.RELATION_RELEVANCE,
832
+ order=0,
833
+ text=f"- {ent} {rel} {tail}", # Manually build the text
834
+ position=TextPosition(
835
+ page_number=0,
836
+ index=0,
837
+ start=0,
838
+ end=0,
839
+ start_seconds=[],
840
+ end_seconds=[],
841
+ ),
842
+ field_labels=[],
843
+ paragraph_labels=[],
844
+ fuzzy_search=False,
845
+ is_a_table=False,
846
+ representation_file="",
847
+ page_with_visual=False,
848
+ relevant_relations=relations,
849
+ )
850
+ for (ent, rel, tail), (score, relations, p_id) in triplets.items()
851
+ if p_id is not None
852
+ ]
853
+ return text_blocks
854
+
855
+
856
+ def get_paragraph_info_from_relations(
857
+ relations: Relations,
858
+ scores: dict[str, list[float]],
859
+ ) -> list[RelationsParagraphMatch]:
860
+ """
861
+ Gathers paragraph info from the 'relations' object, merges relations by paragraph,
862
+ and removes paragraphs contained entirely within others.
863
+ """
864
+
865
+ # Group paragraphs by field so we can detect containment
866
+ paragraphs_by_field: dict[FieldId, list[RelationsParagraphMatch]] = defaultdict(
867
+ list
868
+ )
869
+
870
+ # Loop over each entity in the relation graph
871
+ for ent, subgraph in relations.entities.items():
872
+ if ent not in scores:
873
+ continue
874
+ for rel_score, rel in zip(scores[ent], subgraph.related_to):
875
+ if rel.metadata and rel.metadata.paragraph_id:
876
+ p_id = ParagraphId.from_string(rel.metadata.paragraph_id)
877
+ match = RelationsParagraphMatch(
878
+ paragraph_id=p_id,
879
+ score=rel_score,
880
+ relations=Relations(
881
+ entities={ent: EntitySubgraph(related_to=[rel])}
882
+ ),
883
+ )
884
+ paragraphs_by_field[p_id.field_id].append(match)
885
+
886
+ # For each field, sort paragraphs by start asc, end desc, and do one pass to remove contained ones
887
+ final_paragraphs: list[RelationsParagraphMatch] = []
888
+
889
+ for _, paragraph_list in paragraphs_by_field.items():
890
+ # Sort by paragraph_start ascending; if tie, paragraph_end descending
891
+ paragraph_list.sort(
892
+ key=lambda m: (
893
+ m.paragraph_id.paragraph_start,
894
+ -m.paragraph_id.paragraph_end,
895
+ )
896
+ )
897
+
898
+ kept: list[RelationsParagraphMatch] = []
899
+ current_max_end = -1
900
+
901
+ for match in paragraph_list:
902
+ end = match.paragraph_id.paragraph_end
903
+
904
+ # If end <= current_max_end, this paragraph is contained last one
905
+ if end <= current_max_end:
906
+ # We merge the scores and relations
907
+ container = kept[-1]
908
+ container.score = max(container.score, match.score)
909
+ for ent, subgraph in match.relations.entities.items():
910
+ if ent not in container.relations.entities:
911
+ container.relations.entities[ent] = EntitySubgraph(
912
+ related_to=[]
913
+ )
914
+ container.relations.entities[ent].related_to.extend(
915
+ subgraph.related_to
916
+ )
917
+
918
+ else:
919
+ # Not contained; keep it
920
+ kept.append(match)
921
+ current_max_end = end
922
+ final_paragraphs.extend(kept)
923
+
924
+ return final_paragraphs
925
+
926
+
927
+ async def build_graph_response(
928
+ *,
929
+ search_sdk: NucliaDBAsync,
930
+ kbid: str,
931
+ query: str,
932
+ final_relations: Relations,
933
+ scores: dict[str, list[float]],
934
+ top_k: int,
935
+ reranker: Reranker,
936
+ relation_text_as_paragraphs: bool,
937
+ show: list[ResourceProperties] = [],
938
+ extracted: list[ExtractedDataTypeName] = [],
939
+ field_type_filter: list[FieldTypeName] = [],
940
+ ) -> KnowledgeboxFindResults:
941
+ if relation_text_as_paragraphs:
942
+ text_blocks = build_text_blocks_from_relations(final_relations, scores)
943
+ else:
944
+ paragraphs_info = get_paragraph_info_from_relations(final_relations, scores)
945
+ text_blocks = relations_matches_to_text_block_matches(paragraphs_info)
946
+
947
+ # hydrate and rerank
948
+ resource_hydration_options = ResourceHydrationOptions(
949
+ show=show, extracted=extracted, field_type_filter=field_type_filter
950
+ )
951
+ text_block_hydration_options = TextBlockHydrationOptions(only_hydrate_empty=True)
952
+ reranking_options = RerankingOptions(kbid=kbid, query=query)
953
+ text_blocks, resources, best_matches = await hydrate_and_rerank(
954
+ search_sdk,
955
+ text_blocks,
956
+ kbid,
957
+ resource_hydration_options=resource_hydration_options,
958
+ text_block_hydration_options=text_block_hydration_options,
959
+ reranker=reranker,
960
+ reranking_options=reranking_options,
961
+ top_k=top_k,
962
+ )
963
+
964
+ find_resources = compose_find_resources(text_blocks, resources)
965
+
966
+ return KnowledgeboxFindResults(
967
+ query=query,
968
+ resources=find_resources,
969
+ best_matches=best_matches,
970
+ relations=final_relations,
971
+ total=len(text_blocks),
972
+ )
973
+
974
+
975
+ def relations_match_to_text_block_match(
976
+ paragraph_match: RelationsParagraphMatch,
977
+ ) -> TextBlockMatch:
978
+ """
979
+ Given a paragraph_id, return a TextBlockMatch with the bare minimum fields
980
+ This is required by the Graph Strategy to get text blocks from the relevant paragraphs
981
+ """
982
+ # XXX: this is a workaround for the fact we always assume retrieval means keyword/semantic search and
983
+ # the hydration and find response building code works with TextBlockMatch, we extended it to have relevant relations information
984
+ parsed_paragraph_id = paragraph_match.paragraph_id
985
+ return TextBlockMatch(
986
+ paragraph_id=parsed_paragraph_id,
987
+ scores=[GraphScore(score=paragraph_match.score)],
988
+ score_type=SCORE_TYPE.RELATION_RELEVANCE,
989
+ order=0, # NOTE: this will be filled later
990
+ text="", # NOTE: this will be filled later too
991
+ position=TextPosition(
992
+ page_number=0,
993
+ index=0,
994
+ start=parsed_paragraph_id.paragraph_start,
995
+ end=parsed_paragraph_id.paragraph_end,
996
+ start_seconds=[],
997
+ end_seconds=[],
998
+ ),
999
+ field_labels=[],
1000
+ paragraph_labels=[],
1001
+ fuzzy_search=False,
1002
+ is_a_table=False,
1003
+ representation_file="",
1004
+ page_with_visual=False,
1005
+ relevant_relations=paragraph_match.relations,
1006
+ )
1007
+
1008
+
1009
+ def relations_matches_to_text_block_matches(
1010
+ paragraph_matches: Collection[RelationsParagraphMatch],
1011
+ ) -> list[TextBlockMatch]:
1012
+ return [relations_match_to_text_block_match(match) for match in paragraph_matches]
1013
+
1014
+
1015
+ async def find_graph_neighbours(
1016
+ search_sdk: NucliaDBAsync,
1017
+ kbid: str,
1018
+ entities_to_explore: list[RelationNode],
1019
+ explored_entities: set[FrozenRelationNode],
1020
+ exclude_processor_relations: bool,
1021
+ ) -> GraphSearchResponse:
1022
+ subqueries: list[GraphPathQuery] = []
1023
+
1024
+ # Explore starting from some entities
1025
+ query_to_explore = [
1026
+ GraphPath(
1027
+ source=GraphNode(
1028
+ value=entity.value,
1029
+ type=RelationNodeTypePbMap[entity.ntype],
1030
+ group=entity.subtype,
1031
+ ),
1032
+ destination=GraphNode(
1033
+ type=RelationNodeType.ENTITY,
1034
+ ),
1035
+ undirected=True,
1036
+ )
1037
+ for entity in entities_to_explore
1038
+ ]
1039
+ if len(query_to_explore) > 0:
1040
+ subqueries.append(Or(operands=query_to_explore))
1041
+
1042
+ # Do not return already known entities
1043
+ if explored_entities:
1044
+ query_exclude_explored = [
1045
+ GraphPath(
1046
+ source=GraphNode(
1047
+ value=explored.value,
1048
+ type=RelationNodeTypePbMap[explored.ntype],
1049
+ group=explored.subtype,
1050
+ ),
1051
+ undirected=True,
1052
+ )
1053
+ for explored in explored_entities
1054
+ ]
1055
+ if len(query_exclude_explored):
1056
+ subqueries.append(Not(operand=Or(operands=query_exclude_explored)))
1057
+
1058
+ # Only include relations between entities
1059
+ only_entities = Relation(type=RelationType.ENTITY)
1060
+ subqueries.append(only_entities)
1061
+
1062
+ # Exclude processor entities
1063
+ if exclude_processor_relations:
1064
+ exclude_processor: GraphPathQuery = Not(
1065
+ operand=Generated(by=Generator.PROCESSOR)
1066
+ )
1067
+ subqueries.append(exclude_processor)
1068
+
1069
+ graph_query = GraphSearchRequest(query=And(operands=subqueries), top_k=100)
1070
+ response = await rpc.graph_paths(search_sdk, kbid, graph_query)
1071
+
1072
+ return response
1073
+
1074
+
1075
+ def merge_relations_results(
1076
+ graph_response: GraphSearchResponse,
1077
+ query_entry_points: Iterable[RelationNode],
1078
+ only_with_metadata: bool = False,
1079
+ ) -> Relations:
1080
+ """Merge relation search responses into a single Relations object while applying filters."""
1081
+ relations = Relations(entities={})
1082
+
1083
+ for entry_point in query_entry_points:
1084
+ relations.entities[entry_point.value] = EntitySubgraph(related_to=[])
1085
+
1086
+ for path in graph_response.paths:
1087
+ relation = path.relation
1088
+ source = path.source
1089
+ destination = path.destination
1090
+ metadata = path.metadata
1091
+
1092
+ # BUG: if for some reason we don't have the field_id and we skip the
1093
+ # next if, we'll reuse a wrong resource id
1094
+ if path.metadata is not None and path.metadata.field_id:
1095
+ resource_id = path.metadata.field_id.split("/")[0]
1096
+
1097
+ if only_with_metadata and path.metadata is None:
1098
+ continue
1099
+
1100
+ if source.value in relations.entities:
1101
+ relations.entities[source.value].related_to.append(
1102
+ DirectionalRelation(
1103
+ entity=destination.value,
1104
+ entity_type=destination.type,
1105
+ entity_subtype=destination.group,
1106
+ relation=relation.type,
1107
+ relation_label=relation.label,
1108
+ direction=RelationDirection.OUT,
1109
+ metadata=RelationMetadata(
1110
+ paragraph_id=metadata.paragraph_id,
1111
+ # TODO(decoupled-ask): do we need more fields in the relation metadata?
1112
+ )
1113
+ if metadata is not None
1114
+ else None,
1115
+ resource_id=resource_id,
1116
+ )
1117
+ )
1118
+
1119
+ elif destination.value in relations.entities:
1120
+ relations.entities[destination.value].related_to.append(
1121
+ DirectionalRelation(
1122
+ entity=source.value,
1123
+ entity_type=source.type,
1124
+ entity_subtype=source.group,
1125
+ relation=relation.type,
1126
+ relation_label=relation.label,
1127
+ direction=RelationDirection.IN,
1128
+ metadata=RelationMetadata(
1129
+ paragraph_id=metadata.paragraph_id,
1130
+ # TODO(decoupled-ask): do we need more fields in the relation metadata?
1131
+ )
1132
+ if metadata is not None
1133
+ else None,
1134
+ resource_id=resource_id,
1135
+ )
1136
+ )
1137
+
1138
+ return relations