Metadata-Version: 2.4
Name: melissa-sage
Version: 0.1.0
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]
```

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

## Few-shot examples

Few-shot examples teach the model the exact *format*, *tone*, and *structure* you want.

```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**.
```

Examples are emitted as user/assistant conversation turns before the real question, so the model has seen the format before it has to produce one.

## Jinja2 templates

Templates take cached data and render it as the context message sent to the model. Four custom filters are available:

| Filter    | Example        |
|-----------|----------------|
| `money`   | `$145,000`     |
| `money2`  | `$145,000.00`  |
| `pct`     | `5%`           |
| `num`     | `145,000`      |

```yaml
# prompts/salarios_template.yaml
content: |
  [Context — Salaries — Period: {{ periodo }}]

  {% for e in employees %}
  • {{ e.nombre }}: {{ e.salario | money }} MXN
  {% endfor %}
```

## Providers

| Provider     | YAML key       | Default model                  |
|--------------|----------------|--------------------------------|
| Anthropic    | `anthropic`    | `claude-sonnet-4-20250514`     |
| OpenAI       | `openai`       | `gpt-4o`                       |
| OpenRouter   | `openrouter`   | `anthropic/claude-sonnet-4`    |
| Custom       | `custom`       | `llama3.2` (Ollama by default) |

Switching providers is a single line in the YAML:

```yaml
assistant:
  provider: openai
  model: gpt-4o
  api_key: ${OPENAI_API_KEY}
```

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

## OpenRouter auto mode

When using OpenRouter, the default model is `anthropic/claude-sonnet-4` for determinism — new installations get reproducible costs and responses. If you prefer OpenRouter to pick the best model automatically, opt into auto mode:

```yaml
assistant:
  provider: openrouter
  model: openrouter/auto          # opt-in: OpenRouter picks the best model
  api_key: ${OPENROUTER_API_KEY}
```

You can also specify any other model explicitly: `openai/gpt-4o`, `google/gemini-2.5-pro`, `meta-llama/llama-4-maverick`, etc.

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

## License

MIT — see `LICENSE`.
