# ============================================================ # TruthLens — AI Fake News Detector (High Accuracy & Simplified) # HuggingFace Spaces compatible (Gradio native components) # Stack: HuggingFace Transformers + DuckDuckGo Search + Gradio # ============================================================ import re import json from urllib.parse import urlparse import numpy as np import torch import gradio as gr from PIL import Image, ImageEnhance, ImageOps import pytesseract from transformers import ( pipeline, AutoTokenizer, AutoModelForSeq2SeqLM, ) from duckduckgo_search import DDGS # ───────────────────────────────────────── # 1. LOAD MODELS # ───────────────────────────────────────── print("Loading summariser (facebook/bart-large-cnn)...") _SUM_NAME = "facebook/bart-large-cnn" _sum_tokenizer = AutoTokenizer.from_pretrained(_SUM_NAME) _sum_model = AutoModelForSeq2SeqLM.from_pretrained(_SUM_NAME) _sum_model.eval() print("Loading fake-news classifier...") classifier = pipeline( "text-classification", model="hamzab/roberta-fake-news-classification", truncation=True, ) print("Loading zero-shot classifier (facebook/bart-large-mnli)...") zs = pipeline( "zero-shot-classification", model="facebook/bart-large-mnli", ) print("All models ready.") # ───────────────────────────────────────── # 2. DOMAIN CREDIBILITY REGISTRY # ───────────────────────────────────────── TRUSTED = { "reuters.com": 10, "apnews.com": 10, "bbc.com": 9, "bbc.co.uk": 9, "snopes.com": 10, "factcheck.org": 10, "politifact.com": 10, "fullfact.org": 9, "afp.com": 9, "nytimes.com": 8, "washingtonpost.com": 8, "theguardian.com": 8, "npr.org": 8, "pbs.org": 8, "theatlantic.com": 7, "economist.com": 8, "ft.com": 8, "bloomberg.com": 7, "time.com": 7, "forbes.com": 6, "cnn.com": 6, "nbcnews.com": 6, "abcnews.go.com": 6, "cbsnews.com": 6, "ndtv.com": 6, "thehindu.com": 7, "hindustantimes.com": 6, "indiatoday.in": 6, "wikipedia.org": 5, } SATIRE = ["theonion.com","babylonbee.com","clickhole.com", "worldnewsdailyreport.com","waterfordwhispersnews.com"] DISREPUTABLE = ["infowars.com","naturalnews.com","beforeitsnews.com"] def domain_score(url): try: d = urlparse(url).netloc.lower().replace("www.", "") except Exception: return 0, "Unknown" for bad in SATIRE: if bad in d: return -8, "Satire" for bad in DISREPUTABLE: if bad in d: return -6, "Disreputable" for good, s in TRUSTED.items(): if d.endswith(good) or good in d: if s >= 9: return s, "Premier" if s >= 7: return s, "Credible" return s, "Mainstream" return 2, "Unknown" # ───────────────────────────────────────── # 3. TEXT UTILITIES # ───────────────────────────────────────── STOP = { "the","a","an","is","was","are","were","in","on","at","to","of", "and","or","for","with","that","this","it","as","by","from", "has","have","had","be","been","not","but","so","do","did","will", } def clean_text(text): text = re.sub(r'http\S+|www\.\S+|\S+@\S+', '', text) text = re.sub(r'[^a-zA-Z0-9\s.,!?\'\"%-]', ' ', text) return re.sub(r'\s+', ' ', text).strip() def build_query(text): # ACCURACY FIX 1: Removed exact quotes so search works better words = re.findall(r'\b[a-zA-Z0-9]{3,}\b', text) meaningful = [w for w in words if w.lower() not in STOP] core = " ".join(meaningful[:12]) return f"{core} news" def ocr_image(image): if image is None: return "" if not isinstance(image, Image.Image): image = Image.fromarray(np.uint8(image)) image = ImageOps.grayscale(image) image = ImageEnhance.Contrast(image).enhance(2.5) image = image.point(lambda x: 0 if x < 140 else 255) return clean_text(pytesseract.image_to_string(image, config='--oem 3 --psm 6')) # ───────────────────────────────────────── # 4. SUMMARISE # ───────────────────────────────────────── def summarise(text): words = text.split() if len(words) < 30: return text chunk = " ".join(words[:800]) try: inputs = _sum_tokenizer(chunk, return_tensors="pt", max_length=1024, truncation=True) with torch.no_grad(): ids = _sum_model.generate( inputs["input_ids"], max_new_tokens=80, min_length=25, num_beams=4, early_stopping=True, ) return _sum_tokenizer.decode(ids[0], skip_special_tokens=True) except Exception: return " ".join(words[:40]) # ───────────────────────────────────────── # 5. WEB SEARCH # ───────────────────────────────────────── def web_search(summary_text): query = build_query(summary_text) try: raw = list(DDGS().text(query, max_results=7)) except Exception as e: print(f"DuckDuckGo API Error: {e}") return [], f"Search error: {e}", 0 sources, total = [], 0 for r in raw: link = r.get("href", "") sc, tier = domain_score(link) total += max(0, sc) sources.append({ "title": r.get("title", "—"), "body": r.get("body", "")[:240], "link": link, "score": sc, "tier": tier, }) sources.sort(key=lambda x: x["score"], reverse=True) return sources, query, total # ───────────────────────────────────────── # 6. VERDICT ENGINE # ───────────────────────────────────────── def verdict_engine(text, web_total, sources): score, signals = 50, [] # --- 1. AI Classifier --- try: r = classifier(text[:512])[0] lbl = r["label"].upper() conf = round(r["score"] * 100, 1) if lbl in ["REAL", "TRUE"]: pts = round(conf / 100 * 40) score += pts signals.append(("🤖 AI Classifier", f"REAL ({conf}%)", f"+{pts}", "pos")) else: pts = round(conf / 100 * 40) score -= pts signals.append(("🤖 AI Classifier", f"FAKE ({conf}%)", f"-{pts}", "neg")) except Exception: signals.append(("🤖 AI Classifier", "Skipped", "±0", "neu")) # --- 2. Zero-Shot NLI --- try: zr = zs(text[:512], ["credible news", "misinformation", "satire"]) top = zr["labels"][0] zcon = round(zr["scores"][0] * 100, 1) if top == "credible news": score += 15 signals.append(("🧠 Zero-Shot NLI", f"Credible ({zcon}%)", "+15", "pos")) elif top == "misinformation": score -= 15 signals.append(("🧠 Zero-Shot NLI", f"Misinformation ({zcon}%)", "-15", "neg")) else: score -= 8 signals.append(("🧠 Zero-Shot NLI", f"Satire ({zcon}%)", "-8", "neg")) except Exception: signals.append(("🧠 Zero-Shot NLI", "Skipped", "±0", "neu")) # --- 3. Web Coverage --- if not sources: # ACCURACY FIX 2: Lowered penalty from -15 to -5 when API fails score -= 5 signals.append(("🌐 Web Coverage", "No sources found", "-5", "neg")) elif web_total >= 20: pts = min(35, web_total * 2) score += pts signals.append(("🌐 Web Coverage", f"Strong ({web_total} pts)", f"+{pts}", "pos")) elif web_total >= 8: score += 10 signals.append(("🌐 Web Coverage", f"Moderate ({web_total} pts)", "+10", "pos")) else: score -= 10 signals.append(("🌐 Web Coverage", f"Weak ({web_total} pts)", "-10", "neg")) score = max(0, min(100, score)) if score >= 70: tag, cls = "✅ LIKELY REAL", "real" elif score >= 45: tag, cls = "⚠️ UNCERTAIN", "uncertain" else: tag, cls = "🚨 LIKELY FAKE", "fake" return score, tag, cls, signals # ───────────────────────────────────────── # 7. FORMAT OUTPUT AS HTML (SIMPLIFIED) # ───────────────────────────────────────── def build_html(score, tag, cls, signals, summary, query, sources): cls_colors = { "real": ("#2ecc71", "rgba(46,204,113,0.08)"), "fake": ("#e05252", "rgba(224,82,82,0.08)"), "uncertain": ("#d4a843", "rgba(212,168,67,0.08)"), } color, bg = cls_colors.get(cls, cls_colors["uncertain"]) desc = { "real": "Content appears credible based on AI analysis.", "fake": "Significant misinformation indicators detected.", "uncertain": "Evidence is mixed — manual verification recommended." }.get(cls, "") html = f"""
""" return html # ───────────────────────────────────────── # 8. MAIN PIPELINE # ───────────────────────────────────────── def analyse(text, image): ocr_text = "" if image is not None: ocr_text = ocr_image(image) if len(ocr_text.split()) < 3: ocr_text = "" provided_text = clean_text(text or "") raw = "" if ocr_text and provided_text: raw = f"{ocr_text}. {provided_text}" elif ocr_text: raw = ocr_text else: raw = provided_text if len(raw.split()) < 3: return build_error_html("Input too short. Please provide a headline or sentence.") summary = summarise(raw) sources, query, wt = web_search(summary) score, tag, cls, signals = verdict_engine(raw, wt, sources) return build_html(score, tag, cls, signals, summary, query, sources) def build_error_html(msg): return f"""