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,117 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, cast
|
|
2
|
+
|
|
3
|
+
from fastapi import Header, HTTPException, Request, Response
|
|
4
|
+
from nucliadb_models.resource import NucliaDBRoles
|
|
5
|
+
from nucliadb_utils.authentication import requires
|
|
6
|
+
|
|
7
|
+
from nucliadb_agentic_api import exceptions
|
|
8
|
+
from nucliadb_agentic_api.models import AgenticConfigSchema
|
|
9
|
+
from nucliadb_agentic_api.v1.router import router
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from nucliadb_agentic_api.app import HTTPApplication
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.post(
|
|
16
|
+
"/api/v1/kb/{kbid}/agentic_configs/{agentic_id}",
|
|
17
|
+
status_code=201,
|
|
18
|
+
summary="Create agentic configuration",
|
|
19
|
+
tags=["Agentic configs"],
|
|
20
|
+
)
|
|
21
|
+
@requires(NucliaDBRoles.OWNER)
|
|
22
|
+
async def create_agentic_config_endpoint(
|
|
23
|
+
request: Request,
|
|
24
|
+
kbid: str,
|
|
25
|
+
agentic_id: str,
|
|
26
|
+
item: AgenticConfigSchema,
|
|
27
|
+
x_nucliadb_account: str = Header(default="", include_in_schema=False),
|
|
28
|
+
) -> Response:
|
|
29
|
+
app = cast("HTTPApplication", request.app)
|
|
30
|
+
try:
|
|
31
|
+
await app.agent_manager.create_agentic_config(
|
|
32
|
+
account=x_nucliadb_account,
|
|
33
|
+
kbid=kbid,
|
|
34
|
+
agentic_id=agentic_id,
|
|
35
|
+
config=item,
|
|
36
|
+
)
|
|
37
|
+
except exceptions.Conflict as exc:
|
|
38
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
39
|
+
except exceptions.InvalidReference as exc:
|
|
40
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
41
|
+
return Response(status_code=201)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.get(
|
|
45
|
+
"/api/v1/kb/{kbid}/agentic_configs/{agentic_id}",
|
|
46
|
+
status_code=200,
|
|
47
|
+
summary="Get agentic configuration",
|
|
48
|
+
tags=["Agentic configs"],
|
|
49
|
+
response_model=AgenticConfigSchema,
|
|
50
|
+
response_model_exclude_none=True,
|
|
51
|
+
)
|
|
52
|
+
@requires(NucliaDBRoles.READER)
|
|
53
|
+
async def get_agentic_config_endpoint(
|
|
54
|
+
request: Request,
|
|
55
|
+
kbid: str,
|
|
56
|
+
agentic_id: str,
|
|
57
|
+
x_nucliadb_account: str = Header(default="", include_in_schema=False),
|
|
58
|
+
) -> AgenticConfigSchema:
|
|
59
|
+
app = cast("HTTPApplication", request.app)
|
|
60
|
+
try:
|
|
61
|
+
return await app.agent_manager.get_agentic_config(
|
|
62
|
+
account=x_nucliadb_account,
|
|
63
|
+
kbid=kbid,
|
|
64
|
+
agentic_id=agentic_id,
|
|
65
|
+
)
|
|
66
|
+
except exceptions.NotFound as exc:
|
|
67
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@router.get(
|
|
71
|
+
"/api/v1/kb/{kbid}/agentic_configs",
|
|
72
|
+
status_code=200,
|
|
73
|
+
summary="List agentic configurations",
|
|
74
|
+
tags=["Agentic configs"],
|
|
75
|
+
response_model=dict[str, AgenticConfigSchema],
|
|
76
|
+
response_model_exclude_none=True,
|
|
77
|
+
)
|
|
78
|
+
@requires(NucliaDBRoles.READER)
|
|
79
|
+
async def list_agentic_configs_endpoint(
|
|
80
|
+
request: Request,
|
|
81
|
+
kbid: str,
|
|
82
|
+
x_nucliadb_account: str = Header(default="", include_in_schema=False),
|
|
83
|
+
) -> dict[str, AgenticConfigSchema]:
|
|
84
|
+
app = cast("HTTPApplication", request.app)
|
|
85
|
+
return await app.agent_manager.list_agentic_configs(
|
|
86
|
+
account=x_nucliadb_account,
|
|
87
|
+
kbid=kbid,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@router.patch(
|
|
92
|
+
"/api/v1/kb/{kbid}/agentic_configs/{agentic_id}",
|
|
93
|
+
status_code=204,
|
|
94
|
+
summary="Update agentic configuration",
|
|
95
|
+
tags=["Agentic configs"],
|
|
96
|
+
)
|
|
97
|
+
@requires(NucliaDBRoles.OWNER)
|
|
98
|
+
async def patch_agentic_config_endpoint(
|
|
99
|
+
request: Request,
|
|
100
|
+
kbid: str,
|
|
101
|
+
agentic_id: str,
|
|
102
|
+
item: AgenticConfigSchema,
|
|
103
|
+
x_nucliadb_account: str = Header(default="", include_in_schema=False),
|
|
104
|
+
) -> Response:
|
|
105
|
+
app = cast("HTTPApplication", request.app)
|
|
106
|
+
try:
|
|
107
|
+
await app.agent_manager.patch_agentic_config(
|
|
108
|
+
account=x_nucliadb_account,
|
|
109
|
+
kbid=kbid,
|
|
110
|
+
agentic_id=agentic_id,
|
|
111
|
+
config=item,
|
|
112
|
+
)
|
|
113
|
+
except exceptions.NotFound as exc:
|
|
114
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
115
|
+
except exceptions.InvalidReference as exc:
|
|
116
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
117
|
+
return Response(status_code=204)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from fastapi import Header, Request, Response
|
|
5
|
+
from hyperforge_nucliadb_agentic.ask.exceptions import (
|
|
6
|
+
AnswerJsonSchemaTooLong,
|
|
7
|
+
)
|
|
8
|
+
from hyperforge_nucliadb_agentic.ask.model import (
|
|
9
|
+
AskRequest,
|
|
10
|
+
SyncAskResponse,
|
|
11
|
+
parse_max_tokens,
|
|
12
|
+
)
|
|
13
|
+
from hyperforge_nucliadb_agentic.ask.search import rpc
|
|
14
|
+
from hyperforge_nucliadb_agentic.ask.search.ask import (
|
|
15
|
+
AskResult,
|
|
16
|
+
ask,
|
|
17
|
+
handled_ask_exceptions,
|
|
18
|
+
)
|
|
19
|
+
from hyperforge_nucliadb_agentic.ask.utils.responses import (
|
|
20
|
+
HTTPClientError,
|
|
21
|
+
)
|
|
22
|
+
from nucliadb_models.configuration import AskConfig
|
|
23
|
+
from nucliadb_models.resource import NucliaDBRoles
|
|
24
|
+
from nucliadb_models.search import (
|
|
25
|
+
NucliaDBClientType,
|
|
26
|
+
)
|
|
27
|
+
from nucliadb_models.security import RequestSecurity
|
|
28
|
+
from nucliadb_sdk.v2.exceptions import PreconditionFailed, UnprocessableEntity
|
|
29
|
+
from nucliadb_utils.authentication import NucliaUser, requires
|
|
30
|
+
from pydantic import ValidationError
|
|
31
|
+
from starlette.responses import StreamingResponse
|
|
32
|
+
|
|
33
|
+
from nucliadb_agentic_api.agentic.ask_handler import create_agentic_response
|
|
34
|
+
from nucliadb_agentic_api.v1.router import router
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.post(
|
|
38
|
+
"/api/v1/kb/{kbid}/ask",
|
|
39
|
+
status_code=200,
|
|
40
|
+
summary="Ask Knowledge Box",
|
|
41
|
+
description="Ask questions on a Knowledge Box",
|
|
42
|
+
tags=["Search"],
|
|
43
|
+
response_model=SyncAskResponse,
|
|
44
|
+
)
|
|
45
|
+
@requires(NucliaDBRoles.READER)
|
|
46
|
+
async def ask_knowledgebox_endpoint(
|
|
47
|
+
request: Request,
|
|
48
|
+
kbid: str,
|
|
49
|
+
item: AskRequest,
|
|
50
|
+
x_nucliadb_account: str = Header(default="", include_in_schema=False),
|
|
51
|
+
x_ndb_client: NucliaDBClientType = Header(NucliaDBClientType.API),
|
|
52
|
+
x_show_consumption: bool = Header(default=False),
|
|
53
|
+
x_nucliadb_user: str = Header(""),
|
|
54
|
+
x_forwarded_for: str = Header(""),
|
|
55
|
+
x_synchronous: bool = Header(
|
|
56
|
+
default=False,
|
|
57
|
+
description="When set to true, outputs response as JSON in a non-streaming way. "
|
|
58
|
+
"This is slower and requires waiting for entire answer to be ready.",
|
|
59
|
+
),
|
|
60
|
+
) -> StreamingResponse | HTTPClientError | Response:
|
|
61
|
+
current_user: NucliaUser = request.user
|
|
62
|
+
# If present, security groups from AuthorizationBackend overrides any
|
|
63
|
+
# security group of the payload
|
|
64
|
+
if current_user.security_groups:
|
|
65
|
+
if item.security is None:
|
|
66
|
+
item.security = RequestSecurity(groups=current_user.security_groups)
|
|
67
|
+
else:
|
|
68
|
+
item.security.groups = current_user.security_groups
|
|
69
|
+
|
|
70
|
+
if item.search_configuration is not None:
|
|
71
|
+
search_config = await rpc.get_search_configuration(
|
|
72
|
+
rpc.get_sdk("reader"), kbid, name=item.search_configuration
|
|
73
|
+
)
|
|
74
|
+
if search_config is None:
|
|
75
|
+
return HTTPClientError(
|
|
76
|
+
status_code=400, detail="Search configuration not found"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if not isinstance(search_config.config, AskConfig):
|
|
80
|
+
return HTTPClientError(
|
|
81
|
+
status_code=400,
|
|
82
|
+
detail="This search configuration is not valid for `ask`",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
item = AskRequest.model_validate(
|
|
87
|
+
search_config.config.model_dump(exclude_unset=True)
|
|
88
|
+
| item.model_dump(exclude_unset=True)
|
|
89
|
+
)
|
|
90
|
+
except ValidationError as e:
|
|
91
|
+
detail = json.loads(e.json())
|
|
92
|
+
return HTTPClientError(status_code=422, detail=detail)
|
|
93
|
+
|
|
94
|
+
if item.agentic_config_id is not None:
|
|
95
|
+
return await create_agentic_response(
|
|
96
|
+
app=request.app,
|
|
97
|
+
agentic_config_id=item.agentic_config_id,
|
|
98
|
+
kbid=kbid,
|
|
99
|
+
ask_request=item,
|
|
100
|
+
user_id=x_nucliadb_user,
|
|
101
|
+
account=x_nucliadb_account,
|
|
102
|
+
client_type=x_ndb_client,
|
|
103
|
+
origin=x_forwarded_for,
|
|
104
|
+
x_synchronous=x_synchronous,
|
|
105
|
+
extra_predict_headers={
|
|
106
|
+
"X-Show-Consumption": str(x_show_consumption).lower()
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return await create_ask_response(
|
|
111
|
+
kbid=kbid,
|
|
112
|
+
ask_request=item,
|
|
113
|
+
user_id=x_nucliadb_user,
|
|
114
|
+
client_type=x_ndb_client,
|
|
115
|
+
origin=x_forwarded_for,
|
|
116
|
+
x_synchronous=x_synchronous,
|
|
117
|
+
extra_predict_headers={"X-Show-Consumption": str(x_show_consumption).lower()},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.post(
|
|
122
|
+
"/api/v1/kb/{kbid}/resource/{rid}/ask",
|
|
123
|
+
status_code=200,
|
|
124
|
+
summary="Ask a resource (by id)",
|
|
125
|
+
description="Ask questions to a resource",
|
|
126
|
+
tags=["Search"],
|
|
127
|
+
response_model=SyncAskResponse,
|
|
128
|
+
)
|
|
129
|
+
@requires(NucliaDBRoles.READER)
|
|
130
|
+
async def resource_ask_endpoint_by_uuid(
|
|
131
|
+
request: Request,
|
|
132
|
+
kbid: str,
|
|
133
|
+
rid: UUID,
|
|
134
|
+
item: AskRequest,
|
|
135
|
+
x_show_consumption: bool = Header(default=False),
|
|
136
|
+
x_nucliadb_account: str = Header(default="", include_in_schema=False),
|
|
137
|
+
x_ndb_client: NucliaDBClientType = Header(NucliaDBClientType.API),
|
|
138
|
+
x_nucliadb_user: str = Header(""),
|
|
139
|
+
x_forwarded_for: str = Header(""),
|
|
140
|
+
x_synchronous: bool = Header(
|
|
141
|
+
False,
|
|
142
|
+
description="When set to true, outputs response as JSON in a non-streaming way. "
|
|
143
|
+
"This is slower and requires waiting for entire answer to be ready.",
|
|
144
|
+
),
|
|
145
|
+
) -> StreamingResponse | HTTPClientError | Response:
|
|
146
|
+
current_user: NucliaUser = request.user
|
|
147
|
+
# If present, security groups from AuthorizationBackend overrides any
|
|
148
|
+
# security group of the payload
|
|
149
|
+
if current_user.security_groups:
|
|
150
|
+
if item.security is None:
|
|
151
|
+
item.security = RequestSecurity(groups=current_user.security_groups)
|
|
152
|
+
else:
|
|
153
|
+
item.security.groups = current_user.security_groups
|
|
154
|
+
|
|
155
|
+
if item.agentic_config_id is not None:
|
|
156
|
+
return await create_agentic_response(
|
|
157
|
+
app=request.app,
|
|
158
|
+
agentic_config_id=item.agentic_config_id,
|
|
159
|
+
kbid=kbid,
|
|
160
|
+
account=x_nucliadb_account,
|
|
161
|
+
ask_request=item,
|
|
162
|
+
user_id=x_nucliadb_user,
|
|
163
|
+
client_type=x_ndb_client,
|
|
164
|
+
origin=x_forwarded_for,
|
|
165
|
+
x_synchronous=x_synchronous,
|
|
166
|
+
extra_predict_headers={
|
|
167
|
+
"X-Show-Consumption": str(x_show_consumption).lower()
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return await create_ask_response(
|
|
172
|
+
kbid=kbid,
|
|
173
|
+
ask_request=item,
|
|
174
|
+
user_id=x_nucliadb_user,
|
|
175
|
+
client_type=x_ndb_client,
|
|
176
|
+
origin=x_forwarded_for,
|
|
177
|
+
x_synchronous=x_synchronous,
|
|
178
|
+
resource=str(rid),
|
|
179
|
+
extra_predict_headers={"X-Show-Consumption": str(x_show_consumption).lower()},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@router.post(
|
|
184
|
+
"/api/v1/kb/{kbid}/slug/{slug}/ask",
|
|
185
|
+
status_code=200,
|
|
186
|
+
summary="Ask a resource (by slug)",
|
|
187
|
+
description="Ask questions to a resource",
|
|
188
|
+
tags=["Search"],
|
|
189
|
+
response_model=SyncAskResponse,
|
|
190
|
+
)
|
|
191
|
+
@requires(NucliaDBRoles.READER)
|
|
192
|
+
async def resource_ask_endpoint_by_slug(
|
|
193
|
+
request: Request,
|
|
194
|
+
kbid: str,
|
|
195
|
+
slug: str,
|
|
196
|
+
item: AskRequest,
|
|
197
|
+
x_show_consumption: bool = Header(default=False),
|
|
198
|
+
x_ndb_client: NucliaDBClientType = Header(NucliaDBClientType.API),
|
|
199
|
+
x_nucliadb_user: str = Header(""),
|
|
200
|
+
x_nucliadb_account: str = Header(default="", include_in_schema=False),
|
|
201
|
+
x_forwarded_for: str = Header(""),
|
|
202
|
+
x_synchronous: bool = Header(
|
|
203
|
+
False,
|
|
204
|
+
description="When set to true, outputs response as JSON in a non-streaming way. "
|
|
205
|
+
"This is slower and requires waiting for entire answer to be ready.",
|
|
206
|
+
),
|
|
207
|
+
) -> StreamingResponse | HTTPClientError | Response:
|
|
208
|
+
resource_id = await rpc.get_resource_uuid_from_slug(
|
|
209
|
+
rpc.get_sdk("reader"), kbid, slug
|
|
210
|
+
)
|
|
211
|
+
if resource_id is None:
|
|
212
|
+
return HTTPClientError(status_code=404, detail="Resource not found")
|
|
213
|
+
|
|
214
|
+
current_user: NucliaUser = request.user
|
|
215
|
+
# If present, security groups from AuthorizationBackend overrides any
|
|
216
|
+
# security group of the payload
|
|
217
|
+
if current_user.security_groups:
|
|
218
|
+
if item.security is None:
|
|
219
|
+
item.security = RequestSecurity(groups=current_user.security_groups)
|
|
220
|
+
else:
|
|
221
|
+
item.security.groups = current_user.security_groups
|
|
222
|
+
|
|
223
|
+
if item.agentic_config_id is not None:
|
|
224
|
+
return await create_agentic_response(
|
|
225
|
+
app=request.app,
|
|
226
|
+
agentic_config_id=item.agentic_config_id,
|
|
227
|
+
kbid=kbid,
|
|
228
|
+
account=x_nucliadb_account,
|
|
229
|
+
ask_request=item,
|
|
230
|
+
user_id=x_nucliadb_user,
|
|
231
|
+
client_type=x_ndb_client,
|
|
232
|
+
origin=x_forwarded_for,
|
|
233
|
+
x_synchronous=x_synchronous,
|
|
234
|
+
resource=str(resource_id),
|
|
235
|
+
extra_predict_headers={
|
|
236
|
+
"X-Show-Consumption": str(x_show_consumption).lower()
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return await create_ask_response(
|
|
241
|
+
kbid=kbid,
|
|
242
|
+
ask_request=item,
|
|
243
|
+
user_id=x_nucliadb_user,
|
|
244
|
+
client_type=x_ndb_client,
|
|
245
|
+
origin=x_forwarded_for,
|
|
246
|
+
x_synchronous=x_synchronous,
|
|
247
|
+
resource=resource_id,
|
|
248
|
+
extra_predict_headers={"X-Show-Consumption": str(x_show_consumption).lower()},
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@handled_ask_exceptions
|
|
253
|
+
async def create_ask_response(
|
|
254
|
+
kbid: str,
|
|
255
|
+
ask_request: AskRequest,
|
|
256
|
+
user_id: str,
|
|
257
|
+
client_type: NucliaDBClientType,
|
|
258
|
+
origin: str,
|
|
259
|
+
x_synchronous: bool,
|
|
260
|
+
resource: str | None = None,
|
|
261
|
+
extra_predict_headers: dict[str, str] | None = None,
|
|
262
|
+
) -> Response:
|
|
263
|
+
ask_request.max_tokens = parse_max_tokens(ask_request.max_tokens)
|
|
264
|
+
try:
|
|
265
|
+
ask_result: AskResult = await ask(
|
|
266
|
+
search_sdk=rpc.get_sdk("search"),
|
|
267
|
+
reader_sdk=rpc.get_sdk("reader"),
|
|
268
|
+
kbid=kbid,
|
|
269
|
+
ask_request=ask_request,
|
|
270
|
+
user_id=user_id,
|
|
271
|
+
client_type=client_type,
|
|
272
|
+
origin=origin,
|
|
273
|
+
resource=resource,
|
|
274
|
+
extra_predict_headers=extra_predict_headers,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
except AnswerJsonSchemaTooLong as err:
|
|
278
|
+
return HTTPClientError(status_code=400, detail=str(err))
|
|
279
|
+
|
|
280
|
+
# forward 412 and 422 from nucliadb to the client
|
|
281
|
+
except PreconditionFailed as err:
|
|
282
|
+
return HTTPClientError(status_code=412, detail=err.message)
|
|
283
|
+
except UnprocessableEntity as err:
|
|
284
|
+
return HTTPClientError(status_code=422, detail=err.message)
|
|
285
|
+
|
|
286
|
+
headers = {
|
|
287
|
+
"NUCLIA-LEARNING-ID": ask_result.nuclia_learning_id or "unknown",
|
|
288
|
+
"Access-Control-Expose-Headers": "NUCLIA-LEARNING-ID",
|
|
289
|
+
}
|
|
290
|
+
if x_synchronous:
|
|
291
|
+
return Response(
|
|
292
|
+
content=await ask_result.json(),
|
|
293
|
+
status_code=200,
|
|
294
|
+
headers=headers,
|
|
295
|
+
media_type="application/json",
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
return StreamingResponse(
|
|
299
|
+
content=ask_result.ndjson_stream(),
|
|
300
|
+
status_code=200,
|
|
301
|
+
headers=headers,
|
|
302
|
+
media_type="application/x-ndjson",
|
|
303
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from fastapi import Header, Query, WebSocket, WebSocketDisconnect
|
|
5
|
+
from hyperforge.api.authentication import requires
|
|
6
|
+
from hyperforge.api.v1.interaction import WebsocketReceiver, stream_response
|
|
7
|
+
from hyperforge.interaction import AnswerOperation, AragAnswer, ARAGException
|
|
8
|
+
from hyperforge_nucliadb_agentic.ask.model import (
|
|
9
|
+
AskRequest,
|
|
10
|
+
)
|
|
11
|
+
from hyperforge_nucliadb_agentic.ask.search import rpc
|
|
12
|
+
from hyperforge_nucliadb_agentic.ask.utils.responses import (
|
|
13
|
+
HTTPClientError,
|
|
14
|
+
)
|
|
15
|
+
from nucliadb_models.configuration import AskConfig
|
|
16
|
+
from nucliadb_models.resource import NucliaDBRoles
|
|
17
|
+
from nucliadb_models.search import (
|
|
18
|
+
NucliaDBClientType,
|
|
19
|
+
)
|
|
20
|
+
from nucliadb_models.security import RequestSecurity
|
|
21
|
+
from nucliadb_utils.authentication import NucliaUser
|
|
22
|
+
from pydantic import ValidationError
|
|
23
|
+
|
|
24
|
+
from nucliadb_agentic_api.v1.router import router
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.websocket("/api/v1/kb/{kbid}/ask")
|
|
28
|
+
@requires(NucliaDBRoles.READER)
|
|
29
|
+
async def websocket_endpoint(
|
|
30
|
+
websocket: WebSocket,
|
|
31
|
+
kbid: str,
|
|
32
|
+
agentic_config_id: str = Query(
|
|
33
|
+
..., description="ID of the agentic configuration to use for this session"
|
|
34
|
+
),
|
|
35
|
+
search_configuration: str | None = Query(
|
|
36
|
+
default=None,
|
|
37
|
+
description="Optional search configuration to use for this session",
|
|
38
|
+
),
|
|
39
|
+
groups: list[str] = Query(
|
|
40
|
+
default=[],
|
|
41
|
+
description="List of group ids to do the request with. Overrides any security group from the Authorization header.",
|
|
42
|
+
),
|
|
43
|
+
keep_open: bool = Query(
|
|
44
|
+
default=False,
|
|
45
|
+
description="Whether to keep the websocket open after the first question",
|
|
46
|
+
),
|
|
47
|
+
x_stf_user: str = Header(..., include_in_schema=False),
|
|
48
|
+
x_stf_account: str = Header(..., include_in_schema=False),
|
|
49
|
+
x_stf_account_type: str = Header(..., include_in_schema=False),
|
|
50
|
+
x_nucliadb_account: str = Header(default="", include_in_schema=False),
|
|
51
|
+
x_ndb_client: NucliaDBClientType = Header(NucliaDBClientType.API),
|
|
52
|
+
x_show_consumption: bool = Header(default=False),
|
|
53
|
+
x_nucliadb_user: str = Header(""),
|
|
54
|
+
x_forwarded_for: str = Header(""),
|
|
55
|
+
):
|
|
56
|
+
await websocket.accept()
|
|
57
|
+
receiver = WebsocketReceiver(websocket)
|
|
58
|
+
task = asyncio.create_task(receiver.run())
|
|
59
|
+
|
|
60
|
+
current_user: NucliaUser = websocket.user
|
|
61
|
+
# If present, security groups from AuthorizationBackend overrides any
|
|
62
|
+
# security group of the payload
|
|
63
|
+
if current_user.security_groups:
|
|
64
|
+
security = RequestSecurity(groups=current_user.security_groups)
|
|
65
|
+
else:
|
|
66
|
+
security = RequestSecurity(groups=groups)
|
|
67
|
+
|
|
68
|
+
item = AskRequest(query="")
|
|
69
|
+
item.security = security
|
|
70
|
+
|
|
71
|
+
if search_configuration is not None:
|
|
72
|
+
search_config = await rpc.get_search_configuration(
|
|
73
|
+
rpc.get_sdk("reader"), kbid, name=search_configuration
|
|
74
|
+
)
|
|
75
|
+
if search_config is None:
|
|
76
|
+
return HTTPClientError(
|
|
77
|
+
status_code=400, detail="Search configuration not found"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if not isinstance(search_config.config, AskConfig):
|
|
81
|
+
return HTTPClientError(
|
|
82
|
+
status_code=400,
|
|
83
|
+
detail="This search configuration is not valid for `ask`",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
item = AskRequest.model_validate(
|
|
88
|
+
search_config.config.model_dump(exclude_unset=True)
|
|
89
|
+
| item.model_dump(exclude_unset=True)
|
|
90
|
+
)
|
|
91
|
+
except ValidationError as e:
|
|
92
|
+
detail = json.loads(e.json())
|
|
93
|
+
return HTTPClientError(status_code=422, detail=detail)
|
|
94
|
+
|
|
95
|
+
# Wait for questions
|
|
96
|
+
first_question = True
|
|
97
|
+
while True:
|
|
98
|
+
if not keep_open and not first_question:
|
|
99
|
+
break
|
|
100
|
+
try:
|
|
101
|
+
interaction = await receiver.receive_question()
|
|
102
|
+
except WebSocketDisconnect:
|
|
103
|
+
break
|
|
104
|
+
except ValueError as e:
|
|
105
|
+
# Wrong message type received
|
|
106
|
+
await websocket.send_json(
|
|
107
|
+
AragAnswer(
|
|
108
|
+
exception=ARAGException(detail=f"Unexpected message: {str(e)}"),
|
|
109
|
+
operation=AnswerOperation.ERROR,
|
|
110
|
+
).model_dump()
|
|
111
|
+
)
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
first_question = False
|
|
115
|
+
for header, header_value in websocket.headers.items():
|
|
116
|
+
interaction.headers[header] = header_value
|
|
117
|
+
|
|
118
|
+
item.query = interaction.question
|
|
119
|
+
interaction.arguments["ask_request"] = item.model_dump_json()
|
|
120
|
+
|
|
121
|
+
async for msg in stream_response(
|
|
122
|
+
websocket.app,
|
|
123
|
+
receiver,
|
|
124
|
+
x_stf_account,
|
|
125
|
+
kbid,
|
|
126
|
+
"ephemeral",
|
|
127
|
+
interaction,
|
|
128
|
+
workflow_id=agentic_config_id,
|
|
129
|
+
):
|
|
130
|
+
try:
|
|
131
|
+
await websocket.send_text(msg.model_dump_json())
|
|
132
|
+
except (RuntimeError, WebSocketDisconnect):
|
|
133
|
+
# WebSocket already closed
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
task.cancel()
|
|
138
|
+
await websocket.close()
|
|
139
|
+
except RuntimeError:
|
|
140
|
+
# WebSocket already closed
|
|
141
|
+
pass
|