Metadata-Version: 2.4
Name: melissa-sage
Version: 0.1.1
Summary: AI contextual assistant with few-shot learning and context injection. Provider-agnostic, framework-agnostic.
Project-URL: Repository, https://github.com/roodrigoroot69/melissa-sage
Project-URL: Homepage, https://github.com/roodrigoroot69/melissa-sage
Author-email: Rod <raurcino@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: ai,assistant,context-injection,few-shot,llm
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.10
Requires-Dist: jinja2>=3.1
Requires-Dist: pyyaml>=6.0
Requires-Dist: tenacity>=8.0
Provides-Extra: all
Requires-Dist: anthropic>=0.30; extra == 'all'
Requires-Dist: openai>=1.0; extra == 'all'
Requires-Dist: redis>=5.0; extra == 'all'
Provides-Extra: anthropic
Requires-Dist: anthropic>=0.30; extra == 'anthropic'
Provides-Extra: dev
Requires-Dist: pytest-cov; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Provides-Extra: openai
Requires-Dist: openai>=1.0; extra == 'openai'
Provides-Extra: redis
Requires-Dist: redis>=5.0; extra == 'redis'
Description-Content-Type: text/markdown

# melissa-sage 🌿

[![PyPI version](https://img.shields.io/pypi/v/melissa-sage)](https://pypi.org/project/melissa-sage/)
[![Python versions](https://img.shields.io/pypi/pyversions/melissa-sage)](https://pypi.org/project/melissa-sage/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

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

```bash
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](#provider-cookbook) below for full YAML examples per provider.

```python
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`)

```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:

```python
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:

1. **`$${{ price }}`** — since 0.1.1, a double dollar escapes to a literal `${`, and the rest is Jinja. Cleanest.
2. **`{{ "$" }}{{ price }}`** — use a Jinja literal for the dollar sign, then the variable in a separate Jinja block. Works on any version.
3. **`{{ price | money }}`** — if you just want currency formatting, use the built-in `money` filter, 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:`**:

```yaml
# 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:

```yaml
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:`**:

```yaml
# 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:

```yaml
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:

```yaml
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](#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)

```yaml
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)

```yaml
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)

```yaml
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:

```yaml
    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.)

```yaml
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

```python
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

```python
# 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)

```python
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

```python
{
    "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):

```python
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):

```python
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

```python
# 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

```python
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:

```python
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:

```python
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](#️-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 validate` CLI**: a command that loads a config file and reports all issues before the first LLM call

## License

MIT — see `LICENSE`.
