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,59 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, List, Optional
3
+
4
+ from hyperforge.models import FieldTypes
5
+ from nuclia.lib.nua_responses import SemanticConfig
6
+ from nuclia_models.worker.triggers import Relation
7
+ from nucliadb_models.graph.responses import (
8
+ GraphNodesSearchResponse,
9
+ GraphRelationsSearchResponse,
10
+ GraphSearchResponse,
11
+ )
12
+ from nucliadb_models.search import Filter, KnowledgeboxFindResults, KnowledgeGraphEntity
13
+
14
+
15
+ @dataclass
16
+ class NDBChunk:
17
+ chunk_id: str
18
+ text: str
19
+ page_with_visual: bool
20
+ reference: Optional[str]
21
+ start: Optional[int]
22
+ end: Optional[int]
23
+ page: Optional[int]
24
+ labels: List[str]
25
+ field: Optional[FieldTypes]
26
+ resource_labels: List[str]
27
+ link: Optional[str]
28
+
29
+
30
+ @dataclass
31
+ class Analysis:
32
+ semantic_query: str
33
+ lexical_query: str
34
+ visual: bool
35
+ keywords_filter: List[str]
36
+ reason: Optional[str]
37
+ input_tokens: Optional[float]
38
+ output_tokens: Optional[float]
39
+ labels: List[Filter]
40
+ semantic_model: Optional[str]
41
+ entities: List[KnowledgeGraphEntity]
42
+ pre_queries: List[str]
43
+ relations: List[Relation]
44
+ new_text_fields: List[str]
45
+ new_json_fields: dict[str, Any]
46
+ semantic_config: SemanticConfig
47
+ top_k: int
48
+ link: bool
49
+ knowledge_graph: Optional[str]
50
+
51
+
52
+ @dataclass
53
+ class SearchResults:
54
+ lexical_find_results: Optional[KnowledgeboxFindResults]
55
+ semantic_find_results: Optional[KnowledgeboxFindResults]
56
+ graph_find_results: Optional[KnowledgeboxFindResults]
57
+ graph_results: Optional[GraphSearchResponse]
58
+ nodes_results: Optional[GraphNodesSearchResponse]
59
+ relations_results: Optional[GraphRelationsSearchResponse]
@@ -0,0 +1,146 @@
1
+ import asyncio
2
+ from time import time
3
+ from typing import List, cast
4
+
5
+ from hyperforge.manager import Manager
6
+ from hyperforge.memory.memory import QuestionMemory
7
+ from hyperforge.models import Source
8
+
9
+ from hyperforge import PROMPT_ENVIRONMENT
10
+ from hyperforge_nucliadb.driver import NucliaDBDriver
11
+ from hyperforge_nucliadb.sync.driver import SyncDriver
12
+
13
+ MULTI_KB_PROMPT = """
14
+
15
+ You have multiple Knowledge Boxes (KBs) that you can use as data sources to ask your questions.
16
+ Every KB has an ID, a description, a set of labels, and facets that characterize its contents.
17
+ Your task is to figure out which KB is the most relevant to a given question by applying the important rules specified.
18
+
19
+ Here are the details of each KB:
20
+
21
+ {% for source in sources.values() %}
22
+ {%- set sid = source.id -%}
23
+ {%- set sdescription = source.description -%}
24
+ {%- set slabels = source.labels -%}
25
+ # ID: {{sid}}
26
+
27
+ {{sdescription}}
28
+
29
+ labels: {{slabels}}
30
+
31
+ {% endfor -%}
32
+
33
+ And given the question: {{question}}
34
+
35
+ # Important rules to follow
36
+
37
+ {% for rule in rules %}
38
+ {{rule}}
39
+ {% endfor -%}
40
+
41
+ Now, provide your answer following the answer rules:
42
+ - Which KB is the most relevant and why(short explanation)?
43
+ """
44
+
45
+ MULTI_KB_PROMPT_TEMPLATE = PROMPT_ENVIRONMENT.from_string(MULTI_KB_PROMPT)
46
+
47
+ MULTI_KB_SCHEMA = {
48
+ "title": "source_choice",
49
+ "description": "Choose which KBs should be used to answer the question of the user and why",
50
+ "parameters": {
51
+ "type": "object",
52
+ "properties": {
53
+ "sources": {
54
+ "type": "array",
55
+ "description": "the ids of the choosen sources",
56
+ "items": {"type": "string"},
57
+ },
58
+ "reason": {
59
+ "type": "string",
60
+ "description": "reasoning behind the decision",
61
+ },
62
+ },
63
+ },
64
+ }
65
+
66
+
67
+ async def choose_source(
68
+ memory: QuestionMemory,
69
+ manager: Manager,
70
+ sources: List[str],
71
+ question: str,
72
+ ident: str,
73
+ choose_source_model: str = "chatgpt-o3-mini",
74
+ step_title: str = "Knowledge Box Ask: Choose sources",
75
+ ) -> list[Source]:
76
+ if len(sources) == 0:
77
+ raise Exception("No sources available")
78
+
79
+ sources_objects = {}
80
+ for source_id in sources:
81
+ source = await memory.get_session_source(source_id)
82
+ if source is None:
83
+ source = await load_source_information(source_id, manager)
84
+ await memory.set_session_source(source)
85
+ sources_objects[source_id] = source
86
+
87
+ # Only one source, try to use it without involving the LLM
88
+ if len(sources) == 1:
89
+ return [sources_objects[sources[0]]]
90
+
91
+ t0 = time()
92
+ prompt = MULTI_KB_PROMPT_TEMPLATE.render(
93
+ sources=sources_objects,
94
+ rules=memory.get_rules(),
95
+ question=question,
96
+ )
97
+ data, input, output = await manager.execute_json(
98
+ prompt=prompt,
99
+ schema=MULTI_KB_SCHEMA,
100
+ user_id="source_models",
101
+ model=choose_source_model,
102
+ tracking=memory.get_tracking_info(),
103
+ )
104
+
105
+ chosen_sources = cast(list[str], data.get("sources"))
106
+ reason = data.get("reason")
107
+
108
+ await memory.add_step(
109
+ step_module="router",
110
+ step_title=step_title,
111
+ step_value=str(chosen_sources),
112
+ step_reason=reason,
113
+ timeit=time() - t0,
114
+ input_nuclia_tokens=input,
115
+ output_nuclia_tokens=output,
116
+ step_agent_path=f"/context/{ident if ident else 'default'}",
117
+ )
118
+
119
+ return [sources_objects[chosen] for chosen in chosen_sources]
120
+
121
+
122
+ async def load_source_information(source: str, manager: Manager) -> Source:
123
+ driver = manager.drivers.get(source)
124
+ if not isinstance(driver, NucliaDBDriver) and not isinstance(driver, SyncDriver):
125
+ raise ValueError("Source is not a NucliaDB driver")
126
+ (
127
+ description,
128
+ labels,
129
+ facets_native,
130
+ paragraph_facets,
131
+ learning_configuration,
132
+ ) = await asyncio.gather(
133
+ driver.description(),
134
+ driver.labels(),
135
+ driver.facets_native(),
136
+ driver.paragraph_facets(),
137
+ driver.get_learning_configuration(),
138
+ )
139
+ return Source(
140
+ id=source,
141
+ description=description,
142
+ labels=labels,
143
+ facets_native=facets_native,
144
+ paragraph_facets=paragraph_facets,
145
+ learning_configuration=learning_configuration,
146
+ )
@@ -0,0 +1,238 @@
1
+ from asyncio import gather
2
+ from typing import Awaitable, List, Optional, cast
3
+
4
+ from hyperforge.manager import Manager
5
+ from hyperforge.memory.memory import QuestionMemory
6
+ from nucliadb_models.filters import And, Or
7
+ from nucliadb_models.graph.requests import (
8
+ GraphPathQuery,
9
+ )
10
+ from nucliadb_models.resource import ExtractedDataTypeName
11
+ from nucliadb_models.search import (
12
+ FindOptions,
13
+ FindRequest,
14
+ KnowledgeboxFindResults,
15
+ MinScore,
16
+ RerankerName,
17
+ ResourceProperties,
18
+ )
19
+ from nucliadb_models.security import RequestSecurity
20
+
21
+ from hyperforge_nucliadb.ask.config import AskAgentConfig
22
+ from hyperforge_nucliadb.ask.models import Analysis, SearchResults
23
+ from hyperforge_nucliadb.ask.utils import (
24
+ empty,
25
+ get_nodes,
26
+ get_relations,
27
+ )
28
+ from hyperforge_nucliadb.driver import NucliaDBDriver
29
+
30
+
31
+ def query_relations(
32
+ memory: QuestionMemory,
33
+ manager: Manager,
34
+ analysis: Analysis,
35
+ nucliadb_driver: NucliaDBDriver,
36
+ config: AskAgentConfig,
37
+ relations: List[GraphPathQuery],
38
+ nodes: List[GraphPathQuery],
39
+ ) -> Awaitable[KnowledgeboxFindResults]:
40
+ """Query relations in NucliaDB based on the provided analysis."""
41
+ find_request = FindRequest(
42
+ features=[FindOptions.GRAPH],
43
+ filters=analysis.labels,
44
+ graph_query=And(
45
+ operands=[
46
+ Or(operands=relations),
47
+ Or(operands=nodes),
48
+ ]
49
+ ),
50
+ security=RequestSecurity(groups=config.security_groups),
51
+ reranker=RerankerName.NOOP,
52
+ show=[
53
+ ResourceProperties.BASIC,
54
+ ResourceProperties.ORIGIN,
55
+ ResourceProperties.EXTRA,
56
+ ResourceProperties.VALUES,
57
+ ResourceProperties.RELATIONS,
58
+ ],
59
+ )
60
+ return nucliadb_driver.find_raw(find_request)
61
+
62
+
63
+ def query_semantic(
64
+ memory: QuestionMemory,
65
+ manager: Manager,
66
+ analysis: Analysis,
67
+ nucliadb_driver: NucliaDBDriver,
68
+ config: AskAgentConfig,
69
+ ) -> Awaitable[KnowledgeboxFindResults]:
70
+ """Semantic search in NucliaDB based on the provided analysis."""
71
+
72
+ find_request = FindRequest(
73
+ features=[FindOptions.SEMANTIC],
74
+ query=analysis.semantic_query,
75
+ min_score=MinScore(semantic=analysis.semantic_config.threshold),
76
+ vectorset=analysis.semantic_model,
77
+ filters=analysis.labels,
78
+ security=RequestSecurity(groups=config.security_groups),
79
+ reranker=RerankerName.NOOP,
80
+ show=[
81
+ ResourceProperties.BASIC,
82
+ ResourceProperties.ORIGIN,
83
+ ResourceProperties.EXTRA,
84
+ ResourceProperties.EXTRACTED,
85
+ ResourceProperties.VALUES,
86
+ ResourceProperties.RELATIONS,
87
+ ],
88
+ extracted=[
89
+ ExtractedDataTypeName.TEXT,
90
+ ExtractedDataTypeName.METADATA,
91
+ ExtractedDataTypeName.FILE,
92
+ ExtractedDataTypeName.LINK,
93
+ ],
94
+ )
95
+
96
+ return nucliadb_driver.find_raw(find_request)
97
+
98
+
99
+ def query_lexical(
100
+ memory: QuestionMemory,
101
+ manager: Manager,
102
+ analysis: Analysis,
103
+ nucliadb_driver: NucliaDBDriver,
104
+ config: AskAgentConfig,
105
+ ) -> Awaitable[KnowledgeboxFindResults]:
106
+ """Lexical search (aka keyword search) in NucliaDB based on the provided analysis."""
107
+
108
+ find_request = FindRequest(
109
+ features=[FindOptions.KEYWORD],
110
+ query=analysis.lexical_query,
111
+ filters=analysis.labels,
112
+ keyword_filters=analysis.keywords_filter,
113
+ security=RequestSecurity(groups=config.security_groups),
114
+ reranker=RerankerName.NOOP,
115
+ show=[
116
+ ResourceProperties.BASIC,
117
+ ResourceProperties.ORIGIN,
118
+ ResourceProperties.EXTRA,
119
+ ResourceProperties.EXTRACTED,
120
+ ResourceProperties.VALUES,
121
+ ResourceProperties.RELATIONS,
122
+ ],
123
+ extracted=[
124
+ ExtractedDataTypeName.TEXT,
125
+ ExtractedDataTypeName.METADATA,
126
+ ExtractedDataTypeName.FILE,
127
+ ExtractedDataTypeName.LINK,
128
+ ],
129
+ )
130
+
131
+ return nucliadb_driver.find_raw(find_request)
132
+
133
+
134
+ async def query_ndb(
135
+ memory: QuestionMemory,
136
+ manager: Manager,
137
+ analysis: Analysis,
138
+ nucliadb_driver: NucliaDBDriver,
139
+ config: AskAgentConfig,
140
+ ) -> SearchResults:
141
+ semantic_paragraphs = query_semantic(
142
+ memory, manager, analysis, nucliadb_driver, config
143
+ )
144
+ lexical_paragraphs = query_lexical(
145
+ memory, manager, analysis, nucliadb_driver, config
146
+ )
147
+ nodes = cast(list[GraphPathQuery], get_nodes(analysis))
148
+ relations = cast(list[GraphPathQuery], get_relations(analysis))
149
+
150
+ if len(relations) > 0 and len(nodes) > 0:
151
+ relations_paragraphs: Awaitable[Optional[KnowledgeboxFindResults]] = (
152
+ query_relations(
153
+ memory,
154
+ manager,
155
+ analysis,
156
+ nucliadb_driver,
157
+ config,
158
+ relations=cast(list[GraphPathQuery], relations),
159
+ nodes=cast(list[GraphPathQuery], nodes),
160
+ )
161
+ )
162
+
163
+ else:
164
+ relations_paragraphs = empty()
165
+
166
+ (
167
+ lexical_find_results,
168
+ semantic_find_results,
169
+ graph_find_results,
170
+ ) = await gather(
171
+ lexical_paragraphs,
172
+ semantic_paragraphs,
173
+ relations_paragraphs,
174
+ )
175
+
176
+ if (
177
+ len(lexical_find_results.resources) == 0
178
+ and analysis.keywords_filter is not None
179
+ and len(analysis.keywords_filter) > 0
180
+ ):
181
+ # XXX: This is repeating the lexical query with the same parameters no?
182
+
183
+ # If no lexical results, we can try to find some using the keywords filter
184
+ lexical_find_results = await query_lexical(
185
+ memory, manager, analysis, nucliadb_driver, config
186
+ )
187
+
188
+ result = SearchResults(
189
+ lexical_find_results=lexical_find_results,
190
+ semantic_find_results=semantic_find_results,
191
+ graph_find_results=graph_find_results,
192
+ graph_results=None,
193
+ nodes_results=None,
194
+ relations_results=None,
195
+ )
196
+ return result
197
+
198
+
199
+ async def standard_query_ndb(
200
+ memory: QuestionMemory,
201
+ manager: Manager,
202
+ nucliadb_driver: NucliaDBDriver,
203
+ config: AskAgentConfig,
204
+ question: str,
205
+ ) -> SearchResults:
206
+ find_request = FindRequest(
207
+ features=[FindOptions.SEMANTIC, FindOptions.KEYWORD],
208
+ query=question,
209
+ security=RequestSecurity(groups=config.security_groups),
210
+ reranker=RerankerName.NOOP,
211
+ show=[
212
+ ResourceProperties.BASIC,
213
+ ResourceProperties.ORIGIN,
214
+ ResourceProperties.EXTRA,
215
+ ResourceProperties.EXTRACTED,
216
+ ResourceProperties.VALUES,
217
+ ResourceProperties.RELATIONS,
218
+ ],
219
+ extracted=[
220
+ ExtractedDataTypeName.TEXT,
221
+ ExtractedDataTypeName.METADATA,
222
+ ExtractedDataTypeName.FILE,
223
+ ExtractedDataTypeName.LINK,
224
+ ],
225
+ filters=nucliadb_driver.config.filters,
226
+ )
227
+
228
+ paragraphs = await nucliadb_driver.find_raw(find_request)
229
+
230
+ result = SearchResults(
231
+ lexical_find_results=None,
232
+ semantic_find_results=paragraphs,
233
+ graph_find_results=None,
234
+ graph_results=None,
235
+ nodes_results=None,
236
+ relations_results=None,
237
+ )
238
+ return result
@@ -0,0 +1,50 @@
1
+ from typing import Any, Dict
2
+
3
+ from hyperforge_nucliadb.ask.config import AskAgentConfig
4
+ from hyperforge_nucliadb.ask.kb_analysis import KnowledgeBoxInfo
5
+ from hyperforge_nucliadb.ask.query_analysis import QueryAnalisys
6
+
7
+ # https://huggingface.co/collections/ModelSpace/gemmax2-673714f5049bfa3a90bee6b6
8
+ TRANSLATE_QUERY_PROMPT = "Rephrase this question so its better for lexical retrieval, translate the lexical rephrased question to {language}. Please define ONLY the question without any explanation. JUST A SENTENCE. DO NOT ADD ANY EXTRA NOTES AT THE END, JUST ONE SENTENCE"
9
+
10
+
11
+ def configure_prompts(
12
+ config: AskAgentConfig,
13
+ ask_json_schema_copy: Dict[str, Dict[str, Any]],
14
+ kb_analysis: KnowledgeBoxInfo,
15
+ query_analysis: QueryAnalisys,
16
+ ) -> None:
17
+ """
18
+ Configure the prompts based on the provided configuration.
19
+ This function modifies the ask_json_schema_copy in place to set the
20
+ appropriate prompts for semantic, lexical, and visual queries.
21
+ """
22
+ if config.visual_enable_prompt is not None:
23
+ ask_json_schema_copy["parameters"]["properties"]["visual"]["description"] = (
24
+ config.visual_enable_prompt
25
+ )
26
+
27
+ if config.rephrase_semantic_custom_prompt is not None:
28
+ ask_json_schema_copy["parameters"]["properties"]["semantic_query"][
29
+ "description"
30
+ ] = config.rephrase_semantic_custom_prompt
31
+
32
+ # Rephrase as lexical to query lexical engine
33
+ if config.rephrase_lexical_custom_prompt is not None:
34
+ ask_json_schema_copy["parameters"]["properties"]["lexical_query"][
35
+ "description"
36
+ ] = config.rephrase_lexical_custom_prompt
37
+ elif query_analysis.translate_need is not None:
38
+ ask_json_schema_copy["parameters"]["properties"]["lexical_query"][
39
+ "description"
40
+ ] = TRANSLATE_QUERY_PROMPT.format(language=query_analysis.translate_need)
41
+
42
+ # Rephrase as keywords that must appear only if the language of the query matches the language of the KB
43
+
44
+ if (
45
+ query_analysis.translate_need is None
46
+ and config.keywords_custom_prompt is not None
47
+ ):
48
+ ask_json_schema_copy["parameters"]["properties"]["keywords_filter"][
49
+ "description"
50
+ ] = config.keywords_custom_prompt
@@ -0,0 +1,83 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+
5
+ from lingua import LanguageDetectorBuilder
6
+
7
+ from hyperforge import logger
8
+ from hyperforge_nucliadb.ask.config import AskAgentConfig
9
+ from hyperforge_nucliadb.ask.kb_analysis import KnowledgeBoxInfo
10
+
11
+ LID_MODEL = None
12
+
13
+
14
+ def load_lingua() -> None:
15
+ global LID_MODEL
16
+ LID_MODEL = (
17
+ LanguageDetectorBuilder.from_all_languages()
18
+ .with_preloaded_language_models()
19
+ .build()
20
+ )
21
+
22
+
23
+ def detect_language(text: str, default="en") -> str:
24
+ global LID_MODEL
25
+ lang = default
26
+
27
+ if len(text.strip()) == 0:
28
+ return default
29
+
30
+ if isinstance(text, bytes):
31
+ text = text.decode()
32
+
33
+ text = cleanup_text_for_langdetect(text)
34
+
35
+ if LID_MODEL is None:
36
+ load_lingua()
37
+ assert LID_MODEL is not None # for type checker
38
+ result = LID_MODEL.detect_language_of(text.replace("\n", " "))
39
+
40
+ if result is not None:
41
+ lang = result.iso_code_639_1.name.lower()
42
+
43
+ return lang
44
+
45
+
46
+ def cleanup_text_for_langdetect(text: str) -> str:
47
+ # Get all text from splitting docs
48
+ regex = re.compile(r"(\[image: [a-zA-Z0-9.]*\])", re.S)
49
+ return re.sub(regex, "", text)
50
+
51
+
52
+ @dataclass
53
+ class QueryAnalisys:
54
+ translate_need: Optional[str] = None
55
+
56
+
57
+ def pre_query_analysis(
58
+ config: AskAgentConfig, question: str, kb_analysis: KnowledgeBoxInfo
59
+ ) -> QueryAnalisys:
60
+ # LLM QUERY
61
+ result = QueryAnalisys()
62
+
63
+ # Rephrase as semantic to query semantic engine -> custom prompt
64
+
65
+ query_language = detect_language(question)
66
+
67
+ # Check if the query language is in the languages of the KB
68
+ # or get the most common language in the KB to translate the query
69
+ if query_language in kb_analysis.languages:
70
+ result.translate_need = None
71
+ else:
72
+ try:
73
+ result.translate_need = sorted(
74
+ kb_analysis.languages.items(), key=lambda item: item[1]
75
+ )[0][0]
76
+ except IndexError:
77
+ logger.warning(
78
+ "No languages found in the KB analysis, cannot determine if translation is needed"
79
+ )
80
+ # Kb may not have any languages
81
+ result.translate_need = None
82
+
83
+ return result
@@ -0,0 +1,45 @@
1
+ from hyperforge.manager import Manager
2
+ from hyperforge.models import Context
3
+ from nuclia.lib.nua_responses import RerankModel
4
+
5
+ from hyperforge import logger
6
+
7
+
8
+ async def rerank(context: Context, manager: Manager, top_k: int) -> None:
9
+ """
10
+ Rerank the context chunks based on the analysis.
11
+ """
12
+ if not context.chunks:
13
+ return
14
+
15
+ re_ranked = await manager.rerank(
16
+ RerankModel(
17
+ context={chunk.chunk_id: chunk.text for chunk in context.chunks},
18
+ question=context.question,
19
+ user_id="arag-ask-rerank",
20
+ )
21
+ )
22
+
23
+ # Sort chunks by score
24
+ re_ranked_sorted = sorted(
25
+ re_ranked.context_scores.items(), key=lambda x: x[1], reverse=True
26
+ )
27
+
28
+ re_ranked_sorted = list(
29
+ filter(
30
+ lambda x: x[1] > 0.05,
31
+ re_ranked.context_scores.items(),
32
+ )
33
+ )
34
+
35
+ re_ranked_sorted = re_ranked_sorted[:top_k]
36
+
37
+ # If the analysis has a rerank function, use it to rerank the chunks
38
+ old_chunks = {chunk.chunk_id: chunk for chunk in context.chunks}
39
+ context.chunks = []
40
+ for rerank_chunk_id, _ in re_ranked_sorted:
41
+ try:
42
+ chunk = old_chunks[rerank_chunk_id]
43
+ context.chunks.append(chunk)
44
+ except KeyError:
45
+ logger.error(f"Chunk {rerank_chunk_id} not found in old_chunks")
@@ -0,0 +1,36 @@
1
+ from collections.abc import Sequence
2
+ from typing import List
3
+
4
+ from nucliadb_models.graph.requests import (
5
+ AnyNode,
6
+ NodeMatchKindName,
7
+ Relation,
8
+ )
9
+
10
+ from hyperforge_nucliadb.ask.models import Analysis
11
+
12
+
13
+ async def empty() -> None:
14
+ """A placeholder to call."""
15
+ pass
16
+
17
+
18
+ def get_nodes(analysis: Analysis) -> List[AnyNode]:
19
+ """Extract nodes from the analysis."""
20
+ nodes: List[AnyNode] = []
21
+ for entity in analysis.entities:
22
+ if entity.type == "PERSON":
23
+ nodes.append(AnyNode(value=entity.name, match=NodeMatchKindName.FUZZY))
24
+ elif entity.type == "LOCATION" or entity.type == "ORGANIZATION":
25
+ nodes.append(AnyNode(value=entity.name, match=NodeMatchKindName.EXACT))
26
+ else:
27
+ nodes.append(AnyNode(value=entity.name, match=NodeMatchKindName.FUZZY))
28
+ return nodes
29
+
30
+
31
+ def get_relations(analysis: Analysis) -> Sequence[Relation]:
32
+ """Extract relations from the analysis."""
33
+ relations: List[Relation] = []
34
+ for relation in analysis.relations:
35
+ relations.append(Relation(label=relation.label))
36
+ return relations