File size: 5,420 Bytes
9d8100e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
proxy/classifier.py

Layer 2 of the detection pipeline: ML-based sequence classification.
Attempts to load a fine-tuned DistilBERT model from ./models/classifier/.
If the model is not found, falls back to a rule-based scorer that mimics a
classifier output (suitable for development/testing without training).

NOTE: Train a real model with training/finetune.py for production use.
"""

import logging
import os
import random
from typing import Optional

import numpy as np

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Module-level model cache β€” loaded once, reused on subsequent calls
# ---------------------------------------------------------------------------

_model = None
_tokenizer = None
_model_loaded: bool = False
_use_fallback: bool = False

MODEL_PATH = os.path.join(os.path.dirname(__file__), "..", "models", "classifier")


def _load_model() -> None:
    """
    Attempt to load the fine-tuned DistilBERT classifier from disk.
    Sets module-level flags so loading only happens once.
    """
    global _model, _tokenizer, _model_loaded, _use_fallback

    if _model_loaded:
        return

    model_dir = os.path.abspath(MODEL_PATH)

    if not os.path.isdir(model_dir) or not os.listdir(model_dir):
        logger.warning(
            "Classifier model not found at %s. "
            "Using fallback rule-based scorer. "
            "Run training/finetune.py to train a real model.",
            model_dir,
        )
        _use_fallback = True
        _model_loaded = True
        return

    try:
        from transformers import AutoModelForSequenceClassification, AutoTokenizer  # type: ignore
        import torch  # type: ignore

        logger.info("Loading classifier from %s …", model_dir)
        _tokenizer = AutoTokenizer.from_pretrained(model_dir)
        _model = AutoModelForSequenceClassification.from_pretrained(model_dir)
        _model.eval()
        # Move to GPU if available
        device = "cuda" if torch.cuda.is_available() else "cpu"
        _model = _model.to(device)
        logger.info("Classifier loaded successfully (device=%s)", device)
        _use_fallback = False
    except Exception as exc:
        logger.error("Failed to load classifier model: %s β€” falling back to rule-based scorer.", exc)
        _use_fallback = True

    _model_loaded = True


# ---------------------------------------------------------------------------
# Fallback scorer
# ---------------------------------------------------------------------------

def _fallback_score(text: str, heuristic_score: float) -> float:
    """
    Improved rule-based classifier fallback for when the ML model is not available.

    Uses a piecewise linear mapping to approximate a trained classifier's output
    distribution. A real DistilBERT classifier pushes clear injections (high
    heuristic) to 0.85–0.95; this fallback mirrors that behaviour.

    Mapping:
      heuristic 0.0 – 0.2  β†’  classifier 0.00 – 0.35  (clean territory)
      heuristic 0.2 – 0.5  β†’  classifier 0.35 – 0.72  (borderline)
      heuristic 0.5 – 1.0  β†’  classifier 0.72 – 0.96  (injection territory)

    NOTE: Train a real model with training/finetune.py for production use.
    The fallback exists only so the proxy can run end-to-end without training.
    """
    # Seed on text content so results are deterministic per prompt
    rng = random.Random(hash(text) & 0xFFFFFFFF)
    noise = rng.uniform(-0.03, 0.03)

    if heuristic_score >= 0.5:
        # Clear injection: map [0.5, 1.0] β†’ [0.72, 0.96]
        scaled = 0.72 + (heuristic_score - 0.5) * 0.48
    elif heuristic_score >= 0.2:
        # Borderline: map [0.2, 0.5] β†’ [0.35, 0.72]
        scaled = 0.35 + (heuristic_score - 0.2) * (0.37 / 0.3)
    else:
        # Clean: map [0.0, 0.2] β†’ [0.0, 0.35]
        scaled = heuristic_score * 1.75

    return float(min(1.0, max(0.0, scaled + noise)))



# ---------------------------------------------------------------------------
# Public interface
# ---------------------------------------------------------------------------

def classify(text: str, heuristic_score: float = 0.0) -> float:
    """
    Return injection confidence score in [0, 1] for class 1 (injection).

    Args:
        text: The user prompt text to classify.
        heuristic_score: Score from Layer 1, used by the fallback scorer.

    Returns:
        Confidence float between 0 (clean) and 1 (injection).
    """
    _load_model()

    if _use_fallback:
        return _fallback_score(text, heuristic_score)

    try:
        import torch  # type: ignore

        device = next(_model.parameters()).device
        inputs = _tokenizer(
            text,
            return_tensors="pt",
            truncation=True,
            max_length=512,
            padding=True,
        )
        inputs = {k: v.to(device) for k, v in inputs.items()}

        with torch.no_grad():
            outputs = _model(**inputs)
            logits = outputs.logits

        # Softmax over logits β†’ probabilities; index 1 = injection class
        probs = torch.softmax(logits, dim=-1)
        injection_prob = probs[0][1].item()
        return float(injection_prob)

    except Exception as exc:
        logger.error("Classifier inference failed: %s β€” returning heuristic fallback.", exc)
        return _fallback_score(text, heuristic_score)