promptkeep 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.
- promptkeep/__init__.py +29 -0
- promptkeep/config.py +82 -0
- promptkeep/decorator.py +97 -0
- promptkeep/history.py +117 -0
- promptkeep/integrations/__init__.py +33 -0
- promptkeep/integrations/openai_wrapper.py +384 -0
- promptkeep/prompts.py +233 -0
- promptkeep/py.typed +0 -0
- promptkeep/rendering.py +120 -0
- promptkeep/storage.py +381 -0
- promptkeep/tracking.py +64 -0
- promptkeep-0.1.0.dist-info/METADATA +119 -0
- promptkeep-0.1.0.dist-info/RECORD +15 -0
- promptkeep-0.1.0.dist-info/WHEEL +4 -0
- promptkeep-0.1.0.dist-info/licenses/LICENSE +21 -0
promptkeep/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""promptkeep: versioned prompt templates with lineage and run tracking.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
from promptkeep import Prompt, prompt, wrap, configure, history
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from . import history
|
|
9
|
+
from .config import configure, get_settings
|
|
10
|
+
from .decorator import prompt
|
|
11
|
+
from .integrations import wrap
|
|
12
|
+
from .prompts import Prompt, RenderedText
|
|
13
|
+
from .rendering import MissingVariableError, TemplateParseError, extract_placeholders
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Prompt",
|
|
19
|
+
"RenderedText",
|
|
20
|
+
"prompt",
|
|
21
|
+
"wrap",
|
|
22
|
+
"configure",
|
|
23
|
+
"get_settings",
|
|
24
|
+
"history",
|
|
25
|
+
"extract_placeholders",
|
|
26
|
+
"MissingVariableError",
|
|
27
|
+
"TemplateParseError",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
promptkeep/config.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Global library configuration: DB location, tracking on/off, strict rendering.
|
|
2
|
+
|
|
3
|
+
Settings are resolved fresh on every access with a simple precedence:
|
|
4
|
+
explicit ``configure()`` overrides win, then environment variables
|
|
5
|
+
(``PROMPTKEEP_DB``, ``PROMPTKEEP_DISABLED``), then defaults.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional, Union
|
|
15
|
+
|
|
16
|
+
DEFAULT_DB_FILENAME = ".promptkeep.db"
|
|
17
|
+
|
|
18
|
+
# configure() overrides live here; guarded by a lock since wrapped clients
|
|
19
|
+
# may resolve settings from multiple threads.
|
|
20
|
+
_lock = threading.Lock()
|
|
21
|
+
_overrides: dict = {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Settings:
|
|
26
|
+
"""A resolved, immutable snapshot of the library's configuration."""
|
|
27
|
+
|
|
28
|
+
db_path: Path
|
|
29
|
+
enabled: bool
|
|
30
|
+
strict: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def configure(
|
|
34
|
+
db_path: Optional[Union[str, Path]] = None,
|
|
35
|
+
enabled: Optional[bool] = None,
|
|
36
|
+
strict: Optional[bool] = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Override library settings. Only the arguments you pass are changed.
|
|
39
|
+
|
|
40
|
+
- db_path: where the SQLite database lives (default: ./.promptkeep.db,
|
|
41
|
+
or the PROMPTKEEP_DB env var).
|
|
42
|
+
- enabled: turn persistence on/off entirely (default: on, unless
|
|
43
|
+
PROMPTKEEP_DISABLED is set). Rendering works either way.
|
|
44
|
+
- strict: raise on missing variables instead of leaving `{name}` literal
|
|
45
|
+
(default: False).
|
|
46
|
+
"""
|
|
47
|
+
with _lock:
|
|
48
|
+
if db_path is not None:
|
|
49
|
+
_overrides["db_path"] = Path(db_path)
|
|
50
|
+
if enabled is not None:
|
|
51
|
+
_overrides["enabled"] = bool(enabled)
|
|
52
|
+
if strict is not None:
|
|
53
|
+
_overrides["strict"] = bool(strict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_settings() -> Settings:
|
|
57
|
+
"""Resolve the current settings: configure() overrides > env vars > defaults."""
|
|
58
|
+
with _lock:
|
|
59
|
+
# DB path: explicit override, then $PROMPTKEEP_DB, then ./.promptkeep.db.
|
|
60
|
+
db_path = _overrides.get("db_path")
|
|
61
|
+
if db_path is None:
|
|
62
|
+
env_path = os.environ.get("PROMPTKEEP_DB")
|
|
63
|
+
db_path = Path(env_path) if env_path else Path.cwd() / DEFAULT_DB_FILENAME
|
|
64
|
+
|
|
65
|
+
# Tracking: on by default; $PROMPTKEEP_DISABLED=1/true/yes/on kills it.
|
|
66
|
+
enabled = _overrides.get("enabled")
|
|
67
|
+
if enabled is None:
|
|
68
|
+
disabled = os.environ.get("PROMPTKEEP_DISABLED", "").strip().lower()
|
|
69
|
+
enabled = disabled not in ("1", "true", "yes", "on")
|
|
70
|
+
|
|
71
|
+
# Rendering strictness: lenient unless explicitly opted in.
|
|
72
|
+
strict = _overrides.get("strict", False)
|
|
73
|
+
return Settings(db_path=db_path, enabled=enabled, strict=strict)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def reset() -> None:
|
|
77
|
+
"""Clear all configure() overrides and drop cached DB state. Mainly for tests."""
|
|
78
|
+
with _lock:
|
|
79
|
+
_overrides.clear()
|
|
80
|
+
from . import storage
|
|
81
|
+
|
|
82
|
+
storage.reset_caches()
|
promptkeep/decorator.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""@prompt decorator for computed prompts.
|
|
2
|
+
|
|
3
|
+
The decorated function returns the *raw template* (placeholders intact); the
|
|
4
|
+
decorator turns each call into a Prompt object, using the call's arguments as
|
|
5
|
+
the variables dict. Both raw and rendered live on the returned Prompt, so
|
|
6
|
+
lineage and run tracking work exactly like literal prompts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
import hashlib
|
|
13
|
+
import inspect
|
|
14
|
+
import textwrap
|
|
15
|
+
from typing import Any, Callable, Optional
|
|
16
|
+
|
|
17
|
+
from .prompts import Prompt
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _function_source_hash(fn: Callable) -> Optional[str]:
|
|
21
|
+
"""Hash the function's source so history can tell code changes apart from
|
|
22
|
+
same-code output changes. None when source isn't available (e.g. REPL)."""
|
|
23
|
+
try:
|
|
24
|
+
source = textwrap.dedent(inspect.getsource(fn))
|
|
25
|
+
except (OSError, TypeError):
|
|
26
|
+
return None
|
|
27
|
+
return hashlib.sha256(source.encode("utf-8")).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def prompt(name: str, strict: Optional[bool] = None):
|
|
31
|
+
"""Turn a template-building function into a Prompt factory.
|
|
32
|
+
|
|
33
|
+
@prompt(name="REVIEW_SYSTEM")
|
|
34
|
+
def review_sys_prompt(var1="some value"):
|
|
35
|
+
return "You are a reviewer. Focus on {var1}."
|
|
36
|
+
|
|
37
|
+
p = review_sys_prompt(var1="security") # -> Prompt
|
|
38
|
+
p.raw # the template the function returned
|
|
39
|
+
p.text # rendered with the call's arguments
|
|
40
|
+
|
|
41
|
+
The version identity is the returned template text (content-hash dedup);
|
|
42
|
+
a hash of the function's source is stored alongside each version so
|
|
43
|
+
history can tell "code changed" apart from "same code, different output".
|
|
44
|
+
"""
|
|
45
|
+
# Catch the bare-decorator mistake (@prompt without parentheses) early.
|
|
46
|
+
if callable(name):
|
|
47
|
+
raise TypeError(
|
|
48
|
+
'@prompt requires a name: use @prompt(name="MY_PROMPT") — '
|
|
49
|
+
"the name is the prompt's stable identity"
|
|
50
|
+
)
|
|
51
|
+
if not isinstance(name, str) or not name.strip():
|
|
52
|
+
raise ValueError("@prompt requires a non-empty name string")
|
|
53
|
+
|
|
54
|
+
def decorate(fn: Callable[..., str]):
|
|
55
|
+
"""Wrap fn so each call yields a Prompt built from its return value."""
|
|
56
|
+
# Computed once at decoration time; identical for every call.
|
|
57
|
+
fn_hash = _function_source_hash(fn)
|
|
58
|
+
signature = inspect.signature(fn)
|
|
59
|
+
|
|
60
|
+
@functools.wraps(fn)
|
|
61
|
+
def wrapper(*args: Any, **kwargs: Any) -> Prompt:
|
|
62
|
+
"""Call fn, then package its returned template into a Prompt."""
|
|
63
|
+
# Capture the full call (including defaults) as the variables dict.
|
|
64
|
+
bound = signature.bind(*args, **kwargs)
|
|
65
|
+
bound.apply_defaults()
|
|
66
|
+
variables: dict = {}
|
|
67
|
+
for param_name, value in bound.arguments.items():
|
|
68
|
+
kind = signature.parameters[param_name].kind
|
|
69
|
+
if kind is inspect.Parameter.VAR_KEYWORD:
|
|
70
|
+
# **kwargs entries become top-level variables.
|
|
71
|
+
variables.update(value)
|
|
72
|
+
elif kind is inspect.Parameter.VAR_POSITIONAL:
|
|
73
|
+
# *args recorded as a list under the parameter's name.
|
|
74
|
+
variables[param_name] = list(value)
|
|
75
|
+
else:
|
|
76
|
+
variables[param_name] = value
|
|
77
|
+
|
|
78
|
+
# The function's return value is the raw template.
|
|
79
|
+
template = fn(*args, **kwargs)
|
|
80
|
+
if not isinstance(template, str):
|
|
81
|
+
raise TypeError(
|
|
82
|
+
f"@prompt function {fn.__name__!r} must return a template string,"
|
|
83
|
+
f" got {type(template).__name__}"
|
|
84
|
+
)
|
|
85
|
+
return Prompt(
|
|
86
|
+
template,
|
|
87
|
+
variables,
|
|
88
|
+
name=name,
|
|
89
|
+
strict=strict,
|
|
90
|
+
source="decorator",
|
|
91
|
+
fn_source_hash=fn_hash,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
wrapper.prompt_name = name # type: ignore[attr-defined]
|
|
95
|
+
return wrapper
|
|
96
|
+
|
|
97
|
+
return decorate
|
promptkeep/history.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Query the lineage and run history of a prompt by name.
|
|
2
|
+
|
|
3
|
+
The read-side API: storage returns raw dict rows; this module shapes them
|
|
4
|
+
into typed, immutable dataclasses that are pleasant to work with.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import difflib
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from . import storage
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class VersionInfo:
|
|
19
|
+
"""One version of a prompt's template, as recorded in the lineage."""
|
|
20
|
+
|
|
21
|
+
version: int
|
|
22
|
+
template: str
|
|
23
|
+
template_hash: str
|
|
24
|
+
source: str
|
|
25
|
+
fn_source_hash: Optional[str]
|
|
26
|
+
created_at: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class RunInfo:
|
|
31
|
+
"""One recorded execution: which version ran, with what, and what came back."""
|
|
32
|
+
|
|
33
|
+
id: int
|
|
34
|
+
prompt_name: str
|
|
35
|
+
version: int
|
|
36
|
+
variables: Optional[Dict[str, Any]]
|
|
37
|
+
rendered_text: str
|
|
38
|
+
provider: str
|
|
39
|
+
model: Optional[str]
|
|
40
|
+
request_params: Optional[Dict[str, Any]]
|
|
41
|
+
response_id: Optional[str]
|
|
42
|
+
output_text: Optional[str]
|
|
43
|
+
prompt_tokens: Optional[int]
|
|
44
|
+
completion_tokens: Optional[int]
|
|
45
|
+
total_tokens: Optional[int]
|
|
46
|
+
latency_ms: Optional[int]
|
|
47
|
+
status: str
|
|
48
|
+
error: Optional[str]
|
|
49
|
+
created_at: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_json(value: Optional[str]):
|
|
53
|
+
"""Decode a stored JSON column; malformed/missing data becomes None."""
|
|
54
|
+
if value is None:
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
return json.loads(value)
|
|
58
|
+
except (ValueError, TypeError):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def versions(name: str) -> List[VersionInfo]:
|
|
63
|
+
"""All versions of a prompt, oldest first."""
|
|
64
|
+
return [
|
|
65
|
+
VersionInfo(
|
|
66
|
+
version=row["version"],
|
|
67
|
+
template=row["template"],
|
|
68
|
+
template_hash=row["template_hash"],
|
|
69
|
+
source=row["source"],
|
|
70
|
+
fn_source_hash=row["fn_source_hash"],
|
|
71
|
+
created_at=row["created_at"],
|
|
72
|
+
)
|
|
73
|
+
for row in storage.fetch_versions(name)
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def diff(name: str, old: int, new: int) -> str:
|
|
78
|
+
"""Unified diff between two versions of a prompt's template."""
|
|
79
|
+
# Load the lineage once and validate both requested versions exist.
|
|
80
|
+
by_number = {v.version: v for v in versions(name)}
|
|
81
|
+
for wanted in (old, new):
|
|
82
|
+
if wanted not in by_number:
|
|
83
|
+
raise ValueError(f"prompt {name!r} has no version {wanted}")
|
|
84
|
+
lines = difflib.unified_diff(
|
|
85
|
+
by_number[old].template.splitlines(),
|
|
86
|
+
by_number[new].template.splitlines(),
|
|
87
|
+
fromfile=f"{name} v{old}",
|
|
88
|
+
tofile=f"{name} v{new}",
|
|
89
|
+
lineterm="",
|
|
90
|
+
)
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def runs(name: str, version: Optional[int] = None, limit: int = 50) -> List[RunInfo]:
|
|
95
|
+
"""Recorded runs for a prompt (optionally one version), newest first."""
|
|
96
|
+
return [
|
|
97
|
+
RunInfo(
|
|
98
|
+
id=row["id"],
|
|
99
|
+
prompt_name=row["prompt_name"],
|
|
100
|
+
version=row["version"],
|
|
101
|
+
variables=_load_json(row["variables"]),
|
|
102
|
+
rendered_text=row["rendered_text"],
|
|
103
|
+
provider=row["provider"],
|
|
104
|
+
model=row["model"],
|
|
105
|
+
request_params=_load_json(row["request_params"]),
|
|
106
|
+
response_id=row["response_id"],
|
|
107
|
+
output_text=row["output_text"],
|
|
108
|
+
prompt_tokens=row["prompt_tokens"],
|
|
109
|
+
completion_tokens=row["completion_tokens"],
|
|
110
|
+
total_tokens=row["total_tokens"],
|
|
111
|
+
latency_ms=row["latency_ms"],
|
|
112
|
+
status=row["status"],
|
|
113
|
+
error=row["error"],
|
|
114
|
+
created_at=row["created_at"],
|
|
115
|
+
)
|
|
116
|
+
for row in storage.fetch_runs(name, version=version, limit=limit)
|
|
117
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Provider integrations. `wrap()` is the public entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def wrap(target):
|
|
7
|
+
"""Wrap an OpenAI client class or instance so Prompt objects work as
|
|
8
|
+
message content and every call is recorded as a run.
|
|
9
|
+
|
|
10
|
+
from openai import OpenAI
|
|
11
|
+
from promptkeep import wrap
|
|
12
|
+
|
|
13
|
+
OpenAI = wrap(OpenAI) # wrap the class...
|
|
14
|
+
client = OpenAI(api_key=...) # ...then use it exactly as before
|
|
15
|
+
|
|
16
|
+
# or wrap a live client:
|
|
17
|
+
client = wrap(OpenAI(api_key=...))
|
|
18
|
+
"""
|
|
19
|
+
from .openai_wrapper import wrap_openai_class, wrap_openai_instance
|
|
20
|
+
|
|
21
|
+
if isinstance(target, type):
|
|
22
|
+
return wrap_openai_class(target)
|
|
23
|
+
chat = getattr(target, "chat", None)
|
|
24
|
+
completions = getattr(chat, "completions", None)
|
|
25
|
+
if completions is not None and hasattr(completions, "create"):
|
|
26
|
+
return wrap_openai_instance(target)
|
|
27
|
+
raise TypeError(
|
|
28
|
+
"wrap() expects an OpenAI client class or instance"
|
|
29
|
+
f" (an object with .chat.completions.create); got {target!r}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = ["wrap"]
|