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.
Files changed (55) hide show
  1. promptmap/__init__.py +479 -0
  2. promptmap/classifier.py +165 -0
  3. promptmap/cli/__init__.py +828 -0
  4. promptmap/cli/init_files.py +694 -0
  5. promptmap/cli/pretty_print.py +273 -0
  6. promptmap/cli/test.py +560 -0
  7. promptmap/cli/type_hints.py +249 -0
  8. promptmap/cli/vscode.py +33 -0
  9. promptmap/db.py +124 -0
  10. promptmap/docs.py +282 -0
  11. promptmap/executor/__init__.py +48 -0
  12. promptmap/executor/builtin_kinds.py +467 -0
  13. promptmap/executor/executor.py +524 -0
  14. promptmap/executor/graph.py +251 -0
  15. promptmap/executor/parser_initial.py +191 -0
  16. promptmap/executor/parser_model.py +223 -0
  17. promptmap/executor/run_context.py +580 -0
  18. promptmap/expectations.py +42 -0
  19. promptmap/files.py +910 -0
  20. promptmap/gates.py +158 -0
  21. promptmap/global_instruction.py +59 -0
  22. promptmap/html_templates/_nav.html +8 -0
  23. promptmap/html_templates/docs.html +324 -0
  24. promptmap/html_templates/run.html +96 -0
  25. promptmap/jsonpath.py +121 -0
  26. promptmap/llm.py +161 -0
  27. promptmap/model_spec.py +64 -0
  28. promptmap/model_types.py +238 -0
  29. promptmap/project_values.py +70 -0
  30. promptmap/promptparams.py +444 -0
  31. promptmap/rag.py +52 -0
  32. promptmap/run_ids.py +120 -0
  33. promptmap/secrets.py +80 -0
  34. promptmap/server/__init__.py +28 -0
  35. promptmap/server/__main__.py +36 -0
  36. promptmap/server/app.py +259 -0
  37. promptmap/storage/__init__.py +15 -0
  38. promptmap/storage/base.py +69 -0
  39. promptmap/storage/memory.py +255 -0
  40. promptmap/storage/sqlite.py +457 -0
  41. promptmap/tester.py +475 -0
  42. promptmap/tokens.py +64 -0
  43. promptmap/types.py +25 -0
  44. promptmap/validator.py +113 -0
  45. promptmap-0.5.12.dist-info/METADATA +101 -0
  46. promptmap-0.5.12.dist-info/RECORD +55 -0
  47. promptmap-0.5.12.dist-info/WHEEL +5 -0
  48. promptmap-0.5.12.dist-info/entry_points.txt +2 -0
  49. promptmap-0.5.12.dist-info/top_level.txt +2 -0
  50. promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/__init__.py +9 -0
  51. promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/agent/run_agent.py +517 -0
  52. promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/agent37_exec.py +881 -0
  53. promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/agent_exec.py +659 -0
  54. promptmap-extra-model-kinds/src/promptmap_extra_model_kinds/modal_exec.py +422 -0
  55. 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
+ ))
@@ -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