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,66 @@
|
|
|
1
|
+
import uvicorn
|
|
2
|
+
from hyperforge import openapi
|
|
3
|
+
from hyperforge.feature_flag import get_flag_service
|
|
4
|
+
from nucliadb_telemetry.fastapi import instrument_app
|
|
5
|
+
from nucliadb_telemetry.logs import setup_logging
|
|
6
|
+
from nucliadb_telemetry.settings import LogFormatType, LogLevel, LogSettings
|
|
7
|
+
from nucliadb_telemetry.utils import get_telemetry, setup_telemetry
|
|
8
|
+
from nucliadb_utils.settings import AuditSettings
|
|
9
|
+
|
|
10
|
+
from nucliadb_agentic_api import SERVICE_NAME
|
|
11
|
+
from nucliadb_agentic_api.app import HTTPApplication
|
|
12
|
+
from nucliadb_agentic_api.db.settings import DataManagerSettings
|
|
13
|
+
from nucliadb_agentic_api.logging import set_sentry
|
|
14
|
+
from nucliadb_agentic_api.settings import Settings
|
|
15
|
+
from nucliadb_agentic_api.v1.router import router
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(): # pragma: no cover
|
|
19
|
+
settings = Settings()
|
|
20
|
+
setup_logging(
|
|
21
|
+
settings=LogSettings(
|
|
22
|
+
log_format_type=LogFormatType.STRUCTURED
|
|
23
|
+
if not settings.debug
|
|
24
|
+
else LogFormatType.PLAIN,
|
|
25
|
+
debug=settings.debug,
|
|
26
|
+
log_level=LogLevel(settings.log_level),
|
|
27
|
+
logger_levels={
|
|
28
|
+
"uvicorn.error": LogLevel.ERROR,
|
|
29
|
+
"nucliadb_telemetry": LogLevel.ERROR,
|
|
30
|
+
"mcp.client.streamable_http": LogLevel.WARNING,
|
|
31
|
+
"mcp.server.lowlevel.server": LogLevel.WARNING,
|
|
32
|
+
"hyperforge.configure": LogLevel.WARNING,
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
setup_telemetry(SERVICE_NAME) # type: ignore
|
|
37
|
+
if settings.sentry_url is not None:
|
|
38
|
+
set_sentry(
|
|
39
|
+
settings.zone,
|
|
40
|
+
settings.running_environment,
|
|
41
|
+
settings.sentry_url,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
get_flag_service() # precache the flag service
|
|
45
|
+
|
|
46
|
+
data_manager_settings = DataManagerSettings() # type: ignore
|
|
47
|
+
audit_settings = AuditSettings()
|
|
48
|
+
app = HTTPApplication(
|
|
49
|
+
settings,
|
|
50
|
+
data_manager_settings=data_manager_settings,
|
|
51
|
+
audit_settings=audit_settings,
|
|
52
|
+
)
|
|
53
|
+
instrument_app(
|
|
54
|
+
app,
|
|
55
|
+
tracer_provider=get_telemetry(SERVICE_NAME),
|
|
56
|
+
excluded_urls=["/", "/metrics", "/health/ready", "/health/alive"],
|
|
57
|
+
metrics=True,
|
|
58
|
+
trace_id_on_responses=True,
|
|
59
|
+
)
|
|
60
|
+
uvicorn.run(app, host=settings.http_host, port=settings.http_port)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def extract_openapi():
|
|
64
|
+
openapi.extract_openapi_command(
|
|
65
|
+
"nucliadb_agentic_api", "NucliaDB Agentic API", router
|
|
66
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
from time import time
|
|
4
|
+
from typing import Dict
|
|
5
|
+
|
|
6
|
+
import databases
|
|
7
|
+
import sqlalchemy as sa
|
|
8
|
+
from hyperforge.database import metadata
|
|
9
|
+
from hyperforge.retrieval.config import RetrievalAgentConfig
|
|
10
|
+
from hyperforge_google.config import GoogleDriverConfig, GoogleInnerConfig
|
|
11
|
+
from hyperforge_nucliadb_agentic.ask.model import AskRequest
|
|
12
|
+
from hyperforge_perplexity.config import PerplexityDriverConfig, PerplexityInnerConfig
|
|
13
|
+
from lru import LRU
|
|
14
|
+
from nucliadb_telemetry.utils import get_telemetry, init_telemetry
|
|
15
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
16
|
+
|
|
17
|
+
from nucliadb_agentic_api import exceptions
|
|
18
|
+
from nucliadb_agentic_api.db.settings import DataManagerSettings
|
|
19
|
+
from nucliadb_agentic_api.db.sources import Sources
|
|
20
|
+
from nucliadb_agentic_api.db.transform import transform_agentic_config
|
|
21
|
+
from nucliadb_agentic_api.models import AgenticConfigSchema
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Imported lazily in methods to avoid any load-order sensitivity between the two
|
|
25
|
+
# table modules (both share the same `hyperforge.database.metadata`).
|
|
26
|
+
def _get_sources_table(): # pragma: no cover
|
|
27
|
+
from nucliadb_agentic_api.db.sources import sources_table
|
|
28
|
+
|
|
29
|
+
return sources_table
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
SERVICE_NAME = "AGENTIC_CONFIGS_DB"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def utc_now() -> datetime.datetime:
|
|
36
|
+
return datetime.datetime.now(datetime.timezone.utc)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
agentic_config_table = sa.Table(
|
|
40
|
+
"agentic_config_table",
|
|
41
|
+
metadata,
|
|
42
|
+
sa.Column("account", sa.String, primary_key=True, nullable=False, index=True),
|
|
43
|
+
sa.Column("kbid", sa.String, primary_key=True, nullable=False, index=True), # KBID
|
|
44
|
+
sa.Column("agentic_id", sa.String, primary_key=True, nullable=False), # Agentic ID
|
|
45
|
+
sa.Column("created", sa.DateTime, default=sa.func.now()),
|
|
46
|
+
sa.Column("modified", sa.DateTime, onupdate=sa.func.now()),
|
|
47
|
+
sa.Column("title", sa.String, nullable=True),
|
|
48
|
+
sa.Column("config", JSONB, nullable=False),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
CACHE: Dict[str, AgenticConfigSchema] = LRU(size=1024) # type: ignore
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _cache_key(account: str, kbid: str, agentic_id: str) -> str:
|
|
56
|
+
return f"{account}:{kbid}:{agentic_id}:{int(time()) // 5}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _collect_source_ids(config: AgenticConfigSchema) -> list[str]:
|
|
60
|
+
"""Return all non-None source_id values declared in smart_agent sources."""
|
|
61
|
+
if not config.smart_agent:
|
|
62
|
+
return []
|
|
63
|
+
return [source for source in config.smart_agent.sources if source is not None]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _serialize_config(config: AgenticConfigSchema) -> dict:
|
|
67
|
+
return config.model_dump(mode="json")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _config_from_row(row) -> AgenticConfigSchema:
|
|
71
|
+
return AgenticConfigSchema.model_validate(row["config"])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AgenticConfigs:
|
|
75
|
+
settings: DataManagerSettings
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
database: databases.Database,
|
|
80
|
+
settings: DataManagerSettings,
|
|
81
|
+
):
|
|
82
|
+
self.database = database
|
|
83
|
+
self.settings = settings
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
async def from_settings(
|
|
87
|
+
cls,
|
|
88
|
+
settings: DataManagerSettings,
|
|
89
|
+
):
|
|
90
|
+
tracer_provider = get_telemetry(SERVICE_NAME)
|
|
91
|
+
if tracer_provider:
|
|
92
|
+
await init_telemetry(tracer_provider)
|
|
93
|
+
|
|
94
|
+
database = databases.Database(settings.postgresql_dsn)
|
|
95
|
+
|
|
96
|
+
return cls(database=database, settings=settings)
|
|
97
|
+
|
|
98
|
+
async def initialize(self):
|
|
99
|
+
await self.database.connect()
|
|
100
|
+
|
|
101
|
+
async def finalize(self):
|
|
102
|
+
await self.database.disconnect()
|
|
103
|
+
|
|
104
|
+
async def patch_agentic_config(
|
|
105
|
+
self, account: str, kbid: str, agentic_id: str, config: AgenticConfigSchema
|
|
106
|
+
):
|
|
107
|
+
source_ids = _collect_source_ids(config)
|
|
108
|
+
if source_ids:
|
|
109
|
+
await self._validate_sources_exist(account, kbid, source_ids)
|
|
110
|
+
|
|
111
|
+
query = (
|
|
112
|
+
sa.update(agentic_config_table)
|
|
113
|
+
.values(
|
|
114
|
+
title=config.title,
|
|
115
|
+
config=_serialize_config(config),
|
|
116
|
+
)
|
|
117
|
+
.where(
|
|
118
|
+
agentic_config_table.c.account == account,
|
|
119
|
+
agentic_config_table.c.kbid == kbid,
|
|
120
|
+
agentic_config_table.c.agentic_id == agentic_id,
|
|
121
|
+
)
|
|
122
|
+
.returning(agentic_config_table.c.agentic_id)
|
|
123
|
+
)
|
|
124
|
+
updated = await self.database.fetch_one(query)
|
|
125
|
+
if updated is None:
|
|
126
|
+
raise exceptions.NotFound("Agentic configuration not found")
|
|
127
|
+
CACHE[_cache_key(account, kbid, agentic_id)] = config
|
|
128
|
+
|
|
129
|
+
async def create_agentic_config(
|
|
130
|
+
self, account: str, kbid: str, agentic_id: str, config: AgenticConfigSchema
|
|
131
|
+
):
|
|
132
|
+
source_ids = _collect_source_ids(config)
|
|
133
|
+
if source_ids:
|
|
134
|
+
await self._validate_sources_exist(account, kbid, source_ids)
|
|
135
|
+
|
|
136
|
+
query = sa.select(agentic_config_table.c.agentic_id).where(
|
|
137
|
+
agentic_config_table.c.account == account,
|
|
138
|
+
agentic_config_table.c.kbid == kbid,
|
|
139
|
+
agentic_config_table.c.agentic_id == agentic_id,
|
|
140
|
+
)
|
|
141
|
+
existing = await self.database.fetch_one(query)
|
|
142
|
+
if existing is not None:
|
|
143
|
+
raise exceptions.Conflict("Agentic configuration already exists")
|
|
144
|
+
|
|
145
|
+
insert_query = sa.insert(agentic_config_table).values(
|
|
146
|
+
account=account,
|
|
147
|
+
kbid=kbid,
|
|
148
|
+
agentic_id=agentic_id,
|
|
149
|
+
title=config.title,
|
|
150
|
+
config=_serialize_config(config),
|
|
151
|
+
)
|
|
152
|
+
await self.database.execute(insert_query)
|
|
153
|
+
CACHE[_cache_key(account, kbid, agentic_id)] = config
|
|
154
|
+
|
|
155
|
+
async def _validate_sources_exist(
|
|
156
|
+
self, account: str, kbid: str, source_ids: list[str]
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Raise InvalidReference if any source_id is not present in sources_table."""
|
|
159
|
+
sources_table = _get_sources_table()
|
|
160
|
+
query = sa.select(sources_table.c.source_id).where(
|
|
161
|
+
sources_table.c.account == account,
|
|
162
|
+
sources_table.c.kbid == kbid,
|
|
163
|
+
sources_table.c.source_id.in_(source_ids),
|
|
164
|
+
)
|
|
165
|
+
rows = await self.database.fetch_all(query)
|
|
166
|
+
found = {row["source_id"] for row in rows}
|
|
167
|
+
missing = sorted(set(source_ids) - found)
|
|
168
|
+
if missing:
|
|
169
|
+
raise exceptions.InvalidReference(
|
|
170
|
+
f"Source(s) not found: {', '.join(missing)}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
async def delete_configs_referencing_source(
|
|
174
|
+
self, account: str, kbid: str, source_id: str
|
|
175
|
+
) -> int:
|
|
176
|
+
"""Delete every agentic config that references source_id in its smart_agent
|
|
177
|
+
sources list. Returns the number of deleted configs.
|
|
178
|
+
|
|
179
|
+
Uses the JSONB containment operator (@>) to find matching rows:
|
|
180
|
+
config->'smart_agent'->'sources' @> '[{"source_id": "<id>"}]'
|
|
181
|
+
"""
|
|
182
|
+
query = (
|
|
183
|
+
sa.delete(agentic_config_table)
|
|
184
|
+
.where(
|
|
185
|
+
agentic_config_table.c.account == account,
|
|
186
|
+
agentic_config_table.c.kbid == kbid,
|
|
187
|
+
agentic_config_table.c.config["smart_agent"]["sources"].op("@>")(
|
|
188
|
+
sa.cast(
|
|
189
|
+
json.dumps([{"source_id": source_id}]),
|
|
190
|
+
JSONB,
|
|
191
|
+
)
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
.returning(agentic_config_table.c.agentic_id)
|
|
195
|
+
)
|
|
196
|
+
deleted_rows = await self.database.fetch_all(query)
|
|
197
|
+
for row in deleted_rows:
|
|
198
|
+
key = _cache_key(account, kbid, row["agentic_id"])
|
|
199
|
+
if key in CACHE:
|
|
200
|
+
del CACHE[key]
|
|
201
|
+
return len(deleted_rows)
|
|
202
|
+
|
|
203
|
+
async def get_agentic_config(
|
|
204
|
+
self, account: str, kbid: str, agentic_id: str
|
|
205
|
+
) -> AgenticConfigSchema:
|
|
206
|
+
key = _cache_key(account, kbid, agentic_id)
|
|
207
|
+
if key not in CACHE:
|
|
208
|
+
query = sa.select(agentic_config_table).where(
|
|
209
|
+
agentic_config_table.c.account == account,
|
|
210
|
+
agentic_config_table.c.kbid == kbid,
|
|
211
|
+
agentic_config_table.c.agentic_id == agentic_id,
|
|
212
|
+
)
|
|
213
|
+
row = await self.database.fetch_one(query)
|
|
214
|
+
if not row:
|
|
215
|
+
raise exceptions.NotFound("Agentic configuration not found")
|
|
216
|
+
|
|
217
|
+
config = _config_from_row(row)
|
|
218
|
+
|
|
219
|
+
CACHE[key] = config
|
|
220
|
+
else:
|
|
221
|
+
config = CACHE[key]
|
|
222
|
+
return config
|
|
223
|
+
|
|
224
|
+
async def list_agentic_configs(
|
|
225
|
+
self, account: str, kbid: str
|
|
226
|
+
) -> dict[str, AgenticConfigSchema]:
|
|
227
|
+
query = sa.select(agentic_config_table).where(
|
|
228
|
+
agentic_config_table.c.account == account,
|
|
229
|
+
agentic_config_table.c.kbid == kbid,
|
|
230
|
+
)
|
|
231
|
+
rows = await self.database.fetch_all(query)
|
|
232
|
+
return {row["agentic_id"]: _config_from_row(row) for row in rows}
|
|
233
|
+
|
|
234
|
+
async def get_agent_config(
|
|
235
|
+
self,
|
|
236
|
+
account: str,
|
|
237
|
+
kbid: str,
|
|
238
|
+
internal_nucliadb_url: str | None = None,
|
|
239
|
+
internal_nucliadb: bool = True,
|
|
240
|
+
external_nucliadb_key: str | None = None,
|
|
241
|
+
external_nucliadb_url: str | None = None,
|
|
242
|
+
default_memory: bool = False,
|
|
243
|
+
workflow_id: str = "default",
|
|
244
|
+
ask_request: AskRequest | None = None,
|
|
245
|
+
) -> RetrievalAgentConfig:
|
|
246
|
+
# For now, we only support one config per KB, so we ignore agent_id and workflow_id, but in the future we can extend this method to support multiple configs per KB and select
|
|
247
|
+
# the right one based on these parameters
|
|
248
|
+
retrieval_config: RetrievalAgentConfig
|
|
249
|
+
agentic_config = await self.get_agentic_config(account, kbid, workflow_id)
|
|
250
|
+
|
|
251
|
+
source_manager = Sources(self.database, self.settings)
|
|
252
|
+
retrieval_config, drivers, global_drivers = await transform_agentic_config(
|
|
253
|
+
agentic_config,
|
|
254
|
+
source_manager,
|
|
255
|
+
account,
|
|
256
|
+
internal_nucliadb=internal_nucliadb,
|
|
257
|
+
internal_nucliadb_url=internal_nucliadb_url,
|
|
258
|
+
external_nucliadb_url=external_nucliadb_url,
|
|
259
|
+
external_nucliadb_key=external_nucliadb_key,
|
|
260
|
+
ask_request=ask_request,
|
|
261
|
+
kbid=kbid,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
for driver_config in drivers.values():
|
|
265
|
+
retrieval_config.drivers.append(driver_config)
|
|
266
|
+
|
|
267
|
+
if "google" in global_drivers:
|
|
268
|
+
retrieval_config.drivers.append(
|
|
269
|
+
GoogleDriverConfig(
|
|
270
|
+
identifier="google",
|
|
271
|
+
name="google",
|
|
272
|
+
config=GoogleInnerConfig(
|
|
273
|
+
credentials=self.settings.hyperforge_google_credentials,
|
|
274
|
+
location=self.settings.hyperforge_google_location,
|
|
275
|
+
vertexai=True,
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if "perplexity" in global_drivers:
|
|
281
|
+
retrieval_config.drivers.append(
|
|
282
|
+
PerplexityDriverConfig(
|
|
283
|
+
identifier="perplexity",
|
|
284
|
+
name="perplexity",
|
|
285
|
+
provider="perplexity",
|
|
286
|
+
config=PerplexityInnerConfig(
|
|
287
|
+
key=self.settings.hyperforge_perplexity_key
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
return retrieval_config
|
|
292
|
+
|
|
293
|
+
async def ensure_workflow_active(self, account: str, kbid: str, workflow_id: str):
|
|
294
|
+
# For now, we only support one config per KB, so we ignore agent_id and workflow_id, but in the future we can extend this method to support multiple configs per KB and select
|
|
295
|
+
# the right one based on these parameters
|
|
296
|
+
await self.get_agentic_config(account, kbid, workflow_id)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DataManagerSettings(BaseSettings):
|
|
5
|
+
postgresql_dsn: str
|
|
6
|
+
export_read_chunk_size: int = 1024 * 1024 # 1 MB
|
|
7
|
+
export_read_max_size: int = 10 * 1024 * 1024 # 10 MB
|
|
8
|
+
|
|
9
|
+
hyperforge_google_credentials: str = ""
|
|
10
|
+
hyperforge_google_location: str = ""
|
|
11
|
+
hyperforge_perplexity_key: str = ""
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from time import time
|
|
3
|
+
|
|
4
|
+
import databases
|
|
5
|
+
import sqlalchemy as sa
|
|
6
|
+
from hyperforge.database import metadata
|
|
7
|
+
from lru import LRU
|
|
8
|
+
from nucliadb_telemetry.utils import get_telemetry, init_telemetry
|
|
9
|
+
from pydantic import TypeAdapter
|
|
10
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
11
|
+
|
|
12
|
+
from nucliadb_agentic_api import exceptions
|
|
13
|
+
from nucliadb_agentic_api.db.settings import DataManagerSettings
|
|
14
|
+
from nucliadb_agentic_api.models import SourceSchema
|
|
15
|
+
|
|
16
|
+
SERVICE_NAME = "SOURCES_DB"
|
|
17
|
+
|
|
18
|
+
_source_adapter: TypeAdapter = TypeAdapter(SourceSchema)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def utc_now() -> datetime.datetime:
|
|
22
|
+
return datetime.datetime.now(datetime.timezone.utc)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
sources_table = sa.Table(
|
|
26
|
+
"sources_table",
|
|
27
|
+
metadata,
|
|
28
|
+
sa.Column("account", sa.String, primary_key=True, nullable=False, index=True),
|
|
29
|
+
sa.Column("kbid", sa.String, primary_key=True, nullable=False, index=True),
|
|
30
|
+
sa.Column("source_id", sa.String, primary_key=True, nullable=False),
|
|
31
|
+
sa.Column("agentic_config_id", sa.String, nullable=True, index=True),
|
|
32
|
+
sa.Column("created", sa.DateTime, default=sa.func.now()),
|
|
33
|
+
sa.Column("modified", sa.DateTime, onupdate=sa.func.now()),
|
|
34
|
+
sa.Column("description", sa.String, nullable=True),
|
|
35
|
+
sa.Column("type", sa.String, nullable=False),
|
|
36
|
+
sa.Column("config", JSONB, nullable=False),
|
|
37
|
+
sa.ForeignKeyConstraint(
|
|
38
|
+
["account", "kbid", "agentic_config_id"],
|
|
39
|
+
[
|
|
40
|
+
"agentic_config_table.account",
|
|
41
|
+
"agentic_config_table.kbid",
|
|
42
|
+
"agentic_config_table.agentic_id",
|
|
43
|
+
],
|
|
44
|
+
ondelete="CASCADE",
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
CACHE: LRU = LRU(size=1024)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _cache_key(account: str, kbid: str, source_id: str) -> str:
|
|
53
|
+
return f"src:{account}:{kbid}:{source_id}:{int(time()) // 5}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _serialize_source(source: SourceSchema) -> dict: # type: ignore[valid-type]
|
|
57
|
+
return source.model_dump(mode="json") # type: ignore[union-attr]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _source_from_row(row) -> SourceSchema: # type: ignore[valid-type]
|
|
61
|
+
return _source_adapter.validate_python(row["config"])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Sources:
|
|
65
|
+
settings: DataManagerSettings
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
database: databases.Database,
|
|
70
|
+
settings: DataManagerSettings,
|
|
71
|
+
):
|
|
72
|
+
self.database = database
|
|
73
|
+
self.settings = settings
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
async def from_settings(cls, settings: DataManagerSettings) -> "Sources":
|
|
77
|
+
tracer_provider = get_telemetry(SERVICE_NAME)
|
|
78
|
+
if tracer_provider:
|
|
79
|
+
await init_telemetry(tracer_provider)
|
|
80
|
+
|
|
81
|
+
database = databases.Database(settings.postgresql_dsn)
|
|
82
|
+
return cls(database=database, settings=settings)
|
|
83
|
+
|
|
84
|
+
async def initialize(self) -> None:
|
|
85
|
+
await self.database.connect()
|
|
86
|
+
|
|
87
|
+
async def finalize(self) -> None:
|
|
88
|
+
await self.database.disconnect()
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# CRUD
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
async def create_source(
|
|
95
|
+
self,
|
|
96
|
+
account: str,
|
|
97
|
+
kbid: str,
|
|
98
|
+
source_id: str,
|
|
99
|
+
source: SourceSchema, # type: ignore[valid-type]
|
|
100
|
+
) -> None:
|
|
101
|
+
query = sa.select(sources_table.c.source_id).where(
|
|
102
|
+
sources_table.c.account == account,
|
|
103
|
+
sources_table.c.kbid == kbid,
|
|
104
|
+
sources_table.c.source_id == source_id,
|
|
105
|
+
)
|
|
106
|
+
existing = await self.database.fetch_one(query)
|
|
107
|
+
if existing is not None:
|
|
108
|
+
raise exceptions.Conflict("Source already exists")
|
|
109
|
+
|
|
110
|
+
serialized = _serialize_source(source)
|
|
111
|
+
insert_query = sa.insert(sources_table).values(
|
|
112
|
+
account=account,
|
|
113
|
+
kbid=kbid,
|
|
114
|
+
source_id=source_id,
|
|
115
|
+
description=serialized.get("description"),
|
|
116
|
+
type=serialized["type"],
|
|
117
|
+
config=serialized,
|
|
118
|
+
)
|
|
119
|
+
await self.database.execute(insert_query)
|
|
120
|
+
CACHE[_cache_key(account, kbid, source_id)] = source
|
|
121
|
+
|
|
122
|
+
async def get_source(
|
|
123
|
+
self,
|
|
124
|
+
account: str,
|
|
125
|
+
kbid: str,
|
|
126
|
+
source_id: str,
|
|
127
|
+
) -> SourceSchema: # type: ignore[valid-type]
|
|
128
|
+
key = _cache_key(account, kbid, source_id)
|
|
129
|
+
if key not in CACHE:
|
|
130
|
+
query = sa.select(sources_table).where(
|
|
131
|
+
sources_table.c.account == account,
|
|
132
|
+
sources_table.c.kbid == kbid,
|
|
133
|
+
sources_table.c.source_id == source_id,
|
|
134
|
+
)
|
|
135
|
+
row = await self.database.fetch_one(query)
|
|
136
|
+
if not row:
|
|
137
|
+
raise exceptions.NotFound("Source not found")
|
|
138
|
+
|
|
139
|
+
source = _source_from_row(row)
|
|
140
|
+
CACHE[key] = source
|
|
141
|
+
else:
|
|
142
|
+
source = CACHE[key]
|
|
143
|
+
return source
|
|
144
|
+
|
|
145
|
+
async def patch_source(
|
|
146
|
+
self,
|
|
147
|
+
account: str,
|
|
148
|
+
kbid: str,
|
|
149
|
+
source_id: str,
|
|
150
|
+
source: SourceSchema, # type: ignore[valid-type]
|
|
151
|
+
) -> None:
|
|
152
|
+
serialized = _serialize_source(source)
|
|
153
|
+
query = (
|
|
154
|
+
sa.update(sources_table)
|
|
155
|
+
.values(
|
|
156
|
+
description=serialized.get("description"),
|
|
157
|
+
type=serialized["type"],
|
|
158
|
+
config=serialized,
|
|
159
|
+
)
|
|
160
|
+
.where(
|
|
161
|
+
sources_table.c.account == account,
|
|
162
|
+
sources_table.c.kbid == kbid,
|
|
163
|
+
sources_table.c.source_id == source_id,
|
|
164
|
+
)
|
|
165
|
+
.returning(sources_table.c.source_id)
|
|
166
|
+
)
|
|
167
|
+
updated = await self.database.fetch_one(query)
|
|
168
|
+
if updated is None:
|
|
169
|
+
raise exceptions.NotFound("Source not found")
|
|
170
|
+
CACHE[_cache_key(account, kbid, source_id)] = source
|
|
171
|
+
|
|
172
|
+
async def delete_source(
|
|
173
|
+
self,
|
|
174
|
+
account: str,
|
|
175
|
+
kbid: str,
|
|
176
|
+
source_id: str,
|
|
177
|
+
) -> None:
|
|
178
|
+
query = (
|
|
179
|
+
sa.delete(sources_table)
|
|
180
|
+
.where(
|
|
181
|
+
sources_table.c.account == account,
|
|
182
|
+
sources_table.c.kbid == kbid,
|
|
183
|
+
sources_table.c.source_id == source_id,
|
|
184
|
+
)
|
|
185
|
+
.returning(sources_table.c.source_id)
|
|
186
|
+
)
|
|
187
|
+
deleted = await self.database.fetch_one(query)
|
|
188
|
+
if deleted is None:
|
|
189
|
+
raise exceptions.NotFound("Source not found")
|
|
190
|
+
CACHE.pop(_cache_key(account, kbid, source_id), None)
|
|
191
|
+
|
|
192
|
+
async def list_sources(
|
|
193
|
+
self,
|
|
194
|
+
account: str,
|
|
195
|
+
kbid: str,
|
|
196
|
+
) -> dict[str, SourceSchema]: # type: ignore[valid-type]
|
|
197
|
+
query = sa.select(sources_table).where(
|
|
198
|
+
sources_table.c.account == account,
|
|
199
|
+
sources_table.c.kbid == kbid,
|
|
200
|
+
)
|
|
201
|
+
rows = await self.database.fetch_all(query)
|
|
202
|
+
return {row["source_id"]: _source_from_row(row) for row in rows}
|