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.
- hyperforge_summarize-1.0.0.post21/PKG-INFO +19 -0
- hyperforge_summarize-1.0.0.post21/README.md +1 -0
- hyperforge_summarize-1.0.0.post21/pyproject.toml +41 -0
- hyperforge_summarize-1.0.0.post21/setup.cfg +4 -0
- hyperforge_summarize-1.0.0.post21/src/hyperforge_summarize/__init__.py +5 -0
- hyperforge_summarize-1.0.0.post21/src/hyperforge_summarize/agent.py +377 -0
- hyperforge_summarize-1.0.0.post21/src/hyperforge_summarize/config.py +52 -0
- hyperforge_summarize-1.0.0.post21/src/hyperforge_summarize/prompts.py +39 -0
- hyperforge_summarize-1.0.0.post21/src/hyperforge_summarize.egg-info/PKG-INFO +19 -0
- hyperforge_summarize-1.0.0.post21/src/hyperforge_summarize.egg-info/SOURCES.txt +12 -0
- hyperforge_summarize-1.0.0.post21/src/hyperforge_summarize.egg-info/dependency_links.txt +1 -0
- hyperforge_summarize-1.0.0.post21/src/hyperforge_summarize.egg-info/requires.txt +1 -0
- hyperforge_summarize-1.0.0.post21/src/hyperforge_summarize.egg-info/top_level.txt +1 -0
- hyperforge_summarize-1.0.0.post21/tests/test_summarize.py +414 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hyperforge
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hyperforge_summarize
|
|
@@ -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
|