simasia 0.2.0__tar.gz
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-0.2.0/LICENSE +21 -0
- simasia-0.2.0/PKG-INFO +239 -0
- simasia-0.2.0/README.md +206 -0
- simasia-0.2.0/pyproject.toml +49 -0
- simasia-0.2.0/setup.cfg +4 -0
- simasia-0.2.0/simasia/__init__.py +24 -0
- simasia-0.2.0/simasia/__main__.py +4 -0
- simasia-0.2.0/simasia/cli.py +57 -0
- simasia-0.2.0/simasia/config.py +111 -0
- simasia-0.2.0/simasia/embeddings.py +98 -0
- simasia-0.2.0/simasia/generation.py +68 -0
- simasia-0.2.0/simasia/guard.py +357 -0
- simasia-0.2.0/simasia/sources.py +52 -0
- simasia-0.2.0/simasia/storage.py +60 -0
- simasia-0.2.0/simasia.egg-info/PKG-INFO +239 -0
- simasia-0.2.0/simasia.egg-info/SOURCES.txt +19 -0
- simasia-0.2.0/simasia.egg-info/dependency_links.txt +1 -0
- simasia-0.2.0/simasia.egg-info/entry_points.txt +2 -0
- simasia-0.2.0/simasia.egg-info/requires.txt +18 -0
- simasia-0.2.0/simasia.egg-info/top_level.txt +1 -0
- simasia-0.2.0/tests/test_guard.py +347 -0
simasia-0.2.0/LICENSE
ADDED
|
@@ -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.
|
simasia-0.2.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
simasia-0.2.0/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Simasia Tone Guardrail
|
|
2
|
+
|
|
3
|
+
Simasia scores how closely an LLM response matches a brand's tone of voice. It
|
|
4
|
+
pairs a **frozen, pluggable embedding backend** with a small per-brand
|
|
5
|
+
`LogisticRegression` head. The fitted head is stored in
|
|
6
|
+
`simasia_<brand_id>_head.joblib`, **along with the training chunks and their
|
|
7
|
+
embeddings** — these let `explain()` ground a verdict in the brand's own samples.
|
|
8
|
+
(If your training text is sensitive, note that it is written into that artifact.)
|
|
9
|
+
|
|
10
|
+
Version history is in [CHANGELOG.md](CHANGELOG.md).
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
Two methods: **`train`** once per brand, then **`refine`** in your response pipeline.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from simasia import SimasiaGuard
|
|
18
|
+
|
|
19
|
+
guard = SimasiaGuard(brand_id="fintech_core") # key from EMBEDDING_KEY
|
|
20
|
+
|
|
21
|
+
# 1. Train once from ON-BRAND text only. For each on-brand chunk, a lightweight
|
|
22
|
+
# LLM writes an off-brand opposite to train against (key: GENERATION_KEY, or
|
|
23
|
+
# it falls back to EMBEDDING_KEY). Pass URLs instead of text if you prefer.
|
|
24
|
+
guard.train(on_brand="We build automated investment tools. They work fast.")
|
|
25
|
+
|
|
26
|
+
# Or supply both sides yourself and skip the LLM step entirely:
|
|
27
|
+
# guard.train(on_brand="...", off_brand="...")
|
|
28
|
+
|
|
29
|
+
# 2. Wire into the pipeline: keep regenerating until the reply is on-brand.
|
|
30
|
+
def generate(feedback):
|
|
31
|
+
prompt = "Reply to the customer about their late transfer."
|
|
32
|
+
if feedback: # a hint from the previous low-scoring attempt
|
|
33
|
+
prompt += "\n\n" + feedback
|
|
34
|
+
return my_llm(prompt) # your LLM call
|
|
35
|
+
|
|
36
|
+
result = guard.refine(generate, threshold=0.7, max_attempts=4)
|
|
37
|
+
print(result) # {"text": "...", "score": 0.82, "passed": True, "attempts": 2}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Config-file usage (no code)
|
|
41
|
+
|
|
42
|
+
Prefer not to write Python? Configure once, then run one command.
|
|
43
|
+
|
|
44
|
+
1. `pip install "simasia[openai]"`
|
|
45
|
+
2. Copy `.env.example` → `.env.local`, add your key (`EMBEDDING_KEY=...`). It's gitignored — keys never go in the committed config.
|
|
46
|
+
3. Copy `simasia.example.toml` → `simasia.toml`, set your brand and training source.
|
|
47
|
+
4. Train, then score:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
simasia train # reads simasia.toml + .env.local
|
|
51
|
+
simasia score "Hey! Quick heads up about your transfer."
|
|
52
|
+
simasia explain "Kindly be advised your request is under review."
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`simasia train --config path/to/other.toml` points at a different config.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
The package is parametric — install only the backend you want:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install .[openai] # default backend: OpenAI text-embedding-3-small
|
|
63
|
+
pip install .[urls] # train from URLs (fetch + clean-text extraction)
|
|
64
|
+
pip install .[local] # offline backend: frozen sentence-transformer, CPU
|
|
65
|
+
pip install .[openai,urls,test] # a typical dev setup
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`requirements.txt` is a quick-start shortcut for the OpenAI + URLs combination.
|
|
69
|
+
|
|
70
|
+
## Choosing an embedding backend
|
|
71
|
+
|
|
72
|
+
The backend is any object with an `encode(list[str]) -> np.ndarray` method
|
|
73
|
+
(`EmbeddingModel`). The default is OpenAI, with the API key read from the
|
|
74
|
+
`EMBEDDING_KEY` environment variable:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from simasia import SimasiaGuard, OpenAIEmbedder, LocalEmbedder
|
|
78
|
+
|
|
79
|
+
# Default: OpenAIEmbedder(), key from EMBEDDING_KEY
|
|
80
|
+
guard = SimasiaGuard(brand_id="fintech_core")
|
|
81
|
+
|
|
82
|
+
# Pick a model / shorten the vector / pass a key explicitly
|
|
83
|
+
guard = SimasiaGuard(
|
|
84
|
+
brand_id="fintech_core",
|
|
85
|
+
embedding_model=OpenAIEmbedder(model="text-embedding-3-small", dimensions=512),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Fully offline (no network, CPU) — requires the `local` extra and a cached model
|
|
89
|
+
guard = SimasiaGuard(brand_id="fintech_core", embedding_model=LocalEmbedder())
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Training
|
|
93
|
+
|
|
94
|
+
`train(on_brand, off_brand=None)` is the one entry point. Each side accepts three
|
|
95
|
+
source types, and you can mix them:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
guard.train("We build automated investment tools. They work fast.") # str -> raw text
|
|
99
|
+
guard.train(Path("brand_voice.txt")) # Path -> read file
|
|
100
|
+
guard.train(["https://brand.example/voice", "https://brand.example/blog"]) # list -> URLs
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A `str` is always raw text (never guessed as a path); use `Path` for a file. URLs
|
|
104
|
+
are fetched and reduced to clean article text, then all on-brand pages are
|
|
105
|
+
concatenated into one corpus (same for off-brand) before chunking.
|
|
106
|
+
|
|
107
|
+
**On-brand only (opposites generated).** Omit `off_brand` and a lightweight LLM
|
|
108
|
+
writes an off-brand opposite for each on-brand chunk (key: `GENERATION_KEY`, or it
|
|
109
|
+
falls back to `EMBEDDING_KEY`):
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
guard.train(on_brand="We build automated investment tools. They work fast.")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Both sides supplied (no LLM).** Pass both to skip generation entirely:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
guard.train(
|
|
119
|
+
on_brand="We build automated investment tools. They work fast.",
|
|
120
|
+
off_brand="Our enterprise architecture processes asset allocations. "
|
|
121
|
+
"Systems experience transactional delay cycles.",
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The lower-level `calibrate_weights` (raw text) and `calibrate_from_urls` (URLs)
|
|
126
|
+
methods remain available if you want to call them directly.
|
|
127
|
+
|
|
128
|
+
## Scoring live responses
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
score = guard.evaluate_response("Hey! Let's get your account squared away right now.")
|
|
132
|
+
print(score) # probability in [0, 1] that the text is on-brand
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Explaining a verdict
|
|
136
|
+
|
|
137
|
+
`explain()` returns the score plus the nearest on-brand and off-brand training
|
|
138
|
+
chunks — the concrete text the response reads most and least like. No generative
|
|
139
|
+
model is involved; the "reason" is retrieved from the brand's own samples.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
result = guard.explain("Kindly be advised your request is under review.")
|
|
143
|
+
# {
|
|
144
|
+
# "score": 0.21,
|
|
145
|
+
# "verdict": "off-brand",
|
|
146
|
+
# "closest_on_brand": {"text": "We build automated tools...", "similarity": 0.44},
|
|
147
|
+
# "closest_off_brand": {"text": "Systems experience delay cycles.", "similarity": 0.73},
|
|
148
|
+
# }
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Steering a response on-brand
|
|
152
|
+
|
|
153
|
+
`refine()` keeps asking your LLM for a response until it scores on-brand. You pass
|
|
154
|
+
a `generate(feedback)` function; the first call gets `feedback=None`, and after a
|
|
155
|
+
low score it gets a plain-text hint built from the nearest off/on-brand samples.
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
def generate(feedback):
|
|
159
|
+
prompt = "Reply to the customer."
|
|
160
|
+
if feedback:
|
|
161
|
+
prompt += "\n\n" + feedback # explain()-based hint
|
|
162
|
+
return my_llm(prompt)
|
|
163
|
+
|
|
164
|
+
result = guard.refine(generate, threshold=0.7, max_attempts=4)
|
|
165
|
+
# {"text": "...", "score": 0.82, "passed": True, "attempts": 2}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Where the artifact is stored
|
|
169
|
+
|
|
170
|
+
By default the artifact is a file (`FileArtifactStore`). For production (many
|
|
171
|
+
servers, containers, serverless) put it in shared storage instead — your own
|
|
172
|
+
database or an S3/GCS bucket. Implement `ArtifactStore` (`save`, `load`,
|
|
173
|
+
`exists`) and pass it in; nothing in the guard changes.
|
|
174
|
+
|
|
175
|
+
Use `serialize_artifact` / `deserialize_artifact` so your store only handles
|
|
176
|
+
bytes. Example against any DB (shown with SQLite):
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
import sqlite3
|
|
180
|
+
from simasia import SimasiaGuard, serialize_artifact, deserialize_artifact
|
|
181
|
+
|
|
182
|
+
class SQLiteStore:
|
|
183
|
+
def __init__(self, conn):
|
|
184
|
+
self.conn = conn
|
|
185
|
+
conn.execute("CREATE TABLE IF NOT EXISTS simasia (brand TEXT PRIMARY KEY, blob BLOB)")
|
|
186
|
+
|
|
187
|
+
def save(self, brand_id, artifact):
|
|
188
|
+
self.conn.execute(
|
|
189
|
+
"REPLACE INTO simasia (brand, blob) VALUES (?, ?)",
|
|
190
|
+
(brand_id, serialize_artifact(artifact)),
|
|
191
|
+
)
|
|
192
|
+
self.conn.commit()
|
|
193
|
+
|
|
194
|
+
def load(self, brand_id):
|
|
195
|
+
row = self.conn.execute(
|
|
196
|
+
"SELECT blob FROM simasia WHERE brand = ?", (brand_id,)
|
|
197
|
+
).fetchone()
|
|
198
|
+
return deserialize_artifact(row[0]) if row else None
|
|
199
|
+
|
|
200
|
+
def exists(self, brand_id):
|
|
201
|
+
return self.conn.execute(
|
|
202
|
+
"SELECT 1 FROM simasia WHERE brand = ?", (brand_id,)
|
|
203
|
+
).fetchone() is not None
|
|
204
|
+
|
|
205
|
+
guard = SimasiaGuard(brand_id="fintech_core", store=SQLiteStore(sqlite3.connect("brands.db")))
|
|
206
|
+
```
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "simasia"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Per-brand tone guardrail for LLM responses via pluggable embeddings and a small logistic-regression head."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Armstrong Olusoji", email = "armstrongolusoji9@gmail.com" }]
|
|
14
|
+
keywords = ["llm", "brand-voice", "tone", "guardrail", "embeddings", "classification"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
"Topic :: Text Processing :: Linguistic",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"scikit-learn>=1.3.0",
|
|
25
|
+
"joblib>=1.3.0",
|
|
26
|
+
"numpy>=1.24.0",
|
|
27
|
+
"tomli>=2.0.0; python_version < '3.11'",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
simasia = "simasia.cli:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/Armstrong2035/simasia"
|
|
35
|
+
Repository = "https://github.com/Armstrong2035/simasia"
|
|
36
|
+
Changelog = "https://github.com/Armstrong2035/simasia/blob/main/CHANGELOG.md"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
# Default embedding backend (OpenAI, text-embedding-3-small).
|
|
40
|
+
openai = ["openai>=1.0.0"]
|
|
41
|
+
# Offline embedding backend (frozen sentence-transformer, CPU).
|
|
42
|
+
local = ["sentence-transformers>=3.0.0"]
|
|
43
|
+
# URL-based training data (fetch + clean-text extraction).
|
|
44
|
+
urls = ["trafilatura>=1.6.0"]
|
|
45
|
+
# Everything needed to run the tests.
|
|
46
|
+
test = ["pytest>=7.0.0"]
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
include = ["simasia*"]
|
simasia-0.2.0/setup.cfg
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
|
+
]
|
|
@@ -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())
|