swiftpy-framework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
@@ -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
+