File size: 45,944 Bytes
439e7b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
"""BITS Phishing Email Detector: Gradio demo for the from-scratch 337M encoder classifier.

Mirrors the instructor's reference app (``gr.Interface``, ``gr.Label`` + ``gr.Number``
"Prediction Time (s)`` timed with ``timeit.default_timer``) for text instead of images,
and adds two interpretability outputs:

* **Three classes.** The head is a softmax over ``Legitimate / Phishing / Spam`` (names
  read from ``config.json["labels"]``; a two-class export falls back to the old
  ``Legitimate / Spam/Phishing`` pair). The binary roll-up ``P(unwanted) = P(phishing) +
  P(spam)`` is the headline: it is the quantity the TREC-era training corpora actually
  label, so it stays comparable with the binary runs. The verdict is ``Legitimate`` when
  ``P(unwanted) < 0.5``, otherwise whichever unwanted class carries more mass.
* **Model-based token importance** (gradient x input saliency). The log-probability of
  the verdict (``log P(legitimate)``, or ``log(P(phishing) + P(spam))`` when the verdict is
  unwanted) is differentiated with respect to the token embedding vectors; the dot
  product of gradient and embedding per position is a first-order estimate of how much
  that token moved the decision toward the verdict. A log-probability rather than a raw
  logit is used because softmax is shift-invariant: a bias shared by all logits would
  otherwise show up as spurious importance. Saliency is faithful to this model (it reads
  the model's own gradients) where attention weights are not reliably so.
* **Rule-based cues**: regex heuristics (urgency words, credential lures, link-text /
  href host mismatch, lookalike domains, IP-literal URLs, ALL-CAPS share, reply-to
  redirection, generic greetings, risky attachment names). These are shown as context
  for a human reader and never feed the probability, which comes only from the model.

Runtime layout (Hugging Face Space, ZeroGPU): ``import spaces`` runs before torch so the
CUDA shim is installed first; the model is moved to ``cuda`` at MODULE level (ZeroGPU
patches ``torch.cuda.is_available()`` to True outside ``@spaces.GPU`` functions and
requires module-level placement); the only CUDA-touching function is wrapped in
``@spaces.GPU(duration=30)``. Locally (no ``spaces`` package or ``SPACES_ZERO_GPU``
unset) the decorator is a no-op and the app runs on the local GPU or on the CPU.

Weights: ``PHISH_MODEL_DIR`` (default: this file's directory) is used when it holds
``model.safetensors`` + ``config.json`` + ``tokenizer.json``; otherwise the three files
are downloaded from the private model repo ``PHISH_MODEL_REPO`` with the ``HF_TOKEN``
Space secret (``preload_from_hub`` cannot read private repos). The token is read from
the environment only and never logged.

Hidden flags (development): ``--selftest`` runs ``predict`` on three built-in emails (a
phishing lure, a marketing spam, a legitimate note) on CPU and on CUDA (if present) and
exits; ``--serve-seconds N`` launches the server without blocking, fetches the root URL
once, waits N seconds and exits.
"""

from __future__ import annotations

import argparse
import html
import json
import logging
import math
import os
import re
import sys
import time
from pathlib import Path
from timeit import default_timer as timer
from typing import Any

TITLE = "BITS Phishing Email Detector"


def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    ap = argparse.ArgumentParser(description=TITLE, add_help=True)
    ap.add_argument("--selftest", action="store_true", help=argparse.SUPPRESS)
    ap.add_argument("--serve-seconds", type=float, default=None, help=argparse.SUPPRESS)
    return ap.parse_args(argv)


# Parsed before the heavy imports so ``--help`` (or a bad flag) answers without importing
# torch, model.py or the weights. Spaces runs ``python app.py`` with no arguments.
ARGS = _parse_args() if __name__ == "__main__" else None

try:  # must precede torch: the ZeroGPU shim patches torch.cuda at import time
    import spaces
except ImportError:  # local run without the ``spaces`` package

    class _SpacesShim:
        """``@spaces.GPU`` and ``@spaces.GPU(duration=..)`` both become identity decorators."""

        @staticmethod
        def GPU(task=None, *, duration=None, size=None):  # noqa: N802 - mirrors spaces.GPU
            if callable(task):
                return task
            return lambda f: f

    spaces = _SpacesShim()  # type: ignore[assignment]

import torch  # noqa: E402
import torch.nn.functional as F  # noqa: E402

APP_DIR = Path(__file__).resolve().parent
MODEL_DIR = Path(os.environ.get("PHISH_MODEL_DIR", str(APP_DIR))).resolve()
MODEL_REPO = os.environ.get("PHISH_MODEL_REPO", "BuildITSystems/phishing-detector-encoder")
WEIGHT_FILES = ("model.safetensors", "config.json", "tokenizer.json")
ZERO_GPU = os.environ.get("SPACES_ZERO_GPU", "").lower() in {"1", "t", "true"}

# model.py normally sits next to app.py (export.py copies it there). A development export
# directory may carry its own copy; it is searched second so the Space's copy wins.
for _p in (APP_DIR, MODEL_DIR):
    if str(_p) not in sys.path:
        sys.path.append(str(_p))

import gradio as gr  # noqa: E402
from safetensors.torch import load_file  # noqa: E402
from tokenizers import Tokenizer  # noqa: E402

from model import EncoderConfig, EncoderForClassification  # noqa: E402

logging.basicConfig(
    level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", datefmt="%H:%M:%S", stream=sys.stdout
)
LOG = logging.getLogger("phish_app")

# Display names by class index. Three classes since Nathan's 2026-10-08 scope change (RUN_LOG
# entry 6); the two-class names are kept so an older binary export still renders correctly.
DEFAULT_LABELS: dict[int, list[str]] = {
    2: ["Legitimate", "Spam/Phishing"],
    3: ["Legitimate", "Phishing", "Spam"],
}
UNWANTED_LABEL = "Unwanted (phishing or spam)"   # the binary roll-up P(class 1) + P(class 2) + ...
LEGIT_INDEX = 0                                  # every class >= 1 is "unwanted" (contract: 1 phishing, 2 spam)
TOP_K_TOKENS = 12
HIGH_FRACTION = 0.5  # a selected word is "high" when |score| >= HIGH_FRACTION * max |score|


def label_names(config: dict[str, Any], num_labels: int) -> list[str]:
    """Class display names from ``config.json["labels"]`` (``{"0": "Legitimate", ...}``).

    The export writes the names the fine-tune was trained with; they win when they cover
    every index with a distinct non-empty string. Otherwise the defaults above apply
    (``num_labels == 2`` -> the binary pair, 3 -> Legitimate / Phishing / Spam).
    """
    raw = config.get("labels")
    if isinstance(raw, (list, tuple)):  # tolerate a positional list ["Legitimate", "Phishing", "Spam"]
        raw = {str(i): v for i, v in enumerate(raw)}
    if not isinstance(raw, dict):  # missing, null, a bare string, ... -> defaults, never an import-time crash
        raw = {}
    names = [str(raw.get(str(i), raw.get(i, "")) or "").strip() for i in range(num_labels)]
    if all(names) and len(set(names)) == num_labels:
        return names
    return DEFAULT_LABELS.get(num_labels) or [f"Class {i}" for i in range(num_labels)]


# ----------------------------------------------------------------------------- weights
def resolve_model_files() -> tuple[dict[str, Path], str]:
    """Local files in MODEL_DIR win; otherwise download from the private model repo.

    Returns ``({filename: path}, source)``. The HF token is passed straight from the
    environment to ``hf_hub_download`` and never echoed.
    """
    local = {f: MODEL_DIR / f for f in WEIGHT_FILES}
    if all(p.is_file() for p in local.values()):
        return local, f"local:{MODEL_DIR}"
    missing = [f for f, p in local.items() if not p.is_file()]
    LOG.info("weights not found locally (missing %s in %s); downloading from %s", missing, MODEL_DIR, MODEL_REPO)
    from huggingface_hub import hf_hub_download

    token = os.environ.get("HF_TOKEN")
    LOG.info("HF_TOKEN %s", "present" if token else "absent (public repo access only)")
    files = {f: Path(hf_hub_download(repo_id=MODEL_REPO, filename=f, token=token)) for f in WEIGHT_FILES}
    return files, f"hub:{MODEL_REPO}"


def load_model(files: dict[str, Path]) -> tuple[EncoderForClassification, EncoderConfig, dict[str, Any]]:
    """Build ``EncoderForClassification`` from config.json and load the safetensors strictly.

    The export is bf16; ``load_state_dict`` copies into the fp32 parameters of the freshly
    built module, so the dtype actually used at inference is decided afterwards in
    ``place_model`` (bf16 on CUDA, fp32 on CPU).
    """
    with open(files["config.json"], encoding="utf-8") as fh:
        config = json.load(fh)
    cfg = EncoderConfig.from_dict(config["model"])
    model = EncoderForClassification(cfg)
    state = load_file(str(files["model.safetensors"]), device="cpu")
    model.load_state_dict(state, strict=True)
    model.eval()
    for p in model.parameters():  # inference only; saliency differentiates w.r.t. embeddings, not weights
        p.requires_grad_(False)
    return model, cfg, config


def place_model(model: torch.nn.Module, device: str) -> torch.nn.Module:
    """bf16 on CUDA (halves memory and bandwidth; the export is bf16 anyway), fp32 on CPU
    (CPU bf16 matmuls are slow on most hosts and fp32 is the reference numerics)."""
    if device == "cuda":
        return model.to(device="cuda", dtype=torch.bfloat16)
    return model.float().to("cpu")


_t0 = timer()
_FILES, WEIGHT_SOURCE = resolve_model_files()
MODEL, CFG, CONFIG = load_model(_FILES)
MAX_LEN = int(CFG.max_seq_len)
# Two tokenizer instances with FIXED settings instead of one whose truncation is toggled per
# call: the Rust tokenizer's truncation state is shared mutable state, so toggling it would
# race if Gradio's concurrency_limit were ever raised above 1. TOKENIZER always truncates to
# MAX_LEN (the model input); TOKENIZER_FULL never does (the untruncated count for the notice).
TOKENIZER = Tokenizer.from_file(str(_FILES["tokenizer.json"]))
TOKENIZER.no_padding()
TOKENIZER.enable_truncation(max_length=MAX_LEN)
TOKENIZER_FULL = Tokenizer.from_file(str(_FILES["tokenizer.json"]))
TOKENIZER_FULL.no_padding()
TOKENIZER_FULL.no_truncation()
NUM_LABELS = int(CFG.num_labels)
if NUM_LABELS < 2:
    raise RuntimeError(f"config.json model.num_labels={NUM_LABELS}; the app needs at least a legitimate/unwanted pair")
LABELS: list[str] = label_names(CONFIG, NUM_LABELS)
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"  # patched True under ZeroGPU, by design
MODEL = place_model(MODEL, DEVICE)
N_PARAMS = sum(p.numel() for p in MODEL.parameters())
LOG.info(
    "model loaded in %.1fs from %s: %s params, %d layers, %d classes %s, device=%s dtype=%s, zero_gpu=%s, max_len=%d",
    timer() - _t0, WEIGHT_SOURCE, f"{N_PARAMS:,}", CFG.n_layers, NUM_LABELS, LABELS, DEVICE,
    next(MODEL.parameters()).dtype, ZERO_GPU, MAX_LEN,
)


# ----------------------------------------------------------------------------- inference
def _autocast(device: str):
    if device == "cuda":
        return torch.autocast("cuda", dtype=torch.bfloat16)
    return torch.autocast("cpu", enabled=False)


def encode(text: str) -> dict[str, Any]:
    """[CLS] text [SEP], truncated to MAX_LEN; offsets refer to the ORIGINAL string
    (the tokenizers library keeps alignments through NFKC), which is what lets the
    saliency be painted over the user's text rather than over a normalised copy.
    Stateless: TOKENIZER is configured once at import, nothing is toggled here."""
    enc = TOKENIZER.encode(text)
    return {
        "ids": enc.ids,
        "offsets": enc.offsets,
        "special": enc.special_tokens_mask,
        "n_tokens": len(enc.ids),
        "truncated": len(enc.overflowing) > 0,  # truncation leaves the cut-off part in ``overflowing``
    }


def full_token_count(text: str) -> int:
    """Untruncated token count for the truncation notice. Called only after the prediction
    timer has stopped and only when ``encode`` reported truncation, so a long paste never
    inflates the graded Prediction Time with a second tokenizer pass."""
    return len(TOKENIZER_FULL.encode(text).ids)


def verdict_from_probs(probs: list[float]) -> tuple[int, float]:
    """``(verdict_index, p_unwanted)``: Legitimate when the unwanted mass is below 0.5,
    otherwise the unwanted class with the most mass (phishing vs spam is decided only
    among the unwanted classes, so a 0.40/0.35/0.25 split still reads as unwanted)."""
    p_unwanted = float(sum(probs[LEGIT_INDEX + 1:]))
    if p_unwanted < 0.5:
        return LEGIT_INDEX, p_unwanted
    top = max(range(LEGIT_INDEX + 1, len(probs)), key=lambda i: probs[i])
    return top, p_unwanted


@spaces.GPU(duration=30)
def _infer(text: str) -> dict[str, Any]:
    """Tokenize + forward + softmax (timed like the instructor's app), then one extra
    forward/backward for gradient x input saliency. Runs on ``DEVICE``; returns only
    plain Python data so it crosses the ZeroGPU process boundary cleanly."""
    device = DEVICE
    start = timer()
    enc = encode(text)
    ids = torch.tensor([enc["ids"]], dtype=torch.int64, device=device)
    mask = torch.ones_like(ids, dtype=torch.bool)
    with torch.inference_mode(), _autocast(device):
        logits = MODEL(ids, mask).float()
        probs = [float(v) for v in torch.softmax(logits, dim=-1)[0].tolist()]  # .tolist() syncs CUDA before the timer stops
    elapsed = timer() - start
    if not all(math.isfinite(p) for p in probs):
        # NaN/inf logits (corrupted weights, bf16 overflow) must surface as an error: ``nan < 0.5``
        # is False, so verdict_from_probs would otherwise announce a confident "Phishing" at nan%.
        raise gr.Error("The model produced non-finite probabilities for this input; the weights or the input are corrupt.")
    verdict, p_unwanted = verdict_from_probs(probs)

    full_len = full_token_count(text) if enc["truncated"] else enc["n_tokens"]  # outside the timed window
    t1 = timer()
    saliency = _gradient_x_input(ids, mask, device, unwanted=verdict != LEGIT_INDEX)
    sal_elapsed = timer() - t1
    return {
        "probs": probs,
        "p_unwanted": p_unwanted,
        "verdict": verdict,
        "elapsed_s": elapsed,
        "saliency_s": sal_elapsed,
        "saliency": saliency,
        "offsets": enc["offsets"],
        "special": enc["special"],
        "n_tokens": enc["n_tokens"],
        "truncated": enc["truncated"],
        "full_len": full_len,
        "device": device,
        "dtype": str(next(MODEL.parameters()).dtype).replace("torch.", ""),
    }


def _verdict_log_prob(logits: torch.Tensor, unwanted: bool) -> torch.Tensor:
    """Scalar whose gradient the saliency reads: ``log P(unwanted)`` = ``logsumexp(logits[1:])
    - logsumexp(logits)`` when the verdict is unwanted (the mass of phishing + spam, so
    the two unwanted classes are not played against each other), else ``log P(legit)``.
    Both are shift-invariant in the logits, unlike a raw class logit."""
    row = logits[0]
    lse_all = torch.logsumexp(row, dim=-1)
    if unwanted:
        return torch.logsumexp(row[LEGIT_INDEX + 1:], dim=-1) - lse_all
    return row[LEGIT_INDEX] - lse_all


def _gradient_x_input(ids: torch.Tensor, mask: torch.Tensor, device: str, *, unwanted: bool) -> list[float]:
    """Per-token ``grad(log P(verdict)) . embedding`` with gradients enabled only for this call.

    A forward hook swaps the token-embedding output for a detached leaf that requires
    grad, so autograd builds a graph from that tensor onward only: no parameter
    gradients are accumulated and the backward is as cheap as the model allows.
    Positive values push toward the verdict, negative away from it.
    """
    captured: dict[str, torch.Tensor] = {}

    def hook(_module, _inputs, output):
        leaf = output.detach().requires_grad_(True)
        captured["emb"] = leaf
        return leaf

    handle = MODEL.encoder.embeddings.tok.register_forward_hook(hook)
    try:
        with torch.enable_grad(), _autocast(device):
            logits = MODEL(ids, mask).float()
            target = _verdict_log_prob(logits, unwanted)
            (grad,) = torch.autograd.grad(target, captured["emb"])
    finally:
        handle.remove()
    sal = (grad.float() * captured["emb"].float()).sum(-1)[0]
    return [float(v) for v in sal.tolist()]


# ----------------------------------------------------------------------------- saliency -> words
WORD_BREAK = set(" \t\r\n\f\v<>\"'=")  # whitespace plus HTML attribute syntax; URLs stay whole


def _is_break(ch: str) -> bool:
    return ch.isspace() or ch in WORD_BREAK


def _word_span(text: str, start: int, end: int) -> tuple[int, int] | None:
    """Trim break characters inside a token span, then widen to the enclosing word.

    Words are delimited by whitespace and by ``< > " ' =`` so that ``href="http://x">text</a>``
    splits into its parts while a URL or an e-mail address is kept as one unit."""
    while start < end and _is_break(text[start]):
        start += 1
    while end > start and _is_break(text[end - 1]):
        end -= 1
    if start >= end:
        return None
    while start > 0 and not _is_break(text[start - 1]):
        start -= 1
    while end < len(text) and not _is_break(text[end]):
        end += 1
    return start, end


def saliency_highlights(text: str, out: dict[str, Any], k: int = TOP_K_TOKENS) -> list[tuple[str, str | None]]:
    """Top-k tokens by saliency in the direction of the verdict, merged into words and
    rendered as ``gr.HighlightedText`` segments over the original text.

    The saliency target is already the verdict's log-probability, so positive scores
    answer "what pushed the model to this answer"; tokens pulling the other way are left
    unpainted. Scores of tokens merged into one word are summed, so a word split into
    several sub-tokens is weighted as a whole.
    """
    scored: list[tuple[float, tuple[int, int]]] = []
    for s, (a, b), special in zip(out["saliency"], out["offsets"], out["special"]):
        if special or b <= a:
            continue
        span = _word_span(text, a, b)
        if span is None:
            continue
        scored.append((s, span))
    scored = [t for t in scored if t[0] > 0]
    scored.sort(key=lambda t: -t[0])
    chosen = scored[:k]
    if not chosen:
        return [(text, None)]
    # merge overlapping / identical word spans (sub-tokens of one word, repeated words)
    merged: dict[tuple[int, int], float] = {}
    for score, span in chosen:
        merged[span] = merged.get(span, 0.0) + score
    spans = sorted(merged.items())
    flat: list[tuple[int, int, float]] = []
    for (a, b), sc in spans:
        if flat and a <= flat[-1][1]:
            pa, pb, ps = flat[-1]
            flat[-1] = (pa, max(pb, b), ps + sc)
        else:
            flat.append((a, b, sc))
    top = max(sc for _, _, sc in flat)
    segments: list[tuple[str, str | None]] = []
    cursor = 0
    for a, b, sc in flat:
        if a > cursor:
            segments.append((text[cursor:a], None))
        segments.append((text[a:b], "high" if sc >= HIGH_FRACTION * top else "medium"))
        cursor = b
    if cursor < len(text):
        segments.append((text[cursor:], None))
    return segments


# ----------------------------------------------------------------------------- rule-based cues
# Shared with src/evaluate.py (credential-lure slice of the holdout): keep the two copies
# identical; the app directory is self-contained and cannot import from src/.
CREDENTIAL_LURE_PHRASES: tuple[str, ...] = (
    "verify your account", "verify your identity", "confirm your account", "confirm your identity",
    "confirm your password", "update your password", "reset your password", "password will expire",
    "password expires", "password has expired", "update your payment", "update your billing",
    "billing information", "unusual sign-in", "unusual activity", "suspicious activity",
    "account has been suspended", "account will be suspended", "account has been limited",
    "unlock your account", "re-activate your account", "reactivate your account", "validate your account",
    "login to your account", "log in to your account", "sign in to your account", "click here to verify",
    "security alert", "security notice", "enter your password", "re-enter your", "social security number",
    "credit card number", "one-time passcode", "verification code",
)
URGENCY_WORDS: tuple[str, ...] = (
    "urgent", "immediately", "immediate action", "within 24 hours", "within 48 hours", "final notice",
    "final warning", "last warning", "act now", "right away", "as soon as possible", "asap", "expires today",
    "will expire", "will be closed", "will be terminated", "will be deleted", "limited time", "time sensitive",
    "do not ignore", "failure to", "before it is too late", "today only", "respond now",
)
GENERIC_GREETINGS: tuple[str, ...] = (
    "dear customer", "dear valued customer", "dear user", "dear account holder", "dear member",
    "dear client", "dear sir/madam", "dear sir or madam", "dear friend", "dear beneficiary",
    "dear email user", "dear account user", "dear employee", "attention:", "hello dear", "greetings of the day",
)
BRANDS: tuple[str, ...] = (
    "microsoft", "office365", "office 365", "outlook", "onedrive", "sharepoint", "paypal", "apple", "icloud",
    "amazon", "google", "gmail", "netflix", "chase", "wellsfargo", "bankofamerica", "citibank", "hsbc",
    "barclays", "dhl", "fedex", "ups", "usps", "docusign", "dropbox", "adobe", "linkedin", "facebook",
    "instagram", "whatsapp", "irs", "hmrc", "zoom", "webex", "quickbooks", "xero",
)
RISKY_ATTACHMENT_EXT = r"zip|rar|7z|htm|html|iso|img|docm|xlsm|pptm|js|vbs|scr|exe|lnk|hta|cmd|bat|msi"
LEET = str.maketrans({"0": "o", "1": "l", "3": "e", "4": "a", "5": "s", "7": "t", "@": "a", "$": "s"})
KNOWN_BRAND_DOMAINS = frozenset({
    "microsoftonline.com", "microsoft.com", "office.com", "live.com", "outlook.com", "windows.net", "sharepoint.com",
    "onedrive.com", "paypal.com", "apple.com", "icloud.com", "amazon.com", "amazonaws.com", "google.com", "gmail.com",
    "googleapis.com", "netflix.com", "chase.com", "wellsfargo.com", "bankofamerica.com", "dhl.com", "fedex.com",
    "ups.com", "usps.com", "docusign.com", "docusign.net", "dropbox.com", "adobe.com", "linkedin.com", "facebook.com",
    "zoom.us", "webex.com", "irs.gov", "hmrc.gov.uk", "googleusercontent.com", "gstatic.com",
    "dropboxusercontent.com", "paypalobjects.com", "whatsapp.net", "sharepointonline.com", "intuit.com",
})
SHORTENERS = ("bit.ly", "tinyurl.com", "t.co", "goo.gl", "ow.ly", "is.gd", "buff.ly", "cutt.ly", "rb.gy")
# Extensions that are also everyday technical nouns (node.js, index.html, disk.img, build.cmd):
# reported only when attachment wording appears within ATTACHMENT_WINDOW characters.
AMBIGUOUS_ATTACHMENT_EXT = frozenset({"js", "htm", "html", "img", "cmd"})
ATTACHMENT_WINDOW = 60
ATTACHMENT_CONTEXT_RE = re.compile(
    r"(?i)\b(?:attach(?:ed|ment|ments)?|files?|download(?:ed)?|open|enclosed|document|invoice|receipt)\b"
)
SYNTHETIC_MARK_RE = re.compile(r"(?i)^\s*\[synthetic\]\s*")

URL_RE = re.compile(r"(?i)\b(?:https?://|www\.)[^\s<>\"')\]]+")
ANCHOR_RE = re.compile(r"(?is)<a\b[^>]*?href\s*=\s*[\"']?([^\"'\s>]+)[^>]*>(.*?)</a>")
IP_URL_RE = re.compile(r"(?i)\bhttps?://(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?(?:/|\b)")
EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
REPLY_REDIRECT_RE = re.compile(
    r"(?i)\b(?:reply|respond|write|send|forward|contact|email|e-mail|get back)\b[^.\n]{0,60}?"
    r"([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})"
)
REPLY_TO_HEADER_RE = re.compile(r"(?im)^\s*reply-to\s*:\s*(\S+@\S+)")
ATTACHMENT_RE = re.compile(rf"(?i)\b[\w\-.]{{1,60}}\.({RISKY_ATTACHMENT_EXT})\b")
TAG_RE = re.compile(r"<[^>]+>")
CC_SLD = {"co", "com", "org", "net", "ac", "gov", "edu"}


def _host(url: str) -> str:
    u = url.strip()
    u = re.sub(r"(?i)^https?://", "", u)
    host = u.split("/", 1)[0].split("?", 1)[0].split("#", 1)[0]
    host = host.rsplit("@", 1)[-1].split(":", 1)[0]
    return host.strip(".,;:!?").lower()  # URL_RE may swallow trailing sentence punctuation


def _registrable(host: str) -> str:
    """Approximate eTLD+1 without a public-suffix list: 'a.b.example.co.uk' -> 'example.co.uk'."""
    labels = host.split(".")
    if len(labels) >= 3 and labels[-2] in CC_SLD and len(labels[-1]) == 2:
        return ".".join(labels[-3:])
    return ".".join(labels[-2:]) if len(labels) >= 2 else host


def _plain(text: str) -> str:
    return html.unescape(TAG_RE.sub(" ", text))


def _brand_in(brand: str, host: str) -> bool:
    """Brand match on label boundaries: '.' and '-' delimit, letters and digits do not, so
    'paypal.com.evil.example' and 'paypal-login.example' hit while 'firstbank.example'
    (irs), 'backups.example' (ups), 'zoomedphotos.example' and 'purchase-orders.example'
    (chase) do not. Brands of six or more characters also match inside a label
    ('microsoftsupport.example'), where a collision with an ordinary word is unlikely."""
    if re.search(rf"(?<![a-z0-9]){re.escape(brand)}(?![a-z0-9])", host):
        return True
    return len(brand) >= 6 and brand in host


def _fmt_list(items: list[str], limit: int = 4) -> str:
    items = list(dict.fromkeys(i.replace("`", "'") for i in items))
    shown = ", ".join(f"`{i}`" for i in items[:limit])
    return shown + (f" (+{len(items) - limit} more)" if len(items) > limit else "")


def rule_based_cues(subject: str, body: str) -> list[str]:
    """Heuristic cues as markdown bullets. Deliberately simple regexes: these are reading
    aids for the analyst, not features of the classifier, so recall matters more than
    precision and every cue states what it matched."""
    raw = f"{subject}\n\n{body}"
    text = _plain(raw)
    low = text.lower()
    cues: list[str] = []

    hits = [w for w in URGENCY_WORDS if w in low]
    if hits:
        cues.append(f"**Urgency / pressure language:** {_fmt_list(hits)}")
    hits = [p for p in CREDENTIAL_LURE_PHRASES if p in low]
    if hits:
        cues.append(f"**Credential-lure phrasing:** {_fmt_list(hits)}")
    hits = [g for g in GENERIC_GREETINGS if g in low]
    if hits:
        cues.append(f"**Generic greeting (no personal name):** {_fmt_list(hits)}")

    mismatched: list[str] = []
    for href, inner in ANCHOR_RE.findall(raw):
        href_host = _host(href)
        for shown in URL_RE.findall(_plain(inner)):
            shown_host = _host(shown)
            if shown_host and href_host and _registrable(shown_host) != _registrable(href_host):
                mismatched.append(f"{shown_host} -> {href_host}")
    if mismatched:
        cues.append(f"**Link text points to a different host than its href:** {_fmt_list(mismatched)}")

    urls = URL_RE.findall(raw) + [h for h, _ in ANCHOR_RE.findall(raw)]
    hosts = sorted({_host(u) for u in urls if _host(u)})
    ip_hits = IP_URL_RE.findall(raw)
    if ip_hits:
        cues.append(f"**URL with a raw IP address instead of a domain:** {_fmt_list(ip_hits)}")
    lookalike: list[str] = []
    for host in hosts:
        reg = _registrable(host)
        if reg in KNOWN_BRAND_DOMAINS:
            continue
        reg_label = reg.split(".")[0]
        labels = host.split(".")
        if any(l.count("-") >= 2 for l in labels):
            lookalike.append(f"{host} (many hyphens)")
        deleet = host.translate(LEET)
        for brand in BRANDS:
            b = brand.replace(" ", "")
            in_host = _brand_in(b, host)
            if not in_host and _brand_in(b, deleet):
                lookalike.append(f"{host} (digits/symbols standing in for letters in '{brand}')")
                break
            if in_host and reg_label != b and not reg.startswith(b + "."):
                lookalike.append(f"{host} ('{brand}' appears, but the registered domain is {reg})")
                break
        if reg in SHORTENERS:
            lookalike.append(f"{host} (URL shortener hides the destination)")
    if lookalike:
        cues.append(f"**Suspicious / lookalike domain:** {_fmt_list(lookalike)}")

    words = [w for w in re.findall(r"[A-Za-z]{3,}", text)]
    caps = [w for w in words if w.isupper()]
    if len(words) >= 8 and len(caps) >= 5 and len(caps) / len(words) >= 0.30:
        cues.append(f"**Heavy ALL-CAPS use:** {len(caps)} of {len(words)} words ({100 * len(caps) / len(words):.0f}%)")

    redirects = [m.group(1) for m in REPLY_REDIRECT_RE.finditer(text)] + REPLY_TO_HEADER_RE.findall(raw)
    if redirects:
        cues.append(f"**Asks for a reply to a specific (possibly different) address:** {_fmt_list(redirects)}")

    no_urls = URL_RE.sub(" ", text)  # a URL path like /share/open.htm is a link, not an attachment
    attachments: list[str] = []
    for m in ATTACHMENT_RE.finditer(no_urls):
        if m.group(1).lower() in AMBIGUOUS_ATTACHMENT_EXT:
            window = no_urls[max(0, m.start() - ATTACHMENT_WINDOW): m.end() + ATTACHMENT_WINDOW]
            if not ATTACHMENT_CONTEXT_RE.search(window):
                continue
        attachments.append(m.group(0).strip("."))
    if attachments:
        cues.append(f"**Mentions a risky attachment type (.zip, .htm, .iso, .docm, .exe, ...):** {_fmt_list(attachments)}")
    return cues


# ----------------------------------------------------------------------------- the Gradio function
def predict(subject: str, body: str) -> tuple[dict[str, float], float, list[tuple[str, str | None]], str]:
    """Gradio entry point: (class probabilities, prediction time s, saliency highlights, signals markdown).

    The model input is ``subject + "\\n\\n" + body`` exactly as in training (never sender
    or headers: TREC headers are synthetic). Prediction time covers tokenize + forward +
    softmax only, matching the instructor's benchmark definition; saliency and rules
    are timed separately and reported in the signals panel.
    """
    # A leading "[SYNTHETIC]" demo tag is UI metadata, not email text: it is stripped so the
    # classifier (which never saw such a token sequence in training) scores only the email.
    marked = bool(SYNTHETIC_MARK_RE.match(subject or ""))
    subject = SYNTHETIC_MARK_RE.sub("", subject or "").strip()
    body = (body or "").rstrip()
    text = f"{subject}\n\n{body}"
    if not text.strip():
        raise gr.Error("Paste an email subject and/or body first.")

    out = _infer(text)
    probs: list[float] = out["probs"]
    p_unwanted: float = out["p_unwanted"]
    verdict = LABELS[out["verdict"]]
    label_probs = {name: p for name, p in zip(LABELS, probs)}
    highlights = saliency_highlights(text, out)

    t0 = timer()
    cues = rule_based_cues(subject, body)
    rules_s = timer() - t0
    class_line = ", ".join(f"{name} {100 * p:.1f}%" for name, p in zip(LABELS, probs))
    lines = [
        f"### Model verdict: **{verdict}**",
        f"**{UNWANTED_LABEL}: {100 * p_unwanted:.1f}%**",
        f"Per class: {class_line}.",
        f"The probabilities come from the {N_PARAMS:,}-parameter encoder alone (one softmax over {NUM_LABELS} classes; "
        f"\"{UNWANTED_LABEL}\" is the sum of the non-legitimate classes, decided at 50%). The red words are the tokens "
        f"whose gradient x input saliency pushed hardest toward **{verdict}** (top {TOP_K_TOKENS} tokens, merged into words).",
        "",
        "### Heuristic cues (rules, not the model)",
    ]
    if cues:
        lines += [f"- {c}" for c in cues]
    else:
        lines.append("- _No rule-based cue fired._ Rules are a reading aid; the model can still be right or wrong without them.")
    if marked:
        lines.append("- _Leading `[SYNTHETIC]` demo tag removed from the subject before scoring._")
    trunc = (
        f"; **input truncated** to the first {out['n_tokens']} of {out['full_len']} tokens (the model never sees the rest)"
        if out["truncated"] else ""
    )
    lines += [
        "",
        "### Timing",
        f"- Prediction (tokenize + forward + softmax): **{out['elapsed_s']:.4f} s** on {out['device']} ({out['dtype']})",
        f"- Saliency (extra forward + backward): {out['saliency_s']:.4f} s; rules: {rules_s:.4f} s",
        f"- Tokens: {out['n_tokens']} of max {MAX_LEN}{trunc}",
    ]
    return label_probs, round(out["elapsed_s"], 5), highlights, "\n".join(lines)


# ----------------------------------------------------------------------------- UI text and examples
# The static text is derived from the LOADED export (LABELS, NUM_LABELS, N_PARAMS, MAX_LEN) so a
# two-class fallback export never ships prose promising three classes next to a two-entry Label.
def _params_human(n: int) -> str:
    return f"{n / 1e6:.0f}M" if n >= 1_000_000 else f"{n:,}"


UNWANTED_NAMES: list[str] = LABELS[LEGIT_INDEX + 1:]
_CLASS_LIST = ", ".join(f"**{n}**" for n in LABELS)
_UNWANTED_SUM = " + ".join(f"P({n})" for n in UNWANTED_NAMES)
_UNWANTED_PICK = (
    f"the larger of {' / '.join(UNWANTED_NAMES)}" if len(UNWANTED_NAMES) > 1 else f"**{UNWANTED_NAMES[0]}**"
)
_THREE_WAY = NUM_LABELS == 3 and UNWANTED_NAMES == DEFAULT_LABELS[3][1:]
_APPROX_CHARS = f"{round(MAX_LEN * 3.5, -2):,.0f}"  # ~3.5 characters per BPE token on English email

if _THREE_WAY:
    _WHAT_IT_RETURNS = (
        "the model estimates the probability that it is **legitimate** business mail, a **phishing** attempt "
        "(credential, payment or advance-fee lure) or bulk **spam**. The headline roll-up "
        f"**{UNWANTED_LABEL}** is the sum of the last two."
    )
    _PROVENANCE_BULLET = (
        '- **Spam vs phishing follows corpus provenance, not a per-message review.** "Phishing" rows come from the\n'
        '  Nazario phishing archive, the Nigerian-fraud (advance-fee) archive and BITS synthetic lures; "Spam" rows are\n'
        "  the SpamAssassin and Enron-Spam spam folders. The TREC-05/07 and CEAS-08 positives never separated the two\n"
        '  and were trained only as "unwanted". Expect the phishing/spam boundary to be softer than legitimate/unwanted.\n'
    )
    _SALIENCY_TARGET = "toward the combined phishing + spam mass"
else:
    _WHAT_IT_RETURNS = (
        f"the model returns one probability per class ({_CLASS_LIST}). The headline roll-up "
        f"**{UNWANTED_LABEL}** is {_UNWANTED_SUM}: this export does not separate phishing from spam."
    )
    _PROVENANCE_BULLET = (
        "- This export has a single unwanted class: phishing and bulk spam are not told apart.\n"
    )
    _SALIENCY_TARGET = f"toward {_UNWANTED_SUM}"

DESCRIPTION = f"""
Paste an email **subject** and **body** and {_WHAT_IT_RETURNS}

The classifier is a **{_params_human(N_PARAMS)}-parameter BERT-style encoder written from scratch in plain PyTorch**
(no `transformers`, no pretrained weights): masked-language-model pretraining on deduplicated Enron email, then
fine-tuning on labelled phishing/spam corpora. It is a **defensive** tool for triage and training, built as a
course project by Build IT Systems (BITS).

**Do not paste secrets, passwords, or confidential client content.** The examples below are **synthetic**,
written for this demo; none is a real message, and every domain in them is a reserved `.example` name.
"""
ARTICLE = f"""
### How to read the result
- **Prediction** is one softmax over {NUM_LABELS} classes: {_CLASS_LIST}. The Signals panel
  adds **{UNWANTED_LABEL}** = {_UNWANTED_SUM}, decided at 50%; the verdict is {LABELS[LEGIT_INDEX]} below
  that line and otherwise {_UNWANTED_PICK}. The roll-up is the number to compare with the binary
  runs in the project report.
- **Prediction Time (s)** is tokenize + forward pass + softmax for this one email, measured with
  `timeit.default_timer` exactly like the course reference app. The instructor's benchmark is 0.074 s per sample.
- **Tokens the model weighed most** are gradient x input saliency scores over the model's own token embeddings:
  red words pushed the decision toward the verdict shown (for an unwanted verdict, {_SALIENCY_TARGET}).
  They explain *this model*, not phishing in general.
- **Signals** lists simple regex heuristics (urgency words, credential lures, host mismatches, lookalike domains,
  raw-IP links, ALL-CAPS, reply-to redirection, risky attachment names). They are shown for context only and do
  **not** feed the probability.

### Limitations
{_PROVENANCE_BULLET}- Trained on 1995-2022 English email, mostly 1998-2008 (Enron, TREC, CEAS, SpamAssassin). Modern lures (MFA
  fatigue, QR codes, Teams/Slack phishing) and non-English mail are out of distribution.
- The model reads subject and body text only: no sender, headers, SPF/DKIM, attachments or link reputation.
- Inputs are truncated to {MAX_LEN} tokens (roughly {_APPROX_CHARS} characters); long emails are judged on their opening.
- Length is a known confound in the training data (legitimate mail is on average longer); the error analysis in
  the project report checks for it.
- A probability is not a verdict. Use it to prioritise human review, never to auto-delete mail.

### Links
- Model card and weights: *[private model repo link placeholder]*
- Training code, run log and metrics: *[project repository link placeholder]*
"""
PLACEHOLDER = (
    "Paste the email body here. Plain text or HTML both work; links such as <a href=...> are scanned for "
    "host mismatches. Nothing you paste is stored (flagging is disabled)."
)

EXAMPLES: list[list[str]] = [
    [
        "Action Required: Your Microsoft 365 password expires in 24 hours",
        "Dear user,\n\nYour Office 365 password will expire within 24 hours. To avoid losing access to your mailbox, "
        "verify your account immediately by signing in at:\n\n"
        '<a href="http://login.micr0soft-365-verify.example/reset?u=8f3a9c">https://login.microsoftonline.com</a>\n\n'
        "Failure to act will result in permanent suspension of your account.\n\nIT Security Team\nOffice 365 Support",
    ],
    [
        "Re: Invoice 4471 - updated banking details",
        "Hi Jordan,\n\nQuick one before I board. Our finance team moved banks last week, so please send the payment "
        "for invoice 4471 (USD 48,200) to the new account below rather than the one on the PO. Treat this as time "
        "sensitive; the supplier is holding the shipment until funds clear.\n\nBank: First Meridian\nAccount: "
        "0031-7742-19\nSWIFT: FMRDUS33\n\nI am in meetings all day so email me at ceo.office.priv@fastmail-relay.example "
        "if anything is unclear rather than calling.\n\nThanks,\nMichael\nChief Executive Officer",
    ],
    [
        "A document has been shared with you: Q3_Payroll_Review.docm",
        "Hello,\n\nA colleague shared a file with you via SecureShare.\n\nFile: Q3_Payroll_Review.docm (1.2 MB)\n"
        "Expires: today\n\nOpen document: http://185.212.47.19/share/open.htm?id=771\n\nIf you cannot open the "
        "link, download the attached Q3_Payroll_Review.zip and enable content when prompted.\n\nThis is an automated "
        "message. Please do not reply.",
    ],
    [
        "FINAL HOURS: 70% OFF luxury watches + FREE shipping!!!",
        "CONGRATULATIONS! You have been SELECTED for our EXCLUSIVE VIP SALE.\n\n*** 70% OFF all designer watches, "
        "sunglasses and handbags ***\n*** FREE worldwide shipping - TODAY ONLY ***\n*** Buy 2, get 1 FREE ***\n\n"
        "Thousands of satisfied customers can't be wrong! Shop now: http://bit.ly/vip-watch-deals-2026\n\n"
        "Don't miss out - this offer expires at midnight!\n\nTo unsubscribe, reply with REMOVE in the subject line.",
    ],
    [
        "Agenda for Thursday's project sync",
        "Hi all,\n\nAttaching the agenda for Thursday's 10:00 sync. Main items:\n\n1. Status of the firewall "
        "migration (Dana)\n2. Q4 hardware refresh budget (Priya)\n3. Client onboarding checklist review\n\nIf you "
        "want anything added, reply by Wednesday noon. Dial-in details are unchanged from last week.\n\nThanks,\n"
        "Nathan",
    ],
    [
        "Re: Contract renewal - redlines attached",
        "Hi Sarah,\n\nThanks for turning the redlines around so quickly. We accepted the changes to clauses 4.2 and "
        "7.1 and left a comment on the termination notice period; 60 days works for us but legal prefers 90. "
        "Can we close this out on the call Friday?\n\nAlso, the summary report for September is on the shared "
        "drive under Reports/2026-09.\n\nBest,\nMarcus",
    ],
]
# Shown in the Examples table instead of the raw rows. The "synthetic" marker lives here, in
# the UI only, so the model input is exactly the email text (a "[SYNTHETIC]" token sequence
# in the subject would be out of distribution and perturb the demo predictions).
EXAMPLE_LABELS: list[str] = [
    "Synthetic lure: Microsoft 365 password-expiry credential phish",
    "Synthetic lure: CEO asks to pay an invoice to new bank details (BEC)",
    "Synthetic lure: shared .docm file, raw-IP link, .zip fallback",
    "Synthetic spam: shouting discount blast with a shortener link",
    "Legitimate: agenda for a project sync",
    "Legitimate: contract redlines follow-up",
]


def build_demo() -> gr.Interface:
    return gr.Interface(
        fn=predict,
        inputs=[
            gr.Textbox(label="Subject", lines=1, placeholder="Email subject line"),
            gr.Textbox(label="Email body", lines=14, placeholder=PLACEHOLDER),
        ],
        outputs=[
            gr.Label(num_top_classes=NUM_LABELS, label="Prediction"),
            gr.Number(label="Prediction Time (s)"),
            gr.HighlightedText(
                label="Tokens the model weighed most",
                color_map={"high": "#d62728", "medium": "#ff9896"},
                combine_adjacent=True,
                show_legend=False,
            ),
            gr.Markdown(label="Signals"),
        ],
        title=TITLE,
        description=DESCRIPTION,
        article=ARTICLE,
        examples=EXAMPLES,
        example_labels=EXAMPLE_LABELS,
        cache_examples=False,  # Spaces default is True; caching would run the GPU function at build time
        flagging_mode="never",
        analytics_enabled=False,
        api_name="predict",
    )


# ----------------------------------------------------------------------------- development entry points
SELFTEST_EMAILS: list[tuple[str, str]] = [EXAMPLES[0][:2], EXAMPLES[3][:2], EXAMPLES[4][:2]]  # lure, spam, legitimate


def _set_device(device: str) -> None:
    global MODEL, DEVICE
    DEVICE = device
    MODEL = place_model(MODEL, device)


def selftest() -> int:
    devices = ["cpu"] + (["cuda"] if torch.cuda.is_available() and not ZERO_GPU else [])
    for device in devices:
        _set_device(device)
        LOG.info("selftest on %s", device)
        for subject, body in SELFTEST_EMAILS:
            probs, secs, highlights, details = predict(subject, body)
            painted = [(t, l) for t, l in highlights if l]
            print(f"\n=== [{device}] {subject}")
            print(f"probs={ {k: round(v, 4) for k, v in probs.items()} } prediction_time_s={secs}")
            print(f"highlighted words ({len(painted)}): {painted}")
            print(details)
            assert list(probs) == LABELS and len(probs) == NUM_LABELS, "one probability per class, in index order"
            assert abs(sum(probs.values()) - 1.0) < 1e-5
            p_unwanted = sum(p for name, p in probs.items() if name != LABELS[LEGIT_INDEX])
            assert f"{UNWANTED_LABEL}: {100 * p_unwanted:.1f}%" in details, "headline must report the unwanted roll-up"
            verdict_idx, _ = verdict_from_probs([probs[n] for n in LABELS])
            assert f"**{LABELS[verdict_idx]}**" in details, "verdict wording must follow verdict_from_probs"
            expected = f"{SYNTHETIC_MARK_RE.sub('', subject).strip()}\n\n{body.rstrip()}"
            assert "".join(t for t, _ in highlights) == expected, "highlights must cover the model input text"
    # The static UI text must describe the export that is actually loaded.
    assert all(name in ARTICLE for name in LABELS), "ARTICLE must name every loaded class"
    assert f"one softmax over {NUM_LABELS} classes" in ARTICLE and f"{MAX_LEN} tokens" in ARTICLE
    assert UNWANTED_LABEL in DESCRIPTION and _params_human(N_PARAMS) in DESCRIPTION
    _set_device("cuda" if torch.cuda.is_available() else "cpu")
    print("\nselftest OK")
    return 0


def serve_for(seconds: float) -> int:
    import urllib.request

    demo = build_demo()
    _app, local_url, _share = demo.launch(prevent_thread_lock=True, quiet=True)
    print(f"serving at {local_url} for {seconds:.0f}s")
    status = -1
    for _ in range(20):
        try:
            with urllib.request.urlopen(local_url, timeout=5) as resp:
                status = resp.status
                page = resp.read(4096).decode("utf-8", "replace")
            break
        except Exception:  # server still starting
            time.sleep(0.5)
    print(f"GET {local_url} -> HTTP {status}; html head contains '<html': {'<html' in page.lower() if status == 200 else False}")
    time.sleep(seconds)
    demo.close()
    return 0 if status == 200 else 1


if not ZERO_GPU and __name__ == "__main__":
    # Warm the kernels (CUDA context / oneDNN) so the first user click is not the slow one.
    # Skipped under ZeroGPU: CUDA work is only legal inside @spaces.GPU functions there.
    with torch.inference_mode(), _autocast(DEVICE):
        MODEL(torch.full((1, 16), 5, dtype=torch.int64, device=DEVICE), torch.ones(1, 16, dtype=torch.bool, device=DEVICE))
    LOG.info("warm-up forward done on %s", DEVICE)

if __name__ == "__main__":
    assert ARGS is not None
    if ARGS.selftest:
        raise SystemExit(selftest())
    if ARGS.serve_seconds is not None:
        raise SystemExit(serve_for(ARGS.serve_seconds))
    demo = build_demo()
    demo.launch()
else:
    demo = build_demo()