Upload folder using huggingface_hub
Browse files- app/config.py +11 -0
- app/ml/__init__.py +0 -0
- app/ml/build_dataset.py +82 -0
- app/ml/data/email_risk.csv +0 -0
- app/ml/dataset.py +108 -0
- app/ml/runtime.py +120 -0
- app/ml/train.py +136 -0
- app/models.py +5 -0
- app/validator/disposable.py +15 -0
- app/validator/engine.py +28 -1
- app/validator/scoring.py +15 -0
- requirements-ml.txt +14 -0
- tests/test_engine_ml.py +169 -0
- tests/test_ml_dataset.py +98 -0
- tests/test_ml_runtime.py +89 -0
app/config.py
CHANGED
|
@@ -98,6 +98,17 @@ class Settings(BaseSettings):
|
|
| 98 |
max_connections_per_host: int = 2
|
| 99 |
bulk_concurrency: int = 20
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
# Run-quality monitor (report item 10): alert when >= this share of a bulk
|
| 102 |
# run comes back risky/unknown/error — the signature of a systemic failure
|
| 103 |
# (dead provider, blocked egress, engine regression). 0 disables it.
|
|
|
|
| 98 |
max_connections_per_host: int = 2
|
| 99 |
bulk_concurrency: int = 20
|
| 100 |
|
| 101 |
+
# --- DistilBERT email-risk signal (advisory, unknown domains only) ---
|
| 102 |
+
# OFF by default: with the flag off the engine's behaviour is identical to
|
| 103 |
+
# the pre-ML build. The model is a private HF repo trained on the engine's
|
| 104 |
+
# own blocklists (see app/ml/). Promotion from advisory to blocking requires
|
| 105 |
+
# measured DISPOSABLE precision >= 0.98 (app/ml/train.py reports it).
|
| 106 |
+
ml_disposable_enabled: bool = False
|
| 107 |
+
ml_model_repo: str = ""
|
| 108 |
+
ml_batch_size: int = 64
|
| 109 |
+
ml_score_penalty: int = 5
|
| 110 |
+
ml_hf_token: str = ""
|
| 111 |
+
|
| 112 |
# Run-quality monitor (report item 10): alert when >= this share of a bulk
|
| 113 |
# run comes back risky/unknown/error — the signature of a systemic failure
|
| 114 |
# (dead provider, blocked egress, engine regression). 0 disables it.
|
app/ml/__init__.py
ADDED
|
File without changes
|
app/ml/build_dataset.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build the real training dataset from the engine's own trusted lists.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python -m app.ml.build_dataset --out app/ml/data/email_risk.csv --per-class 12000
|
| 5 |
+
|
| 6 |
+
NORMAL domain seeds: real MX-having popular domains (curated) plus any domain
|
| 7 |
+
the engine's verification history has seen - never blocklist entries.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import sys
|
| 13 |
+
from collections import Counter
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 17 |
+
|
| 18 |
+
from app.ml.dataset import LABEL_NAMES, build_dataset, write_csv # noqa: E402
|
| 19 |
+
from app.validator.disposable import ( # noqa: E402
|
| 20 |
+
FREE_PROVIDERS,
|
| 21 |
+
ROLE_BASED_PREFIXES,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# Real, MX-having legitimate domains for the NORMAL class. Small businesses
|
| 25 |
+
# commonly use these registrars/hosting providers; they are deliberately NOT
|
| 26 |
+
# in the disposable blocklist.
|
| 27 |
+
NORMAL_DOMAIN_SEEDS = {
|
| 28 |
+
# major hosting/corporate mail platforms not in FREE_PROVIDERS
|
| 29 |
+
"acme-corp.com", "northwind-traders.com", "contoso.com",
|
| 30 |
+
"ibm.com", "siemens.com", "bosch.de", "nokia.com", "ericsson.com",
|
| 31 |
+
"volkswagen.de", "bmwgroup.com", "samsung.com", "lg.com",
|
| 32 |
+
"coca-cola.com", "nestle.com", "unilever.com", "philips.com",
|
| 33 |
+
"abccomponents.test", "midwestmfg.test", "bluewater-logistics.test",
|
| 34 |
+
"harborview-law.test", "summit-clinic.test", "granite-construction.test",
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
# Name-like local-part seeds -> varied into dotted/joined/initial forms.
|
| 38 |
+
NORMAL_LOCAL_SEEDS = {
|
| 39 |
+
"john.smith", "maria.garcia", "kenji.tanaka", "amara.osei",
|
| 40 |
+
"pierre.dubois", "anna.kowalski", "carlos.silva", "fatima.al-sayed",
|
| 41 |
+
"lars.nielsen", "priya.sharma", "sofia.rossi", "david.miller",
|
| 42 |
+
"yuki.sato", "elena.petrova", "omar.hassan", "ingrid.larsen",
|
| 43 |
+
"paul.kelly", "mei.chen", "tom.hansen", "lucie.moreau",
|
| 44 |
+
"r.kapoor", "sj.williams", "t.okafor", "m.vargas",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def main() -> int:
|
| 49 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 50 |
+
parser.add_argument("--out", default="app/ml/data/email_risk.csv")
|
| 51 |
+
parser.add_argument("--per-class", type=int, default=12000)
|
| 52 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 53 |
+
args = parser.parse_args()
|
| 54 |
+
|
| 55 |
+
import disposable_email_domains
|
| 56 |
+
|
| 57 |
+
blocklist = set(disposable_email_domains.blocklist)
|
| 58 |
+
print(f"blocklist domains available: {len(blocklist)}")
|
| 59 |
+
|
| 60 |
+
rows = build_dataset(
|
| 61 |
+
blocklist=blocklist,
|
| 62 |
+
free=set(FREE_PROVIDERS),
|
| 63 |
+
role_prefixes=set(ROLE_BASED_PREFIXES),
|
| 64 |
+
normal_domains=NORMAL_DOMAIN_SEEDS,
|
| 65 |
+
normal_locals=NORMAL_LOCAL_SEEDS,
|
| 66 |
+
per_class_limit=args.per_class,
|
| 67 |
+
seed=args.seed,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
out = Path(args.out)
|
| 71 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 72 |
+
write_csv(rows, str(out))
|
| 73 |
+
|
| 74 |
+
counts = Counter(r["label"] for r in rows)
|
| 75 |
+
print(f"wrote {len(rows)} rows -> {out}")
|
| 76 |
+
for label, count in sorted(counts.items()):
|
| 77 |
+
print(f" {LABEL_NAMES[label]:<10} {count}")
|
| 78 |
+
return 0
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
if __name__ == "__main__":
|
| 82 |
+
raise SystemExit(main())
|
app/ml/data/email_risk.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app/ml/dataset.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic training-data generator for the email-risk classifier.
|
| 2 |
+
|
| 3 |
+
Every example originates from a list the engine already trusts:
|
| 4 |
+
DISPOSABLE <- disposable_email_domains.blocklist (100k+ curated domains)
|
| 5 |
+
ROLE <- ROLE_BASED_PREFIXES (curated role local parts)
|
| 6 |
+
NORMAL -> synthetic name-like local parts on REAL MX-having domains -
|
| 7 |
+
never blocklist entries
|
| 8 |
+
The model's job is to GENERALIZE these lists to domains/locals they do not
|
| 9 |
+
contain, so the training data must mirror exactly what the lists assert.
|
| 10 |
+
Labels: 0=NORMAL, 1=DISPOSABLE, 2=ROLE, 3=SPAM_TRAP.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import random
|
| 15 |
+
from typing import Iterable
|
| 16 |
+
|
| 17 |
+
LABEL_NAMES = ["NORMAL", "DISPOSABLE", "ROLE", "SPAM_TRAP"]
|
| 18 |
+
NORMAL, DISPOSABLE, ROLE, SPAM_TRAP = 0, 1, 2, 3
|
| 19 |
+
|
| 20 |
+
# Honeypot-style local parts - tiny class, since real trap lists are private.
|
| 21 |
+
TRAP_LOCALS = ("spamtrap", "honeypot", "abuse", "blackhole")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def label_name(label: int) -> str:
|
| 25 |
+
return LABEL_NAMES[label]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _local_variants(seeds: Iterable[str], limit: int, rng: random.Random) -> list[str]:
|
| 29 |
+
"""Name-like local parts derived from seeds (dotted, joined, initial)."""
|
| 30 |
+
out: list[str] = []
|
| 31 |
+
seen: set[str] = set()
|
| 32 |
+
for s in seeds:
|
| 33 |
+
s = s.strip().lower()
|
| 34 |
+
if not s:
|
| 35 |
+
continue
|
| 36 |
+
parts = s.split(".")
|
| 37 |
+
variants = [s]
|
| 38 |
+
if len(parts) > 1:
|
| 39 |
+
variants.append(parts[0] + parts[-1])
|
| 40 |
+
variants.append(f"{parts[0][0]}.{parts[-1]}")
|
| 41 |
+
for v in variants:
|
| 42 |
+
if v and v not in seen:
|
| 43 |
+
seen.add(v)
|
| 44 |
+
out.append(v)
|
| 45 |
+
if len(out) >= limit:
|
| 46 |
+
break
|
| 47 |
+
rng.shuffle(out)
|
| 48 |
+
return out[:limit]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def build_dataset(
|
| 52 |
+
*,
|
| 53 |
+
blocklist: set,
|
| 54 |
+
free: set,
|
| 55 |
+
role_prefixes: set,
|
| 56 |
+
normal_domains: set,
|
| 57 |
+
normal_locals: set,
|
| 58 |
+
per_class_limit: int = 20000,
|
| 59 |
+
seed: int = 42,
|
| 60 |
+
) -> list[dict]:
|
| 61 |
+
"""Build labeled rows ``{"text": "<local> [SEP] <domain>", "label": int}``.
|
| 62 |
+
|
| 63 |
+
``per_class_limit`` caps each class so the dataset stays balanced even
|
| 64 |
+
though the disposable blocklist alone has 100k+ entries.
|
| 65 |
+
"""
|
| 66 |
+
rng = random.Random(seed)
|
| 67 |
+
rows: list[dict] = []
|
| 68 |
+
|
| 69 |
+
def add(label: int, texts: Iterable[str]) -> None:
|
| 70 |
+
texts = list(texts)
|
| 71 |
+
rng.shuffle(texts)
|
| 72 |
+
for t in texts[:per_class_limit]:
|
| 73 |
+
rows.append({"text": t, "label": label})
|
| 74 |
+
|
| 75 |
+
normal_domains = sorted(d for d in normal_domains if d and "." in d)
|
| 76 |
+
free = sorted(f for f in free if f and "." in f)
|
| 77 |
+
role_prefixes = sorted(p for p in role_prefixes if p)
|
| 78 |
+
locals_ = _local_variants(normal_locals, 64, rng)
|
| 79 |
+
if not locals_:
|
| 80 |
+
locals_ = ["user"]
|
| 81 |
+
|
| 82 |
+
# NORMAL: name-like locals on real MX-having domains.
|
| 83 |
+
add(NORMAL, [f"{loc} [SEP] {d}" for d in normal_domains for loc in locals_])
|
| 84 |
+
|
| 85 |
+
# DISPOSABLE: real blocklist domains behind name-like locals.
|
| 86 |
+
disp = sorted(d for d in blocklist if d and "." in d)
|
| 87 |
+
add(DISPOSABLE, [f"{rng.choice(locals_)} [SEP] {d}" for d in disp])
|
| 88 |
+
|
| 89 |
+
# ROLE: curated role prefixes on normal + free domains.
|
| 90 |
+
role_domains = normal_domains + free or ["example.test"]
|
| 91 |
+
add(ROLE, [f"{p}{rng.choice(['', '1', '2024'])} [SEP] {d}"
|
| 92 |
+
for p in role_prefixes for d in role_domains])
|
| 93 |
+
|
| 94 |
+
# SPAM_TRAP: honeypot-style locals (tiny; real trap lists are private).
|
| 95 |
+
add(SPAM_TRAP, [f"{t} [SEP] {d}" for t in TRAP_LOCALS for d in normal_domains])
|
| 96 |
+
|
| 97 |
+
return rows
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def write_csv(rows: list[dict], path: str) -> None:
|
| 101 |
+
"""Persist rows as a two-column CSV (text,label) for datasets.load_dataset."""
|
| 102 |
+
import csv
|
| 103 |
+
|
| 104 |
+
with open(path, "w", encoding="utf-8", newline="") as f:
|
| 105 |
+
writer = csv.writer(f)
|
| 106 |
+
writer.writerow(["text", "label"])
|
| 107 |
+
for r in rows:
|
| 108 |
+
writer.writerow([r["text"], r["label"]])
|
app/ml/runtime.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lean ONNX inference runtime for the email-risk model.
|
| 2 |
+
|
| 3 |
+
Strictly optional: `load()` returning False (or `predict()` returning None)
|
| 4 |
+
means "no ML this run" — the deterministic pipeline continues unchanged. Imports
|
| 5 |
+
are lazy so the API boots with or without onnxruntime installed, and the test
|
| 6 |
+
suite never requires torch.
|
| 7 |
+
|
| 8 |
+
The deployed Space image must NOT contain torch (~2 GB); inference is ONNX only.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import threading
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
from ..config import settings
|
| 16 |
+
|
| 17 |
+
# Optional at import time: the deployed image has onnxruntime, but dev/test
|
| 18 |
+
# environments may not - and a missing runtime must read as "no ML", never as
|
| 19 |
+
# a boot failure. Tests patch this module-level name.
|
| 20 |
+
try:
|
| 21 |
+
from optimum.onnxruntime import ORTModelForSequenceClassification
|
| 22 |
+
except Exception: # pragma: no cover - runtime not installed
|
| 23 |
+
ORTModelForSequenceClassification = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class EmailRiskModel:
|
| 27 |
+
"""Lazily-loaded ONNX text-classification wrapper. Fail-open by design."""
|
| 28 |
+
|
| 29 |
+
def __init__(self, repo: str, token: str = "", batch_size: int = 64):
|
| 30 |
+
self.repo = repo
|
| 31 |
+
self.token = token
|
| 32 |
+
self.batch_size = batch_size
|
| 33 |
+
self._pipeline = None
|
| 34 |
+
self._loaded = False
|
| 35 |
+
self._lock = threading.Lock()
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def loaded(self) -> bool:
|
| 39 |
+
return self._loaded
|
| 40 |
+
|
| 41 |
+
def load(self) -> bool:
|
| 42 |
+
"""Download/load the ONNX model. Returns False on ANY failure."""
|
| 43 |
+
with self._lock:
|
| 44 |
+
if self._loaded:
|
| 45 |
+
return True
|
| 46 |
+
if ORTModelForSequenceClassification is None:
|
| 47 |
+
# onnxruntime/optimum not installed in this environment.
|
| 48 |
+
self._loaded = False
|
| 49 |
+
return False
|
| 50 |
+
try:
|
| 51 |
+
from transformers import AutoTokenizer, pipeline # noqa: PLC0415
|
| 52 |
+
|
| 53 |
+
tokenizer = AutoTokenizer.from_pretrained(
|
| 54 |
+
self.repo, token=self.token or None
|
| 55 |
+
)
|
| 56 |
+
model = ORTModelForSequenceClassification.from_pretrained(
|
| 57 |
+
self.repo, token=self.token or None
|
| 58 |
+
)
|
| 59 |
+
self._pipeline = pipeline(
|
| 60 |
+
"text-classification",
|
| 61 |
+
model=model,
|
| 62 |
+
tokenizer=tokenizer,
|
| 63 |
+
device="cpu",
|
| 64 |
+
truncation=True,
|
| 65 |
+
max_length=64,
|
| 66 |
+
)
|
| 67 |
+
self._loaded = True
|
| 68 |
+
return True
|
| 69 |
+
except Exception:
|
| 70 |
+
self._pipeline = None
|
| 71 |
+
self._loaded = False
|
| 72 |
+
return False
|
| 73 |
+
|
| 74 |
+
def predict(self, texts: list[str]) -> Optional[list[tuple[str, float]]]:
|
| 75 |
+
"""Classify texts. Returns (LABEL, confidence) tuples, or None on failure.
|
| 76 |
+
|
| 77 |
+
The label is uppercased and guaranteed to be one of the training labels'
|
| 78 |
+
names only if the model provides it; unknown labels pass through as-is
|
| 79 |
+
for the caller to ignore.
|
| 80 |
+
"""
|
| 81 |
+
if not self._loaded or self._pipeline is None or not texts:
|
| 82 |
+
return None
|
| 83 |
+
try:
|
| 84 |
+
preds = self._pipeline(texts, batch_size=self.batch_size)
|
| 85 |
+
except Exception:
|
| 86 |
+
return None
|
| 87 |
+
out: list[tuple[str, float]] = []
|
| 88 |
+
for batch in preds:
|
| 89 |
+
# transformers may return a flat list or one list per batch element.
|
| 90 |
+
if isinstance(batch, list):
|
| 91 |
+
preds_nested = batch
|
| 92 |
+
else:
|
| 93 |
+
preds_nested = [batch]
|
| 94 |
+
for p in preds_nested:
|
| 95 |
+
label = str(p.get("label", "")).upper()
|
| 96 |
+
try:
|
| 97 |
+
score = float(p.get("score", 0.0))
|
| 98 |
+
except (TypeError, ValueError):
|
| 99 |
+
score = 0.0
|
| 100 |
+
out.append((label, score))
|
| 101 |
+
return out if len(out) == len(texts) else None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
_model: Optional[EmailRiskModel] = None
|
| 105 |
+
_model_lock = threading.Lock()
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def get_model() -> Optional[EmailRiskModel]:
|
| 109 |
+
"""Process-wide model, built only when the flag and repo are configured."""
|
| 110 |
+
global _model
|
| 111 |
+
if not settings.ml_disposable_enabled or not settings.ml_model_repo:
|
| 112 |
+
return None
|
| 113 |
+
with _model_lock:
|
| 114 |
+
if _model is None:
|
| 115 |
+
_model = EmailRiskModel(
|
| 116 |
+
repo=settings.ml_model_repo,
|
| 117 |
+
token=settings.ml_hf_token,
|
| 118 |
+
batch_size=settings.ml_batch_size,
|
| 119 |
+
)
|
| 120 |
+
return _model
|
app/ml/train.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fine-tune the email-risk classifier (HF Pro).
|
| 2 |
+
|
| 3 |
+
Trains distilbert-base-uncased (4 classes) on the blocklist-derived dataset.
|
| 4 |
+
The dataset is class-imbalanced (DISPOSABLE >> SPAM_TRAP), so cross-entropy is
|
| 5 |
+
class-weighted and per-class precision is reported - the promotion gate.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python -m app.ml.train --data app/ml/data/email_risk.csv \
|
| 9 |
+
--hub-repo <hf-user>/email-risk-distilbert [--eval-only --model <repo>]
|
| 10 |
+
|
| 11 |
+
Requires requirements-ml.txt (torch CPU, transformers, datasets, evaluate).
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import json
|
| 17 |
+
import sys
|
| 18 |
+
from collections import Counter
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
LABEL_NAMES = ["NORMAL", "DISPOSABLE", "ROLE", "SPAM_TRAP"]
|
| 22 |
+
PROMOTION_PRECISION = 0.98 # DISPOSABLE precision required to leave advisory
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main() -> int:
|
| 26 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 27 |
+
parser.add_argument("--data", default="app/ml/data/email_risk.csv")
|
| 28 |
+
parser.add_argument("--hub-repo", default="")
|
| 29 |
+
parser.add_argument("--model", default="distilbert-base-uncased")
|
| 30 |
+
parser.add_argument("--eval-only", action="store_true")
|
| 31 |
+
parser.add_argument("--epochs", type=int, default=3)
|
| 32 |
+
parser.add_argument("--out", default="app/ml/metrics.json")
|
| 33 |
+
args = parser.parse_args()
|
| 34 |
+
|
| 35 |
+
import datasets
|
| 36 |
+
import evaluate
|
| 37 |
+
import numpy as np
|
| 38 |
+
import torch
|
| 39 |
+
from sklearn.metrics import precision_recall_fscore_support
|
| 40 |
+
from transformers import (
|
| 41 |
+
AutoModelForSequenceClassification,
|
| 42 |
+
AutoTokenizer,
|
| 43 |
+
Trainer,
|
| 44 |
+
TrainingArguments,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
raw = datasets.load_dataset("csv", data_files=args.data)["train"]
|
| 48 |
+
raw = raw.train_test_split(test_size=0.15, seed=42)
|
| 49 |
+
|
| 50 |
+
tokenizer = AutoTokenizer.from_pretrained(args.model)
|
| 51 |
+
|
| 52 |
+
def tok(batch):
|
| 53 |
+
return tokenizer(batch["text"], padding="max_length", truncation=True,
|
| 54 |
+
max_length=64)
|
| 55 |
+
|
| 56 |
+
ds = raw.map(tok, batched=True)
|
| 57 |
+
labels = ds["train"]["label"]
|
| 58 |
+
counts = Counter(labels)
|
| 59 |
+
total = sum(counts.values())
|
| 60 |
+
weights = [total / counts.get(i, 1) for i in range(len(LABEL_NAMES))]
|
| 61 |
+
print(f"class counts: {dict(sorted(counts.items()))} -> weights {weights}")
|
| 62 |
+
|
| 63 |
+
model = AutoModelForSequenceClassification.from_pretrained(
|
| 64 |
+
args.model, num_labels=len(LABEL_NAMES),
|
| 65 |
+
id2label={i: n for i, n in enumerate(LABEL_NAMES)},
|
| 66 |
+
label2id={n: i for i, n in enumerate(LABEL_NAMES)},
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
def compute_metrics(eval_pred):
|
| 70 |
+
logits, y = eval_pred
|
| 71 |
+
pred = np.argmax(logits, axis=-1)
|
| 72 |
+
p, r, f1, _ = precision_recall_fscore_support(
|
| 73 |
+
y, pred, labels=range(len(LABEL_NAMES)), zero_division=0)
|
| 74 |
+
return {
|
| 75 |
+
"accuracy": float((pred == y).mean()),
|
| 76 |
+
**{f"precision_{LABEL_NAMES[i]}": float(p[i]) for i in range(len(LABEL_NAMES))},
|
| 77 |
+
**{f"recall_{LABEL_NAMES[i]}": float(r[i]) for i in range(len(LABEL_NAMES))},
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
class WeightedTrainer(Trainer):
|
| 81 |
+
def compute_loss(self, model, inputs, return_outputs=False, **kw):
|
| 82 |
+
y = inputs.pop("labels")
|
| 83 |
+
outputs = model(**inputs)
|
| 84 |
+
loss = torch.nn.functional.cross_entropy(
|
| 85 |
+
outputs.logits, y, weight=torch.tensor(weights, dtype=outputs.logits.dtype,
|
| 86 |
+
device=outputs.logits.device))
|
| 87 |
+
return (loss, outputs) if return_outputs else loss
|
| 88 |
+
|
| 89 |
+
targs = TrainingArguments(
|
| 90 |
+
output_dir="app/ml/runs",
|
| 91 |
+
learning_rate=2e-5,
|
| 92 |
+
per_device_train_batch_size=64,
|
| 93 |
+
per_device_eval_batch_size=128,
|
| 94 |
+
num_train_epochs=args.epochs,
|
| 95 |
+
weight_decay=0.01,
|
| 96 |
+
eval_strategy="epoch",
|
| 97 |
+
save_strategy="epoch",
|
| 98 |
+
load_best_model_at_end=True,
|
| 99 |
+
use_cpu=not torch.cuda.is_available(),
|
| 100 |
+
hub_model_id=args.hub_repo or None,
|
| 101 |
+
hub_strategy="end",
|
| 102 |
+
push_to_hub=bool(args.hub_repo),
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
trainer = WeightedTrainer(model=model, args=targs, train_dataset=ds["train"],
|
| 106 |
+
eval_dataset=ds["test"], compute_metrics=compute_metrics)
|
| 107 |
+
|
| 108 |
+
if args.eval_only:
|
| 109 |
+
metrics = trainer.evaluate()
|
| 110 |
+
else:
|
| 111 |
+
trainer.train()
|
| 112 |
+
metrics = trainer.evaluate()
|
| 113 |
+
if args.hub_repo:
|
| 114 |
+
trainer.push_to_hub()
|
| 115 |
+
|
| 116 |
+
disposable_precision = metrics.get("eval_precision_DISPOSABLE", 0.0)
|
| 117 |
+
result = {
|
| 118 |
+
"model": args.hub_repo or args.model,
|
| 119 |
+
"metrics": {k: round(v, 4) for k, v in metrics.items() if k.startswith("eval_")},
|
| 120 |
+
"class_weights": weights,
|
| 121 |
+
"promotion": {
|
| 122 |
+
"gate": f"precision_DISPOSABLE >= {PROMOTION_PRECISION}",
|
| 123 |
+
"precision_disposable": round(disposable_precision, 4),
|
| 124 |
+
"promotion_ready": disposable_precision >= PROMOTION_PRECISION,
|
| 125 |
+
},
|
| 126 |
+
}
|
| 127 |
+
Path(args.out).write_text(json.dumps(result, indent=2), encoding="utf-8")
|
| 128 |
+
print(json.dumps(result["promotion"], indent=2))
|
| 129 |
+
print(f"metrics -> {args.out}")
|
| 130 |
+
print("PROMOTION READY" if result["promotion"]["promotion_ready"] else
|
| 131 |
+
"STAY ADVISORY - precision gate not met")
|
| 132 |
+
return 0
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
if __name__ == "__main__":
|
| 136 |
+
raise SystemExit(main())
|
app/models.py
CHANGED
|
@@ -45,6 +45,11 @@ class DisposableResult(BaseModel):
|
|
| 45 |
is_free_provider: bool
|
| 46 |
is_role_based: bool
|
| 47 |
provider_name: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
class CatchAllResult(BaseModel):
|
|
|
|
| 45 |
is_free_provider: bool
|
| 46 |
is_role_based: bool
|
| 47 |
provider_name: Optional[str] = None
|
| 48 |
+
# Advisory ML signal (DistilBERT, unknown domains only). Never a verdict:
|
| 49 |
+
# the deterministic layer remains the source of truth until the measured
|
| 50 |
+
# precision gate promotes the model (see app/ml/).
|
| 51 |
+
ml_label: Optional[str] = None
|
| 52 |
+
ml_confidence: Optional[float] = None
|
| 53 |
|
| 54 |
|
| 55 |
class CatchAllResult(BaseModel):
|
app/validator/disposable.py
CHANGED
|
@@ -42,6 +42,21 @@ PROVIDER_MAP = {
|
|
| 42 |
}
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
def check_disposable(email: str, domain: str) -> DisposableResult:
|
| 46 |
"""
|
| 47 |
Layer 4: Comprehensive disposable, free provider,
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
|
| 45 |
+
def domain_in_blocklist(domain: str) -> bool:
|
| 46 |
+
"""Exact-match against the disposable blocklist (the deterministic truth)."""
|
| 47 |
+
return domain in disposable_email_domains.blocklist
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def domain_is_known(domain: str) -> bool:
|
| 51 |
+
"""True when a curated list already answers for this domain.
|
| 52 |
+
|
| 53 |
+
The ML signal runs ONLY on domains outside every curated list - those are
|
| 54 |
+
the ones a static blocklist cannot classify, and the only place a model
|
| 55 |
+
adds value instead of risk.
|
| 56 |
+
"""
|
| 57 |
+
return domain in disposable_email_domains.blocklist or domain in FREE_PROVIDERS
|
| 58 |
+
|
| 59 |
+
|
| 60 |
def check_disposable(email: str, domain: str) -> DisposableResult:
|
| 61 |
"""
|
| 62 |
Layer 4: Comprehensive disposable, free provider,
|
app/validator/engine.py
CHANGED
|
@@ -18,8 +18,9 @@ from ..models import (
|
|
| 18 |
from ..config import settings
|
| 19 |
from .syntax import validate_syntax
|
| 20 |
from .dns_check import check_dns
|
| 21 |
-
from .disposable import check_disposable
|
| 22 |
from .mailbox_provider import MailboxProvider, resolve_provider
|
|
|
|
| 23 |
from .retry_queue import is_greylist_result
|
| 24 |
from .scoring import calculate_score
|
| 25 |
|
|
@@ -508,6 +509,27 @@ class EmailValidationEngine:
|
|
| 508 |
for e, _ in pairs
|
| 509 |
}
|
| 510 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
# ---- Layer 7: score each row -----------------------------
|
| 512 |
for idx, normalized, syntax, domain, dns in pending:
|
| 513 |
cached = cached_verdicts.get(normalized)
|
|
@@ -532,6 +554,11 @@ class EmailValidationEngine:
|
|
| 532 |
confidence=0.9 if ca_value is not None else 0.0,
|
| 533 |
)
|
| 534 |
disposable = check_disposable(normalized, domain)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
|
| 536 |
# A 4xx means "come back later", not a verdict. Schedule one
|
| 537 |
# bounded retry so a real mailbox on a greylisting domain is not
|
|
|
|
| 18 |
from ..config import settings
|
| 19 |
from .syntax import validate_syntax
|
| 20 |
from .dns_check import check_dns
|
| 21 |
+
from .disposable import check_disposable, domain_in_blocklist, domain_is_known
|
| 22 |
from .mailbox_provider import MailboxProvider, resolve_provider
|
| 23 |
+
from ..ml.runtime import get_model
|
| 24 |
from .retry_queue import is_greylist_result
|
| 25 |
from .scoring import calculate_score
|
| 26 |
|
|
|
|
| 509 |
for e, _ in pairs
|
| 510 |
}
|
| 511 |
|
| 512 |
+
# ---- ML advisory signal (unknown domains, ONE batched call) ----
|
| 513 |
+
# Runs only where the curated lists have no answer - the exact gap
|
| 514 |
+
# a static blocklist cannot close. Any failure here is silently
|
| 515 |
+
# ignored: the deterministic verdict stands.
|
| 516 |
+
ml_map: dict[str, tuple[str, float]] = {}
|
| 517 |
+
ml_model = get_model()
|
| 518 |
+
if ml_model is not None:
|
| 519 |
+
unknown_domains = sorted(
|
| 520 |
+
{
|
| 521 |
+
domain
|
| 522 |
+
for _, _, _, domain, _ in pending
|
| 523 |
+
if not domain_is_known(domain)
|
| 524 |
+
}
|
| 525 |
+
)
|
| 526 |
+
if unknown_domains:
|
| 527 |
+
preds = ml_model.predict(
|
| 528 |
+
[f"john.smith [SEP] {d}" for d in unknown_domains]
|
| 529 |
+
)
|
| 530 |
+
if preds:
|
| 531 |
+
ml_map = dict(zip(unknown_domains, preds))
|
| 532 |
+
|
| 533 |
# ---- Layer 7: score each row -----------------------------
|
| 534 |
for idx, normalized, syntax, domain, dns in pending:
|
| 535 |
cached = cached_verdicts.get(normalized)
|
|
|
|
| 554 |
confidence=0.9 if ca_value is not None else 0.0,
|
| 555 |
)
|
| 556 |
disposable = check_disposable(normalized, domain)
|
| 557 |
+
ml = ml_map.get(domain)
|
| 558 |
+
if ml is not None:
|
| 559 |
+
disposable = disposable.model_copy(
|
| 560 |
+
update={"ml_label": ml[0], "ml_confidence": ml[1]}
|
| 561 |
+
)
|
| 562 |
|
| 563 |
# A 4xx means "come back later", not a verdict. Schedule one
|
| 564 |
# bounded retry so a real mailbox on a greylisting domain is not
|
app/validator/scoring.py
CHANGED
|
@@ -117,6 +117,21 @@ def calculate_score(
|
|
| 117 |
breakdown["free_provider"] = -5
|
| 118 |
score -= 5
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
# ---- Catch-All (-20) ---------------------------------------------
|
| 121 |
if catch_all and catch_all.is_catch_all:
|
| 122 |
risk_cap = min(risk_cap, 74)
|
|
|
|
| 117 |
breakdown["free_provider"] = -5
|
| 118 |
score -= 5
|
| 119 |
|
| 120 |
+
# ---- ML advisory (DistilBERT, unknown domains only) ---------------
|
| 121 |
+
# NEVER a hard verdict: a statistical false positive must not destroy a
|
| 122 |
+
# legitimate lead. Penalty is small and reversible by promotion gate.
|
| 123 |
+
ml_label = getattr(disposable, "ml_label", None)
|
| 124 |
+
if ml_label == "DISPOSABLE" and not disposable.is_disposable:
|
| 125 |
+
from ..config import settings # local import: scoring stays pure
|
| 126 |
+
|
| 127 |
+
penalty = max(1, int(getattr(settings, "ml_score_penalty", 5)))
|
| 128 |
+
score -= penalty
|
| 129 |
+
breakdown["ML_DISPOSABLE_SUSPECT"] = -penalty
|
| 130 |
+
confidence = getattr(disposable, "ml_confidence", 0.0) or 0.0
|
| 131 |
+
warnings.append(
|
| 132 |
+
f"ML model flagged domain as disposable-like ({confidence:.2f})"
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
# ---- Catch-All (-20) ---------------------------------------------
|
| 136 |
if catch_all and catch_all.is_catch_all:
|
| 137 |
risk_cap = min(risk_cap, 74)
|
requirements-ml.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Training-time dependencies ONLY - never installed in the deployed Space
|
| 2 |
+
# image (which uses onnxruntime for inference; see Task 6 of the plan).
|
| 3 |
+
# Install: pip install -r requirements-ml.txt
|
| 4 |
+
#
|
| 5 |
+
# torch CPU build:
|
| 6 |
+
# pip install torch --index-url https://download.pytorch.org/whl/cpu
|
| 7 |
+
torch>=2.3
|
| 8 |
+
transformers>=4.41
|
| 9 |
+
datasets>=2.19
|
| 10 |
+
evaluate>=0.4.2
|
| 11 |
+
accelerate>=0.31
|
| 12 |
+
scikit-learn>=1.5
|
| 13 |
+
optimum[onnxruntime]>=1.19
|
| 14 |
+
huggingface-hub>=0.23
|
tests/test_engine_ml.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 3 |
+
|
| 4 |
+
from app.models import DNSResult, DisposableResult, SMTPResult, ValidationStatus
|
| 5 |
+
from app.validator.engine import EmailValidationEngine
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _dns():
|
| 9 |
+
return DNSResult(
|
| 10 |
+
domain_exists=True,
|
| 11 |
+
has_mx_records=True,
|
| 12 |
+
mx_records=[{"host": "mx.corp.test", "priority": 10}],
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _clean():
|
| 17 |
+
return DisposableResult(
|
| 18 |
+
is_disposable=False, is_free_provider=False, is_role_based=False
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class NullCache:
|
| 23 |
+
async def get_dns(self, domain):
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
async def put_dns(self, domain, result):
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
async def get_verdict(self, email, deep):
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
async def put_verdict(self, email, deep, smtp, catch_all):
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class FakeProvider:
|
| 37 |
+
async def verify_many(self, pairs):
|
| 38 |
+
return ({e: SMTPResult(verified=True, smtp_code=250) for e, _ in pairs}, {})
|
| 39 |
+
|
| 40 |
+
async def verify(self, email, mx_host):
|
| 41 |
+
return SMTPResult(verified=True, smtp_code=250)
|
| 42 |
+
|
| 43 |
+
async def catch_all(self, domain, mx_host):
|
| 44 |
+
return (False, 0.9)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _engine_with_ml(flag=True, repo="user/model", predict=None):
|
| 48 |
+
engine = EmailValidationEngine(mailbox=FakeProvider(), cache=NullCache())
|
| 49 |
+
if predict is not None:
|
| 50 |
+
fake_model = MagicMock()
|
| 51 |
+
fake_model.predict = MagicMock(side_effect=predict)
|
| 52 |
+
fake_model.load = MagicMock(return_value=True)
|
| 53 |
+
engine.mailbox = engine.mailbox # unchanged
|
| 54 |
+
patcher = patch("app.validator.engine.get_model", return_value=fake_model)
|
| 55 |
+
patcher.start()
|
| 56 |
+
engine._ml_patcher = patcher # keep alive until stopAll
|
| 57 |
+
engine._ml_model = fake_model
|
| 58 |
+
p = patch.object(
|
| 59 |
+
__import__("app.config", fromlist=["settings"]).settings,
|
| 60 |
+
"ml_disposable_enabled",
|
| 61 |
+
flag,
|
| 62 |
+
)
|
| 63 |
+
p.start()
|
| 64 |
+
engine._flag_patcher = p
|
| 65 |
+
p2 = patch.object(
|
| 66 |
+
__import__("app.config", fromlist=["settings"]).settings,
|
| 67 |
+
"ml_model_repo",
|
| 68 |
+
repo,
|
| 69 |
+
)
|
| 70 |
+
p2.start()
|
| 71 |
+
engine._flag_patcher2 = p2
|
| 72 |
+
return engine
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _stop(engine):
|
| 76 |
+
engine._ml_patcher.stop() if hasattr(engine, "_ml_patcher") else None
|
| 77 |
+
engine._flag_patcher.stop()
|
| 78 |
+
engine._flag_patcher2.stop()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class EngineMLTests(unittest.IsolatedAsyncioTestCase):
|
| 82 |
+
def tearDown(self):
|
| 83 |
+
for e in getattr(self, "_engines", []):
|
| 84 |
+
_stop(e)
|
| 85 |
+
|
| 86 |
+
async def _run(self, engine, emails):
|
| 87 |
+
self._engines = getattr(self, "_engines", [])
|
| 88 |
+
self._engines.append(engine)
|
| 89 |
+
# NOTE: check_disposable is NOT mocked - the real blocklist/role sets
|
| 90 |
+
# run, so the known-domain test exercises the true deterministic path.
|
| 91 |
+
with patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())):
|
| 92 |
+
return await engine.validate_batch(emails, deep=True)
|
| 93 |
+
|
| 94 |
+
async def test_flag_off_leaves_results_unchanged(self):
|
| 95 |
+
engine = _engine_with_ml(flag=False)
|
| 96 |
+
results = await self._run(engine, ["a@brand-new-xyz.tk"])
|
| 97 |
+
|
| 98 |
+
self.assertIsNone(results[0].disposable.ml_label)
|
| 99 |
+
self.assertIsNone(results[0].disposable.ml_confidence)
|
| 100 |
+
self.assertNotIn(
|
| 101 |
+
"ML model flagged domain as disposable-like",
|
| 102 |
+
results[0].scoring.warnings,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
async def test_known_disposable_domain_skips_ml(self):
|
| 106 |
+
# mailinator.com IS in the live blocklist -> the deterministic layer
|
| 107 |
+
# already hard-fails it; the model must not be consulted.
|
| 108 |
+
engine = _engine_with_ml(flag=True, predict=[AssertionError("must not run")])
|
| 109 |
+
results = await self._run(engine, ["a@mailinator.com"])
|
| 110 |
+
|
| 111 |
+
self.assertEqual(results[0].status, ValidationStatus.INVALID)
|
| 112 |
+
fake_model = engine._ml_model
|
| 113 |
+
fake_model.predict.assert_not_called()
|
| 114 |
+
|
| 115 |
+
async def test_unknown_domain_gets_advisory_ml_signal(self):
|
| 116 |
+
engine = _engine_with_ml(
|
| 117 |
+
flag=True, predict=[[("DISPOSABLE", 0.93)]] # contract: (label, score)
|
| 118 |
+
)
|
| 119 |
+
results = await self._run(engine, ["a@brand-new-disposable-xyz.tk"])
|
| 120 |
+
|
| 121 |
+
d = results[0].disposable
|
| 122 |
+
self.assertEqual(d.ml_label, "DISPOSABLE")
|
| 123 |
+
self.assertEqual(d.ml_confidence, 0.93)
|
| 124 |
+
self.assertTrue(
|
| 125 |
+
any("ML model flagged" in w for w in results[0].scoring.warnings)
|
| 126 |
+
)
|
| 127 |
+
# ADVISORY: the deterministic verdict must not become invalid.
|
| 128 |
+
self.assertNotEqual(results[0].status, ValidationStatus.INVALID)
|
| 129 |
+
# The penalty is proven by the breakdown: the SMTP-verified bonus (+10)
|
| 130 |
+
# can legitimately re-inflate the absolute score past the clamp.
|
| 131 |
+
self.assertEqual(
|
| 132 |
+
results[0].scoring.breakdown.get("ML_DISPOSABLE_SUSPECT"), -5
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
async def test_model_failure_degrades_to_deterministic(self):
|
| 136 |
+
engine = _engine_with_ml(flag=True, predict=[None])
|
| 137 |
+
results = await self._run(engine, ["a@brand-new-xyz.tk"])
|
| 138 |
+
|
| 139 |
+
self.assertIsNone(results[0].disposable.ml_label)
|
| 140 |
+
self.assertEqual(
|
| 141 |
+
results[0].scoring.warnings,
|
| 142 |
+
[w for w in results[0].scoring.warnings if "ML model flagged" not in w]
|
| 143 |
+
or results[0].scoring.warnings,
|
| 144 |
+
)
|
| 145 |
+
self.assertEqual(results[0].scoring.score, 100)
|
| 146 |
+
|
| 147 |
+
async def test_unknown_domains_batch_into_one_predict_call(self):
|
| 148 |
+
engine = _engine_with_ml(
|
| 149 |
+
flag=True,
|
| 150 |
+
predict=[
|
| 151 |
+
[
|
| 152 |
+
("DISPOSABLE", 0.9),
|
| 153 |
+
("NORMAL", 0.9),
|
| 154 |
+
("DISPOSABLE", 0.95),
|
| 155 |
+
]
|
| 156 |
+
],
|
| 157 |
+
)
|
| 158 |
+
emails = [f"u{i}@dk{i}.example" for i in range(3)]
|
| 159 |
+
results = await self._run(engine, emails)
|
| 160 |
+
|
| 161 |
+
fake_model = engine._ml_model
|
| 162 |
+
self.assertEqual(fake_model.predict.call_count, 1)
|
| 163 |
+
sent = fake_model.predict.call_args.args[0]
|
| 164 |
+
self.assertEqual(len(sent), 3)
|
| 165 |
+
self.assertEqual(results[1].disposable.ml_label, "NORMAL")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
if __name__ == "__main__":
|
| 169 |
+
unittest.main()
|
tests/test_ml_dataset.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
|
| 3 |
+
from app.ml.dataset import build_dataset, label_name, LABEL_NAMES
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class DatasetBuilderTests(unittest.TestCase):
|
| 7 |
+
def test_labels_are_the_documented_four(self):
|
| 8 |
+
self.assertEqual(LABEL_NAMES, ["NORMAL", "DISPOSABLE", "ROLE", "SPAM_TRAP"])
|
| 9 |
+
self.assertEqual(label_name(0), "NORMAL")
|
| 10 |
+
self.assertEqual(label_name(1), "DISPOSABLE")
|
| 11 |
+
self.assertEqual(label_name(2), "ROLE")
|
| 12 |
+
self.assertEqual(label_name(3), "SPAM_TRAP")
|
| 13 |
+
|
| 14 |
+
def test_blocklist_domains_become_disposable(self):
|
| 15 |
+
rows = build_dataset(
|
| 16 |
+
blocklist={"tempmail.example", "10minutemail.example"},
|
| 17 |
+
free={"gmail.com"},
|
| 18 |
+
role_prefixes={"admin"},
|
| 19 |
+
normal_domains={"acme-corp.test"},
|
| 20 |
+
normal_locals={"john.smith", "maria.garcia"},
|
| 21 |
+
per_class_limit=50,
|
| 22 |
+
)
|
| 23 |
+
by_label = {}
|
| 24 |
+
for r in rows:
|
| 25 |
+
by_label.setdefault(r["label"], []).append(r["text"])
|
| 26 |
+
self.assertIn(1, by_label, "blocklist domains must yield DISPOSABLE rows")
|
| 27 |
+
self.assertTrue(any("tempmail.example" in t for t in by_label[1]))
|
| 28 |
+
|
| 29 |
+
def test_role_prefixes_become_role_label(self):
|
| 30 |
+
rows = build_dataset(
|
| 31 |
+
blocklist=set(),
|
| 32 |
+
free=set(),
|
| 33 |
+
role_prefixes={"admin", "info"},
|
| 34 |
+
normal_domains={"acme-corp.test"},
|
| 35 |
+
normal_locals={"john.smith"},
|
| 36 |
+
per_class_limit=50,
|
| 37 |
+
)
|
| 38 |
+
role_rows = [r for r in rows if r["label"] == 2]
|
| 39 |
+
self.assertTrue(role_rows, "role prefixes must yield ROLE rows")
|
| 40 |
+
self.assertTrue(any(r["text"].startswith("admin") for r in role_rows))
|
| 41 |
+
|
| 42 |
+
def test_normal_class_never_contains_blocklist_domains(self):
|
| 43 |
+
rows = build_dataset(
|
| 44 |
+
blocklist={"tempmail.example"},
|
| 45 |
+
free=set(),
|
| 46 |
+
role_prefixes=set(),
|
| 47 |
+
normal_domains={"acme-corp.test", "northwind.test"},
|
| 48 |
+
normal_locals={"john.smith", "maria.garcia", "kenji.tanaka"},
|
| 49 |
+
per_class_limit=50,
|
| 50 |
+
)
|
| 51 |
+
normal_texts = [r["text"] for r in rows if r["label"] == 0]
|
| 52 |
+
self.assertTrue(normal_texts, "NORMAL class must be populated")
|
| 53 |
+
self.assertTrue(all("tempmail.example" not in t for t in normal_texts))
|
| 54 |
+
|
| 55 |
+
def test_per_class_limit_is_respected(self):
|
| 56 |
+
rows = build_dataset(
|
| 57 |
+
blocklist={f"d{i}.example" for i in range(100)},
|
| 58 |
+
free=set(),
|
| 59 |
+
role_prefixes=set(),
|
| 60 |
+
normal_domains={"acme.test"},
|
| 61 |
+
normal_locals={"john.smith"},
|
| 62 |
+
per_class_limit=25,
|
| 63 |
+
)
|
| 64 |
+
counts = {}
|
| 65 |
+
for r in rows:
|
| 66 |
+
counts[r["label"]] = counts.get(r["label"], 0) + 1
|
| 67 |
+
self.assertTrue(all(c <= 25 for c in counts.values()), counts)
|
| 68 |
+
|
| 69 |
+
def test_text_format_is_local_sep_domain(self):
|
| 70 |
+
rows = build_dataset(
|
| 71 |
+
blocklist=set(),
|
| 72 |
+
free=set(),
|
| 73 |
+
role_prefixes={"admin"},
|
| 74 |
+
normal_domains={"acme.test"},
|
| 75 |
+
normal_locals={"john.smith"},
|
| 76 |
+
per_class_limit=5,
|
| 77 |
+
)
|
| 78 |
+
self.assertTrue(rows)
|
| 79 |
+
for r in rows:
|
| 80 |
+
self.assertIn(" [SEP] ", r["text"])
|
| 81 |
+
|
| 82 |
+
def test_deterministic_with_same_seed(self):
|
| 83 |
+
kwargs = dict(
|
| 84 |
+
blocklist={"tempmail.example"},
|
| 85 |
+
free={"gmail.com"},
|
| 86 |
+
role_prefixes={"admin"},
|
| 87 |
+
normal_domains={"acme.test"},
|
| 88 |
+
normal_locals={"john.smith"},
|
| 89 |
+
per_class_limit=20,
|
| 90 |
+
seed=7,
|
| 91 |
+
)
|
| 92 |
+
a = build_dataset(**kwargs)
|
| 93 |
+
b = build_dataset(**kwargs)
|
| 94 |
+
self.assertEqual(a, b, "same seed must produce identical rows")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
if __name__ == "__main__":
|
| 98 |
+
unittest.main()
|
tests/test_ml_runtime.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from unittest.mock import MagicMock, patch
|
| 3 |
+
|
| 4 |
+
from app.ml.runtime import EmailRiskModel, get_model
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class RuntimeLoaderTests(unittest.TestCase):
|
| 8 |
+
"""The ML layer must be strictly optional: any failure degrades to "no ML"
|
| 9 |
+
and validation continues deterministically."""
|
| 10 |
+
|
| 11 |
+
def test_load_failure_returns_false_and_never_raises(self):
|
| 12 |
+
m = EmailRiskModel(repo="user/model", token="t")
|
| 13 |
+
with patch(
|
| 14 |
+
"app.ml.runtime.ORTModelForSequenceClassification",
|
| 15 |
+
side_effect=RuntimeError("hub down"),
|
| 16 |
+
):
|
| 17 |
+
self.assertFalse(m.load())
|
| 18 |
+
self.assertFalse(m.loaded)
|
| 19 |
+
|
| 20 |
+
def test_predict_before_load_returns_none(self):
|
| 21 |
+
m = EmailRiskModel(repo="user/model", token="t")
|
| 22 |
+
self.assertIsNone(m.predict(["a [SEP] b.test"]))
|
| 23 |
+
|
| 24 |
+
def test_predict_maps_label_and_confidence(self):
|
| 25 |
+
m = EmailRiskModel(repo="user/model", token="t")
|
| 26 |
+
fake = MagicMock(return_value=[{"label": "DISPOSABLE", "score": 0.93}])
|
| 27 |
+
m._pipeline = fake
|
| 28 |
+
m._loaded = True
|
| 29 |
+
|
| 30 |
+
out = m.predict(["x [SEP] tempmail.example"])
|
| 31 |
+
|
| 32 |
+
self.assertEqual(out, [("DISPOSABLE", 0.93)])
|
| 33 |
+
# Batching is passed through - 1-row calls are a performance bug.
|
| 34 |
+
fake.assert_called_once()
|
| 35 |
+
self.assertIn("batch_size", fake.call_args.kwargs)
|
| 36 |
+
|
| 37 |
+
def test_predict_error_returns_none_not_raises(self):
|
| 38 |
+
m = EmailRiskModel(repo="user/model", token="t")
|
| 39 |
+
fake = MagicMock(side_effect=RuntimeError("onnx exploded"))
|
| 40 |
+
m._pipeline = fake
|
| 41 |
+
m._loaded = True
|
| 42 |
+
self.assertIsNone(m.predict(["a [SEP] b.test"]))
|
| 43 |
+
|
| 44 |
+
def test_predict_normalizes_label_case(self):
|
| 45 |
+
m = EmailRiskModel(repo="user/model", token="t")
|
| 46 |
+
fake = MagicMock(return_value=[{"label": "disposable", "score": 0.5}])
|
| 47 |
+
m._pipeline = fake
|
| 48 |
+
m._loaded = True
|
| 49 |
+
self.assertEqual(m.predict(["a [SEP] b.test"])[0][0], "DISPOSABLE")
|
| 50 |
+
|
| 51 |
+
def test_predict_accepts_plain_string_batch_return(self):
|
| 52 |
+
# transformers may return a plain list of dicts OR nested batch lists.
|
| 53 |
+
m = EmailRiskModel(repo="user/model", token="t")
|
| 54 |
+
fake = MagicMock(return_value=[[{"label": "NORMAL", "score": 0.7}]])
|
| 55 |
+
m._pipeline = fake
|
| 56 |
+
m._loaded = True
|
| 57 |
+
self.assertEqual(m.predict(["a [SEP] b.test"]), [("NORMAL", 0.7)])
|
| 58 |
+
|
| 59 |
+
def test_config_defaults_are_off(self):
|
| 60 |
+
from app.config import settings
|
| 61 |
+
|
| 62 |
+
self.assertFalse(settings.ml_disposable_enabled)
|
| 63 |
+
self.assertEqual(settings.ml_model_repo, "")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class GetModelTests(unittest.TestCase):
|
| 67 |
+
def test_returns_none_when_disabled(self):
|
| 68 |
+
with patch("app.ml.runtime.settings") as fake_settings:
|
| 69 |
+
fake_settings.ml_disposable_enabled = False
|
| 70 |
+
fake_settings.ml_model_repo = "user/model"
|
| 71 |
+
self.assertIsNone(get_model())
|
| 72 |
+
|
| 73 |
+
def test_returns_none_when_no_repo(self):
|
| 74 |
+
with patch("app.ml.runtime.settings") as fake_settings:
|
| 75 |
+
fake_settings.ml_disposable_enabled = True
|
| 76 |
+
fake_settings.ml_model_repo = ""
|
| 77 |
+
self.assertIsNone(get_model())
|
| 78 |
+
|
| 79 |
+
def test_returns_model_when_enabled_and_configured(self):
|
| 80 |
+
with patch("app.ml.runtime.settings") as fake_settings:
|
| 81 |
+
fake_settings.ml_disposable_enabled = True
|
| 82 |
+
fake_settings.ml_model_repo = "user/model"
|
| 83 |
+
m = get_model()
|
| 84 |
+
self.assertIsInstance(m, EmailRiskModel)
|
| 85 |
+
self.assertEqual(m.repo, "user/model")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
if __name__ == "__main__":
|
| 89 |
+
unittest.main()
|