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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|