File size: 6,822 Bytes
d82b7e3 cd07add d82b7e3 cd07add d82b7e3 cd07add d82b7e3 cd07add 6c516b6 3be79c6 6c516b6 3be79c6 6c516b6 3be79c6 cd07add 3be79c6 6c516b6 3be79c6 cd07add 3be79c6 cd07add 9a67bad cd07add 3be79c6 cd07add 3be79c6 6c516b6 cd07add 9a67bad cd07add 9a67bad cd07add d82b7e3 cd07add d82b7e3 cd07add d82b7e3 cd07add 7658255 cd07add 7658255 cd07add | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | """Inference runtime for the email-risk model (PyTorch / transformers).
Strictly optional: `load()` returning False (or `predict()` returning None)
means "no ML this run" — the deterministic pipeline continues unchanged.
Imports are lazy so the API boots with or without torch installed, and the test
suite never requires a model download.
The deployed Space image DOES contain CPU torch (Dockerfile installs it from
the PyTorch CPU index) and ships the bundled model at `app/ml/model`. Do not
strip either: the loader fails open, so removing them silently disables the
advisory layer instead of raising.
CPU inference is offloaded to a worker thread (`predict_async`, `load_async`)
because running it inline stalls every concurrent request — measured at 42.6s
cold start before this.
"""
from __future__ import annotations
import asyncio
import threading
from typing import Callable, Optional
from ..config import settings
def default_pipeline_factory(repo: str, batch_size: int):
"""Production pipeline builder: transformers (PyTorch).
The ONNX export produced confidently-wrong labels under two transformers
versions while the PyTorch checkpoint scored 0.999 held-out - so inference
runs on the trained weights directly. transformers is imported lazily here:
a module-level import can fail under some import contexts (importlib_metadata
'Version' bug on Python 3.14), which must degrade to "no ML" only when
inference is actually attempted, not kill the capability at import time.
The repo is a PUBLIC HF Hub download (free, unmetered); no token is used —
Part B removed every HF token from the deployment surface.
"""
import transformers
tokenizer = transformers.AutoTokenizer.from_pretrained(repo)
model = transformers.AutoModelForSequenceClassification.from_pretrained(repo)
return transformers.pipeline(
"text-classification",
model=model,
tokenizer=tokenizer,
device="cpu",
truncation=True,
max_length=64,
)
class EmailRiskModel:
"""Lazily-loaded ONNX text-classification wrapper. Fail-open by design."""
def __init__(
self,
repo: str,
batch_size: int = 64,
pipeline_factory: Optional[Callable[[str, int], object]] = None,
):
self.repo = repo
self.batch_size = batch_size
self._factory = pipeline_factory or default_pipeline_factory
self._pipeline = None
self._loaded = False
self.last_error: Optional[str] = None
self._lock = threading.Lock()
@property
def loaded(self) -> bool:
return self._loaded
def load(self) -> bool:
"""Load the model. Returns False on ANY failure (fail-open design)."""
with self._lock:
if self._loaded:
return True
try:
self._pipeline = self._factory(
self.repo, self.batch_size
)
self._loaded = True
return True
except Exception as exc:
self._pipeline = None
self._loaded = False
self.last_error = f"{type(exc).__name__}: {exc}"
return False
async def load_async(self) -> bool:
"""Load the model without blocking the event loop.
Loading torch + a checkpoint takes seconds; doing it inline in an async
handler stalls every concurrent request (measured: 42.6s cold start
before this).
"""
return await asyncio.to_thread(self.load)
def predict(self, texts: list[str]) -> Optional[list[tuple[str, float]]]:
"""Classify texts. Returns (LABEL, confidence) tuples, or None on failure.
The label is uppercased and guaranteed to be one of the training labels'
names only if the model provides it; unknown labels pass through as-is
for the caller to ignore.
"""
if not self._loaded or self._pipeline is None or not texts:
return None
try:
preds = self._pipeline(texts, batch_size=self.batch_size)
out: list[tuple[str, float]] = []
for batch in preds:
# transformers may return a flat list or one list per element.
if isinstance(batch, list):
preds_nested = batch
else:
preds_nested = [batch]
for p in preds_nested:
# An unexpected payload shape must fail open, not raise:
# the batch path calls predict() outside its own try.
if not isinstance(p, dict):
raise TypeError(f"unexpected prediction: {type(p).__name__}")
label = str(p.get("label", "")).upper()
try:
score = float(p.get("score", 0.0))
except (TypeError, ValueError):
score = 0.0
out.append((label, score))
return out if len(out) == len(texts) else None
except Exception as exc:
self.last_error = f"predict: {type(exc).__name__}: {exc}"
return None
_model: Optional[EmailRiskModel] = None
_model_lock = threading.Lock()
async def predict_async(model, texts: list[str]):
"""Classify texts without blocking the event loop.
Works with either an `EmailRiskModel` (CPU inference in a worker thread) or
any injected test double exposing a sync `predict` (called directly).
"""
if model is None or not texts:
return None
predict = getattr(model, "predict", None)
if predict is None:
return None
if isinstance(model, EmailRiskModel):
return await asyncio.to_thread(predict, texts)
return predict(texts)
async def get_model_async() -> Optional[EmailRiskModel]:
"""`get_model()` with the (slow) first load kept off the event loop."""
global _model
if not settings.ml_disposable_enabled or not settings.ml_model_repo:
return None
if _model is not None:
return _model
await asyncio.to_thread(get_model)
return _model
def get_model() -> Optional[EmailRiskModel]:
"""Process-wide model, built AND loaded when flag + repo are configured."""
global _model
if not settings.ml_disposable_enabled or not settings.ml_model_repo:
return None
with _model_lock:
if _model is None:
_model = EmailRiskModel(
repo=settings.ml_model_repo,
batch_size=settings.ml_batch_size,
)
# Load at construction: without this the model fail-opens to None
# on every predict and the feature is silently dead (found live).
_model.load()
return _model |