File size: 4,692 Bytes
c8b1fd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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))