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.
Files changed (37) hide show
  1. nucliadb_agentic_api/VERSION +1 -0
  2. nucliadb_agentic_api/__init__.py +20 -0
  3. nucliadb_agentic_api/agentic/__init__.py +0 -0
  4. nucliadb_agentic_api/agentic/ask_handler.py +66 -0
  5. nucliadb_agentic_api/agentic/ask_result.py +389 -0
  6. nucliadb_agentic_api/agentic/ask_transform_to_interaction.py +10 -0
  7. nucliadb_agentic_api/app.py +147 -0
  8. nucliadb_agentic_api/commands.py +66 -0
  9. nucliadb_agentic_api/db/__init__.py +0 -0
  10. nucliadb_agentic_api/db/agentic_configs.py +296 -0
  11. nucliadb_agentic_api/db/settings.py +11 -0
  12. nucliadb_agentic_api/db/sources.py +202 -0
  13. nucliadb_agentic_api/db/transform.py +208 -0
  14. nucliadb_agentic_api/exceptions.py +10 -0
  15. nucliadb_agentic_api/logging.py +18 -0
  16. nucliadb_agentic_api/models.py +321 -0
  17. nucliadb_agentic_api/py.typed +0 -0
  18. nucliadb_agentic_api/server/__init__.py +1 -0
  19. nucliadb_agentic_api/server/server.py +113 -0
  20. nucliadb_agentic_api/server/session.py +276 -0
  21. nucliadb_agentic_api/server/settings.py +22 -0
  22. nucliadb_agentic_api/settings.py +39 -0
  23. nucliadb_agentic_api/utils.py +284 -0
  24. nucliadb_agentic_api/v1/__init__.py +19 -0
  25. nucliadb_agentic_api/v1/agentic_configs.py +117 -0
  26. nucliadb_agentic_api/v1/ask.py +303 -0
  27. nucliadb_agentic_api/v1/ask_websocket.py +141 -0
  28. nucliadb_agentic_api/v1/mcp_content.py +310 -0
  29. nucliadb_agentic_api/v1/mcp_nucliadb.py +785 -0
  30. nucliadb_agentic_api/v1/oauth.py +60 -0
  31. nucliadb_agentic_api/v1/router.py +3 -0
  32. nucliadb_agentic_api/v1/sources.py +158 -0
  33. nucliadb_agentic_api/v1/utils.py +12 -0
  34. nucliadb_agentic_api-1.0.0.post156.dist-info/METADATA +150 -0
  35. nucliadb_agentic_api-1.0.0.post156.dist-info/RECORD +37 -0
  36. nucliadb_agentic_api-1.0.0.post156.dist-info/WHEEL +4 -0
  37. 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)