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,275 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from collections.abc import AsyncIterator
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ _OPENAI_HTTP_ERRORS: tuple[type[Exception], ...] = (httpx.TimeoutException, httpx.RequestError)
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 (
20
+ LLMRequest,
21
+ LLMResponse,
22
+ ProviderInterface,
23
+ TokenUsage,
24
+ ToolCall,
25
+ extract_error_message,
26
+ parse_retry_after,
27
+ strip_internal_extra,
28
+ )
29
+ from swiftpy.ai.streaming import parse_sse_events
30
+
31
+
32
+ class OpenAIProvider(ProviderInterface):
33
+ """
34
+ OpenAI provider adapter mapping unified requests to the /v1/chat/completions endpoint.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ config: dict[str, Any] | None = None,
40
+ http_pool: AIHttpPool | None = None,
41
+ ) -> None:
42
+ super().__init__(config=config, http_pool=http_pool)
43
+ self._api_key: str = str(self.config.get("api_key", ""))
44
+ self._default_model: str = str(
45
+ self.config.get("model") or self.config.get("default_model") or "gpt-4o"
46
+ )
47
+ self._base_url: str = str(
48
+ self.config.get("base_url", "https://api.openai.com/v1")
49
+ ).rstrip("/")
50
+ self._timeout: float = float(self.config.get("timeout", 60.0))
51
+
52
+ @property
53
+ def name(self) -> str:
54
+ return "openai"
55
+
56
+ def get_default_model(self) -> str:
57
+ return self._default_model
58
+
59
+ def _build_payload(self, request: LLMRequest, *, stream: bool = False) -> tuple[str, dict[str, Any]]:
60
+ model = request.model or self.get_default_model()
61
+ payload: dict[str, Any] = {
62
+ "model": model,
63
+ "messages": [m.to_dict() for m in request.messages],
64
+ }
65
+ if stream:
66
+ payload["stream"] = True
67
+ if request.temperature is not None:
68
+ payload["temperature"] = request.temperature
69
+ if request.max_tokens is not None:
70
+ payload["max_tokens"] = request.max_tokens
71
+ if request.top_p is not None:
72
+ payload["top_p"] = request.top_p
73
+ if request.stop is not None:
74
+ payload["stop"] = request.stop
75
+ if request.tools is not None:
76
+ payload["tools"] = request.tools
77
+ if request.extra:
78
+ payload.update(strip_internal_extra(request.extra))
79
+ return model, payload
80
+
81
+ def _raise_for_http_error(self, response: httpx.Response, body_text: str, data: Any) -> None:
82
+ if response.status_code in (401, 403):
83
+ err_msg = extract_error_message(data, body_text) or "Authentication failed"
84
+ raise AIAuthenticationError(
85
+ f"OpenAI authentication failed ({response.status_code}): {err_msg}",
86
+ provider=self.name,
87
+ status_code=response.status_code,
88
+ response_body=body_text,
89
+ )
90
+ if response.status_code == 429:
91
+ retry_after = parse_retry_after(response.headers.get("Retry-After"))
92
+ err_msg = extract_error_message(data, body_text) or "Rate limit reached"
93
+ raise AIRateLimitError(
94
+ f"OpenAI rate limit exceeded: {err_msg}",
95
+ provider=self.name,
96
+ status_code=response.status_code,
97
+ response_body=body_text,
98
+ retry_after=retry_after,
99
+ )
100
+ err_msg = extract_error_message(data, body_text) or f"HTTP {response.status_code}"
101
+ raise AIProviderError(
102
+ f"OpenAI API error ({response.status_code}): {err_msg}",
103
+ provider=self.name,
104
+ status_code=response.status_code,
105
+ response_body=body_text,
106
+ )
107
+
108
+ async def send(self, request: LLMRequest) -> LLMResponse:
109
+ url = f"{self._base_url}/chat/completions"
110
+ headers = {
111
+ "Authorization": f"Bearer {self._api_key}",
112
+ "Content-Type": "application/json",
113
+ }
114
+ model, payload = self._build_payload(request, stream=False)
115
+ client = self._http_pool.get_client()
116
+
117
+ async def _attempt() -> tuple[httpx.Response, float, str, Any]:
118
+ start_time = time.perf_counter()
119
+ try:
120
+ response = await client.post(
121
+ url,
122
+ headers=headers,
123
+ json=payload,
124
+ timeout=self._timeout,
125
+ )
126
+ except _OPENAI_HTTP_ERRORS as exc:
127
+ sanitized_exc = self.sanitize_error(str(exc))
128
+ raise AIConnectionError(
129
+ f"Failed to connect to OpenAI ({type(exc).__name__}): {sanitized_exc}",
130
+ provider=self.name,
131
+ ) from exc
132
+
133
+ latency_ms = (time.perf_counter() - start_time) * 1000
134
+ body_text = self.sanitize_error(response.text)
135
+ try:
136
+ data: Any = response.json()
137
+ except (json.JSONDecodeError, ValueError):
138
+ data = None
139
+
140
+ if response.status_code >= 400:
141
+ self._raise_for_http_error(response, body_text, data)
142
+ return response, latency_ms, body_text, data
143
+
144
+ response, latency_ms, body_text, data = await self._call_with_retries(_attempt)
145
+
146
+ if not isinstance(data, dict):
147
+ raise AIProviderError(
148
+ f"OpenAI returned non-JSON response ({response.status_code}): {body_text[:200]}",
149
+ provider=self.name,
150
+ status_code=response.status_code,
151
+ response_body=body_text,
152
+ )
153
+
154
+ choices = data.get("choices")
155
+ if not isinstance(choices, list) or len(choices) == 0:
156
+ raise AIProviderError(
157
+ "OpenAI returned a response with no choices",
158
+ provider=self.name,
159
+ status_code=response.status_code,
160
+ response_body=body_text,
161
+ )
162
+
163
+ choice = choices[0]
164
+ if not isinstance(choice, dict):
165
+ raise AIProviderError(
166
+ "OpenAI returned malformed choice object",
167
+ provider=self.name,
168
+ status_code=response.status_code,
169
+ response_body=body_text,
170
+ )
171
+
172
+ msg = choice.get("message") or {}
173
+ content = str(msg.get("content") or "") if isinstance(msg, dict) else ""
174
+ role = str(msg.get("role") or "assistant") if isinstance(msg, dict) else "assistant"
175
+ finish_reason = choice.get("finish_reason")
176
+
177
+ extracted_tool_calls: list[ToolCall] = []
178
+ raw_tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else None
179
+ if isinstance(raw_tool_calls, list):
180
+ for tc in raw_tool_calls:
181
+ if isinstance(tc, dict):
182
+ tc_id = str(tc.get("id") or "")
183
+ fn = tc.get("function") or {}
184
+ fn_name = str(fn.get("name") or "") if isinstance(fn, dict) else ""
185
+ args_raw = fn.get("arguments") if isinstance(fn, dict) else {}
186
+ if isinstance(args_raw, str):
187
+ try:
188
+ args_parsed = json.loads(args_raw)
189
+ except (json.JSONDecodeError, TypeError):
190
+ args_parsed = {"raw": args_raw}
191
+ elif isinstance(args_raw, dict):
192
+ args_parsed = args_raw
193
+ else:
194
+ args_parsed = {}
195
+ extracted_tool_calls.append(
196
+ ToolCall(id=tc_id, name=fn_name, arguments=args_parsed)
197
+ )
198
+
199
+ raw_usage = data.get("usage")
200
+ if isinstance(raw_usage, dict):
201
+ usage = TokenUsage(
202
+ prompt_tokens=int(raw_usage.get("prompt_tokens") or 0),
203
+ completion_tokens=int(raw_usage.get("completion_tokens") or 0),
204
+ total_tokens=int(raw_usage.get("total_tokens") or 0),
205
+ )
206
+ else:
207
+ usage = TokenUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0)
208
+
209
+ model_reported = str(data.get("model") or model)
210
+
211
+ return LLMResponse(
212
+ content=content,
213
+ role=role,
214
+ model=model_reported,
215
+ usage=usage,
216
+ raw=data,
217
+ latency_ms=round(latency_ms, 2),
218
+ finish_reason=str(finish_reason) if finish_reason else None,
219
+ tool_calls=extracted_tool_calls,
220
+ )
221
+
222
+ async def stream(self, request: LLMRequest) -> AsyncIterator[str]:
223
+ url = f"{self._base_url}/chat/completions"
224
+ headers = {
225
+ "Authorization": f"Bearer {self._api_key}",
226
+ "Content-Type": "application/json",
227
+ "Accept": "text/event-stream",
228
+ }
229
+ _, payload = self._build_payload(request, stream=True)
230
+ client = self._http_pool.get_client()
231
+
232
+ async def _open_stream() -> httpx.Response:
233
+ try:
234
+ req = client.build_request(
235
+ "POST",
236
+ url,
237
+ headers=headers,
238
+ json=payload,
239
+ timeout=self._timeout,
240
+ )
241
+ resp = await client.send(req, stream=True)
242
+ except _OPENAI_HTTP_ERRORS as exc:
243
+ sanitized_exc = self.sanitize_error(str(exc))
244
+ raise AIConnectionError(
245
+ f"Failed to connect to OpenAI stream ({type(exc).__name__}): {sanitized_exc}",
246
+ provider=self.name,
247
+ ) from exc
248
+
249
+ if resp.status_code >= 400:
250
+ raw_bytes = await resp.aread()
251
+ body_text = self.sanitize_error(raw_bytes.decode("utf-8", errors="replace"))
252
+ await resp.aclose()
253
+ try:
254
+ err_data: Any = json.loads(body_text)
255
+ except (json.JSONDecodeError, ValueError):
256
+ err_data = None
257
+ self._raise_for_http_error(resp, body_text, err_data)
258
+ return resp
259
+
260
+ response = await self._call_with_retries(_open_stream)
261
+
262
+ try:
263
+ async for event in parse_sse_events(response.aiter_lines()):
264
+ data = event.get("data")
265
+ if isinstance(data, dict):
266
+ choices = data.get("choices")
267
+ if isinstance(choices, list) and choices:
268
+ delta = choices[0].get("delta")
269
+ if isinstance(delta, dict):
270
+ content = delta.get("content")
271
+ if content and isinstance(content, str):
272
+ yield content
273
+ finally:
274
+ await response.aclose()
275
+
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from swiftpy.ai.rag.context_formatter import (
4
+ ContextFormatter,
5
+ NumberedListFormatter,
6
+ PlainConcatenationFormatter,
7
+ XMLBlocksFormatter,
8
+ )
9
+ from swiftpy.ai.rag.pipeline_builder import (
10
+ DEFAULT_PROMPT_TEMPLATE,
11
+ DEFAULT_SYSTEM_PROMPT,
12
+ RAGPipelineBuilder,
13
+ )
14
+ from swiftpy.ai.rag.reranker import (
15
+ BaseReranker,
16
+ CohereReranker,
17
+ CrossEncoderReranker,
18
+ )
19
+ from swiftpy.ai.rag.result import (
20
+ DEFAULT_MODEL_PRICING,
21
+ RAGResult,
22
+ calculate_token_cost,
23
+ register_model_pricing,
24
+ )
25
+ from swiftpy.ai.rag.retriever import Retriever, chunk_text, truncate_to_token_budget
26
+
27
+
28
+ def rag() -> RAGPipelineBuilder:
29
+ """Convenience factory returning a fluent RAGPipelineBuilder."""
30
+ return RAGPipelineBuilder()
31
+
32
+
33
+ __all__ = [
34
+ "DEFAULT_MODEL_PRICING",
35
+ "DEFAULT_PROMPT_TEMPLATE",
36
+ "DEFAULT_SYSTEM_PROMPT",
37
+ "BaseReranker",
38
+ "CohereReranker",
39
+ "ContextFormatter",
40
+ "CrossEncoderReranker",
41
+ "NumberedListFormatter",
42
+ "PlainConcatenationFormatter",
43
+ "RAGPipelineBuilder",
44
+ "RAGResult",
45
+ "Retriever",
46
+ "XMLBlocksFormatter",
47
+ "calculate_token_cost",
48
+ "chunk_text",
49
+ "rag",
50
+ "register_model_pricing",
51
+ "truncate_to_token_budget",
52
+ ]
53
+
@@ -0,0 +1,263 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import re
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Sequence
7
+ from typing import Any
8
+
9
+ from swiftpy.ai.vectorstore.base import SearchResult
10
+
11
+ _CONTEXT_BOUNDARY_RE = re.compile(
12
+ r"-{3,}\s*(BEGIN|END)\s+UNTRUSTED\s+REFERENCE\s+CONTEXT\s*-{3,}",
13
+ re.IGNORECASE,
14
+ )
15
+ _NONCE_BOUNDARY_RE = re.compile(
16
+ r"<<<\s*(?:END\s+)?CONTEXT_[A-Za-z0-9_-]*>>>",
17
+ re.IGNORECASE,
18
+ )
19
+ _XML_TAG_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
20
+
21
+
22
+ def escape_context_boundaries(text: str) -> str:
23
+ """Neutralize embedded context boundary delimiters in untrusted text."""
24
+ escaped = _CONTEXT_BOUNDARY_RE.sub(
25
+ lambda m: f"[escaped: {m.group(1).upper()} UNTRUSTED REFERENCE CONTEXT]",
26
+ text,
27
+ )
28
+ return _NONCE_BOUNDARY_RE.sub("[escaped: CONTEXT BOUNDARY]", escaped)
29
+
30
+
31
+ def _extract_doc_content(
32
+ doc: SearchResult | str | dict[str, Any],
33
+ index: int,
34
+ ) -> tuple[str, str, dict[str, Any], float]:
35
+ """
36
+ Extract (content, id, metadata, score) from a document input.
37
+ """
38
+ if isinstance(doc, SearchResult):
39
+ return doc.content, doc.id, doc.metadata, doc.score
40
+ if isinstance(doc, str):
41
+ return doc, f"doc_{index}", {}, 0.0
42
+ if isinstance(doc, dict):
43
+ return (
44
+ str(doc.get("content", "")),
45
+ str(doc.get("id", f"doc_{index}")),
46
+ dict(doc.get("metadata") or {}),
47
+ float(doc.get("score", 0.0)),
48
+ )
49
+ raise TypeError(f"Unsupported document type at index {index}: {type(doc).__name__}")
50
+
51
+
52
+ class ContextFormatter(ABC):
53
+ """
54
+ Abstract contract for context formatting strategies in RAG pipelines.
55
+ """
56
+
57
+ @abstractmethod
58
+ def format(
59
+ self,
60
+ documents: Sequence[SearchResult | str | dict[str, Any]],
61
+ ) -> str:
62
+ """
63
+ Format a sequence of retrieved chunks into a prompt-ready context string.
64
+ """
65
+
66
+ @classmethod
67
+ def create(cls, strategy: str | ContextFormatter) -> ContextFormatter:
68
+ """
69
+ Factory method resolving a formatter instance from a strategy name or instance.
70
+
71
+ Supported strategies:
72
+ - 'numbered', 'numbered_list' -> NumberedListFormatter
73
+ - 'xml', 'xml_blocks' -> XMLBlocksFormatter
74
+ - 'plain', 'concatenation', 'plain_concatenation' -> PlainConcatenationFormatter
75
+ """
76
+ if isinstance(strategy, ContextFormatter):
77
+ return strategy
78
+
79
+ norm = str(strategy).lower().strip().replace("-", "_")
80
+ if norm in ("numbered", "numbered_list", "number"):
81
+ return NumberedListFormatter()
82
+ if norm in ("xml", "xml_blocks", "xml_block"):
83
+ return XMLBlocksFormatter()
84
+ if norm in ("plain", "concatenation", "plain_concatenation", "concat"):
85
+ return PlainConcatenationFormatter()
86
+
87
+ raise ValueError(
88
+ f"Unknown context formatter strategy '{strategy}'. "
89
+ f"Supported strategies: 'numbered', 'xml', 'concatenation'."
90
+ )
91
+
92
+ @classmethod
93
+ def numbered(
94
+ cls,
95
+ separator: str = "\n\n",
96
+ include_metadata: bool = False,
97
+ ) -> NumberedListFormatter:
98
+ """Create a numbered list context formatter."""
99
+ return NumberedListFormatter(separator=separator, include_metadata=include_metadata)
100
+
101
+ @classmethod
102
+ def xml(
103
+ cls,
104
+ root_tag: str = "context",
105
+ document_tag: str = "document",
106
+ include_attributes: bool = True,
107
+ escape_content: bool = True,
108
+ ) -> XMLBlocksFormatter:
109
+ """Create an XML blocks context formatter."""
110
+ return XMLBlocksFormatter(
111
+ root_tag=root_tag,
112
+ document_tag=document_tag,
113
+ include_attributes=include_attributes,
114
+ escape_content=escape_content,
115
+ )
116
+
117
+ @classmethod
118
+ def concatenation(
119
+ cls,
120
+ separator: str = "\n\n",
121
+ prefix: str = "",
122
+ suffix: str = "",
123
+ ) -> PlainConcatenationFormatter:
124
+ """Create a plain concatenation context formatter."""
125
+ return PlainConcatenationFormatter(separator=separator, prefix=prefix, suffix=suffix)
126
+
127
+
128
+ class NumberedListFormatter(ContextFormatter):
129
+ """
130
+ Formats retrieved documents as a numbered list:
131
+ [1] First document content
132
+ [2] Second document content
133
+ """
134
+
135
+ def __init__(
136
+ self,
137
+ separator: str = "\n\n",
138
+ include_metadata: bool = False,
139
+ ) -> None:
140
+ self.separator = separator
141
+ self.include_metadata = include_metadata
142
+
143
+ def format(
144
+ self,
145
+ documents: Sequence[SearchResult | str | dict[str, Any]],
146
+ ) -> str:
147
+ if not documents:
148
+ return ""
149
+
150
+ blocks: list[str] = []
151
+ for idx, doc in enumerate(documents, start=1):
152
+ content, doc_id, metadata, _ = _extract_doc_content(doc, idx)
153
+ safe_content = escape_context_boundaries(content)
154
+ header = f"[{idx}]"
155
+ if self.include_metadata:
156
+ meta_parts = [f"id: {escape_context_boundaries(str(doc_id))}"]
157
+ source = metadata.get("source") or metadata.get("title")
158
+ if source:
159
+ meta_parts.append(
160
+ f"source: {escape_context_boundaries(str(source))}"
161
+ )
162
+ header += f" ({', '.join(meta_parts)})"
163
+
164
+ blocks.append(f"{header} {safe_content}")
165
+
166
+ return self.separator.join(blocks)
167
+
168
+
169
+ class XMLBlocksFormatter(ContextFormatter):
170
+ """
171
+ Formats retrieved documents into structured XML blocks:
172
+ <context>
173
+ <document index="1" id="doc_1">
174
+ Content of document 1
175
+ </document>
176
+ </context>
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ root_tag: str = "context",
182
+ document_tag: str = "document",
183
+ include_attributes: bool = True,
184
+ escape_content: bool = True,
185
+ ) -> None:
186
+ if not isinstance(root_tag, str) or not _XML_TAG_NAME_RE.match(root_tag):
187
+ raise ValueError(
188
+ f"Invalid XML root_tag {root_tag!r}; expected match for {_XML_TAG_NAME_RE.pattern}"
189
+ )
190
+ if not isinstance(document_tag, str) or not _XML_TAG_NAME_RE.match(document_tag):
191
+ raise ValueError(
192
+ f"Invalid XML document_tag {document_tag!r}; expected match for {_XML_TAG_NAME_RE.pattern}"
193
+ )
194
+ self.root_tag = root_tag
195
+ self.document_tag = document_tag
196
+ self.include_attributes = include_attributes
197
+ self.escape_content = escape_content
198
+
199
+ def format(
200
+ self,
201
+ documents: Sequence[SearchResult | str | dict[str, Any]],
202
+ ) -> str:
203
+ if not documents:
204
+ return f"<{self.root_tag}></{self.root_tag}>"
205
+
206
+ lines: list[str] = [f"<{self.root_tag}>"]
207
+ for idx, doc in enumerate(documents, start=1):
208
+ content, doc_id, metadata, _score = _extract_doc_content(doc, idx)
209
+ attrs = ""
210
+ if self.include_attributes:
211
+ safe_id = html.escape(str(doc_id), quote=True)
212
+ attrs = f' index="{idx}" id="{safe_id}"'
213
+ source = metadata.get("source") or metadata.get("title")
214
+ if source:
215
+ safe_source = html.escape(str(source), quote=True)
216
+ attrs += f' source="{safe_source}"'
217
+
218
+ lines.append(f" <{self.document_tag}{attrs}>")
219
+ # Indent each line of content (preserving trailing newlines) and escape untrusted markup
220
+ safe_content = (
221
+ content if self.escape_content else escape_context_boundaries(content)
222
+ )
223
+ for content_line in safe_content.split("\n"):
224
+ formatted_line = (
225
+ html.escape(escape_context_boundaries(content_line))
226
+ if self.escape_content
227
+ else content_line
228
+ )
229
+ lines.append(f" {formatted_line}")
230
+ lines.append(f" </{self.document_tag}>")
231
+
232
+ lines.append(f"</{self.root_tag}>")
233
+ return "\n".join(lines)
234
+
235
+
236
+ class PlainConcatenationFormatter(ContextFormatter):
237
+ """
238
+ Formats retrieved documents by joining their raw text with a separator.
239
+ """
240
+
241
+ def __init__(
242
+ self,
243
+ separator: str = "\n\n",
244
+ prefix: str = "",
245
+ suffix: str = "",
246
+ ) -> None:
247
+ self.separator = separator
248
+ self.prefix = prefix
249
+ self.suffix = suffix
250
+
251
+ def format(
252
+ self,
253
+ documents: Sequence[SearchResult | str | dict[str, Any]],
254
+ ) -> str:
255
+ if not documents:
256
+ return ""
257
+
258
+ chunks = [
259
+ escape_context_boundaries(_extract_doc_content(doc, idx)[0])
260
+ for idx, doc in enumerate(documents, start=1)
261
+ ]
262
+ joined = self.separator.join(chunks)
263
+ return f"{self.prefix}{joined}{self.suffix}"