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,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
|
swiftpy/ai/rag/result.py
ADDED
|
@@ -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
|
+
|