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 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
+ ```
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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())