promptfuse 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.
- promptfuse/__init__.py +14 -0
- promptfuse/cache.py +217 -0
- promptfuse/chain.py +58 -0
- promptfuse/client.py +108 -0
- promptfuse/errors.py +17 -0
- promptfuse/models.py +259 -0
- promptfuse/registry.py +218 -0
- promptfuse/snapshot.py +105 -0
- promptfuse/stores/__init__.py +1 -0
- promptfuse/stores/memory.py +71 -0
- promptfuse/stores/protocol.py +61 -0
- promptfuse/stores/sqlite.py +198 -0
- promptfuse/stores/yaml.py +266 -0
- promptfuse-0.1.0.dist-info/METADATA +356 -0
- promptfuse-0.1.0.dist-info/RECORD +17 -0
- promptfuse-0.1.0.dist-info/WHEEL +4 -0
- promptfuse-0.1.0.dist-info/licenses/LICENSE +21 -0
promptfuse/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Local prompt registry with a Langfuse-compatible read API."""
|
|
2
|
+
|
|
3
|
+
from promptfuse.client import Promptfuse
|
|
4
|
+
from promptfuse.errors import InvalidPromptRequest, PromptNotFound, PromptStoreError
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"InvalidPromptRequest",
|
|
10
|
+
"PromptNotFound",
|
|
11
|
+
"PromptStoreError",
|
|
12
|
+
"Promptfuse",
|
|
13
|
+
"__version__",
|
|
14
|
+
]
|
promptfuse/cache.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""In-memory prompt cache with stale-while-revalidate refresh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from promptfuse.chain import load_prompt
|
|
11
|
+
from promptfuse.errors import InvalidPromptRequest, PromptNotFound, PromptStoreError
|
|
12
|
+
from promptfuse.models import PromptClient, clone_client, fallback_client
|
|
13
|
+
from promptfuse.registry import Registry
|
|
14
|
+
from promptfuse.snapshot import Snapshot
|
|
15
|
+
|
|
16
|
+
CacheKey = tuple[str, str, str | int]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class _Entry:
|
|
21
|
+
client: PromptClient
|
|
22
|
+
expires_at: float
|
|
23
|
+
ttl: float
|
|
24
|
+
prompt_type: str
|
|
25
|
+
version: int | None
|
|
26
|
+
label: str | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MemoryCache:
|
|
30
|
+
"""Cache resolved prompt clients in front of a registry."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
registry: Registry,
|
|
35
|
+
*,
|
|
36
|
+
ttl_seconds: float = 60,
|
|
37
|
+
clock: Callable[[], float] | None = None,
|
|
38
|
+
seed: Registry | None = None,
|
|
39
|
+
snapshot: Snapshot | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._registry = registry
|
|
42
|
+
self._seed = seed
|
|
43
|
+
self._snapshot = snapshot
|
|
44
|
+
self._ttl = ttl_seconds
|
|
45
|
+
self._clock = clock if clock is not None else time.monotonic
|
|
46
|
+
self._entries: dict[CacheKey, _Entry] = {}
|
|
47
|
+
self._inflight: set[CacheKey] = set()
|
|
48
|
+
self._lock = threading.Lock()
|
|
49
|
+
|
|
50
|
+
def get_prompt(
|
|
51
|
+
self,
|
|
52
|
+
name: str,
|
|
53
|
+
*,
|
|
54
|
+
version: int | None = None,
|
|
55
|
+
label: str | None = None,
|
|
56
|
+
type: str = "text",
|
|
57
|
+
cache_ttl_seconds: float | None = None,
|
|
58
|
+
fallback: str | list[Any] | None = None,
|
|
59
|
+
) -> PromptClient:
|
|
60
|
+
if version is not None and label is not None:
|
|
61
|
+
raise InvalidPromptRequest("label and version are mutually exclusive")
|
|
62
|
+
ttl = self._ttl if cache_ttl_seconds is None else cache_ttl_seconds
|
|
63
|
+
if isinstance(ttl, bool) or not isinstance(ttl, (int, float)) or ttl < 0:
|
|
64
|
+
raise InvalidPromptRequest(f"invalid cache ttl: {ttl!r}")
|
|
65
|
+
key = _key(name, version, label)
|
|
66
|
+
if ttl == 0:
|
|
67
|
+
return self._load(key, name, version, label, type, fallback, remember=False, ttl=0)
|
|
68
|
+
now = self._clock()
|
|
69
|
+
with self._lock:
|
|
70
|
+
entry = self._entries.get(key)
|
|
71
|
+
if entry is not None and entry.expires_at > now and entry.prompt_type == type:
|
|
72
|
+
return clone_client(entry.client)
|
|
73
|
+
stale = entry if entry is not None and entry.prompt_type == type else None
|
|
74
|
+
if stale is not None:
|
|
75
|
+
self._schedule(key, stale)
|
|
76
|
+
return clone_client(stale.client)
|
|
77
|
+
return self._load(key, name, version, label, type, fallback, remember=True, ttl=ttl)
|
|
78
|
+
|
|
79
|
+
def invalidate_labels(self, name: str) -> None:
|
|
80
|
+
with self._lock:
|
|
81
|
+
for key in [key for key in self._entries if key[0] == name and key[1] == "label"]:
|
|
82
|
+
del self._entries[key]
|
|
83
|
+
if self._snapshot is not None:
|
|
84
|
+
self._snapshot.drop_labels(name)
|
|
85
|
+
|
|
86
|
+
def _load(
|
|
87
|
+
self,
|
|
88
|
+
key: CacheKey,
|
|
89
|
+
name: str,
|
|
90
|
+
version: int | None,
|
|
91
|
+
label: str | None,
|
|
92
|
+
prompt_type: str,
|
|
93
|
+
fallback: str | list[Any] | None,
|
|
94
|
+
*,
|
|
95
|
+
remember: bool,
|
|
96
|
+
ttl: float,
|
|
97
|
+
) -> PromptClient:
|
|
98
|
+
try:
|
|
99
|
+
loaded = load_prompt(
|
|
100
|
+
self._registry,
|
|
101
|
+
name=name,
|
|
102
|
+
version=version,
|
|
103
|
+
label=label,
|
|
104
|
+
prompt_type=prompt_type,
|
|
105
|
+
snapshot=self._snapshot,
|
|
106
|
+
key=key,
|
|
107
|
+
seed=self._seed,
|
|
108
|
+
allow_snapshot=True,
|
|
109
|
+
)
|
|
110
|
+
except PromptNotFound:
|
|
111
|
+
if fallback is not None:
|
|
112
|
+
return fallback_client(name, fallback)
|
|
113
|
+
raise
|
|
114
|
+
except PromptStoreError:
|
|
115
|
+
if fallback is not None:
|
|
116
|
+
return fallback_client(name, fallback)
|
|
117
|
+
raise
|
|
118
|
+
if loaded.source == "snapshot":
|
|
119
|
+
self._remember(
|
|
120
|
+
key,
|
|
121
|
+
loaded.client,
|
|
122
|
+
expires_at=self._clock(),
|
|
123
|
+
ttl=self._ttl,
|
|
124
|
+
prompt_type=prompt_type,
|
|
125
|
+
version=version,
|
|
126
|
+
label=label,
|
|
127
|
+
)
|
|
128
|
+
return clone_client(loaded.client)
|
|
129
|
+
self._remember_success(key, loaded.client, prompt_type, version, label, remember, ttl)
|
|
130
|
+
return clone_client(loaded.client)
|
|
131
|
+
|
|
132
|
+
def _remember_success(
|
|
133
|
+
self,
|
|
134
|
+
key: CacheKey,
|
|
135
|
+
client: PromptClient,
|
|
136
|
+
prompt_type: str,
|
|
137
|
+
version: int | None,
|
|
138
|
+
label: str | None,
|
|
139
|
+
remember: bool,
|
|
140
|
+
ttl: float,
|
|
141
|
+
) -> None:
|
|
142
|
+
if remember:
|
|
143
|
+
self._remember(
|
|
144
|
+
key,
|
|
145
|
+
client,
|
|
146
|
+
expires_at=self._clock() + ttl,
|
|
147
|
+
ttl=ttl,
|
|
148
|
+
prompt_type=prompt_type,
|
|
149
|
+
version=version,
|
|
150
|
+
label=label,
|
|
151
|
+
)
|
|
152
|
+
if self._snapshot is not None:
|
|
153
|
+
self._snapshot.put(key, client)
|
|
154
|
+
|
|
155
|
+
def _remember(
|
|
156
|
+
self,
|
|
157
|
+
key: CacheKey,
|
|
158
|
+
client: PromptClient,
|
|
159
|
+
*,
|
|
160
|
+
expires_at: float,
|
|
161
|
+
ttl: float,
|
|
162
|
+
prompt_type: str,
|
|
163
|
+
version: int | None,
|
|
164
|
+
label: str | None,
|
|
165
|
+
) -> None:
|
|
166
|
+
with self._lock:
|
|
167
|
+
self._entries[key] = _Entry(
|
|
168
|
+
client=clone_client(client),
|
|
169
|
+
expires_at=expires_at,
|
|
170
|
+
ttl=ttl,
|
|
171
|
+
prompt_type=prompt_type,
|
|
172
|
+
version=version,
|
|
173
|
+
label=label,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def _schedule(self, key: CacheKey, entry: _Entry) -> None:
|
|
177
|
+
with self._lock:
|
|
178
|
+
if key in self._inflight:
|
|
179
|
+
return
|
|
180
|
+
self._inflight.add(key)
|
|
181
|
+
thread = threading.Thread(target=self._refresh, args=(key, entry), daemon=True)
|
|
182
|
+
thread.start()
|
|
183
|
+
|
|
184
|
+
def _refresh(self, key: CacheKey, entry: _Entry) -> None:
|
|
185
|
+
try:
|
|
186
|
+
try:
|
|
187
|
+
loaded = load_prompt(
|
|
188
|
+
self._registry,
|
|
189
|
+
name=key[0],
|
|
190
|
+
version=entry.version,
|
|
191
|
+
label=entry.label,
|
|
192
|
+
prompt_type=entry.prompt_type,
|
|
193
|
+
snapshot=self._snapshot,
|
|
194
|
+
key=key,
|
|
195
|
+
seed=self._seed,
|
|
196
|
+
allow_snapshot=False,
|
|
197
|
+
)
|
|
198
|
+
except (PromptNotFound, PromptStoreError):
|
|
199
|
+
return
|
|
200
|
+
self._remember_success(
|
|
201
|
+
key,
|
|
202
|
+
loaded.client,
|
|
203
|
+
entry.prompt_type,
|
|
204
|
+
entry.version,
|
|
205
|
+
entry.label,
|
|
206
|
+
True,
|
|
207
|
+
entry.ttl,
|
|
208
|
+
)
|
|
209
|
+
finally:
|
|
210
|
+
with self._lock:
|
|
211
|
+
self._inflight.discard(key)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _key(name: str, version: int | None, label: str | None) -> CacheKey:
|
|
215
|
+
if version is not None:
|
|
216
|
+
return (name, "version", version)
|
|
217
|
+
return (name, "label", "production" if label is None else label)
|
promptfuse/chain.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Read a primary registry, then the disk snapshot, then a YAML seed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from promptfuse.errors import PromptNotFound, PromptStoreError
|
|
9
|
+
from promptfuse.models import PromptClient
|
|
10
|
+
from promptfuse.registry import Registry
|
|
11
|
+
from promptfuse.snapshot import Snapshot
|
|
12
|
+
|
|
13
|
+
Source = Literal["primary", "snapshot", "seed"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class LoadedPrompt:
|
|
18
|
+
client: PromptClient
|
|
19
|
+
source: Source
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_prompt(
|
|
23
|
+
registry: Registry,
|
|
24
|
+
*,
|
|
25
|
+
name: str,
|
|
26
|
+
version: int | None,
|
|
27
|
+
label: str | None,
|
|
28
|
+
prompt_type: str,
|
|
29
|
+
snapshot: Snapshot | None,
|
|
30
|
+
key: tuple[str, str, str | int],
|
|
31
|
+
seed: Registry | None,
|
|
32
|
+
allow_snapshot: bool,
|
|
33
|
+
) -> LoadedPrompt:
|
|
34
|
+
"""Resolve a prompt from the primary store.
|
|
35
|
+
|
|
36
|
+
``PromptNotFound`` from a healthy primary store propagates. ``PromptStoreError``
|
|
37
|
+
tries the snapshot, then ``seed``, and then propagates.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
client = registry.get_prompt(name, version=version, label=label, type=prompt_type)
|
|
42
|
+
except PromptStoreError as exc:
|
|
43
|
+
if allow_snapshot and snapshot is not None:
|
|
44
|
+
try:
|
|
45
|
+
snapped = snapshot.get(key)
|
|
46
|
+
except PromptStoreError:
|
|
47
|
+
snapped = None
|
|
48
|
+
if snapped is not None and snapped.type == prompt_type:
|
|
49
|
+
return LoadedPrompt(snapped, "snapshot")
|
|
50
|
+
if seed is not None:
|
|
51
|
+
try:
|
|
52
|
+
seeded = seed.get_prompt(name, version=version, label=label, type=prompt_type)
|
|
53
|
+
except (PromptNotFound, PromptStoreError):
|
|
54
|
+
seeded = None
|
|
55
|
+
if seeded is not None:
|
|
56
|
+
return LoadedPrompt(seeded, "seed")
|
|
57
|
+
raise exc
|
|
58
|
+
return LoadedPrompt(client, "primary")
|
promptfuse/client.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Public promptfuse client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from promptfuse.cache import MemoryCache
|
|
9
|
+
from promptfuse.errors import InvalidPromptRequest
|
|
10
|
+
from promptfuse.models import PromptClient
|
|
11
|
+
from promptfuse.registry import Registry
|
|
12
|
+
from promptfuse.snapshot import Snapshot
|
|
13
|
+
from promptfuse.stores.sqlite import SqliteStore
|
|
14
|
+
from promptfuse.stores.yaml import YamlStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Promptfuse:
|
|
18
|
+
"""Local prompt registry with a Langfuse-compatible prompt API."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
yaml_dir: Path | str | None = None,
|
|
24
|
+
sqlite_path: Path | str | None = None,
|
|
25
|
+
snapshot_path: Path | str | None = None,
|
|
26
|
+
cache_ttl_seconds: float = 60,
|
|
27
|
+
import_yaml: bool = False,
|
|
28
|
+
) -> None:
|
|
29
|
+
if yaml_dir is None and sqlite_path is None:
|
|
30
|
+
raise InvalidPromptRequest("yaml_dir or sqlite_path is required")
|
|
31
|
+
self._yaml = YamlStore(yaml_dir) if yaml_dir is not None else None
|
|
32
|
+
self._sqlite = SqliteStore(sqlite_path) if sqlite_path is not None else None
|
|
33
|
+
if self._sqlite is not None:
|
|
34
|
+
primary = self._sqlite
|
|
35
|
+
seed = Registry(self._yaml) if self._yaml is not None else None
|
|
36
|
+
else:
|
|
37
|
+
primary = self._yaml
|
|
38
|
+
seed = None
|
|
39
|
+
if primary is None:
|
|
40
|
+
raise InvalidPromptRequest("yaml_dir or sqlite_path is required")
|
|
41
|
+
self._registry = Registry(primary)
|
|
42
|
+
snapshot = Snapshot(snapshot_path) if snapshot_path is not None else None
|
|
43
|
+
self._cache = MemoryCache(
|
|
44
|
+
self._registry,
|
|
45
|
+
ttl_seconds=cache_ttl_seconds,
|
|
46
|
+
seed=seed,
|
|
47
|
+
snapshot=snapshot,
|
|
48
|
+
)
|
|
49
|
+
if import_yaml:
|
|
50
|
+
self.import_yaml()
|
|
51
|
+
|
|
52
|
+
def get_prompt(
|
|
53
|
+
self,
|
|
54
|
+
name: str,
|
|
55
|
+
*,
|
|
56
|
+
version: int | None = None,
|
|
57
|
+
label: str | None = None,
|
|
58
|
+
type: str = "text",
|
|
59
|
+
cache_ttl_seconds: float | None = None,
|
|
60
|
+
fallback: str | list[Any] | None = None,
|
|
61
|
+
) -> PromptClient:
|
|
62
|
+
return self._cache.get_prompt(
|
|
63
|
+
name,
|
|
64
|
+
version=version,
|
|
65
|
+
label=label,
|
|
66
|
+
type=type,
|
|
67
|
+
cache_ttl_seconds=cache_ttl_seconds,
|
|
68
|
+
fallback=fallback,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def create_prompt(
|
|
72
|
+
self,
|
|
73
|
+
name: str,
|
|
74
|
+
*,
|
|
75
|
+
type: str,
|
|
76
|
+
prompt: Any,
|
|
77
|
+
labels: list[str] | None = None,
|
|
78
|
+
config: dict[str, Any] | None = None,
|
|
79
|
+
tags: list[str] | None = None,
|
|
80
|
+
commit_message: str | None = None,
|
|
81
|
+
) -> PromptClient:
|
|
82
|
+
client = self._registry.create_prompt(
|
|
83
|
+
name,
|
|
84
|
+
type=type,
|
|
85
|
+
prompt=prompt,
|
|
86
|
+
labels=labels,
|
|
87
|
+
config=config,
|
|
88
|
+
tags=tags,
|
|
89
|
+
commit_message=commit_message,
|
|
90
|
+
)
|
|
91
|
+
self._cache.invalidate_labels(name)
|
|
92
|
+
return client
|
|
93
|
+
|
|
94
|
+
def update_prompt(self, name: str, *, version: int, new_labels: list[str]) -> PromptClient:
|
|
95
|
+
client = self._registry.update_prompt(name, version=version, new_labels=new_labels)
|
|
96
|
+
self._cache.invalidate_labels(name)
|
|
97
|
+
return client
|
|
98
|
+
|
|
99
|
+
def import_yaml(self) -> None:
|
|
100
|
+
"""Copy the YAML tree into the primary SQLite store."""
|
|
101
|
+
|
|
102
|
+
if self._yaml is None or self._sqlite is None:
|
|
103
|
+
raise InvalidPromptRequest("import_yaml requires yaml_dir and sqlite_path")
|
|
104
|
+
self._registry.import_records(self._yaml)
|
|
105
|
+
names = set(self._yaml.list_names())
|
|
106
|
+
names.update(name for name, _label, _version in self._yaml.label_assignments())
|
|
107
|
+
for name in names:
|
|
108
|
+
self._cache.invalidate_labels(name)
|
promptfuse/errors.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Errors raised by the prompt registry."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PromptfuseError(Exception):
|
|
5
|
+
"""Base error for promptfuse."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PromptNotFound(PromptfuseError):
|
|
9
|
+
"""The name, label, version, or prompt type does not resolve."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidPromptRequest(PromptfuseError):
|
|
13
|
+
"""The request or the stored prompt breaks the registry rules."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PromptStoreError(PromptfuseError):
|
|
17
|
+
"""The store could not be read or written."""
|
promptfuse/models.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Prompt clients and Langfuse-style ``{{variable}}`` compilation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from promptfuse.errors import InvalidPromptRequest
|
|
10
|
+
|
|
11
|
+
PromptType = str
|
|
12
|
+
ChatItem = dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def find_variable_names(content: str) -> list[str]:
|
|
16
|
+
"""Return ``{{name}}`` placeholders in ``content``, in order."""
|
|
17
|
+
|
|
18
|
+
names: list[str] = []
|
|
19
|
+
index = 0
|
|
20
|
+
while index < len(content):
|
|
21
|
+
found = _parse_variable(content, index)
|
|
22
|
+
if found is None:
|
|
23
|
+
break
|
|
24
|
+
names.append(found[0])
|
|
25
|
+
index = found[2]
|
|
26
|
+
return names
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compile_template(content: str, data: Mapping[str, Any] | None = None) -> str:
|
|
30
|
+
"""Substitute ``{{variable}}`` values.
|
|
31
|
+
|
|
32
|
+
A missing name is left unchanged, including its braces. ``None`` becomes
|
|
33
|
+
an empty string. Surrounding whitespace inside the braces is ignored.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
if data is None:
|
|
37
|
+
return content
|
|
38
|
+
pieces: list[str] = []
|
|
39
|
+
index = 0
|
|
40
|
+
while index < len(content):
|
|
41
|
+
found = _parse_variable(content, index)
|
|
42
|
+
if found is None:
|
|
43
|
+
pieces.append(content[index:])
|
|
44
|
+
break
|
|
45
|
+
name, start, end = found
|
|
46
|
+
pieces.append(content[index:start])
|
|
47
|
+
if name in data:
|
|
48
|
+
value = data[name]
|
|
49
|
+
pieces.append("" if value is None else str(value))
|
|
50
|
+
else:
|
|
51
|
+
pieces.append(content[start:end])
|
|
52
|
+
index = end
|
|
53
|
+
return "".join(pieces)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_variable(content: str, start_index: int) -> tuple[str, int, int] | None:
|
|
57
|
+
opening = content.find("{{", start_index)
|
|
58
|
+
if opening == -1:
|
|
59
|
+
return None
|
|
60
|
+
closing = content.find("}}", opening + 2)
|
|
61
|
+
if closing == -1:
|
|
62
|
+
return None
|
|
63
|
+
name = content[opening + 2 : closing].strip()
|
|
64
|
+
return name, opening, closing + 2
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def normalize_prompt(prompt_type: str, prompt: Any) -> str | list[ChatItem]:
|
|
68
|
+
"""Return the stored form of a text or chat prompt."""
|
|
69
|
+
|
|
70
|
+
if prompt_type == "text":
|
|
71
|
+
if not isinstance(prompt, str):
|
|
72
|
+
raise InvalidPromptRequest("a text prompt must be a string")
|
|
73
|
+
return prompt
|
|
74
|
+
if prompt_type == "chat":
|
|
75
|
+
if not isinstance(prompt, list):
|
|
76
|
+
raise InvalidPromptRequest("a chat prompt must be a list of messages")
|
|
77
|
+
return [_normalize_chat_item(item) for item in prompt]
|
|
78
|
+
raise InvalidPromptRequest(f"unknown prompt type: {prompt_type!r}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _normalize_chat_item(item: Any) -> ChatItem:
|
|
82
|
+
if not isinstance(item, dict):
|
|
83
|
+
raise InvalidPromptRequest("a chat item must be a mapping")
|
|
84
|
+
kind = item.get("type")
|
|
85
|
+
if kind == "placeholder" or (kind is None and "name" in item and "role" not in item):
|
|
86
|
+
if "role" in item or "content" in item:
|
|
87
|
+
raise InvalidPromptRequest("a placeholder must not include role or content")
|
|
88
|
+
name = item.get("name")
|
|
89
|
+
if not isinstance(name, str) or not name:
|
|
90
|
+
raise InvalidPromptRequest("a placeholder name must be a non-empty string")
|
|
91
|
+
return {"type": "placeholder", "name": name}
|
|
92
|
+
if kind not in (None, "message"):
|
|
93
|
+
raise InvalidPromptRequest(f"unknown chat item type: {kind!r}")
|
|
94
|
+
role = item.get("role")
|
|
95
|
+
content = item.get("content")
|
|
96
|
+
if not isinstance(role, str) or not isinstance(content, str):
|
|
97
|
+
raise InvalidPromptRequest("a chat message needs string role and content")
|
|
98
|
+
return {"type": "message", "role": role, "content": content}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class TextPrompt:
|
|
103
|
+
"""A text prompt client."""
|
|
104
|
+
|
|
105
|
+
name: str
|
|
106
|
+
version: int
|
|
107
|
+
prompt: str
|
|
108
|
+
config: dict[str, Any]
|
|
109
|
+
labels: tuple[str, ...]
|
|
110
|
+
tags: tuple[str, ...]
|
|
111
|
+
commit_message: str | None
|
|
112
|
+
is_fallback: bool = False
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def type(self) -> str:
|
|
116
|
+
return "text"
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def variables(self) -> list[str]:
|
|
120
|
+
return find_variable_names(self.prompt)
|
|
121
|
+
|
|
122
|
+
def compile(self, **kwargs: Any) -> str:
|
|
123
|
+
return compile_template(self.prompt, kwargs)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class ChatPrompt:
|
|
128
|
+
"""A chat prompt client."""
|
|
129
|
+
|
|
130
|
+
name: str
|
|
131
|
+
version: int
|
|
132
|
+
stored_messages: tuple[tuple[tuple[str, Any], ...], ...]
|
|
133
|
+
config: dict[str, Any]
|
|
134
|
+
labels: tuple[str, ...]
|
|
135
|
+
tags: tuple[str, ...]
|
|
136
|
+
commit_message: str | None
|
|
137
|
+
is_fallback: bool = False
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def type(self) -> str:
|
|
141
|
+
return "chat"
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def prompt(self) -> list[ChatItem]:
|
|
145
|
+
return [dict(item) for item in self.stored_messages]
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def variables(self) -> list[str]:
|
|
149
|
+
names: list[str] = []
|
|
150
|
+
for item in self.prompt:
|
|
151
|
+
if item.get("type") == "message":
|
|
152
|
+
names.extend(find_variable_names(item["content"]))
|
|
153
|
+
return names
|
|
154
|
+
|
|
155
|
+
def compile(self, **kwargs: Any) -> list[dict[str, Any]]:
|
|
156
|
+
compiled: list[dict[str, Any]] = []
|
|
157
|
+
for item in self.prompt:
|
|
158
|
+
if item["type"] == "message":
|
|
159
|
+
compiled.append(
|
|
160
|
+
{
|
|
161
|
+
"role": item["role"],
|
|
162
|
+
"content": compile_template(item["content"], kwargs),
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
continue
|
|
166
|
+
name = item["name"]
|
|
167
|
+
if name not in kwargs:
|
|
168
|
+
compiled.append({"type": "placeholder", "name": name})
|
|
169
|
+
continue
|
|
170
|
+
value = kwargs[name]
|
|
171
|
+
if isinstance(value, list):
|
|
172
|
+
for entry in value:
|
|
173
|
+
if isinstance(entry, dict):
|
|
174
|
+
copied = dict(entry)
|
|
175
|
+
content = copied.get("content", "")
|
|
176
|
+
if isinstance(content, str):
|
|
177
|
+
copied["content"] = compile_template(content, kwargs)
|
|
178
|
+
compiled.append(copied)
|
|
179
|
+
else:
|
|
180
|
+
compiled.append({"role": "NOT_GIVEN", "content": str(entry)})
|
|
181
|
+
else:
|
|
182
|
+
compiled.append({"role": "NOT_GIVEN", "content": str(value)})
|
|
183
|
+
return compiled
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
PromptClient = TextPrompt | ChatPrompt
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def make_client(
|
|
190
|
+
*,
|
|
191
|
+
name: str,
|
|
192
|
+
version: int,
|
|
193
|
+
prompt_type: str,
|
|
194
|
+
prompt: str | list[ChatItem],
|
|
195
|
+
config: Mapping[str, Any] | None = None,
|
|
196
|
+
labels: list[str] | tuple[str, ...] = (),
|
|
197
|
+
tags: list[str] | tuple[str, ...] = (),
|
|
198
|
+
commit_message: str | None = None,
|
|
199
|
+
is_fallback: bool = False,
|
|
200
|
+
) -> PromptClient:
|
|
201
|
+
"""Build a prompt client from stored fields."""
|
|
202
|
+
|
|
203
|
+
stored_config = copy.deepcopy(dict(config or {}))
|
|
204
|
+
stored_labels = tuple(sorted(labels))
|
|
205
|
+
stored_tags = tuple(tags)
|
|
206
|
+
if prompt_type == "text":
|
|
207
|
+
if not isinstance(prompt, str):
|
|
208
|
+
raise InvalidPromptRequest("a text prompt must be a string")
|
|
209
|
+
return TextPrompt(
|
|
210
|
+
name=name,
|
|
211
|
+
version=version,
|
|
212
|
+
prompt=prompt,
|
|
213
|
+
config=stored_config,
|
|
214
|
+
labels=stored_labels,
|
|
215
|
+
tags=stored_tags,
|
|
216
|
+
commit_message=commit_message,
|
|
217
|
+
is_fallback=is_fallback,
|
|
218
|
+
)
|
|
219
|
+
if prompt_type == "chat":
|
|
220
|
+
normalized = normalize_prompt("chat", prompt)
|
|
221
|
+
assert isinstance(normalized, list)
|
|
222
|
+
frozen = tuple(tuple(sorted(item.items())) for item in normalized)
|
|
223
|
+
return ChatPrompt(
|
|
224
|
+
name=name,
|
|
225
|
+
version=version,
|
|
226
|
+
stored_messages=frozen,
|
|
227
|
+
config=stored_config,
|
|
228
|
+
labels=stored_labels,
|
|
229
|
+
tags=stored_tags,
|
|
230
|
+
commit_message=commit_message,
|
|
231
|
+
is_fallback=is_fallback,
|
|
232
|
+
)
|
|
233
|
+
raise InvalidPromptRequest(f"unknown prompt type: {prompt_type!r}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def fallback_client(name: str, fallback: str | list[Any]) -> PromptClient:
|
|
237
|
+
"""Build the client used when the cache and the stores cannot answer."""
|
|
238
|
+
|
|
239
|
+
if isinstance(fallback, str):
|
|
240
|
+
prompt_type: str = "text"
|
|
241
|
+
prompt: str | list[ChatItem] = fallback
|
|
242
|
+
elif isinstance(fallback, list):
|
|
243
|
+
prompt_type = "chat"
|
|
244
|
+
prompt = normalize_prompt("chat", fallback)
|
|
245
|
+
else:
|
|
246
|
+
raise InvalidPromptRequest("fallback must be a string or a list of messages")
|
|
247
|
+
return make_client(
|
|
248
|
+
name=name,
|
|
249
|
+
version=0,
|
|
250
|
+
prompt_type=prompt_type,
|
|
251
|
+
prompt=prompt,
|
|
252
|
+
is_fallback=True,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def clone_client(client: PromptClient) -> PromptClient:
|
|
257
|
+
"""Return a detached copy of a cached client."""
|
|
258
|
+
|
|
259
|
+
return copy.deepcopy(client)
|