Skip to main content

Composable prompt-construction library with named routes, context overlays, injections, streaming, middleware, and output validation.

Project description

promptlibretto

A prompt-engineering library — plus a browser studio to design, tune, and export that setup as runnable Python.

Define named routes that each compose their own system + user prompt, sampling params, and output policy. Layer transient context overlays on a long-lived base. Attach stackable injections for cross-cutting style/format tweaks. Swap providers without touching the rest.

Good fit for: multi-mode assistants, agents that switch strategies per task, prompt A/B testing, iterative refinement loops where each user follow-up becomes a reusable overlay, and any app where prompt-construction logic has outgrown f-strings.

Design rationale: DESIGN.md. Studio (browser designer): studio/.

Analyst route generating

Skip the prompt-chain code — use the studio

If you don't want to wire CompositeBuilder / PromptRoute / overlays by hand, design your setup in the browser, export it as a .py, and import it.

Studio overview

End-to-end in five steps

1. Install the studio and a model provider (Ollama shown — any provider works):

pip install "promptlibretto[studio,ollama]"

2. From your project root, launch the studio. Saves land in ./promptlibretto_exports/ by default — PROMPTLIBRETTO_EXPORT_DIR=. drops them in CWD instead:

PROMPTLIBRETTO_EXPORT_DIR=. promptlibretto-studio

3. In the browser: pick a route, edit the base context, add overlays. For each overlay you want filled at call time, set its runtime dropdown to optional or required. Generate, iterate, inspect the debug trace until you're happy.

4. Click Export as Python → type a name (e.g. my_assistant) → Save to disk. You now have ./my_assistant.py next to your app code.

5. In your app, depend on the library at runtime (no [studio] extra needed) and import the export:

pip install "promptlibretto[ollama]"
# your_app.py
import asyncio
from my_assistant import run

async def main():
    # Required + optional runtime slots are keyword args on run().
    # Anything else (**extra) becomes a priority-10 overlay for that call.
    result = await run(
        "what should I cook tonight?",
        location="kitchen",            # required slot you marked in the studio
        focus="quick weeknight meal",  # optional slot
        dietary="vegetarian",          # ad-hoc context via **extra
    )
    print(result.text)

asyncio.run(main())

That's the whole integration. The exported file reconstructs a PromptEngine with the exact config, base context, fixed overlays, and resolved route sections you tuned in the studio — your app depends on the promptlibretto library at runtime, not on the studio.

Want to tweak it later? Click Load scenario on the saved-export entry to restore the exact studio state that produced it, edit, and re-export over the same file.

The walkthrough below covers each studio panel in detail. If you'd rather build the engine in code, jump to Minimal example.

Recommended workflow

  1. pip install "promptlibretto[studio]" and launch promptlibretto-studio.
  2. In the browser: pick a route, edit the base context, add overlays, toggle injections, tweak overrides. Generate, iterate, inspect the debug trace.
  3. Need to try a one-off tweak without touching the route? Click Edit prompt next to the route selector to override the resolved system / user text for the next run(s). The override sticks until you clear it; it's never written back to the route.
  4. Click Export as PythonCopy the snippet, Download it as a .py file, or Save to disk under a name. Saves land in ./promptlibretto_exports/ by default — run promptlibretto-studio from your project root and the file appears right in the project. Override the target with PROMPTLIBRETTO_EXPORT_DIR=./src/prompts.

The exported snippet reconstructs a PromptEngine with the exact config, base context, overlays, and resolved route sections you tuned. Dynamic user-input slots survive as lambdas. Your app imports the library and runs it — no studio dependency at runtime.

promptlibretto.export_python(engine, route="...") is also a public API — generate the same snippet from any PromptEngine you've wired up in code, not just one built interactively. GenerationRequest.section_overrides is the library-level equivalent of the Edit prompt button: pass {"system": "...", "user": "..."} to bypass the builder for one call.

What the exported file looks like

Every export ships with an async def run(user_input="", *, <slots>, **extra) wrapper, so calling code doesn't need to know about overlays:

# my_assistant.py — generated by promptlibretto.export_python
from promptlibretto import PromptEngine, ContextOverlay
engine = PromptEngine(...)            # config, base, fixed overlays, route

async def run(user_input="", *, location, focus="", **extra):
    """Required runtime slots: location. Optional: focus.
    Extra kwargs become priority-10 overlays."""
    if not location:
        raise ValueError("runtime slot 'location' is required")
    engine.context_store.set_overlay("location", ContextOverlay(text=location, priority=20))
    if focus:
        engine.context_store.set_overlay("focus", ContextOverlay(text=focus, priority=15))
    for _name, _value in extra.items():
        if _value:
            engine.context_store.set_overlay(_name, ContextOverlay(text=str(_value), priority=10))
    return await engine.generate_once(user_input)
from my_assistant import run
result = await run("what should I do?", location="kitchen", focus="cleanup")

In the studio, each overlay card has a runtime dropdown:

  • fixed — text is baked into the export.
  • runtime — optional — becomes a keyword arg with a "" default; only applied if non-empty.
  • runtime — required — becomes a required keyword arg; raises ValueError if empty.

Runtime dropdown on an overlay card

Runtime-tagged overlays are skipped during studio generation (their text is a placeholder, not a value), and the exported run() clears any prior runtime/**extra overlays at entry so calls don't leak state into one another.

Export modal

Editing an export later

When you Save to disk, the studio also snapshots the current state as a scenario under the same name. The saved-exports list shows a Load scenario button — click it to restore the exact studio setup that produced the export, edit, and re-export. No round-trip parser; the scenario is the editable form.

Install

pip install promptlibretto                # library only, no runtime deps
pip install "promptlibretto[ollama]"      # adds httpx for OllamaProvider
pip install "promptlibretto[studio]"      # adds FastAPI stack for the browser studio
pip install "promptlibretto[dev]"         # adds pytest + pytest-asyncio

Hello world

The smallest useful engine — one route, mock provider, one line of output:

import asyncio
from promptlibretto import PromptEngine

engine = PromptEngine(routes={"default": "Say hi."})
print(asyncio.run(engine.generate_once()).text)

Everything after this adds features on top of the same shape. The constructor accepts looser types than the named classes suggest:

  • configGenerationConfig, dict, or omitted (defaults to mock).
  • context_storeContextStore, str (base text), dict, or omitted.
  • providerProviderAdapter, "mock", "ollama", or omitted.
  • routes{name: str | list | dict | CompositeBuilder | PromptRoute} — strings and lists become user sections automatically.
  • generate_once takes a GenerationRequest, a dict, a bare string (wrapped into inputs={"input": ...}), or nothing.

Drop the full classes in when you want the full surface — no special paths.

Why not just f-strings?

Use this when:

  • You have more than one kind of prompt and they share structure — frame, rules, persona, output format. Routes let you name and swap strategies without duplicating boilerplate.
  • Follow-ups should affect future runs, not just the current one. Overlays let a user's "make it shorter" stick around as a reusable piece of context.
  • Output needs validation or retry — required regex, stripped code fences, banned phrases — handled once by the output processor instead of copy-pasted around call sites.

Don't use it when you send exactly one prompt shape and don't need any of the above. An f-string and a direct provider call are fine.

Core concepts

Piece What it does
GenerationConfig Sampling params + provider/model selection. Immutable; merged_with().
ContextStore Long-lived base + named overlays with priority and optional expiry.
PromptAssetRegistry Named snippets: frames, rules, personas, endings, example/nudge pools, injectors.
PromptRoute / Router Named composition strategies. Router picks one per request.
CompositeBuilder Assembles system + user prompts from ordered section callables.
ProviderAdapter Runs the model. Ships with OllamaProvider, MockProvider.
OutputProcessor Cleans and validates model output against a policy.
RecentOutputMemory Bounded log for near-duplicate detection (Jaccard).
RunHistory Bounded log of full runs for replay.
TemplateRenderer {slot} substitution for parameterised base / overlays.
RandomSource Injectable RNG used by example/nudge pools.
PromptEngine Glues it together. generate_once(request) is the entry point.

Flow

GenerationRequest
      │
      ▼
PromptRouter ──► PromptRoute.builder ──► PromptPackage ──► ProviderAdapter
      ▲                ▲                                         │
      │                │                                         ▼
ContextStore     PromptAssetRegistry                     OutputProcessor
                                                                 │
                                                                 ▼
                                                         GenerationResult

Minimal example

import asyncio
from promptlibretto import (
    CompositeBuilder, ContextStore, GenerationConfig, GenerationRequest,
    MockProvider, OutputProcessor, PromptAssetRegistry, PromptEngine,
    PromptRoute, PromptRouter, section,
)
from promptlibretto.builders.builder import BuildContext


def frame(ctx: BuildContext) -> str:
    return ctx.assets.frame("core")


def user_input(ctx: BuildContext) -> str:
    return f"Question:\n{ctx.request.inputs.get('input', '')}"


assets = PromptAssetRegistry()
assets.add_frame("core", "You are a careful, helpful assistant. Be concise.")

router = PromptRouter(default_route="default")
router.register(PromptRoute(
    name="default",
    builder=CompositeBuilder(
        name="default",
        system_sections=(frame,),
        user_sections=(user_input, section("Respond now.")),
    ),
))

engine = PromptEngine(
    config=GenerationConfig(provider="mock", model="demo"),
    context_store=ContextStore(base="The assistant operates in demo mode."),
    asset_registry=assets,
    router=router,
    provider=MockProvider(),
    output_processor=OutputProcessor(),
)

async def main():
    result = await engine.generate_once(GenerationRequest(
        mode="default",
        inputs={"input": "What is entropy?"},
    ))
    print(result.text)

asyncio.run(main())

To run against a real model, swap the provider and config:

from promptlibretto import OllamaProvider

provider = OllamaProvider(base_url="http://localhost:11434")
config = GenerationConfig(provider="ollama", model="llama3")

Everything else stays the same.


Prompt engineering & routing

Context overlays

ContextStore holds one base string plus named overlays. Higher priority applies first; overlays can expire. Use them for transient facts, user preferences, or iteration follow-ups.

from promptlibretto import ContextOverlay, make_turn_overlay

store.set_overlay("budget", ContextOverlay(text="Keep total under $800.", priority=20))
store.set_overlay("iter_1", make_turn_overlay(
    verbatim="actually please make this shorter",
    compacted="Prefer shorter responses.",
    priority=25,
))

Routes and builders

CompositeBuilder takes ordered section callables. Each receives a BuildContext and returns a string — return "" to omit.

PromptRoute(
    name="analyst",
    builder=CompositeBuilder(
        name="analyst",
        system_sections=(frame_fn, persona_fn),
        user_sections=(user_input_fn, section("Summary / Tradeoffs / Open questions.")),
        generation_overrides={"temperature": 0.6, "max_tokens": 700},
        output_policy={"strip_prefixes": ["```"]},
    ),
)

Injections

Named InjectionTemplates registered on the asset registry. Callers pass their names in GenerationRequest.injections to layer instructions, generation overrides, or output policy on top of a route.

assets.add_injector("json_only", InjectionTemplate(
    instructions="Return ONLY minified JSON.",
    generation_overrides={"temperature": 0.2},
    output_policy={"strip_prefixes": ["```json", "```"]},
))

Templating

TemplateRenderer does {slot} substitution with aliases and whitespace normalisation — useful for parameterised base contexts or overlays.


Execution & integration

Providers

  • OllamaProvider(base_url=...) — local Ollama / OpenAI-compatible server.
  • MockProvider() — echoes the prompt; for tests.

Implement async def generate(request) -> ProviderResponse for your own.

Streaming

Providers may implement stream(request). The engine exposes generate_stream(request) yielding GenerationChunk(delta=...) per chunk and a terminal GenerationChunk(done=True, result=...).

async for chunk in engine.generate_stream(request):
    if chunk.done:
        final = chunk.result
    elif chunk.delta:
        print(chunk.delta, end="", flush=True)

⚠ Warning — Streaming makes exactly one provider call; output-policy retries are skipped. If result.accepted is False, fall back to generate_once.

Output processor

Applies a policy derived from the route + injection overrides: strip code fences, enforce required regex, reject forbidden substrings. Rejected attempts retry up to GenerationConfig.retries times.

Prompt-size budget

Set max_prompt_chars on GenerationConfig to cap the outgoing prompt. When over budget, the engine drops the lowest-priority overlay and rebuilds until it fits. The debug trace reports which overlays were dropped under metadata.budget.


Observability & production

Reproducibility

Pass SeededRandom(n) for deterministic example/nudge picks:

engine = PromptEngine(..., random=SeededRandom(42))

Middleware

Cross-cutting concerns (logging, metrics, caching, redaction) without touching prompt construction. Any object with before(request) and/or after(request, result) — sync or async. Return None to pass through, a new value to replace.

class LatencyLogger:
    async def before(self, request):
        self.started = time.perf_counter()
    async def after(self, request, result):
        print(f"route={result.route} ms={(time.perf_counter() - self.started)*1000:.1f}")

engine = PromptEngine(..., middlewares=[LatencyLogger()])

before runs in registration order, after in reverse. Wraps both generate_once and generate_stream.

Debug trace

GenerationRequest(debug=True) attaches a GenerationTrace with system/user prompts, every attempt, resolved config, and context snapshot.

Note — Config merge order is base → route → request. Values on GenerationRequest.config_overrides win over a route's generation_overrides, which win over the engine's base GenerationConfig. The trace exposes all four layers under metadata.config_layers so you can see exactly who contributed what.

Run history

Plug a RunHistory in and every generate_once is recorded with its request shape for replay. Stores only the caller's explicit config_overrides; resolved config lives in record metadata.


Development

pip install "promptlibretto[dev]"
pytest

License

MIT (see LICENSE when added).

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

promptlibretto-0.3.2.tar.gz (76.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

promptlibretto-0.3.2-py3-none-any.whl (85.0 kB view details)

Uploaded Python 3

File details

Details for the file promptlibretto-0.3.2.tar.gz.

File metadata

  • Download URL: promptlibretto-0.3.2.tar.gz
  • Upload date:
  • Size: 76.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for promptlibretto-0.3.2.tar.gz
Algorithm Hash digest
SHA256 44f8b9304e15b911038255c1cfeffaf365dbe28a89df8509a786ba0587ab1459
MD5 740cfaabe43cf32783b86640a9fa43a7
BLAKE2b-256 061b7aeeddac8a65acc7c688d2c206d3f8e9368fcc7f333a783ba7323296402d

See more details on using hashes here.

Provenance

The following attestation bundles were made for promptlibretto-0.3.2.tar.gz:

Publisher: workflow.yml on sockheadrps/promptlibretto

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file promptlibretto-0.3.2-py3-none-any.whl.

File metadata

  • Download URL: promptlibretto-0.3.2-py3-none-any.whl
  • Upload date:
  • Size: 85.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for promptlibretto-0.3.2-py3-none-any.whl
Algorithm Hash digest
SHA256 58f4afb78516442d21b032973228032c789acb8606497a88e6f7436fdeda7ea7
MD5 f88b56c9e97f869d0ddbc0b15c6448b4
BLAKE2b-256 01a2feb50d596468c96a16159e66803e3cedd7af99032ee5b98df0af34cf40ff

See more details on using hashes here.

Provenance

The following attestation bundles were made for promptlibretto-0.3.2-py3-none-any.whl:

Publisher: workflow.yml on sockheadrps/promptlibretto

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page