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,542 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import ipaddress
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
from urllib.parse import quote, urlparse
|
|
12
|
+
|
|
13
|
+
from swiftpy.ai.exceptions import (
|
|
14
|
+
AIAuthenticationError,
|
|
15
|
+
AIModuleDisabledError,
|
|
16
|
+
AIProviderError,
|
|
17
|
+
AIRateLimitError,
|
|
18
|
+
)
|
|
19
|
+
from swiftpy.ai.pool import AIHttpPool
|
|
20
|
+
from swiftpy.core.config import Config
|
|
21
|
+
from swiftpy.core.container import Container, get_container
|
|
22
|
+
|
|
23
|
+
ALLOWED_METRICS: frozenset[str] = frozenset({"cosine", "l2", "inner_product"})
|
|
24
|
+
ALLOWED_FILTER_OPERATORS: frozenset[str] = frozenset(
|
|
25
|
+
{"$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in", "$nin", "$and", "$or", "$exists"}
|
|
26
|
+
)
|
|
27
|
+
RESERVED_META_KEYS: frozenset[str] = frozenset({"_content", "_orig_id"})
|
|
28
|
+
|
|
29
|
+
_COLLECTION_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
|
30
|
+
_PINECONE_ENV_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
|
31
|
+
_PINECONE_HOST_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9]$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_collection_name(name: str) -> str:
|
|
35
|
+
"""Validate collection/index names to block path traversal, query injection, and host injection."""
|
|
36
|
+
if not isinstance(name, str) or not _COLLECTION_NAME_RE.fullmatch(name):
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"Invalid collection name {name!r}. "
|
|
39
|
+
"Collection names must start with an alphanumeric character and contain only [A-Za-z0-9_-] (1-128 chars)."
|
|
40
|
+
)
|
|
41
|
+
return name
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_record_id(record_id: str) -> str:
|
|
45
|
+
"""Validate a record identifier is a non-empty string free of null bytes or ASCII control chars."""
|
|
46
|
+
if not isinstance(record_id, str) or not record_id or not record_id.strip():
|
|
47
|
+
raise ValueError("Record id must be a non-empty string.")
|
|
48
|
+
if "\x00" in record_id or any(ord(ch) < 32 for ch in record_id):
|
|
49
|
+
raise ValueError("Record id must not contain control characters.")
|
|
50
|
+
return record_id
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def encode_path_segment(value: str) -> str:
|
|
54
|
+
"""Percent-encode a path segment for safe HTTP URL interpolation."""
|
|
55
|
+
return quote(value, safe="")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def dedupe_preserve_order(ids: Sequence[str]) -> list[str]:
|
|
59
|
+
"""Deduplicate non-empty string IDs while preserving caller order."""
|
|
60
|
+
return list(dict.fromkeys(ids))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate_pinecone_environment(environment: str) -> str:
|
|
64
|
+
"""Validate Pinecone environment identifier against strict DNS-label characters."""
|
|
65
|
+
if not isinstance(environment, str) or not _PINECONE_ENV_RE.fullmatch(environment):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Invalid Pinecone environment {environment!r}. Expected alphanumeric/hyphen/underscore token."
|
|
68
|
+
)
|
|
69
|
+
return environment
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def validate_pinecone_url(url: str, *, require_pinecone_host: bool = False) -> str:
|
|
73
|
+
"""Validate a Pinecone endpoint URL, rejecting credentials, fragments, queries, and SSRF targets."""
|
|
74
|
+
if not isinstance(url, str) or not url.strip():
|
|
75
|
+
raise ValueError("Pinecone URL must be a non-empty string.")
|
|
76
|
+
trimmed = url.strip().rstrip("/")
|
|
77
|
+
if any(ch in trimmed for ch in ("#", "?", "@")) or any(ch.isspace() for ch in trimmed):
|
|
78
|
+
raise ValueError(f"Invalid Pinecone URL {url!r}: contains disallowed characters.")
|
|
79
|
+
parsed = urlparse(trimmed)
|
|
80
|
+
if parsed.scheme not in ("https", "http"):
|
|
81
|
+
raise ValueError(f"Invalid Pinecone URL scheme in {url!r}.")
|
|
82
|
+
if require_pinecone_host and parsed.scheme != "https":
|
|
83
|
+
raise ValueError(f"Pinecone data-plane host URL must use https, got {url!r}.")
|
|
84
|
+
if parsed.username or parsed.password or parsed.params or parsed.query or parsed.fragment:
|
|
85
|
+
raise ValueError(f"Invalid Pinecone URL components in {url!r}.")
|
|
86
|
+
host = parsed.hostname
|
|
87
|
+
if not host or not _PINECONE_HOST_RE.fullmatch(host):
|
|
88
|
+
raise ValueError(f"Invalid Pinecone hostname in {url!r}.")
|
|
89
|
+
# Block link-local / raw metadata IPs
|
|
90
|
+
try:
|
|
91
|
+
ip_obj = ipaddress.ip_address(host)
|
|
92
|
+
if ip_obj.is_link_local:
|
|
93
|
+
raise ValueError(f"Disallowed link-local Pinecone IP host in {url!r}.")
|
|
94
|
+
except ValueError as ip_err:
|
|
95
|
+
if "link-local" in str(ip_err):
|
|
96
|
+
raise
|
|
97
|
+
if require_pinecone_host and not host.endswith(".pinecone.io"):
|
|
98
|
+
raise ValueError(f"Untrusted Pinecone host {host!r}: expected *.pinecone.io suffix.")
|
|
99
|
+
return trimmed
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def validate_filter_operators(
|
|
103
|
+
flt: dict[str, Any],
|
|
104
|
+
*,
|
|
105
|
+
allowed_ops: frozenset[str] = ALLOWED_FILTER_OPERATORS,
|
|
106
|
+
nested_allowed: bool = False,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Validate metadata filter dictionary keys and dollar-prefixed query operators."""
|
|
109
|
+
if not isinstance(flt, dict):
|
|
110
|
+
raise TypeError(f"Filter must be a dict, got {type(flt).__name__}.")
|
|
111
|
+
for raw_key, value in flt.items():
|
|
112
|
+
if not isinstance(raw_key, str) or not raw_key or "\x00" in raw_key:
|
|
113
|
+
raise ValueError(f"Invalid filter key {raw_key!r}.")
|
|
114
|
+
if raw_key.startswith("$"):
|
|
115
|
+
if raw_key not in allowed_ops:
|
|
116
|
+
raise ValueError(f"Unsupported filter operator {raw_key!r}.")
|
|
117
|
+
if raw_key in ("$and", "$or"):
|
|
118
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, dict)):
|
|
119
|
+
raise ValueError(f"Operator {raw_key!r} expects a sequence of filter clauses.")
|
|
120
|
+
for clause in value:
|
|
121
|
+
validate_filter_operators(clause, allowed_ops=allowed_ops, nested_allowed=nested_allowed)
|
|
122
|
+
elif raw_key in ("$in", "$nin"):
|
|
123
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, dict)):
|
|
124
|
+
raise ValueError(f"Operator {raw_key!r} expects a sequence of scalar values.")
|
|
125
|
+
elif raw_key == "$exists" and not isinstance(value, bool):
|
|
126
|
+
raise ValueError("Operator '$exists' expects a boolean.")
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
if isinstance(value, dict):
|
|
130
|
+
if any(isinstance(k, str) and k.startswith("$") for k in value):
|
|
131
|
+
for op_key, op_val in value.items():
|
|
132
|
+
if not isinstance(op_key, str) or not op_key.startswith("$") or op_key not in allowed_ops:
|
|
133
|
+
raise ValueError(f"Unsupported filter operator {op_key!r} on field {raw_key!r}.")
|
|
134
|
+
if op_key in ("$in", "$nin") and (
|
|
135
|
+
not isinstance(op_val, Sequence) or isinstance(op_val, (str, bytes, dict))
|
|
136
|
+
):
|
|
137
|
+
raise ValueError(f"Operator {op_key!r} expects a sequence of values.")
|
|
138
|
+
elif not nested_allowed:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Nested dictionary filter on key {raw_key!r} is not supported by this adapter."
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
validate_filter_operators(value, allowed_ops=allowed_ops, nested_allowed=True)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class VectorStoreCapabilities:
|
|
148
|
+
"""Declared capabilities supported by a vector store adapter."""
|
|
149
|
+
|
|
150
|
+
metadata_filtering: bool
|
|
151
|
+
nested_metadata_filtering: bool
|
|
152
|
+
namespaces: bool
|
|
153
|
+
pagination: bool
|
|
154
|
+
batch_upsert: bool
|
|
155
|
+
native_distance_metrics: tuple[str, ...]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(slots=True)
|
|
159
|
+
class SearchResult:
|
|
160
|
+
"""
|
|
161
|
+
Represents a vector similarity search result.
|
|
162
|
+
|
|
163
|
+
Public Scoring Contract:
|
|
164
|
+
-----------------------
|
|
165
|
+
In SwiftPY, `SearchResult.score` is normalized such that higher values
|
|
166
|
+
consistently indicate greater similarity across all adapters:
|
|
167
|
+
- 'cosine': Cosine similarity in range [-1.0, 1.0], where 1.0 is identical and -1.0 is opposite.
|
|
168
|
+
- 'l2': Negated Euclidean distance (-||a - b||_2) in range (-inf, 0.0], where 0.0 is identical.
|
|
169
|
+
- 'inner_product': Dot product sum (sum(a_i * b_i)).
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
id: str
|
|
173
|
+
score: float
|
|
174
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
175
|
+
content: str = ""
|
|
176
|
+
vector: list[float] | None = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(slots=True)
|
|
180
|
+
class VectorRecord:
|
|
181
|
+
"""Represents an individual vector record for insertion or update."""
|
|
182
|
+
|
|
183
|
+
id: str
|
|
184
|
+
vector: list[float]
|
|
185
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
186
|
+
content: str = ""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def validate_vector(
|
|
190
|
+
vector: Sequence[float],
|
|
191
|
+
dimensions: int | None = None,
|
|
192
|
+
metric: str = "cosine",
|
|
193
|
+
*,
|
|
194
|
+
zero_norm_message: str | None = None,
|
|
195
|
+
) -> list[float]:
|
|
196
|
+
"""
|
|
197
|
+
Validate that vector is a non-empty sequence of finite floats matching dimensions.
|
|
198
|
+
For cosine distance, strictly rejects zero-magnitude vectors.
|
|
199
|
+
"""
|
|
200
|
+
if not isinstance(vector, (list, tuple)) or not vector:
|
|
201
|
+
raise ValueError("Vector must be a non-empty sequence of floats.")
|
|
202
|
+
if dimensions is not None and len(vector) != dimensions:
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"Vector dimension {len(vector)} does not match collection dimension {dimensions}"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
parsed: list[float] = []
|
|
208
|
+
norm_sq = 0.0
|
|
209
|
+
for idx, x in enumerate(vector):
|
|
210
|
+
if not isinstance(x, (int, float)) or isinstance(x, bool):
|
|
211
|
+
raise TypeError(
|
|
212
|
+
f"Vector element at index {idx} must be a float, got {type(x).__name__}"
|
|
213
|
+
)
|
|
214
|
+
fx = float(x)
|
|
215
|
+
if math.isnan(fx) or math.isinf(fx):
|
|
216
|
+
raise ValueError(f"Vector element at index {idx} is NaN or Infinite.")
|
|
217
|
+
parsed.append(fx)
|
|
218
|
+
norm_sq += fx * fx
|
|
219
|
+
|
|
220
|
+
if metric == "cosine" and norm_sq <= 1e-12:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
zero_norm_message or "Cannot calculate cosine similarity for a zero-magnitude vector."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return parsed
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def normalize_record(
|
|
229
|
+
record: VectorRecord | dict[str, Any] | tuple[Any, ...],
|
|
230
|
+
dimensions: int | None = None,
|
|
231
|
+
metric: str = "cosine",
|
|
232
|
+
) -> VectorRecord:
|
|
233
|
+
"""Normalize and validate a vector record input format."""
|
|
234
|
+
if isinstance(record, VectorRecord):
|
|
235
|
+
rec_id = record.id
|
|
236
|
+
rec_vec = record.vector
|
|
237
|
+
rec_meta = copy.deepcopy(dict(record.metadata)) if record.metadata else {}
|
|
238
|
+
rec_content = record.content
|
|
239
|
+
elif isinstance(record, dict):
|
|
240
|
+
if "id" not in record or "vector" not in record:
|
|
241
|
+
raise ValueError("Record dict must contain 'id' and 'vector' keys.")
|
|
242
|
+
rec_id = str(record["id"])
|
|
243
|
+
rec_vec = record["vector"]
|
|
244
|
+
raw_meta = record.get("metadata")
|
|
245
|
+
rec_meta = copy.deepcopy(dict(raw_meta)) if raw_meta else {}
|
|
246
|
+
rec_content = str(record.get("content", ""))
|
|
247
|
+
elif isinstance(record, (list, tuple)) and len(record) >= 2:
|
|
248
|
+
rec_id = str(record[0])
|
|
249
|
+
rec_vec = record[1]
|
|
250
|
+
rec_meta = copy.deepcopy(dict(record[2])) if len(record) > 2 and record[2] is not None else {}
|
|
251
|
+
rec_content = str(record[3]) if len(record) > 3 and record[3] is not None else ""
|
|
252
|
+
else:
|
|
253
|
+
raise TypeError(f"Unsupported record format: {type(record).__name__}")
|
|
254
|
+
|
|
255
|
+
rec_id = validate_record_id(rec_id)
|
|
256
|
+
|
|
257
|
+
parsed_vec = validate_vector(rec_vec, dimensions, metric)
|
|
258
|
+
return VectorRecord(
|
|
259
|
+
id=rec_id,
|
|
260
|
+
vector=parsed_vec,
|
|
261
|
+
metadata=rec_meta,
|
|
262
|
+
content=rec_content,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
_REDACT_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
267
|
+
(re.compile(r"(Bearer\s+)[^\s\"',]+", re.IGNORECASE), r"\1[REDACTED]"),
|
|
268
|
+
(re.compile(r"((?:api[-_]?key|x[-_]api[-_]?key)[\"'\s:=]+)[^\s\"',]+", re.IGNORECASE), r"\1[REDACTED]"),
|
|
269
|
+
(re.compile(r"(token[\"'\s:=]+)[^\s\"',]+", re.IGNORECASE), r"\1[REDACTED]"),
|
|
270
|
+
(re.compile(r"(password[\"'\s:=]+)[^\s\"',]+", re.IGNORECASE), r"\1[REDACTED]"),
|
|
271
|
+
(re.compile(r"sk-[A-Za-z0-9_\-]{8,}"), "[REDACTED]"),
|
|
272
|
+
(re.compile(r"pcsk_[A-Za-z0-9_\-]{8,}"), "[REDACTED]"),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def sanitize_error_message(text: str) -> str:
|
|
277
|
+
"""Sanitize sensitive credentials, API keys, and tokens from error messages."""
|
|
278
|
+
if not text:
|
|
279
|
+
return ""
|
|
280
|
+
sanitized = text
|
|
281
|
+
for pattern, repl in _REDACT_PATTERNS:
|
|
282
|
+
sanitized = pattern.sub(repl, sanitized)
|
|
283
|
+
return sanitized
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def handle_adapter_http_error(status_code: int, text: str, provider: str) -> None:
|
|
287
|
+
"""Translate raw HTTP error status codes into SwiftPY AI exceptions."""
|
|
288
|
+
clean_text = sanitize_error_message(text)
|
|
289
|
+
if status_code in (401, 403):
|
|
290
|
+
raise AIAuthenticationError(
|
|
291
|
+
f"[{provider}] Authentication failed (HTTP {status_code}): {clean_text}"
|
|
292
|
+
)
|
|
293
|
+
if status_code == 429:
|
|
294
|
+
raise AIRateLimitError(
|
|
295
|
+
f"[{provider}] Rate limit exceeded (HTTP 429): {clean_text}"
|
|
296
|
+
)
|
|
297
|
+
if status_code == 400:
|
|
298
|
+
raise AIProviderError(
|
|
299
|
+
f"[{provider}] Bad request (HTTP 400): {clean_text}"
|
|
300
|
+
)
|
|
301
|
+
if status_code == 404:
|
|
302
|
+
raise AIProviderError(
|
|
303
|
+
f"[{provider}] Resource not found (HTTP 404): {clean_text}"
|
|
304
|
+
)
|
|
305
|
+
if status_code == 409:
|
|
306
|
+
raise AIProviderError(
|
|
307
|
+
f"[{provider}] Conflict error (HTTP 409): {clean_text}"
|
|
308
|
+
)
|
|
309
|
+
if status_code == 413:
|
|
310
|
+
raise AIProviderError(
|
|
311
|
+
f"[{provider}] Payload too large (HTTP 413): {clean_text}"
|
|
312
|
+
)
|
|
313
|
+
if status_code == 422:
|
|
314
|
+
raise AIProviderError(
|
|
315
|
+
f"[{provider}] Unprocessable entity (HTTP 422): {clean_text}"
|
|
316
|
+
)
|
|
317
|
+
if status_code == 503:
|
|
318
|
+
raise AIProviderError(
|
|
319
|
+
f"[{provider}] Service unavailable (HTTP 503): {clean_text}"
|
|
320
|
+
)
|
|
321
|
+
if status_code >= 400:
|
|
322
|
+
raise AIProviderError(
|
|
323
|
+
f"[{provider}] API error (HTTP {status_code}): {clean_text}"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
_ADAPTER_REGISTRY: dict[str, type[VectorStore]] = {}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class VectorStore(ABC):
|
|
331
|
+
"""
|
|
332
|
+
Abstract contract and unified facade for vector database stores.
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
@classmethod
|
|
336
|
+
def register_adapter(cls, name: str, adapter_cls: type[VectorStore]) -> None:
|
|
337
|
+
"""Register a vector store adapter implementation."""
|
|
338
|
+
_ADAPTER_REGISTRY[name.lower().strip()] = adapter_cls
|
|
339
|
+
|
|
340
|
+
@classmethod
|
|
341
|
+
def get_adapter_cls(cls, name: str) -> type[VectorStore] | None:
|
|
342
|
+
"""Retrieve a registered adapter class by name."""
|
|
343
|
+
return _ADAPTER_REGISTRY.get(name.lower().strip())
|
|
344
|
+
|
|
345
|
+
@classmethod
|
|
346
|
+
def _create_from_di(
|
|
347
|
+
cls,
|
|
348
|
+
adapter_name: str,
|
|
349
|
+
collection: str = "default",
|
|
350
|
+
dimensions: int = 1536,
|
|
351
|
+
metric: str = "cosine",
|
|
352
|
+
**kwargs: Any,
|
|
353
|
+
) -> VectorStore:
|
|
354
|
+
"""
|
|
355
|
+
Instantiate and configure an adapter using the DI container and application configuration.
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
container = get_container()
|
|
359
|
+
except RuntimeError:
|
|
360
|
+
container = Container()
|
|
361
|
+
|
|
362
|
+
adapter_cls = cls.get_adapter_cls(adapter_name)
|
|
363
|
+
if adapter_cls is None:
|
|
364
|
+
raise ValueError(
|
|
365
|
+
f"Unknown vector store adapter '{adapter_name}'. "
|
|
366
|
+
f"Available adapters: {sorted(_ADAPTER_REGISTRY.keys())}"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
has_explicit_override = (
|
|
370
|
+
collection != "default"
|
|
371
|
+
or dimensions != 1536
|
|
372
|
+
or metric != "cosine"
|
|
373
|
+
or bool(kwargs)
|
|
374
|
+
)
|
|
375
|
+
if container.has(adapter_cls) and not has_explicit_override:
|
|
376
|
+
instance = container.resolve(adapter_cls)
|
|
377
|
+
if isinstance(instance, VectorStore):
|
|
378
|
+
return instance
|
|
379
|
+
|
|
380
|
+
config = container.resolve(Config) if container.can_resolve(Config) else Config()
|
|
381
|
+
if not config.get("ai.enabled", True):
|
|
382
|
+
raise AIModuleDisabledError("AI module is disabled in configuration (ai.enabled is false).")
|
|
383
|
+
|
|
384
|
+
factory: Any = adapter_cls
|
|
385
|
+
if adapter_name == "in_memory":
|
|
386
|
+
return cast(
|
|
387
|
+
VectorStore,
|
|
388
|
+
factory(
|
|
389
|
+
collection_name=collection,
|
|
390
|
+
dimensions=dimensions,
|
|
391
|
+
metric=metric,
|
|
392
|
+
**kwargs,
|
|
393
|
+
),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
http_pool: AIHttpPool | None = kwargs.pop("http_pool", None)
|
|
397
|
+
if http_pool is None:
|
|
398
|
+
if container.has(AIHttpPool):
|
|
399
|
+
http_pool = container.resolve(AIHttpPool)
|
|
400
|
+
else:
|
|
401
|
+
http_pool = AIHttpPool.get_instance()
|
|
402
|
+
|
|
403
|
+
cfg_section = config.get(f"ai.vectorstores.{adapter_name}", {})
|
|
404
|
+
merged_kwargs: dict[str, Any] = dict(cfg_section) if isinstance(cfg_section, dict) else {}
|
|
405
|
+
merged_kwargs.update(kwargs)
|
|
406
|
+
merged_kwargs["collection_name"] = collection
|
|
407
|
+
merged_kwargs["dimensions"] = dimensions
|
|
408
|
+
merged_kwargs["metric"] = metric
|
|
409
|
+
merged_kwargs["config"] = config
|
|
410
|
+
merged_kwargs["http_pool"] = http_pool
|
|
411
|
+
|
|
412
|
+
return cast(VectorStore, factory(**merged_kwargs))
|
|
413
|
+
|
|
414
|
+
@classmethod
|
|
415
|
+
def qdrant(
|
|
416
|
+
cls,
|
|
417
|
+
collection: str = "default",
|
|
418
|
+
dimensions: int = 1536,
|
|
419
|
+
metric: str = "cosine",
|
|
420
|
+
**kwargs: Any,
|
|
421
|
+
) -> VectorStore:
|
|
422
|
+
"""Return a configured Qdrant vector store adapter from DI container."""
|
|
423
|
+
return cls._create_from_di("qdrant", collection, dimensions, metric, **kwargs)
|
|
424
|
+
|
|
425
|
+
@classmethod
|
|
426
|
+
def lancedb(
|
|
427
|
+
cls,
|
|
428
|
+
collection: str = "default",
|
|
429
|
+
dimensions: int = 1536,
|
|
430
|
+
metric: str = "cosine",
|
|
431
|
+
**kwargs: Any,
|
|
432
|
+
) -> VectorStore:
|
|
433
|
+
"""Return a configured LanceDB vector store adapter from DI container."""
|
|
434
|
+
return cls._create_from_di("lancedb", collection, dimensions, metric, **kwargs)
|
|
435
|
+
|
|
436
|
+
@classmethod
|
|
437
|
+
def chroma(
|
|
438
|
+
cls,
|
|
439
|
+
collection: str = "default",
|
|
440
|
+
dimensions: int = 1536,
|
|
441
|
+
metric: str = "cosine",
|
|
442
|
+
**kwargs: Any,
|
|
443
|
+
) -> VectorStore:
|
|
444
|
+
"""Return a configured Chroma vector store adapter from DI container."""
|
|
445
|
+
return cls._create_from_di("chroma", collection, dimensions, metric, **kwargs)
|
|
446
|
+
|
|
447
|
+
@classmethod
|
|
448
|
+
def pinecone(
|
|
449
|
+
cls,
|
|
450
|
+
collection: str = "default",
|
|
451
|
+
dimensions: int = 1536,
|
|
452
|
+
metric: str = "cosine",
|
|
453
|
+
**kwargs: Any,
|
|
454
|
+
) -> VectorStore:
|
|
455
|
+
"""Return a configured Pinecone vector store adapter from DI container."""
|
|
456
|
+
return cls._create_from_di("pinecone", collection, dimensions, metric, **kwargs)
|
|
457
|
+
|
|
458
|
+
@classmethod
|
|
459
|
+
def in_memory(
|
|
460
|
+
cls,
|
|
461
|
+
collection: str = "default",
|
|
462
|
+
dimensions: int = 1536,
|
|
463
|
+
metric: str = "cosine",
|
|
464
|
+
**kwargs: Any,
|
|
465
|
+
) -> VectorStore:
|
|
466
|
+
"""Return a configured InMemory vector store adapter from DI container."""
|
|
467
|
+
return cls._create_from_di("in_memory", collection, dimensions, metric, **kwargs)
|
|
468
|
+
|
|
469
|
+
@classmethod
|
|
470
|
+
def pgvector(
|
|
471
|
+
cls,
|
|
472
|
+
collection: str = "default",
|
|
473
|
+
dimensions: int = 1536,
|
|
474
|
+
metric: str = "cosine",
|
|
475
|
+
**kwargs: Any,
|
|
476
|
+
) -> VectorStore:
|
|
477
|
+
"""Return a configured pgvector vector store adapter from DI container."""
|
|
478
|
+
return cls._create_from_di("pgvector", collection, dimensions, metric, **kwargs)
|
|
479
|
+
|
|
480
|
+
@property
|
|
481
|
+
@abstractmethod
|
|
482
|
+
def capabilities(self) -> VectorStoreCapabilities:
|
|
483
|
+
"""Return the declared capabilities supported by this adapter."""
|
|
484
|
+
|
|
485
|
+
@abstractmethod
|
|
486
|
+
async def create_collection(
|
|
487
|
+
self,
|
|
488
|
+
name: str,
|
|
489
|
+
dimensions: int,
|
|
490
|
+
metric: str = "cosine",
|
|
491
|
+
) -> None:
|
|
492
|
+
"""Create a collection / table configured for vectors of the specified dimension and metric."""
|
|
493
|
+
|
|
494
|
+
@abstractmethod
|
|
495
|
+
async def upsert(
|
|
496
|
+
self,
|
|
497
|
+
id: str,
|
|
498
|
+
vector: list[float],
|
|
499
|
+
metadata: dict[str, Any] | None = None,
|
|
500
|
+
content: str = "",
|
|
501
|
+
) -> None:
|
|
502
|
+
"""Insert or update a single vector record."""
|
|
503
|
+
|
|
504
|
+
@abstractmethod
|
|
505
|
+
async def upsert_batch(
|
|
506
|
+
self,
|
|
507
|
+
records: Sequence[
|
|
508
|
+
VectorRecord
|
|
509
|
+
| dict[str, Any]
|
|
510
|
+
| tuple[str, list[float], dict[str, Any] | None]
|
|
511
|
+
],
|
|
512
|
+
) -> None:
|
|
513
|
+
"""Insert or update multiple vector records in batch."""
|
|
514
|
+
|
|
515
|
+
@abstractmethod
|
|
516
|
+
async def search(
|
|
517
|
+
self,
|
|
518
|
+
vector: list[float],
|
|
519
|
+
limit: int = 10,
|
|
520
|
+
filter: dict[str, Any] | None = None,
|
|
521
|
+
) -> list[SearchResult]:
|
|
522
|
+
"""Search the collection for vectors nearest to the query vector."""
|
|
523
|
+
|
|
524
|
+
@abstractmethod
|
|
525
|
+
async def delete(self, id: str) -> bool:
|
|
526
|
+
"""Delete a vector record by id. Return True if deleted, False if not found."""
|
|
527
|
+
|
|
528
|
+
@abstractmethod
|
|
529
|
+
async def delete_batch(self, ids: Sequence[str]) -> int:
|
|
530
|
+
"""Delete multiple vector records by ids. Return the number of deleted records."""
|
|
531
|
+
|
|
532
|
+
@abstractmethod
|
|
533
|
+
async def get(self, id: str) -> SearchResult | None:
|
|
534
|
+
"""Retrieve a single vector record by id."""
|
|
535
|
+
|
|
536
|
+
@abstractmethod
|
|
537
|
+
async def count(self) -> int:
|
|
538
|
+
"""Return the total number of records in the active collection."""
|
|
539
|
+
|
|
540
|
+
@abstractmethod
|
|
541
|
+
async def drop_collection(self, name: str) -> None:
|
|
542
|
+
"""Drop a collection and all its stored records."""
|