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,276 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from asyncio import Task, create_task, timeout # type: ignore
|
|
3
|
+
from functools import partial
|
|
4
|
+
|
|
5
|
+
import nucliadb_telemetry.context
|
|
6
|
+
import nucliadb_telemetry.metrics
|
|
7
|
+
import prometheus_client
|
|
8
|
+
from hyperforge.broker import Broker
|
|
9
|
+
from hyperforge.configure import GLOBAL_REGISTRY, load_all_configurations, scan
|
|
10
|
+
from hyperforge.engine import State, get_state
|
|
11
|
+
from hyperforge.interaction import AnswerOperation, AragAnswer, ARAGException
|
|
12
|
+
from hyperforge.memory import QuestionMemory
|
|
13
|
+
from hyperforge.pubsub import AgentDone, StartInteraction
|
|
14
|
+
from hyperforge.server.cache import Cache
|
|
15
|
+
from hyperforge.server.session import SessionManager
|
|
16
|
+
from hyperforge.server.utils import get_memory
|
|
17
|
+
from hyperforge_nucliadb_agentic.ask.audit import (
|
|
18
|
+
start_audit_utility,
|
|
19
|
+
stop_audit_utility,
|
|
20
|
+
)
|
|
21
|
+
from hyperforge_nucliadb_agentic.ask.model import AskRequest
|
|
22
|
+
from lru import LRU
|
|
23
|
+
from nucliadb_telemetry import errors
|
|
24
|
+
from nucliadb_telemetry.utils import get_telemetry
|
|
25
|
+
from nucliadb_utils.settings import AuditSettings
|
|
26
|
+
from opentelemetry import trace
|
|
27
|
+
|
|
28
|
+
from nucliadb_agentic_api import logger
|
|
29
|
+
from nucliadb_agentic_api.db.agentic_configs import AgenticConfigs
|
|
30
|
+
from nucliadb_agentic_api.server import SERVICE_NAME
|
|
31
|
+
from nucliadb_agentic_api.server.settings import Settings as ServerSettings
|
|
32
|
+
|
|
33
|
+
HOSTNAME = os.environ.get("HOSTNAME", "nucliadb-agentic-api-server").encode()
|
|
34
|
+
|
|
35
|
+
answer_observer = nucliadb_telemetry.metrics.Observer("nucliadb_agentic_api_answer")
|
|
36
|
+
activation_observer = nucliadb_telemetry.metrics.Observer(
|
|
37
|
+
"nucliadb_agentic_api_activation"
|
|
38
|
+
)
|
|
39
|
+
answer_running = prometheus_client.Gauge(
|
|
40
|
+
"nucliadb_agentic_api_running_answers_count",
|
|
41
|
+
"Number of answering processess currently running",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def tracer():
|
|
46
|
+
provider = get_telemetry(SERVICE_NAME)
|
|
47
|
+
if provider:
|
|
48
|
+
return provider.get_tracer(__name__)
|
|
49
|
+
else:
|
|
50
|
+
return trace.NoOpTracer()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class NucliaDBAgenticSessionManager(SessionManager):
|
|
54
|
+
agent_manager: AgenticConfigs # type: ignore[assignment]
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
settings: ServerSettings,
|
|
59
|
+
audit_settings: AuditSettings,
|
|
60
|
+
broker: Broker,
|
|
61
|
+
agent_manager: AgenticConfigs,
|
|
62
|
+
cache: Cache,
|
|
63
|
+
):
|
|
64
|
+
self.settings = settings
|
|
65
|
+
self.audit_settings = audit_settings
|
|
66
|
+
self.agent_manager = agent_manager
|
|
67
|
+
self.broker = broker
|
|
68
|
+
self.memory: LRU = LRU(800)
|
|
69
|
+
self.activation_task: Task | None = None
|
|
70
|
+
self.tasks = []
|
|
71
|
+
self.cache = cache
|
|
72
|
+
|
|
73
|
+
async def initialize(self, health_check: bool = True):
|
|
74
|
+
await super().initialize(health_check)
|
|
75
|
+
|
|
76
|
+
await start_audit_utility(SERVICE_NAME, self.audit_settings)
|
|
77
|
+
|
|
78
|
+
for load_module in self.settings.load_modules:
|
|
79
|
+
try:
|
|
80
|
+
scan(load_module)
|
|
81
|
+
load_all_configurations(load_module)
|
|
82
|
+
except ImportError:
|
|
83
|
+
logger.error(f"Module {load_module} could not be loaded")
|
|
84
|
+
|
|
85
|
+
async def finalize(self):
|
|
86
|
+
await super().finalize()
|
|
87
|
+
await stop_audit_utility()
|
|
88
|
+
GLOBAL_REGISTRY.clear()
|
|
89
|
+
|
|
90
|
+
async def activate(self, message: StartInteraction):
|
|
91
|
+
topic = None
|
|
92
|
+
|
|
93
|
+
ask_request_json = message.arguments.get("ask_request")
|
|
94
|
+
ask_request = None
|
|
95
|
+
if ask_request_json:
|
|
96
|
+
ask_request = AskRequest.model_validate_json(ask_request_json)
|
|
97
|
+
|
|
98
|
+
logger.info("Activation message received: %s", message)
|
|
99
|
+
observation = activation_observer()
|
|
100
|
+
observation.start()
|
|
101
|
+
try:
|
|
102
|
+
nucliadb_telemetry.context.add_context(
|
|
103
|
+
{
|
|
104
|
+
"agent_id": message.agent_id,
|
|
105
|
+
"session_id": message.session,
|
|
106
|
+
"question_id": message.question_id,
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
topic = self.question_topic(
|
|
111
|
+
message.account,
|
|
112
|
+
message.agent_id,
|
|
113
|
+
message.session,
|
|
114
|
+
message.question_id,
|
|
115
|
+
message.workflow_id,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Get or load session
|
|
119
|
+
config = await self.agent_manager.get_agent_config(
|
|
120
|
+
account=message.account,
|
|
121
|
+
kbid=message.agent_id,
|
|
122
|
+
internal_nucliadb_url=self.settings.internal_nucliadb_url,
|
|
123
|
+
internal_nucliadb=self.settings.internal_nucliadb,
|
|
124
|
+
external_nucliadb_url=self.settings.external_nucliadb_url,
|
|
125
|
+
external_nucliadb_key=self.settings.external_nucliadb_key,
|
|
126
|
+
workflow_id=message.workflow_id,
|
|
127
|
+
ask_request=ask_request,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
state = await get_state(
|
|
131
|
+
agent_id=message.agent_id,
|
|
132
|
+
config=config,
|
|
133
|
+
internal_nua_api=self.settings.internal_nua_api,
|
|
134
|
+
internal_nua=self.settings.internal_nua,
|
|
135
|
+
local_openai=self.settings.local_openai,
|
|
136
|
+
external_nua_api_key=self.settings.external_nua_api_key,
|
|
137
|
+
account=message.account,
|
|
138
|
+
kbid=None if self.settings.standalone else message.agent_id,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if message.session not in self.memory:
|
|
142
|
+
memory = await get_memory(
|
|
143
|
+
settings=self.settings,
|
|
144
|
+
session=message.session,
|
|
145
|
+
cache=self.cache,
|
|
146
|
+
config=config.memory,
|
|
147
|
+
agent=message.agent_id,
|
|
148
|
+
workflow_id=message.workflow_id,
|
|
149
|
+
)
|
|
150
|
+
self.memory[message.session] = memory
|
|
151
|
+
else:
|
|
152
|
+
memory = self.memory[message.session]
|
|
153
|
+
|
|
154
|
+
memory.rules = config.rules.rules
|
|
155
|
+
|
|
156
|
+
question = memory.start_question(
|
|
157
|
+
message.question,
|
|
158
|
+
question_id=message.question_id,
|
|
159
|
+
headers=message.headers,
|
|
160
|
+
arguments=message.arguments,
|
|
161
|
+
streaming=message.streaming,
|
|
162
|
+
chat_history=message.chat_history,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
task = create_task(
|
|
166
|
+
self.answer(
|
|
167
|
+
message.account,
|
|
168
|
+
message.agent_id,
|
|
169
|
+
message.workflow_id,
|
|
170
|
+
topic,
|
|
171
|
+
state,
|
|
172
|
+
question,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
task.add_done_callback(self._remove_task)
|
|
176
|
+
self.tasks.append(task)
|
|
177
|
+
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.exception("Activation exception")
|
|
180
|
+
errors.capture_exception(e)
|
|
181
|
+
observation.set_status("error")
|
|
182
|
+
if topic:
|
|
183
|
+
await self.callback(
|
|
184
|
+
topic,
|
|
185
|
+
AragAnswer(
|
|
186
|
+
exception=ARAGException(detail="Unable to start agent"),
|
|
187
|
+
operation=AnswerOperation.ERROR,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
await self.send_message(topic, AgentDone())
|
|
191
|
+
|
|
192
|
+
observation.end()
|
|
193
|
+
|
|
194
|
+
async def answer(
|
|
195
|
+
self,
|
|
196
|
+
account_id: str,
|
|
197
|
+
agent_id: str,
|
|
198
|
+
workflow_id: str,
|
|
199
|
+
topic: str,
|
|
200
|
+
state: State,
|
|
201
|
+
question_memory: QuestionMemory,
|
|
202
|
+
):
|
|
203
|
+
error = None
|
|
204
|
+
|
|
205
|
+
keepalive = create_task(self.keep_alive(topic))
|
|
206
|
+
observation = answer_observer()
|
|
207
|
+
observation.start()
|
|
208
|
+
answer_running.inc()
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
callback = partial(self.callback, topic)
|
|
212
|
+
question_memory.set_callback_fn(callback)
|
|
213
|
+
|
|
214
|
+
feedback = partial(self.feedback, topic)
|
|
215
|
+
question_memory.set_feedback_fn(feedback)
|
|
216
|
+
|
|
217
|
+
oauth = partial(self.oauth, topic)
|
|
218
|
+
question_memory.set_oauth_fn(oauth)
|
|
219
|
+
|
|
220
|
+
oauth_callback = partial(
|
|
221
|
+
self.get_oauth_callback,
|
|
222
|
+
account_id,
|
|
223
|
+
agent_id,
|
|
224
|
+
question_memory.session.id,
|
|
225
|
+
workflow_id,
|
|
226
|
+
)
|
|
227
|
+
question_memory.set_oauth_callback_fn(oauth_callback)
|
|
228
|
+
|
|
229
|
+
await self.callback(
|
|
230
|
+
topic,
|
|
231
|
+
AragAnswer(operation=AnswerOperation.START),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
async with timeout(self.settings.question_timeout_seconds):
|
|
235
|
+
await state.agent(question_memory, state.manager)
|
|
236
|
+
|
|
237
|
+
except Exception as e:
|
|
238
|
+
logger.exception("Answering exception")
|
|
239
|
+
errors.capture_exception(e)
|
|
240
|
+
error = ARAGException(detail=str(e))
|
|
241
|
+
observation.set_status("error")
|
|
242
|
+
|
|
243
|
+
observation.end()
|
|
244
|
+
answer_running.dec()
|
|
245
|
+
keepalive.cancel()
|
|
246
|
+
|
|
247
|
+
await self.callback(
|
|
248
|
+
topic,
|
|
249
|
+
AragAnswer(
|
|
250
|
+
exception=error,
|
|
251
|
+
answer=question_memory.final_answer,
|
|
252
|
+
answer_citations=question_memory.final_answer_citations,
|
|
253
|
+
answer_urls=question_memory.final_answer_urls,
|
|
254
|
+
operation=AnswerOperation.ERROR
|
|
255
|
+
if error is not None
|
|
256
|
+
else AnswerOperation.ANSWER,
|
|
257
|
+
data_visualizations=question_memory.data_visualizations
|
|
258
|
+
if question_memory.data_visualizations
|
|
259
|
+
else None,
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
await self.send_message(
|
|
263
|
+
topic,
|
|
264
|
+
AgentDone(),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
await question_memory.save()
|
|
269
|
+
self.process_event(
|
|
270
|
+
"memory_saved",
|
|
271
|
+
{"account_id": account_id, "question_memory": question_memory},
|
|
272
|
+
)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
# Log memory errors but don't report them to the user
|
|
275
|
+
logger.exception("Error saving memory")
|
|
276
|
+
errors.capture_exception(e)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from hyperforge.server.settings import Settings as HyperforgeServerSettings
|
|
2
|
+
|
|
3
|
+
MODULES = [
|
|
4
|
+
"hyperforge_rephrase",
|
|
5
|
+
"hyperforge_nucliadb",
|
|
6
|
+
"hyperforge_nucliadb_agentic",
|
|
7
|
+
"hyperforge_summarize",
|
|
8
|
+
"hyperforge_smart",
|
|
9
|
+
"hyperforge_mcp",
|
|
10
|
+
"hyperforge_google",
|
|
11
|
+
"hyperforge_perplexity",
|
|
12
|
+
"hyperforge_nucliadb_agentic.internal_driver",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Settings(HyperforgeServerSettings):
|
|
17
|
+
load_modules: list[str] = MODULES
|
|
18
|
+
activate_subject: str = "ndb_agentic.activate"
|
|
19
|
+
answers_subject: str = (
|
|
20
|
+
"ndb_agentic.{account}.{agent_id}.{workflow_id}.{session}.{question}.answer"
|
|
21
|
+
)
|
|
22
|
+
oauth_subject: str = "ndb_agentic.{account}.{agent_id}.{workflow_id}.{session}.{question}.oauth.{oauth_uuid}"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Settings(BaseSettings):
|
|
7
|
+
debug: bool = False
|
|
8
|
+
log_level: str = "ERROR"
|
|
9
|
+
http_host: str = "0.0.0.0"
|
|
10
|
+
http_port: int = 8080
|
|
11
|
+
|
|
12
|
+
sentry_url: Optional[str] = None
|
|
13
|
+
running_environment: str = "stage"
|
|
14
|
+
zone: str = "stashify"
|
|
15
|
+
grpc_port: int = 8030
|
|
16
|
+
|
|
17
|
+
valkey_url: str = "redis://ndb_agentic-valkey-cluster"
|
|
18
|
+
valkey_cluster_mode: bool = False
|
|
19
|
+
answers_subject: str = (
|
|
20
|
+
"ndb_agentic.{account}.{agent_id}.{workflow_id}.{session}.{question}.answer"
|
|
21
|
+
)
|
|
22
|
+
oauth_subject: str = "ndb_agentic.{account}.{agent_id}.{workflow_id}.{session}.{question}.oauth.{oauth_uuid}"
|
|
23
|
+
activate_subject: str = "ndb_agentic.activate"
|
|
24
|
+
pubsub_keepalive_seconds: float = 20
|
|
25
|
+
|
|
26
|
+
# Hydra settings for MCP Oauth
|
|
27
|
+
hydra_public_url: str = "https://oauth.progress.cloud"
|
|
28
|
+
hydra_scopes_supported: list[str] = ["offline_access", "openid"]
|
|
29
|
+
|
|
30
|
+
hyperforge_google_credentials: Optional[str] = None
|
|
31
|
+
hyperforge_google_location: Optional[str] = None
|
|
32
|
+
hyperforge_perplexity_key: Optional[str] = None
|
|
33
|
+
internal_nua_api: str = "http://predict.learning.svc.cluster.local:8080"
|
|
34
|
+
|
|
35
|
+
internal_nucliadb: bool = False
|
|
36
|
+
internal_nucliadb_url: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
external_nucliadb_key: Optional[str] = None
|
|
39
|
+
external_nucliadb_url: Optional[str] = None
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any, List, TypeVar
|
|
5
|
+
|
|
6
|
+
import pydantic
|
|
7
|
+
from typing_extensions import TypeGuard
|
|
8
|
+
|
|
9
|
+
_T = TypeVar("_T")
|
|
10
|
+
|
|
11
|
+
FLOW_PROPERTIES = [
|
|
12
|
+
"next_agent",
|
|
13
|
+
"fallback",
|
|
14
|
+
"then",
|
|
15
|
+
"else_",
|
|
16
|
+
"agents",
|
|
17
|
+
"registered_agents",
|
|
18
|
+
"agent",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def to_strict_json_schema(
|
|
23
|
+
model: type[pydantic.BaseModel] | pydantic.TypeAdapter[Any],
|
|
24
|
+
exclude_properties: List[str] = [],
|
|
25
|
+
exclude_defs: List[str] = [],
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
if inspect.isclass(model) and is_basemodel_type(model):
|
|
28
|
+
schema = model.model_json_schema()
|
|
29
|
+
elif isinstance(model, pydantic.TypeAdapter):
|
|
30
|
+
schema = model.json_schema()
|
|
31
|
+
else:
|
|
32
|
+
raise TypeError(
|
|
33
|
+
f"Non BaseModel types are only supported with Pydantic v2 - {model}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
for exclude in exclude_properties:
|
|
37
|
+
if "properties" in schema and exclude in schema["properties"]:
|
|
38
|
+
del schema["properties"][exclude]
|
|
39
|
+
for exclude in exclude_defs:
|
|
40
|
+
if "$defs" in schema and exclude in schema["$defs"]:
|
|
41
|
+
del schema["$defs"][exclude]
|
|
42
|
+
return _ensure_strict_json_schema(schema, path=(), root=schema)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ensure_strict_json_schema(
|
|
46
|
+
json_schema: object,
|
|
47
|
+
*,
|
|
48
|
+
path: tuple[str, ...],
|
|
49
|
+
root: dict[str, object],
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Mutates the given JSON schema to ensure it conforms to the `strict` standard
|
|
52
|
+
that the API expects.
|
|
53
|
+
"""
|
|
54
|
+
if not is_dict(json_schema):
|
|
55
|
+
raise TypeError(f"Expected {json_schema} to be a dictionary; path={path}")
|
|
56
|
+
|
|
57
|
+
# defs = json_schema.get("$defs")
|
|
58
|
+
# if is_dict(defs):
|
|
59
|
+
# for def_name, def_schema in defs.items():
|
|
60
|
+
# _ensure_strict_json_schema(
|
|
61
|
+
# def_schema, path=(*path, "$defs", def_name), root=root
|
|
62
|
+
# )
|
|
63
|
+
|
|
64
|
+
definitions = json_schema.get("definitions")
|
|
65
|
+
if is_dict(definitions):
|
|
66
|
+
for definition_name, definition_schema in definitions.items():
|
|
67
|
+
_ensure_strict_json_schema(
|
|
68
|
+
definition_schema,
|
|
69
|
+
path=(*path, "definitions", definition_name),
|
|
70
|
+
root=root,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
typ = json_schema.get("type")
|
|
74
|
+
if typ == "object" and "additionalProperties" not in json_schema:
|
|
75
|
+
json_schema["additionalProperties"] = False
|
|
76
|
+
|
|
77
|
+
# object types
|
|
78
|
+
# { 'type': 'object', 'properties': { 'a': {...} } }
|
|
79
|
+
properties = json_schema.get("properties")
|
|
80
|
+
if is_dict(properties):
|
|
81
|
+
json_schema["required"] = [prop for prop in properties.keys()]
|
|
82
|
+
json_schema["properties"] = {
|
|
83
|
+
key: _ensure_strict_json_schema(
|
|
84
|
+
prop_schema, path=(*path, "properties", key), root=root
|
|
85
|
+
)
|
|
86
|
+
for key, prop_schema in properties.items()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# arrays
|
|
90
|
+
# { 'type': 'array', 'items': {...} }
|
|
91
|
+
items = json_schema.get("items")
|
|
92
|
+
if is_dict(items):
|
|
93
|
+
json_schema["items"] = _ensure_strict_json_schema(
|
|
94
|
+
items, path=(*path, "items"), root=root
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# unions
|
|
98
|
+
one_of = json_schema.get("oneOf")
|
|
99
|
+
if isinstance(one_of, list):
|
|
100
|
+
json_schema["oneOf"] = [
|
|
101
|
+
_ensure_strict_json_schema(
|
|
102
|
+
variant, path=(*path, "oneOf", str(i)), root=root
|
|
103
|
+
)
|
|
104
|
+
for i, variant in enumerate(one_of)
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
# unions
|
|
108
|
+
any_of = json_schema.get("anyOf")
|
|
109
|
+
if isinstance(any_of, list):
|
|
110
|
+
json_schema["anyOf"] = [
|
|
111
|
+
_ensure_strict_json_schema(
|
|
112
|
+
variant, path=(*path, "anyOf", str(i)), root=root
|
|
113
|
+
)
|
|
114
|
+
for i, variant in enumerate(any_of)
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
# intersections
|
|
118
|
+
all_of = json_schema.get("allOf")
|
|
119
|
+
if isinstance(all_of, list):
|
|
120
|
+
if len(all_of) == 1:
|
|
121
|
+
json_schema.update(
|
|
122
|
+
_ensure_strict_json_schema(
|
|
123
|
+
all_of[0], path=(*path, "allOf", "0"), root=root
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
json_schema.pop("allOf")
|
|
127
|
+
else:
|
|
128
|
+
json_schema["allOf"] = [
|
|
129
|
+
_ensure_strict_json_schema(
|
|
130
|
+
entry, path=(*path, "allOf", str(i)), root=root
|
|
131
|
+
)
|
|
132
|
+
for i, entry in enumerate(all_of)
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
# strip `None` defaults as there's no meaningful distinction here
|
|
136
|
+
# the schema will still be `nullable` and the model will default
|
|
137
|
+
# to using `None` anyway
|
|
138
|
+
if json_schema.get("default", "") is None:
|
|
139
|
+
json_schema.pop("default")
|
|
140
|
+
|
|
141
|
+
# we can't use `$ref`s if there are also other properties defined, e.g.
|
|
142
|
+
# `{"$ref": "...", "description": "my description"}`
|
|
143
|
+
#
|
|
144
|
+
# so we unravel the ref
|
|
145
|
+
# `{"type": "string", "description": "my description"}`
|
|
146
|
+
ref = json_schema.get("$ref")
|
|
147
|
+
if ref is not None:
|
|
148
|
+
assert isinstance(ref, str), f"Received non-string $ref - {ref}"
|
|
149
|
+
|
|
150
|
+
resolved = resolve_ref(root=root, ref=ref)
|
|
151
|
+
if not is_dict(resolved):
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"Expected `$ref: {ref}` to resolved to a dictionary but got {resolved}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# properties from the json schema take priority over the ones on the `$ref`
|
|
157
|
+
json_schema.update({**resolved, **json_schema})
|
|
158
|
+
json_schema.pop("$ref")
|
|
159
|
+
# Since the schema expanded from `$ref` might not have `additionalProperties: false` applied,
|
|
160
|
+
# we call `_ensure_strict_json_schema` again to fix the inlined schema and ensure it's valid.
|
|
161
|
+
return _ensure_strict_json_schema(json_schema, path=path, root=root)
|
|
162
|
+
|
|
163
|
+
return json_schema
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def resolve_ref(*, root: dict[str, object], ref: str) -> object:
|
|
167
|
+
if not ref.startswith("#/"):
|
|
168
|
+
raise ValueError(f"Unexpected $ref format {ref!r}; Does not start with #/")
|
|
169
|
+
|
|
170
|
+
path = ref[2:].split("/")
|
|
171
|
+
resolved = root
|
|
172
|
+
for key in path:
|
|
173
|
+
value = resolved[key]
|
|
174
|
+
assert is_dict(value), (
|
|
175
|
+
f"encountered non-dictionary entry while resolving {ref} - {resolved}"
|
|
176
|
+
)
|
|
177
|
+
resolved = value
|
|
178
|
+
|
|
179
|
+
return resolved
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def is_basemodel_type(typ: type) -> TypeGuard[type[pydantic.BaseModel]]:
|
|
183
|
+
if not inspect.isclass(typ):
|
|
184
|
+
return False
|
|
185
|
+
return issubclass(typ, pydantic.BaseModel)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def is_dataclass_like_type(typ: type) -> bool:
|
|
189
|
+
"""Returns True if the given type likely used `@pydantic.dataclass`"""
|
|
190
|
+
return hasattr(typ, "__pydantic_config__")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def is_dict(obj: object) -> TypeGuard[dict[str, object]]:
|
|
194
|
+
# just pretend that we know there are only `str` keys
|
|
195
|
+
# as that check is not worth the performance cost
|
|
196
|
+
return isinstance(obj, dict)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def clean_up_items(items: dict[str, Any], filtered: list[str]) -> dict[str, Any]:
|
|
200
|
+
"""Cleans up the items section of the schema by removing filtered agents and drivers from the references."""
|
|
201
|
+
if "discriminator" in items and "mapping" in items["discriminator"]:
|
|
202
|
+
for name, module in list(items["discriminator"]["mapping"].items()):
|
|
203
|
+
if filtered and module.split("/")[-1] in filtered:
|
|
204
|
+
del items["discriminator"]["mapping"][name]
|
|
205
|
+
if "oneOf" in items:
|
|
206
|
+
for item in list(items["oneOf"]):
|
|
207
|
+
if "$ref" in item:
|
|
208
|
+
module_name = item["$ref"].split("/")[-1]
|
|
209
|
+
if module_name in filtered:
|
|
210
|
+
items["oneOf"].remove(item)
|
|
211
|
+
return items
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def cleanup_anyof(anyof: list[dict[str, Any]], filtered: list[str]):
|
|
215
|
+
"""Cleans up the anyof section of the schema by removing filtered agents and drivers from the references."""
|
|
216
|
+
mapping = {}
|
|
217
|
+
if "discriminator" in anyof[0]:
|
|
218
|
+
anyof_mapping = anyof[0]["discriminator"].get("mapping", {})
|
|
219
|
+
for name, module in anyof_mapping.items():
|
|
220
|
+
module_name = module.split("/")[-1]
|
|
221
|
+
if all(module_name not in fa for fa in filtered):
|
|
222
|
+
mapping[name] = module
|
|
223
|
+
anyof[0]["discriminator"]["mapping"] = mapping
|
|
224
|
+
if "oneOf" in anyof[0]:
|
|
225
|
+
for module in anyof[0]["oneOf"]:
|
|
226
|
+
if "$ref" in module:
|
|
227
|
+
module_name = module["$ref"].split("/")[-1]
|
|
228
|
+
if module_name in filtered:
|
|
229
|
+
anyof[0]["oneOf"].remove(module)
|
|
230
|
+
return anyof
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def cleanup_properties(
|
|
234
|
+
properties: dict[str, Any], filtered_agents: list[str], filtered_drivers: list[str]
|
|
235
|
+
) -> dict[str, Any]:
|
|
236
|
+
"""Cleans up the properties section of the schema by removing filtered agents and drivers from the references."""
|
|
237
|
+
filtered = filtered_agents + filtered_drivers
|
|
238
|
+
steps = ["preprocess", "context", "generation", "postprocess", "drivers"]
|
|
239
|
+
for step in steps:
|
|
240
|
+
if step not in properties:
|
|
241
|
+
continue
|
|
242
|
+
items = properties[step].get("items")
|
|
243
|
+
if items:
|
|
244
|
+
properties[step]["items"] = await clean_up_items(items, filtered)
|
|
245
|
+
return properties
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def cleanup_definitions(
|
|
249
|
+
definitions: dict[str, Any], filtered_agents: list[str]
|
|
250
|
+
) -> dict[str, Any]:
|
|
251
|
+
"""Cleans up the definitions section of the schema by removing filtered agents from the definitions."""
|
|
252
|
+
for item, item_schema in definitions.items():
|
|
253
|
+
if item.endswith("AgentConfig"):
|
|
254
|
+
if item in filtered_agents:
|
|
255
|
+
del definitions[item]
|
|
256
|
+
continue
|
|
257
|
+
else:
|
|
258
|
+
properties = item_schema.get("properties", {})
|
|
259
|
+
for property in FLOW_PROPERTIES:
|
|
260
|
+
if property in item_schema["properties"]:
|
|
261
|
+
if "anyOf" in properties[property]:
|
|
262
|
+
anyof = properties[property]["anyOf"]
|
|
263
|
+
if "items" in anyof[0]:
|
|
264
|
+
items = anyof[0]["items"]
|
|
265
|
+
properties[property]["anyOf"][0][
|
|
266
|
+
"items"
|
|
267
|
+
] = await clean_up_items(items, filtered_agents)
|
|
268
|
+
else:
|
|
269
|
+
properties[property]["anyOf"] = await cleanup_anyof(
|
|
270
|
+
anyof, filtered_agents
|
|
271
|
+
)
|
|
272
|
+
elif "items" in properties[property]:
|
|
273
|
+
items = properties[property]["items"]
|
|
274
|
+
properties[property]["items"] = await clean_up_items(
|
|
275
|
+
items, filtered_agents
|
|
276
|
+
)
|
|
277
|
+
elif "discriminator" in properties[property]:
|
|
278
|
+
properties[property] = await clean_up_items(
|
|
279
|
+
properties[property], filtered_agents
|
|
280
|
+
)
|
|
281
|
+
item_schema["properties"] = properties
|
|
282
|
+
definitions[item] = item_schema
|
|
283
|
+
|
|
284
|
+
return definitions
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from nucliadb_agentic_api.v1 import (
|
|
2
|
+
agentic_configs,
|
|
3
|
+
ask,
|
|
4
|
+
ask_websocket,
|
|
5
|
+
mcp_nucliadb,
|
|
6
|
+
oauth,
|
|
7
|
+
sources,
|
|
8
|
+
)
|
|
9
|
+
from nucliadb_agentic_api.v1.router import router
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"agentic_configs",
|
|
13
|
+
"ask_websocket",
|
|
14
|
+
"ask",
|
|
15
|
+
"mcp_nucliadb",
|
|
16
|
+
"oauth",
|
|
17
|
+
"sources",
|
|
18
|
+
"router",
|
|
19
|
+
]
|