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,343 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from swiftpy.core.config import Config
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("swiftpy.ai.prompts")
|
|
16
|
+
|
|
17
|
+
_DEFAULT_CACHE_DIR = Path("bootstrap/cache")
|
|
18
|
+
|
|
19
|
+
_TEMPLATE_TOKEN_PATTERN = re.compile(
|
|
20
|
+
r"(?P<pair>(?:\\\\)+)(?=(?:\\?(?:\{\{|\}\})))"
|
|
21
|
+
r"|\\(?P<escaped>\{\{|\}\})"
|
|
22
|
+
r"|\{\{\s*(?P<var>[a-zA-Z0-9_]+)\s*\}\}"
|
|
23
|
+
)
|
|
24
|
+
VARIABLE_PATTERN = re.compile(r"(?<!\\)\{\{\s*([a-zA-Z0-9_]+)\s*\}\}")
|
|
25
|
+
ESCAPED_OPEN_PATTERN = re.compile(r"\\\{\{")
|
|
26
|
+
ESCAPED_CLOSE_PATTERN = re.compile(r"\\\}\}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _extract_variables(template: str) -> list[str]:
|
|
30
|
+
"""Extract unescaped {{ variable }} placeholder names in left-to-right order."""
|
|
31
|
+
return [
|
|
32
|
+
m.group("var")
|
|
33
|
+
for m in _TEMPLATE_TOKEN_PATTERN.finditer(template)
|
|
34
|
+
if m.group("var") is not None
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PromptLoader:
|
|
39
|
+
"""
|
|
40
|
+
Prompt manager responsible for loading prompt templates from disk,
|
|
41
|
+
interpolating {{ variable }} placeholders, and caching in production.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
_instance: PromptLoader | None = None
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
base_dir: str | Path | None = None,
|
|
49
|
+
cache: bool | None = None,
|
|
50
|
+
config: Config | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
if base_dir is None:
|
|
53
|
+
self._base_dir = Path("prompts")
|
|
54
|
+
else:
|
|
55
|
+
self._base_dir = Path(base_dir)
|
|
56
|
+
|
|
57
|
+
self._cache_override = cache
|
|
58
|
+
if config is None:
|
|
59
|
+
try:
|
|
60
|
+
self._config = Config.get_instance()
|
|
61
|
+
except RuntimeError:
|
|
62
|
+
self._config = Config()
|
|
63
|
+
else:
|
|
64
|
+
self._config = config
|
|
65
|
+
|
|
66
|
+
self._lock = threading.Lock()
|
|
67
|
+
self._cache: dict[str, str] = {}
|
|
68
|
+
self._load_compiled_cache()
|
|
69
|
+
|
|
70
|
+
def _default_cache_path(self) -> Path:
|
|
71
|
+
"""Compute deterministic precompiled cache path isolated to this loader's resolved base_dir."""
|
|
72
|
+
base_hash = hashlib.sha256(
|
|
73
|
+
str(self._base_dir.resolve()).encode("utf-8")
|
|
74
|
+
).hexdigest()[:16]
|
|
75
|
+
return _DEFAULT_CACHE_DIR / f"prompts_{base_hash}.json"
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _sidecar_meta_path(cache_path: Path) -> Path:
|
|
79
|
+
return cache_path.with_name(f"{cache_path.name}.meta.json")
|
|
80
|
+
|
|
81
|
+
def _load_compiled_cache(self, cache_path: str | Path | None = None) -> None:
|
|
82
|
+
p = Path(cache_path) if cache_path is not None else self._default_cache_path()
|
|
83
|
+
if not p.is_file():
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
meta_path = self._sidecar_meta_path(p)
|
|
87
|
+
expected_base = str(self._base_dir.resolve())
|
|
88
|
+
if meta_path.is_file():
|
|
89
|
+
try:
|
|
90
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
91
|
+
if isinstance(meta, dict) and meta.get("base_dir") != expected_base:
|
|
92
|
+
return
|
|
93
|
+
except Exception as exc: # noqa: BLE001
|
|
94
|
+
logger.debug("Could not read prompt cache metadata: %s", exc)
|
|
95
|
+
return
|
|
96
|
+
elif cache_path is None:
|
|
97
|
+
# Scoped default filename already hashes resolved base_dir
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
102
|
+
cached = json.load(f)
|
|
103
|
+
if isinstance(cached, dict):
|
|
104
|
+
with self._lock:
|
|
105
|
+
for key, value in cached.items():
|
|
106
|
+
if isinstance(key, str) and isinstance(value, str):
|
|
107
|
+
self._cache[key] = value
|
|
108
|
+
except Exception as exc: # noqa: BLE001
|
|
109
|
+
logger.debug("Could not read prompt cache: %s", exc)
|
|
110
|
+
|
|
111
|
+
def dump_cache(self, cache_path: str | Path | None = None) -> Path:
|
|
112
|
+
"""Recursively scan base prompts directory and precompile templates into a serialized cache."""
|
|
113
|
+
target = Path(cache_path) if cache_path is not None else self._default_cache_path()
|
|
114
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
data: dict[str, str] = {}
|
|
116
|
+
base = self._base_dir.resolve()
|
|
117
|
+
if base.is_dir():
|
|
118
|
+
candidates = sorted(
|
|
119
|
+
(
|
|
120
|
+
p
|
|
121
|
+
for p in base.rglob("*")
|
|
122
|
+
if p.is_file()
|
|
123
|
+
and p.suffix in (".md", ".py")
|
|
124
|
+
and not any(part.startswith(("_", ".")) for part in p.relative_to(base).parts)
|
|
125
|
+
),
|
|
126
|
+
key=lambda p: (0 if p.suffix == ".md" else 1, p.relative_to(base).as_posix()),
|
|
127
|
+
)
|
|
128
|
+
for p in candidates:
|
|
129
|
+
rel_name = p.relative_to(base).with_suffix("").as_posix()
|
|
130
|
+
if rel_name not in data:
|
|
131
|
+
try:
|
|
132
|
+
data[rel_name] = self.get_template(rel_name)
|
|
133
|
+
except Exception as exc: # noqa: BLE001
|
|
134
|
+
logger.debug("Skipping prompt '%s': %s", rel_name, exc)
|
|
135
|
+
|
|
136
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
137
|
+
json.dump(data, f, indent=2)
|
|
138
|
+
|
|
139
|
+
meta_path = self._sidecar_meta_path(target)
|
|
140
|
+
meta_path.write_text(
|
|
141
|
+
json.dumps({"base_dir": str(base)}, indent=2),
|
|
142
|
+
encoding="utf-8",
|
|
143
|
+
)
|
|
144
|
+
return target
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def clear_compiled_cache(cls, cache_path: str | Path | None = None) -> bool:
|
|
148
|
+
"""Remove serialized prompt cache file(s) and metadata sidecars if present."""
|
|
149
|
+
removed = False
|
|
150
|
+
if cache_path is not None:
|
|
151
|
+
target = Path(cache_path)
|
|
152
|
+
if target.is_file():
|
|
153
|
+
target.unlink()
|
|
154
|
+
removed = True
|
|
155
|
+
meta = cls._sidecar_meta_path(target)
|
|
156
|
+
if meta.is_file():
|
|
157
|
+
meta.unlink()
|
|
158
|
+
return removed
|
|
159
|
+
|
|
160
|
+
if _DEFAULT_CACHE_DIR.is_dir():
|
|
161
|
+
for p in _DEFAULT_CACHE_DIR.glob("prompts*.json*"):
|
|
162
|
+
if p.is_file():
|
|
163
|
+
p.unlink()
|
|
164
|
+
removed = True
|
|
165
|
+
return removed
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def get_instance(cls) -> PromptLoader:
|
|
169
|
+
"""Return or create a singleton instance of PromptLoader."""
|
|
170
|
+
if cls._instance is None:
|
|
171
|
+
cls._instance = cls()
|
|
172
|
+
return cls._instance
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def reset_instance(cls) -> None:
|
|
176
|
+
"""Reset singleton instance (useful in tests)."""
|
|
177
|
+
cls._instance = None
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def base_dir(self) -> Path:
|
|
181
|
+
"""Return the base prompts directory."""
|
|
182
|
+
return self._base_dir
|
|
183
|
+
|
|
184
|
+
def is_cache_enabled(self) -> bool:
|
|
185
|
+
"""
|
|
186
|
+
Check if prompt caching is active.
|
|
187
|
+
|
|
188
|
+
Caching is enabled when explicitly configured or when running in production
|
|
189
|
+
(APP_ENV == 'production').
|
|
190
|
+
"""
|
|
191
|
+
if self._cache_override is not None:
|
|
192
|
+
return self._cache_override
|
|
193
|
+
|
|
194
|
+
cfg_cache = self._config.get("ai.prompts.cache")
|
|
195
|
+
if cfg_cache is not None:
|
|
196
|
+
return bool(cfg_cache)
|
|
197
|
+
|
|
198
|
+
app_env = str(self._config.get("app.env", "local")).lower()
|
|
199
|
+
return app_env == "production"
|
|
200
|
+
|
|
201
|
+
def clear_cache(self) -> None:
|
|
202
|
+
"""Clear all cached prompt templates."""
|
|
203
|
+
with self._lock:
|
|
204
|
+
self._cache.clear()
|
|
205
|
+
|
|
206
|
+
def get_template(self, name: str, /) -> str:
|
|
207
|
+
"""
|
|
208
|
+
Load raw template string for the given prompt name without variable substitution.
|
|
209
|
+
|
|
210
|
+
Searches for {name}.md then {name}.py in the base directory (explicit precedence: .md preferred).
|
|
211
|
+
Guards against directory traversal by verifying resolved path containment.
|
|
212
|
+
Caches the template string when caching is enabled.
|
|
213
|
+
"""
|
|
214
|
+
clean_name = name.strip()
|
|
215
|
+
if not clean_name:
|
|
216
|
+
raise ValueError("Prompt name cannot be empty.")
|
|
217
|
+
|
|
218
|
+
# Guard against path traversal
|
|
219
|
+
base_resolved = self._base_dir.resolve()
|
|
220
|
+
path_md = (self._base_dir / f"{clean_name}.md").resolve()
|
|
221
|
+
path_py = (self._base_dir / f"{clean_name}.py").resolve()
|
|
222
|
+
|
|
223
|
+
if not path_md.is_relative_to(base_resolved) or not path_py.is_relative_to(base_resolved):
|
|
224
|
+
raise ValueError(f"Path traversal detected in prompt name: '{clean_name}'")
|
|
225
|
+
|
|
226
|
+
with self._lock:
|
|
227
|
+
if self.is_cache_enabled() and clean_name in self._cache:
|
|
228
|
+
return self._cache[clean_name]
|
|
229
|
+
|
|
230
|
+
template_str: str | None = None
|
|
231
|
+
if path_md.is_file():
|
|
232
|
+
template_str = path_md.read_text(encoding="utf-8")
|
|
233
|
+
elif path_py.is_file():
|
|
234
|
+
template_str = self._load_from_python_file(path_py)
|
|
235
|
+
else:
|
|
236
|
+
raise FileNotFoundError(
|
|
237
|
+
f"Prompt '{clean_name}' not found. Searched {path_md} and {path_py}."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
with self._lock:
|
|
241
|
+
if self.is_cache_enabled():
|
|
242
|
+
self._cache[clean_name] = template_str
|
|
243
|
+
|
|
244
|
+
return template_str
|
|
245
|
+
|
|
246
|
+
async def aget_template(self, name: str, /) -> str:
|
|
247
|
+
"""Asynchronously load raw template string off the asyncio event loop."""
|
|
248
|
+
return await asyncio.to_thread(self.get_template, name)
|
|
249
|
+
|
|
250
|
+
def _load_from_python_file(self, path: Path) -> str:
|
|
251
|
+
"""Load prompt template string from a Python script."""
|
|
252
|
+
digest = hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:12]
|
|
253
|
+
module_name = f"_swiftpy_prompt_{path.stem}_{digest}"
|
|
254
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
255
|
+
if spec is None or spec.loader is None:
|
|
256
|
+
raise ImportError(f"Could not load Python prompt specification from {path}")
|
|
257
|
+
|
|
258
|
+
module = importlib.util.module_from_spec(spec)
|
|
259
|
+
spec.loader.exec_module(module)
|
|
260
|
+
|
|
261
|
+
for attr in ("PROMPT", "prompt", "TEMPLATE", "template"):
|
|
262
|
+
if hasattr(module, attr):
|
|
263
|
+
val = getattr(module, attr)
|
|
264
|
+
if isinstance(val, str):
|
|
265
|
+
return val
|
|
266
|
+
if callable(val):
|
|
267
|
+
res = val()
|
|
268
|
+
if isinstance(res, str):
|
|
269
|
+
return res
|
|
270
|
+
|
|
271
|
+
if module.__doc__ and module.__doc__.strip():
|
|
272
|
+
return module.__doc__.strip()
|
|
273
|
+
|
|
274
|
+
raise ValueError(
|
|
275
|
+
f"Python prompt file '{path}' does not define 'PROMPT', 'prompt', or 'TEMPLATE' (str or callable)."
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def render(self, template: str, /, **variables: Any) -> str:
|
|
279
|
+
"""
|
|
280
|
+
Interpolate {{ variable }} placeholders in a single pass.
|
|
281
|
+
|
|
282
|
+
Supports escaped \\{{ ... \\}} placeholders without corrupting substituted
|
|
283
|
+
variable values or preceding literal backslash pairs (e.g. C:\\\\{{ var }}).
|
|
284
|
+
Raises KeyError if any referenced placeholder is missing from variables.
|
|
285
|
+
"""
|
|
286
|
+
def replacer(match: re.Match[str]) -> str:
|
|
287
|
+
pair = match.group("pair")
|
|
288
|
+
if pair is not None:
|
|
289
|
+
return "\\" * (len(pair) // 2)
|
|
290
|
+
escaped = match.group("escaped")
|
|
291
|
+
if escaped is not None:
|
|
292
|
+
return escaped
|
|
293
|
+
key = match.group("var")
|
|
294
|
+
if key not in variables:
|
|
295
|
+
raise KeyError(
|
|
296
|
+
f"Missing prompt variable: '{key}'. Provided variables: {list(variables.keys())}"
|
|
297
|
+
)
|
|
298
|
+
return str(variables[key])
|
|
299
|
+
|
|
300
|
+
return _TEMPLATE_TOKEN_PATTERN.sub(replacer, template)
|
|
301
|
+
|
|
302
|
+
def load(self, name: str, /, **variables: Any) -> str:
|
|
303
|
+
"""
|
|
304
|
+
Load prompt by name, interpolate {{ variable }} placeholders, and return rendered prompt.
|
|
305
|
+
"""
|
|
306
|
+
template = self.get_template(name)
|
|
307
|
+
if not variables:
|
|
308
|
+
remaining = _extract_variables(template)
|
|
309
|
+
if remaining:
|
|
310
|
+
missing = ", ".join(sorted(set(remaining)))
|
|
311
|
+
raise KeyError(
|
|
312
|
+
f"Missing required prompt variables: {missing} for prompt '{name}'"
|
|
313
|
+
)
|
|
314
|
+
return self.render(template, **variables)
|
|
315
|
+
|
|
316
|
+
async def aload(self, name: str, /, **variables: Any) -> str:
|
|
317
|
+
"""
|
|
318
|
+
Asynchronously load prompt by name off the event loop, interpolate variables,
|
|
319
|
+
and return the rendered prompt.
|
|
320
|
+
"""
|
|
321
|
+
template = await self.aget_template(name)
|
|
322
|
+
if not variables:
|
|
323
|
+
remaining = _extract_variables(template)
|
|
324
|
+
if remaining:
|
|
325
|
+
missing = ", ".join(sorted(set(remaining)))
|
|
326
|
+
raise KeyError(
|
|
327
|
+
f"Missing required prompt variables: {missing} for prompt '{name}'"
|
|
328
|
+
)
|
|
329
|
+
return self.render(template, **variables)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def load_prompt(name: str, /, **variables: Any) -> str:
|
|
333
|
+
"""
|
|
334
|
+
Convenience function to load and interpolate a prompt using the default PromptLoader.
|
|
335
|
+
"""
|
|
336
|
+
return PromptLoader.get_instance().load(name, **variables)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
async def aload_prompt(name: str, /, **variables: Any) -> str:
|
|
340
|
+
"""
|
|
341
|
+
Asynchronously load and interpolate a prompt using the default PromptLoader.
|
|
342
|
+
"""
|
|
343
|
+
return await PromptLoader.get_instance().aload(name, **variables)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from swiftpy.ai.providers.anthropic_provider import AnthropicProvider
|
|
4
|
+
from swiftpy.ai.providers.base import (
|
|
5
|
+
LLMRequest,
|
|
6
|
+
LLMResponse,
|
|
7
|
+
Message,
|
|
8
|
+
ProviderInterface,
|
|
9
|
+
TokenUsage,
|
|
10
|
+
)
|
|
11
|
+
from swiftpy.ai.providers.openai_provider import OpenAIProvider
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AnthropicProvider",
|
|
15
|
+
"LLMRequest",
|
|
16
|
+
"LLMResponse",
|
|
17
|
+
"Message",
|
|
18
|
+
"OpenAIProvider",
|
|
19
|
+
"ProviderInterface",
|
|
20
|
+
"TokenUsage",
|
|
21
|
+
]
|
|
22
|
+
|