hyperforge-summarize 1.0.0.post21__tar.gz

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,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: hyperforge_summarize
3
+ Version: 1.0.0.post21
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
+
19
+ # Summarization Hyperforge agents
@@ -0,0 +1 @@
1
+ # Summarization Hyperforge agents
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hyperforge_summarize"
7
+ version = "1.0.0.post21"
8
+ license = "Apache-2.0"
9
+ description = "NucliaDB Hyperforge agent"
10
+ authors = [{ name = "Nuclia", email = "nucliadb@nuclia.com" }]
11
+ readme = "README.md"
12
+ classifiers = [
13
+ "Programming Language :: Python",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Topic :: Software Development :: Libraries :: Python Modules",
19
+ ]
20
+ requires-python = ">=3.10, <4"
21
+ dependencies = ["hyperforge"]
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "pytest",
26
+ "pytest-benchmark",
27
+ "pytest-docker-fixtures>=1.4.2",
28
+ "pytest-lazy-fixtures",
29
+ "pytest-recording",
30
+ "pytest-asyncio",
31
+ "pytest-cov",
32
+ "pytest-mock",
33
+ "hyperforge_rephrase",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://progress.com"
38
+ Repository = "https://github.com/nuclia/forge"
39
+
40
+ [tool.pytest.ini_options]
41
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from .agent import SummarizeAgent
2
+
3
+ __all__ = [
4
+ "SummarizeAgent",
5
+ ]
@@ -0,0 +1,377 @@
1
+ from time import time
2
+ from typing import List, Optional, overload
3
+
4
+ from hyperforge import PROMPT_ENVIRONMENT
5
+ from hyperforge.agent import Agent
6
+ from hyperforge.configure import agent
7
+ from hyperforge.context.agent import generate_ctx_block_id
8
+ from hyperforge.manager import Manager
9
+ from hyperforge.memory import QuestionMemory
10
+ from hyperforge.models import AnswerCitations, CitationMetadata, Context
11
+ from hyperforge.trace import trace_agent
12
+ from nuclia.lib.nua_responses import ChatModel, Tool, ToolChoiceAuto, UserPrompt
13
+ from nuclia_models.predict.generative_responses import ToolCall
14
+
15
+ from hyperforge_summarize.config import SummarizeAgentConfig
16
+ from hyperforge_summarize.prompts import MARKDOWN_TWO_LEVELS_CITATIONS_PROMPT_ADJUSTMENT
17
+
18
+ DEFAULT_SYSTEM_PROMPT = """You are a helpful AI assistant. Your role is to provide accurate, clear, and well-structured answers based strictly on the information provided to you.
19
+ Key principles:
20
+ - Answer only using the information in the provided context
21
+ - Do not use external knowledge, assumptions, or prior experience
22
+ - Maintain a professional and informative tone
23
+ - Be concise yet thorough
24
+ - If information is insufficient, acknowledge this clearly
25
+
26
+ Always follow any additional instructions provided about format, style, or domain-specific behavior."""
27
+
28
+ SUMMARIZE_PROMPT_CONVERSATIONAL = """
29
+ {% if rules -%}
30
+ # Generation Rules
31
+ {% for rule in rules -%}
32
+ - {{ rule }}
33
+ {% endfor -%}
34
+ {% endif -%}
35
+
36
+
37
+ ## Question
38
+ {{ question }}
39
+
40
+ ## Provided Context
41
+ [START OF CONTEXT]
42
+ {{ context }}
43
+ [END OF CONTEXT]
44
+
45
+ ## Answering Guidelines
46
+ - Carefully read all context; it may be lengthy or detailed
47
+ - Do not omit or overlook any relevant information
48
+ - If the context is incomplete or insufficient, try to provide a partial answer and encourage the user to clarify their question
49
+ - Read carefully any extra instructions below if provided and use them to answer
50
+
51
+ {% if prompt -%}
52
+ ## Additional Instructions for answering
53
+ {{ prompt }}
54
+ {% endif -%}
55
+
56
+ {% if chat_history -%}
57
+ ## Previous conversation history
58
+ - {{ chat_history }}
59
+ {% endif -%}
60
+
61
+ {% if extra_prompts -%}
62
+ ## Extra Prompts to consider for generating the answer
63
+ This information was used to generate the context.
64
+ Use it to help generate a better answer and follow any specific instructions it may contain about the format or style of the answer.
65
+ {% for extra in extra_prompts -%}
66
+ - {{ extra }}
67
+ {% endfor -%}
68
+ {% endif -%}
69
+
70
+ Now provide your answer to the question: {{ question }}
71
+ """
72
+
73
+ SUMMARIZE_PROMPT = """
74
+ {% if rules -%}
75
+ # Generation Rules
76
+ {% for rule in rules -%}
77
+ - {{ rule }}
78
+ {% endfor -%}
79
+ {% endif -%}
80
+
81
+ ## Question
82
+ {{ question }}
83
+
84
+ ## Provided Context
85
+ [START OF CONTEXT]
86
+ {{ context }}
87
+ [END OF CONTEXT]
88
+
89
+ ## Answering Guidelines
90
+ - Carefully read all context; it may be lengthy or detailed
91
+ - Do not omit or overlook any relevant information
92
+ - If the context is incomplete or insufficient, state: "Not enough data to answer this."
93
+ - Read carefully any extra instructions below if provided and use them to answer
94
+
95
+ {% if prompt -%}
96
+ ## Additional Instructions for answering
97
+ - {{ prompt }}
98
+ {% endif -%}
99
+
100
+ {% if chat_history -%}
101
+ ## Previous conversation history
102
+ - {{ chat_history }}
103
+ {% endif -%}
104
+
105
+
106
+ {% if extra_prompts -%}
107
+ ## Extra Prompts to consider for generating the answer
108
+ This information was used to generate the context.
109
+ Use it to help generate a better answer and follow any specific instructions it may contain about the format or style of the answer.
110
+ {% for extra in extra_prompts -%}
111
+ - {{ extra }}
112
+ {% endfor -%}
113
+ {% endif -%}
114
+
115
+ Now provide your answer to the question: {{ question }}
116
+ """
117
+
118
+ SUMMARIZE_PROMPT_TEMPLATE = PROMPT_ENVIRONMENT.from_string(SUMMARIZE_PROMPT)
119
+ SUMMARIZE_PROMPT_CONVERSATIONAL_TEMPLATE = PROMPT_ENVIRONMENT.from_string(
120
+ SUMMARIZE_PROMPT_CONVERSATIONAL
121
+ )
122
+
123
+
124
+ @agent(
125
+ id="summarize",
126
+ agent_type="generation",
127
+ title="Summarize",
128
+ description="Summarize the provided context.",
129
+ config_schema=SummarizeAgentConfig,
130
+ )
131
+ class SummarizeAgent(Agent[SummarizeAgentConfig]):
132
+ __root_agent__ = True
133
+ config: SummarizeAgentConfig
134
+
135
+ @overload
136
+ async def __call__(
137
+ self,
138
+ memory: QuestionMemory,
139
+ manager: Manager,
140
+ tools: None = None,
141
+ ) -> None: ...
142
+
143
+ @overload
144
+ async def __call__(
145
+ self,
146
+ memory: QuestionMemory,
147
+ manager: Manager,
148
+ tools: list[Tool],
149
+ ) -> None | dict[str, list[ToolCall]]: ...
150
+
151
+ @trace_agent
152
+ async def __call__(
153
+ self,
154
+ memory: QuestionMemory,
155
+ manager: Manager,
156
+ tools: list[Tool] | None = None,
157
+ ) -> None | dict[str, list[ToolCall]]:
158
+ citations_enabled = self.config.citations
159
+
160
+ # For each context in memory add context query, summary and answer onto a text and the initial question
161
+ # In cases when the original question has been rephrased, use the rephrased question
162
+ questions = memory.get_questions()
163
+ if len(questions) == 1:
164
+ question = questions[0][1]
165
+ else:
166
+ question = memory.original_question
167
+
168
+ session_context_parts: List[str] = []
169
+
170
+ if self.config.history:
171
+ qa_history, interactions = await memory.context_history()
172
+ await memory.add_step(
173
+ step_module=self.config.module,
174
+ step_title=self.step_title("History check"),
175
+ step_value="Included {} interactions of Q&A history".format(
176
+ interactions
177
+ ),
178
+ step_reason="",
179
+ timeit=0,
180
+ step_agent_path=f"/context/{self.config.id if self.config.id else 'default'}",
181
+ input_nuclia_tokens=0.0,
182
+ output_nuclia_tokens=0.0,
183
+ )
184
+ session_context_parts.append(
185
+ f"## Previous questions and answers in this session:\n{qa_history}"
186
+ )
187
+
188
+ session_context = "\n\n".join(session_context_parts)
189
+
190
+ PROMPT_TEMPLATE = (
191
+ SUMMARIZE_PROMPT_CONVERSATIONAL_TEMPLATE
192
+ if self.config.conversational
193
+ else SUMMARIZE_PROMPT_TEMPLATE
194
+ )
195
+ prompt = self.config.prompt
196
+ extra_prompts: List[str] = []
197
+ if self.config.include_mcp_prompts:
198
+ extra_prompts = memory.get_prompt_texts()
199
+
200
+ if citations_enabled:
201
+ # Add citation ids to each context so they can be referenced in the answer
202
+ for index, context in enumerate(memory.contexts):
203
+ context.citations_id = generate_ctx_block_id(index)
204
+
205
+ prompt = PROMPT_TEMPLATE.render(
206
+ question=question,
207
+ context=memory.contexts_markdown()
208
+ if citations_enabled and self.config.force_chunk_level_citations
209
+ else memory.contexts_minimal(),
210
+ prompt=prompt,
211
+ extra_prompts=extra_prompts,
212
+ rules=self.config.rules,
213
+ chat_history=session_context,
214
+ )
215
+
216
+ if citations_enabled:
217
+ # Adjust the prompt so that the model returns citations
218
+ prompt += MARKDOWN_TWO_LEVELS_CITATIONS_PROMPT_ADJUSTMENT
219
+
220
+ t0 = time()
221
+ images = {}
222
+ for memory_context in memory.contexts:
223
+ if memory_context.images:
224
+ images.update(memory_context.images)
225
+ chat_model = ChatModel(
226
+ user_id="summarize",
227
+ question="",
228
+ user_prompt=UserPrompt(prompt=prompt),
229
+ system=self.config.system_prompt
230
+ if self.config.system_prompt
231
+ else DEFAULT_SYSTEM_PROMPT,
232
+ format_prompt=False,
233
+ generative_model=self.config.model,
234
+ query_context_images=images,
235
+ max_tokens=5000,
236
+ chat_history=await memory.get_chat_history(),
237
+ tools=tools if tools else [],
238
+ tool_choice=ToolChoiceAuto(),
239
+ )
240
+
241
+ agent_path = f"/generation/{self.config.id if self.config.id else 'default'}"
242
+ # Pass memory so execute_raw streams automatically when memory.streaming is True.
243
+ # Streaming is skipped when tools are active (tool calls are not streamable).
244
+ streaming_memory = memory if not tools else None
245
+ resp, input_tokens, output_tokens = await manager.execute_raw(
246
+ chat_model,
247
+ memory=streaming_memory,
248
+ module="summarize",
249
+ agent_path=agent_path,
250
+ tracking=memory.get_tracking_info(),
251
+ )
252
+ answer = resp.answer
253
+ end_code = resp.code
254
+
255
+ # Fall back to original question and full contexts
256
+ if end_code == "-2": # indicates not enough data
257
+ prompt = PROMPT_TEMPLATE.render(
258
+ question=memory.original_question,
259
+ context=memory.contexts_markdown(),
260
+ prompt=prompt,
261
+ extra_prompts=extra_prompts,
262
+ rules=self.config.rules,
263
+ )
264
+ if citations_enabled:
265
+ # Adjust the prompt so that the model returns citations
266
+ prompt += MARKDOWN_TWO_LEVELS_CITATIONS_PROMPT_ADJUSTMENT
267
+
268
+ chat_model.user_prompt = UserPrompt(prompt=prompt)
269
+ resp, input_tokens, output_tokens = await manager.execute_raw(
270
+ chat_model,
271
+ memory=streaming_memory,
272
+ module="summarize",
273
+ agent_path=agent_path,
274
+ tracking=memory.get_tracking_info(),
275
+ )
276
+ answer = resp.answer
277
+ end_code = resp.code
278
+
279
+ if not resp.tools or answer:
280
+ # Only add answer if not a tool call or if answer is present (maybe some models return both)
281
+ await memory.add_answer(
282
+ answer,
283
+ module="summarize",
284
+ agent_path=f"/generation/{self.config.id if self.config.id else 'default'}",
285
+ citations=build_answer_citations(answer, memory.contexts)
286
+ if citations_enabled
287
+ else None,
288
+ )
289
+ memory.is_answered = end_code != "-2"
290
+ await memory.add_step(
291
+ step_module=self.config.module,
292
+ step_title=self.step_title("Summarize"),
293
+ step_value=str(answer),
294
+ step_reason="Summarized",
295
+ step_agent_path=f"/generation/{self.config.id if self.config.id else 'default'}",
296
+ timeit=time() - t0,
297
+ input_nuclia_tokens=input_tokens,
298
+ output_nuclia_tokens=output_tokens,
299
+ )
300
+ return resp.tools
301
+
302
+
303
+ def build_answer_citations(answer: str, contexts: list[Context]) -> AnswerCitations:
304
+ result = AnswerCitations()
305
+ # Build a map of citation_id to context
306
+ citation_map = {
307
+ context.citations_id: context
308
+ for context in contexts
309
+ if context.citations_id is not None
310
+ }
311
+
312
+ # Parse citations in the answer
313
+ citations_in_answer: set[str] = set()
314
+ for line in answer.splitlines():
315
+ if line.startswith("[") and "]: block-" in line:
316
+ citation_id = line.split("]: ")[1].strip()
317
+ citations_in_answer.add(citation_id)
318
+
319
+ for citation_id in citations_in_answer:
320
+ try:
321
+ context_citation_id, chunk_index = _parse_citation_id(citation_id)
322
+ except ValueError:
323
+ # Unknown format, skip
324
+ continue
325
+
326
+ if context_citation_id not in citation_map:
327
+ # Unknown citation, skip
328
+ continue
329
+
330
+ context: Context = citation_map[context_citation_id]
331
+ origin_urls: list[str] = []
332
+
333
+ if chunk_index is not None:
334
+ # This is a citation to a specific chunk of a context
335
+ try:
336
+ chunk = context.chunks[chunk_index]
337
+ if chunk.origin_url:
338
+ origin_urls.append(chunk.origin_url)
339
+ except IndexError:
340
+ # Chunk index is out of range, skip
341
+ pass
342
+
343
+ else:
344
+ # This is a citation to a summarized context
345
+ for chunk in context.chunks:
346
+ if (
347
+ context.citations is None or chunk.chunk_id in context.citations
348
+ ) and chunk.origin_url:
349
+ origin_urls.append(chunk.origin_url)
350
+
351
+ result.metadata[citation_id] = CitationMetadata(
352
+ context_id=context.id,
353
+ origin_urls=origin_urls,
354
+ chunk_index=chunk_index,
355
+ )
356
+
357
+ return result
358
+
359
+
360
+ def _parse_citation_id(citation_id: str) -> tuple[str, Optional[int]]:
361
+ """Parse a citation id into context citation id and optional chunk index.
362
+
363
+ Examples:
364
+ - "block-abc123" -> ("block-abc123", None)
365
+ - "block-abc123-0" -> ("block-abc123", 0)
366
+ """
367
+ if citation_id.count("-") >= 2:
368
+ # Assume the last part is the chunk index
369
+ parts = citation_id.rsplit("-", 1)
370
+ context_citation_id = parts[0]
371
+ try:
372
+ chunk_index = int(parts[1])
373
+ return context_citation_id, chunk_index
374
+ except ValueError:
375
+ raise ValueError("Invalid citation id format")
376
+ else:
377
+ return citation_id, None
@@ -0,0 +1,52 @@
1
+ from typing import Literal, Optional
2
+
3
+ from hyperforge.agent import AgentConfig
4
+ from hyperforge.utils import WidgetType
5
+ from pydantic import Field
6
+ from pydantic.config import ConfigDict
7
+
8
+
9
+ class SummarizeAgentConfig(AgentConfig):
10
+ model_config = ConfigDict(title="Summarize")
11
+ module: Literal["summarize"] = "summarize"
12
+ system_prompt: Optional[str] = Field(
13
+ default=None,
14
+ title="System prompt",
15
+ description="System prompt to guide the model's behavior and response style",
16
+ json_schema_extra={
17
+ "show_in_node": True,
18
+ "widget": WidgetType.EXPANDABLE_TEXTAREA,
19
+ },
20
+ )
21
+ prompt: Optional[str] = Field(
22
+ default=None,
23
+ json_schema_extra={
24
+ "show_in_node": True,
25
+ "widget": WidgetType.EXPANDABLE_TEXTAREA,
26
+ },
27
+ )
28
+ model: str = Field(
29
+ default="chatgpt-azure-4o-mini",
30
+ title="Generative model",
31
+ description="Model used to generate the response",
32
+ json_schema_extra={"widget": WidgetType.MODEL_SELECT},
33
+ )
34
+ images: bool = False
35
+ conversational: bool = False
36
+ include_mcp_prompts: bool = Field(
37
+ default=False,
38
+ title="If MCP prompts were used during the context steps, include them in the prompt to generate the final answer",
39
+ )
40
+ citations: bool = Field(
41
+ default=False,
42
+ title="Whether to include markdown citations in the generated answer.",
43
+ )
44
+ force_chunk_level_citations: bool = Field(
45
+ default=False,
46
+ title="Whether to always use chunk-level citations instead of context-level citations.",
47
+ )
48
+ history: bool = Field(
49
+ default=False,
50
+ title="Session history",
51
+ description="Include previous Q&A history from the current session in the context provided to the summarize agent",
52
+ )
@@ -0,0 +1,39 @@
1
+ MARKDOWN_TWO_LEVELS_CITATIONS_PROMPT_ADJUSTMENT = """
2
+ You are given source blocks with IDs like: block-AB, block-BA, block-CD, etc. or block-AB-1, block-BA-2, block-CD-3, etc.
3
+ When producing an answer, cite these sources precisely using markdown footnotes.
4
+
5
+ CITATION RULES
6
+
7
+ 1. In the main body, cite sources only with bracketed Arabic numerals: [1], [2], [3], etc.
8
+ - Never put a block ID directly in brackets (e.g. NO: [AB], [block-AB], [BA]).
9
+ - Never mix styles (no superscripts, no inline block names, no [Ref 1], etc.).
10
+ 2. Numbering is assigned in order of FIRST USE of a unique block ID.
11
+ - The first time you need info from a block, assign it [1].
12
+ - The first time you need info from a never-before-used block, assign it the next unused number (e.g. [2]).
13
+ - If you later cite the SAME block again, REUSE its existing number (do NOT create a new one).
14
+ - This guarantees there are no duplicate footnote definitions and no gaps.
15
+ 3. If facts in a sentence come from different blocks, you may concatenate citations WITH spaces: like [1] [3]. Do NOT merge them (no ranges like [1-3]) or output them without spaces (no [1][3]).
16
+ 4. At the end, output section consisting ONLY of the unique citation mappings, one per line, in ascending numeric order
17
+ - Don't title this section or add any extra text, just the mappings.
18
+ - No duplicates.
19
+ - No skipped numbers.
20
+ - ONLY include blocks actually cited in the body.
21
+ 5. Do NOT hallucinate block IDs. Only use those provided in the context.
22
+
23
+ FORMATTING CONTRACT
24
+
25
+ * Body: free text with numeric citations as specified.
26
+ * A blank line.
27
+ * References section (if any) exactly as described, no heading.
28
+
29
+
30
+ Example format:
31
+
32
+ ---
33
+ "The OP-1 has a built-in tape feature with 6 minutes of recording time [1] [2]. You can record to any of the 4 individual tracks [1]."
34
+
35
+ [1]: block-AB
36
+ [2]: block-FZ-2
37
+ ---
38
+
39
+ """
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: hyperforge_summarize
3
+ Version: 1.0.0.post21
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
+
19
+ # Summarization Hyperforge agents
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/hyperforge_summarize/__init__.py
4
+ src/hyperforge_summarize/agent.py
5
+ src/hyperforge_summarize/config.py
6
+ src/hyperforge_summarize/prompts.py
7
+ src/hyperforge_summarize.egg-info/PKG-INFO
8
+ src/hyperforge_summarize.egg-info/SOURCES.txt
9
+ src/hyperforge_summarize.egg-info/dependency_links.txt
10
+ src/hyperforge_summarize.egg-info/requires.txt
11
+ src/hyperforge_summarize.egg-info/top_level.txt
12
+ tests/test_summarize.py
@@ -0,0 +1,414 @@
1
+ import os
2
+ from copy import deepcopy
3
+
4
+ import pytest
5
+ from hyperforge.engine import main as arag_main
6
+ from hyperforge.minimal_fixtures import cassette_nua_key
7
+
8
+ # Real key used when recording; the stub is sufficient for cassette replay.
9
+ NUA_KEY = os.environ.get(
10
+ "NUA_KEY",
11
+ ) or cassette_nua_key("https://europe-1.nuclia.cloud/")
12
+
13
+ pytestmark = [pytest.mark.vcr(ignore_localhost=True), pytest.mark.asyncio]
14
+
15
+ DE48CFAA_3209_4041_BB64_8604AFF061FB = os.environ.get(
16
+ "KB_DE48CFAA_3209_4041_BB64_8604AFF061FB"
17
+ ) or cassette_nua_key("https://europe-1.nuclia.cloud/")
18
+
19
+ DF8B4C24_2807_4888_AD6C_AE97357A638B = os.environ.get(
20
+ "KB_DF8B4C24_2807_4888_AD6C_AE97357A638B"
21
+ ) or cassette_nua_key("https://europe-1.nuclia.cloud/")
22
+
23
+ CONFIG = {
24
+ "drivers": [
25
+ {
26
+ "name": "nuclia-conversation",
27
+ "provider": "nucliadb",
28
+ "identifier": "nucliadb-1",
29
+ "config": {
30
+ "url": "https://europe-1.stashify.cloud/api",
31
+ "manager": "https://europe-1.stashify.cloud/api",
32
+ "kbid": "de48cfaa-3209-4041-bb64-8604aff061FB",
33
+ "key": DE48CFAA_3209_4041_BB64_8604AFF061FB,
34
+ "filters": [],
35
+ "description": "Make Discourse Conversation",
36
+ },
37
+ },
38
+ {
39
+ "name": "nuclia-docs",
40
+ "provider": "nucliadb",
41
+ "identifier": "nucliadb-2",
42
+ "config": {
43
+ "identifier": "nucliadb-2",
44
+ "url": "https://europe-1.nuclia.cloud/api",
45
+ "manager": "https://europe-1.nuclia.cloud/api",
46
+ "kbid": "df8b4c24-2807-4888-ad6c-ae97357a638b",
47
+ "key": DF8B4C24_2807_4888_AD6C_AE97357A638B,
48
+ "filters": [],
49
+ "description": "Documentation of the Nuclia API, recipies, reference",
50
+ },
51
+ },
52
+ ],
53
+ "rules": {
54
+ "rules": [
55
+ {"prompt": "Be polite"},
56
+ {
57
+ "prompt": "The documentation of Nuclia is hosted at https://docs.nuclia.dev"
58
+ },
59
+ ]
60
+ },
61
+ "memory": {},
62
+ "workflow": {
63
+ "id": "default",
64
+ "name": "Default workflow",
65
+ "description": "Default workflow for testing",
66
+ "parameters": {},
67
+ },
68
+ "preprocess": [],
69
+ "context": [],
70
+ "generation": [],
71
+ "postprocess": [],
72
+ }
73
+
74
+
75
+ async def test_summarize_answers():
76
+ config = deepcopy(CONFIG)
77
+ config["context"] = [
78
+ {
79
+ "module": "ask",
80
+ "title": "",
81
+ "sources": ["nucliadb-2"],
82
+ "ai_parameter_search": False,
83
+ }
84
+ ]
85
+ config["generation"] = [
86
+ {
87
+ "module": "summarize",
88
+ "conversational": False,
89
+ }
90
+ ]
91
+
92
+ question_memory = await arag_main(
93
+ agent_id="default",
94
+ internal_nua=False,
95
+ external_nua_api_key=NUA_KEY,
96
+ question="Como usar max_magic y dime como cambiará este parametro en el futuro",
97
+ config=config,
98
+ loaded_modules=[
99
+ "hyperforge_summarize",
100
+ "hyperforge_nucliadb",
101
+ "hyperforge_rephrase",
102
+ ],
103
+ )
104
+ assert "not enough data to answer this" in question_memory.final_answer.lower()
105
+ config["generation"][0]["conversational"] = True # type: ignore
106
+ question_memory = await arag_main(
107
+ agent_id="default",
108
+ internal_nua=False,
109
+ external_nua_api_key=NUA_KEY,
110
+ question="Como usar max_magic y dime como cambiará este parametro en el futuro",
111
+ config=config,
112
+ loaded_modules=[
113
+ "hyperforge_summarize",
114
+ "hyperforge_nucliadb",
115
+ "hyperforge_rephrase",
116
+ ],
117
+ )
118
+ assert "not enough data to answer this" not in question_memory.final_answer.lower()
119
+
120
+
121
+ async def test_summarize_tokens():
122
+ config = deepcopy(CONFIG)
123
+
124
+ config["context"] = [
125
+ {
126
+ "module": "ask",
127
+ "title": "",
128
+ "sources": ["nucliadb-2"],
129
+ "ai_parameter_search": False,
130
+ },
131
+ ]
132
+ config["generation"] = [
133
+ {
134
+ "module": "summarize",
135
+ "conversational": False,
136
+ "model": "claude-4-5-haiku",
137
+ }
138
+ ]
139
+ question_memory = await arag_main(
140
+ agent_id="default",
141
+ internal_nua=False,
142
+ external_nua_api_key=NUA_KEY,
143
+ question="Como usar max_tokens",
144
+ config=config,
145
+ loaded_modules=[
146
+ "hyperforge_summarize",
147
+ "hyperforge_nucliadb",
148
+ "hyperforge_rephrase",
149
+ ],
150
+ )
151
+
152
+ summarize_step = question_memory.steps[-1]
153
+ assert summarize_step.input_nuclia_tokens >= 0 # type: ignore
154
+ assert summarize_step.output_nuclia_tokens >= 0 # type: ignore
155
+
156
+
157
+ async def test_summarize_answers_with_citations():
158
+ config = deepcopy(CONFIG)
159
+
160
+ config["context"] = [
161
+ {
162
+ "module": "ask",
163
+ "title": "",
164
+ "sources": ["nucliadb-2"],
165
+ "ai_parameter_search": False,
166
+ },
167
+ ]
168
+ config["generation"] = [
169
+ {"module": "summarize", "conversational": True, "citations": True},
170
+ ]
171
+ question_memory = await arag_main(
172
+ agent_id="default",
173
+ internal_nua=False,
174
+ external_nua_api_key=NUA_KEY,
175
+ question="Como usar max_tokens.magic y dime como cambiará este parametro en el futuro",
176
+ config=config,
177
+ loaded_modules=[
178
+ "hyperforge_summarize",
179
+ "hyperforge_nucliadb",
180
+ "hyperforge_rephrase",
181
+ ],
182
+ )
183
+ assert "not enough data to answer this" not in question_memory.final_answer.lower()
184
+ # Check that we have citations
185
+ assert question_memory.final_answer_citations is not None
186
+ assert question_memory.final_answer_citations.metadata != {}
187
+
188
+ # Check that we can refer back to the context using the citation metadata
189
+ context_id = question_memory.final_answer_citations.metadata["block-AA"].context_id
190
+ context = next(
191
+ (c for c in question_memory.contexts if c.id == context_id),
192
+ None,
193
+ )
194
+ assert context is not None
195
+ assert context.chunks != []
196
+
197
+
198
+ async def test_summarize_answers_force_chunk_level_citations():
199
+ config = deepcopy(CONFIG)
200
+ config["context"] = [
201
+ {
202
+ "module": "ask",
203
+ "title": "",
204
+ "sources": ["nucliadb-2"],
205
+ "ai_parameter_search": False,
206
+ },
207
+ ]
208
+ config["generation"] = [
209
+ {
210
+ "module": "summarize",
211
+ "conversational": True,
212
+ "citations": True,
213
+ "force_chunk_level_citations": True,
214
+ },
215
+ ]
216
+ question_memory = await arag_main(
217
+ agent_id="default",
218
+ internal_nua=False,
219
+ external_nua_api_key=NUA_KEY,
220
+ question="Como usar max_tokens.magic y dime como cambiará este parametro en el futuro",
221
+ config=config,
222
+ loaded_modules=[
223
+ "hyperforge_summarize",
224
+ "hyperforge_nucliadb",
225
+ "hyperforge_rephrase",
226
+ ],
227
+ )
228
+
229
+ assert "not enough data to answer this" not in question_memory.final_answer.lower()
230
+ # Check that we have citations
231
+ assert question_memory.final_answer_citations is not None
232
+ assert question_memory.final_answer_citations.metadata != {}
233
+
234
+ # Check that we can refer back to the cited chunks using the citation metadata
235
+ for chunk_citation, chunk_index in [
236
+ ("block-AA-0", 0),
237
+ ("block-AA-1", 1),
238
+ ]:
239
+ assert (
240
+ question_memory.final_answer_citations.metadata[chunk_citation].chunk_index
241
+ == chunk_index
242
+ )
243
+ context_id = question_memory.final_answer_citations.metadata[
244
+ chunk_citation
245
+ ].context_id
246
+ context = next(
247
+ (c for c in question_memory.contexts if c.id == context_id),
248
+ None,
249
+ )
250
+ assert context is not None
251
+ chunk = context.chunks[chunk_index]
252
+ assert chunk.text is not None
253
+
254
+
255
+ async def test_summarize_with_funny_system_prompt():
256
+ """Test that system prompt can make responses fun and engaging with emojis."""
257
+ config = deepcopy(CONFIG)
258
+ config["context"] = [
259
+ {
260
+ "module": "ask",
261
+ "title": "",
262
+ "sources": ["nucliadb-2"],
263
+ "ai_parameter_search": False,
264
+ },
265
+ ]
266
+
267
+ # Test with a custom system prompt that makes responses funny and uses emojis
268
+ funny_system_prompt = """You are a cheerful and enthusiastic technical assistant who loves making learning fun! 🎉
269
+
270
+ Your style:
271
+ - Use emojis liberally to make your explanations more engaging and visual 😊
272
+ - Make technical concepts feel approachable with friendly language
273
+ - Add humor where appropriate, but stay accurate and helpful
274
+ - Use analogies and fun comparisons to explain complex ideas
275
+ - Celebrate the user's curiosity and questions! 🌟
276
+
277
+ Guidelines:
278
+ - Sprinkle relevant emojis throughout your answers (but don't overdo it)
279
+ - Keep the friendly, upbeat tone while remaining professional
280
+ - Make technical topics feel less intimidating
281
+ - Use fun phrases and expressions to keep things lively
282
+
283
+ Remember: You're here to make learning enjoyable while providing accurate technical information! 🚀
284
+ If you can't answer, just say that they can always ask the Oracle of Nuclia for more wisdom! 🧙‍♂️"""
285
+
286
+ config["generation"] = [
287
+ {
288
+ "module": "summarize",
289
+ "conversational": True,
290
+ "system_prompt": funny_system_prompt,
291
+ },
292
+ ]
293
+
294
+ question_memory = await arag_main(
295
+ agent_id="default",
296
+ internal_nua=False,
297
+ external_nua_api_key=NUA_KEY,
298
+ question="How do I use max_tokens parameter?",
299
+ config=config,
300
+ loaded_modules=[
301
+ "hyperforge_summarize",
302
+ "hyperforge_nucliadb",
303
+ "hyperforge_rephrase",
304
+ ],
305
+ )
306
+
307
+ assert question_memory.final_answer
308
+ assert len(question_memory.final_answer) > 0
309
+ # Verify that system prompt influenced the response - should contain emojis
310
+ has_emoji = any(
311
+ emoji in question_memory.final_answer
312
+ for emoji in ["😊", "🎉", "🚀", "✨", "💡", "👍", "🌟", "📝", "⚡", "🔧", "💻"]
313
+ )
314
+ assert has_emoji, "Response should contain emojis as per the funny system prompt"
315
+
316
+ # Test with a question about unknown/future information
317
+ question_memory_unknown = await arag_main(
318
+ agent_id="default",
319
+ internal_nua=False,
320
+ external_nua_api_key=NUA_KEY,
321
+ question="What are Nuclia's plans for 2027 and beyond?",
322
+ config=config,
323
+ loaded_modules=[
324
+ "hyperforge_summarize",
325
+ "hyperforge_nucliadb",
326
+ "hyperforge_rephrase",
327
+ ],
328
+ )
329
+
330
+ # Verify that it mentions the Oracle of Nuclia and still contains emojis to maintain the fun tone, even when it can't provide a concrete answer about the future
331
+ assert question_memory_unknown.final_answer
332
+ assert len(question_memory_unknown.final_answer) > 0
333
+ has_emoji_unknown = any(
334
+ emoji in question_memory_unknown.final_answer
335
+ for emoji in [
336
+ "😊",
337
+ "🎉",
338
+ "🚀",
339
+ "✨",
340
+ "💡",
341
+ "👍",
342
+ "🌟",
343
+ "📝",
344
+ "⚡",
345
+ "🔧",
346
+ "💻",
347
+ "🧙",
348
+ "🔮",
349
+ ]
350
+ )
351
+ assert has_emoji_unknown, (
352
+ "Response should maintain fun tone with emojis even when information is unavailable"
353
+ )
354
+ assert "oracle of nuclia" in question_memory_unknown.final_answer.lower()
355
+
356
+
357
+ async def test_summarize_streaming():
358
+ """Test that streaming mode produces the same quality answer as non-streaming."""
359
+ config = deepcopy(CONFIG)
360
+ config["context"] = [
361
+ {
362
+ "module": "ask",
363
+ "title": "",
364
+ "sources": ["nucliadb-2"],
365
+ "ai_parameter_search": False,
366
+ }
367
+ ]
368
+ config["generation"] = [
369
+ {
370
+ "module": "summarize",
371
+ "conversational": True,
372
+ }
373
+ ]
374
+
375
+ # Collect streamed chunks via callback
376
+ streamed_chunks: list[str] = []
377
+
378
+ async def capture_callback(answer):
379
+ from hyperforge.interaction import AnswerOperation
380
+
381
+ if (
382
+ answer.operation == AnswerOperation.ANSWER_CHUNK
383
+ and answer.streaming_response_chunk
384
+ and answer.streaming_response_chunk.text
385
+ ):
386
+ streamed_chunks.append(answer.streaming_response_chunk.text)
387
+
388
+ question_memory = await arag_main(
389
+ agent_id="default",
390
+ internal_nua=False,
391
+ external_nua_api_key=NUA_KEY,
392
+ question="Como usar max_tokens",
393
+ config=config,
394
+ callback=capture_callback,
395
+ streaming=True,
396
+ loaded_modules=[
397
+ "hyperforge_summarize",
398
+ "hyperforge_nucliadb",
399
+ "hyperforge_rephrase",
400
+ ],
401
+ )
402
+
403
+ # Should have a final answer
404
+ assert question_memory.final_answer is not None
405
+ assert len(question_memory.final_answer) > 0
406
+
407
+ # Streamed chunks should reassemble to the final answer
408
+ reassembled = "".join(streamed_chunks)
409
+ assert reassembled == question_memory.final_answer
410
+
411
+ # Step should have token consumption recorded
412
+ summarize_step = question_memory.steps[-1]
413
+ assert summarize_step.input_nuclia_tokens >= 0 # type: ignore
414
+ assert summarize_step.output_nuclia_tokens >= 0 # type: ignore