promptmap 0.5.12__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.
- promptmap/__init__.py +479 -0
- promptmap/classifier.py +165 -0
- promptmap/cli/__init__.py +828 -0
- promptmap/cli/init_files.py +694 -0
- promptmap/cli/pretty_print.py +273 -0
- promptmap/cli/test.py +560 -0
- promptmap/cli/type_hints.py +249 -0
- promptmap/cli/vscode.py +33 -0
- promptmap/db.py +124 -0
- promptmap/docs.py +282 -0
- promptmap/executor/__init__.py +48 -0
- promptmap/executor/builtin_kinds.py +467 -0
- promptmap/executor/executor.py +524 -0
- promptmap/executor/graph.py +251 -0
- promptmap/executor/parser_initial.py +191 -0
- promptmap/executor/parser_model.py +223 -0
- promptmap/executor/run_context.py +580 -0
- promptmap/expectations.py +42 -0
- promptmap/files.py +910 -0
- promptmap/gates.py +158 -0
- promptmap/global_instruction.py +59 -0
- promptmap/html_templates/_nav.html +8 -0
- promptmap/html_templates/docs.html +324 -0
- promptmap/html_templates/run.html +96 -0
- promptmap/jsonpath.py +121 -0
- promptmap/llm.py +161 -0
- promptmap/model_spec.py +64 -0
- promptmap/model_types.py +238 -0
- promptmap/project_values.py +70 -0
- promptmap/promptparams.py +444 -0
- promptmap/rag.py +52 -0
- promptmap/run_ids.py +120 -0
- promptmap/secrets.py +80 -0
- promptmap/server/__init__.py +28 -0
- promptmap/server/__main__.py +36 -0
- promptmap/server/app.py +259 -0
- promptmap/storage/__init__.py +15 -0
- promptmap/storage/base.py +69 -0
- promptmap/storage/memory.py +255 -0
- promptmap/storage/sqlite.py +457 -0
- promptmap/tester.py +475 -0
- promptmap/tokens.py +64 -0
- promptmap/types.py +25 -0
- promptmap/validator.py +113 -0
- promptmap-0.5.12.dist-info/METADATA +101 -0
- promptmap-0.5.12.dist-info/RECORD +55 -0
- promptmap-0.5.12.dist-info/WHEEL +5 -0
- promptmap-0.5.12.dist-info/entry_points.txt +2 -0
- promptmap-0.5.12.dist-info/top_level.txt +2 -0
- promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/__init__.py +9 -0
- promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/agent/run_agent.py +517 -0
- promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/agent37_exec.py +881 -0
- promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/agent_exec.py +659 -0
- promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/modal_exec.py +422 -0
- promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/models.py +29 -0
promptmap/__init__.py
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""promptmap — dbt-inspired LLM prompt orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version as package_version
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Awaitable, Callable, Mapping
|
|
10
|
+
|
|
11
|
+
from promptmap.executor.parser_initial import (
|
|
12
|
+
UnknownConfigKeyWarning,
|
|
13
|
+
known_config_keys,
|
|
14
|
+
register_config_keys,
|
|
15
|
+
)
|
|
16
|
+
from promptmap.classifier import classifier_cache_check, systemone_classifier
|
|
17
|
+
from promptmap.executor.graph import UnknownModelTypeError
|
|
18
|
+
from promptmap.model_spec import ModelSpec
|
|
19
|
+
from promptmap.model_types import (
|
|
20
|
+
ModelCall,
|
|
21
|
+
ModelKind,
|
|
22
|
+
get_model_kind,
|
|
23
|
+
known_model_kinds,
|
|
24
|
+
model_kind,
|
|
25
|
+
register_model_kind,
|
|
26
|
+
)
|
|
27
|
+
from promptmap.files import (
|
|
28
|
+
BlobStore,
|
|
29
|
+
Dir,
|
|
30
|
+
File,
|
|
31
|
+
MemoryBlobStore,
|
|
32
|
+
Output,
|
|
33
|
+
SQLiteBlobStore,
|
|
34
|
+
)
|
|
35
|
+
from promptmap.secrets import Secrets
|
|
36
|
+
from promptmap.storage.base import StorageBackend
|
|
37
|
+
from promptmap.tokens import LLMResult
|
|
38
|
+
from promptmap.executor.executor import ModelRunResult
|
|
39
|
+
from promptmap.executor.parser_model import PreviousRun
|
|
40
|
+
from promptmap.types import PromptFile, PromptModelsDict
|
|
41
|
+
|
|
42
|
+
# Registers the built-in kinds ("", template, execute_python, agent). Imported here,
|
|
43
|
+
# after the registry exists, so that every entry point into promptmap has them without
|
|
44
|
+
# the registry needing a lazy-import hook.
|
|
45
|
+
import promptmap.executor.builtin_kinds # noqa: E402,F401
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"run",
|
|
49
|
+
"async_run",
|
|
50
|
+
"ModelSpec",
|
|
51
|
+
"ModelKind",
|
|
52
|
+
"ModelCall",
|
|
53
|
+
"model_kind",
|
|
54
|
+
"register_model_kind",
|
|
55
|
+
"get_model_kind",
|
|
56
|
+
"known_model_kinds",
|
|
57
|
+
"register_config_keys",
|
|
58
|
+
"known_config_keys",
|
|
59
|
+
"UnknownConfigKeyWarning",
|
|
60
|
+
"UnknownModelTypeError",
|
|
61
|
+
"systemone_classifier",
|
|
62
|
+
"classifier_cache_check",
|
|
63
|
+
"ModelStatus",
|
|
64
|
+
"ModelError",
|
|
65
|
+
"PromptModelsDict",
|
|
66
|
+
"PromptFile",
|
|
67
|
+
"StorageBackend",
|
|
68
|
+
"Secrets",
|
|
69
|
+
"File",
|
|
70
|
+
"Dir",
|
|
71
|
+
"Output",
|
|
72
|
+
"LLMResult",
|
|
73
|
+
"ModelRunResult",
|
|
74
|
+
"PreviousRun",
|
|
75
|
+
"BlobStore",
|
|
76
|
+
"MemoryBlobStore",
|
|
77
|
+
"SQLiteBlobStore",
|
|
78
|
+
"__version__",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _version_from_pyproject() -> str:
|
|
83
|
+
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
84
|
+
contents = pyproject.read_text(encoding="utf-8")
|
|
85
|
+
project_block = re.search(r"(?ms)^\[project\]\n(.*?)(?:^\[|\Z)", contents)
|
|
86
|
+
if project_block is None:
|
|
87
|
+
raise RuntimeError("Could not find [project] table in pyproject.toml")
|
|
88
|
+
|
|
89
|
+
match = re.search(r'^version\s*=\s*"([^"]+)"\s*$', project_block.group(1), re.MULTILINE)
|
|
90
|
+
if match is None:
|
|
91
|
+
raise RuntimeError("Could not find project.version in pyproject.toml")
|
|
92
|
+
return match.group(1)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _detect_version() -> str:
|
|
96
|
+
try:
|
|
97
|
+
return package_version("promptmap")
|
|
98
|
+
except PackageNotFoundError:
|
|
99
|
+
return _version_from_pyproject()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
__version__ = _detect_version()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ModelStatus(Enum):
|
|
106
|
+
"""Returned in the promptmap.run() dict for models that produced no LLM output."""
|
|
107
|
+
SKIPPED = "skipped" # upstream dependency failed → model was not run
|
|
108
|
+
ERROR = "error" # LLM call or render raised an exception (legacy)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ModelError:
|
|
112
|
+
"""Returned by promptmap.run() when a model fails; carries the exception message."""
|
|
113
|
+
def __init__(self, message: str) -> None:
|
|
114
|
+
self.message = message
|
|
115
|
+
|
|
116
|
+
def __str__(self) -> str:
|
|
117
|
+
return f"error: {self.message}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def async_run(
|
|
121
|
+
map_dir: str = "map",
|
|
122
|
+
models_from_dict: PromptModelsDict | dict[str, str] | None = None,
|
|
123
|
+
select: list[str] | None = None,
|
|
124
|
+
llm_call: Callable[[str], str | Awaitable[str]] | None = None,
|
|
125
|
+
rag_call: Callable[..., list] | None = None,
|
|
126
|
+
verbose: bool = True,
|
|
127
|
+
promptdata: dict | None = None,
|
|
128
|
+
promptfiles: dict[str, PromptFile] | None = None,
|
|
129
|
+
validation_dir: str | None = "validation",
|
|
130
|
+
tests_dir: str | None = "tests",
|
|
131
|
+
storage_backend: StorageBackend | None = None,
|
|
132
|
+
global_instruction: str | Callable[[], str] | None = None,
|
|
133
|
+
blob_store: BlobStore | None = None,
|
|
134
|
+
on_model_start: Callable[[str], None] | None = None,
|
|
135
|
+
on_model_done: Callable[[ModelRunResult], None] | None = None,
|
|
136
|
+
secrets: Mapping[str, str] | None = None,
|
|
137
|
+
cache_check: Callable[..., bool | Awaitable[bool]] | None = None,
|
|
138
|
+
):
|
|
139
|
+
"""
|
|
140
|
+
Execute prompt models as a Python library call.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
map_dir:
|
|
145
|
+
Path to the directory containing *.prompt files.
|
|
146
|
+
select:
|
|
147
|
+
Optional list of model names to run. All upstream dependencies are
|
|
148
|
+
also executed fresh; the prompt cache makes unchanged nodes instant.
|
|
149
|
+
llm_call:
|
|
150
|
+
Optional function ``(prompt: str) -> str`` to use as the LLM backend.
|
|
151
|
+
Falls back to ``llm_call`` in client.py alongside *map_dir*; with
|
|
152
|
+
neither, the run fails with a FileNotFoundError naming the fix.
|
|
153
|
+
rag_call:
|
|
154
|
+
Optional function ``(*args) -> list | str`` to back
|
|
155
|
+
``return_list_RAG_results()`` in templates.
|
|
156
|
+
Falls back to ``map/rag.py::do_RAG`` if present.
|
|
157
|
+
verbose:
|
|
158
|
+
Print a dbt-style progress log to stdout (default: True).
|
|
159
|
+
promptdata:
|
|
160
|
+
Optional dict of runtime variables. Access them in templates via
|
|
161
|
+
``{{ promptdata('key') }}``.
|
|
162
|
+
promptfiles:
|
|
163
|
+
Optional dict mapping name → file reference. Models that declare
|
|
164
|
+
``promptfiles: name`` in their config block will receive these as a
|
|
165
|
+
list passed to ``llm_call(prompt, files=[…])``.
|
|
166
|
+
Values may be a **string path**, a :class:`pathlib.Path`, or any
|
|
167
|
+
**binary file-like object** (``open(..., "rb")``, ``io.BytesIO``,
|
|
168
|
+
etc.) — whatever your ``llm_call`` implementation accepts.
|
|
169
|
+
tests_dir:
|
|
170
|
+
The tests directory, hashed into the run's ``map_hash`` so a changed
|
|
171
|
+
test changes the map's ID (see :mod:`promptmap.run_ids`). Ignored
|
|
172
|
+
for ``models_from_dict`` runs.
|
|
173
|
+
global_instruction:
|
|
174
|
+
Optional prompt text rendered into **every** model prompt — promptmap's
|
|
175
|
+
analogue of dbt's query-comment. Either a string or a zero-argument
|
|
176
|
+
callable returning one. Falls back to a ``global.prompt`` file
|
|
177
|
+
alongside ``map_dir`` when not given (file-based runs only).
|
|
178
|
+
|
|
179
|
+
The text is itself a Jinja template rendered with each model's own
|
|
180
|
+
context, so ``promptdata()``, ``model.name`` and ``was_skipped()``
|
|
181
|
+
work inside it; ``ref()`` does not, since that would make every model
|
|
182
|
+
depend on the referenced one. Reference ``{{ prompt }}`` to place the
|
|
183
|
+
model body explicitly, otherwise the instruction is prepended.
|
|
184
|
+
|
|
185
|
+
Individual models opt out with
|
|
186
|
+
``{{ config(global_instruction=False) }}``, and ``execute_python``
|
|
187
|
+
models never receive it.
|
|
188
|
+
blob_store:
|
|
189
|
+
Where the bytes of file outputs (:class:`File`, :class:`Dir`,
|
|
190
|
+
:class:`Output`) are kept. Falls back to ``blob_store`` in client.py,
|
|
191
|
+
then to the storage backend's own store (the SQLite database).
|
|
192
|
+
on_model_start:
|
|
193
|
+
Optional callback ``(model_name: str) -> None`` fired just before a
|
|
194
|
+
model starts executing. Use it for live progress in your own code.
|
|
195
|
+
on_model_done:
|
|
196
|
+
Optional callback ``(result: ModelRunResult) -> None`` fired when a
|
|
197
|
+
model finishes, whether it succeeded, errored or was skipped.
|
|
198
|
+
``result.status`` is ``"success"``, ``"error"`` or ``"skipped"``.
|
|
199
|
+
secrets:
|
|
200
|
+
Optional dict of run-scoped credentials (API keys, tokens). Model
|
|
201
|
+
kinds read them as ``call.secrets["NAME"]``; an ``llm_call`` that
|
|
202
|
+
declares a ``secrets`` parameter receives them as well, so one
|
|
203
|
+
``llm_call`` can serve a different key per run. They are never
|
|
204
|
+
rendered into templates, part of the cache key, stored, or shown in a
|
|
205
|
+
repr — changing a key's value still gets cache hits.
|
|
206
|
+
cache_check:
|
|
207
|
+
Optional function ``(prompt: str, previous: PreviousRun) -> bool``
|
|
208
|
+
asked on an exact prompt-cache miss whether the model's last
|
|
209
|
+
successful result (``previous.prompt``, ``previous.output``) is still
|
|
210
|
+
good enough for the new *prompt*. Return True to reuse it as a cache
|
|
211
|
+
hit — e.g. a cheap judge model deciding whether the prompt changed
|
|
212
|
+
meaningfully. Sync or async; may also declare ``config``,
|
|
213
|
+
``secrets`` and ``model`` parameters. Falls back to ``cache_check``
|
|
214
|
+
in client.py.
|
|
215
|
+
|
|
216
|
+
Returns
|
|
217
|
+
-------
|
|
218
|
+
``dict`` keyed by model name. Each value is the model's output string —
|
|
219
|
+
or, for a model that produced files, its :class:`File` / :class:`Dir` /
|
|
220
|
+
:class:`Output` value (or the dict/list holding them) — or
|
|
221
|
+
:class:`ModelStatus.SKIPPED` when an upstream model failed, or a
|
|
222
|
+
:class:`ModelError` carrying the message when that model itself failed.
|
|
223
|
+
|
|
224
|
+
The full per-model detail — rendered prompt, timing, cache hit — is not in
|
|
225
|
+
this return value; it is written to storage and read back by ``promptmap test``
|
|
226
|
+
and ``promptmap docs``.
|
|
227
|
+
"""
|
|
228
|
+
import time
|
|
229
|
+
from datetime import datetime
|
|
230
|
+
|
|
231
|
+
from promptmap.executor.executor import execute_run, ModelRunResult
|
|
232
|
+
from promptmap.executor.graph import (
|
|
233
|
+
load_models,
|
|
234
|
+
build_models_from_dict,
|
|
235
|
+
build_dag,
|
|
236
|
+
)
|
|
237
|
+
if storage_backend is None:
|
|
238
|
+
from promptmap.storage.sqlite import SQLiteStorageBackend
|
|
239
|
+
storage_backend = SQLiteStorageBackend()
|
|
240
|
+
|
|
241
|
+
console = None
|
|
242
|
+
if verbose:
|
|
243
|
+
from rich.console import Console
|
|
244
|
+
console = Console(highlight=False, soft_wrap=True)
|
|
245
|
+
|
|
246
|
+
def ts() -> str:
|
|
247
|
+
return datetime.now().strftime("%H:%M:%S")
|
|
248
|
+
|
|
249
|
+
def log(msg: str) -> None:
|
|
250
|
+
if verbose:
|
|
251
|
+
console.print(f"[dim]{ts()}[/dim] {msg}")
|
|
252
|
+
|
|
253
|
+
storage_backend.init_db()
|
|
254
|
+
|
|
255
|
+
# client.py is imported before the models are parsed: it is where a project
|
|
256
|
+
# registers its own model types, and an unregistered type looks like a typo.
|
|
257
|
+
if models_from_dict is None:
|
|
258
|
+
from promptmap.llm import try_load_client_module
|
|
259
|
+
|
|
260
|
+
try_load_client_module(map_dir)
|
|
261
|
+
|
|
262
|
+
raw = models_from_dict.models if isinstance(models_from_dict, PromptModelsDict) else models_from_dict
|
|
263
|
+
all_models = build_models_from_dict(raw) if models_from_dict is not None else load_models(map_dir)
|
|
264
|
+
|
|
265
|
+
if select:
|
|
266
|
+
import networkx as nx
|
|
267
|
+
dag = build_dag(all_models)
|
|
268
|
+
to_run: set[str] = set(select)
|
|
269
|
+
for name in select:
|
|
270
|
+
to_run.update(nx.ancestors(dag, name))
|
|
271
|
+
ordered = [all_models[name] for name in to_run if name in all_models]
|
|
272
|
+
else:
|
|
273
|
+
ordered = list(all_models.values())
|
|
274
|
+
|
|
275
|
+
git_sha = None
|
|
276
|
+
if models_from_dict is None:
|
|
277
|
+
import subprocess
|
|
278
|
+
try:
|
|
279
|
+
git_sha = subprocess.check_output(
|
|
280
|
+
["git", "rev-parse", "--short", "HEAD"], stderr=subprocess.DEVNULL
|
|
281
|
+
).decode().strip()
|
|
282
|
+
except Exception:
|
|
283
|
+
git_sha = None
|
|
284
|
+
|
|
285
|
+
# Resolve LLM and RAG backends from user files if not explicitly provided
|
|
286
|
+
if llm_call is None:
|
|
287
|
+
if models_from_dict is not None:
|
|
288
|
+
raise ValueError(
|
|
289
|
+
"llm_call must be provided when using models_from_dict. "
|
|
290
|
+
"Inline runs do not auto-load client.py from disk."
|
|
291
|
+
)
|
|
292
|
+
from promptmap.llm import resolve_llm_call
|
|
293
|
+
llm_call = resolve_llm_call(map_dir)
|
|
294
|
+
if rag_call is None and models_from_dict is None:
|
|
295
|
+
from promptmap.rag import resolve_rag_call
|
|
296
|
+
rag_call = resolve_rag_call(map_dir)
|
|
297
|
+
if blob_store is None and models_from_dict is None:
|
|
298
|
+
from promptmap.llm import resolve_blob_store
|
|
299
|
+
blob_store = resolve_blob_store(map_dir)
|
|
300
|
+
if cache_check is None and models_from_dict is None:
|
|
301
|
+
from promptmap.llm import resolve_cache_check
|
|
302
|
+
cache_check = resolve_cache_check(map_dir)
|
|
303
|
+
|
|
304
|
+
# Global instruction: explicit argument wins, else global.prompt on disk.
|
|
305
|
+
from promptmap.global_instruction import (
|
|
306
|
+
coerce_global_instruction,
|
|
307
|
+
resolve_global_instruction,
|
|
308
|
+
)
|
|
309
|
+
global_instruction = coerce_global_instruction(global_instruction)
|
|
310
|
+
if global_instruction is None and models_from_dict is None:
|
|
311
|
+
global_instruction = resolve_global_instruction(map_dir)
|
|
312
|
+
|
|
313
|
+
# Load per-model validators from validation_dir (optional)
|
|
314
|
+
validators = None
|
|
315
|
+
if validation_dir is not None and models_from_dict is None:
|
|
316
|
+
from promptmap.validator import load_validators
|
|
317
|
+
validators = load_validators(validation_dir)
|
|
318
|
+
|
|
319
|
+
run_id = storage_backend.create_run(
|
|
320
|
+
model_count=len(ordered),
|
|
321
|
+
git_sha=git_sha,
|
|
322
|
+
)
|
|
323
|
+
from promptmap.run_ids import record_run_ids
|
|
324
|
+
|
|
325
|
+
record_run_ids(
|
|
326
|
+
storage_backend,
|
|
327
|
+
run_id,
|
|
328
|
+
all_models.values(),
|
|
329
|
+
ordered,
|
|
330
|
+
tests_dir=tests_dir if models_from_dict is None else None,
|
|
331
|
+
global_instruction=global_instruction,
|
|
332
|
+
promptdata=promptdata,
|
|
333
|
+
promptfiles=promptfiles,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
total = len(ordered)
|
|
337
|
+
|
|
338
|
+
if verbose:
|
|
339
|
+
console.print(
|
|
340
|
+
f"[dim]{ts()}[/dim] Running with [bold cyan]promptmap[/bold cyan]={__version__}"
|
|
341
|
+
)
|
|
342
|
+
console.print(
|
|
343
|
+
f"[dim]{ts()}[/dim] Found [bold]{total}[/bold] prompt model{'s' if total != 1 else ''}"
|
|
344
|
+
+ (f" [dim](select: {sorted(select)})[/dim]" if select else "")
|
|
345
|
+
)
|
|
346
|
+
if git_sha:
|
|
347
|
+
console.print(f"[dim]{ts()}[/dim] git SHA [dim]{git_sha}[/dim]")
|
|
348
|
+
console.print(f"[dim]{ts()}[/dim]")
|
|
349
|
+
|
|
350
|
+
completed: list[ModelRunResult] = []
|
|
351
|
+
model_start_times: dict[str, float] = {}
|
|
352
|
+
|
|
353
|
+
def _log_model_line(idx: int, verb: str, name: str, badge: str) -> None:
|
|
354
|
+
prefix = f"{idx} of {total} {verb} prompt model {name} "
|
|
355
|
+
dots = "." * max(2, 50 - len(prefix))
|
|
356
|
+
if verbose:
|
|
357
|
+
console.print(f"[dim]{ts()}[/dim] [bold]{idx} of {total}[/bold] {verb} prompt model [cyan]{name}[/cyan] {dots} {badge}")
|
|
358
|
+
|
|
359
|
+
def on_start(name: str) -> None:
|
|
360
|
+
idx = len(completed) + 1
|
|
361
|
+
model_start_times[name] = time.monotonic()
|
|
362
|
+
_log_model_line(idx, "START", name, "[[yellow]RUN[/yellow]]")
|
|
363
|
+
if on_model_start:
|
|
364
|
+
on_model_start(name)
|
|
365
|
+
|
|
366
|
+
def on_done(result: ModelRunResult) -> None:
|
|
367
|
+
completed.append(result)
|
|
368
|
+
idx = len(completed)
|
|
369
|
+
elapsed = time.monotonic() - model_start_times.get(result.model_name, 0)
|
|
370
|
+
if result.status == "success":
|
|
371
|
+
cached = " cached" if result.cached else ""
|
|
372
|
+
_log_model_line(idx, "OK ", result.model_name, f"[[green]OK{cached} in {elapsed:.2f}s[/green]]")
|
|
373
|
+
elif result.status == "skipped":
|
|
374
|
+
_log_model_line(idx, "SKIP ", result.model_name, "[[yellow]SKIP[/yellow]]")
|
|
375
|
+
else:
|
|
376
|
+
_log_model_line(idx, "ERR ", result.model_name, "[[red]ERROR[/red]]")
|
|
377
|
+
if verbose:
|
|
378
|
+
console.print(f" [dim]{result.error}[/dim]")
|
|
379
|
+
if on_model_done:
|
|
380
|
+
on_model_done(result)
|
|
381
|
+
|
|
382
|
+
run_start = time.monotonic()
|
|
383
|
+
|
|
384
|
+
results = await execute_run(
|
|
385
|
+
run_id=run_id,
|
|
386
|
+
ordered_models=ordered,
|
|
387
|
+
storage_backend=storage_backend,
|
|
388
|
+
llm_call=llm_call,
|
|
389
|
+
rag_call=rag_call,
|
|
390
|
+
on_model_start=on_start,
|
|
391
|
+
on_model_done=on_done,
|
|
392
|
+
promptdata=promptdata,
|
|
393
|
+
promptfiles=promptfiles,
|
|
394
|
+
validators=validators or None,
|
|
395
|
+
global_instruction=global_instruction,
|
|
396
|
+
blob_store=blob_store,
|
|
397
|
+
secrets=secrets,
|
|
398
|
+
cache_check=cache_check,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
errors = sum(1 for r in results if r.status == "error")
|
|
402
|
+
skipped = sum(1 for r in results if r.status == "skipped")
|
|
403
|
+
successes = sum(1 for r in results if r.status == "success")
|
|
404
|
+
final_status = "success" if errors == 0 else ("partial" if successes > 0 else "error")
|
|
405
|
+
storage_backend.finish_run(run_id, final_status)
|
|
406
|
+
total_elapsed = time.monotonic() - run_start
|
|
407
|
+
|
|
408
|
+
if verbose:
|
|
409
|
+
console.print(f"[dim]{ts()}[/dim]")
|
|
410
|
+
console.print(
|
|
411
|
+
f"[dim]{ts()}[/dim] Finished running {total} prompt model{'s' if total != 1 else ''} "
|
|
412
|
+
f"in [bold]{total_elapsed:.2f}s[/bold]. "
|
|
413
|
+
+ ("[green]Completed successfully[/green]" if errors == 0 else "[red]Completed with errors[/red]")
|
|
414
|
+
)
|
|
415
|
+
pass_str = f"[green]PASS={successes}[/green]"
|
|
416
|
+
error_str = f"[red]ERROR={errors}[/red]"
|
|
417
|
+
skip_str = f"[yellow]SKIP={skipped}[/yellow]"
|
|
418
|
+
total_str = f"TOTAL={total}"
|
|
419
|
+
console.print(f"[dim]{ts()}[/dim] {pass_str} {error_str} {skip_str} {total_str}")
|
|
420
|
+
|
|
421
|
+
def _value(r):
|
|
422
|
+
if r.status == "skipped":
|
|
423
|
+
return ModelStatus.SKIPPED
|
|
424
|
+
if r.status == "error":
|
|
425
|
+
return ModelError(r.error or "error")
|
|
426
|
+
from promptmap.files import contains_files
|
|
427
|
+
|
|
428
|
+
if contains_files(r.value) or isinstance(r.value, str):
|
|
429
|
+
return r.value
|
|
430
|
+
return r.llm_output
|
|
431
|
+
|
|
432
|
+
return {r.model_name: _value(r) for r in results}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def run(
|
|
436
|
+
map_dir: str = "map",
|
|
437
|
+
models_from_dict: "PromptModelsDict | dict[str, str] | None" = None,
|
|
438
|
+
select: "list[str] | None" = None,
|
|
439
|
+
llm_call: "Callable[[str], str | Awaitable[str]] | None" = None,
|
|
440
|
+
rag_call: "Callable[..., list] | None" = None,
|
|
441
|
+
verbose: bool = True,
|
|
442
|
+
promptdata: "dict | None" = None,
|
|
443
|
+
promptfiles: "dict[str, PromptFile] | None" = None,
|
|
444
|
+
validation_dir: "str | None" = "validation",
|
|
445
|
+
tests_dir: "str | None" = "tests",
|
|
446
|
+
storage_backend: "StorageBackend | None" = None,
|
|
447
|
+
global_instruction: "str | Callable[[], str] | None" = None,
|
|
448
|
+
blob_store: "BlobStore | None" = None,
|
|
449
|
+
on_model_start: "Callable[[str], None] | None" = None,
|
|
450
|
+
on_model_done: "Callable[[ModelRunResult], None] | None" = None,
|
|
451
|
+
secrets: "Mapping[str, str] | None" = None,
|
|
452
|
+
cache_check: "Callable[..., bool | Awaitable[bool]] | None" = None,
|
|
453
|
+
):
|
|
454
|
+
"""Run prompt models synchronously.
|
|
455
|
+
|
|
456
|
+
Wraps :func:`async_run` in ``asyncio.run()``; same arguments, same dict
|
|
457
|
+
return value. Call ``async_run`` directly from inside an event loop.
|
|
458
|
+
"""
|
|
459
|
+
import asyncio
|
|
460
|
+
|
|
461
|
+
return asyncio.run(async_run(
|
|
462
|
+
map_dir=map_dir,
|
|
463
|
+
models_from_dict=models_from_dict,
|
|
464
|
+
select=select,
|
|
465
|
+
llm_call=llm_call,
|
|
466
|
+
rag_call=rag_call,
|
|
467
|
+
verbose=verbose,
|
|
468
|
+
promptdata=promptdata,
|
|
469
|
+
promptfiles=promptfiles,
|
|
470
|
+
validation_dir=validation_dir,
|
|
471
|
+
tests_dir=tests_dir,
|
|
472
|
+
storage_backend=storage_backend,
|
|
473
|
+
global_instruction=global_instruction,
|
|
474
|
+
blob_store=blob_store,
|
|
475
|
+
on_model_start=on_model_start,
|
|
476
|
+
on_model_done=on_model_done,
|
|
477
|
+
secrets=secrets,
|
|
478
|
+
cache_check=cache_check,
|
|
479
|
+
))
|
promptmap/classifier.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Classifier judging for ``promptmap test``.
|
|
3
|
+
|
|
4
|
+
A classifier (such as TypeSafe's Jev) answers a yes/no question about some
|
|
5
|
+
text with a probability instead of generating a reply. A classifier test
|
|
6
|
+
puts the question above a ``---`` line and the text being judged below it::
|
|
7
|
+
|
|
8
|
+
Does this haiku have exactly three lines?
|
|
9
|
+
---
|
|
10
|
+
{{ ref('haiku') }}
|
|
11
|
+
|
|
12
|
+
The test passes when P(yes) reaches the threshold (0.5 unless the test sets
|
|
13
|
+
``{{ config(threshold=0.8) }}``).
|
|
14
|
+
|
|
15
|
+
The backend is ``classify_call(state: str, question: str) -> float`` in
|
|
16
|
+
client.py. :func:`systemone_classifier` builds one for any service speaking
|
|
17
|
+
the ``/v1/systemone`` API — TypeSafe's hosted Jev, or a local Ollaya server —
|
|
18
|
+
using only the standard library.
|
|
19
|
+
|
|
20
|
+
Which judge a test uses: its own ``config(judge=...)``, else the run's judge
|
|
21
|
+
(``promptmap test --judge``, else ``test_judge`` in client.py, else ``"llm"``).
|
|
22
|
+
LLM-judged tests are unchanged: the whole rendered file, ``---`` included,
|
|
23
|
+
goes to ``llm_call``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.request
|
|
32
|
+
from typing import Callable
|
|
33
|
+
|
|
34
|
+
JUDGES = ("llm", "classifier")
|
|
35
|
+
DEFAULT_THRESHOLD = 0.5
|
|
36
|
+
SEPARATOR = "---"
|
|
37
|
+
|
|
38
|
+
ClassifyCall = Callable[[str, str], float]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def split_question(rendered: str) -> tuple[str, str]:
|
|
42
|
+
"""
|
|
43
|
+
Split a rendered classifier test into ``(question, state)`` at the first
|
|
44
|
+
line that is exactly ``---``.
|
|
45
|
+
|
|
46
|
+
Raises ValueError when there is no separator or either side is empty.
|
|
47
|
+
"""
|
|
48
|
+
lines = rendered.splitlines()
|
|
49
|
+
for i, line in enumerate(lines):
|
|
50
|
+
if line.strip() == SEPARATOR:
|
|
51
|
+
question = "\n".join(lines[:i]).strip()
|
|
52
|
+
state = "\n".join(lines[i + 1:]).strip()
|
|
53
|
+
if not question:
|
|
54
|
+
raise ValueError("Classifier test has no question above the '---' line.")
|
|
55
|
+
if not state:
|
|
56
|
+
raise ValueError("Classifier test has nothing to judge below the '---' line.")
|
|
57
|
+
return question, state
|
|
58
|
+
raise ValueError(
|
|
59
|
+
"Classifier test needs a '---' line: the question goes above it and "
|
|
60
|
+
"the text to judge below it."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_judge(value: str, where: str) -> str:
|
|
65
|
+
"""Validate a judge name, naming *where* it came from on error."""
|
|
66
|
+
if value not in JUDGES:
|
|
67
|
+
raise ValueError(f"{where}: judge must be one of {', '.join(JUDGES)}; got {value!r}.")
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_threshold(value: str | None) -> float:
|
|
72
|
+
"""Return the pass threshold from a config() value (a string), default 0.5."""
|
|
73
|
+
if value is None:
|
|
74
|
+
return DEFAULT_THRESHOLD
|
|
75
|
+
try:
|
|
76
|
+
threshold = float(value)
|
|
77
|
+
except ValueError:
|
|
78
|
+
raise ValueError(f"threshold must be a number between 0 and 1; got {value!r}.") from None
|
|
79
|
+
if not 0 <= threshold <= 1:
|
|
80
|
+
raise ValueError(f"threshold must be between 0 and 1; got {threshold}.")
|
|
81
|
+
return threshold
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def systemone_classifier(
|
|
85
|
+
model: str | None = None,
|
|
86
|
+
base_url: str | None = None,
|
|
87
|
+
api_key: str | None = None,
|
|
88
|
+
timeout: float = 30.0,
|
|
89
|
+
) -> ClassifyCall:
|
|
90
|
+
"""
|
|
91
|
+
Return a ``classify_call`` for a ``/v1/systemone`` endpoint.
|
|
92
|
+
|
|
93
|
+
Defaults follow TypeSafe's SDK: ``TYPESAFE_BASE_URL`` (else
|
|
94
|
+
``https://api.typesafe.ai``), ``TYPESAFE_DEFAULT_MODEL`` (else
|
|
95
|
+
``jev-latest``) and ``TYPESAFE_API_KEY``. For a local Ollaya server::
|
|
96
|
+
|
|
97
|
+
classify_call = promptmap.systemone_classifier(
|
|
98
|
+
model="laya:en", base_url="http://localhost:11435"
|
|
99
|
+
)
|
|
100
|
+
"""
|
|
101
|
+
base = (base_url or os.environ.get("TYPESAFE_BASE_URL") or "https://api.typesafe.ai").rstrip("/")
|
|
102
|
+
model = model or os.environ.get("TYPESAFE_DEFAULT_MODEL") or "jev-latest"
|
|
103
|
+
key = api_key if api_key is not None else os.environ.get("TYPESAFE_API_KEY")
|
|
104
|
+
|
|
105
|
+
def classify_call(state: str, question: str) -> float:
|
|
106
|
+
body = json.dumps({
|
|
107
|
+
"model": model,
|
|
108
|
+
"state": state,
|
|
109
|
+
"questions": {"pass": {"type": "noul", "instructions": question}},
|
|
110
|
+
}).encode()
|
|
111
|
+
headers = {"Content-Type": "application/json"}
|
|
112
|
+
if key:
|
|
113
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
114
|
+
request = urllib.request.Request(f"{base}/v1/systemone", data=body, headers=headers)
|
|
115
|
+
try:
|
|
116
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
117
|
+
data = json.load(response)
|
|
118
|
+
except urllib.error.HTTPError as exc:
|
|
119
|
+
detail = exc.read().decode(errors="replace")[:500]
|
|
120
|
+
raise RuntimeError(f"{base}/v1/systemone returned {exc.code}: {detail}") from None
|
|
121
|
+
try:
|
|
122
|
+
return float(data["answers"]["pass"]["noul"])
|
|
123
|
+
except (KeyError, TypeError, ValueError):
|
|
124
|
+
raise RuntimeError(f"Unexpected /v1/systemone response: {data!r}"[:500]) from None
|
|
125
|
+
|
|
126
|
+
return classify_call
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
#: The question :func:`classifier_cache_check` asks by default.
|
|
130
|
+
CACHE_QUESTION = (
|
|
131
|
+
"The same prompt template was filled in with different input data. Is the "
|
|
132
|
+
"previous answer still a good answer to the new prompt, so that asking "
|
|
133
|
+
"again is not worth it?"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def classifier_cache_check(
|
|
138
|
+
classify_call: ClassifyCall,
|
|
139
|
+
threshold: float = DEFAULT_THRESHOLD,
|
|
140
|
+
question: str = CACHE_QUESTION,
|
|
141
|
+
) -> Callable:
|
|
142
|
+
"""
|
|
143
|
+
Return a ``cache_check`` that asks *classify_call* whether a model's last
|
|
144
|
+
answer still fits its changed prompt, reusing it when P(yes) reaches
|
|
145
|
+
*threshold*::
|
|
146
|
+
|
|
147
|
+
# client.py
|
|
148
|
+
classify_call = promptmap.systemone_classifier()
|
|
149
|
+
cache_check = promptmap.classifier_cache_check(classify_call, threshold=0.8)
|
|
150
|
+
|
|
151
|
+
The state judged is the previous compiled prompt, the new one and the
|
|
152
|
+
previous answer. Both prompts come from the same template, so what
|
|
153
|
+
differs is the data rendered into them.
|
|
154
|
+
"""
|
|
155
|
+
threshold = parse_threshold(str(threshold))
|
|
156
|
+
|
|
157
|
+
def cache_check(prompt: str, previous) -> bool:
|
|
158
|
+
state = (
|
|
159
|
+
f"PREVIOUS PROMPT:\n{previous.prompt}\n\n"
|
|
160
|
+
f"NEW PROMPT:\n{prompt}\n\n"
|
|
161
|
+
f"PREVIOUS ANSWER:\n{previous.output}"
|
|
162
|
+
)
|
|
163
|
+
return float(classify_call(state, question)) >= threshold
|
|
164
|
+
|
|
165
|
+
return cache_check
|