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,484 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import math
5
+ import re
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Callable, Sequence
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from swiftpy.ai.exceptions import (
13
+ AIAuthenticationError,
14
+ AIConnectionError,
15
+ AIProviderError,
16
+ AIRateLimitError,
17
+ )
18
+ from swiftpy.ai.pool import AIHttpPool
19
+ from swiftpy.ai.providers.base import parse_retry_after
20
+ from swiftpy.ai.vectorstore.base import SearchResult, sanitize_error_message
21
+ from swiftpy.core.config import Config
22
+
23
+ _RERANK_HTTP_ERRORS: tuple[type[Exception], ...] = (
24
+ httpx.TimeoutException,
25
+ httpx.RequestError,
26
+ )
27
+
28
+
29
+ def _normalize_documents(
30
+ documents: Sequence[SearchResult | str | dict[str, Any]],
31
+ ) -> tuple[list[str], list[SearchResult]]:
32
+ """
33
+ Normalize document inputs into a pair of (texts, SearchResult instances).
34
+ """
35
+ texts: list[str] = []
36
+ results: list[SearchResult] = []
37
+
38
+ for idx, doc in enumerate(documents):
39
+ if isinstance(doc, SearchResult):
40
+ texts.append(doc.content)
41
+ # Create a shallow copy to safely update score without mutating original
42
+ results.append(
43
+ SearchResult(
44
+ id=doc.id,
45
+ score=doc.score,
46
+ metadata=dict(doc.metadata),
47
+ content=doc.content,
48
+ vector=doc.vector,
49
+ )
50
+ )
51
+ elif isinstance(doc, str):
52
+ texts.append(doc)
53
+ results.append(
54
+ SearchResult(
55
+ id=f"doc_{idx}",
56
+ score=0.0,
57
+ metadata={},
58
+ content=doc,
59
+ )
60
+ )
61
+ elif isinstance(doc, dict):
62
+ content = str(doc.get("content", ""))
63
+ texts.append(content)
64
+ results.append(
65
+ SearchResult(
66
+ id=str(doc.get("id", f"doc_{idx}")),
67
+ score=float(doc.get("score", 0.0)),
68
+ metadata=dict(doc.get("metadata") or {}),
69
+ content=content,
70
+ )
71
+ )
72
+ else:
73
+ raise TypeError(
74
+ f"Unsupported document type at index {idx}: {type(doc).__name__}"
75
+ )
76
+
77
+ return texts, results
78
+
79
+
80
+ class BaseReranker(ABC):
81
+ """
82
+ Abstract base class for RAG reranker models and services.
83
+ """
84
+
85
+ @abstractmethod
86
+ async def rerank(
87
+ self,
88
+ query: str,
89
+ documents: Sequence[SearchResult | str | dict[str, Any]],
90
+ top_n: int | None = None,
91
+ ) -> list[SearchResult]:
92
+ """
93
+ Rerank a sequence of documents against a query and return them sorted by relevance score.
94
+ """
95
+
96
+
97
+ class CohereReranker(BaseReranker):
98
+ """
99
+ Cohere Rerank API adapter.
100
+
101
+ Uses SwiftPY's shared AIHttpPool for async non-blocking HTTP requests
102
+ with zero third-party vendor SDK dependencies.
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ api_key: str | None = None,
108
+ model: str | None = None,
109
+ base_url: str | None = None,
110
+ http_pool: AIHttpPool | None = None,
111
+ config: Config | None = None,
112
+ timeout: float | None = None,
113
+ ) -> None:
114
+ cfg = config
115
+ if cfg is None:
116
+ try:
117
+ cfg = Config.get_instance()
118
+ except RuntimeError:
119
+ cfg = Config()
120
+
121
+ if api_key is not None:
122
+ self._api_key = api_key.strip()
123
+ else:
124
+ self._api_key = str(cfg.get("ai.rerankers.cohere.api_key", "")).strip()
125
+
126
+ raw_model = (
127
+ model if model is not None else cfg.get("ai.rerankers.cohere.model", "rerank-v3.5")
128
+ )
129
+ self._model = str(raw_model).strip()
130
+ if not self._model:
131
+ raise ValueError("Cohere model must be a non-empty string.")
132
+
133
+ raw_base_url = (
134
+ base_url
135
+ if base_url is not None
136
+ else cfg.get("ai.rerankers.cohere.base_url", "https://api.cohere.com/v2/rerank")
137
+ )
138
+ self._base_url = str(raw_base_url).strip().rstrip("/")
139
+ if not self._base_url:
140
+ raise ValueError("Cohere base_url must be a non-empty URL.")
141
+
142
+ raw_timeout = timeout if timeout is not None else cfg.get("ai.rerankers.cohere.timeout", 30.0)
143
+ if isinstance(raw_timeout, bool) or not isinstance(raw_timeout, (int, float)):
144
+ raise TypeError(
145
+ f"Cohere timeout must be a positive finite number, got {raw_timeout!r}"
146
+ )
147
+ parsed_timeout = float(raw_timeout)
148
+ if not math.isfinite(parsed_timeout) or parsed_timeout <= 0:
149
+ raise ValueError(
150
+ f"Cohere timeout must be a positive finite number, got {raw_timeout!r}"
151
+ )
152
+ self._timeout = parsed_timeout
153
+ self._http_pool = http_pool or AIHttpPool.get_instance()
154
+
155
+ @property
156
+ def model(self) -> str:
157
+ return self._model
158
+
159
+ async def rerank(
160
+ self,
161
+ query: str,
162
+ documents: Sequence[SearchResult | str | dict[str, Any]],
163
+ top_n: int | None = None,
164
+ ) -> list[SearchResult]:
165
+ if not documents:
166
+ return []
167
+
168
+ if not isinstance(query, str) or not query.strip():
169
+ raise ValueError("Query must be a non-empty string.")
170
+
171
+ if top_n is not None and top_n <= 0:
172
+ raise ValueError(f"top_n must be greater than 0, got {top_n}")
173
+
174
+ texts, results = _normalize_documents(documents)
175
+ effective_top_n = min(top_n, len(texts)) if top_n is not None else len(texts)
176
+
177
+ if not self._api_key:
178
+ raise AIAuthenticationError(
179
+ "Cohere API key is missing. Configure 'ai.rerankers.cohere.api_key' or set COHERE_API_KEY.",
180
+ provider="cohere",
181
+ )
182
+
183
+ client = self._http_pool.get_client()
184
+ headers = {
185
+ "Authorization": f"Bearer {self._api_key}",
186
+ "Content-Type": "application/json",
187
+ "Accept": "application/json",
188
+ }
189
+ payload: dict[str, Any] = {
190
+ "model": self._model,
191
+ "query": query,
192
+ "documents": texts,
193
+ "top_n": effective_top_n,
194
+ }
195
+
196
+ try:
197
+ response = await client.post(
198
+ self._base_url,
199
+ headers=headers,
200
+ json=payload,
201
+ timeout=self._timeout,
202
+ )
203
+ except _RERANK_HTTP_ERRORS as exc:
204
+ raise AIConnectionError(
205
+ f"Failed to connect to Cohere Rerank API: {exc}",
206
+ provider="cohere",
207
+ ) from exc
208
+
209
+ body_text = response.text
210
+ clean_text = sanitize_error_message(body_text)
211
+
212
+ if response.status_code in (401, 403):
213
+ raise AIAuthenticationError(
214
+ f"Cohere authentication failed ({response.status_code}): {clean_text}",
215
+ provider="cohere",
216
+ status_code=response.status_code,
217
+ response_body=clean_text,
218
+ )
219
+ if response.status_code == 400:
220
+ raise AIProviderError(
221
+ f"Cohere invalid request (400): {clean_text}",
222
+ provider="cohere",
223
+ status_code=400,
224
+ response_body=clean_text,
225
+ )
226
+ if response.status_code == 404:
227
+ raise AIProviderError(
228
+ f"Cohere model or resource not found (404): {clean_text}",
229
+ provider="cohere",
230
+ status_code=404,
231
+ response_body=clean_text,
232
+ )
233
+ if response.status_code == 413:
234
+ raise AIProviderError(
235
+ f"Cohere payload too large (413): {clean_text}",
236
+ provider="cohere",
237
+ status_code=413,
238
+ response_body=clean_text,
239
+ )
240
+ if response.status_code == 422:
241
+ raise AIProviderError(
242
+ f"Cohere unprocessable entity (422): {clean_text}",
243
+ provider="cohere",
244
+ status_code=422,
245
+ response_body=clean_text,
246
+ )
247
+ if response.status_code == 429:
248
+ retry_after = parse_retry_after(response.headers.get("Retry-After"))
249
+ raise AIRateLimitError(
250
+ f"Cohere rate limit exceeded (429): {clean_text}",
251
+ provider="cohere",
252
+ status_code=response.status_code,
253
+ response_body=clean_text,
254
+ retry_after=retry_after,
255
+ )
256
+ if response.status_code == 503:
257
+ raise AIProviderError(
258
+ f"Cohere service unavailable (503): {clean_text}",
259
+ provider="cohere",
260
+ status_code=503,
261
+ response_body=clean_text,
262
+ )
263
+ if response.status_code >= 400:
264
+ raise AIProviderError(
265
+ f"Cohere Rerank API error ({response.status_code}): {clean_text}",
266
+ provider="cohere",
267
+ status_code=response.status_code,
268
+ response_body=clean_text,
269
+ )
270
+
271
+ try:
272
+ data = response.json()
273
+ except Exception as exc:
274
+ raise AIProviderError(
275
+ f"Cohere returned non-JSON response: {clean_text[:200]}",
276
+ provider="cohere",
277
+ ) from exc
278
+
279
+ raw_results = data.get("results")
280
+ if not isinstance(raw_results, list):
281
+ raise AIProviderError(
282
+ "Cohere response missing 'results' list",
283
+ provider="cohere",
284
+ )
285
+
286
+ reranked_results: list[SearchResult] = []
287
+ seen_indices: set[int] = set()
288
+ for item in raw_results:
289
+ if not isinstance(item, dict):
290
+ continue
291
+ idx_val = item.get("index")
292
+ if (
293
+ isinstance(idx_val, bool)
294
+ or not isinstance(idx_val, int)
295
+ or idx_val < 0
296
+ or idx_val >= len(results)
297
+ ):
298
+ continue
299
+ if idx_val in seen_indices:
300
+ continue
301
+ seen_indices.add(idx_val)
302
+
303
+ score_val = item.get("relevance_score")
304
+ if isinstance(score_val, bool) or not isinstance(score_val, (int, float)):
305
+ raise AIProviderError(
306
+ f"Cohere returned invalid relevance_score for index {idx_val}: {score_val!r}",
307
+ provider="cohere",
308
+ )
309
+ score = float(score_val)
310
+ if not math.isfinite(score):
311
+ raise AIProviderError(
312
+ f"Cohere returned non-finite relevance_score for index {idx_val}: {score_val!r}",
313
+ provider="cohere",
314
+ )
315
+ res = results[idx_val]
316
+ res.score = score
317
+ reranked_results.append(res)
318
+
319
+ reranked_results.sort(key=lambda r: r.score, reverse=True)
320
+ return reranked_results[:effective_top_n]
321
+
322
+
323
+ def _compute_lexical_cross_scores(
324
+ query: str,
325
+ texts: list[str],
326
+ ) -> list[float]:
327
+ """
328
+ Deterministic cross-encoder relevance scoring function.
329
+
330
+ Computes query-document cross-entropy scores using query token overlap,
331
+ exact phrase matching bonuses, BM25-style term frequency saturation,
332
+ and document length normalization.
333
+ """
334
+ clean_query = query.lower().strip()
335
+ query_tokens = [w for w in re.split(r"\W+", clean_query) if w]
336
+ if not query_tokens:
337
+ return [0.0] * len(texts)
338
+
339
+ query_set = set(query_tokens)
340
+ scores: list[float] = []
341
+
342
+ for text in texts:
343
+ clean_text = text.lower().strip()
344
+ doc_tokens = [w for w in re.split(r"\W+", clean_text) if w]
345
+ doc_len = len(doc_tokens)
346
+ if doc_len == 0:
347
+ scores.append(0.0)
348
+ continue
349
+
350
+ # 1. Exact phrase match bonus
351
+ phrase_bonus = 0.4 if clean_query in clean_text else 0.0
352
+
353
+ # 2. Token overlap & frequency
354
+ match_count = 0
355
+ freq_sum = 0.0
356
+ doc_token_counts: dict[str, int] = {}
357
+ for t in doc_tokens:
358
+ doc_token_counts[t] = doc_token_counts.get(t, 0) + 1
359
+
360
+ for qt in query_tokens:
361
+ if qt in doc_token_counts:
362
+ match_count += 1
363
+ tf = doc_token_counts[qt]
364
+ # BM25-style term frequency saturation
365
+ freq_sum += (tf * 2.2) / (tf + 1.2 * (0.25 + 0.75 * (doc_len / 50.0)))
366
+
367
+ coverage = match_count / len(query_tokens)
368
+ normalized_freq = freq_sum / (len(query_tokens) * 2.0)
369
+
370
+ # 3. Position bonus (earlier appearance gives slight bonus)
371
+ pos_bonus = 0.0
372
+ for qt in query_set:
373
+ first_idx = clean_text.find(qt)
374
+ if first_idx != -1:
375
+ pos_bonus += max(0.0, 0.1 * (1.0 - min(first_idx, 500) / 500.0))
376
+ pos_bonus = min(0.1, pos_bonus / max(1, len(query_set)))
377
+
378
+ raw_score = (
379
+ 0.5 * coverage
380
+ + 0.3 * min(1.0, normalized_freq)
381
+ + phrase_bonus
382
+ + pos_bonus
383
+ )
384
+ # Sigmoid clamp to [0.0, 1.0]
385
+ score = 1.0 / (1.0 + math.exp(-3.0 * (raw_score - 0.5)))
386
+ scores.append(round(score, 6))
387
+
388
+ return scores
389
+
390
+
391
+ class CrossEncoderReranker(BaseReranker):
392
+ """
393
+ Reranker for in-process or model-based document scoring.
394
+
395
+ Can be initialized with:
396
+ 1. A custom scoring callable `scoring_fn(query, texts) -> Sequence[float]`.
397
+ 2. A SentenceTransformers `CrossEncoder` model duck-typed via `.predict([[query, text], ...])`.
398
+ 3. Default built-in deterministic heuristic cross-scorer (BM25-style frequency saturation,
399
+ token overlap, exact phrase match bonuses, and position weighting).
400
+
401
+ All synchronous CPU computations are offloaded to an executor to adhere strictly
402
+ to SwiftPY's event-loop non-blocking concurrency rule.
403
+ """
404
+
405
+ def __init__(
406
+ self,
407
+ model: Any | None = None,
408
+ scoring_fn: Callable[[str, list[str]], Sequence[float]] | None = None,
409
+ ) -> None:
410
+ self._model = model
411
+ self._scoring_fn = scoring_fn
412
+
413
+ async def rerank(
414
+ self,
415
+ query: str,
416
+ documents: Sequence[SearchResult | str | dict[str, Any]],
417
+ top_n: int | None = None,
418
+ ) -> list[SearchResult]:
419
+ if not documents:
420
+ return []
421
+
422
+ if not isinstance(query, str) or not query.strip():
423
+ raise ValueError("Query must be a non-empty string.")
424
+
425
+ if top_n is not None and top_n <= 0:
426
+ raise ValueError(f"top_n must be greater than 0, got {top_n}")
427
+
428
+ texts, results = _normalize_documents(documents)
429
+ effective_top_n = min(top_n, len(texts)) if top_n is not None else len(texts)
430
+
431
+ # Offload CPU-heavy scoring to thread executor to prevent event loop blocking
432
+ loop = asyncio.get_running_loop()
433
+ scores = await loop.run_in_executor(
434
+ None,
435
+ self._score_sync,
436
+ query,
437
+ texts,
438
+ )
439
+
440
+ for i, score in enumerate(scores):
441
+ results[i].score = float(score)
442
+
443
+ # Stable sort descending by relevance score (preserves relative order for ties)
444
+ results.sort(key=lambda r: r.score, reverse=True)
445
+ return results[:effective_top_n]
446
+
447
+ def _score_sync(self, query: str, texts: list[str]) -> list[float]:
448
+ """Synchronous scoring execution inside worker thread."""
449
+ if not texts:
450
+ return []
451
+
452
+ raw_scores: Any
453
+ if self._scoring_fn is not None:
454
+ raw_scores = self._scoring_fn(query, texts)
455
+ elif self._model is not None and hasattr(self._model, "predict"):
456
+ # SentenceTransformers CrossEncoder model duck typing
457
+ pairs = [[query, t] for t in texts]
458
+ raw_scores = self._model.predict(pairs)
459
+ else:
460
+ # Built-in deterministic heuristic scoring
461
+ raw_scores = _compute_lexical_cross_scores(query, texts)
462
+
463
+ if not hasattr(raw_scores, "__len__") or not hasattr(raw_scores, "__iter__"):
464
+ raise TypeError(
465
+ f"Scoring engine must return an iterable sequence of floats, got {type(raw_scores).__name__}"
466
+ )
467
+
468
+ if len(raw_scores) != len(texts):
469
+ raise ValueError(
470
+ f"Scoring engine returned {len(raw_scores)} scores for {len(texts)} documents"
471
+ )
472
+
473
+ validated: list[float] = []
474
+ for i, s in enumerate(raw_scores):
475
+ if not isinstance(s, (int, float)) or isinstance(s, bool):
476
+ raise TypeError(
477
+ f"Score at index {i} must be a numeric float, got {type(s).__name__}"
478
+ )
479
+ fs = float(s)
480
+ if math.isnan(fs) or math.isinf(fs):
481
+ raise ValueError(f"Score at index {i} is NaN or Infinite: {fs}")
482
+ validated.append(fs)
483
+
484
+ return validated
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from swiftpy.ai.pricing import (
7
+ DEFAULT_MODEL_PRICING,
8
+ calculate_token_cost,
9
+ register_model_pricing,
10
+ )
11
+ from swiftpy.ai.providers.base import TokenUsage
12
+ from swiftpy.ai.vectorstore.base import SearchResult
13
+
14
+ __all__ = [
15
+ "DEFAULT_MODEL_PRICING",
16
+ "RAGResult",
17
+ "calculate_token_cost",
18
+ "register_model_pricing",
19
+ ]
20
+
21
+
22
+ def _safe_serialize_val(val: Any) -> Any:
23
+ """Safely convert metadata values to JSON-serializable types."""
24
+ if val is None or isinstance(val, (bool, int, float, str)):
25
+ return val
26
+ if isinstance(val, dict):
27
+ return {str(k): _safe_serialize_val(v) for k, v in val.items()}
28
+ if isinstance(val, (list, tuple, set)):
29
+ return [_safe_serialize_val(item) for item in val]
30
+ if hasattr(val, "to_dict") and callable(val.to_dict):
31
+ return val.to_dict()
32
+ if hasattr(val, "isoformat") and callable(val.isoformat):
33
+ return val.isoformat()
34
+ return str(val)
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class RAGResult:
39
+ """
40
+ Result returned by a RAG pipeline execution.
41
+
42
+ Exposes the generated text, retrieved source chunks, token telemetry,
43
+ estimated dollar cost, and execution metadata.
44
+ """
45
+
46
+ text: str
47
+ sources: list[SearchResult] = field(default_factory=list)
48
+ tokens: TokenUsage = field(default_factory=TokenUsage)
49
+ cost: float = 0.0
50
+ model: str = ""
51
+ latency_ms: float = 0.0
52
+ context: str = ""
53
+ raw: dict[str, Any] = field(default_factory=dict)
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ """Convert RAGResult to a serializable dictionary representation."""
57
+ return {
58
+ "text": self.text,
59
+ "sources": [
60
+ {
61
+ "id": s.id,
62
+ "score": s.score,
63
+ "content": s.content,
64
+ "metadata": _safe_serialize_val(s.metadata),
65
+ }
66
+ for s in self.sources
67
+ ],
68
+ "tokens": {
69
+ "prompt_tokens": self.tokens.prompt_tokens,
70
+ "completion_tokens": self.tokens.completion_tokens,
71
+ "total_tokens": self.tokens.total_tokens,
72
+ },
73
+ "cost": self.cost,
74
+ "model": self.model,
75
+ "latency_ms": self.latency_ms,
76
+ "context": self.context,
77
+ }
78
+