DevPatel0611/TruthLens
1
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 