simasia 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simasia/__init__.py +24 -0
- simasia/__main__.py +4 -0
- simasia/cli.py +57 -0
- simasia/config.py +111 -0
- simasia/embeddings.py +98 -0
- simasia/generation.py +68 -0
- simasia/guard.py +357 -0
- simasia/sources.py +52 -0
- simasia/storage.py +60 -0
- simasia-0.2.0.dist-info/METADATA +239 -0
- simasia-0.2.0.dist-info/RECORD +15 -0
- simasia-0.2.0.dist-info/WHEEL +5 -0
- simasia-0.2.0.dist-info/entry_points.txt +2 -0
- simasia-0.2.0.dist-info/licenses/LICENSE +21 -0
- simasia-0.2.0.dist-info/top_level.txt +1 -0
simasia/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Local tone classification utilities for LLM responses."""
|
|
2
|
+
|
|
3
|
+
from .embeddings import EmbeddingModel, LocalEmbedder, OpenAIEmbedder
|
|
4
|
+
from .generation import GenerationModel, OpenAIGenerator
|
|
5
|
+
from .guard import SimasiaGuard
|
|
6
|
+
from .storage import (
|
|
7
|
+
ArtifactStore,
|
|
8
|
+
FileArtifactStore,
|
|
9
|
+
deserialize_artifact,
|
|
10
|
+
serialize_artifact,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"SimasiaGuard",
|
|
15
|
+
"OpenAIEmbedder",
|
|
16
|
+
"LocalEmbedder",
|
|
17
|
+
"EmbeddingModel",
|
|
18
|
+
"OpenAIGenerator",
|
|
19
|
+
"GenerationModel",
|
|
20
|
+
"ArtifactStore",
|
|
21
|
+
"FileArtifactStore",
|
|
22
|
+
"serialize_artifact",
|
|
23
|
+
"deserialize_artifact",
|
|
24
|
+
]
|
simasia/__main__.py
ADDED
simasia/cli.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Command-line entry point: ``simasia train`` / ``simasia score``.
|
|
2
|
+
|
|
3
|
+
Loads keys from ``.env`` / ``.env.local`` (if present), reads ``simasia.toml``,
|
|
4
|
+
and runs the requested command.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
|
|
11
|
+
from .config import build_guard, load_config, load_dotenv, run_training
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv: list[str] | None = None) -> int:
|
|
15
|
+
parser = argparse.ArgumentParser(prog="simasia", description="Brand tone guardrail.")
|
|
16
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
17
|
+
|
|
18
|
+
train = sub.add_parser("train", help="Train a brand model from the config file.")
|
|
19
|
+
train.add_argument("--config", default="simasia.toml")
|
|
20
|
+
|
|
21
|
+
score = sub.add_parser("score", help="Score one response against a trained brand.")
|
|
22
|
+
score.add_argument("--config", default="simasia.toml")
|
|
23
|
+
score.add_argument("text", help="The response text to score.")
|
|
24
|
+
|
|
25
|
+
explain = sub.add_parser(
|
|
26
|
+
"explain", help="Score a response and show the closest on/off-brand samples."
|
|
27
|
+
)
|
|
28
|
+
explain.add_argument("--config", default="simasia.toml")
|
|
29
|
+
explain.add_argument("text", help="The response text to explain.")
|
|
30
|
+
|
|
31
|
+
args = parser.parse_args(argv)
|
|
32
|
+
|
|
33
|
+
# Make keys in a local .env available before we build any backend.
|
|
34
|
+
load_dotenv(".env")
|
|
35
|
+
load_dotenv(".env.local")
|
|
36
|
+
|
|
37
|
+
config = load_config(args.config)
|
|
38
|
+
|
|
39
|
+
if args.command == "train":
|
|
40
|
+
accuracy = run_training(config)
|
|
41
|
+
print(f"Trained brand '{config['brand']['id']}'. Training accuracy: {accuracy:.3f}")
|
|
42
|
+
elif args.command == "score":
|
|
43
|
+
guard = build_guard(config)
|
|
44
|
+
print(f"{guard.evaluate_response(args.text):.3f}")
|
|
45
|
+
elif args.command == "explain":
|
|
46
|
+
guard = build_guard(config)
|
|
47
|
+
result = guard.explain(args.text)
|
|
48
|
+
on = result["closest_on_brand"]
|
|
49
|
+
off = result["closest_off_brand"]
|
|
50
|
+
print(f"score: {result['score']:.3f} ({result['verdict']})")
|
|
51
|
+
print(f"on-brand (sim {on['similarity']:.2f}): {on['text']}")
|
|
52
|
+
print(f"off-brand (sim {off['similarity']:.2f}): {off['text']}")
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
raise SystemExit(main())
|
simasia/config.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Config-file driven setup: build a guard and run training from ``simasia.toml``.
|
|
2
|
+
|
|
3
|
+
Secrets stay in the environment (``EMBEDDING_KEY`` / ``GENERATION_KEY``, which a
|
|
4
|
+
``.env`` file can supply); the TOML file holds only non-secret settings, so it is
|
|
5
|
+
safe to commit.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
try: # stdlib on 3.11+, backport on 3.10
|
|
14
|
+
import tomllib
|
|
15
|
+
except ModuleNotFoundError: # pragma: no cover - version dependent
|
|
16
|
+
import tomli as tomllib
|
|
17
|
+
|
|
18
|
+
from .embeddings import LocalEmbedder, OpenAIEmbedder
|
|
19
|
+
from .generation import OpenAIGenerator
|
|
20
|
+
from .guard import SimasiaGuard
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_dotenv(path: str | os.PathLike[str]) -> None:
|
|
24
|
+
"""Load simple ``KEY=VALUE`` lines from a .env file into the environment.
|
|
25
|
+
|
|
26
|
+
Existing environment variables win, so a real shell export is never clobbered.
|
|
27
|
+
"""
|
|
28
|
+
path = Path(path)
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return
|
|
31
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
32
|
+
line = line.strip()
|
|
33
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
34
|
+
continue
|
|
35
|
+
key, value = line.split("=", 1)
|
|
36
|
+
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_config(path: str | os.PathLike[str]) -> dict:
|
|
40
|
+
"""Parse a ``simasia.toml`` config file into a dict."""
|
|
41
|
+
with open(path, "rb") as handle:
|
|
42
|
+
return tomllib.load(handle)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_guard(config: dict) -> SimasiaGuard:
|
|
46
|
+
"""Construct a :class:`SimasiaGuard` from a parsed config dict."""
|
|
47
|
+
brand_id = config["brand"]["id"]
|
|
48
|
+
|
|
49
|
+
embed_cfg = config.get("embedding", {})
|
|
50
|
+
provider = embed_cfg.get("provider", "openai")
|
|
51
|
+
if provider == "openai":
|
|
52
|
+
embedder = OpenAIEmbedder(
|
|
53
|
+
model=embed_cfg.get("model", OpenAIEmbedder.DEFAULT_MODEL),
|
|
54
|
+
api_key=embed_cfg.get("api_key"),
|
|
55
|
+
dimensions=embed_cfg.get("dimensions"),
|
|
56
|
+
)
|
|
57
|
+
elif provider == "local":
|
|
58
|
+
embedder = LocalEmbedder(
|
|
59
|
+
model_name=embed_cfg.get("model", LocalEmbedder.DEFAULT_MODEL_NAME)
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError(f"Unknown embedding provider: {provider!r} (use 'openai' or 'local').")
|
|
63
|
+
|
|
64
|
+
# Only build a generator when configured; otherwise the guard makes a default
|
|
65
|
+
# one lazily, and only if on-brand-only training actually needs it.
|
|
66
|
+
gen_cfg = config.get("generation")
|
|
67
|
+
generator = (
|
|
68
|
+
OpenAIGenerator(
|
|
69
|
+
model=gen_cfg.get("model", OpenAIGenerator.DEFAULT_MODEL),
|
|
70
|
+
api_key=gen_cfg.get("api_key"),
|
|
71
|
+
)
|
|
72
|
+
if gen_cfg
|
|
73
|
+
else None
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
storage_dir = config.get("storage", {}).get("dir", ".")
|
|
77
|
+
return SimasiaGuard(
|
|
78
|
+
brand_id,
|
|
79
|
+
artifact_dir=storage_dir,
|
|
80
|
+
embedding_model=embedder,
|
|
81
|
+
generator=generator,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve_side(training: dict, side: str) -> str | list[str] | None:
|
|
86
|
+
"""Resolve one training side to raw text or a URL list.
|
|
87
|
+
|
|
88
|
+
Accepts ``<side>_text`` (inline), ``<side>_file`` (path to a .txt), or
|
|
89
|
+
``<side>_urls`` (list). Returns ``None`` when the side is not configured.
|
|
90
|
+
"""
|
|
91
|
+
if f"{side}_urls" in training:
|
|
92
|
+
return list(training[f"{side}_urls"])
|
|
93
|
+
if f"{side}_file" in training:
|
|
94
|
+
return Path(training[f"{side}_file"]).read_text(encoding="utf-8")
|
|
95
|
+
if f"{side}_text" in training:
|
|
96
|
+
return training[f"{side}_text"]
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def run_training(config: dict) -> float:
|
|
101
|
+
"""Build a guard from config and train it. Returns training accuracy."""
|
|
102
|
+
guard = build_guard(config)
|
|
103
|
+
training = config["training"]
|
|
104
|
+
|
|
105
|
+
on_brand = resolve_side(training, "on_brand")
|
|
106
|
+
if on_brand is None:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
"config [training] needs on_brand_text, on_brand_file, or on_brand_urls."
|
|
109
|
+
)
|
|
110
|
+
off_brand = resolve_side(training, "off_brand")
|
|
111
|
+
return guard.train(on_brand, off_brand)
|
simasia/embeddings.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Pluggable embedding backends for Simasia.
|
|
2
|
+
|
|
3
|
+
A backend is anything satisfying :class:`EmbeddingModel` — an object with an
|
|
4
|
+
``encode(list[str]) -> np.ndarray`` method. ``OpenAIEmbedder`` is the default
|
|
5
|
+
used by :class:`~simasia.guard.SimasiaGuard`; ``LocalEmbedder`` keeps the
|
|
6
|
+
original offline sentence-transformer path available for consumers who install
|
|
7
|
+
the ``local`` extra.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EmbeddingModel(Protocol):
|
|
19
|
+
"""Minimal interface required from an embedding backend."""
|
|
20
|
+
|
|
21
|
+
def encode(self, sentences: list[str], **kwargs: object) -> np.ndarray: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OpenAIEmbedder:
|
|
25
|
+
"""Embed text with an OpenAI embedding model (default backend).
|
|
26
|
+
|
|
27
|
+
The API key is read from the ``EMBEDDING_KEY`` environment variable unless
|
|
28
|
+
passed explicitly. ``dimensions`` is forwarded to the API to shorten the
|
|
29
|
+
output vector when the model supports it (``text-embedding-3-*`` do).
|
|
30
|
+
A pre-built ``client`` may be injected for testing.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
DEFAULT_MODEL = "text-embedding-3-small"
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
model: str = DEFAULT_MODEL,
|
|
38
|
+
api_key: str | None = None,
|
|
39
|
+
dimensions: int | None = None,
|
|
40
|
+
client: object | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.model = model
|
|
43
|
+
self.dimensions = dimensions
|
|
44
|
+
if client is None:
|
|
45
|
+
api_key = api_key or os.environ.get("EMBEDDING_KEY")
|
|
46
|
+
if not api_key:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"No OpenAI API key found. Set the EMBEDDING_KEY environment "
|
|
49
|
+
"variable or pass api_key=..."
|
|
50
|
+
)
|
|
51
|
+
try:
|
|
52
|
+
from openai import OpenAI
|
|
53
|
+
except ImportError as exc: # pragma: no cover - import guard
|
|
54
|
+
raise ImportError(
|
|
55
|
+
"OpenAIEmbedder requires the 'openai' package. "
|
|
56
|
+
"Install it with: pip install simasia[openai]"
|
|
57
|
+
) from exc
|
|
58
|
+
client = OpenAI(api_key=api_key)
|
|
59
|
+
self._client = client
|
|
60
|
+
|
|
61
|
+
def encode(self, sentences: list[str], **_kwargs: object) -> np.ndarray:
|
|
62
|
+
request: dict[str, object] = {"model": self.model, "input": list(sentences)}
|
|
63
|
+
if self.dimensions is not None:
|
|
64
|
+
request["dimensions"] = self.dimensions
|
|
65
|
+
response = self._client.embeddings.create(**request)
|
|
66
|
+
return np.asarray([item.embedding for item in response.data], dtype=np.float32)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LocalEmbedder:
|
|
70
|
+
"""Embed text with a local, frozen sentence-transformer (offline backend).
|
|
71
|
+
|
|
72
|
+
Defaults to ``BAAI/bge-small-en-v1.5`` loaded strictly from the local cache,
|
|
73
|
+
so training and inference run on CPU without network access. Pass a
|
|
74
|
+
pre-loaded ``model`` to reuse a cached instance or to inject a fake.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
DEFAULT_MODEL_NAME = "BAAI/bge-small-en-v1.5"
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
model_name: str = DEFAULT_MODEL_NAME,
|
|
82
|
+
local_files_only: bool = True,
|
|
83
|
+
model: object | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
if model is None:
|
|
86
|
+
try:
|
|
87
|
+
from sentence_transformers import SentenceTransformer
|
|
88
|
+
except ImportError as exc: # pragma: no cover - import guard
|
|
89
|
+
raise ImportError(
|
|
90
|
+
"LocalEmbedder requires the 'sentence-transformers' package. "
|
|
91
|
+
"Install it with: pip install simasia[local]"
|
|
92
|
+
) from exc
|
|
93
|
+
model = SentenceTransformer(model_name, local_files_only=local_files_only)
|
|
94
|
+
self._model = model
|
|
95
|
+
|
|
96
|
+
def encode(self, sentences: list[str], **_kwargs: object) -> np.ndarray:
|
|
97
|
+
embeddings = self._model.encode(list(sentences), convert_to_numpy=True)
|
|
98
|
+
return np.asarray(embeddings, dtype=np.float32)
|
simasia/generation.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Pluggable text-generation backend for Simasia.
|
|
2
|
+
|
|
3
|
+
Used at training time to turn each on-brand chunk into an off-brand "opposite"
|
|
4
|
+
sample, so a brand can be calibrated from on-brand text alone. A backend is
|
|
5
|
+
anything with a ``generate(prompt) -> str`` method; ``OpenAIGenerator`` is the
|
|
6
|
+
default.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GenerationModel(Protocol):
|
|
16
|
+
"""Minimal interface required from a generation backend."""
|
|
17
|
+
|
|
18
|
+
def generate(self, prompt: str) -> str: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OpenAIGenerator:
|
|
22
|
+
"""Generate text with an OpenAI chat model (default backend).
|
|
23
|
+
|
|
24
|
+
The API key is read from ``GENERATION_KEY``, falling back to ``EMBEDDING_KEY``,
|
|
25
|
+
unless passed explicitly. ``model`` defaults to a small, cheap model since the
|
|
26
|
+
opposite-example step is meant to be lightweight. A pre-built ``client`` may
|
|
27
|
+
be injected for testing.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
DEFAULT_MODEL = "gpt-4o-mini"
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
model: str = DEFAULT_MODEL,
|
|
35
|
+
api_key: str | None = None,
|
|
36
|
+
temperature: float = 0.7,
|
|
37
|
+
client: object | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.model = model
|
|
40
|
+
self.temperature = temperature
|
|
41
|
+
if client is None:
|
|
42
|
+
api_key = (
|
|
43
|
+
api_key
|
|
44
|
+
or os.environ.get("GENERATION_KEY")
|
|
45
|
+
or os.environ.get("EMBEDDING_KEY")
|
|
46
|
+
)
|
|
47
|
+
if not api_key:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"No OpenAI API key found. Set GENERATION_KEY (or EMBEDDING_KEY) "
|
|
50
|
+
"or pass api_key=..."
|
|
51
|
+
)
|
|
52
|
+
try:
|
|
53
|
+
from openai import OpenAI
|
|
54
|
+
except ImportError as exc: # pragma: no cover - import guard
|
|
55
|
+
raise ImportError(
|
|
56
|
+
"OpenAIGenerator requires the 'openai' package. "
|
|
57
|
+
"Install it with: pip install simasia[openai]"
|
|
58
|
+
) from exc
|
|
59
|
+
client = OpenAI(api_key=api_key)
|
|
60
|
+
self._client = client
|
|
61
|
+
|
|
62
|
+
def generate(self, prompt: str) -> str:
|
|
63
|
+
response = self._client.chat.completions.create(
|
|
64
|
+
model=self.model,
|
|
65
|
+
messages=[{"role": "user", "content": prompt}],
|
|
66
|
+
temperature=self.temperature,
|
|
67
|
+
)
|
|
68
|
+
return (response.choices[0].message.content or "").strip()
|
simasia/guard.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""The embedding-plus-classification pipeline used by Simasia."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from sklearn.linear_model import LogisticRegression
|
|
12
|
+
|
|
13
|
+
from .embeddings import EmbeddingModel, OpenAIEmbedder
|
|
14
|
+
from .generation import GenerationModel, OpenAIGenerator
|
|
15
|
+
from .sources import build_corpus, fetch_url_text
|
|
16
|
+
from .storage import ArtifactStore, FileArtifactStore
|
|
17
|
+
|
|
18
|
+
ARTIFACT_FORMAT = "simasia-head/2"
|
|
19
|
+
|
|
20
|
+
OPPOSITE_PROMPT = (
|
|
21
|
+
"Rewrite the text below so it has the OPPOSITE tone and voice, while keeping a "
|
|
22
|
+
"similar topic and length. Return only the rewritten text, with no preamble.\n\n"
|
|
23
|
+
"Text: {chunk}"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SimasiaGuard:
|
|
28
|
+
"""Classify short LLM responses as matching a brand's tone or not.
|
|
29
|
+
|
|
30
|
+
The embedding backend is frozen and pluggable (OpenAI by default; see
|
|
31
|
+
:mod:`simasia.embeddings`); only the small per-brand logistic-regression
|
|
32
|
+
head is fitted. The persisted artifact
|
|
33
|
+
(``simasia_<brand_id>_head.joblib``) also stores the training chunks and
|
|
34
|
+
their embeddings so :meth:`explain` can ground a verdict in the brand's own
|
|
35
|
+
on-brand and off-brand samples.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
brand_id: str,
|
|
41
|
+
artifact_dir: str | os.PathLike[str] = ".",
|
|
42
|
+
embedding_model: EmbeddingModel | None = None,
|
|
43
|
+
store: ArtifactStore | None = None,
|
|
44
|
+
generator: GenerationModel | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Initialize a guard for ``brand_id``.
|
|
47
|
+
|
|
48
|
+
``embedding_model`` defaults to :class:`~simasia.embeddings.OpenAIEmbedder`
|
|
49
|
+
(``text-embedding-3-small``, key from the ``EMBEDDING_KEY`` environment
|
|
50
|
+
variable). Pass any object implementing ``encode`` to choose a different
|
|
51
|
+
model, supply your own key/dimensions, or run fully offline via
|
|
52
|
+
:class:`~simasia.embeddings.LocalEmbedder`.
|
|
53
|
+
|
|
54
|
+
``store`` decides where the trained artifact lives. It defaults to
|
|
55
|
+
:class:`~simasia.storage.FileArtifactStore` writing under ``artifact_dir``;
|
|
56
|
+
pass a custom :class:`~simasia.storage.ArtifactStore` to use a database or
|
|
57
|
+
object store instead.
|
|
58
|
+
"""
|
|
59
|
+
if not brand_id or not brand_id.strip():
|
|
60
|
+
raise ValueError("brand_id must be a non-empty string")
|
|
61
|
+
|
|
62
|
+
self.brand_id = brand_id
|
|
63
|
+
self.store: ArtifactStore = store or FileArtifactStore(artifact_dir)
|
|
64
|
+
self.embedding_model: EmbeddingModel = embedding_model or OpenAIEmbedder()
|
|
65
|
+
# Created lazily only if opposite generation is actually needed.
|
|
66
|
+
self.generator: GenerationModel | None = generator
|
|
67
|
+
self.classifier: LogisticRegression | None = None
|
|
68
|
+
self.on_chunks: list[str] | None = None
|
|
69
|
+
self.on_embeddings: np.ndarray | None = None
|
|
70
|
+
self.off_chunks: list[str] | None = None
|
|
71
|
+
self.off_embeddings: np.ndarray | None = None
|
|
72
|
+
|
|
73
|
+
def _chunk_text(self, raw_text: str, sentences_per_chunk: int = 2) -> list[str]:
|
|
74
|
+
"""Return overlapping sentence windows suitable for tone training.
|
|
75
|
+
|
|
76
|
+
A chunk is retained only when the complete window has at least four words.
|
|
77
|
+
This avoids discarding useful short sentences when they form a meaningful
|
|
78
|
+
two-sentence sample with their neighbour.
|
|
79
|
+
"""
|
|
80
|
+
if sentences_per_chunk < 1:
|
|
81
|
+
raise ValueError("sentences_per_chunk must be at least 1")
|
|
82
|
+
if not isinstance(raw_text, str):
|
|
83
|
+
raise TypeError("raw_text must be a string")
|
|
84
|
+
|
|
85
|
+
cleaned = re.sub(r"\s+", " ", raw_text).strip()
|
|
86
|
+
if not cleaned:
|
|
87
|
+
return []
|
|
88
|
+
sentences = [
|
|
89
|
+
sentence.strip()
|
|
90
|
+
for sentence in re.split(r"(?<=[.!?])\s+", cleaned)
|
|
91
|
+
if sentence.strip()
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
return [
|
|
95
|
+
" ".join(sentences[index : index + sentences_per_chunk])
|
|
96
|
+
for index in range(len(sentences) - sentences_per_chunk + 1)
|
|
97
|
+
if len(" ".join(sentences[index : index + sentences_per_chunk]).split()) >= 4
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
def train(
|
|
101
|
+
self,
|
|
102
|
+
on_brand: str | Path | list[str],
|
|
103
|
+
off_brand: str | Path | list[str] | None = None,
|
|
104
|
+
) -> float:
|
|
105
|
+
"""Train the brand model (the main training entry point).
|
|
106
|
+
|
|
107
|
+
Each side accepts raw text (``str``), a file path (``pathlib.Path``, read
|
|
108
|
+
as text), or a list of URLs (``list[str]``). If ``off_brand`` is omitted, an
|
|
109
|
+
off-brand opposite is generated for each on-brand chunk with the generation
|
|
110
|
+
backend (see :meth:`calibrate_from_on_brand`). Returns training accuracy.
|
|
111
|
+
"""
|
|
112
|
+
if off_brand is None:
|
|
113
|
+
return self.calibrate_from_on_brand(on_brand)
|
|
114
|
+
return self.calibrate_weights(self._to_corpus(on_brand), self._to_corpus(off_brand))
|
|
115
|
+
|
|
116
|
+
def calibrate_from_on_brand(self, on_brand: str | Path | list[str]) -> float:
|
|
117
|
+
"""Train from on-brand text alone, generating each off-brand opposite.
|
|
118
|
+
|
|
119
|
+
The on-brand corpus (raw text, a file path, or a list of URLs) is chunked,
|
|
120
|
+
and the generation backend rewrites each chunk into an off-brand opposite.
|
|
121
|
+
Both sets are then embedded, labelled, and fitted like any other run.
|
|
122
|
+
"""
|
|
123
|
+
on_chunks = self._chunk_text(self._to_corpus(on_brand))
|
|
124
|
+
if not on_chunks:
|
|
125
|
+
raise ValueError("Training input contains insufficient sentences to extract tone.")
|
|
126
|
+
off_chunks = self._generate_opposites(on_chunks)
|
|
127
|
+
return self._fit(on_chunks, off_chunks)
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _to_corpus(source: str | Path | list[str]) -> str:
|
|
131
|
+
"""Resolve a training source to raw text.
|
|
132
|
+
|
|
133
|
+
``Path`` is read as a text file, ``list``/``tuple`` is fetched as URLs, and
|
|
134
|
+
``str`` is used as-is (raw text — never treated as a path, to avoid guessing).
|
|
135
|
+
"""
|
|
136
|
+
if isinstance(source, Path):
|
|
137
|
+
return source.read_text(encoding="utf-8")
|
|
138
|
+
if isinstance(source, (list, tuple)):
|
|
139
|
+
return build_corpus(list(source))
|
|
140
|
+
if isinstance(source, str):
|
|
141
|
+
return source
|
|
142
|
+
raise TypeError(
|
|
143
|
+
"Training source must be str (raw text), pathlib.Path (a file), or "
|
|
144
|
+
"list[str] (URLs)."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _generate_opposites(self, on_chunks: list[str]) -> list[str]:
|
|
148
|
+
"""Rewrite each on-brand chunk into an off-brand opposite via the LLM."""
|
|
149
|
+
if self.generator is None:
|
|
150
|
+
self.generator = OpenAIGenerator()
|
|
151
|
+
opposites: list[str] = []
|
|
152
|
+
for chunk in on_chunks:
|
|
153
|
+
opposite = self.generator.generate(OPPOSITE_PROMPT.format(chunk=chunk)).strip()
|
|
154
|
+
if not opposite:
|
|
155
|
+
raise ValueError("Generation backend returned an empty opposite example.")
|
|
156
|
+
opposites.append(opposite)
|
|
157
|
+
return opposites
|
|
158
|
+
|
|
159
|
+
def calibrate_from_urls(
|
|
160
|
+
self,
|
|
161
|
+
on_brand_urls: list[str],
|
|
162
|
+
off_brand_urls: list[str],
|
|
163
|
+
fetcher: Callable[[str], str] = fetch_url_text,
|
|
164
|
+
) -> float:
|
|
165
|
+
"""Train from URLs: fetch and extract each page, then calibrate.
|
|
166
|
+
|
|
167
|
+
Every on-brand URL's extracted text is concatenated into one corpus, and
|
|
168
|
+
likewise for off-brand, before the usual chunking and fitting. ``fetcher``
|
|
169
|
+
is injectable for testing or for supplying pre-fetched content.
|
|
170
|
+
"""
|
|
171
|
+
on_brand_raw = build_corpus(on_brand_urls, fetcher=fetcher)
|
|
172
|
+
off_brand_raw = build_corpus(off_brand_urls, fetcher=fetcher)
|
|
173
|
+
return self.calibrate_weights(on_brand_raw, off_brand_raw)
|
|
174
|
+
|
|
175
|
+
def calibrate_weights(self, on_brand_raw: str, off_brand_raw: str) -> float:
|
|
176
|
+
"""Fit and persist the brand-specific logistic-regression head.
|
|
177
|
+
|
|
178
|
+
On-brand and off-brand corpora are chunked independently and labelled
|
|
179
|
+
1/0; the counts need not match. The chunks and their embeddings are
|
|
180
|
+
persisted alongside the head so verdicts can be explained against real
|
|
181
|
+
samples. Returns training accuracy.
|
|
182
|
+
"""
|
|
183
|
+
on_chunks = self._chunk_text(on_brand_raw)
|
|
184
|
+
off_chunks = self._chunk_text(off_brand_raw)
|
|
185
|
+
if not on_chunks or not off_chunks:
|
|
186
|
+
raise ValueError("Training inputs contain insufficient sentences to extract tone.")
|
|
187
|
+
return self._fit(on_chunks, off_chunks)
|
|
188
|
+
|
|
189
|
+
def _fit(self, on_chunks: list[str], off_chunks: list[str]) -> float:
|
|
190
|
+
"""Embed labelled chunks, fit the head, persist the artifact, return accuracy."""
|
|
191
|
+
text_samples = on_chunks + off_chunks
|
|
192
|
+
labels = np.concatenate(
|
|
193
|
+
(np.ones(len(on_chunks), dtype=np.int64), np.zeros(len(off_chunks), dtype=np.int64))
|
|
194
|
+
)
|
|
195
|
+
embeddings = self._encode(text_samples)
|
|
196
|
+
|
|
197
|
+
self.classifier = LogisticRegression(class_weight="balanced", max_iter=1000)
|
|
198
|
+
self.classifier.fit(embeddings, labels)
|
|
199
|
+
|
|
200
|
+
self.on_chunks = on_chunks
|
|
201
|
+
self.off_chunks = off_chunks
|
|
202
|
+
self.on_embeddings = embeddings[: len(on_chunks)]
|
|
203
|
+
self.off_embeddings = embeddings[len(on_chunks) :]
|
|
204
|
+
|
|
205
|
+
self.store.save(
|
|
206
|
+
self.brand_id,
|
|
207
|
+
{
|
|
208
|
+
"format": ARTIFACT_FORMAT,
|
|
209
|
+
"classifier": self.classifier,
|
|
210
|
+
"on_chunks": self.on_chunks,
|
|
211
|
+
"off_chunks": self.off_chunks,
|
|
212
|
+
"on_embeddings": self.on_embeddings,
|
|
213
|
+
"off_embeddings": self.off_embeddings,
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
return float(self.classifier.score(embeddings, labels))
|
|
217
|
+
|
|
218
|
+
def evaluate_response(self, llm_generated_text: str) -> float:
|
|
219
|
+
"""Return the probability that a live response is on-brand."""
|
|
220
|
+
text = self._validate_response(llm_generated_text)
|
|
221
|
+
self._load_artifact()
|
|
222
|
+
embedding = self._encode([text])
|
|
223
|
+
return self._on_brand_probability(embedding)
|
|
224
|
+
|
|
225
|
+
def explain(self, llm_generated_text: str) -> dict[str, object]:
|
|
226
|
+
"""Score a response and ground the verdict in the brand's own samples.
|
|
227
|
+
|
|
228
|
+
Returns the on-brand probability plus the nearest on-brand and off-brand
|
|
229
|
+
training chunks (by cosine similarity) — the concrete text the response
|
|
230
|
+
reads most and least like. No generative model is involved.
|
|
231
|
+
"""
|
|
232
|
+
text = self._validate_response(llm_generated_text)
|
|
233
|
+
self._load_artifact()
|
|
234
|
+
if self.on_embeddings is None or self.off_embeddings is None:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"Artifact for brand '{self.brand_id}' has no stored exemplars. "
|
|
237
|
+
"Retrain with calibrate_weights/calibrate_from_urls to enable explain()."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
embedding = self._encode([text])
|
|
241
|
+
score = self._on_brand_probability(embedding)
|
|
242
|
+
query = embedding[0]
|
|
243
|
+
return {
|
|
244
|
+
"score": score,
|
|
245
|
+
"verdict": "on-brand" if score >= 0.5 else "off-brand",
|
|
246
|
+
"closest_on_brand": self._nearest(query, self.on_embeddings, self.on_chunks),
|
|
247
|
+
"closest_off_brand": self._nearest(query, self.off_embeddings, self.off_chunks),
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
def refine(
|
|
251
|
+
self,
|
|
252
|
+
generate: Callable[[str | None], str],
|
|
253
|
+
threshold: float = 0.7,
|
|
254
|
+
max_attempts: int = 4,
|
|
255
|
+
) -> dict[str, object]:
|
|
256
|
+
"""Regenerate a response until it scores on-brand, using explain feedback.
|
|
257
|
+
|
|
258
|
+
``generate(feedback)`` is your LLM call: on the first attempt ``feedback``
|
|
259
|
+
is ``None``; after a low score it receives a plain-text hint built from the
|
|
260
|
+
nearest off-brand and on-brand samples. Stops as soon as a response reaches
|
|
261
|
+
``threshold``; otherwise returns the best attempt after ``max_attempts``.
|
|
262
|
+
|
|
263
|
+
Returns ``{"text", "score", "passed", "attempts"}``.
|
|
264
|
+
"""
|
|
265
|
+
if not callable(generate):
|
|
266
|
+
raise TypeError("generate must be callable")
|
|
267
|
+
if max_attempts < 1:
|
|
268
|
+
raise ValueError("max_attempts must be at least 1")
|
|
269
|
+
|
|
270
|
+
best: dict[str, object] | None = None
|
|
271
|
+
feedback: str | None = None
|
|
272
|
+
for attempt in range(1, max_attempts + 1):
|
|
273
|
+
text = generate(feedback)
|
|
274
|
+
result = self.explain(text)
|
|
275
|
+
score = float(result["score"])
|
|
276
|
+
if best is None or score > float(best["score"]):
|
|
277
|
+
best = {"text": text, "score": score}
|
|
278
|
+
if score >= threshold:
|
|
279
|
+
return {**best, "passed": True, "attempts": attempt}
|
|
280
|
+
feedback = self._build_feedback(result)
|
|
281
|
+
|
|
282
|
+
return {**best, "passed": False, "attempts": max_attempts}
|
|
283
|
+
|
|
284
|
+
@staticmethod
|
|
285
|
+
def _build_feedback(result: dict[str, object]) -> str:
|
|
286
|
+
"""Turn an explain() result into a hint for the next generation."""
|
|
287
|
+
off = result["closest_off_brand"]["text"]
|
|
288
|
+
on = result["closest_on_brand"]["text"]
|
|
289
|
+
return (
|
|
290
|
+
f"That response scored {float(result['score']):.2f} on-brand (too low). "
|
|
291
|
+
f'It sounds too much like this off-brand sample: "{off}". '
|
|
292
|
+
f'Rewrite it to sound more like this on-brand sample: "{on}".'
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def _nearest(
|
|
296
|
+
self, query: np.ndarray, matrix: np.ndarray, chunks: list[str]
|
|
297
|
+
) -> dict[str, object]:
|
|
298
|
+
"""Return the chunk in ``matrix`` most similar to ``query``."""
|
|
299
|
+
similarities = self._cosine_similarity(query, matrix)
|
|
300
|
+
best = int(np.argmax(similarities))
|
|
301
|
+
return {"text": chunks[best], "similarity": float(similarities[best])}
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
def _cosine_similarity(query: np.ndarray, matrix: np.ndarray) -> np.ndarray:
|
|
305
|
+
"""Cosine similarity between one vector and each row of ``matrix``."""
|
|
306
|
+
query_norm = query / (np.linalg.norm(query) + 1e-12)
|
|
307
|
+
matrix_norm = matrix / (np.linalg.norm(matrix, axis=1, keepdims=True) + 1e-12)
|
|
308
|
+
return matrix_norm @ query_norm
|
|
309
|
+
|
|
310
|
+
def _on_brand_probability(self, embedding: np.ndarray) -> float:
|
|
311
|
+
"""Probability of the on-brand class for an already-encoded sample."""
|
|
312
|
+
assert self.classifier is not None # set by _load_artifact
|
|
313
|
+
probabilities = self.classifier.predict_proba(embedding)
|
|
314
|
+
on_brand_index = int(np.where(self.classifier.classes_ == 1)[0][0])
|
|
315
|
+
return float(probabilities[0, on_brand_index])
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def _validate_response(llm_generated_text: str) -> str:
|
|
319
|
+
if not isinstance(llm_generated_text, str) or not llm_generated_text.strip():
|
|
320
|
+
raise ValueError("llm_generated_text must be a non-empty string")
|
|
321
|
+
return llm_generated_text
|
|
322
|
+
|
|
323
|
+
def _load_artifact(self) -> None:
|
|
324
|
+
"""Load the persisted head (and exemplars, if present) once per process.
|
|
325
|
+
|
|
326
|
+
Supports both the current dict artifact and legacy files that stored a
|
|
327
|
+
bare ``LogisticRegression``; legacy files load without exemplars.
|
|
328
|
+
"""
|
|
329
|
+
if self.classifier is not None:
|
|
330
|
+
return
|
|
331
|
+
loaded = self.store.load(self.brand_id)
|
|
332
|
+
if loaded is None:
|
|
333
|
+
raise FileNotFoundError(
|
|
334
|
+
f"No trained artifact for brand '{self.brand_id}'. Call "
|
|
335
|
+
"calibrate_weights or calibrate_from_urls first."
|
|
336
|
+
)
|
|
337
|
+
if isinstance(loaded, dict):
|
|
338
|
+
self.classifier = loaded["classifier"]
|
|
339
|
+
self.on_chunks = loaded.get("on_chunks")
|
|
340
|
+
self.off_chunks = loaded.get("off_chunks")
|
|
341
|
+
self.on_embeddings = loaded.get("on_embeddings")
|
|
342
|
+
self.off_embeddings = loaded.get("off_embeddings")
|
|
343
|
+
else: # legacy: bare classifier, no exemplars
|
|
344
|
+
self.classifier = loaded
|
|
345
|
+
|
|
346
|
+
def _encode(self, text_samples: list[str]) -> np.ndarray:
|
|
347
|
+
"""Embed text and validate the returned sample matrix.
|
|
348
|
+
|
|
349
|
+
The embedding dimension is model-dependent and no longer fixed; sklearn
|
|
350
|
+
enforces train/inference consistency, so we only check the matrix shape.
|
|
351
|
+
"""
|
|
352
|
+
embeddings = np.asarray(
|
|
353
|
+
self.embedding_model.encode(text_samples, convert_to_numpy=True), dtype=np.float32
|
|
354
|
+
)
|
|
355
|
+
if embeddings.ndim != 2 or embeddings.shape[0] != len(text_samples):
|
|
356
|
+
raise ValueError("Embedding model returned an invalid sample matrix.")
|
|
357
|
+
return embeddings
|
simasia/sources.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Turn training URLs into a single text corpus.
|
|
2
|
+
|
|
3
|
+
Fetching a page is easy; extracting *clean* prose (no nav, cookie banners,
|
|
4
|
+
footers, or ads) is the hard part, so this delegates both steps to
|
|
5
|
+
``trafilatura`` rather than hand-rolling an HTML reader. Each URL's extracted
|
|
6
|
+
article text is concatenated into one corpus, which the guard then chunks
|
|
7
|
+
normally — a page's content is raw material, never a single chunk.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
CORPUS_SEPARATOR = "\n\n"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fetch_url_text(url: str) -> str:
|
|
18
|
+
"""Download ``url`` and return its main article text.
|
|
19
|
+
|
|
20
|
+
Raises ``ValueError`` when the page cannot be downloaded or yields no
|
|
21
|
+
extractable content, so a dead link never silently contributes an empty
|
|
22
|
+
sample to training.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
import trafilatura
|
|
26
|
+
except ImportError as exc: # pragma: no cover - import guard
|
|
27
|
+
raise ImportError(
|
|
28
|
+
"URL training requires the 'trafilatura' package. "
|
|
29
|
+
"Install it with: pip install simasia[urls]"
|
|
30
|
+
) from exc
|
|
31
|
+
|
|
32
|
+
downloaded = trafilatura.fetch_url(url)
|
|
33
|
+
if downloaded is None:
|
|
34
|
+
raise ValueError(f"Could not download URL: {url!r}")
|
|
35
|
+
text = trafilatura.extract(downloaded)
|
|
36
|
+
if not text or not text.strip():
|
|
37
|
+
raise ValueError(f"No extractable text content at URL: {url!r}")
|
|
38
|
+
return text.strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_corpus(
|
|
42
|
+
urls: list[str],
|
|
43
|
+
fetcher: Callable[[str], str] = fetch_url_text,
|
|
44
|
+
) -> str:
|
|
45
|
+
"""Fetch every URL and join the results into one corpus string.
|
|
46
|
+
|
|
47
|
+
``fetcher`` is injectable so callers can supply cached content or a stub in
|
|
48
|
+
tests without network access.
|
|
49
|
+
"""
|
|
50
|
+
if not urls:
|
|
51
|
+
raise ValueError("At least one URL is required.")
|
|
52
|
+
return CORPUS_SEPARATOR.join(fetcher(url) for url in urls)
|
simasia/storage.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Where a brand's trained artifact is saved and loaded.
|
|
2
|
+
|
|
3
|
+
The guard talks to this seam instead of touching the filesystem directly, so a
|
|
4
|
+
database or object-store backend is a drop-in later: implement the three
|
|
5
|
+
``ArtifactStore`` methods and pass it to :class:`~simasia.guard.SimasiaGuard`.
|
|
6
|
+
``FileArtifactStore`` is the default and keeps the original on-disk behaviour.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
import joblib
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ArtifactStore(Protocol):
|
|
20
|
+
"""Reads and writes one artifact per brand."""
|
|
21
|
+
|
|
22
|
+
def save(self, brand_id: str, artifact: object) -> None: ...
|
|
23
|
+
def load(self, brand_id: str) -> object | None: ...
|
|
24
|
+
def exists(self, brand_id: str) -> bool: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def serialize_artifact(artifact: object) -> bytes:
|
|
28
|
+
"""Turn an artifact into bytes for storing in a DB blob (or anywhere)."""
|
|
29
|
+
buffer = io.BytesIO()
|
|
30
|
+
joblib.dump(artifact, buffer)
|
|
31
|
+
return buffer.getvalue()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def deserialize_artifact(blob: bytes) -> object:
|
|
35
|
+
"""Rebuild an artifact from bytes produced by :func:`serialize_artifact`."""
|
|
36
|
+
return joblib.load(io.BytesIO(blob))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FileArtifactStore:
|
|
40
|
+
"""Save each brand's artifact as ``simasia_<brand_id>_head.joblib``."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, artifact_dir: str | os.PathLike[str] = ".") -> None:
|
|
43
|
+
self.artifact_dir = Path(artifact_dir)
|
|
44
|
+
|
|
45
|
+
def path_for(self, brand_id: str) -> Path:
|
|
46
|
+
return self.artifact_dir / f"simasia_{brand_id}_head.joblib"
|
|
47
|
+
|
|
48
|
+
def save(self, brand_id: str, artifact: object) -> None:
|
|
49
|
+
path = self.path_for(brand_id)
|
|
50
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
joblib.dump(artifact, path)
|
|
52
|
+
|
|
53
|
+
def load(self, brand_id: str) -> object | None:
|
|
54
|
+
path = self.path_for(brand_id)
|
|
55
|
+
if not path.exists():
|
|
56
|
+
return None
|
|
57
|
+
return joblib.load(path)
|
|
58
|
+
|
|
59
|
+
def exists(self, brand_id: str) -> bool:
|
|
60
|
+
return self.path_for(brand_id).exists()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simasia
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Per-brand tone guardrail for LLM responses via pluggable embeddings and a small logistic-regression head.
|
|
5
|
+
Author-email: Armstrong Olusoji <armstrongolusoji9@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Armstrong2035/simasia
|
|
8
|
+
Project-URL: Repository, https://github.com/Armstrong2035/simasia
|
|
9
|
+
Project-URL: Changelog, https://github.com/Armstrong2035/simasia/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: llm,brand-voice,tone,guardrail,embeddings,classification
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: scikit-learn>=1.3.0
|
|
21
|
+
Requires-Dist: joblib>=1.3.0
|
|
22
|
+
Requires-Dist: numpy>=1.24.0
|
|
23
|
+
Requires-Dist: tomli>=2.0.0; python_version < "3.11"
|
|
24
|
+
Provides-Extra: openai
|
|
25
|
+
Requires-Dist: openai>=1.0.0; extra == "openai"
|
|
26
|
+
Provides-Extra: local
|
|
27
|
+
Requires-Dist: sentence-transformers>=3.0.0; extra == "local"
|
|
28
|
+
Provides-Extra: urls
|
|
29
|
+
Requires-Dist: trafilatura>=1.6.0; extra == "urls"
|
|
30
|
+
Provides-Extra: test
|
|
31
|
+
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# Simasia Tone Guardrail
|
|
35
|
+
|
|
36
|
+
Simasia scores how closely an LLM response matches a brand's tone of voice. It
|
|
37
|
+
pairs a **frozen, pluggable embedding backend** with a small per-brand
|
|
38
|
+
`LogisticRegression` head. The fitted head is stored in
|
|
39
|
+
`simasia_<brand_id>_head.joblib`, **along with the training chunks and their
|
|
40
|
+
embeddings** — these let `explain()` ground a verdict in the brand's own samples.
|
|
41
|
+
(If your training text is sensitive, note that it is written into that artifact.)
|
|
42
|
+
|
|
43
|
+
Version history is in [CHANGELOG.md](CHANGELOG.md).
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
Two methods: **`train`** once per brand, then **`refine`** in your response pipeline.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from simasia import SimasiaGuard
|
|
51
|
+
|
|
52
|
+
guard = SimasiaGuard(brand_id="fintech_core") # key from EMBEDDING_KEY
|
|
53
|
+
|
|
54
|
+
# 1. Train once from ON-BRAND text only. For each on-brand chunk, a lightweight
|
|
55
|
+
# LLM writes an off-brand opposite to train against (key: GENERATION_KEY, or
|
|
56
|
+
# it falls back to EMBEDDING_KEY). Pass URLs instead of text if you prefer.
|
|
57
|
+
guard.train(on_brand="We build automated investment tools. They work fast.")
|
|
58
|
+
|
|
59
|
+
# Or supply both sides yourself and skip the LLM step entirely:
|
|
60
|
+
# guard.train(on_brand="...", off_brand="...")
|
|
61
|
+
|
|
62
|
+
# 2. Wire into the pipeline: keep regenerating until the reply is on-brand.
|
|
63
|
+
def generate(feedback):
|
|
64
|
+
prompt = "Reply to the customer about their late transfer."
|
|
65
|
+
if feedback: # a hint from the previous low-scoring attempt
|
|
66
|
+
prompt += "\n\n" + feedback
|
|
67
|
+
return my_llm(prompt) # your LLM call
|
|
68
|
+
|
|
69
|
+
result = guard.refine(generate, threshold=0.7, max_attempts=4)
|
|
70
|
+
print(result) # {"text": "...", "score": 0.82, "passed": True, "attempts": 2}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Config-file usage (no code)
|
|
74
|
+
|
|
75
|
+
Prefer not to write Python? Configure once, then run one command.
|
|
76
|
+
|
|
77
|
+
1. `pip install "simasia[openai]"`
|
|
78
|
+
2. Copy `.env.example` → `.env.local`, add your key (`EMBEDDING_KEY=...`). It's gitignored — keys never go in the committed config.
|
|
79
|
+
3. Copy `simasia.example.toml` → `simasia.toml`, set your brand and training source.
|
|
80
|
+
4. Train, then score:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
simasia train # reads simasia.toml + .env.local
|
|
84
|
+
simasia score "Hey! Quick heads up about your transfer."
|
|
85
|
+
simasia explain "Kindly be advised your request is under review."
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`simasia train --config path/to/other.toml` points at a different config.
|
|
89
|
+
|
|
90
|
+
## Install
|
|
91
|
+
|
|
92
|
+
The package is parametric — install only the backend you want:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install .[openai] # default backend: OpenAI text-embedding-3-small
|
|
96
|
+
pip install .[urls] # train from URLs (fetch + clean-text extraction)
|
|
97
|
+
pip install .[local] # offline backend: frozen sentence-transformer, CPU
|
|
98
|
+
pip install .[openai,urls,test] # a typical dev setup
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`requirements.txt` is a quick-start shortcut for the OpenAI + URLs combination.
|
|
102
|
+
|
|
103
|
+
## Choosing an embedding backend
|
|
104
|
+
|
|
105
|
+
The backend is any object with an `encode(list[str]) -> np.ndarray` method
|
|
106
|
+
(`EmbeddingModel`). The default is OpenAI, with the API key read from the
|
|
107
|
+
`EMBEDDING_KEY` environment variable:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from simasia import SimasiaGuard, OpenAIEmbedder, LocalEmbedder
|
|
111
|
+
|
|
112
|
+
# Default: OpenAIEmbedder(), key from EMBEDDING_KEY
|
|
113
|
+
guard = SimasiaGuard(brand_id="fintech_core")
|
|
114
|
+
|
|
115
|
+
# Pick a model / shorten the vector / pass a key explicitly
|
|
116
|
+
guard = SimasiaGuard(
|
|
117
|
+
brand_id="fintech_core",
|
|
118
|
+
embedding_model=OpenAIEmbedder(model="text-embedding-3-small", dimensions=512),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Fully offline (no network, CPU) — requires the `local` extra and a cached model
|
|
122
|
+
guard = SimasiaGuard(brand_id="fintech_core", embedding_model=LocalEmbedder())
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Training
|
|
126
|
+
|
|
127
|
+
`train(on_brand, off_brand=None)` is the one entry point. Each side accepts three
|
|
128
|
+
source types, and you can mix them:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
guard.train("We build automated investment tools. They work fast.") # str -> raw text
|
|
132
|
+
guard.train(Path("brand_voice.txt")) # Path -> read file
|
|
133
|
+
guard.train(["https://brand.example/voice", "https://brand.example/blog"]) # list -> URLs
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
A `str` is always raw text (never guessed as a path); use `Path` for a file. URLs
|
|
137
|
+
are fetched and reduced to clean article text, then all on-brand pages are
|
|
138
|
+
concatenated into one corpus (same for off-brand) before chunking.
|
|
139
|
+
|
|
140
|
+
**On-brand only (opposites generated).** Omit `off_brand` and a lightweight LLM
|
|
141
|
+
writes an off-brand opposite for each on-brand chunk (key: `GENERATION_KEY`, or it
|
|
142
|
+
falls back to `EMBEDDING_KEY`):
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
guard.train(on_brand="We build automated investment tools. They work fast.")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Both sides supplied (no LLM).** Pass both to skip generation entirely:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
guard.train(
|
|
152
|
+
on_brand="We build automated investment tools. They work fast.",
|
|
153
|
+
off_brand="Our enterprise architecture processes asset allocations. "
|
|
154
|
+
"Systems experience transactional delay cycles.",
|
|
155
|
+
)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The lower-level `calibrate_weights` (raw text) and `calibrate_from_urls` (URLs)
|
|
159
|
+
methods remain available if you want to call them directly.
|
|
160
|
+
|
|
161
|
+
## Scoring live responses
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
score = guard.evaluate_response("Hey! Let's get your account squared away right now.")
|
|
165
|
+
print(score) # probability in [0, 1] that the text is on-brand
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Explaining a verdict
|
|
169
|
+
|
|
170
|
+
`explain()` returns the score plus the nearest on-brand and off-brand training
|
|
171
|
+
chunks — the concrete text the response reads most and least like. No generative
|
|
172
|
+
model is involved; the "reason" is retrieved from the brand's own samples.
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
result = guard.explain("Kindly be advised your request is under review.")
|
|
176
|
+
# {
|
|
177
|
+
# "score": 0.21,
|
|
178
|
+
# "verdict": "off-brand",
|
|
179
|
+
# "closest_on_brand": {"text": "We build automated tools...", "similarity": 0.44},
|
|
180
|
+
# "closest_off_brand": {"text": "Systems experience delay cycles.", "similarity": 0.73},
|
|
181
|
+
# }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Steering a response on-brand
|
|
185
|
+
|
|
186
|
+
`refine()` keeps asking your LLM for a response until it scores on-brand. You pass
|
|
187
|
+
a `generate(feedback)` function; the first call gets `feedback=None`, and after a
|
|
188
|
+
low score it gets a plain-text hint built from the nearest off/on-brand samples.
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
def generate(feedback):
|
|
192
|
+
prompt = "Reply to the customer."
|
|
193
|
+
if feedback:
|
|
194
|
+
prompt += "\n\n" + feedback # explain()-based hint
|
|
195
|
+
return my_llm(prompt)
|
|
196
|
+
|
|
197
|
+
result = guard.refine(generate, threshold=0.7, max_attempts=4)
|
|
198
|
+
# {"text": "...", "score": 0.82, "passed": True, "attempts": 2}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Where the artifact is stored
|
|
202
|
+
|
|
203
|
+
By default the artifact is a file (`FileArtifactStore`). For production (many
|
|
204
|
+
servers, containers, serverless) put it in shared storage instead — your own
|
|
205
|
+
database or an S3/GCS bucket. Implement `ArtifactStore` (`save`, `load`,
|
|
206
|
+
`exists`) and pass it in; nothing in the guard changes.
|
|
207
|
+
|
|
208
|
+
Use `serialize_artifact` / `deserialize_artifact` so your store only handles
|
|
209
|
+
bytes. Example against any DB (shown with SQLite):
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
import sqlite3
|
|
213
|
+
from simasia import SimasiaGuard, serialize_artifact, deserialize_artifact
|
|
214
|
+
|
|
215
|
+
class SQLiteStore:
|
|
216
|
+
def __init__(self, conn):
|
|
217
|
+
self.conn = conn
|
|
218
|
+
conn.execute("CREATE TABLE IF NOT EXISTS simasia (brand TEXT PRIMARY KEY, blob BLOB)")
|
|
219
|
+
|
|
220
|
+
def save(self, brand_id, artifact):
|
|
221
|
+
self.conn.execute(
|
|
222
|
+
"REPLACE INTO simasia (brand, blob) VALUES (?, ?)",
|
|
223
|
+
(brand_id, serialize_artifact(artifact)),
|
|
224
|
+
)
|
|
225
|
+
self.conn.commit()
|
|
226
|
+
|
|
227
|
+
def load(self, brand_id):
|
|
228
|
+
row = self.conn.execute(
|
|
229
|
+
"SELECT blob FROM simasia WHERE brand = ?", (brand_id,)
|
|
230
|
+
).fetchone()
|
|
231
|
+
return deserialize_artifact(row[0]) if row else None
|
|
232
|
+
|
|
233
|
+
def exists(self, brand_id):
|
|
234
|
+
return self.conn.execute(
|
|
235
|
+
"SELECT 1 FROM simasia WHERE brand = ?", (brand_id,)
|
|
236
|
+
).fetchone() is not None
|
|
237
|
+
|
|
238
|
+
guard = SimasiaGuard(brand_id="fintech_core", store=SQLiteStore(sqlite3.connect("brands.db")))
|
|
239
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
simasia/__init__.py,sha256=KfucTbTHknLm-j4gjsx0wfu0NizDYYj72fV4HL1y1yk,584
|
|
2
|
+
simasia/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
|
|
3
|
+
simasia/cli.py,sha256=BdeNq2sA_0qavrKjeSjNh401hwPlA3f5uVpsXc8_RNE,2108
|
|
4
|
+
simasia/config.py,sha256=vusGbduWJw9hXFE5qoYJkxt-c2GMKQ-7c6q8UjIvPn0,3826
|
|
5
|
+
simasia/embeddings.py,sha256=j0caLtv2EnvEoDLq-Wob3S8kXPB5UcUn6MpklC9O9-A,3752
|
|
6
|
+
simasia/generation.py,sha256=obagUYRDw0FeFHPlMRiFKzpxs0fhOC11n7yJZ__GkOM,2312
|
|
7
|
+
simasia/guard.py,sha256=SpxmhB3641mguMGhEeQCR4S7rqm_siK86Cnvz2KUY-Q,15905
|
|
8
|
+
simasia/sources.py,sha256=ZzSoHaPoijASYaD3yzEGDvergIbCQVJFXeyExxeKVOM,1801
|
|
9
|
+
simasia/storage.py,sha256=3kamnWJs_bpC343I824fEYxhpqSrlwTL2OscLzihSB4,1954
|
|
10
|
+
simasia-0.2.0.dist-info/licenses/LICENSE,sha256=7BxXjkZsF8T3LbDpuLSWkVk2t37M2Q9fZFjcVCXBmLU,1074
|
|
11
|
+
simasia-0.2.0.dist-info/METADATA,sha256=FXki6-TlQllqOUrguHQ3MdPsHypcmFXvXG-bu_Vub84,9356
|
|
12
|
+
simasia-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
13
|
+
simasia-0.2.0.dist-info/entry_points.txt,sha256=4aSQpn3TyMEXo5YKznHD0-vw844c0FOv2XqBluxzByU,45
|
|
14
|
+
simasia-0.2.0.dist-info/top_level.txt,sha256=e6v7s5eLYDIFWV9ksnrlGkhT1esBb-i7UXqwiaP7RD4,8
|
|
15
|
+
simasia-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Armstrong Olusoji
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
simasia
|