Upload folder using huggingface_hub
Browse files- app/validator/engine.py +17 -0
- tests/test_engine_ml.py +46 -1
app/validator/engine.py
CHANGED
|
@@ -226,6 +226,23 @@ class EmailValidationEngine:
|
|
| 226 |
# ---- Layer 4: Disposable Detection ---------------------------
|
| 227 |
disposable = check_disposable(normalized_email, domain)
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
if not deep:
|
| 230 |
# Fast path: skip SMTP for quick validation
|
| 231 |
elapsed = (time.monotonic() - start_time) * 1000
|
|
|
|
| 226 |
# ---- Layer 4: Disposable Detection ---------------------------
|
| 227 |
disposable = check_disposable(normalized_email, domain)
|
| 228 |
|
| 229 |
+
# ML advisory (single path): unknown domains only, fail-open. Mirrors
|
| 230 |
+
# the batch pipeline so both surfaces agree on the same address.
|
| 231 |
+
ml_model = get_model()
|
| 232 |
+
if (
|
| 233 |
+
ml_model is not None
|
| 234 |
+
and not domain_is_known(domain)
|
| 235 |
+
and deep
|
| 236 |
+
):
|
| 237 |
+
try:
|
| 238 |
+
preds = ml_model.predict([f"john.smith [SEP] {domain}"])
|
| 239 |
+
except Exception:
|
| 240 |
+
preds = None
|
| 241 |
+
if preds:
|
| 242 |
+
disposable = disposable.model_copy(
|
| 243 |
+
update={"ml_label": preds[0][0], "ml_confidence": preds[0][1]}
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
if not deep:
|
| 247 |
# Fast path: skip SMTP for quick validation
|
| 248 |
elapsed = (time.monotonic() - start_time) * 1000
|
tests/test_engine_ml.py
CHANGED
|
@@ -46,7 +46,10 @@ class FakeProvider:
|
|
| 46 |
|
| 47 |
def _engine_with_ml(flag=True, repo="user/model", predict=None):
|
| 48 |
engine = EmailValidationEngine(mailbox=FakeProvider(), cache=NullCache())
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
| 50 |
fake_model = MagicMock()
|
| 51 |
fake_model.predict = MagicMock(side_effect=predict)
|
| 52 |
fake_model.load = MagicMock(return_value=True)
|
|
@@ -79,6 +82,13 @@ def _stop(engine):
|
|
| 79 |
|
| 80 |
|
| 81 |
class EngineMLTests(unittest.IsolatedAsyncioTestCase):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
def tearDown(self):
|
| 83 |
for e in getattr(self, "_engines", []):
|
| 84 |
_stop(e)
|
|
@@ -165,5 +175,40 @@ class EngineMLTests(unittest.IsolatedAsyncioTestCase):
|
|
| 165 |
self.assertEqual(results[1].disposable.ml_label, "NORMAL")
|
| 166 |
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
if __name__ == "__main__":
|
| 169 |
unittest.main()
|
|
|
|
| 46 |
|
| 47 |
def _engine_with_ml(flag=True, repo="user/model", predict=None):
|
| 48 |
engine = EmailValidationEngine(mailbox=FakeProvider(), cache=NullCache())
|
| 49 |
+
# The get_model patcher must honor the flag: with the flag off the REAL
|
| 50 |
+
# get_model returns None (that IS the production contract), so the double
|
| 51 |
+
# must not force a model into a disabled engine.
|
| 52 |
+
if predict is not None and flag:
|
| 53 |
fake_model = MagicMock()
|
| 54 |
fake_model.predict = MagicMock(side_effect=predict)
|
| 55 |
fake_model.load = MagicMock(return_value=True)
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
class EngineMLTests(unittest.IsolatedAsyncioTestCase):
|
| 85 |
+
def setUp(self):
|
| 86 |
+
self._engines = []
|
| 87 |
+
|
| 88 |
+
def tearDown(self):
|
| 89 |
+
# Stop settings/model patchers so they never leak into other tests.
|
| 90 |
+
for e in self._engines:
|
| 91 |
+
_stop(e)
|
| 92 |
def tearDown(self):
|
| 93 |
for e in getattr(self, "_engines", []):
|
| 94 |
_stop(e)
|
|
|
|
| 175 |
self.assertEqual(results[1].disposable.ml_label, "NORMAL")
|
| 176 |
|
| 177 |
|
| 178 |
+
async def test_single_validate_also_applies_the_ml_signal(self):
|
| 179 |
+
# The single endpoint runs engine.validate() - it must get the same
|
| 180 |
+
# advisory signal as bulk, or the two surfaces disagree on one address.
|
| 181 |
+
engine = _engine_with_ml(
|
| 182 |
+
flag=True, predict=[[("DISPOSABLE", 0.93)]]
|
| 183 |
+
)
|
| 184 |
+
self._engines.append(engine)
|
| 185 |
+
with patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())):
|
| 186 |
+
result = await engine.validate("a@brand-new-disposable-xyz.tk", deep=True)
|
| 187 |
+
|
| 188 |
+
self.assertEqual(result.disposable.ml_label, "DISPOSABLE")
|
| 189 |
+
self.assertEqual(
|
| 190 |
+
result.scoring.breakdown.get("ML_DISPOSABLE_SUSPECT"), -5
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
async def test_single_validate_flag_off_unchanged(self):
|
| 194 |
+
engine = _engine_with_ml(flag=False, predict=[[("DISPOSABLE", 0.93)]])
|
| 195 |
+
self._engines.append(engine)
|
| 196 |
+
with patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())):
|
| 197 |
+
result = await engine.validate("a@brand-new-disposable-xyz.tk", deep=True)
|
| 198 |
+
|
| 199 |
+
self.assertIsNone(result.disposable.ml_label)
|
| 200 |
+
|
| 201 |
+
async def test_single_validate_known_domain_skips_ml(self):
|
| 202 |
+
engine = _engine_with_ml(
|
| 203 |
+
flag=True, predict=[AssertionError("must not run")]
|
| 204 |
+
)
|
| 205 |
+
self._engines.append(engine)
|
| 206 |
+
with patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())):
|
| 207 |
+
result = await engine.validate("a@mailinator.com", deep=True)
|
| 208 |
+
|
| 209 |
+
self.assertEqual(result.status, ValidationStatus.INVALID)
|
| 210 |
+
self.assertIsNone(result.disposable.ml_label)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
if __name__ == "__main__":
|
| 214 |
unittest.main()
|