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,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}