PromptArmor / proxy /classifier.py
Shourya Kakkar
Initial commit for PromptArmor SaaS
9d8100e
Raw
History Blame Contribute Delete
5.42 kB
"""
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)