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 an off-brand opposite for each on-brand chunk (key: GENERATION_KEY, or it falls back to EMBEDDING_KEY):

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

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}

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.2.1.tar.gz (22.2 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.2.1-py3-none-any.whl (18.9 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for simasia-0.2.1.tar.gz
Algorithm Hash digest
SHA256 ce54d9aa5a29db1e2747606edca4592d1457f975cd0f74c09bd1deaad066eaf9
MD5 47950dc001862f1fc7ea5c2d57e7d95a
BLAKE2b-256 8e654c6b293ee03ce5ae5c1d1dd67787ca51aeb7400133b20d84aaed7b8839d0

See more details on using hashes here.

File details

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

File metadata

  • Download URL: simasia-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 18.9 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.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 4e3e134d7564cf2e7dd92ca62fadce87af9ad4be0a65e1b136d9b84d4a484d5c
MD5 127905e75f23af5d73ad85c23287f9da
BLAKE2b-256 9b4b15b328b8dcaadb03097405efe3eafa3a9c9b6910b4f233c462e7b5ebf1a9

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