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/client.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Python client for the Spamkit v1 API. Needs only the base install (httpx + pydantic)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from spamkit.schemas import InfoResponse, ReadyResponse, ScoreResult
|
|
14
|
+
|
|
15
|
+
RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SpamkitError(Exception):
|
|
19
|
+
"""An error response from the service. ``code`` is one of the stable codes in ``ErrorCode``."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, code: str, message: str, status: int | None = None, request_id: str | None = None) -> None:
|
|
22
|
+
super().__init__(f"{code}: {message}")
|
|
23
|
+
self.code = code
|
|
24
|
+
self.message = message
|
|
25
|
+
self.status = status
|
|
26
|
+
self.request_id = request_id
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SpamkitTransportError(SpamkitError):
|
|
30
|
+
"""The request never produced an HTTP response (connection refused, reset, DNS failure)."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str) -> None:
|
|
33
|
+
super().__init__("transport_error", message)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SpamkitTimeoutError(SpamkitTransportError):
|
|
37
|
+
"""The request timed out before a response arrived."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.code = "timeout"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ItemError:
|
|
46
|
+
code: str
|
|
47
|
+
message: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class BatchOutcome:
|
|
52
|
+
"""One entry per submitted item, in input order. Exactly one of ``result`` or ``error`` is set."""
|
|
53
|
+
|
|
54
|
+
id: str | None
|
|
55
|
+
result: ScoreResult | None = None
|
|
56
|
+
error: ItemError | None = None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def ok(self) -> bool:
|
|
60
|
+
return self.result is not None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _api_error(response: httpx.Response) -> SpamkitError:
|
|
64
|
+
# Never include the response body in the message: it may echo submitted text.
|
|
65
|
+
try:
|
|
66
|
+
body = response.json()["error"]
|
|
67
|
+
return SpamkitError(
|
|
68
|
+
code=str(body["code"]),
|
|
69
|
+
message=str(body["message"]),
|
|
70
|
+
status=response.status_code,
|
|
71
|
+
request_id=body.get("request_id"),
|
|
72
|
+
)
|
|
73
|
+
except (ValueError, KeyError, TypeError):
|
|
74
|
+
return SpamkitError(
|
|
75
|
+
code="unknown_error",
|
|
76
|
+
message=f"Unexpected HTTP {response.status_code} response.",
|
|
77
|
+
status=response.status_code,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SpamkitClient:
|
|
82
|
+
"""Synchronous client. Retries are opt-in; scoring is read-only, so a retry cannot duplicate side effects."""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
base_url: str,
|
|
87
|
+
*,
|
|
88
|
+
timeout: float = 5.0,
|
|
89
|
+
api_token: str | None = None,
|
|
90
|
+
max_retries: int = 0,
|
|
91
|
+
retry_backoff: float = 0.25,
|
|
92
|
+
transport: httpx.BaseTransport | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
if max_retries < 0:
|
|
95
|
+
raise ValueError("max_retries must be zero or greater")
|
|
96
|
+
headers = {"Authorization": f"Bearer {api_token}"} if api_token else {}
|
|
97
|
+
self._http = httpx.Client(
|
|
98
|
+
base_url=base_url.rstrip("/"),
|
|
99
|
+
timeout=timeout,
|
|
100
|
+
headers=headers,
|
|
101
|
+
transport=transport,
|
|
102
|
+
)
|
|
103
|
+
self._max_retries = max_retries
|
|
104
|
+
self._retry_backoff = retry_backoff
|
|
105
|
+
|
|
106
|
+
def __enter__(self) -> SpamkitClient: # noqa: PYI034 - returns the instance for context managers
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def __exit__(
|
|
110
|
+
self,
|
|
111
|
+
exc_type: type[BaseException] | None,
|
|
112
|
+
exc: BaseException | None,
|
|
113
|
+
traceback: TracebackType | None,
|
|
114
|
+
) -> None:
|
|
115
|
+
self.close()
|
|
116
|
+
|
|
117
|
+
def close(self) -> None:
|
|
118
|
+
self._http.close()
|
|
119
|
+
|
|
120
|
+
def score(self, text: str, *, id: str | None = None, context: str | None = None) -> ScoreResult:
|
|
121
|
+
payload = _drop_none({"text": text, "id": id, "context": context})
|
|
122
|
+
response = self._request("POST", "/v1/score", payload)
|
|
123
|
+
return ScoreResult.model_validate(response.json())
|
|
124
|
+
|
|
125
|
+
def score_batch(self, items: Sequence[Mapping[str, Any]]) -> list[BatchOutcome]:
|
|
126
|
+
"""Each item is a mapping with ``text`` and optionally ``id`` and ``context``."""
|
|
127
|
+
response = self._request("POST", "/v1/score/batch", {"items": [dict(item) for item in items]})
|
|
128
|
+
outcomes: list[BatchOutcome] = []
|
|
129
|
+
for entry in response.json()["results"]:
|
|
130
|
+
if entry.get("ok"):
|
|
131
|
+
outcomes.append(BatchOutcome(id=entry["result"].get("id"), result=ScoreResult.model_validate(entry["result"])))
|
|
132
|
+
else:
|
|
133
|
+
error = entry["error"]
|
|
134
|
+
outcomes.append(
|
|
135
|
+
BatchOutcome(id=entry.get("id"), error=ItemError(code=error["code"], message=error["message"]))
|
|
136
|
+
)
|
|
137
|
+
return outcomes
|
|
138
|
+
|
|
139
|
+
def health(self) -> bool:
|
|
140
|
+
return self._request("GET", "/healthz").is_success
|
|
141
|
+
|
|
142
|
+
def ready(self) -> ReadyResponse:
|
|
143
|
+
return ReadyResponse.model_validate(self._request("GET", "/readyz").json())
|
|
144
|
+
|
|
145
|
+
def info(self) -> InfoResponse:
|
|
146
|
+
return InfoResponse.model_validate(self._request("GET", "/v1/info").json())
|
|
147
|
+
|
|
148
|
+
def _request(self, method: str, path: str, json_body: Any = None) -> httpx.Response:
|
|
149
|
+
attempts = self._max_retries + 1
|
|
150
|
+
for attempt in range(attempts):
|
|
151
|
+
error: SpamkitError
|
|
152
|
+
try:
|
|
153
|
+
response = self._http.request(method, path, json=json_body)
|
|
154
|
+
except httpx.TimeoutException as exc:
|
|
155
|
+
error = SpamkitTimeoutError(str(type(exc).__name__))
|
|
156
|
+
except httpx.TransportError as exc:
|
|
157
|
+
error = SpamkitTransportError(str(type(exc).__name__))
|
|
158
|
+
else:
|
|
159
|
+
if response.is_success:
|
|
160
|
+
return response
|
|
161
|
+
error = _api_error(response)
|
|
162
|
+
if response.status_code not in RETRYABLE_STATUS:
|
|
163
|
+
raise error
|
|
164
|
+
if attempt == attempts - 1:
|
|
165
|
+
raise error
|
|
166
|
+
time.sleep(self._retry_delay(attempt))
|
|
167
|
+
raise AssertionError("unreachable") # the loop always returns or raises
|
|
168
|
+
|
|
169
|
+
def _retry_delay(self, attempt: int) -> float:
|
|
170
|
+
return self._retry_backoff * (2**attempt)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _drop_none(values: dict[str, Any]) -> dict[str, Any]:
|
|
174
|
+
return {key: value for key, value in values.items() if value is not None}
|
spamkit/errors.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Stable error codes shared by the server, the Python client, and the TypeScript client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ErrorCode(str, Enum):
|
|
9
|
+
INVALID_REQUEST = "invalid_request"
|
|
10
|
+
UNAUTHORIZED = "unauthorized"
|
|
11
|
+
INPUT_TOO_LARGE = "input_too_large"
|
|
12
|
+
UNSUPPORTED_INPUT = "unsupported_input"
|
|
13
|
+
RATE_LIMITED = "rate_limited"
|
|
14
|
+
MODEL_NOT_READY = "model_not_ready"
|
|
15
|
+
INTERNAL_ERROR = "internal_error"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
HTTP_STATUS: dict[ErrorCode, int] = {
|
|
19
|
+
ErrorCode.INVALID_REQUEST: 400,
|
|
20
|
+
ErrorCode.UNAUTHORIZED: 401,
|
|
21
|
+
ErrorCode.INPUT_TOO_LARGE: 413,
|
|
22
|
+
ErrorCode.UNSUPPORTED_INPUT: 422,
|
|
23
|
+
ErrorCode.RATE_LIMITED: 429,
|
|
24
|
+
ErrorCode.MODEL_NOT_READY: 503,
|
|
25
|
+
ErrorCode.INTERNAL_ERROR: 500,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApiError(Exception):
|
|
30
|
+
"""An error with a stable code. The message must never contain submitted text."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, code: ErrorCode, message: str, headers: dict[str, str] | None = None) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.code = code
|
|
35
|
+
self.message = message
|
|
36
|
+
self.headers = headers or {}
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def status(self) -> int:
|
|
40
|
+
return HTTP_STATUS[self.code]
|
spamkit/evaluation.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Offline evaluation on a user's own labeled file. Nothing here uploads data or retains text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
DEFAULT_SWEEP = tuple(round(step / 20, 2) for step in range(1, 20)) # 0.05 .. 0.95
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EvaluationInputError(ValueError):
|
|
16
|
+
"""Raised for malformed input. Messages name the row, never its text."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Sample:
|
|
21
|
+
row: int
|
|
22
|
+
text: str
|
|
23
|
+
label: str # "spam" or "clean"
|
|
24
|
+
id: str | None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Confusion:
|
|
29
|
+
tp: int
|
|
30
|
+
fp: int
|
|
31
|
+
tn: int
|
|
32
|
+
fn: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_samples(path: Path) -> list[Sample]:
|
|
36
|
+
suffix = path.suffix.lower()
|
|
37
|
+
if suffix == ".csv":
|
|
38
|
+
raw_rows = _read_csv(path)
|
|
39
|
+
elif suffix == ".jsonl":
|
|
40
|
+
raw_rows = _read_jsonl(path)
|
|
41
|
+
else:
|
|
42
|
+
raise EvaluationInputError("Input must be a .csv or .jsonl file.")
|
|
43
|
+
if not raw_rows:
|
|
44
|
+
raise EvaluationInputError("The file contains no data rows.")
|
|
45
|
+
return [_to_sample(row, number) for number, row in raw_rows]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_csv(path: Path) -> list[tuple[int, dict[str, Any]]]:
|
|
49
|
+
with path.open(newline="", encoding="utf-8") as handle:
|
|
50
|
+
reader = csv.DictReader(handle)
|
|
51
|
+
fields = {name.strip() for name in (reader.fieldnames or []) if name}
|
|
52
|
+
missing = {"text", "label"} - fields
|
|
53
|
+
if missing:
|
|
54
|
+
raise EvaluationInputError(f"CSV is missing required column(s): {', '.join(sorted(missing))}.")
|
|
55
|
+
# Row numbers are physical file lines, so they match what an editor shows.
|
|
56
|
+
rows = []
|
|
57
|
+
for row in reader:
|
|
58
|
+
rows.append((reader.line_num, row))
|
|
59
|
+
return rows
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_jsonl(path: Path) -> list[tuple[int, dict[str, Any]]]:
|
|
63
|
+
# Line numbers, not record counts: blank lines are skipped but still counted.
|
|
64
|
+
rows: list[tuple[int, dict[str, Any]]] = []
|
|
65
|
+
with path.open(encoding="utf-8") as handle:
|
|
66
|
+
for number, line in enumerate(handle, start=1):
|
|
67
|
+
if not line.strip():
|
|
68
|
+
continue
|
|
69
|
+
try:
|
|
70
|
+
value = json.loads(line)
|
|
71
|
+
except ValueError as exc:
|
|
72
|
+
raise EvaluationInputError(f"row {number}: line is not valid JSON.") from exc
|
|
73
|
+
if not isinstance(value, dict):
|
|
74
|
+
raise EvaluationInputError(f"row {number}: each line must be a JSON object.")
|
|
75
|
+
rows.append((number, value))
|
|
76
|
+
return rows
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _to_sample(row: dict[str, Any], number: int) -> Sample:
|
|
80
|
+
text = row.get("text")
|
|
81
|
+
if not isinstance(text, str) or not text.strip():
|
|
82
|
+
raise EvaluationInputError(f"row {number}: text is empty or missing.")
|
|
83
|
+
label = str(row.get("label", "")).strip().lower()
|
|
84
|
+
# "ham" is accepted because most public spam datasets use it. Internally the class is "clean".
|
|
85
|
+
label = "clean" if label == "ham" else label
|
|
86
|
+
if label not in {"spam", "clean"}:
|
|
87
|
+
raise EvaluationInputError(f"row {number}: label must be 'spam' or 'clean'.")
|
|
88
|
+
raw_id = row.get("id")
|
|
89
|
+
sample_id = str(raw_id).strip() if raw_id not in (None, "") else None
|
|
90
|
+
return Sample(row=number, text=text, label=label, id=sample_id)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def confusion_at(samples: Sequence[Sample], scores: Sequence[float], threshold: float) -> Confusion:
|
|
94
|
+
"""Predicted spam when score >= threshold. Review is not modeled here; see the sweep for trade-offs."""
|
|
95
|
+
tp = fp = tn = fn = 0
|
|
96
|
+
for sample, score in zip(samples, scores):
|
|
97
|
+
predicted_spam = score >= threshold
|
|
98
|
+
actual_spam = sample.label == "spam"
|
|
99
|
+
if predicted_spam and actual_spam:
|
|
100
|
+
tp += 1
|
|
101
|
+
elif predicted_spam:
|
|
102
|
+
fp += 1
|
|
103
|
+
elif actual_spam:
|
|
104
|
+
fn += 1
|
|
105
|
+
else:
|
|
106
|
+
tn += 1
|
|
107
|
+
return Confusion(tp=tp, fp=fp, tn=tn, fn=fn)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _ratio(numerator: int, denominator: int) -> float | None:
|
|
111
|
+
return None if denominator == 0 else numerator / denominator
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def metrics_for(matrix: Confusion) -> dict[str, float | None]:
|
|
115
|
+
precision = _ratio(matrix.tp, matrix.tp + matrix.fp)
|
|
116
|
+
recall = _ratio(matrix.tp, matrix.tp + matrix.fn)
|
|
117
|
+
f1 = None
|
|
118
|
+
if precision is not None and recall is not None and precision + recall > 0:
|
|
119
|
+
f1 = 2 * precision * recall / (precision + recall)
|
|
120
|
+
return {
|
|
121
|
+
"precision": precision,
|
|
122
|
+
"recall": recall,
|
|
123
|
+
"specificity": _ratio(matrix.tn, matrix.tn + matrix.fp),
|
|
124
|
+
"false_positive_rate": _ratio(matrix.fp, matrix.fp + matrix.tn),
|
|
125
|
+
"false_negative_rate": _ratio(matrix.fn, matrix.fn + matrix.tp),
|
|
126
|
+
"f1": f1,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def build_report(
|
|
131
|
+
samples: Sequence[Sample],
|
|
132
|
+
scores: Sequence[float],
|
|
133
|
+
*,
|
|
134
|
+
threshold: float,
|
|
135
|
+
sweep: Sequence[float] = DEFAULT_SWEEP,
|
|
136
|
+
model: str,
|
|
137
|
+
model_version: str,
|
|
138
|
+
software_version: str,
|
|
139
|
+
show_text: bool = False,
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
if len(samples) != len(scores):
|
|
142
|
+
raise ValueError("samples and scores must have the same length")
|
|
143
|
+
|
|
144
|
+
matrix = confusion_at(samples, scores, threshold)
|
|
145
|
+
spam_count = sum(1 for sample in samples if sample.label == "spam")
|
|
146
|
+
false_positives = []
|
|
147
|
+
false_negatives = []
|
|
148
|
+
for sample, score in zip(samples, scores):
|
|
149
|
+
predicted_spam = score >= threshold
|
|
150
|
+
if predicted_spam and sample.label == "clean":
|
|
151
|
+
false_positives.append(_error_row(sample, score, show_text))
|
|
152
|
+
elif not predicted_spam and sample.label == "spam":
|
|
153
|
+
false_negatives.append(_error_row(sample, score, show_text))
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"dataset": {
|
|
157
|
+
"count": len(samples),
|
|
158
|
+
"spam": spam_count,
|
|
159
|
+
"clean": len(samples) - spam_count,
|
|
160
|
+
"note": "Metrics describe only the supplied dataset.",
|
|
161
|
+
},
|
|
162
|
+
"model": {"id": model, "version": model_version},
|
|
163
|
+
"software_version": software_version,
|
|
164
|
+
"threshold": threshold,
|
|
165
|
+
"confusion": {"tp": matrix.tp, "fp": matrix.fp, "tn": matrix.tn, "fn": matrix.fn},
|
|
166
|
+
"metrics": metrics_for(matrix),
|
|
167
|
+
"sweep": [
|
|
168
|
+
{"threshold": point, **metrics_for(confusion_at(samples, scores, point))}
|
|
169
|
+
for point in sweep
|
|
170
|
+
],
|
|
171
|
+
"false_positives": false_positives,
|
|
172
|
+
"false_negatives": false_negatives,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _error_row(sample: Sample, score: float, show_text: bool) -> dict[str, Any]:
|
|
177
|
+
entry: dict[str, Any] = {"row": sample.row, "id": sample.id, "score": score}
|
|
178
|
+
if show_text:
|
|
179
|
+
entry["text"] = sample.text
|
|
180
|
+
return entry
|
spamkit/observability.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Structured logs and metrics. Neither ever receives submitted text or caller IDs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections import Counter
|
|
10
|
+
|
|
11
|
+
from spamkit.verdict import Verdict
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("spamkit")
|
|
14
|
+
|
|
15
|
+
LATENCY_BUCKETS_MS = (5, 10, 25, 50, 100, 250, 500, 1000, 2500)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def log_event(event: str, **fields: object) -> None:
|
|
19
|
+
record = {"event": event, "ts": round(time.time(), 3), **fields}
|
|
20
|
+
logger.info(json.dumps(record, sort_keys=True))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def score_band(score: float) -> str:
|
|
24
|
+
"""Coarse bucket for logs. Exact scores are kept out of logs to limit fingerprinting."""
|
|
25
|
+
low = min(int(score * 10), 9)
|
|
26
|
+
return f"{low / 10:.1f}-{(low + 1) / 10:.1f}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Metrics:
|
|
30
|
+
"""Process-local counters and a latency histogram, rendered in Prometheus text format."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
self._requests: Counter[tuple[str, int]] = Counter()
|
|
35
|
+
self._verdicts: Counter[str] = Counter()
|
|
36
|
+
self._errors: Counter[str] = Counter()
|
|
37
|
+
self._latency_bucket_counts = [0] * (len(LATENCY_BUCKETS_MS) + 1)
|
|
38
|
+
self._latency_sum_ms = 0.0
|
|
39
|
+
self._latency_count = 0
|
|
40
|
+
|
|
41
|
+
def record_request(self, route: str, status: int) -> None:
|
|
42
|
+
with self._lock:
|
|
43
|
+
self._requests[(route, status)] += 1
|
|
44
|
+
|
|
45
|
+
def record_verdict(self, verdict: Verdict) -> None:
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._verdicts[verdict.value] += 1
|
|
48
|
+
|
|
49
|
+
def record_error(self, code: str) -> None:
|
|
50
|
+
with self._lock:
|
|
51
|
+
self._errors[code] += 1
|
|
52
|
+
|
|
53
|
+
def record_latency(self, milliseconds: float) -> None:
|
|
54
|
+
index = next(
|
|
55
|
+
(i for i, bound in enumerate(LATENCY_BUCKETS_MS) if milliseconds <= bound),
|
|
56
|
+
len(LATENCY_BUCKETS_MS),
|
|
57
|
+
)
|
|
58
|
+
with self._lock:
|
|
59
|
+
self._latency_bucket_counts[index] += 1
|
|
60
|
+
self._latency_sum_ms += milliseconds
|
|
61
|
+
self._latency_count += 1
|
|
62
|
+
|
|
63
|
+
def render(self) -> str:
|
|
64
|
+
lines: list[str] = []
|
|
65
|
+
with self._lock:
|
|
66
|
+
lines.append("# TYPE spamkit_requests_total counter")
|
|
67
|
+
for (route, status), count in sorted(self._requests.items()):
|
|
68
|
+
lines.append(f'spamkit_requests_total{{route="{route}",status="{status}"}} {count}')
|
|
69
|
+
lines.append("# TYPE spamkit_verdicts_total counter")
|
|
70
|
+
for verdict in Verdict:
|
|
71
|
+
lines.append(
|
|
72
|
+
f'spamkit_verdicts_total{{verdict="{verdict.value}"}} {self._verdicts[verdict.value]}'
|
|
73
|
+
)
|
|
74
|
+
lines.append("# TYPE spamkit_errors_total counter")
|
|
75
|
+
for code, count in sorted(self._errors.items()):
|
|
76
|
+
lines.append(f'spamkit_errors_total{{code="{code}"}} {count}')
|
|
77
|
+
lines.append("# TYPE spamkit_score_latency_ms histogram")
|
|
78
|
+
cumulative = 0
|
|
79
|
+
for bound, count in zip(LATENCY_BUCKETS_MS, self._latency_bucket_counts):
|
|
80
|
+
cumulative += count
|
|
81
|
+
lines.append(f'spamkit_score_latency_ms_bucket{{le="{bound}"}} {cumulative}')
|
|
82
|
+
cumulative += self._latency_bucket_counts[-1]
|
|
83
|
+
lines.append(f'spamkit_score_latency_ms_bucket{{le="+Inf"}} {cumulative}')
|
|
84
|
+
lines.append(f"spamkit_score_latency_ms_sum {self._latency_sum_ms:.3f}")
|
|
85
|
+
lines.append(f"spamkit_score_latency_ms_count {self._latency_count}")
|
|
86
|
+
return "\n".join(lines) + "\n"
|
spamkit/schemas.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Wire types for the v1 API, plus request validation that never echoes submitted text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from spamkit.errors import ApiError, ErrorCode
|
|
11
|
+
from spamkit.verdict import Verdict
|
|
12
|
+
|
|
13
|
+
API_VERSION = "v1"
|
|
14
|
+
MAX_ID_CHARS = 256
|
|
15
|
+
MAX_CONTEXT_CHARS = 64
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ScoreResult(BaseModel):
|
|
19
|
+
id: str | None
|
|
20
|
+
score: float
|
|
21
|
+
verdict: Verdict
|
|
22
|
+
model: str
|
|
23
|
+
model_version: str
|
|
24
|
+
latency_ms: int
|
|
25
|
+
truncated: bool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ErrorBody(BaseModel):
|
|
29
|
+
code: ErrorCode
|
|
30
|
+
message: str
|
|
31
|
+
request_id: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ErrorResponse(BaseModel):
|
|
35
|
+
error: ErrorBody
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BatchItemOk(BaseModel):
|
|
39
|
+
ok: Literal[True] = True
|
|
40
|
+
result: ScoreResult
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BatchItemError(BaseModel):
|
|
44
|
+
ok: Literal[False] = False
|
|
45
|
+
id: str | None
|
|
46
|
+
error: ErrorBody
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BatchResponse(BaseModel):
|
|
50
|
+
results: list[BatchItemOk | BatchItemError]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HealthResponse(BaseModel):
|
|
54
|
+
status: Literal["ok"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ReadyResponse(BaseModel):
|
|
58
|
+
status: Literal["ready"]
|
|
59
|
+
model: str
|
|
60
|
+
model_version: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class InfoResponse(BaseModel):
|
|
64
|
+
api_version: str
|
|
65
|
+
model: str
|
|
66
|
+
model_version: str | None
|
|
67
|
+
max_tokens: int
|
|
68
|
+
max_text_bytes: int
|
|
69
|
+
max_batch_items: int
|
|
70
|
+
thresholds: dict[str, float]
|
|
71
|
+
threshold_config_version: str
|
|
72
|
+
service_version: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class ScoreInput:
|
|
77
|
+
text: str
|
|
78
|
+
id: str | None
|
|
79
|
+
context: str | None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def require_utf8(value: str, field: str) -> str:
|
|
83
|
+
"""JSON allows lone surrogates such as \\ud800. They cannot be encoded as UTF-8, so reject them."""
|
|
84
|
+
try:
|
|
85
|
+
value.encode("utf-8")
|
|
86
|
+
except UnicodeEncodeError as exc:
|
|
87
|
+
raise ApiError(ErrorCode.UNSUPPORTED_INPUT, f"{field} must be valid UTF-8 without lone surrogates.") from exc
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _optional_short_string(value: Any, field: str, limit: int) -> str | None:
|
|
92
|
+
if value is None:
|
|
93
|
+
return None
|
|
94
|
+
if not isinstance(value, str):
|
|
95
|
+
raise ApiError(ErrorCode.INVALID_REQUEST, f"{field} must be a string when provided.")
|
|
96
|
+
if len(value) > limit:
|
|
97
|
+
raise ApiError(ErrorCode.INVALID_REQUEST, f"{field} must be at most {limit} characters.")
|
|
98
|
+
return require_utf8(value, field)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_score_input(payload: Any, max_text_bytes: int) -> ScoreInput:
|
|
102
|
+
if not isinstance(payload, dict):
|
|
103
|
+
raise ApiError(ErrorCode.INVALID_REQUEST, "Request body must be a JSON object.")
|
|
104
|
+
|
|
105
|
+
text = payload.get("text")
|
|
106
|
+
if not isinstance(text, str) or not text.strip():
|
|
107
|
+
raise ApiError(ErrorCode.INVALID_REQUEST, "text is required and must be a non-empty string.")
|
|
108
|
+
require_utf8(text, "text")
|
|
109
|
+
if len(text.encode("utf-8")) > max_text_bytes:
|
|
110
|
+
raise ApiError(
|
|
111
|
+
ErrorCode.INPUT_TOO_LARGE,
|
|
112
|
+
f"text must be at most {max_text_bytes} bytes of UTF-8.",
|
|
113
|
+
)
|
|
114
|
+
if "\x00" in text:
|
|
115
|
+
raise ApiError(ErrorCode.UNSUPPORTED_INPUT, "text must not contain NUL characters.")
|
|
116
|
+
|
|
117
|
+
return ScoreInput(
|
|
118
|
+
text=text,
|
|
119
|
+
id=_optional_short_string(payload.get("id"), "id", MAX_ID_CHARS),
|
|
120
|
+
context=_optional_short_string(payload.get("context"), "context", MAX_CONTEXT_CHARS),
|
|
121
|
+
)
|
spamkit/settings.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Runtime configuration, read from ``SPAMKIT_*`` environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from spamkit.verdict import Thresholds
|
|
11
|
+
|
|
12
|
+
DEFAULT_MODEL_DIR = Path("models/spamton")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _int(env: Mapping[str, str], name: str, default: int) -> int:
|
|
16
|
+
raw = env.get(name)
|
|
17
|
+
if raw is None or raw == "":
|
|
18
|
+
return default
|
|
19
|
+
try:
|
|
20
|
+
value = int(raw)
|
|
21
|
+
except ValueError as exc:
|
|
22
|
+
raise ValueError(f"{name} must be an integer") from exc
|
|
23
|
+
if value <= 0:
|
|
24
|
+
raise ValueError(f"{name} must be greater than zero")
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _float(env: Mapping[str, str], name: str, default: float) -> float:
|
|
29
|
+
raw = env.get(name)
|
|
30
|
+
if raw is None or raw == "":
|
|
31
|
+
return default
|
|
32
|
+
try:
|
|
33
|
+
return float(raw)
|
|
34
|
+
except ValueError as exc:
|
|
35
|
+
raise ValueError(f"{name} must be a number") from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Settings:
|
|
40
|
+
model_dir: Path = DEFAULT_MODEL_DIR
|
|
41
|
+
host: str = "127.0.0.1"
|
|
42
|
+
port: int = 8080
|
|
43
|
+
thresholds: Thresholds = field(default_factory=Thresholds)
|
|
44
|
+
max_text_bytes: int = 16_384
|
|
45
|
+
max_body_bytes: int = 1_048_576
|
|
46
|
+
max_batch_items: int = 100
|
|
47
|
+
max_inflight: int = 32
|
|
48
|
+
api_token: str | None = None
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_env(cls, env: Mapping[str, str] | None = None) -> Settings:
|
|
52
|
+
env = os.environ if env is None else env
|
|
53
|
+
token = env.get("SPAMKIT_API_TOKEN") or None
|
|
54
|
+
return cls(
|
|
55
|
+
model_dir=Path(env.get("SPAMKIT_MODEL_DIR") or DEFAULT_MODEL_DIR),
|
|
56
|
+
host=env.get("SPAMKIT_HOST") or "127.0.0.1",
|
|
57
|
+
port=_int(env, "SPAMKIT_PORT", 8080),
|
|
58
|
+
thresholds=Thresholds(
|
|
59
|
+
clean=_float(env, "SPAMKIT_CLEAN_THRESHOLD", 0.0),
|
|
60
|
+
spam=_float(env, "SPAMKIT_SPAM_THRESHOLD", 1.0),
|
|
61
|
+
),
|
|
62
|
+
max_text_bytes=_int(env, "SPAMKIT_MAX_TEXT_BYTES", 16_384),
|
|
63
|
+
max_body_bytes=_int(env, "SPAMKIT_MAX_BODY_BYTES", 1_048_576),
|
|
64
|
+
max_batch_items=_int(env, "SPAMKIT_MAX_BATCH_ITEMS", 100),
|
|
65
|
+
max_inflight=_int(env, "SPAMKIT_MAX_INFLIGHT", 32),
|
|
66
|
+
api_token=token,
|
|
67
|
+
)
|