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