CoolFace
Apppublic

DevPatel0611/TruthLens

sourceHugging Faceupdated 6mo agoView on Hugging Face
1likes
stage4_inference.py868 linesDownload Raw Back to src
1"""2Stage 4 — Inference Engine (5-Signal Weighted Scoring)3=====================================================4Evaluates articles across five independent signals:5  1. Source Credibility   (30%)6  2. Claim Verification   (30%)7  3. Linguistic Analysis   (20%)8  4. Freshness             (10%)9  5. Ensemble Model Vote   (10%)10Then applies adversarial overrides and maps to a final verdict.11"""12 13import os14import re15import sys16import yaml17import logging18import pickle19import pandas as pd20import numpy as np21import torch22from datetime import datetime, timezone23 24_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))25if str(_PROJECT_ROOT) not in sys.path:26    sys.path.insert(0, str(_PROJECT_ROOT))27 28from src.utils.text_utils import clean_text, build_full_text, word_count as wc_func, text_length_bucket29from src.stage2_preprocessing import KerasStyleTokenizer30 31import sys32setattr(sys.modules['__main__'], 'KerasStyleTokenizer', KerasStyleTokenizer)33 34logger = logging.getLogger("stage4_inference")35 36# ═════════════════════════════════════════════════════════════════════════════37#  CONSTANTS38# ═════════════════════════════════════════════════════════════════════════════39 40CREDIBLE_OUTLETS = {41    "reuters.com", "apnews.com", "bbc.com", "bbc.co.uk", "nytimes.com",42    "washingtonpost.com", "theguardian.com", "cnn.com", "cbsnews.com",43    "nbcnews.com", "abcnews.go.com", "npr.org", "pbs.org", "bloomberg.com",44    "wsj.com", "ft.com", "economist.com", "usatoday.com", "time.com",45    "politico.com", "thehill.com", "axios.com", "propublica.org",46    "snopes.com", "factcheck.org", "politifact.com", "fullfact.org",47    "aljazeera.com", "dw.com", "france24.com", "scmp.com",48    "theatlantic.com", "newyorker.com", "wired.com", "nature.com",49    "sciencemag.org", "thelancet.com", "bmj.com", "who.int",50    "un.org", "whitehouse.gov", "gov.uk", "europa.eu",51    "hindustantimes.com", "ndtv.com", "thehindu.com", "indianexpress.com",52    "timesofindia.indiatimes.com", "livemint.com",53    "abc.net.au", "cbc.ca", "globalnews.ca", "stuff.co.nz",54    "forbes.com", "businessinsider.com", "cnbc.com", "techcrunch.com",55    "arstechnica.com", "theverge.com", "engadget.com",56    "espn.com", "bbc.com/sport", "skysports.com",57}58 59CORROBORATION_OUTLETS_RE = re.compile(60    r"(?i)\b(Reuters|Associated Press|\bAP\b|CBS|BBC|NBC|CNN|"61    r"New York Times|NYT|Washington Post|The Guardian|NPR|PBS|"62    r"Bloomberg|Wall Street Journal|Forbes)\b"63)64 65AUTHOR_PATTERNS = re.compile(66    r"(?i)\b(by|written by|reporter|staff writer|correspondent|"67    r"contributing writer|author|edited by|reported by)\b\s*[A-Z]"68)69BYLINE_NAME_RE = re.compile(r"^[A-Z][a-z]+ [A-Z][a-z]+", re.MULTILINE)70 71SUPERLATIVE_RE = re.compile(72    r"(?i)\b(shocking|massive|unprecedented|bombshell|explosive|"73    r"stunning|jaw-dropping|mind-blowing|unbelievable|outrageous)\b"74)75SENSATIONAL_RE = re.compile(76    r"(?i)(you won't believe|what happened next|this is why|"77    r"one weird trick|exposed|destroyed|slammed)"78)79NO_ATTRIB_RE = re.compile(80    r"(?i)(sources say|it is believed|reportedly|some people say|"81    r"many believe|rumor has it|anonymous source|unconfirmed reports)"82)83PASSIVE_VOICE_RE = re.compile(84    r"(?i)(it is being said|it was reported|it has been claimed|"85    r"it is alleged|it was alleged|it is rumored)"86)87QUOTE_RE = re.compile(r'"([^"]{10,})"')88QUOTE_ATTRIB_RE = re.compile(89    r"(?i)(said|stated|according to|told|announced|confirmed|wrote|called|described|noted|added|explained|argued|claimed)"90)91 92STAT_RE = re.compile(r"\d+\s*%|\d+\s*(million|billion|trillion)", re.IGNORECASE)93CITATION_RE = re.compile(94    r"(?i)(according to|source:|study by|data from|published by|research by|"95    r"report by|survey by|analysis by|statistics from)"96)97 98INSTITUTION_RE = re.compile(99    r"(?i)(university|department of|ministry|commission|institute|agency|"100    r"foundation|world health|WHO|FDA|CDC|NASA|UNICEF|IMF|World Bank)"101)102TEMPORAL_RE = re.compile(103    r"(?i)(this week|this month|recently|new report|just released|"104    r"annual forecast|latest data|new study|breaking|today|yesterday)"105)106 107 108class ModelNotTrainedError(Exception):109    def __init__(self, message="Run python run_pipeline.py --stage 3 first"):110        super().__init__(message)111 112 113# ═════════════════════════════════════════════════════════════════════════════114#  MODEL LOADING (unchanged from original)115# ═════════════════════════════════════════════════════════════════════════════116 117_MODEL_CACHE = {}118 119def load_config():120    cfg_path = os.path.join(_PROJECT_ROOT, "config", "config.yaml")121    with open(cfg_path, "r", encoding="utf-8") as f:122        return yaml.safe_load(f)123 124def _get_model(model_name, cfg):125    """Lazy load models."""126    if model_name in _MODEL_CACHE:127        return _MODEL_CACHE[model_name]128 129    models_dir = os.path.join(_PROJECT_ROOT, cfg.get("paths", {}).get("models_dir", "models/saved"))130 131    if model_name == "logistic":132        import joblib133        fpath = os.path.join(models_dir, "logistic_model", "logistic_model.pkl")134        if not os.path.exists(fpath): raise ModelNotTrainedError()135        _MODEL_CACHE[model_name] = joblib.load(fpath)136 137    elif model_name == "lstm":138        from src.models.lstm_model import BiLSTMClassifier, load_glove_embeddings, pad_sequences139        tok_path = os.path.join(models_dir, "tokenizer.pkl")140        if not os.path.exists(tok_path) or not os.path.exists(os.path.join(models_dir, "lstm_model", "model.pt")):141            raise ModelNotTrainedError()142        with open(tok_path, "rb") as f:143            tok = pickle.load(f)144        glove_path = os.path.join(_PROJECT_ROOT, cfg["paths"]["glove_path"])145        emb_matrix, vocab_size = load_glove_embeddings(glove_path, tok.word_index)146 147        device = torch.device("cuda" if torch.cuda.is_available() else "cpu")148        model = BiLSTMClassifier(vocab_size, emb_matrix).to(device)149        model.load_state_dict(torch.load(os.path.join(models_dir, "lstm_model", "model.pt"), map_location=device))150        model.eval()151        _MODEL_CACHE[model_name] = (model, tok, device)152 153    elif model_name in ("distilbert", "roberta"):154        try:155            from transformers import AutoTokenizer, AutoModelForSequenceClassification156        except ImportError:157            raise ModelNotTrainedError()158        d_path = os.path.join(models_dir, f"{model_name}_model")159        if not os.path.exists(os.path.join(d_path, "config.json")):160            raise ModelNotTrainedError()161        device = torch.device("cuda" if torch.cuda.is_available() else "cpu")162        tok = AutoTokenizer.from_pretrained(d_path)163        model = AutoModelForSequenceClassification.from_pretrained(d_path).to(device)164        model.eval()165        _MODEL_CACHE[model_name] = (model, tok, device)166 167    elif model_name == "meta":168        import joblib169        fpath = os.path.join(models_dir, "meta_classifier", "meta_classifier.pkl")170        if not os.path.exists(fpath): raise ModelNotTrainedError()171        _MODEL_CACHE[model_name] = joblib.load(fpath)172 173    return _MODEL_CACHE[model_name]174 175 176# ═════════════════════════════════════════════════════════════════════════════177#  FEATURE EXTRACTION178# ═════════════════════════════════════════════════════════════════════════════179 180def extract_features(title, text, source_domain, published_date, cfg):181    """Build standardized structural mapping for raw strings."""182    full = build_full_text(title, text)183    clean = clean_text(full)184    wc = wc_func(clean)185    bucket = text_length_bucket(wc)186 187    has_date = pd.notna(published_date) and published_date != ""188    if has_date and isinstance(published_date, str):189        try:190            published_date = pd.to_datetime(published_date, utc=True)191        except Exception:192            has_date = False193            published_date = None194    elif has_date:195        try:196            published_date = pd.Timestamp(published_date, tz="UTC")197        except Exception:198            has_date = False199            published_date = None200 201    return {202        "clean_text": clean,203        "full_text": full,204        "word_count": wc,205        "text_length_bucket": bucket,206        "has_date": has_date,207        "published_date": published_date,208        "source_domain": source_domain if source_domain else "unknown",209    }210 211 212# ═════════════════════════════════════════════════════════════════════════════213#  STEP 1 — SOURCE CREDIBILITY  (weight: 30%)214# ═════════════════════════════════════════════════════════════════════════════215 216def _levenshtein(s1, s2):217    """Minimal Levenshtein distance for typosquatting check."""218    if len(s1) < len(s2):219        return _levenshtein(s2, s1)220    if len(s2) == 0:221        return len(s1)222    prev_row = range(len(s2) + 1)223    for i, c1 in enumerate(s1):224        curr_row = [i + 1]225        for j, c2 in enumerate(s2):226            curr_row.append(min(curr_row[j] + 1, prev_row[j + 1] + 1,227                                prev_row[j] + (c1 != c2)))228        prev_row = curr_row229    return prev_row[-1]230 231 232def score_source_credibility(source_domain, title, text):233    """234    Step 1: Evaluate source trustworthiness.235    Returns: (score, author_found, typosquatting_detected)236    """237    # ── Early return: no source at all ──238    if not source_domain or source_domain.strip() == "" or source_domain == "unknown":239        # Still check for author in text body240        author_found = bool(AUTHOR_PATTERNS.search(text[:500])) or bool(BYLINE_NAME_RE.search(text[:200]))241        return 0.3, author_found, False242 243    domain = source_domain.strip().lower()244 245    # ── Typosquatting check ──246    for outlet in CREDIBLE_OUTLETS:247        dist = _levenshtein(domain, outlet)248        if 0 < dist <= 2:  # close but not exact249            return 0.0, False, True250 251    # ── Component scoring ──252    score = 0.0253 254    # Base: any valid domain255    score += 0.20256 257    # Known outlet258    if domain in CREDIBLE_OUTLETS:259        score += 0.40260 261    # Author verifiability262    search_area = text[:500]263    author_found = bool(AUTHOR_PATTERNS.search(search_area)) or bool(BYLINE_NAME_RE.search(text[:200]))264    if author_found:265        score += 0.20266 267    # Corroboration: text mentions other major outlets268    if CORROBORATION_OUTLETS_RE.search(text):269        score += 0.20270 271    return min(1.0, score), author_found, False272 273 274# ═════════════════════════════════════════════════════════════════════════════275#  STEP 2 — CLAIM VERIFICATION  (weight: 30%)276# ═════════════════════════════════════════════════════════════════════════════277 278_SPACY_NLP = None279 280def _get_spacy():281    global _SPACY_NLP282    if _SPACY_NLP is None:283        import spacy284        try:285            _SPACY_NLP = spacy.load("en_core_web_sm")286        except OSError:287            import subprocess288            subprocess.run([sys.executable, "-m", "spacy", "download", "en_core_web_sm"], check=True)289            _SPACY_NLP = spacy.load("en_core_web_sm")290    return _SPACY_NLP291 292 293def score_claim_verification(meta_proba, clean_text_str, title):294    """295    Step 2: Entity-level claim verification.296    Returns: (claim_score, entities_found, n_verifiable, quotes_attributed, quotes_total)297    """298    nlp = _get_spacy()299    # Process a capped version to avoid memory issues on long articles300    doc = nlp(clean_text_str[:5000])301 302    # Sub-step A: Named Entity Extraction303    verifiable_types = {"PERSON", "ORG", "GPE"}304    numeric_types = {"MONEY", "PERCENT", "CARDINAL"}305 306    verifiable_ents = [ent.text for ent in doc.ents if ent.label_ in verifiable_types]307    numeric_ents = [ent for ent in doc.ents if ent.label_ in numeric_types]308 309    n_verifiable = len(set(verifiable_ents))310 311    # Count unverifiable numeric claims (no citation within ±100 chars)312    n_unverifiable = 0313    for ent in numeric_ents:314        start = max(0, ent.start_char - 100)315        end = min(len(clean_text_str), ent.end_char + 100)316        context = clean_text_str[start:end]317        if not CITATION_RE.search(context):318            n_unverifiable += 1319 320    # Sub-step B: Quote Attribution321    quotes = QUOTE_RE.findall(clean_text_str[:5000])322    quotes_total = len(quotes)323    quotes_attributed = 0324 325    for q in quotes:326        q_pos = clean_text_str.find(q)327        if q_pos == -1:328            continue329        context_start = max(0, q_pos - 50)330        context_end = min(len(clean_text_str), q_pos + len(q) + 50)331        context = clean_text_str[context_start:context_end]332        if QUOTE_ATTRIB_RE.search(context):333            quotes_attributed += 1334 335    attributed_ratio = (quotes_attributed / quotes_total) if quotes_total > 0 else 1.0336 337    # Sub-step C: Combine338    entity_score = min(1.0, n_verifiable / 3)  # 3+ verifiable entities = full marks339    unverifiable_penalty = min(0.15, n_unverifiable * 0.05)340 341    claim_score = (meta_proba * 0.60) + (entity_score * 0.25) + (attributed_ratio * 0.15)342    claim_score = max(0.0, min(1.0, claim_score - unverifiable_penalty))343 344    entities_found = list(set(verifiable_ents))[:10]  # Cap for JSON output345 346    return claim_score, entities_found, n_verifiable, quotes_attributed, quotes_total347 348 349# ═════════════════════════════════════════════════════════════════════════════350#  STEP 3 — LINGUISTIC ANALYSIS  (weight: 20%)351# ═════════════════════════════════════════════════════════════════════════════352 353def score_linguistic_quality(title, text, clean_text_str, author_found, cfg=None):354    """355    Step 3: Rule-based linguistic quality scoring.356    Reuses DistilBERT for headline contradiction check.357    Returns: (linguistic_score, deductions_applied, headline_contradicts)358    """359    score = 1.0360    deductions = []361    headline_contradicts = False362    title_str = str(title) if title else ""363 364    # ── 1. Sensationalist headline (-0.20) ──365    sensational = False366    if title_str:367        caps_words = re.findall(r"\b[A-Z]{4,}\b", title_str)368        if len(caps_words) >= 1:369            sensational = True370        if "!" in title_str:371            sensational = True372        if SENSATIONAL_RE.search(title_str):373            sensational = True374    if sensational:375        score -= 0.20376        deductions.append("Sensationalist headline detected")377 378    # ── 2. Excessive superlatives (-0.15, needs ≥2 matches) ──379    superlative_matches = SUPERLATIVE_RE.findall(clean_text_str)380    if len(superlative_matches) >= 2:381        score -= 0.15382        deductions.append(f"Excessive superlatives ({len(superlative_matches)} found)")383 384    # ── 3. No attribution (-0.15) ──385    if NO_ATTRIB_RE.search(clean_text_str):386        score -= 0.15387        deductions.append("Anonymous/vague attribution patterns found")388 389    # ── 4. Headline contradicts body (-0.10) ──390    # Guard: only run if title looks like a real headline, not an auto-extracted body sentence391    is_real_headline = (392        title_str393        and len(title_str) > 10394        and len(title_str.split()) <= 15395        and not title_str.lower().startswith(("it has", "it was", "it is", "there was", "there is"))396        and title_str.lower() not in str(text).lower()[:100]397    )398    if is_real_headline:399        body_only = str(text)[:512]  # Raw body text, NOT clean_text_str which has title prepended400        try:401            if "distilbert" in _MODEL_CACHE:402                model, tok, device = _MODEL_CACHE["distilbert"]403                with torch.no_grad():404                    t_enc = tok(title_str, return_tensors="pt", truncation=True, max_length=64, padding=True).to(device)405                    b_enc = tok(body_only, return_tensors="pt", truncation=True, max_length=512, padding=True).to(device)406                    t_hidden = model.distilbert(**t_enc).last_hidden_state[:, 0, :]  # CLS token407                    b_hidden = model.distilbert(**b_enc).last_hidden_state[:, 0, :]408                    cos_sim = float(torch.nn.functional.cosine_similarity(t_hidden, b_hidden).item())409                    if cos_sim < 0.30:410                        headline_contradicts = True411                        score -= 0.10412                        deductions.append(f"Headline may contradict body (similarity={cos_sim:.2f})")413        except Exception as e:414            # Fallback: simple word overlap against body only415            title_words = set(title_str.lower().split())416            body_words = set(body_only.lower().split())417            overlap = len(title_words & body_words) / max(len(title_words), 1)418            if overlap < 0.15 and len(title_words) > 3:419                headline_contradicts = True420                score -= 0.10421                deductions.append("Headline has very low word overlap with body")422 423    # ── 5. Internal contradictions (-0.10) ──424    # Heuristic: negation near repeated noun phrase425    sentences = re.split(r'[.!?]+', clean_text_str[:3000])426    negation_re = re.compile(r"\b(not|no|never|false|deny|denied|incorrect|wrong)\b", re.IGNORECASE)427    noun_counts = {}428    contradiction_found = False429    for sent in sentences:430        words = sent.lower().split()431        # Track nouns (simple: capitalized words in original text)432        for w in words:433            if len(w) > 3:434                noun_counts[w] = noun_counts.get(w, 0) + 1435        # Check if a repeated noun appears near negation436        if negation_re.search(sent):437            for w in words:438                if noun_counts.get(w, 0) >= 2 and len(w) > 4:439                    contradiction_found = True440                    break441        if contradiction_found:442            break443    if contradiction_found:444        score -= 0.10445        deductions.append("Possible internal contradiction detected")446 447    # ── 6. Passive voice obscuring agency (-0.10) ──448    if PASSIVE_VOICE_RE.search(clean_text_str):449        score -= 0.10450        deductions.append("Passive voice used to obscure agency")451 452    # ── 7. Missing byline (-0.05) ──453    if not author_found:454        score -= 0.05455        deductions.append("No byline or author attribution found")456 457    score = max(0.0, score)458    return score, deductions, headline_contradicts459 460 461# ═════════════════════════════════════════════════════════════════════════════462#  STEP 4 — FRESHNESS  (weight: 10%)463# ═════════════════════════════════════════════════════════════════════════════464 465def score_freshness_v2(published_date, has_date, title, text):466    """467    Step 4: Temporal freshness scoring.468    Case A: Date found → bracket-based scoring.469    Case B: No date → contextual signal scanning.470    Returns: (score, case, signals_found)471    """472    if has_date and published_date is not None:473        # ── Case A ──474        now = datetime.now(timezone.utc)475        try:476            if getattr(published_date, 'tzinfo', None) is None:477                published_date = published_date.replace(tzinfo=timezone.utc)478            days_old = (now - published_date).days479        except Exception:480            # Fallback to Case B if date math fails481            return _freshness_case_b(title, text)482 483        if days_old < 0:484            days_old = 0485 486        if days_old < 30:487            return 1.0, "A", []488        elif days_old <= 180:489            return 0.75, "A", []490        elif days_old <= 730:  # 2 years491            return 0.5, "A", []492        else:493            return 0.2, "A", []494    else:495        return _freshness_case_b(title, text)496 497 498def _freshness_case_b(title, text):499    """Case B: No date found — scan for contextual freshness signals."""500    combined = str(title) + " " + str(text)501    signals = []502    now = datetime.now()503 504    # Signal 1: Current year mentioned (dynamic)505    year_re = re.compile(r"\b(" + str(now.year) + r"|" + str(now.year - 1) + r")\b")506    if year_re.search(combined):507        signals.append(f"Current/recent year mentioned ({now.year} or {now.year-1})")508 509    # Signal 2: Temporal phrases510    if TEMPORAL_RE.search(combined):511        signals.append("Temporal freshness phrase detected")512 513    # Signal 3: Named institution514    if INSTITUTION_RE.search(combined):515        signals.append("Named institutional publisher found")516 517    # Signal 4: Major outlet corroboration518    if CORROBORATION_OUTLETS_RE.search(combined):519        signals.append("Major outlet corroboration cited")520 521    score_map = {4: 0.80, 3: 0.70, 2: 0.60, 1: 0.50, 0: 0.40}522    n = min(len(signals), 4)523    return score_map[n], "B", signals524 525 526# ═════════════════════════════════════════════════════════════════════════════527#  STEP 5 — MODEL VOTE  (weight: 10%)528# ═════════════════════════════════════════════════════════════════════════════529 530def score_model_vote(votes):531    """Step 5: Proportion of TRUE votes from the ensemble."""532    if not votes:533        return 0.5534    return sum(votes.values()) / len(votes)535 536 537# ═════════════════════════════════════════════════════════════════════════════538#  ADVERSARIAL OVERRIDE539# ═════════════════════════════════════════════════════════════════════════════540 541def check_adversarial_flags(has_date, author_found, n_verifiable, headline_contradicts,542                            typosquatting_detected, text):543    """544    Post-scoring adversarial check.545    Any flag → cap final_score at 0.25.546    Returns: list of triggered flag names.547    """548    flags = []549 550    # Flag 1: Triple anonymity551    if not has_date and not author_found and n_verifiable == 0:552        flags.append("Triple anonymity (no date, no author, no named sources)")553 554    # Flag 2: Headline contradicts body555    if headline_contradicts:556        flags.append("Headline contradicts article body")557 558    # Flag 3: Typosquatting559    if typosquatting_detected:560        flags.append("Domain mimics a known outlet (typosquatting)")561 562    # Flag 4: Statistics without traceable source563    stats_found = STAT_RE.findall(text)564    if stats_found:565        # Check if any citation pattern exists in the text566        if not CITATION_RE.search(text):567            flags.append("Statistics cited with no traceable primary source")568 569    return flags570 571 572# ═════════════════════════════════════════════════════════════════════════════573#  REASON BUILDER574# ═════════════════════════════════════════════════════════════════════════════575 576def build_reasons_and_missing(scores, n_verifiable, author_found, has_date,577                              deductions, adversarial_flags):578    """579    Programmatically generate top_reasons and missing_signals from scores.580    Returns: (reasons[:3], missing_signals)581    """582    reasons = []583    missing = []584 585    # ── Negative signals ──586    if scores["source"] < 0.4:587        reasons.append("Source is unknown or not editorially accountable")588    if scores["claim"] < 0.5:589        reasons.append("Core claims could not be fully verified")590    if scores["linguistic"] < 0.7:591        reasons.append("Writing style shows signs of sensationalism or manipulation")592    if scores["freshness"] < 0.5:593        reasons.append("Article age or missing date reduces temporal reliability")594    if scores["model_vote"] < 0.5:595        reasons.append("AI models flagged patterns inconsistent with credible journalism")596 597    # ── Positive signals ──598    if scores["source"] >= 0.8:599        reasons.append("Article is from a known, credible outlet")600    if scores["claim"] >= 0.8:601        reasons.append("Core claims are well-attributed with verifiable entities")602    if scores["linguistic"] >= 0.9:603        reasons.append("Writing style is neutral and well-attributed")604    if scores["model_vote"] >= 0.75:605        reasons.append("AI models strongly agree this content is credible")606 607    # ── Adversarial flags ──608    for flag in adversarial_flags:609        reasons.append(f"Adversarial flag: {flag}")610 611    # ── Missing signals ──612    if not author_found:613        missing.append("Author identity could not be verified")614    if not has_date:615        missing.append("Publication date not found")616    if scores["source"] <= 0.3:617        missing.append("Source domain not recognized")618    if n_verifiable == 0:619        missing.append("No verifiable named entities found in text")620 621    return reasons[:3], missing622 623 624# ═════════════════════════════════════════════════════════════════════════════625#  MAIN INFERENCE INTERFACE626# ═════════════════════════════════════════════════════════════════════════════627 628def predict_article(title, text, source_domain, published_date, mode="full", trigger_rag=True):629    """630    5-Signal weighted scoring inference.631 632    Execution order:633      1. extract_features()634      2. Run base models (LR/LSTM/DistilBERT/RoBERTa) → probas, votes635      3. Run meta-classifier → meta_proba636      4. Step 1: score_source_credibility()637      5. Step 2: score_claim_verification()638      6. Step 3: score_linguistic_quality() [needs author_found from Step 1]639      7. Step 4: score_freshness_v2()640      8. Step 5: score_model_vote()641      9. Weighted final score + adversarial override + verdict642    """643    cfg = load_config()644    feat = extract_features(title, text, source_domain, published_date, cfg)645 646    probas = {647        "lr_proba": np.nan, "lstm_proba": np.nan,648        "distilbert_proba": np.nan, "roberta_proba": np.nan,649    }650    votes = {}651 652    # ── Base Model Inference ──────────────────────────────────────────────653 654    # 1. Logistic Regression655    if mode in ("fast", "balanced", "full"):656        lr_pipe = _get_model("logistic", cfg)657        df_lr = pd.DataFrame([{658            "clean_text": feat["clean_text"],659            "word_count": feat["word_count"],660            "text_length_bucket": feat["text_length_bucket"],661            "has_date": 1 if feat["has_date"] else 0,662            "freshness_score": 0.5,  # neutral for model input663            "source_domain": feat["source_domain"],664        }])665        try:666            p = float(lr_pipe.predict_proba(df_lr)[:, 1][0])667            probas["lr_proba"] = p668            votes["logistic"] = int(p >= 0.5)669        except Exception as e:670            logger.warning(f"LR inference failed: {e}")671 672    # 2. Bi-LSTM673    if mode in ("balanced", "full"):674        lstm_model, tok, device = _get_model("lstm", cfg)675        maxlen = cfg.get("preprocessing", {}).get("lstm_max_len", 512)676        from src.models.lstm_model import pad_sequences677 678        seq = tok.texts_to_sequences([feat["clean_text"]])679        pad = pad_sequences(seq, maxlen=maxlen, padding='post')680        t_pad = torch.from_numpy(pad).long().to(device)681 682        with torch.no_grad():683            logits = lstm_model(t_pad)684            p = float(torch.sigmoid(logits).cpu().numpy()[0])685            probas["lstm_proba"] = p686            votes["lstm"] = int(p >= 0.5)687 688    # 3. Transformers (DistilBERT + RoBERTa)689    if mode == "full":690        for t_name in ("distilbert", "roberta"):691            model, tok, device = _get_model(t_name, cfg)692            inputs = tok(feat["clean_text"], padding=True, truncation=True,693                         max_length=512, return_tensors="pt").to(device)694            with torch.no_grad():695                out = model(**inputs)696                p = float(torch.softmax(out.logits, dim=-1)[0, 1].item())697                if t_name == "roberta":698                    p = p * 0.92  # RoBERTa TRUE-bias dampening699                probas[t_name + "_proba"] = p700                votes[t_name] = int(p >= 0.5)701 702    # 4. Meta-Classifier703    meta_bundle = _get_model("meta", cfg)704    meta_preprocessor = meta_bundle["preprocessor"]705    meta_model = meta_bundle["model"]706 707    df_meta = pd.DataFrame([{708        "lr_proba": probas["lr_proba"],709        "lstm_proba": probas["lstm_proba"],710        "distilbert_proba": probas["distilbert_proba"],711        "roberta_proba": probas["roberta_proba"],712        "word_count": feat["word_count"],713        "has_date": 1 if feat["has_date"] else 0,714        "freshness_score": 0.5,  # neutral — freshness is now scored separately in Step 4715    }])716 717    df_cats = pd.DataFrame([{718        "text_length_bucket": feat["text_length_bucket"],719        "source_domain": feat["source_domain"],720    }])721    cat_feats = meta_preprocessor.transform(df_cats)722    X_meta = np.hstack((df_meta.values, cat_feats))723 724    meta_proba = float(meta_model.predict_proba(X_meta)[:, 1][0])725 726    # Short-text dampening (under 50 words)727    short_text = feat["word_count"] < 50728    if short_text:729        meta_proba = 0.5 + (meta_proba - 0.5) * 0.6730 731    # ── 5-Signal Scoring ─────────────────────────────────────────────────732 733    # Step 1: Source Credibility734    source_score, author_found, typosquat = score_source_credibility(735        feat["source_domain"], title, text736    )737 738    # Step 2: Claim Verification739    claim_score, entities_found, n_verifiable, q_attr, q_total = score_claim_verification(740        meta_proba, feat["clean_text"], title741    )742 743    # Step 3: Linguistic Analysis (depends on author_found from Step 1)744    ling_score, deductions, headline_contradicts = score_linguistic_quality(745        title, text, feat["clean_text"], author_found, cfg746    )747 748    # Step 4: Freshness749    fresh_score, fresh_case, fresh_signals = score_freshness_v2(750        feat.get("published_date"), feat["has_date"], title, text751    )752 753    # Step 5: Model Vote754    vote_score = score_model_vote(votes)755 756    # ── Final Weighted Score ──────────────────────────────────────────────757 758    scores = {759        "source": round(source_score, 4),760        "claim": round(claim_score, 4),761        "linguistic": round(ling_score, 4),762        "freshness": round(fresh_score, 4),763        "model_vote": round(vote_score, 4),764    }765 766    final_score = (767        source_score * 0.30 +768        claim_score * 0.30 +769        ling_score * 0.20 +770        fresh_score * 0.10 +771        vote_score * 0.10772    )773 774    # ── Adversarial Override ──────────────────────────────────────────────775 776    adv_flags = check_adversarial_flags(777        feat["has_date"], author_found, n_verifiable,778        headline_contradicts, typosquat, feat["clean_text"]779    )780    if adv_flags:781        final_score = min(final_score, 0.25)782 783    final_score = round(final_score, 4)784 785    # ── Verdict ───────────────────────────────────────────────────────────786 787    if final_score >= 0.75:788        verdict = "TRUE"789    elif final_score >= 0.55:790        verdict = "UNCERTAIN"791    elif final_score >= 0.35:792        verdict = "LIKELY FALSE"793    else:794        verdict = "FALSE"795 796    # ── Reasons & Missing Signals ─────────────────────────────────────────797 798    top_reasons, missing_signals = build_reasons_and_missing(799        scores, n_verifiable, author_found, feat["has_date"],800        deductions, adv_flags801    )802 803    # ── Confidence ────────────────────────────────────────────────────────804 805    missing_count = len(missing_signals)806    if adv_flags or missing_count >= 3:807        confidence = "LOW"808    elif verdict == "UNCERTAIN" or missing_count in (1, 2):809        confidence = "MEDIUM"810    elif final_score >= 0.75 or final_score < 0.35:811        confidence = "HIGH"812    else:813        confidence = "MEDIUM"814 815    # ── Recommended Action + LOW Guard ────────────────────────────────────816 817    action_map = {818        "TRUE": "Publish",819        "UNCERTAIN": "Flag for review",820        "LIKELY FALSE": "Suppress",821        "FALSE": "Escalate",822    }823    recommended_action = action_map[verdict]824 825    # Hard rule: LOW confidence → never "Publish"826    if confidence == "LOW" and recommended_action == "Publish":827        recommended_action = "Flag for review"828 829    # ── Return Full JSON ──────────────────────────────────────────────────830 831    return {832        "verdict": verdict,833        "final_score": final_score,834        "scores": scores,835        "freshness_case": fresh_case,836        "freshness_signals_found": fresh_signals,837        "adversarial_flags": adv_flags,838        "top_reasons": top_reasons,839        "missing_signals": missing_signals,840        "confidence": confidence,841        "recommended_action": recommended_action,842        "base_model_votes": votes,843        "base_model_probas": probas,844        "word_count": feat["word_count"],845        "short_text_warning": short_text,846        "deductions_applied": deductions,847        "entities_found": entities_found,848        "quotes_attributed": q_attr,849        "quotes_total": q_total,850    }851 852 853if __name__ == "__main__":854    import json855    try:856        res = predict_article(857            "Breaking: AI solves P=NP",858            "The algorithm has shocked absolutely everyone across the earth entirely "859            "resolving everything overnight. Sources say it is unprecedented.",860            "techcrunch.com",861            datetime.now().isoformat(),862            mode="fast"863        )864        print("Verdict Dict:")865        print(json.dumps(res, indent=2, default=str))866    except ModelNotTrainedError as e:867        print("ERROR:", str(e))868