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,315 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from swiftpy.ai.embeddings import EmbeddingsClient
|
|
8
|
+
from swiftpy.ai.vectorstore.base import SearchResult, VectorRecord, VectorStore
|
|
9
|
+
from swiftpy.core.container import Container, get_container
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _estimate_text_tokens(text: str) -> int:
|
|
13
|
+
"""Approximate token count cleanly (~4 chars per token)."""
|
|
14
|
+
if not text:
|
|
15
|
+
return 0
|
|
16
|
+
return max(1, (len(text) + 3) // 4)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _extract_document_text(doc: SearchResult | str | dict[str, Any], index: int) -> str:
|
|
20
|
+
"""Extract plain text content from a document or raise TypeError."""
|
|
21
|
+
if isinstance(doc, SearchResult):
|
|
22
|
+
return doc.content
|
|
23
|
+
if isinstance(doc, str):
|
|
24
|
+
return doc
|
|
25
|
+
if isinstance(doc, dict):
|
|
26
|
+
raw = doc.get("content", "")
|
|
27
|
+
return "" if raw is None else str(raw)
|
|
28
|
+
raise TypeError(f"Unsupported document type at index {index}: {type(doc).__name__}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def chunk_text(
|
|
32
|
+
text: str,
|
|
33
|
+
chunk_size: int = 1000,
|
|
34
|
+
overlap: int = 200,
|
|
35
|
+
) -> list[str]:
|
|
36
|
+
"""
|
|
37
|
+
Split text into overlapping chunks bounded by `chunk_size` characters.
|
|
38
|
+
|
|
39
|
+
Prefers splitting on whitespace boundaries when available while strictly
|
|
40
|
+
enforcing ``len(chunk) <= chunk_size`` and monotonic forward progress.
|
|
41
|
+
"""
|
|
42
|
+
if not isinstance(text, str):
|
|
43
|
+
raise TypeError(f"text must be a str, got {type(text).__name__}")
|
|
44
|
+
if isinstance(chunk_size, bool) or not isinstance(chunk_size, int) or chunk_size <= 0:
|
|
45
|
+
raise ValueError(f"chunk_size must be a positive integer, got {chunk_size!r}")
|
|
46
|
+
if isinstance(overlap, bool) or not isinstance(overlap, int) or overlap < 0:
|
|
47
|
+
raise ValueError(f"overlap must be a non-negative integer, got {overlap!r}")
|
|
48
|
+
if overlap >= chunk_size:
|
|
49
|
+
raise ValueError(f"overlap ({overlap}) must be smaller than chunk_size ({chunk_size})")
|
|
50
|
+
|
|
51
|
+
if not text.strip():
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
chunks: list[str] = []
|
|
55
|
+
start = 0
|
|
56
|
+
length = len(text)
|
|
57
|
+
|
|
58
|
+
while start < length:
|
|
59
|
+
while start < length and text[start].isspace():
|
|
60
|
+
start += 1
|
|
61
|
+
if start >= length:
|
|
62
|
+
break
|
|
63
|
+
|
|
64
|
+
end = min(start + chunk_size, length)
|
|
65
|
+
if end < length and not text[end].isspace():
|
|
66
|
+
split_at = text.rfind(" ", start, end)
|
|
67
|
+
if split_at == -1:
|
|
68
|
+
split_at = text.rfind("\n", start, end)
|
|
69
|
+
if split_at > start:
|
|
70
|
+
end = split_at
|
|
71
|
+
|
|
72
|
+
chunk = text[start:end].strip()
|
|
73
|
+
if chunk:
|
|
74
|
+
chunks.append(chunk)
|
|
75
|
+
|
|
76
|
+
if end >= length:
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
next_start = end - overlap
|
|
80
|
+
if next_start <= start:
|
|
81
|
+
next_start = start + 1
|
|
82
|
+
start = next_start
|
|
83
|
+
|
|
84
|
+
return chunks
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def truncate_to_token_budget[DocT: SearchResult | str | dict[str, Any]](
|
|
88
|
+
documents: Sequence[DocT],
|
|
89
|
+
max_tokens: int,
|
|
90
|
+
token_estimator: Callable[[str], int] | None = None,
|
|
91
|
+
) -> list[DocT]:
|
|
92
|
+
"""
|
|
93
|
+
Retain an ordered prefix of documents whose cumulative token estimate stays
|
|
94
|
+
within `max_tokens`.
|
|
95
|
+
"""
|
|
96
|
+
if isinstance(max_tokens, bool) or not isinstance(max_tokens, int) or max_tokens <= 0:
|
|
97
|
+
raise ValueError(f"max_tokens must be a positive integer, got {max_tokens!r}")
|
|
98
|
+
if token_estimator is not None and not callable(token_estimator):
|
|
99
|
+
raise TypeError("token_estimator must be callable when provided")
|
|
100
|
+
|
|
101
|
+
kept: list[DocT] = []
|
|
102
|
+
used_tokens = 0
|
|
103
|
+
for idx, doc in enumerate(documents):
|
|
104
|
+
content = _extract_document_text(doc, idx)
|
|
105
|
+
est = token_estimator(content) if token_estimator is not None else _estimate_text_tokens(content)
|
|
106
|
+
if est < 0:
|
|
107
|
+
raise ValueError(f"Token estimate must be non-negative, got {est}")
|
|
108
|
+
if used_tokens + est > max_tokens:
|
|
109
|
+
break
|
|
110
|
+
kept.append(doc)
|
|
111
|
+
used_tokens += est
|
|
112
|
+
return kept
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Retriever:
|
|
116
|
+
"""
|
|
117
|
+
Asynchronous document retriever for vector similarity search.
|
|
118
|
+
|
|
119
|
+
Embeds incoming text queries asynchronously and queries the configured
|
|
120
|
+
vector store to return ranked search result chunks.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
vectorstore: VectorStore,
|
|
126
|
+
embeddings: EmbeddingsClient | None = None,
|
|
127
|
+
top_k: int = 5,
|
|
128
|
+
filter: dict[str, Any] | None = None,
|
|
129
|
+
min_score: float | None = None,
|
|
130
|
+
model: str | None = None,
|
|
131
|
+
provider: str | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
if top_k <= 0:
|
|
134
|
+
raise ValueError(f"top_k must be greater than 0, got {top_k}")
|
|
135
|
+
|
|
136
|
+
self._vectorstore = vectorstore
|
|
137
|
+
if embeddings is None:
|
|
138
|
+
try:
|
|
139
|
+
container = get_container()
|
|
140
|
+
except RuntimeError:
|
|
141
|
+
container = Container()
|
|
142
|
+
|
|
143
|
+
if container.has(EmbeddingsClient):
|
|
144
|
+
self._embeddings = container.resolve(EmbeddingsClient)
|
|
145
|
+
else:
|
|
146
|
+
self._embeddings = EmbeddingsClient()
|
|
147
|
+
else:
|
|
148
|
+
self._embeddings = embeddings
|
|
149
|
+
|
|
150
|
+
self._top_k = top_k
|
|
151
|
+
self._filter = filter
|
|
152
|
+
self._min_score = min_score
|
|
153
|
+
self._model = model
|
|
154
|
+
self._provider = provider
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def vectorstore(self) -> VectorStore:
|
|
158
|
+
"""The underlying vector store instance."""
|
|
159
|
+
return self._vectorstore
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def embeddings(self) -> EmbeddingsClient:
|
|
163
|
+
"""The underlying embeddings client instance."""
|
|
164
|
+
return self._embeddings
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def top_k(self) -> int:
|
|
168
|
+
"""Default top_k results to retrieve."""
|
|
169
|
+
return self._top_k
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def filter(self) -> dict[str, Any] | None:
|
|
173
|
+
"""Default metadata filter."""
|
|
174
|
+
return self._filter
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def min_score(self) -> float | None:
|
|
178
|
+
"""Optional minimum score threshold."""
|
|
179
|
+
return self._min_score
|
|
180
|
+
|
|
181
|
+
async def retrieve(
|
|
182
|
+
self,
|
|
183
|
+
query: str,
|
|
184
|
+
top_k: int | None = None,
|
|
185
|
+
filter: dict[str, Any] | None = None,
|
|
186
|
+
min_score: float | None = None,
|
|
187
|
+
) -> list[SearchResult]:
|
|
188
|
+
"""
|
|
189
|
+
Embed the query asynchronously, search the vector store, and return ranked chunks.
|
|
190
|
+
"""
|
|
191
|
+
if not isinstance(query, str) or not query.strip():
|
|
192
|
+
raise ValueError("Query must be a non-empty string.")
|
|
193
|
+
|
|
194
|
+
limit = top_k if top_k is not None else self._top_k
|
|
195
|
+
if limit <= 0:
|
|
196
|
+
raise ValueError(f"top_k must be greater than 0, got {limit}")
|
|
197
|
+
|
|
198
|
+
active_filter = filter if filter is not None else self._filter
|
|
199
|
+
cutoff = min_score if min_score is not None else self._min_score
|
|
200
|
+
|
|
201
|
+
query_vector = await self._embeddings.embed(
|
|
202
|
+
query,
|
|
203
|
+
model=self._model,
|
|
204
|
+
provider=self._provider,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
results = await self._vectorstore.search(
|
|
208
|
+
vector=query_vector,
|
|
209
|
+
limit=limit,
|
|
210
|
+
filter=active_filter,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if cutoff is not None:
|
|
214
|
+
results = [r for r in results if r.score >= cutoff]
|
|
215
|
+
|
|
216
|
+
return results
|
|
217
|
+
|
|
218
|
+
async def add_documents(
|
|
219
|
+
self,
|
|
220
|
+
documents: Sequence[str | dict[str, Any] | VectorRecord],
|
|
221
|
+
metadatas: Sequence[dict[str, Any]] | None = None,
|
|
222
|
+
ids: Sequence[str] | None = None,
|
|
223
|
+
chunk_size: int = 100,
|
|
224
|
+
concurrency: int = 5,
|
|
225
|
+
) -> list[str]:
|
|
226
|
+
"""
|
|
227
|
+
Convenience method to embed and ingest documents into the underlying vector store.
|
|
228
|
+
|
|
229
|
+
Accepts a sequence of strings (texts), dictionaries, or VectorRecords.
|
|
230
|
+
Returns the list of stored document identifiers.
|
|
231
|
+
"""
|
|
232
|
+
if not documents:
|
|
233
|
+
return []
|
|
234
|
+
|
|
235
|
+
if ids is not None and len(ids) != len(documents):
|
|
236
|
+
raise ValueError(
|
|
237
|
+
f"Length of ids ({len(ids)}) does not match length of documents ({len(documents)})"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if metadatas is not None and len(metadatas) != len(documents):
|
|
241
|
+
raise ValueError(
|
|
242
|
+
f"Length of metadatas ({len(metadatas)}) does not match length of documents ({len(documents)})"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# 1. Validate & partition EVERY document up front (supporting mixed types)
|
|
246
|
+
records_by_idx: dict[int, VectorRecord] = {}
|
|
247
|
+
embed_indices: list[int] = []
|
|
248
|
+
texts_to_embed: list[str] = []
|
|
249
|
+
embed_ids: list[str] = []
|
|
250
|
+
embed_metas: list[dict[str, Any]] = []
|
|
251
|
+
|
|
252
|
+
for idx, doc in enumerate(documents):
|
|
253
|
+
custom_id = str(ids[idx]) if ids is not None else f"doc_{idx}"
|
|
254
|
+
custom_meta = copy.deepcopy(dict(metadatas[idx])) if metadatas is not None else {}
|
|
255
|
+
|
|
256
|
+
if isinstance(doc, VectorRecord):
|
|
257
|
+
merged_meta = (
|
|
258
|
+
{**custom_meta, **copy.deepcopy(doc.metadata)}
|
|
259
|
+
if custom_meta
|
|
260
|
+
else copy.deepcopy(doc.metadata)
|
|
261
|
+
)
|
|
262
|
+
records_by_idx[idx] = VectorRecord(
|
|
263
|
+
id=str(ids[idx]) if ids is not None else doc.id,
|
|
264
|
+
vector=list(doc.vector),
|
|
265
|
+
metadata=merged_meta,
|
|
266
|
+
content=doc.content,
|
|
267
|
+
)
|
|
268
|
+
elif isinstance(doc, str):
|
|
269
|
+
embed_indices.append(idx)
|
|
270
|
+
texts_to_embed.append(doc)
|
|
271
|
+
embed_ids.append(custom_id)
|
|
272
|
+
embed_metas.append(custom_meta)
|
|
273
|
+
elif isinstance(doc, dict):
|
|
274
|
+
raw_id = doc.get("id")
|
|
275
|
+
item_id = str(raw_id) if raw_id is not None and str(raw_id) != "" else custom_id
|
|
276
|
+
raw_content = doc.get("content", "")
|
|
277
|
+
item_content = "" if raw_content is None else str(raw_content)
|
|
278
|
+
doc_meta = doc.get("metadata")
|
|
279
|
+
if doc_meta is not None and not isinstance(doc_meta, dict):
|
|
280
|
+
raise TypeError(
|
|
281
|
+
f"Document metadata at index {idx} must be a dict, got {type(doc_meta).__name__}"
|
|
282
|
+
)
|
|
283
|
+
item_meta = (
|
|
284
|
+
{**custom_meta, **copy.deepcopy(doc_meta)} if doc_meta is not None else custom_meta
|
|
285
|
+
)
|
|
286
|
+
embed_indices.append(idx)
|
|
287
|
+
texts_to_embed.append(item_content)
|
|
288
|
+
embed_ids.append(item_id)
|
|
289
|
+
embed_metas.append(item_meta)
|
|
290
|
+
else:
|
|
291
|
+
raise TypeError(
|
|
292
|
+
f"Unsupported document type at index {idx}: {type(doc).__name__}"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# 2. Batch-embed only text/dict entries asynchronously
|
|
296
|
+
if texts_to_embed:
|
|
297
|
+
vectors = await self._embeddings.embed_batch(
|
|
298
|
+
texts=texts_to_embed,
|
|
299
|
+
model=self._model,
|
|
300
|
+
provider=self._provider,
|
|
301
|
+
chunk_size=chunk_size,
|
|
302
|
+
concurrency=concurrency,
|
|
303
|
+
)
|
|
304
|
+
for pos, orig_idx in enumerate(embed_indices):
|
|
305
|
+
records_by_idx[orig_idx] = VectorRecord(
|
|
306
|
+
id=embed_ids[pos],
|
|
307
|
+
vector=vectors[pos],
|
|
308
|
+
metadata=embed_metas[pos],
|
|
309
|
+
content=texts_to_embed[pos],
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# 3. Preserve original document ordering and upsert atomically
|
|
313
|
+
records_to_upsert = [records_by_idx[i] for i in range(len(documents))]
|
|
314
|
+
await self._vectorstore.upsert_batch(records_to_upsert)
|
|
315
|
+
return [r.id for r in records_to_upsert]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from collections.abc import Awaitable
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
import redis.asyncio as aioredis
|
|
10
|
+
from redis.exceptions import RedisError
|
|
11
|
+
|
|
12
|
+
from swiftpy.ai.conversation import Conversation
|
|
13
|
+
from swiftpy.ai.exceptions import AIConnectionError
|
|
14
|
+
from swiftpy.core.config import Config
|
|
15
|
+
from swiftpy.support.redis_config import resolve_redis_params
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _ConversationRedisClient(Protocol):
|
|
19
|
+
def get(self, name: str, /) -> Awaitable[bytes | str | None]: ...
|
|
20
|
+
def set(
|
|
21
|
+
self, name: str, value: str, /, ex: int | None = None
|
|
22
|
+
) -> Awaitable[bool | str | bytes | None]: ...
|
|
23
|
+
def delete(self, name: str, /) -> Awaitable[int]: ...
|
|
24
|
+
def exists(self, name: str, /) -> Awaitable[int]: ...
|
|
25
|
+
def aclose(self) -> Awaitable[None]: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConversationStore:
|
|
29
|
+
"""Abstract protocol for conversation persistence."""
|
|
30
|
+
|
|
31
|
+
async def save(
|
|
32
|
+
self,
|
|
33
|
+
session_id: str,
|
|
34
|
+
conversation: Conversation,
|
|
35
|
+
ttl: int | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
async def load(self, session_id: str) -> Conversation:
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
async def delete(self, session_id: str) -> bool:
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
async def exists(self, session_id: str) -> bool:
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InMemoryConversationStore(ConversationStore):
|
|
50
|
+
"""Bounded LRU + TTL-aware in-memory conversation store."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
max_entries: int = 1000,
|
|
55
|
+
default_ttl: int | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
if max_entries <= 0:
|
|
58
|
+
raise ValueError(f"max_entries must be greater than 0, got {max_entries}")
|
|
59
|
+
self.max_entries = max_entries
|
|
60
|
+
self.default_ttl = default_ttl
|
|
61
|
+
self._data: OrderedDict[str, tuple[str, float | None]] = OrderedDict()
|
|
62
|
+
|
|
63
|
+
def _get_valid_payload(self, session_id: str) -> str | None:
|
|
64
|
+
entry = self._data.get(session_id)
|
|
65
|
+
if entry is None:
|
|
66
|
+
return None
|
|
67
|
+
raw, expires_at = entry
|
|
68
|
+
if expires_at is not None and time.monotonic() >= expires_at:
|
|
69
|
+
del self._data[session_id]
|
|
70
|
+
return None
|
|
71
|
+
self._data.move_to_end(session_id)
|
|
72
|
+
return raw
|
|
73
|
+
|
|
74
|
+
async def save(
|
|
75
|
+
self,
|
|
76
|
+
session_id: str,
|
|
77
|
+
conversation: Conversation,
|
|
78
|
+
ttl: int | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
effective_ttl = self.default_ttl if ttl is None else ttl
|
|
81
|
+
if effective_ttl is not None and effective_ttl < 0:
|
|
82
|
+
self._data.pop(session_id, None)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
expires_at = (
|
|
86
|
+
time.monotonic() + effective_ttl
|
|
87
|
+
if effective_ttl is not None and effective_ttl > 0
|
|
88
|
+
else None
|
|
89
|
+
)
|
|
90
|
+
self._data[session_id] = (conversation.to_json(), expires_at)
|
|
91
|
+
self._data.move_to_end(session_id)
|
|
92
|
+
while len(self._data) > self.max_entries:
|
|
93
|
+
self._data.popitem(last=False)
|
|
94
|
+
|
|
95
|
+
async def load(self, session_id: str) -> Conversation:
|
|
96
|
+
raw = self._get_valid_payload(session_id)
|
|
97
|
+
if raw is None:
|
|
98
|
+
return Conversation()
|
|
99
|
+
try:
|
|
100
|
+
return Conversation.from_json(raw)
|
|
101
|
+
except (ValueError, TypeError) as exc:
|
|
102
|
+
raise ValueError(f"Corrupted in-memory conversation data: {exc}") from exc
|
|
103
|
+
|
|
104
|
+
async def delete(self, session_id: str) -> bool:
|
|
105
|
+
return self._data.pop(session_id, None) is not None
|
|
106
|
+
|
|
107
|
+
async def exists(self, session_id: str) -> bool:
|
|
108
|
+
return self._get_valid_payload(session_id) is not None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class RedisConversationStore(ConversationStore):
|
|
112
|
+
"""
|
|
113
|
+
Redis-backed conversation store serializing dialogue history keyed by session/user ID.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
redis_client: _ConversationRedisClient | None = None,
|
|
119
|
+
config: Config | None = None,
|
|
120
|
+
key_prefix: str = "ai:conversation",
|
|
121
|
+
default_ttl: int = 86400,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._client: _ConversationRedisClient | None = redis_client
|
|
124
|
+
self._config = config
|
|
125
|
+
self._key_prefix = key_prefix
|
|
126
|
+
self.default_ttl = default_ttl
|
|
127
|
+
self._owns_client = redis_client is None
|
|
128
|
+
|
|
129
|
+
def _format_key(self, session_id: str) -> str:
|
|
130
|
+
# Sanitize session_id to prevent injection or whitespace issues
|
|
131
|
+
clean_id = session_id.strip()
|
|
132
|
+
return f"{self._key_prefix}:{clean_id}"
|
|
133
|
+
|
|
134
|
+
async def get_client(self) -> _ConversationRedisClient:
|
|
135
|
+
"""Lazily initialize Redis client if not explicitly provided."""
|
|
136
|
+
if self._client is None:
|
|
137
|
+
cfg = self._config or Config.get_instance()
|
|
138
|
+
params = resolve_redis_params(cfg, section="ai")
|
|
139
|
+
client: _ConversationRedisClient = aioredis.from_url(
|
|
140
|
+
params["url"], decode_responses=True
|
|
141
|
+
)
|
|
142
|
+
self._client = client
|
|
143
|
+
return client
|
|
144
|
+
return self._client
|
|
145
|
+
|
|
146
|
+
async def save(
|
|
147
|
+
self,
|
|
148
|
+
session_id: str,
|
|
149
|
+
conversation: Conversation,
|
|
150
|
+
ttl: int | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""
|
|
153
|
+
Serialize and persist a conversation into Redis.
|
|
154
|
+
|
|
155
|
+
If ttl is None, self.default_ttl is used.
|
|
156
|
+
If ttl == 0, the key is saved with no expiration.
|
|
157
|
+
If ttl < 0, any existing key is removed immediately.
|
|
158
|
+
"""
|
|
159
|
+
client = await self.get_client()
|
|
160
|
+
key = self._format_key(session_id)
|
|
161
|
+
effective_ttl = self.default_ttl if ttl is None else ttl
|
|
162
|
+
payload = conversation.to_json()
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
if effective_ttl < 0:
|
|
166
|
+
await client.delete(key)
|
|
167
|
+
elif effective_ttl > 0:
|
|
168
|
+
await client.set(key, payload, ex=effective_ttl)
|
|
169
|
+
else:
|
|
170
|
+
await client.set(key, payload)
|
|
171
|
+
except RedisError as exc:
|
|
172
|
+
raise AIConnectionError(
|
|
173
|
+
f"Failed to save conversation to Redis: {exc}",
|
|
174
|
+
provider="redis",
|
|
175
|
+
) from exc
|
|
176
|
+
|
|
177
|
+
async def load(self, session_id: str) -> Conversation:
|
|
178
|
+
"""Retrieve and deserialize a conversation from Redis. Returns empty Conversation if missing."""
|
|
179
|
+
client = await self.get_client()
|
|
180
|
+
key = self._format_key(session_id)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
raw = await client.get(key)
|
|
184
|
+
except RedisError as exc:
|
|
185
|
+
raise AIConnectionError(
|
|
186
|
+
f"Failed to load conversation from Redis: {exc}",
|
|
187
|
+
provider="redis",
|
|
188
|
+
) from exc
|
|
189
|
+
|
|
190
|
+
if raw is None or not str(raw).strip():
|
|
191
|
+
return Conversation()
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
return Conversation.from_json(str(raw))
|
|
195
|
+
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
196
|
+
raise ValueError(f"Corrupted conversation data in Redis for key '{key}': {exc}") from exc
|
|
197
|
+
|
|
198
|
+
async def delete(self, session_id: str) -> bool:
|
|
199
|
+
"""Remove a conversation from Redis."""
|
|
200
|
+
client = await self.get_client()
|
|
201
|
+
key = self._format_key(session_id)
|
|
202
|
+
try:
|
|
203
|
+
deleted_count = await client.delete(key)
|
|
204
|
+
return bool(deleted_count > 0)
|
|
205
|
+
except RedisError as exc:
|
|
206
|
+
raise AIConnectionError(
|
|
207
|
+
f"Failed to delete conversation from Redis: {exc}",
|
|
208
|
+
provider="redis",
|
|
209
|
+
) from exc
|
|
210
|
+
|
|
211
|
+
async def exists(self, session_id: str) -> bool:
|
|
212
|
+
"""Check whether a conversation exists in Redis."""
|
|
213
|
+
client = await self.get_client()
|
|
214
|
+
key = self._format_key(session_id)
|
|
215
|
+
try:
|
|
216
|
+
return bool(await client.exists(key))
|
|
217
|
+
except RedisError as exc:
|
|
218
|
+
raise AIConnectionError(
|
|
219
|
+
f"Failed to check conversation existence in Redis: {exc}",
|
|
220
|
+
provider="redis",
|
|
221
|
+
) from exc
|
|
222
|
+
|
|
223
|
+
async def close(self) -> None:
|
|
224
|
+
"""Close the Redis client connection if owned by this store."""
|
|
225
|
+
if self._owns_client and self._client is not None:
|
|
226
|
+
await self._client.aclose()
|
|
227
|
+
self._client = None
|
swiftpy/ai/streaming.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import codecs
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from swiftpy.ai.exceptions import AIProviderError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _iter_sse_lines(lines: AsyncIterator[str]) -> AsyncIterator[str]:
|
|
12
|
+
"""Expand chunks containing embedded newlines into individual SSE lines."""
|
|
13
|
+
async for chunk in lines:
|
|
14
|
+
if not chunk:
|
|
15
|
+
yield ""
|
|
16
|
+
continue
|
|
17
|
+
for line in chunk.splitlines():
|
|
18
|
+
yield line
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_sse_event_payload(full_data: str, current_event: str) -> dict[str, Any]:
|
|
22
|
+
"""Parse buffered SSE event data and raise AIProviderError on active provider errors."""
|
|
23
|
+
try:
|
|
24
|
+
parsed_json = json.loads(full_data)
|
|
25
|
+
except (json.JSONDecodeError, ValueError):
|
|
26
|
+
return {"event": current_event, "data": full_data}
|
|
27
|
+
|
|
28
|
+
if isinstance(parsed_json, dict):
|
|
29
|
+
err_obj = parsed_json.get("error")
|
|
30
|
+
if parsed_json.get("type") == "error" or (
|
|
31
|
+
err_obj is not None and err_obj is not False
|
|
32
|
+
):
|
|
33
|
+
payload_err = err_obj if err_obj is not None else parsed_json
|
|
34
|
+
err_msg = (
|
|
35
|
+
payload_err.get("message")
|
|
36
|
+
if isinstance(payload_err, dict)
|
|
37
|
+
else str(payload_err)
|
|
38
|
+
)
|
|
39
|
+
raise AIProviderError(f"Stream error: {err_msg}")
|
|
40
|
+
return {"event": current_event, "data": parsed_json}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def parse_sse_events(
|
|
44
|
+
lines: AsyncIterator[str],
|
|
45
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
46
|
+
"""
|
|
47
|
+
Parse an asynchronous stream of Server-Sent Event (SSE) lines into event dictionaries.
|
|
48
|
+
|
|
49
|
+
Supports:
|
|
50
|
+
- CRLF (\\r\\n) and LF (\\n) line endings
|
|
51
|
+
- Multiple data: lines within a single event (joined by newline per SSE specification)
|
|
52
|
+
- Event type identifiers (event: <name>)
|
|
53
|
+
- SSE comments (: ping/keepalive)
|
|
54
|
+
- Provider error detection
|
|
55
|
+
- Clean [DONE] termination
|
|
56
|
+
"""
|
|
57
|
+
current_event = "message"
|
|
58
|
+
data_buffer: list[str] = []
|
|
59
|
+
|
|
60
|
+
async for raw_line in _iter_sse_lines(lines):
|
|
61
|
+
line = raw_line.rstrip("\r\n")
|
|
62
|
+
|
|
63
|
+
if not line:
|
|
64
|
+
# Empty line indicates event boundary
|
|
65
|
+
if data_buffer:
|
|
66
|
+
full_data = "\n".join(data_buffer)
|
|
67
|
+
data_buffer.clear()
|
|
68
|
+
if full_data == "[DONE]":
|
|
69
|
+
break
|
|
70
|
+
yield _parse_sse_event_payload(full_data, current_event)
|
|
71
|
+
current_event = "message"
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
if line.startswith(":"):
|
|
75
|
+
# SSE comment line, ignore
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
if line.startswith("event:"):
|
|
79
|
+
current_event = line[len("event:") :].strip()
|
|
80
|
+
elif line.startswith("data:"):
|
|
81
|
+
# Strip leading space after data: if present per spec
|
|
82
|
+
val = line[len("data:") :]
|
|
83
|
+
val = val.removeprefix(" ")
|
|
84
|
+
data_buffer.append(val)
|
|
85
|
+
|
|
86
|
+
# Flush remaining data if stream closed without trailing empty line
|
|
87
|
+
if data_buffer:
|
|
88
|
+
full_data = "\n".join(data_buffer)
|
|
89
|
+
if full_data != "[DONE]":
|
|
90
|
+
yield _parse_sse_event_payload(full_data, current_event)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def split_chunk_lines(
|
|
94
|
+
chunks: AsyncIterator[str | bytes],
|
|
95
|
+
) -> AsyncIterator[str]:
|
|
96
|
+
"""
|
|
97
|
+
Split arbitrary incoming network chunks into individual lines, buffering
|
|
98
|
+
partial lines and multi-byte UTF-8 sequences across chunk boundaries.
|
|
99
|
+
"""
|
|
100
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
101
|
+
buffer = ""
|
|
102
|
+
async for chunk in chunks:
|
|
103
|
+
if isinstance(chunk, (bytes, bytearray)):
|
|
104
|
+
text = decoder.decode(chunk, final=False)
|
|
105
|
+
else:
|
|
106
|
+
text = chunk
|
|
107
|
+
buffer += text
|
|
108
|
+
while "\n" in buffer:
|
|
109
|
+
line, buffer = buffer.split("\n", 1)
|
|
110
|
+
yield line
|
|
111
|
+
tail = decoder.decode(b"", final=True)
|
|
112
|
+
if tail:
|
|
113
|
+
buffer += tail
|
|
114
|
+
if buffer:
|
|
115
|
+
yield buffer
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from swiftpy.ai.exceptions import AgentLoopError
|
|
4
|
+
from swiftpy.ai.tools.base_tool import BaseTool, FunctionTool, tool
|
|
5
|
+
from swiftpy.ai.tools.first_party import (
|
|
6
|
+
CalculatorTool,
|
|
7
|
+
DateTimeTool,
|
|
8
|
+
WebSearchTool,
|
|
9
|
+
)
|
|
10
|
+
from swiftpy.ai.tools.tool_loop import ToolLoop
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AgentLoopError",
|
|
14
|
+
"BaseTool",
|
|
15
|
+
"CalculatorTool",
|
|
16
|
+
"DateTimeTool",
|
|
17
|
+
"FunctionTool",
|
|
18
|
+
"ToolLoop",
|
|
19
|
+
"WebSearchTool",
|
|
20
|
+
"tool",
|
|
21
|
+
]
|
|
22
|
+
|