spamkit-api 0.1.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.
- spamkit/__init__.py +27 -0
- spamkit/app.py +314 -0
- spamkit/classifier.py +122 -0
- spamkit/cli.py +199 -0
- spamkit/client.py +174 -0
- spamkit/errors.py +40 -0
- spamkit/evaluation.py +180 -0
- spamkit/observability.py +86 -0
- spamkit/schemas.py +121 -0
- spamkit/settings.py +67 -0
- spamkit/verdict.py +46 -0
- spamkit_api-0.1.0.dist-info/METADATA +245 -0
- spamkit_api-0.1.0.dist-info/RECORD +16 -0
- spamkit_api-0.1.0.dist-info/WHEEL +4 -0
- spamkit_api-0.1.0.dist-info/entry_points.txt +2 -0
- spamkit_api-0.1.0.dist-info/licenses/LICENSE +661 -0
spamkit/verdict.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Threshold policy: maps a raw spam score to clean, review, or spam."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Verdict(str, Enum):
|
|
12
|
+
CLEAN = "clean"
|
|
13
|
+
REVIEW = "review"
|
|
14
|
+
SPAM = "spam"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Thresholds:
|
|
19
|
+
"""Scores below ``clean`` are clean, scores above ``spam`` are spam, and everything between is review.
|
|
20
|
+
|
|
21
|
+
The defaults route every submission to review. A deployment has to choose thresholds
|
|
22
|
+
deliberately, based on its own evaluation results, before any automatic action is safe.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
clean: float = 0.0
|
|
26
|
+
spam: float = 1.0
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
for name, value in (("clean", self.clean), ("spam", self.spam)):
|
|
30
|
+
if not 0.0 <= value <= 1.0:
|
|
31
|
+
raise ValueError(f"{name} threshold must be between 0 and 1")
|
|
32
|
+
if not self.clean < self.spam:
|
|
33
|
+
raise ValueError("clean threshold must be lower than spam threshold")
|
|
34
|
+
|
|
35
|
+
def verdict_for(self, score: float) -> Verdict:
|
|
36
|
+
if score < self.clean:
|
|
37
|
+
return Verdict.CLEAN
|
|
38
|
+
if score > self.spam:
|
|
39
|
+
return Verdict.SPAM
|
|
40
|
+
return Verdict.REVIEW
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def version(self) -> str:
|
|
44
|
+
"""Short, deterministic fingerprint of the threshold values. Changes whenever they change."""
|
|
45
|
+
canonical = json.dumps({"clean": self.clean, "spam": self.spam}, sort_keys=True)
|
|
46
|
+
return hashlib.sha256(canonical.encode()).hexdigest()[:12]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: spamkit-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Self-hostable spam-scoring service and client for user-submitted text.
|
|
5
|
+
Project-URL: Homepage, https://spamkit.pages.dev
|
|
6
|
+
License-Expression: AGPL-3.0-only
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: classifier,moderation,self-hosted,spam
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: pydantic>=2.6
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
15
|
+
Provides-Extra: server
|
|
16
|
+
Requires-Dist: fastapi>=0.110; extra == 'server'
|
|
17
|
+
Requires-Dist: huggingface-hub>=0.22; extra == 'server'
|
|
18
|
+
Requires-Dist: torch>=2.2; extra == 'server'
|
|
19
|
+
Requires-Dist: transformers<5,>=4.48; extra == 'server'
|
|
20
|
+
Requires-Dist: uvicorn>=0.29; extra == 'server'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Spamkit
|
|
24
|
+
|
|
25
|
+
Spam scoring for user-submitted text, designed to run in your own environment.
|
|
26
|
+
|
|
27
|
+
Spamkit is a small HTTP service around the [spamton](https://huggingface.co/tiagozip/spamton) text classifier, plus Python and TypeScript clients and an offline evaluation CLI. It returns a score and a three-way verdict (`clean`, `review`, `spam`). Your application decides what to do with each verdict. Spamkit never deletes, blocks, or edits content.
|
|
28
|
+
|
|
29
|
+
> **Status: v0.1 prototype.** The product hypothesis is in [PRD.md](PRD.md). Model quality has **not** been validated on a labeled dataset for any target segment, so no accuracy or false-positive claim is made here. Use `spamkit evaluate` on your own labeled examples before you trust any threshold.
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
### Docker (recommended)
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
docker compose up --build
|
|
37
|
+
curl -s http://127.0.0.1:8080/readyz
|
|
38
|
+
curl -s -X POST http://127.0.0.1:8080/v1/score \
|
|
39
|
+
-H 'content-type: application/json' \
|
|
40
|
+
-d '{"text":"I can improve your Shopify SEO. Reply for a free audit.","id":"submission_123"}'
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The image pins the model revision and fetches the model at build time, so the running container makes no network calls. The Dockerfile and compose file have not been built in this repository's CI yet. Verify with `docker compose build` before you rely on them.
|
|
44
|
+
|
|
45
|
+
### Local (Python 3.10+)
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python -m venv .venv && . .venv/bin/activate
|
|
49
|
+
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple torch
|
|
50
|
+
pip install "spamkit-api[server]" # or: pip install -e ".[server]" from a checkout
|
|
51
|
+
spamkit fetch-model --dest models/spamton # downloads spamton at the pinned revision
|
|
52
|
+
spamkit serve # 127.0.0.1:8080
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## API
|
|
56
|
+
|
|
57
|
+
Base path `/v1`. All request and response bodies are JSON.
|
|
58
|
+
|
|
59
|
+
### `POST /v1/score`
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{ "text": "I can improve your Shopify SEO. Reply for a free audit.", "id": "submission_123", "context": "store_contact_form" }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"id": "submission_123",
|
|
68
|
+
"score": 0.5499,
|
|
69
|
+
"verdict": "review",
|
|
70
|
+
"model": "spamton",
|
|
71
|
+
"model_version": "7aa94d049d24bbc302c5a69e63cafe6c44d49bc1",
|
|
72
|
+
"latency_ms": 18,
|
|
73
|
+
"truncated": false
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `text` is required, must be non-empty UTF-8, and is limited to `SPAMKIT_MAX_TEXT_BYTES` (default 16,384 bytes).
|
|
78
|
+
- `id` is opaque and echoed back. It is never logged or used as a metric label.
|
|
79
|
+
- `context` is a short label (max 64 characters). It is logged and counted, and it does not condition the classifier.
|
|
80
|
+
- `score` is the raw spam-class output in [0, 1]. **It is not a calibrated probability.** Calibration has not been measured.
|
|
81
|
+
- `truncated` is `true` when the text exceeded the model's 512-token window. The model then scores only the leading part. The service first clips to 6,000 characters, matching the model author's reference preprocessing.
|
|
82
|
+
|
|
83
|
+
### `POST /v1/score/batch`
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{ "items": [{ "text": "...", "id": "a" }, { "text": "", "id": "b" }] }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Returns `results` in the same order as `items`. Each entry is `{"ok": true, "result": {...}}` or `{"ok": false, "id": ..., "error": {...}}`, so one bad item does not discard the others. Up to `SPAMKIT_MAX_BATCH_ITEMS` items (default 100).
|
|
90
|
+
|
|
91
|
+
### Verdicts
|
|
92
|
+
|
|
93
|
+
| Score | Verdict |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `score < clean` | `clean` |
|
|
96
|
+
| `score > spam` | `spam` |
|
|
97
|
+
| otherwise | `review` |
|
|
98
|
+
|
|
99
|
+
Defaults are `clean=0.0` and `spam=1.0`, so every submission is `review` until you choose thresholds from your own evaluation. Thresholds are returned by `GET /v1/info` with a `threshold_config_version` fingerprint.
|
|
100
|
+
|
|
101
|
+
### Errors
|
|
102
|
+
|
|
103
|
+
Every error uses one envelope, and the message never includes submitted text:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{ "error": { "code": "input_too_large", "message": "text must be at most 16384 bytes of UTF-8.", "request_id": "..." } }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| HTTP | `code` | Meaning |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| 400 | `invalid_request` | Malformed JSON, missing or empty `text`, wrong field types |
|
|
112
|
+
| 401 | `unauthorized` | Bearer token required and missing or wrong (only when `SPAMKIT_API_TOKEN` is set) |
|
|
113
|
+
| 413 | `input_too_large` | Body, text, or batch item count over the limit |
|
|
114
|
+
| 422 | `unsupported_input` | Text contains NUL characters |
|
|
115
|
+
| 429 | `rate_limited` | Too many requests in flight (`SPAMKIT_MAX_INFLIGHT`). Retry after `Retry-After`. |
|
|
116
|
+
| 503 | `model_not_ready` | Model failed to load or is still loading |
|
|
117
|
+
| 500 | `internal_error` | Unexpected failure. Details are logged by type only. |
|
|
118
|
+
|
|
119
|
+
### Operations
|
|
120
|
+
|
|
121
|
+
| Endpoint | Purpose |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `GET /healthz` | Liveness. Always 200 while the process is up. |
|
|
124
|
+
| `GET /readyz` | 200 only when the model is loaded. Returns the model version. |
|
|
125
|
+
| `GET /v1/info` | Model ID, version, token limit, thresholds, and size limits. No secrets. |
|
|
126
|
+
| `GET /metrics` | Prometheus text: request counts, verdict counts, error codes, latency histogram. |
|
|
127
|
+
|
|
128
|
+
## Evaluate on your own data
|
|
129
|
+
|
|
130
|
+
Put your labeled examples in a CSV or JSONL file with a `text` column and a `label` column (`spam` or `clean`). An optional `id` column is echoed in the output.
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
spamkit evaluate ./samples.csv --threshold 0.82 --output report.json
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The command runs the model locally. It does not upload the file. By default it prints row numbers only (file line numbers, so they match your editor). Pass `--show-text` to print the text of false positives and false negatives. The output includes:
|
|
137
|
+
|
|
138
|
+
- dataset count and label balance,
|
|
139
|
+
- confusion matrix, precision, recall, specificity, FPR, FNR, and F1,
|
|
140
|
+
- a threshold sweep from 0.05 to 0.95 (or `--sweep ...`),
|
|
141
|
+
- the model version and software version,
|
|
142
|
+
- the note "Metrics describe only the supplied dataset."
|
|
143
|
+
|
|
144
|
+
Undefined ratios (for example precision when nothing was predicted as spam) print as `n/a`.
|
|
145
|
+
|
|
146
|
+
## SDKs
|
|
147
|
+
|
|
148
|
+
Both SDKs use the same field names as the REST API, return the same verdicts, and map errors to the same codes. Both are tested against [`contract/fixtures.json`](contract/fixtures.json).
|
|
149
|
+
|
|
150
|
+
**Python** (`pip install -e .` from this repo until the package is published; the base install has no torch):
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from spamkit import SpamkitClient, SpamkitError
|
|
154
|
+
|
|
155
|
+
client = SpamkitClient("http://127.0.0.1:8080", timeout=2.0, max_retries=1)
|
|
156
|
+
try:
|
|
157
|
+
result = client.score("Win a free cruise!!!", id="submission_123")
|
|
158
|
+
print(result.verdict, result.score)
|
|
159
|
+
except SpamkitError as error:
|
|
160
|
+
print(error.code, error.status, error.request_id)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**TypeScript** (`sdk/typescript`, Node 18.17+):
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
import { SpamkitClient, SpamkitError } from "spamkit";
|
|
167
|
+
|
|
168
|
+
const client = new SpamkitClient({ baseUrl: "http://127.0.0.1:8080", timeoutMs: 2000, maxRetries: 1 });
|
|
169
|
+
try {
|
|
170
|
+
const result = await client.score({ text: "Win a free cruise!!!", id: "submission_123" });
|
|
171
|
+
console.log(result.verdict, result.score);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error instanceof SpamkitError) console.error(error.code, error.status, error.requestId);
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Retries are off by default. When you turn them on, they cover transport errors, timeouts, 429, and 5xx. Scoring is read-only, so a retry cannot duplicate side effects. Samples for a Node server and a FastAPI app are in [`examples/`](examples/).
|
|
178
|
+
|
|
179
|
+
## Configuration
|
|
180
|
+
|
|
181
|
+
| Variable | Default | Purpose |
|
|
182
|
+
|---|---|---|
|
|
183
|
+
| `SPAMKIT_MODEL_DIR` | `models/spamton` | Directory from `spamkit fetch-model` |
|
|
184
|
+
| `SPAMKIT_HOST` | `127.0.0.1` | Bind address. The container sets `0.0.0.0`. |
|
|
185
|
+
| `SPAMKIT_PORT` | `8080` | Listen port |
|
|
186
|
+
| `SPAMKIT_CLEAN_THRESHOLD` | `0.0` | Scores below this are `clean` |
|
|
187
|
+
| `SPAMKIT_SPAM_THRESHOLD` | `1.0` | Scores above this are `spam` |
|
|
188
|
+
| `SPAMKIT_MAX_TEXT_BYTES` | `16384` | Per-text byte limit |
|
|
189
|
+
| `SPAMKIT_MAX_BODY_BYTES` | `1048576` | Request body limit |
|
|
190
|
+
| `SPAMKIT_MAX_BATCH_ITEMS` | `100` | Batch item limit |
|
|
191
|
+
| `SPAMKIT_MAX_INFLIGHT` | `32` | Requests in flight (queued or running) before 429 |
|
|
192
|
+
| `SPAMKIT_API_TOKEN` | unset | When set, `/v1/*` and `/metrics` require `Authorization: Bearer <token>` |
|
|
193
|
+
|
|
194
|
+
Run one worker per process. Each worker loads its own copy of the model. `spamkit serve` already runs one.
|
|
195
|
+
|
|
196
|
+
## Privacy and data handling
|
|
197
|
+
|
|
198
|
+
This describes what the code does today.
|
|
199
|
+
|
|
200
|
+
- Submitted text is held in process memory for the duration of scoring. It is not written to disk, a database, or a cache.
|
|
201
|
+
- Logs are JSON lines with request ID, route, status, latency, verdict, score band (for example `0.1-0.2`), model version, error code, and `context`. They never include `text` or `id`. The test suite checks this.
|
|
202
|
+
- Metrics contain only fixed labels: route, status, verdict, and error code.
|
|
203
|
+
- The `evaluate` command reads your file locally and writes only aggregate metrics and the error-row list. Text is printed only with `--show-text`.
|
|
204
|
+
- There is no telemetry and no outbound network call at runtime.
|
|
205
|
+
- Local deployment reduces data exposure. It does not make a deployment GDPR-compliant or "zero data processing". Your own operations still matter, including TLS, access control, and log retention.
|
|
206
|
+
|
|
207
|
+
## Known gaps
|
|
208
|
+
|
|
209
|
+
- **No measured accuracy.** No labeled evaluation set has been run for any segment. PRD section 11 lists the release gates. Until they pass, do not present the service as production-ready.
|
|
210
|
+
- **Score is uncalibrated.** Do not treat it as a probability.
|
|
211
|
+
- **English only.** The model's training data is English.
|
|
212
|
+
- **Text only.** No image, link, sender, or account signals.
|
|
213
|
+
- **Rate limiting is only admission control.** `SPAMKIT_MAX_INFLIGHT` returns 429 when saturated. There is no per-client limit. Apply one at the reverse proxy.
|
|
214
|
+
- **Body size is enforced while streaming**, but there is no per-client limit. Put a proxy-level limit in front of the service for untrusted traffic.
|
|
215
|
+
- **No debug text logging, no auth beyond one shared bearer token, no multi-tenant support.**
|
|
216
|
+
- **Performance was measured only on Apple Silicon, not on the x86 target in the PRD.** On an M-series Mac with the pinned model, warm batch-size-1 latency was p50 9 ms and p95 24 ms over 30 runs. Startup includes one warm-up pass. Memory and concurrency have not been benchmarked on target hardware.
|
|
217
|
+
- **Batch latency** reported per item is the latency of the whole batch scoring call.
|
|
218
|
+
|
|
219
|
+
## Model and license
|
|
220
|
+
|
|
221
|
+
- Model: `tiagozip/spamton` at revision `7aa94d049d24bbc302c5a69e63cafe6c44d49bc1`, a 32M-parameter ModernBERT classifier fine-tuned from `jhu-clsp/ettin-encoder-32m`.
|
|
222
|
+
- License: **AGPL-3.0** for the model. The `LICENSE` and `NOTICE` files are kept in the model directory and in the image. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
|
223
|
+
- PRD section 14 flags that AGPL obligations for hosted or redistributed use have not been reviewed by counsel. Resolve that before any commercial release.
|
|
224
|
+
- The license for this repository's own code has not been chosen.
|
|
225
|
+
|
|
226
|
+
## Development
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
# Python: unit tests run without the model; model tests run when models/spamton exists.
|
|
230
|
+
pip install -e ".[server,dev]" && pytest
|
|
231
|
+
|
|
232
|
+
# TypeScript SDK
|
|
233
|
+
cd sdk/typescript && npm ci && npm run typecheck && npm test
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Layout:
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
src/spamkit/ server (app, classifier, settings), evaluation, client, CLI
|
|
240
|
+
sdk/typescript/ spamkit
|
|
241
|
+
contract/ shared request/response fixtures used by both SDK suites
|
|
242
|
+
examples/ Node and FastAPI integration samples
|
|
243
|
+
tests/ pytest suite
|
|
244
|
+
Dockerfile single-node image with the model pinned at build time
|
|
245
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
spamkit/__init__.py,sha256=Bh6bJeaC_7OUTzqjsJIbi5I3ofgab-OQqrA-Lem9_zk,616
|
|
2
|
+
spamkit/app.py,sha256=DFtt75YAf6RMyxiBV3epo56BoTPzoru4OxLtB8kNrv8,12822
|
|
3
|
+
spamkit/classifier.py,sha256=T8Lftx1hbiwbjmYyEdTwlYo4PID5gH8QSFk8o-O272w,4638
|
|
4
|
+
spamkit/cli.py,sha256=RpH69OUjHOYhu3gixvbZk8kpGK24MGOXNFacR4NOLIA,7580
|
|
5
|
+
spamkit/client.py,sha256=Z-Vk1quiC6jWpESOv0yPwYs1Bll1UX3BKFQypNJgSHo,6162
|
|
6
|
+
spamkit/errors.py,sha256=eHa_0z4MeAw2cjykKbxDm1qObpkiA0MhwhhR_WOv1TI,1168
|
|
7
|
+
spamkit/evaluation.py,sha256=90Lk91k28YHJCeSvvGJeN4Gu8zhCl1hyLniTYssIAKw,6473
|
|
8
|
+
spamkit/observability.py,sha256=ERTVd7OdsO4WAjwAfnT8O4BqyA5529TIybTt6Iz3ido,3432
|
|
9
|
+
spamkit/schemas.py,sha256=d9AR4VrJmGvR6zy5sHR0e3vzIGqI6yoHaNjVlEDk-Nk,3205
|
|
10
|
+
spamkit/settings.py,sha256=rxa2rp_EGcJo54x7R73EJGn3c2AcieJH1Ig4xs1Dlkc,2220
|
|
11
|
+
spamkit/verdict.py,sha256=dDq8g-Fmd36ttgSBMDTG3mauVxN61QD6QbaAXx4Lido,1530
|
|
12
|
+
spamkit_api-0.1.0.dist-info/METADATA,sha256=3V2_r-JO_9dIuYhZv01JNMb9I4BIrv2S_DxioC-4FMY,12006
|
|
13
|
+
spamkit_api-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
14
|
+
spamkit_api-0.1.0.dist-info/entry_points.txt,sha256=iroCCVAdDu_JlOJJmml_xcgePgr3_ITEPhAb-Dlo9Ys,45
|
|
15
|
+
spamkit_api-0.1.0.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
|
|
16
|
+
spamkit_api-0.1.0.dist-info/RECORD,,
|