swiftpy-framework 0.1.0__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 (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,321 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import copy
5
+ import math
6
+ from collections.abc import Sequence
7
+ from typing import Any
8
+
9
+ from swiftpy.ai.vectorstore.base import (
10
+ ALLOWED_METRICS,
11
+ SearchResult,
12
+ VectorRecord,
13
+ VectorStore,
14
+ VectorStoreCapabilities,
15
+ dedupe_preserve_order,
16
+ normalize_record,
17
+ validate_collection_name,
18
+ validate_record_id,
19
+ validate_vector,
20
+ )
21
+
22
+
23
+ def _matches_jsonb_containment(target: Any, filter_val: Any) -> bool:
24
+ """
25
+ Check if target contains filter_val, mirroring PostgreSQL JSONB @> semantics:
26
+ - If filter_val is a dict, target must be a dict and for every (k, v) in filter_val,
27
+ k must be in target and target[k] must contain v.
28
+ - If filter_val is a list, target must be a list and every element in filter_val
29
+ must match an element in target.
30
+ - Otherwise, scalar equality target == filter_val (including None without bool/int confusion).
31
+ """
32
+ if filter_val is None:
33
+ return target is None
34
+ if isinstance(filter_val, dict):
35
+ if not isinstance(target, dict):
36
+ return False
37
+ for k, v in filter_val.items():
38
+ if k not in target:
39
+ return False
40
+ if not _matches_jsonb_containment(target[k], v):
41
+ return False
42
+ return True
43
+ if isinstance(filter_val, list):
44
+ if not isinstance(target, list):
45
+ return False
46
+ for item in filter_val:
47
+ if not any(_matches_jsonb_containment(target_elem, item) for target_elem in target):
48
+ return False
49
+ return True
50
+ if isinstance(target, bool) != isinstance(filter_val, bool):
51
+ return False
52
+ return bool(target == filter_val)
53
+
54
+
55
+ class InMemoryVectorStore(VectorStore):
56
+ """
57
+ Pure Python, in-memory vector store adapter.
58
+
59
+ Requires zero external dependencies. Supports JSONB-style containment metadata
60
+ filtering, multiple distance metrics (cosine, l2, inner_product), and strictly
61
+ validates finite numerical representations.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ collection_name: str = "default",
67
+ dimensions: int | None = None,
68
+ metric: str = "cosine",
69
+ *,
70
+ max_records: int | None = 100_000,
71
+ ) -> None:
72
+ validate_collection_name(collection_name)
73
+ norm_metric = metric.lower().strip()
74
+ if norm_metric not in ALLOWED_METRICS:
75
+ raise ValueError(f"Unsupported metric '{metric}'. Expected one of {sorted(ALLOWED_METRICS)}")
76
+ if max_records is not None and (isinstance(max_records, bool) or not isinstance(max_records, int) or max_records <= 0):
77
+ raise ValueError(f"max_records must be a positive integer or None, got {max_records!r}")
78
+
79
+ self._current_collection = collection_name
80
+ self._collections: dict[str, dict[str, dict[str, Any]]] = {}
81
+ self._dimensions: dict[str, int] = {}
82
+ self._metrics: dict[str, str] = {collection_name: norm_metric}
83
+ self._max_records = max_records
84
+ self._lock = asyncio.Lock()
85
+
86
+ if dimensions is not None:
87
+ if isinstance(dimensions, bool) or dimensions <= 0:
88
+ raise ValueError(f"Dimensions must be a positive integer, got {dimensions}")
89
+ self._dimensions[collection_name] = dimensions
90
+ self._collections[collection_name] = {}
91
+
92
+ @property
93
+ def capabilities(self) -> VectorStoreCapabilities:
94
+ """Return the declared capabilities supported by InMemoryVectorStore."""
95
+ return VectorStoreCapabilities(
96
+ metadata_filtering=True,
97
+ nested_metadata_filtering=True,
98
+ namespaces=False,
99
+ pagination=False,
100
+ batch_upsert=True,
101
+ native_distance_metrics=("cosine", "l2", "inner_product"),
102
+ )
103
+
104
+ def _put_record_locked(
105
+ self,
106
+ coll: dict[str, dict[str, Any]],
107
+ record: VectorRecord,
108
+ ) -> None:
109
+ # Refresh insertion order on overwrite so FIFO/LRU eviction behaves predictably.
110
+ coll.pop(record.id, None)
111
+ coll[record.id] = {
112
+ "id": record.id,
113
+ "vector": list(record.vector),
114
+ "metadata": copy.deepcopy(record.metadata),
115
+ "content": record.content,
116
+ }
117
+ if self._max_records is not None:
118
+ while len(coll) > self._max_records:
119
+ oldest_key = next(iter(coll))
120
+ del coll[oldest_key]
121
+
122
+ async def create_collection(
123
+ self,
124
+ name: str,
125
+ dimensions: int,
126
+ metric: str = "cosine",
127
+ ) -> None:
128
+ """Create or initialize a named collection with specified dimensions and metric."""
129
+ validate_collection_name(name)
130
+ if isinstance(dimensions, bool) or dimensions <= 0:
131
+ raise ValueError(f"Dimensions must be a positive integer, got {dimensions}")
132
+ norm_metric = metric.lower().strip()
133
+ if norm_metric not in ALLOWED_METRICS:
134
+ raise ValueError(f"Unsupported metric '{metric}'. Expected one of {sorted(ALLOWED_METRICS)}")
135
+
136
+ async with self._lock:
137
+ # Check for existing collection dimension mismatch
138
+ if name in self._dimensions and self._dimensions[name] != dimensions:
139
+ raise ValueError(
140
+ f"Collection '{name}' already exists with {self._dimensions[name]} dimensions, "
141
+ f"cannot reinitialize with {dimensions} dimensions."
142
+ )
143
+
144
+ self._current_collection = name
145
+ self._dimensions[name] = dimensions
146
+ self._metrics[name] = norm_metric
147
+ if name not in self._collections:
148
+ self._collections[name] = {}
149
+
150
+ async def upsert(
151
+ self,
152
+ id: str,
153
+ vector: list[float],
154
+ metadata: dict[str, Any] | None = None,
155
+ content: str = "",
156
+ ) -> None:
157
+ """Insert or update a single vector record."""
158
+ rec_id = validate_record_id(id)
159
+ async with self._lock:
160
+ expected_dim = self._dimensions.get(self._current_collection)
161
+ active_metric = self._metrics.get(self._current_collection, "cosine")
162
+ parsed_vec = validate_vector(
163
+ vector,
164
+ expected_dim,
165
+ active_metric,
166
+ zero_norm_message="Zero vector cannot be indexed with cosine distance metric.",
167
+ )
168
+ if expected_dim is None:
169
+ self._dimensions[self._current_collection] = len(parsed_vec)
170
+ coll = self._collections.setdefault(self._current_collection, {})
171
+ norm_rec = VectorRecord(
172
+ id=rec_id,
173
+ vector=parsed_vec,
174
+ metadata=copy.deepcopy(dict(metadata)) if metadata else {},
175
+ content=content,
176
+ )
177
+ self._put_record_locked(coll, norm_rec)
178
+
179
+ async def upsert_batch(
180
+ self,
181
+ records: Sequence[
182
+ VectorRecord
183
+ | dict[str, Any]
184
+ | tuple[str, list[float], dict[str, Any] | None]
185
+ | tuple[str, list[float], dict[str, Any] | None, str]
186
+ ],
187
+ ) -> None:
188
+ """Insert or update multiple vector records atomically in batch."""
189
+ if not records:
190
+ return
191
+ async with self._lock:
192
+ expected_dim = self._dimensions.get(self._current_collection)
193
+ active_metric = self._metrics.get(self._current_collection, "cosine")
194
+ normalized: list[VectorRecord] = []
195
+ batch_dim = expected_dim
196
+ for rec in records:
197
+ parsed = normalize_record(rec, batch_dim, active_metric)
198
+ if batch_dim is None:
199
+ batch_dim = len(parsed.vector)
200
+ normalized.append(parsed)
201
+
202
+ if expected_dim is None and batch_dim is not None:
203
+ self._dimensions[self._current_collection] = batch_dim
204
+
205
+ coll = self._collections.setdefault(self._current_collection, {})
206
+ for item in normalized:
207
+ self._put_record_locked(coll, item)
208
+
209
+ async def search(
210
+ self,
211
+ vector: list[float],
212
+ limit: int = 10,
213
+ filter: dict[str, Any] | None = None,
214
+ ) -> list[SearchResult]:
215
+ """Search the active collection for nearest vectors using the collection's metric."""
216
+ if limit <= 0:
217
+ raise ValueError(f"Limit must be a positive integer, got {limit}")
218
+
219
+ async with self._lock:
220
+ expected_dim = self._dimensions.get(self._current_collection)
221
+ active_metric = self._metrics.get(self._current_collection, "cosine")
222
+ query_vec = validate_vector(
223
+ vector,
224
+ expected_dim,
225
+ active_metric,
226
+ zero_norm_message="Zero vector cannot be used as query with cosine distance metric.",
227
+ )
228
+ norm_q_sq = sum(x * x for x in query_vec)
229
+ norm_q = math.sqrt(norm_q_sq) if active_metric == "cosine" else 1.0
230
+
231
+ coll = self._collections.get(self._current_collection, {})
232
+ if not coll:
233
+ return []
234
+
235
+ candidates: list[SearchResult] = []
236
+ for record in coll.values():
237
+ meta = record["metadata"]
238
+ if filter is not None and not _matches_jsonb_containment(meta, filter):
239
+ continue
240
+
241
+ rec_vec: list[float] = record["vector"]
242
+ if active_metric == "cosine":
243
+ dot = sum(a * b for a, b in zip(query_vec, rec_vec, strict=True))
244
+ norm_r_sq = sum(b * b for b in rec_vec)
245
+ if norm_q_sq > 1e-12 and norm_r_sq > 1e-12:
246
+ score = dot / (norm_q * math.sqrt(norm_r_sq))
247
+ else:
248
+ score = 0.0
249
+ score = max(-1.0, min(1.0, score))
250
+ elif active_metric == "l2":
251
+ dist = math.sqrt(sum((a - b) ** 2 for a, b in zip(query_vec, rec_vec, strict=True)))
252
+ # Higher score = closer match (negative Euclidean distance)
253
+ score = -dist
254
+ elif active_metric == "inner_product":
255
+ score = sum(a * b for a, b in zip(query_vec, rec_vec, strict=True))
256
+ else:
257
+ score = 0.0
258
+
259
+ candidates.append(
260
+ SearchResult(
261
+ id=record["id"],
262
+ score=score,
263
+ metadata=copy.deepcopy(meta),
264
+ content=record["content"],
265
+ vector=list(rec_vec),
266
+ )
267
+ )
268
+
269
+ # Sort descending by score (highest similarity / closest distance first)
270
+ candidates.sort(key=lambda item: item.score, reverse=True)
271
+ return candidates[:limit]
272
+
273
+ async def delete(self, id: str) -> bool:
274
+ """Delete a vector record by id."""
275
+ async with self._lock:
276
+ coll = self._collections.get(self._current_collection)
277
+ if coll is not None and id in coll:
278
+ del coll[id]
279
+ return True
280
+ return False
281
+
282
+ async def delete_batch(self, ids: Sequence[str]) -> int:
283
+ """Delete multiple deduplicated vector records by ids. Return count of deleted items."""
284
+ deleted_count = 0
285
+ async with self._lock:
286
+ coll = self._collections.get(self._current_collection)
287
+ if coll is not None:
288
+ for target_id in dedupe_preserve_order(ids):
289
+ if target_id in coll:
290
+ del coll[target_id]
291
+ deleted_count += 1
292
+ return deleted_count
293
+
294
+ async def get(self, id: str) -> SearchResult | None:
295
+ """Retrieve a single vector record by id."""
296
+ async with self._lock:
297
+ coll = self._collections.get(self._current_collection)
298
+ if coll is None or id not in coll:
299
+ return None
300
+ data = coll[id]
301
+ return SearchResult(
302
+ id=data["id"],
303
+ score=1.0,
304
+ metadata=copy.deepcopy(data["metadata"]),
305
+ content=data["content"],
306
+ vector=list(data["vector"]),
307
+ )
308
+
309
+ async def count(self) -> int:
310
+ """Return the count of vector records in the active collection."""
311
+ async with self._lock:
312
+ coll = self._collections.get(self._current_collection)
313
+ return len(coll) if coll else 0
314
+
315
+ async def drop_collection(self, name: str) -> None:
316
+ """Drop a collection and remove its records."""
317
+ validate_collection_name(name)
318
+ async with self._lock:
319
+ self._collections.pop(name, None)
320
+ self._dimensions.pop(name, None)
321
+ self._metrics.pop(name, None)
@@ -0,0 +1,370 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import copy
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from swiftpy.ai.exceptions import (
11
+ AIConnectionError,
12
+ AIModuleDisabledError,
13
+ )
14
+ from swiftpy.ai.pool import AIHttpPool
15
+ from swiftpy.ai.vectorstore.base import (
16
+ ALLOWED_METRICS,
17
+ SearchResult,
18
+ VectorRecord,
19
+ VectorStore,
20
+ VectorStoreCapabilities,
21
+ dedupe_preserve_order,
22
+ encode_path_segment,
23
+ handle_adapter_http_error,
24
+ normalize_record,
25
+ validate_collection_name,
26
+ validate_filter_operators,
27
+ validate_vector,
28
+ )
29
+ from swiftpy.core.config import Config
30
+
31
+ _HTTP_ERRORS: tuple[type[Exception], ...] = (httpx.TimeoutException, httpx.RequestError, OSError)
32
+
33
+ _METRIC_MAP: dict[str, str] = {
34
+ "cosine": "cosine",
35
+ "l2": "l2",
36
+ "inner_product": "dot",
37
+ }
38
+
39
+
40
+ class LanceDbAdapter(VectorStore):
41
+ """
42
+ LanceDB vector store adapter via REST API through the shared async HTTP pool.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ collection_name: str = "default",
48
+ dimensions: int | None = None,
49
+ metric: str = "cosine",
50
+ base_url: str | None = None,
51
+ api_key: str | None = None,
52
+ timeout: float = 30.0,
53
+ config: Config | None = None,
54
+ http_pool: AIHttpPool | None = None,
55
+ **kwargs: Any,
56
+ ) -> None:
57
+ validate_collection_name(collection_name)
58
+ self._config = config or Config()
59
+ if not self._config.get("ai.enabled", True):
60
+ raise AIModuleDisabledError("AI module is disabled in configuration (ai.enabled is false).")
61
+
62
+ norm_metric = metric.lower().strip()
63
+ if norm_metric not in ALLOWED_METRICS:
64
+ raise ValueError(f"Unsupported metric '{metric}'. Expected one of {sorted(ALLOWED_METRICS)}")
65
+
66
+ self._collection = collection_name
67
+ self._dimensions = dimensions
68
+ self._metric = norm_metric
69
+
70
+ cfg_base = self._config.get("ai.vectorstores.lancedb.base_url", "http://localhost:8080")
71
+ self._base_url = (base_url or str(cfg_base)).rstrip("/")
72
+ cfg_key = self._config.get("ai.vectorstores.lancedb.api_key", "")
73
+ self._api_key = api_key if api_key is not None else str(cfg_key)
74
+ self._timeout = timeout
75
+
76
+ self._http_pool = http_pool or AIHttpPool.get_instance()
77
+
78
+ @property
79
+ def capabilities(self) -> VectorStoreCapabilities:
80
+ """Return the declared capabilities supported by LanceDB."""
81
+ return VectorStoreCapabilities(
82
+ metadata_filtering=True,
83
+ nested_metadata_filtering=True,
84
+ namespaces=False,
85
+ pagination=False,
86
+ batch_upsert=True,
87
+ native_distance_metrics=("cosine", "l2", "inner_product"),
88
+ )
89
+
90
+ def _get_headers(self) -> dict[str, str]:
91
+ headers = {"Content-Type": "application/json"}
92
+ if self._api_key:
93
+ headers["x-api-key"] = self._api_key
94
+ headers["Authorization"] = f"Bearer {self._api_key}"
95
+ return headers
96
+
97
+ def _table_url(self, name: str | None = None, suffix: str = "") -> str:
98
+ coll = self._collection if name is None else validate_collection_name(name)
99
+ seg = encode_path_segment(coll)
100
+ return f"{self._base_url}/v1/table/{seg}{suffix}"
101
+
102
+ async def create_collection(
103
+ self,
104
+ name: str,
105
+ dimensions: int,
106
+ metric: str = "cosine",
107
+ ) -> None:
108
+ validate_collection_name(name)
109
+ norm_metric = metric.lower().strip()
110
+ if norm_metric not in ALLOWED_METRICS:
111
+ raise ValueError(f"Unsupported metric '{metric}'. Expected one of {sorted(ALLOWED_METRICS)}")
112
+ if isinstance(dimensions, bool) or not isinstance(dimensions, int) or dimensions <= 0:
113
+ raise ValueError(f"Dimensions must be positive, got {dimensions}")
114
+
115
+ url = self._table_url(name, "/create")
116
+ payload = {
117
+ "dimensions": dimensions,
118
+ "metric": _METRIC_MAP[norm_metric],
119
+ }
120
+
121
+ client = self._http_pool.get_client()
122
+ try:
123
+ resp = await client.post(
124
+ url,
125
+ json=payload,
126
+ headers=self._get_headers(),
127
+ timeout=self._timeout,
128
+ )
129
+ if resp.status_code not in (200, 201, 409):
130
+ handle_adapter_http_error(resp.status_code, resp.text, "lancedb")
131
+ except _HTTP_ERRORS as err:
132
+ raise AIConnectionError(f"[lancedb] Connection error: {err}") from err
133
+
134
+ self._collection = name
135
+ self._dimensions = dimensions
136
+ self._metric = norm_metric
137
+
138
+ async def upsert(
139
+ self,
140
+ id: str,
141
+ vector: list[float],
142
+ metadata: dict[str, Any] | None = None,
143
+ content: str = "",
144
+ ) -> None:
145
+ await self.upsert_batch([
146
+ VectorRecord(id=id, vector=vector, metadata=metadata or {}, content=content)
147
+ ])
148
+
149
+ async def upsert_batch(
150
+ self,
151
+ records: Sequence[
152
+ VectorRecord
153
+ | dict[str, Any]
154
+ | tuple[str, list[float], dict[str, Any] | None]
155
+ ],
156
+ ) -> None:
157
+ if not records:
158
+ return
159
+
160
+ norm_records = [
161
+ normalize_record(r, self._dimensions, self._metric) for r in records
162
+ ]
163
+
164
+ url = self._table_url(suffix="/insert")
165
+ rows: list[dict[str, Any]] = []
166
+ for r in norm_records:
167
+ rows.append({
168
+ "id": r.id,
169
+ "vector": r.vector,
170
+ "content": r.content,
171
+ "metadata": copy.deepcopy(r.metadata),
172
+ })
173
+
174
+ body = {
175
+ "data": rows,
176
+ "mode": "merge",
177
+ }
178
+
179
+ client = self._http_pool.get_client()
180
+ try:
181
+ resp = await client.post(
182
+ url,
183
+ json=body,
184
+ headers=self._get_headers(),
185
+ timeout=self._timeout,
186
+ )
187
+ if resp.status_code not in (200, 201):
188
+ handle_adapter_http_error(resp.status_code, resp.text, "lancedb")
189
+ except _HTTP_ERRORS as err:
190
+ raise AIConnectionError(f"[lancedb] Connection error: {err}") from err
191
+
192
+ async def search(
193
+ self,
194
+ vector: list[float],
195
+ limit: int = 10,
196
+ filter: dict[str, Any] | None = None,
197
+ ) -> list[SearchResult]:
198
+ if limit <= 0:
199
+ raise ValueError(f"Limit must be a positive integer, got {limit}")
200
+
201
+ parsed_vec = validate_vector(vector, self._dimensions, self._metric)
202
+ url = self._table_url(suffix="/query")
203
+
204
+ body: dict[str, Any] = {
205
+ "vector": parsed_vec,
206
+ "k": limit,
207
+ "metric": _METRIC_MAP[self._metric],
208
+ }
209
+
210
+ if filter is not None:
211
+ validate_filter_operators(filter, nested_allowed=True)
212
+ if filter:
213
+ body["filter"] = filter
214
+
215
+ client = self._http_pool.get_client()
216
+ try:
217
+ resp = await client.post(
218
+ url,
219
+ json=body,
220
+ headers=self._get_headers(),
221
+ timeout=self._timeout,
222
+ )
223
+ if resp.status_code != 200:
224
+ handle_adapter_http_error(resp.status_code, resp.text, "lancedb")
225
+ rows = resp.json().get("rows", [])
226
+ except _HTTP_ERRORS as err:
227
+ raise AIConnectionError(f"[lancedb] Connection error: {err}") from err
228
+
229
+ results: list[SearchResult] = []
230
+ for row in rows:
231
+ dist = float(row.get("_distance", 0.0))
232
+ if self._metric == "cosine":
233
+ score = 1.0 - dist
234
+ elif self._metric == "l2":
235
+ score = -dist
236
+ else: # inner_product ("dot")
237
+ score = 1.0 - dist
238
+
239
+ results.append(
240
+ SearchResult(
241
+ id=str(row.get("id", "")),
242
+ score=score,
243
+ metadata=dict(row.get("metadata") or {}),
244
+ content=str(row.get("content", "")),
245
+ vector=row.get("vector"),
246
+ )
247
+ )
248
+
249
+ results.sort(key=lambda r: r.score, reverse=True)
250
+ return results
251
+
252
+ async def get(self, id: str) -> SearchResult | None:
253
+ if not id or not isinstance(id, str):
254
+ return None
255
+
256
+ url = self._table_url(suffix="/query")
257
+ body = {"filter": {"id": id}, "k": 1}
258
+
259
+ client = self._http_pool.get_client()
260
+ try:
261
+ resp = await client.post(
262
+ url,
263
+ json=body,
264
+ headers=self._get_headers(),
265
+ timeout=self._timeout,
266
+ )
267
+ if resp.status_code == 404:
268
+ return None
269
+ if resp.status_code != 200:
270
+ handle_adapter_http_error(resp.status_code, resp.text, "lancedb")
271
+ rows = resp.json().get("rows", [])
272
+ if not rows:
273
+ return None
274
+ row = rows[0]
275
+ if str(row.get("id")) != id:
276
+ return None
277
+
278
+ return SearchResult(
279
+ id=id,
280
+ score=1.0,
281
+ metadata=dict(row.get("metadata") or {}),
282
+ content=str(row.get("content", "")),
283
+ vector=row.get("vector"),
284
+ )
285
+ except _HTTP_ERRORS as err:
286
+ raise AIConnectionError(f"[lancedb] Connection error: {err}") from err
287
+
288
+ async def delete(self, id: str) -> bool:
289
+ existing = await self.get(id)
290
+ if existing is None:
291
+ return False
292
+
293
+ url = self._table_url(suffix="/delete")
294
+ body = {"ids": [id]}
295
+
296
+ client = self._http_pool.get_client()
297
+ try:
298
+ resp = await client.post(
299
+ url,
300
+ json=body,
301
+ headers=self._get_headers(),
302
+ timeout=self._timeout,
303
+ )
304
+ if resp.status_code not in (200, 204):
305
+ handle_adapter_http_error(resp.status_code, resp.text, "lancedb")
306
+ return True
307
+ except _HTTP_ERRORS as err:
308
+ raise AIConnectionError(f"[lancedb] Connection error: {err}") from err
309
+
310
+ async def delete_batch(self, ids: Sequence[str]) -> int:
311
+ unique_ids = dedupe_preserve_order(ids)
312
+ if not unique_ids:
313
+ return 0
314
+
315
+ semaphore = asyncio.Semaphore(8)
316
+
317
+ async def _exists(item_id: str) -> bool:
318
+ async with semaphore:
319
+ return await self.get(item_id) is not None
320
+
321
+ exists_flags = await asyncio.gather(*(_exists(item_id) for item_id in unique_ids))
322
+ existing_ids = [item_id for item_id, found in zip(unique_ids, exists_flags, strict=True) if found]
323
+ if not existing_ids:
324
+ return 0
325
+
326
+ url = self._table_url(suffix="/delete")
327
+ body = {"ids": existing_ids}
328
+
329
+ client = self._http_pool.get_client()
330
+ try:
331
+ resp = await client.post(
332
+ url,
333
+ json=body,
334
+ headers=self._get_headers(),
335
+ timeout=self._timeout,
336
+ )
337
+ if resp.status_code not in (200, 204):
338
+ handle_adapter_http_error(resp.status_code, resp.text, "lancedb")
339
+ return len(existing_ids)
340
+ except _HTTP_ERRORS as err:
341
+ raise AIConnectionError(f"[lancedb] Connection error: {err}") from err
342
+
343
+ async def count(self) -> int:
344
+ url = self._table_url(suffix="/count")
345
+ client = self._http_pool.get_client()
346
+ try:
347
+ resp = await client.get(
348
+ url,
349
+ headers=self._get_headers(),
350
+ timeout=self._timeout,
351
+ )
352
+ if resp.status_code != 200:
353
+ handle_adapter_http_error(resp.status_code, resp.text, "lancedb")
354
+ return int(resp.json().get("count", 0))
355
+ except _HTTP_ERRORS as err:
356
+ raise AIConnectionError(f"[lancedb] Connection error: {err}") from err
357
+
358
+ async def drop_collection(self, name: str) -> None:
359
+ url = self._table_url(name)
360
+ client = self._http_pool.get_client()
361
+ try:
362
+ resp = await client.delete(
363
+ url,
364
+ headers=self._get_headers(),
365
+ timeout=self._timeout,
366
+ )
367
+ if resp.status_code not in (200, 204, 404):
368
+ handle_adapter_http_error(resp.status_code, resp.text, "lancedb")
369
+ except _HTTP_ERRORS as err:
370
+ raise AIConnectionError(f"[lancedb] Connection error: {err}") from err