Metadata-Version: 2.4
Name: simasia
Version: 0.3.0
Summary: Per-brand tone guardrail for LLM responses via pluggable embeddings and a small logistic-regression head.
Author-email: Armstrong Olusoji <armstrongolusoji9@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/Armstrong2035/simasia
Project-URL: Repository, https://github.com/Armstrong2035/simasia
Project-URL: Changelog, https://github.com/Armstrong2035/simasia/blob/main/CHANGELOG.md
Keywords: llm,brand-voice,tone,guardrail,embeddings,classification
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Text Processing :: Linguistic
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: scikit-learn>=1.3.0
Requires-Dist: joblib>=1.3.0
Requires-Dist: numpy>=1.24.0
Requires-Dist: tomli>=2.0.0; python_version < "3.11"
Provides-Extra: openai
Requires-Dist: openai>=1.0.0; extra == "openai"
Provides-Extra: local
Requires-Dist: sentence-transformers>=3.0.0; extra == "local"
Provides-Extra: urls
Requires-Dist: trafilatura>=1.6.0; extra == "urls"
Provides-Extra: test
Requires-Dist: pytest>=7.0.0; extra == "test"
Dynamic: license-file

# Simasia Tone Guardrail

**Does an AI reply sound like your brand? Simasia gives it a score from 0 to 1.**

What you can do:

- **Train** it once on your brand's writing.
- **Score** any AI response — how on-brand is it?
- **Explain** the score — see the closest on-brand and off-brand samples.
- **Refine** — keep regenerating a reply until it sounds right.

How it works, in one line: a shared model turns text into numbers, and a tiny
per-brand classifier learns your voice. Runs on CPU, no GPU needed.

One heads-up: the trained model is saved to a file that includes your training
text — keep it private if that text is sensitive.

Version history is in [CHANGELOG.md](CHANGELOG.md).

## Quick start

Two methods: **`train`** once per brand, then **`refine`** in your response pipeline.

```python
from simasia import SimasiaGuard

guard = SimasiaGuard(brand_id="fintech_core")   # key from EMBEDDING_KEY

# 1. Train once from ON-BRAND text only. For each on-brand chunk, a lightweight
#    LLM writes an off-brand opposite to train against (key: GENERATION_KEY, or
#    it falls back to EMBEDDING_KEY). Pass URLs instead of text if you prefer.
guard.train(on_brand="We build automated investment tools. They work fast.")

# Or supply both sides yourself and skip the LLM step entirely:
# guard.train(on_brand="...", off_brand="...")

# 2. Wire into the pipeline: keep regenerating until the reply is on-brand.
def generate(feedback):
    prompt = "Reply to the customer about their late transfer."
    if feedback:                 # a hint from the previous low-scoring attempt
        prompt += "\n\n" + feedback
    return my_llm(prompt)        # your LLM call

result = guard.refine(generate, threshold=0.7, max_attempts=4)
print(result)  # {"text": "...", "score": 0.82, "passed": True, "attempts": 2}
```

## Config-file usage (no code)

Prefer not to write Python? Configure once, then run one command.

1. `pip install "simasia[openai]"`
2. Copy `.env.example` → `.env.local`, add your key (`EMBEDDING_KEY=...`). It's gitignored — keys never go in the committed config.
3. Copy `simasia.example.toml` → `simasia.toml`, set your brand and training source.
4. Train, then score:

```bash
simasia train                       # reads simasia.toml + .env.local
simasia score "Hey! Quick heads up about your transfer."
simasia explain "Kindly be advised your request is under review."
```

`simasia train --config path/to/other.toml` points at a different config.

## Install

The package is parametric — install only the backend you want:

```bash
pip install .[openai]          # default backend: OpenAI text-embedding-3-small
pip install .[urls]            # train from URLs (fetch + clean-text extraction)
pip install .[local]           # offline backend: frozen sentence-transformer, CPU
pip install .[openai,urls,test]  # a typical dev setup
```

`requirements.txt` is a quick-start shortcut for the OpenAI + URLs combination.

## Choosing an embedding backend

The backend is any object with an `encode(list[str]) -> np.ndarray` method
(`EmbeddingModel`). The default is OpenAI, with the API key read from the
`EMBEDDING_KEY` environment variable:

```python
from simasia import SimasiaGuard, OpenAIEmbedder, LocalEmbedder

# Default: OpenAIEmbedder(), key from EMBEDDING_KEY
guard = SimasiaGuard(brand_id="fintech_core")

# Pick a model / shorten the vector / pass a key explicitly
guard = SimasiaGuard(
    brand_id="fintech_core",
    embedding_model=OpenAIEmbedder(model="text-embedding-3-small", dimensions=512),
)

# Fully offline (no network, CPU) — requires the `local` extra and a cached model
guard = SimasiaGuard(brand_id="fintech_core", embedding_model=LocalEmbedder())
```

## Training

`train(on_brand, off_brand=None)` is the one entry point. Each side accepts three
source types, and you can mix them:

```python
guard.train("We build automated investment tools. They work fast.")  # str  -> raw text
guard.train(Path("brand_voice.txt"))                                 # Path -> read file
guard.train(["https://brand.example/voice", "https://brand.example/blog"])  # list -> URLs
```

A `str` is always raw text (never guessed as a path); use `Path` for a file. URLs
are fetched and reduced to clean article text, then all on-brand pages are
concatenated into one corpus (same for off-brand) before chunking.

**On-brand only (opposites generated).** Omit `off_brand` and a lightweight LLM
writes off-brand opposites for each on-brand chunk — one per **tone dimension**
(directness, empathy, hedging, formality, enthusiasm, technicality), each flipping
a single axis (key: `GENERATION_KEY`, or it falls back to `EMBEDDING_KEY`):

```python
guard.train(on_brand="We build automated investment tools. They work fast.")

# Cap cost / pick axes (also settable in simasia.toml):
guard.train(on_brand="...", max_chunks=100, dimensions=["empathy", "hedging"])
```

`max_chunks` randomly samples the corpus (each chunk yields one opposite per
dimension, so cost = chunks x dimensions). Web boilerplate (URLs, dates, contact
lines, "min read") is stripped before chunking.

> Note: generated opposites are a *bootstrap*. Real user feedback (see
> [Learning from production](#learning-from-production-thumbs-updown)) makes the
> model far sharper.

**Both sides supplied (no LLM).** Pass both to skip generation entirely:

```python
guard.train(
    on_brand="We build automated investment tools. They work fast.",
    off_brand="Our enterprise architecture processes asset allocations. "
              "Systems experience transactional delay cycles.",
)
```

The lower-level `calibrate_weights` (raw text) and `calibrate_from_urls` (URLs)
methods remain available if you want to call them directly.

## Scoring live responses

```python
score = guard.evaluate_response("Hey! Let's get your account squared away right now.")
print(score)  # probability in [0, 1] that the text is on-brand
```

## Explaining a verdict

`explain()` returns the score plus the nearest on-brand and off-brand training
chunks — the concrete text the response reads most and least like. No generative
model is involved; the "reason" is retrieved from the brand's own samples.

```python
result = guard.explain("Kindly be advised your request is under review.")
# {
#   "score": 0.21,
#   "verdict": "off-brand",
#   "closest_on_brand":  {"text": "We build automated tools...", "similarity": 0.44},
#   "closest_off_brand": {"text": "Systems experience delay cycles.", "similarity": 0.73},
# }
```

## Steering a response on-brand

`refine()` keeps asking your LLM for a response until it scores on-brand. You pass
a `generate(feedback)` function; the first call gets `feedback=None`, and after a
low score it gets a plain-text hint built from the nearest off/on-brand samples.

```python
def generate(feedback):
    prompt = "Reply to the customer."
    if feedback:
        prompt += "\n\n" + feedback   # explain()-based hint
    return my_llm(prompt)

result = guard.refine(generate, threshold=0.7, max_attempts=4)
# {"text": "...", "score": 0.82, "passed": True, "attempts": 2}
```

## Picking the best of N (recommended for production)

`pick_best()` scores several candidate responses and returns the most on-brand
one. **You generate the candidates; Simasia only ranks them.** Because it ranks
candidates against each other, it is robust even when the model's absolute scores
are not well-calibrated.

```python
candidates = [my_llm(prompt) for _ in range(5)]   # you generate N
best = guard.pick_best(candidates)                 # Simasia ranks
send(best["text"])
# best = {"text": "...", "score": 0.83, "ranked": [{"text": ..., "score": ...}, ...]}
```

## Learning from production (thumbs up/down)

Generated opposites only bootstrap the model. The real gain comes from **user
feedback**: log thumbs up (on-brand, `1`) and thumbs down (off-brand, `0`) in your
app, then feed them back. Each response is one example — embedded as-is, no
chunking, no generation.

```python
guard.train_from_labeled([
    ("Hey lovely, here's how to refresh day-2 curls...", 1),   # 👍
    ("Per our records, your inquiry is being processed.", 0),  # 👎
])

# Grow an existing model with the day's feedback (reuses stored embeddings):
guard.train_from_labeled(todays_thumbs, include_existing=True)
```

Once enough real feedback accumulates, retrain **without** `include_existing` on
the pure feedback set to drop the synthetic bootstrap entirely.

**The full production loop:** generate N → `pick_best` → ship the winner → collect
👍/👎 → `train_from_labeled` on a schedule → the ranking sharpens over time.

## Where the artifact is stored

By default the artifact is a file (`FileArtifactStore`). For production (many
servers, containers, serverless) put it in shared storage instead — your own
database or an S3/GCS bucket. Implement `ArtifactStore` (`save`, `load`,
`exists`) and pass it in; nothing in the guard changes.

Use `serialize_artifact` / `deserialize_artifact` so your store only handles
bytes. Example against any DB (shown with SQLite):

```python
import sqlite3
from simasia import SimasiaGuard, serialize_artifact, deserialize_artifact

class SQLiteStore:
    def __init__(self, conn):
        self.conn = conn
        conn.execute("CREATE TABLE IF NOT EXISTS simasia (brand TEXT PRIMARY KEY, blob BLOB)")

    def save(self, brand_id, artifact):
        self.conn.execute(
            "REPLACE INTO simasia (brand, blob) VALUES (?, ?)",
            (brand_id, serialize_artifact(artifact)),
        )
        self.conn.commit()

    def load(self, brand_id):
        row = self.conn.execute(
            "SELECT blob FROM simasia WHERE brand = ?", (brand_id,)
        ).fetchone()
        return deserialize_artifact(row[0]) if row else None

    def exists(self, brand_id):
        return self.conn.execute(
            "SELECT 1 FROM simasia WHERE brand = ?", (brand_id,)
        ).fetchone() is not None

guard = SimasiaGuard(brand_id="fintech_core", store=SQLiteStore(sqlite3.connect("brands.db")))
```
