nucliadb-agentic-api 1.0.0.post156__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.
- nucliadb_agentic_api/VERSION +1 -0
- nucliadb_agentic_api/__init__.py +20 -0
- nucliadb_agentic_api/agentic/__init__.py +0 -0
- nucliadb_agentic_api/agentic/ask_handler.py +66 -0
- nucliadb_agentic_api/agentic/ask_result.py +389 -0
- nucliadb_agentic_api/agentic/ask_transform_to_interaction.py +10 -0
- nucliadb_agentic_api/app.py +147 -0
- nucliadb_agentic_api/commands.py +66 -0
- nucliadb_agentic_api/db/__init__.py +0 -0
- nucliadb_agentic_api/db/agentic_configs.py +296 -0
- nucliadb_agentic_api/db/settings.py +11 -0
- nucliadb_agentic_api/db/sources.py +202 -0
- nucliadb_agentic_api/db/transform.py +208 -0
- nucliadb_agentic_api/exceptions.py +10 -0
- nucliadb_agentic_api/logging.py +18 -0
- nucliadb_agentic_api/models.py +321 -0
- nucliadb_agentic_api/py.typed +0 -0
- nucliadb_agentic_api/server/__init__.py +1 -0
- nucliadb_agentic_api/server/server.py +113 -0
- nucliadb_agentic_api/server/session.py +276 -0
- nucliadb_agentic_api/server/settings.py +22 -0
- nucliadb_agentic_api/settings.py +39 -0
- nucliadb_agentic_api/utils.py +284 -0
- nucliadb_agentic_api/v1/__init__.py +19 -0
- nucliadb_agentic_api/v1/agentic_configs.py +117 -0
- nucliadb_agentic_api/v1/ask.py +303 -0
- nucliadb_agentic_api/v1/ask_websocket.py +141 -0
- nucliadb_agentic_api/v1/mcp_content.py +310 -0
- nucliadb_agentic_api/v1/mcp_nucliadb.py +785 -0
- nucliadb_agentic_api/v1/oauth.py +60 -0
- nucliadb_agentic_api/v1/router.py +3 -0
- nucliadb_agentic_api/v1/sources.py +158 -0
- nucliadb_agentic_api/v1/utils.py +12 -0
- nucliadb_agentic_api-1.0.0.post156.dist-info/METADATA +150 -0
- nucliadb_agentic_api-1.0.0.post156.dist-info/RECORD +37 -0
- nucliadb_agentic_api-1.0.0.post156.dist-info/WHEEL +4 -0
- nucliadb_agentic_api-1.0.0.post156.dist-info/entry_points.txt +6 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.0.0
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
logger = logging.getLogger("nucliadb_agentic_api")
|
|
4
|
+
|
|
5
|
+
SERVICE_NAME = "nucliadb_agentic_api"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Define the filter
|
|
9
|
+
class EndpointFilter(logging.Filter):
|
|
10
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
11
|
+
return (
|
|
12
|
+
record.args is not None
|
|
13
|
+
and len(record.args) >= 3
|
|
14
|
+
and record.args[2] # type: ignore
|
|
15
|
+
not in ("/", "/metrics", "/health/alive", "/health/ready")
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Add filter to the logger
|
|
20
|
+
logging.getLogger("uvicorn.access").addFilter(EndpointFilter())
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
from fastapi import Response
|
|
4
|
+
from hyperforge_nucliadb_agentic.ask.model import (
|
|
5
|
+
AskRequest,
|
|
6
|
+
parse_max_tokens,
|
|
7
|
+
)
|
|
8
|
+
from hyperforge_nucliadb_agentic.ask.search.ask import (
|
|
9
|
+
handled_ask_exceptions,
|
|
10
|
+
)
|
|
11
|
+
from nucliadb_models.search import (
|
|
12
|
+
NucliaDBClientType,
|
|
13
|
+
)
|
|
14
|
+
from starlette.responses import StreamingResponse
|
|
15
|
+
|
|
16
|
+
from nucliadb_agentic_api.agentic.ask_result import AgenticAskResult
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from nucliadb_agentic_api.app import HTTPApplication
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@handled_ask_exceptions
|
|
23
|
+
async def create_agentic_response(
|
|
24
|
+
app: "HTTPApplication",
|
|
25
|
+
kbid: str,
|
|
26
|
+
account: str,
|
|
27
|
+
ask_request: AskRequest,
|
|
28
|
+
agentic_config_id: str,
|
|
29
|
+
user_id: str,
|
|
30
|
+
client_type: NucliaDBClientType,
|
|
31
|
+
origin: str,
|
|
32
|
+
x_synchronous: bool,
|
|
33
|
+
resource: str | None = None,
|
|
34
|
+
extra_predict_headers: dict[str, str] | None = None,
|
|
35
|
+
) -> Response:
|
|
36
|
+
ask_request.max_tokens = parse_max_tokens(ask_request.max_tokens)
|
|
37
|
+
ask_result = AgenticAskResult(
|
|
38
|
+
app=app,
|
|
39
|
+
kbid=kbid,
|
|
40
|
+
ask_request=ask_request,
|
|
41
|
+
agentic_config_id=agentic_config_id,
|
|
42
|
+
account=account,
|
|
43
|
+
origin=origin,
|
|
44
|
+
resource=resource,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
nuclia_learning_id = await ask_result.start()
|
|
48
|
+
|
|
49
|
+
headers = {
|
|
50
|
+
"NUCLIA-LEARNING-ID": nuclia_learning_id or "unknown",
|
|
51
|
+
"Access-Control-Expose-Headers": "NUCLIA-LEARNING-ID",
|
|
52
|
+
}
|
|
53
|
+
if x_synchronous:
|
|
54
|
+
return Response(
|
|
55
|
+
content=await ask_result.json(),
|
|
56
|
+
status_code=200,
|
|
57
|
+
headers=headers,
|
|
58
|
+
media_type="application/json",
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
return StreamingResponse(
|
|
62
|
+
content=ask_result.ndjson_stream(),
|
|
63
|
+
status_code=200,
|
|
64
|
+
headers=headers,
|
|
65
|
+
media_type="application/x-ndjson",
|
|
66
|
+
)
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from asyncio import Event, Queue, Task, create_task
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from hyperforge.api.v1.interaction import stream_response
|
|
8
|
+
from hyperforge.interaction import AnswerOperation, AragAnswer
|
|
9
|
+
from hyperforge_nucliadb_agentic.agent import JSON_OBJECT_ID
|
|
10
|
+
from hyperforge_nucliadb_agentic.ask.model import (
|
|
11
|
+
AnswerAskResponseItem,
|
|
12
|
+
AskRequest,
|
|
13
|
+
AskResponseItemType,
|
|
14
|
+
AskRetrievalMatch,
|
|
15
|
+
AskTimings,
|
|
16
|
+
AskTokens,
|
|
17
|
+
CitationsAskResponseItem,
|
|
18
|
+
ConsumptionResponseItem,
|
|
19
|
+
FootnoteCitationsAskResponseItem,
|
|
20
|
+
JSONAskResponseItem,
|
|
21
|
+
MetadataAskResponseItem,
|
|
22
|
+
ReasoningAskResponseItem,
|
|
23
|
+
RelationsAskResponseItem,
|
|
24
|
+
RetrievalAskResponseItem,
|
|
25
|
+
StatusAskResponseItem,
|
|
26
|
+
TokensDetail,
|
|
27
|
+
)
|
|
28
|
+
from hyperforge_nucliadb_agentic.ask.predict import AnswerStatusCode
|
|
29
|
+
from hyperforge_nucliadb_agentic.ask.search.ask import AskResult, RetrievalMatch
|
|
30
|
+
from hyperforge_nucliadb_agentic.ask.search.metrics import (
|
|
31
|
+
AskMetrics,
|
|
32
|
+
)
|
|
33
|
+
from nuclia_models.common.consumption import (
|
|
34
|
+
Consumption,
|
|
35
|
+
TokensDetail as ConsumptionTokensDetail,
|
|
36
|
+
)
|
|
37
|
+
from nuclia_models.predict.generative_responses import (
|
|
38
|
+
CitationsGenerativeResponse,
|
|
39
|
+
FootnoteCitationsGenerativeResponse,
|
|
40
|
+
JSONGenerativeResponse,
|
|
41
|
+
MetaGenerativeResponse,
|
|
42
|
+
ReasoningGenerativeResponse,
|
|
43
|
+
StatusGenerativeResponse,
|
|
44
|
+
TextGenerativeResponse,
|
|
45
|
+
)
|
|
46
|
+
from nucliadb_models.search import KnowledgeboxFindResults, Relations
|
|
47
|
+
from nucliadb_sdk.v2.exceptions import UnprocessableEntity
|
|
48
|
+
from typing_extensions import assert_never
|
|
49
|
+
|
|
50
|
+
from nucliadb_agentic_api import logger
|
|
51
|
+
from nucliadb_agentic_api.agentic.ask_transform_to_interaction import (
|
|
52
|
+
interaction_from_ask_request,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if TYPE_CHECKING:
|
|
56
|
+
from nucliadb_agentic_api.app import HTTPApplication
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AgenticAskResult(AskResult):
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
kbid: str,
|
|
64
|
+
ask_request: AskRequest,
|
|
65
|
+
agentic_config_id: str,
|
|
66
|
+
account: str,
|
|
67
|
+
app: "HTTPApplication",
|
|
68
|
+
origin: str | None = None,
|
|
69
|
+
generate_inner_answer: bool = True,
|
|
70
|
+
resource: str | None = None,
|
|
71
|
+
):
|
|
72
|
+
# Initial attributes
|
|
73
|
+
self.kbid = kbid
|
|
74
|
+
self.ask_request = ask_request
|
|
75
|
+
self.agentic_config_id = agentic_config_id
|
|
76
|
+
self.account = account
|
|
77
|
+
self.origin = origin
|
|
78
|
+
self.metrics = AskMetrics()
|
|
79
|
+
self.app = app
|
|
80
|
+
self.nuclia_learning_id: str = ""
|
|
81
|
+
self.event_learning_id = Event()
|
|
82
|
+
self.queue: Queue[AragAnswer] = Queue()
|
|
83
|
+
self.task: Task | None = None
|
|
84
|
+
self.main_results = KnowledgeboxFindResults(resources={})
|
|
85
|
+
self.generate_inner_answer = generate_inner_answer
|
|
86
|
+
self.resource = resource
|
|
87
|
+
|
|
88
|
+
self._answer_text = ""
|
|
89
|
+
self._reasoning_text: str | None = None
|
|
90
|
+
|
|
91
|
+
self._object: JSONGenerativeResponse | None = None
|
|
92
|
+
self._status: StatusGenerativeResponse | None = None
|
|
93
|
+
self._citations: CitationsGenerativeResponse | None = None
|
|
94
|
+
self._footnote_citations: FootnoteCitationsGenerativeResponse | None = None
|
|
95
|
+
self._metadata: MetaGenerativeResponse | None = None
|
|
96
|
+
self._relations: Relations | None = None
|
|
97
|
+
self._consumption: Consumption | None = None
|
|
98
|
+
|
|
99
|
+
self.best_matches: list[RetrievalMatch] = []
|
|
100
|
+
|
|
101
|
+
async def loop(self):
|
|
102
|
+
|
|
103
|
+
interaction = interaction_from_ask_request(self.ask_request)
|
|
104
|
+
msg: AragAnswer
|
|
105
|
+
async for msg in stream_response(
|
|
106
|
+
self.app, # type: ignore
|
|
107
|
+
None,
|
|
108
|
+
self.account,
|
|
109
|
+
self.kbid,
|
|
110
|
+
"ephemeral",
|
|
111
|
+
interaction,
|
|
112
|
+
workflow_id=self.agentic_config_id,
|
|
113
|
+
):
|
|
114
|
+
try:
|
|
115
|
+
if msg.operation == AnswerOperation.DONE:
|
|
116
|
+
await self.queue.put(msg)
|
|
117
|
+
self.event_learning_id.set()
|
|
118
|
+
break
|
|
119
|
+
if (
|
|
120
|
+
msg.step
|
|
121
|
+
and msg.step.metadata
|
|
122
|
+
and "learning_id" in msg.step.metadata
|
|
123
|
+
):
|
|
124
|
+
self.nuclia_learning_id = msg.step.metadata["learning_id"]
|
|
125
|
+
self.event_learning_id.set()
|
|
126
|
+
await self.queue.put(msg)
|
|
127
|
+
except (RuntimeError, asyncio.QueueFull):
|
|
128
|
+
# WebSocket already closed
|
|
129
|
+
self.event_learning_id.set()
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
async def start(self) -> str:
|
|
133
|
+
self.task = create_task(self.loop())
|
|
134
|
+
|
|
135
|
+
await self.event_learning_id.wait()
|
|
136
|
+
return self.nuclia_learning_id
|
|
137
|
+
|
|
138
|
+
async def _stream(self) -> AsyncGenerator[AskResponseItemType, None]:
|
|
139
|
+
# First, stream out the predict answer
|
|
140
|
+
first_chunk_yielded = False
|
|
141
|
+
first_reasoning_chunk_yielded = False
|
|
142
|
+
with self.metrics.time("stream_websocket_answer"):
|
|
143
|
+
async for answer_chunk in self.websocket_to_ask():
|
|
144
|
+
if isinstance(answer_chunk, TextGenerativeResponse):
|
|
145
|
+
yield AnswerAskResponseItem(text=answer_chunk.text)
|
|
146
|
+
if not first_chunk_yielded:
|
|
147
|
+
self.metrics.record_first_chunk_yielded()
|
|
148
|
+
first_chunk_yielded = True
|
|
149
|
+
elif isinstance(answer_chunk, ReasoningGenerativeResponse):
|
|
150
|
+
yield ReasoningAskResponseItem(text=answer_chunk.text)
|
|
151
|
+
if not first_reasoning_chunk_yielded:
|
|
152
|
+
self.metrics.record_first_reasoning_chunk_yielded()
|
|
153
|
+
first_reasoning_chunk_yielded = True
|
|
154
|
+
else:
|
|
155
|
+
assert_never(answer_chunk)
|
|
156
|
+
|
|
157
|
+
if self._object is not None:
|
|
158
|
+
yield JSONAskResponseItem(object=self._object.object)
|
|
159
|
+
if not first_chunk_yielded:
|
|
160
|
+
# When there is a JSON generative response, we consider the first chunk yielded
|
|
161
|
+
# to be the moment when the JSON object is yielded, not the text
|
|
162
|
+
self.metrics.record_first_chunk_yielded()
|
|
163
|
+
first_chunk_yielded = True
|
|
164
|
+
|
|
165
|
+
yield RetrievalAskResponseItem(
|
|
166
|
+
results=self.main_results,
|
|
167
|
+
best_matches=[
|
|
168
|
+
AskRetrievalMatch(
|
|
169
|
+
id=match.paragraph.id,
|
|
170
|
+
)
|
|
171
|
+
for match in self.best_matches
|
|
172
|
+
],
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Then the status
|
|
176
|
+
if self.status_code == AnswerStatusCode.ERROR:
|
|
177
|
+
# If predict yielded an error status, we yield it too and halt the stream immediately
|
|
178
|
+
yield StatusAskResponseItem(
|
|
179
|
+
code=self.status_code.value,
|
|
180
|
+
status=self.status_code.prettify(),
|
|
181
|
+
details=self.status_error_details or "Unknown error",
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
yield StatusAskResponseItem(
|
|
186
|
+
code=self.status_code.value,
|
|
187
|
+
status=self.status_code.prettify(),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Stream out the citations
|
|
191
|
+
if self._citations is not None:
|
|
192
|
+
yield CitationsAskResponseItem(
|
|
193
|
+
citations=self._citations.citations,
|
|
194
|
+
)
|
|
195
|
+
# Stream out the footnote citations mapping
|
|
196
|
+
if self._footnote_citations is not None:
|
|
197
|
+
yield FootnoteCitationsAskResponseItem(
|
|
198
|
+
footnote_to_context=self._footnote_citations.footnote_to_context,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Stream out generic metadata about the answer
|
|
202
|
+
if self._metadata is not None:
|
|
203
|
+
yield MetadataAskResponseItem(
|
|
204
|
+
tokens=AskTokens(
|
|
205
|
+
input=self._metadata.input_tokens,
|
|
206
|
+
output=self._metadata.output_tokens,
|
|
207
|
+
input_nuclia=self._metadata.input_nuclia_tokens,
|
|
208
|
+
output_nuclia=self._metadata.output_nuclia_tokens,
|
|
209
|
+
),
|
|
210
|
+
timings=AskTimings(
|
|
211
|
+
generative_first_chunk=self._metadata.timings.get(
|
|
212
|
+
"generative_first_chunk"
|
|
213
|
+
),
|
|
214
|
+
generative_total=self._metadata.timings.get("generative"),
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if self._consumption is not None:
|
|
219
|
+
yield ConsumptionResponseItem(
|
|
220
|
+
normalized_tokens=TokensDetail(
|
|
221
|
+
input=self._consumption.normalized_tokens.input,
|
|
222
|
+
output=self._consumption.normalized_tokens.output,
|
|
223
|
+
image=self._consumption.normalized_tokens.image,
|
|
224
|
+
),
|
|
225
|
+
customer_key_tokens=TokensDetail(
|
|
226
|
+
input=self._consumption.customer_key_tokens.input,
|
|
227
|
+
output=self._consumption.customer_key_tokens.output,
|
|
228
|
+
image=self._consumption.customer_key_tokens.image,
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Stream out the relations results
|
|
233
|
+
should_query_relations = (
|
|
234
|
+
self.ask_request_with_relations
|
|
235
|
+
and self.status_code == AnswerStatusCode.SUCCESS
|
|
236
|
+
)
|
|
237
|
+
if should_query_relations:
|
|
238
|
+
relations = await self.get_relations_results()
|
|
239
|
+
yield RelationsAskResponseItem(relations=relations)
|
|
240
|
+
|
|
241
|
+
async def websocket_to_ask(
|
|
242
|
+
self,
|
|
243
|
+
) -> AsyncGenerator[TextGenerativeResponse | ReasoningGenerativeResponse, None]:
|
|
244
|
+
|
|
245
|
+
output_nuclia_tokens = 0.0
|
|
246
|
+
input_nuclia_tokens = 0.0
|
|
247
|
+
timings = {}
|
|
248
|
+
self._answer_text = ""
|
|
249
|
+
|
|
250
|
+
while True:
|
|
251
|
+
answer: AragAnswer = await self.queue.get()
|
|
252
|
+
if answer.operation == AnswerOperation.DONE:
|
|
253
|
+
break
|
|
254
|
+
|
|
255
|
+
if answer.operation == AnswerOperation.ERROR:
|
|
256
|
+
raise UnprocessableEntity(
|
|
257
|
+
message=answer.exception.detail
|
|
258
|
+
if answer.exception
|
|
259
|
+
else "Unknown error in agent execution"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if answer.operation == AnswerOperation.REASONING and answer.reasoning:
|
|
263
|
+
if self._reasoning_text is None:
|
|
264
|
+
self._reasoning_text = answer.reasoning.text
|
|
265
|
+
else:
|
|
266
|
+
self._reasoning_text += answer.reasoning.text
|
|
267
|
+
yield ReasoningGenerativeResponse(text=answer.reasoning.text)
|
|
268
|
+
|
|
269
|
+
if (
|
|
270
|
+
answer.operation == AnswerOperation.ANSWER_CHUNK
|
|
271
|
+
and answer.streaming_response_chunk
|
|
272
|
+
):
|
|
273
|
+
self._answer_text += answer.streaming_response_chunk.text
|
|
274
|
+
yield TextGenerativeResponse(
|
|
275
|
+
text=answer.streaming_response_chunk.text
|
|
276
|
+
if answer.streaming_response_chunk
|
|
277
|
+
else ""
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if answer.operation == AnswerOperation.ANSWER:
|
|
281
|
+
if answer.step:
|
|
282
|
+
if answer.step.module == "rephrase":
|
|
283
|
+
logger.debug("Received rephrase step, recording rephrase")
|
|
284
|
+
|
|
285
|
+
if answer.step.module == "smart":
|
|
286
|
+
logger.debug(
|
|
287
|
+
"Received smart step, recording: %s", answer.step.value
|
|
288
|
+
)
|
|
289
|
+
if answer.step.module == "basic_ask":
|
|
290
|
+
logger.debug(
|
|
291
|
+
"Received Basic Ask step %s with value %s",
|
|
292
|
+
answer.step.agent_path,
|
|
293
|
+
answer.step.value,
|
|
294
|
+
)
|
|
295
|
+
input_nuclia_tokens += (
|
|
296
|
+
answer.step.input_nuclia_tokens
|
|
297
|
+
if answer.step.input_nuclia_tokens is not None
|
|
298
|
+
else 0.0
|
|
299
|
+
)
|
|
300
|
+
output_nuclia_tokens += (
|
|
301
|
+
answer.step.output_nuclia_tokens
|
|
302
|
+
if answer.step.output_nuclia_tokens is not None
|
|
303
|
+
else 0.0
|
|
304
|
+
)
|
|
305
|
+
timings[answer.step.module] = answer.step.timeit
|
|
306
|
+
|
|
307
|
+
if answer.possible_answer:
|
|
308
|
+
# Not usefull for ask endpoint, more for a agent playground where we want to show the final answer separately
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
# Context(id='100d70a657884c2c8fdc80738dda71b9', original_question_uuid='201701ca9cd7412ba3153bcbdeb1f07e', actual_question_uuid='201701ca9cd7412ba3153bcbdeb1f07e', question='Provide dessert options that are both healthy and delicious.', chunks=[Chunk(chunk_id='catalog_search_result-0', title=None, source=None, text='Here are some healthy and delicious dessert recipes from the catalog search results:\n\n1. **Carrot Cake** - A classic dessert that can be made healthier by using whole grain flour and reducing sugar.\n - [Download Carrot Cake Recipe](carrot-cake-A4.pdf)\n\n2. **Chocolate Chip Cookies** - You can make these healthier by using dark chocolate and whole grain flour.\n - [Download Chocolate Chip Cookies Recipe](chocolate-chip-cookies-A4.pdf)\n\n3. **Zucchini Bread** - A great way to incorporate vegetables into a sweet treat, often made with whole grains and less sugar.\n - [Download Zucchini Bread Recipe](Zucchini-bread-A4.pdf)\n\n4. **Banana Bread** - A delicious option that can be made healthier by using ripe bananas for sweetness and whole grain flour.\n - [Download Banana Bread Recipe](banana-bread-A4.pdf)\n\n5. **Gingerbread Cake** - A spiced cake that can be made with healthier ingredients like whole wheat flour and less sugar.\n - [Download Gingerbread Cake Recipe](Gingerbread-Cake-A4.pdf)\n\n6. **Sugar Cookies** - These can be made healthier by using natural sweeteners and whole grain flour.\n - [Download Sugar Cookies Recipe](Sugar-Cookies-A4.pdf)\n\nFeel free to explore these recipes for a healthier dessert option!', labels=[], url=[], metadata=None, action="catalog_search of 4c9b0b15-de46-4a15-849b-82fe502fa5cb with parameters {'question': 'healthy and delicious dessert recipes'}", origin_url=None, origin_agent='basic_ask')], images={}, prompts=[], structured=[], source='smart_agent', agent='smart_agent', summary='Here are some healthy and delicious dessert recipes:\n\n1. **Carrot Cake** - Made healthier by using whole grain flour and reducing sugar.\n2. **Chocolate Chip Cookies** - Made healthier by using dark chocolate and whole grain flour.\n3. **Zucchini Bread** - Incorporates vegetables, often made with whole grains and less sugar.\n4. **Banana Bread** - Made healthier by using ripe bananas for sweetness and whole grain flour.\n5. **Gingerbread Cake** - Made with healthier ingredients like whole wheat flour and less sugar.\n6. **Sugar Cookies** - Made healthier by using natural sweeteners and whole grain flour.', agent_id='b6d72f8c-2e09-4ee1-bba0-b0ec711894a1', title='Default Agentic Config - Smart Agent', missing=None, citations=['catalog_search_result-0'], citations_id=None, image_urls=[])
|
|
312
|
+
if answer.context:
|
|
313
|
+
try:
|
|
314
|
+
for structured in answer.context.json_objects:
|
|
315
|
+
if structured.id == JSON_OBJECT_ID:
|
|
316
|
+
self.main_results = (
|
|
317
|
+
KnowledgeboxFindResults.model_validate(
|
|
318
|
+
structured.json_object
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
except Exception as e:
|
|
322
|
+
logger.error("Error validating structured data: %s", e)
|
|
323
|
+
|
|
324
|
+
if answer.generated_text:
|
|
325
|
+
# Not usefull for ask endpoint, more for a agent playground where we want to show the final answer separately
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
yield TextGenerativeResponse(
|
|
329
|
+
text=answer.answer if answer.answer else ""
|
|
330
|
+
)
|
|
331
|
+
if answer.answer_citations:
|
|
332
|
+
citations = {}
|
|
333
|
+
for key, value in answer.answer_citations.metadata.items():
|
|
334
|
+
citations[key] = {
|
|
335
|
+
"context_id": value.context_id,
|
|
336
|
+
"origin_urls": value.origin_urls,
|
|
337
|
+
"chunk_index": value.chunk_index,
|
|
338
|
+
}
|
|
339
|
+
self._citations = CitationsGenerativeResponse(citations=citations)
|
|
340
|
+
|
|
341
|
+
if answer.generated_text:
|
|
342
|
+
# TODO: This is a bit of a hack, but we need to parse the generated text as JSON and store it in the _object attribute. This is because the AskResult class expects a JSONGenerativeResponse object to be returned from the websocket_to_ask method, but we don't have that yet. Once we have a proper JSONGenerativeResponse object, we can remove this hack.
|
|
343
|
+
try:
|
|
344
|
+
self._object = JSONGenerativeResponse(
|
|
345
|
+
object=json.loads(answer.generated_text)
|
|
346
|
+
)
|
|
347
|
+
except Exception as e:
|
|
348
|
+
logger.error(f"Error processing generated text: {e}")
|
|
349
|
+
# elif isinstance(item, FootnoteCitationsGenerativeResponse):
|
|
350
|
+
# self._footnote_citations = item
|
|
351
|
+
|
|
352
|
+
if answer.operation == AnswerOperation.AGENT_REQUEST:
|
|
353
|
+
# Not supported by Ask endpoint
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
if answer.operation == AnswerOperation.START:
|
|
357
|
+
logger.debug("Received start message")
|
|
358
|
+
|
|
359
|
+
self._consumption = Consumption(
|
|
360
|
+
normalized_tokens=ConsumptionTokensDetail(
|
|
361
|
+
input=input_nuclia_tokens,
|
|
362
|
+
output=output_nuclia_tokens,
|
|
363
|
+
image=self.metrics.get("normalized_image_tokens") or 0,
|
|
364
|
+
),
|
|
365
|
+
customer_key_tokens=ConsumptionTokensDetail(
|
|
366
|
+
input=self.metrics.get("customer_key_input_tokens") or 0,
|
|
367
|
+
output=self.metrics.get("customer_key_output_tokens") or 0,
|
|
368
|
+
image=self.metrics.get("customer_key_image_tokens") or 0,
|
|
369
|
+
),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
self._metadata = MetaGenerativeResponse(
|
|
373
|
+
input_tokens=0,
|
|
374
|
+
output_tokens=0,
|
|
375
|
+
input_nuclia_tokens=input_nuclia_tokens,
|
|
376
|
+
output_nuclia_tokens=output_nuclia_tokens,
|
|
377
|
+
timings=timings,
|
|
378
|
+
learning_id=self.nuclia_learning_id,
|
|
379
|
+
model_name=None,
|
|
380
|
+
trace_id=None,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
self._status = StatusGenerativeResponse(
|
|
384
|
+
code=AnswerStatusCode.SUCCESS.value,
|
|
385
|
+
details=None,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if self.task:
|
|
389
|
+
self.task.cancel()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from hyperforge.api.models import InteractionRequest
|
|
2
|
+
from hyperforge_nucliadb_agentic.ask.model import AskRequest
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def interaction_from_ask_request(ask_request: AskRequest) -> InteractionRequest:
|
|
6
|
+
# Transform the AskRequest into an ARAG interaction
|
|
7
|
+
# This is a placeholder implementation and should be adapted to the actual ARAG interaction format
|
|
8
|
+
interaction = InteractionRequest(question=ask_request.query, streaming=True)
|
|
9
|
+
interaction.arguments["ask_request"] = ask_request.model_dump_json()
|
|
10
|
+
return interaction
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
|
|
4
|
+
import prometheus_client
|
|
5
|
+
from fastapi import APIRouter, FastAPI
|
|
6
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
7
|
+
from hyperforge.api.authentication import RaoAuthenticationBackend
|
|
8
|
+
from hyperforge.broker import Broker
|
|
9
|
+
from hyperforge.broker.redis import RedisBroker
|
|
10
|
+
from hyperforge.driver import Driver
|
|
11
|
+
from hyperforge_nucliadb_agentic.ask.audit import (
|
|
12
|
+
AuditMiddleware,
|
|
13
|
+
start_audit_utility,
|
|
14
|
+
stop_audit_utility,
|
|
15
|
+
)
|
|
16
|
+
from hyperforge_nucliadb_agentic.ask.predict import (
|
|
17
|
+
start_predict_engine,
|
|
18
|
+
stop_predict_engine,
|
|
19
|
+
)
|
|
20
|
+
from lru import LRU
|
|
21
|
+
from mcp.server.lowlevel.server import Server as MCPServer
|
|
22
|
+
from mcp.server.streamable_http import (
|
|
23
|
+
StreamableHTTPServerTransport,
|
|
24
|
+
)
|
|
25
|
+
from nucliadb_sdk import NucliaDBAsync
|
|
26
|
+
from nucliadb_telemetry.utils import clean_telemetry
|
|
27
|
+
from nucliadb_utils.settings import AuditSettings
|
|
28
|
+
from prometheus_client import CONTENT_TYPE_LATEST
|
|
29
|
+
from starlette.middleware.authentication import AuthenticationMiddleware
|
|
30
|
+
from starlette.responses import PlainTextResponse
|
|
31
|
+
|
|
32
|
+
from nucliadb_agentic_api import SERVICE_NAME, v1
|
|
33
|
+
from nucliadb_agentic_api.db.agentic_configs import AgenticConfigs
|
|
34
|
+
from nucliadb_agentic_api.db.settings import DataManagerSettings
|
|
35
|
+
from nucliadb_agentic_api.db.sources import Sources
|
|
36
|
+
from nucliadb_agentic_api.settings import Settings
|
|
37
|
+
|
|
38
|
+
router = APIRouter()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.get("/metrics")
|
|
42
|
+
async def serve_metrics(): # pragma: no cover
|
|
43
|
+
output = prometheus_client.exposition.generate_latest()
|
|
44
|
+
return PlainTextResponse(
|
|
45
|
+
output.decode("utf8"), headers={"Content-Type": CONTENT_TYPE_LATEST}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.get("/health/ready")
|
|
50
|
+
async def health_ready():
|
|
51
|
+
return {"status": "ok"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.get("/health/alive")
|
|
55
|
+
async def health_alive():
|
|
56
|
+
return {"status": "ok"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HTTPApplication(FastAPI):
|
|
60
|
+
agent_manager: AgenticConfigs
|
|
61
|
+
source_manager: Sources
|
|
62
|
+
broker: Broker
|
|
63
|
+
hyperforge_drivers: dict[str, "Driver"]
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
settings: Settings,
|
|
68
|
+
data_manager_settings: DataManagerSettings,
|
|
69
|
+
audit_settings: AuditSettings,
|
|
70
|
+
*args,
|
|
71
|
+
**kwargs,
|
|
72
|
+
):
|
|
73
|
+
@asynccontextmanager
|
|
74
|
+
async def lifespan(app: "HTTPApplication"):
|
|
75
|
+
await app.startup()
|
|
76
|
+
yield
|
|
77
|
+
await app.shutdown()
|
|
78
|
+
|
|
79
|
+
super().__init__(*args, lifespan=lifespan, **kwargs)
|
|
80
|
+
self.settings = settings
|
|
81
|
+
self.data_manager_settings = data_manager_settings
|
|
82
|
+
self.audit_settings = audit_settings
|
|
83
|
+
self.include_router(v1.router)
|
|
84
|
+
self.include_router(router)
|
|
85
|
+
self.add_middleware(
|
|
86
|
+
AuthenticationMiddleware,
|
|
87
|
+
backend=RaoAuthenticationBackend(),
|
|
88
|
+
)
|
|
89
|
+
self.add_middleware(
|
|
90
|
+
CORSMiddleware,
|
|
91
|
+
allow_origins=["*"],
|
|
92
|
+
allow_methods=["*"],
|
|
93
|
+
allow_headers=["*"],
|
|
94
|
+
)
|
|
95
|
+
self.add_middleware(AuditMiddleware)
|
|
96
|
+
|
|
97
|
+
async def startup(self) -> None:
|
|
98
|
+
|
|
99
|
+
await start_predict_engine()
|
|
100
|
+
await start_audit_utility(SERVICE_NAME, self.audit_settings)
|
|
101
|
+
|
|
102
|
+
self.broker = RedisBroker.from_url(
|
|
103
|
+
url=self.settings.valkey_url,
|
|
104
|
+
activate_subject=self.settings.activate_subject,
|
|
105
|
+
keepalive_ms=int(self.settings.pubsub_keepalive_seconds * 1000),
|
|
106
|
+
cluster_mode=self.settings.valkey_cluster_mode,
|
|
107
|
+
)
|
|
108
|
+
if self.settings.internal_nucliadb:
|
|
109
|
+
headers = {"X-NUCLIADB-ROLES": "READER"}
|
|
110
|
+
api_key = None
|
|
111
|
+
nucliadb_url = self.settings.internal_nucliadb_url
|
|
112
|
+
else:
|
|
113
|
+
nucliadb_url = self.settings.external_nucliadb_url
|
|
114
|
+
api_key = self.settings.external_nucliadb_key
|
|
115
|
+
headers = {}
|
|
116
|
+
|
|
117
|
+
self.arag_reader = NucliaDBAsync(
|
|
118
|
+
url=nucliadb_url,
|
|
119
|
+
api_key=api_key,
|
|
120
|
+
headers=headers,
|
|
121
|
+
)
|
|
122
|
+
self.arag_search = NucliaDBAsync(
|
|
123
|
+
url=nucliadb_url,
|
|
124
|
+
api_key=api_key,
|
|
125
|
+
headers=headers,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
self.sses: LRU[Tuple[str, str], StreamableHTTPServerTransport] = LRU(size=100)
|
|
129
|
+
self.mcp_servers: LRU[str, MCPServer] = LRU(size=100)
|
|
130
|
+
|
|
131
|
+
self.agent_manager = await AgenticConfigs.from_settings(
|
|
132
|
+
settings=self.data_manager_settings
|
|
133
|
+
)
|
|
134
|
+
await self.agent_manager.initialize()
|
|
135
|
+
|
|
136
|
+
self.source_manager = await Sources.from_settings(
|
|
137
|
+
settings=self.data_manager_settings
|
|
138
|
+
)
|
|
139
|
+
await self.source_manager.initialize()
|
|
140
|
+
|
|
141
|
+
async def shutdown(self) -> None:
|
|
142
|
+
await self.agent_manager.finalize()
|
|
143
|
+
await self.source_manager.finalize()
|
|
144
|
+
await self.broker.finalize()
|
|
145
|
+
await stop_audit_utility()
|
|
146
|
+
await stop_predict_engine()
|
|
147
|
+
await clean_telemetry(SERVICE_NAME)
|