import os from typing import Dict, Any BASE_MODEL_NAME = "distilbert-base-uncased" # Path to the LoRA adapter produced by training/train_clickbait_classifier.py _MODEL_DIR = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "models", "clickbait-lora", ) _DEFAULT_RESULT: Dict[str, Any] = { "clickbait": False, "clickbait_score": 0.0, "model_status": "not_loaded", "explanation": "Finetuned clickbait model not found. Run " "backend/training/train_clickbait_classifier.py and copy " "the output into backend/models/clickbait-lora/ to enable " "this feature.", } # Lazy-loaded globals - we only import torch/transformers and load # weights the first time analyze_clickbait() is actually called, so a # missing/broken model never slows down or crashes app startup. _model = None _tokenizer = None _load_attempted = False _load_error = None def _try_load_model(): global _model, _tokenizer, _load_attempted, _load_error if _load_attempted: return _load_attempted = True if not os.path.isdir(_MODEL_DIR): _load_error = f"Model directory not found at {_MODEL_DIR}" return try: import torch # noqa: F401 from transformers import AutoTokenizer, AutoModelForSequenceClassification from peft import PeftModel tokenizer = AutoTokenizer.from_pretrained(_MODEL_DIR) base_model = AutoModelForSequenceClassification.from_pretrained( BASE_MODEL_NAME, num_labels=2, id2label={0: "not_clickbait", 1: "clickbait"}, label2id={"not_clickbait": 0, "clickbait": 1}, ) model = PeftModel.from_pretrained(base_model, _MODEL_DIR) model.eval() _model = model _tokenizer = tokenizer except Exception as e: # noqa: BLE001 - intentionally broad, this must never crash the app _load_error = str(e) _model = None _tokenizer = None def _get_headline(text: str) -> str: """Use the first sentence as a headline proxy if no explicit title exists.""" if not text: return "" first_sentence = text.strip().split(".")[0] # Keep it short - headlines/titles are short by nature return first_sentence[:200] def analyze_clickbait(text: str) -> Dict[str, Any]: """ Returns a dict: { "clickbait": bool, "clickbait_score": float (0.0 - 1.0), "model_status": "loaded" | "not_loaded" | "error", "explanation": str } This function NEVER raises. Any failure path returns a safe default so callers (routes/analyze.py) don't need special-case error handling beyond what they already do for the other analyzers. """ if not text or not text.strip(): return { **_DEFAULT_RESULT, "model_status": "not_loaded" if _model is None else "loaded", "explanation": "Empty input text.", } _try_load_model() if _model is None or _tokenizer is None: result = dict(_DEFAULT_RESULT) if _load_error: result["explanation"] = ( "Clickbait model unavailable, using neutral default. " f"(Reason: {_load_error})" ) return result try: import torch headline = _get_headline(text) inputs = _tokenizer( headline, return_tensors="pt", truncation=True, max_length=64, ) with torch.no_grad(): outputs = _model(**inputs) probs = torch.softmax(outputs.logits, dim=-1)[0] clickbait_score = float(probs[1].item()) return { "clickbait": clickbait_score >= 0.5, "clickbait_score": round(clickbait_score, 4), "model_status": "loaded", "explanation": ( f"Finetuned model classified this as " f"{'likely' if clickbait_score >= 0.5 else 'unlikely'} clickbait " f"(confidence {round(clickbait_score, 2)})." ), } except Exception as e: # noqa: BLE001 - never let inference errors reach the caller return { **_DEFAULT_RESULT, "model_status": "error", "explanation": f"Clickbait model inference failed, using neutral default. ({e})", } if __name__ == "__main__": samples = [ "You Won't Believe What Happened Next!", "Central Bank Raises Interest Rates by 0.25 Percent", ] for s in samples: print(s, "->", analyze_clickbait(s))