AI contextual assistant with few-shot learning and context injection. Provider-agnostic, framework-agnostic.
Project description
melissa-sage 🌿
AI contextual assistant with few-shot learning and context injection. Provider-agnostic. Framework-agnostic. Pure Python.
What is it
melissa-sage helps you explain on-screen data to users using real values, not generic explanations. It combines two techniques:
- Few-shot learning — YAML example pairs that teach the model HOW to respond (tone, formatting, structure)
- Context injection — Jinja2 templates that take cached data and render it as WHAT the model should explain, including business rules and formulas
It is provider-agnostic (Anthropic, OpenAI, OpenRouter, self-hosted), framework-agnostic (Django, FastAPI, Celery, scripts), and has zero framework dependencies.
Quickstart
pip install melissa-sage[anthropic]
Pick the install extra that matches your provider:
| Provider | Install command | Env var |
|---|---|---|
| Anthropic | pip install melissa-sage[anthropic] |
ANTHROPIC_API_KEY |
| OpenAI | pip install melissa-sage[openai] |
OPENAI_API_KEY |
| OpenRouter | pip install melissa-sage[openai] |
OPENROUTER_API_KEY |
| Custom (Ollama/vLLM/LM Studio) | pip install melissa-sage[openai] |
(optional) |
| All providers | pip install melissa-sage[all] |
— |
Note: OpenRouter and Custom both use the [openai] extra because they piggyback on the openai SDK underneath. See the Provider cookbook below for full YAML examples per provider.
from melissa_sage import Melissa
melissa = Melissa.from_config("melissa_sage.yaml")
melissa.cache_data("salarios", {"employees": [...]}, context_key="tenant_1")
result = melissa.explain("salarios", context_key="tenant_1")
print(result["reply"])
Configuration (melissa_sage.yaml)
assistant:
provider: anthropic
model: claude-sonnet-4-20250514
api_key: ${ANTHROPIC_API_KEY}
max_tokens: 1024
cache:
backend: redis # or "memory" for dev
url: ${REDIS_URL}
prefix: msa
ttl: 3600
prompt:
system: |
You are an assistant that explains data the user is viewing on screen.
Always use the exact values from the injected context.
sections:
salarios:
label: Salarios Base
examples: prompts/salarios_examples.yaml
template: prompts/salarios_template.yaml
required_keys:
- employees
⚠️ Config gotchas
Three things you'll probably hit when writing your first melissa_sage.yaml. Reading this section will save you a debugging round.
1. The top-level LLM block MUST be named exactly assistant:
Melissa.from_config() looks for the literal key assistant:. Any other name (provider:, llm:, config:, etc.) is silently ignored, and melissa-sage falls back to an Anthropic default with no API key — producing a confusing authentication error at the first LLM call.
Since 0.1.1, from_config() raises ConfigError immediately if assistant: is missing, so this is now a loud failure instead of a silent one:
from melissa_sage import Melissa, ConfigError
try:
melissa = Melissa.from_config("melissa_sage.yaml")
except ConfigError as e:
print(e) # names the missing key and shows an example
2. ${VAR} env-var interpolation runs on the ENTIRE config tree
melissa-sage walks every string value in your loaded config and substitutes ${VAR_NAME} with os.environ["VAR_NAME"]. This is how api_key: ${ANTHROPIC_API_KEY} works — but it also means the substring ${{ inside a Jinja template (e.g. ${{ price }}, intended as a literal $ followed by a Jinja expression) collides with the env-var regex: the regex greedily matches ${...} up to the first }, tries to resolve { price as a variable name, and crashes with EnvironmentError: Environment variable '{ price ' not defined.
You have three clean ways to write a literal ${ in a template:
$${{ price }}— since 0.1.1, a double dollar escapes to a literal${, and the rest is Jinja. Cleanest.{{ "$" }}{{ price }}— use a Jinja literal for the dollar sign, then the variable in a separate Jinja block. Works on any version.{{ price | money }}— if you just want currency formatting, use the built-inmoneyfilter, which emits$1,234. No escape needed.
3. template: and examples: accept several forms, not just file paths
Both fields auto-detect their source type. The next section enumerates every working form.
Section definition forms
Every section in the sections: block can define its template: and examples: using any of these forms. melissa-sage auto-detects the type at load time.
template:
| Value | Behavior |
|---|---|
"prompts/foo_template.yaml" |
Load .yaml/.yml file (reads content: key) |
"prompts/foo_template.json" |
Load .json file (reads content: key) |
"Hello {{ name }}" |
Inline Jinja string (detected by {{) |
{content: "Hello {{ name }}"} |
Inline dict (since 0.1.1) |
None (or unset) |
Fallback: render input dict as indented JSON |
"https://example.com/tpl.yaml" |
Fetch URL as YAML |
examples:
| Value | Behavior |
|---|---|
"prompts/foo_examples.yaml" |
Load file (reads examples: key) |
[{user: "hi", assistant: "hello"}] |
Inline list |
None (or unset) |
No few-shot examples |
"https://example.com/examples.json" |
Fetch URL as JSON |
The file-based forms are recommended for more than ~2 examples or templates longer than ~5 lines. Inline forms are fine for tiny configs where keeping everything in one file is nicer than managing a sidecar directory.
Few-shot examples
Few-shot examples teach the model the exact format, tone, and structure you want. They are injected as alternating user/assistant conversation turns before the real user question, so the model has seen the format once before it has to produce one.
External file form
Referenced from your main config as examples: prompts/salarios_examples.yaml. The file's top-level key must be examples::
# prompts/salarios_examples.yaml
examples:
- user: |
[Context: Sofía Mendoza | Sr. Executive | $15,800]
Explain this
assistant: |
💰 **Salary for Sofía Mendoza**
Sofía earns a base monthly salary of **$15,800 MXN**.
Inline form
Good for 1-2 examples you don't want to split into a sidecar file:
sections:
salarios:
label: Salarios
template: prompts/salarios_template.yaml
examples:
- user: "Explain this"
assistant: "A short explanation here."
Jinja2 templates
Templates take cached data and render it as the context message sent to the model. melissa-sage compiles them with Jinja2 and makes four custom filters available:
| Filter | Input | Output |
|---|---|---|
money |
145000 |
$145,000 |
money2 |
145000 |
$145,000.00 |
pct |
5 |
5% |
num |
145000 |
145,000 |
External file form
Referenced from your main config as template: prompts/salarios_template.yaml. The file's top-level key must be content::
# prompts/salarios_template.yaml
content: |
[Context — Salaries — Period: {{ periodo }}]
{% for e in employees %}
• {{ e.nombre }}: {{ e.salario | money }} MXN
{% endfor %}
Inline forms
Good for short templates. Both of these are equivalent:
sections:
salarios:
label: Salarios
# (a) inline Jinja string — auto-detected by the presence of {{
template: "Total: {{ employees | length }} empleados"
# (b) inline dict with a `content` key (since 0.1.1) — same behavior
# template: {content: "Total: {{ employees | length }} empleados"}
Multi-line inline templates work too, thanks to YAML's | block scalar:
sections:
salarios:
template: |
[Context — Salaries]
{% for e in employees %}
• {{ e.nombre }}: {{ e.salario | money }}
{% endfor %}
Providers
| Provider | YAML key | Default model | Extra |
|---|---|---|---|
| Anthropic | anthropic |
claude-sonnet-4-20250514 |
[anthropic] |
| OpenAI | openai |
gpt-4o |
[openai] |
| OpenRouter | openrouter |
anthropic/claude-sonnet-4 |
[openai] |
| Custom | custom |
llama3.2 (Ollama by default) |
[openai] |
Switching providers is a single line in the YAML. See the Provider cookbook below for a complete example per provider.
Provider cookbook
All four providers share the same assistant: block — only the provider value, default model, and env var change. Pick the one that fits your deployment.
Anthropic (direct)
assistant:
provider: anthropic
model: claude-sonnet-4-20250514
api_key: ${ANTHROPIC_API_KEY}
max_tokens: 1024
Install with pip install melissa-sage[anthropic]. Uses the official anthropic SDK.
OpenAI (direct)
assistant:
provider: openai
model: gpt-4o
api_key: ${OPENAI_API_KEY}
max_tokens: 1024
Install with pip install melissa-sage[openai].
OpenRouter (access Claude / GPT / Gemini / Llama with one API key)
assistant:
provider: openrouter
model: anthropic/claude-sonnet-4 # default — override with any OpenRouter model id
api_key: ${OPENROUTER_API_KEY}
max_tokens: 1024
Install with pip install melissa-sage[openai]. The OpenRouter provider piggybacks on the openai SDK.
The api_base is auto-set to https://openrouter.ai/api/v1 — you do not need to configure it. Only override if you proxy OpenRouter through a different gateway.
You can swap the model to any OpenRouter-supported id:
model: openai/gpt-4o
model: google/gemini-2.5-pro
model: meta-llama/llama-4-maverick
model: openrouter/auto # OpenRouter picks the best model server-side
Self-hosted (Ollama, vLLM, LM Studio, etc.)
assistant:
provider: custom
model: llama3.2
api_base: http://localhost:11434/v1 # Ollama's default
api_key: not-needed # most self-hosted servers don't check
max_tokens: 1024
Install with pip install melissa-sage[openai]. The default api_base is http://localhost:11434/v1 (Ollama), and the default api_key is the literal string "not-needed" — both are overridable. Point api_base at your vLLM / LM Studio / TGI endpoint if you're running something else.
Cache
| Backend | Use case |
|---|---|
memory |
Dev, tests, scripts |
redis |
Production |
The context_key parameter is deliberately neutral — you decide whether it represents a tenant, user, session, page, or a composite scope. The full cache key format is {prefix}:{context_key}:{section_id}.
Examples
Standalone script
from melissa_sage import Melissa
melissa = Melissa.from_config("melissa_sage.yaml")
melissa.cache_data(
"salarios",
{"employees": [{"nombre": "Sofía", "puesto": "Sr.", "salario": 15800}]},
context_key="demo",
)
print(melissa.explain("salarios", context_key="demo")["reply"])
Django
# views.py
def cache_salarios(request):
employees = Employee.objects.filter(org=request.user.org)
melissa.cache_data(
"salarios",
{"employees": [e.as_dict() for e in employees]},
context_key=str(request.user.org_id),
)
return JsonResponse({"ok": True})
def explain_salarios(request):
result = melissa.explain(
"salarios",
context_key=str(request.user.org_id),
question=request.GET.get("q"),
)
return JsonResponse(result)
FastAPI (async + streaming)
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.get("/explain/{section_id}")
async def explain(section_id: str, tenant: str, q: str | None = None):
result = await melissa.aexplain(section_id, context_key=tenant, question=q)
return result
@app.get("/explain-stream/{section_id}")
async def explain_stream(section_id: str, tenant: str):
async def gen():
async for chunk in melissa.aexplain_stream(section_id, context_key=tenant):
if chunk["type"] == "text":
yield chunk["content"]
return StreamingResponse(gen(), media_type="text/plain")
API Reference
Melissa main methods
| Method | Description |
|---|---|
Melissa.from_config(path) |
Classmethod: load a YAML/TOML/JSON config and register sections |
register(section_id, ...) |
Register a section with examples, template, required_keys |
cache_data(section_id, data,...) |
Cache data for a section (validates required_keys) |
get_cached_data(section_id, ...) |
Read cached data |
invalidate(section_id, ...) |
Delete cached data |
explain(section_id, ...) |
Sync explanation |
aexplain(section_id, ...) |
Async explanation |
explain_stream(section_id, ...) |
Sync streaming generator |
aexplain_stream(section_id, ...) |
Async streaming generator |
list_sections() |
List registered sections |
Melissa.register_cache_backend(name, cls) |
Static: register a third-party cache backend |
Melissa.register_provider(name, dotted_path) |
Static: register a third-party provider |
explain() return shape
{
"reply": "📊 Diego sold $78,500...",
"section_id": "comisiones",
"section_label": "Comisiones del Periodo",
"provider": "<AnthropicProvider model='claude-sonnet-4-20250514'>",
"cached": True,
"usage": {"input_tokens": 234, "output_tokens": 156},
}
Streaming
Sync streaming (scripts, Django views):
for chunk in melissa.explain_stream("comisiones", context_key="tenant_1"):
if chunk["type"] == "text":
print(chunk["content"], end="", flush=True)
elif chunk["type"] == "done":
print(f"\nTokens used: {chunk['usage']}")
Async streaming (FastAPI, async handlers):
async for chunk in melissa.aexplain_stream("comisiones", context_key="tenant_1"):
if chunk["type"] == "text":
await response.write(chunk["content"])
elif chunk["type"] == "done":
logger.info(f"Tokens: {chunk['usage']}")
Async
# Single response (async)
result = await melissa.aexplain("comisiones", context_key="tenant_1")
# Streaming (async)
async for chunk in melissa.aexplain_stream("comisiones", context_key="tenant_1"):
...
Full method matrix:
sync async
complete explain() aexplain()
stream explain_stream() aexplain_stream()
Error handling and retries
from melissa_sage.providers import ProviderError
try:
result = melissa.explain("comisiones", context_key="tenant_1")
except ProviderError as e:
if e.retryable:
# 429 rate limit or 5xx — already retried 3 times with
# exponential backoff. If you see this, all retries failed.
...
else:
# auth error, bad request — not retried (permanent error)
...
Retries are automatic via tenacity. When a provider returns 429 (rate limit) or 5xx (server error), melissa-sage retries up to 3 times with exponential backoff (1s, 2s, 4s). Permanent errors (401 auth, 400 bad request) are raised immediately without retrying. Streaming calls (explain_stream and aexplain_stream) are NOT retried since a partial stream cannot be resumed.
Design decisions
Stateless by design: Each explain() call is independent — there is no conversation history. This is intentional. melissa-sage is designed to explain data sections on demand, not to be a conversational chatbot. Each call gets the full context it needs from the cache and the few-shot examples. This keeps the API simple and the cache scope clean.
No observability hooks in 0.1: Features like on_before_call / on_after_call hooks for logging, metrics, or tracing are planned for a future release. For now, use the usage field in the explain() response to track token consumption, and standard Python logging (melissa_sage logger) for debugging.
Why "sage"?
The name melissa-sage combines two ideas: Melissa (the assistant identity) and sage (wisdom — one who explains with insight). The -sage suffix also ensures the package name is unique on PyPI and leaves room for future packages in the melissa-* family if needed.
Extending
Custom cache backend:
from melissa_sage import Melissa
from melissa_sage.cache.base import BaseCache
class MyDiskCache(BaseCache):
def get(self, key): ...
def set(self, key, data, ttl=None): ...
def delete(self, key): ...
Melissa.register_cache_backend("disk", MyDiskCache)
Custom provider:
from melissa_sage.providers import BaseProvider, ProviderError
class MyProvider(BaseProvider):
def complete(self, system_prompt, messages, max_tokens=1024): ...
async def acomplete(self, system_prompt, messages, max_tokens=1024): ...
def stream(self, system_prompt, messages, max_tokens=1024): ...
async def astream(self, system_prompt, messages, max_tokens=1024): ...
# Register from a dotted path:
Melissa.register_provider("my_provider", "my_pkg.providers.MyProvider")
⚠️ Data privacy notice
This library sends data to external LLM APIs (Anthropic, OpenAI, OpenRouter) unless you explicitly use a self-hosted provider (custom). If your data contains personally identifiable information (PII), make sure you comply with applicable regulations (GDPR, LFPDPPP in Mexico, LGPD in Brazil, etc.). Consider:
- Anonymizing data before calling
cache_data() - Using a self-hosted provider (Ollama, vLLM) for sensitive workloads
- Reviewing your LLM provider's data-retention and training policies
melissa-sage is a tool; compliance is your responsibility.
Roadmap
Features planned for future releases:
- Observability hooks (
on_before_call,on_after_call) for metrics and tracing - Template versioning to detect cache/template mismatches
- Optional conversation history for multi-turn follow-ups
- Configurable retry parameters from YAML
- Scoped env-var resolution: apply
${VAR}substitution only to specific config keys (api_key,api_base,cache.url, etc.) instead of the whole tree, eliminating the Jinja-template collision described in the Config gotchas section - Full inline section definitions: a single unified
section:block format that carries template + examples + metadata without needing external files, for configs that fit on one screen melissa-sage validateCLI: a command that loads a config file and reports all issues before the first LLM call
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file melissa_sage-0.1.1.tar.gz.
File metadata
- Download URL: melissa_sage-0.1.1.tar.gz
- Upload date:
- Size: 48.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d36ab804f745039aa16d52c5e58cbd4f16a6774f192e334957f5d776242618f7
|
|
| MD5 |
ddac797a3710afb75598e0839957829f
|
|
| BLAKE2b-256 |
a2ec4666946103cc417aba6c18ed27f2389cd1258eed072ef58573f0277ad54f
|
File details
Details for the file melissa_sage-0.1.1-py3-none-any.whl.
File metadata
- Download URL: melissa_sage-0.1.1-py3-none-any.whl
- Upload date:
- Size: 30.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
10bee43c8e9eac42921151a8d3476af41b69a713db5b2c4f33056723ec617116
|
|
| MD5 |
cf6da79a3519cd008e058f1d7a640f82
|
|
| BLAKE2b-256 |
944929bc291b6e89a9968031b253c15c05e6126b4adcb83ce901ab76f20cf4c4
|