Skip to main content

Per-brand tone guardrail for LLM responses via pluggable embeddings and a small logistic-regression head.

Project description

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.

Quick start

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

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.tomlsimasia.toml, set your brand and training source.
  4. Train, then score:
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:

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:

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:

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

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) makes the model far sharper.

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

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

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.

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.

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.

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.

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

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")))

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

simasia-0.3.0.tar.gz (28.5 kB view details)

Uploaded Source

Built Distribution

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

simasia-0.3.0-py3-none-any.whl (22.7 kB view details)

Uploaded Python 3

File details

Details for the file simasia-0.3.0.tar.gz.

File metadata

  • Download URL: simasia-0.3.0.tar.gz
  • Upload date:
  • Size: 28.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.3

File hashes

Hashes for simasia-0.3.0.tar.gz
Algorithm Hash digest
SHA256 070b494270b0a05d23a88bff09569b4d41d811f1d15df8633179ec1e7d0888cb
MD5 2f8e376059a4de35ae6a8c746e459211
BLAKE2b-256 1b9df2d498df8ddb65d7a1170682100583fbfdb6c3f75860edb462c8b1aeef44

See more details on using hashes here.

File details

Details for the file simasia-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: simasia-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 22.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.3

File hashes

Hashes for simasia-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1c968bd47681e1df9d3fec10c0109ec64b97b1bd659fdc332ba6a0f4e4208140
MD5 a48f02c7e8b0b30093da8996eeb1bc1e
BLAKE2b-256 415e9595a96e91f453b370d5c49e523be4b439eba28ccd0a47e35646f3fe68b2

See more details on using hashes here.

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