Spaces:
Runtime error
Runtime error
| """ | |
| Refactored analyzers using deterministic data retrieval and structured LLM outputs. | |
| This module replaces heuristic-based methods with: | |
| - LangChain's .with_structured_output() for type-safe LLM responses | |
| - python-whois for deterministic domain age data | |
| - DuckDuckGo search for external information gathering | |
| Classes: | |
| OpinionAnalyzer: Classifies articles using LLM content analysis (no URL heuristics) | |
| TrafficLongevityAnalyzer: Gets domain age from WHOIS + traffic from search/LLM | |
| MediaTypeAnalyzer: Classifies media type using Wikipedia search + LLM | |
| """ | |
| import logging | |
| import re | |
| from datetime import date, datetime | |
| from typing import Optional | |
| from urllib.parse import urlparse | |
| import os | |
| try: | |
| import whois | |
| WHOIS_AVAILABLE = True | |
| except ImportError: | |
| WHOIS_AVAILABLE = False | |
| logging.getLogger(__name__).warning( | |
| "python-whois not installed. WHOIS lookups will be skipped. " | |
| "Install with: pip install python-whois" | |
| ) | |
| WHOIS_DEBUG = os.environ.get("WHOIS_DEBUG", "0") == "1" | |
| # DDGS is no longer imported directly — search is delegated to search_backends | |
| from langchain_openai import ChatOpenAI | |
| from schemas import ( | |
| ArticleClassification, | |
| ArticleType, | |
| BatchArticleClassification, | |
| BiasDirection, | |
| EditorialBiasLLMOutput, | |
| EditorialBiasResult, | |
| FactCheckAnalysisResult, | |
| FactCheckFinding, | |
| FactCheckLLMOutput, | |
| FactCheckSource, | |
| FactCheckVerdict, | |
| MediaType, | |
| MediaTypeClassification, | |
| MediaTypeLLMOutput, | |
| MediaTypeSource, | |
| PolicyDomain, | |
| PolicyPosition, | |
| PseudoscienceAnalysisResult, | |
| PseudoscienceCategory, | |
| PseudoscienceIndicator, | |
| PseudoscienceLLMOutput, | |
| PseudoscienceSeverity, | |
| SourceAssessment, | |
| SourceQuality, | |
| SourcingAnalysisResult, | |
| SourcingLLMOutput, | |
| TransparencyLLMOutput, | |
| TransparencyResult, | |
| OneSidednessLLMOutput, | |
| OneSidednessResult, | |
| TrafficData, | |
| TrafficEstimate, | |
| TrafficSource, | |
| TrafficTier, | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # ============================================================================= | |
| # LLM Configuration | |
| # ============================================================================= | |
| def get_llm(model: str = "gpt-5-mini-2025-08-07", temperature: float = 0.0) -> ChatOpenAI: | |
| """ | |
| Get a configured LLM instance. | |
| Args: | |
| model: The OpenAI model to use | |
| temperature: Temperature setting (0 for deterministic) | |
| Returns: | |
| Configured ChatOpenAI instance | |
| """ | |
| return ChatOpenAI(model=model, temperature=temperature) | |
| # ============================================================================= | |
| # OpinionAnalyzer | |
| # ============================================================================= | |
| class OpinionAnalyzer: | |
| """ | |
| Analyzes article content to classify it as News, Opinion, Satire, or PR. | |
| This analyzer uses pure content analysis via LLM - it does NOT rely on | |
| URL patterns or title heuristics. Classification is based on: | |
| - Writing style and tone | |
| - Use of first-person vs third-person | |
| - Presence of subjective language | |
| - Factual reporting vs commentary patterns | |
| Attributes: | |
| llm: The LangChain LLM with structured output binding | |
| max_text_chars: Maximum characters of text to analyze (default 1000) | |
| """ | |
| SYSTEM_PROMPT = """You are an expert media analyst specializing in distinguishing | |
| between different types of journalistic content. Your task is to classify articles | |
| based on their actual content, writing style, and journalistic intent. | |
| Classification Guidelines: | |
| NEWS: | |
| - Objective, fact-based reporting | |
| - Third-person perspective | |
| - Balanced presentation of multiple viewpoints | |
| - Attribution to sources | |
| - Inverted pyramid structure (most important facts first) | |
| - Minimal adjectives and loaded language | |
| OPINION: | |
| - First-person perspective or clear editorial voice | |
| - Subjective analysis and commentary | |
| - Author's personal views and judgments | |
| - Persuasive language and arguments | |
| - May include "I think", "we should", "in my view" | |
| - Editorial, op-ed, column, or analysis pieces | |
| SATIRE: | |
| - Exaggerated or absurd scenarios | |
| - Ironic or sarcastic tone | |
| - Humorous intent | |
| - Clearly implausible claims played straight | |
| - Parody of news formats | |
| PR (Press Release / Promotional): | |
| - Promotional language about a company/product/person | |
| - One-sided positive framing | |
| - Corporate speak and marketing language | |
| - Quotes primarily from the subject being promoted | |
| - Announcement-style structure | |
| Analyze the CONTENT and STYLE, not the URL or publication name.""" | |
| def __init__( | |
| self, | |
| model: str = "gpt-5-mini-2025-08-07", | |
| temperature: float = 0.0, | |
| max_text_chars: int = 1000, | |
| ): | |
| """ | |
| Initialize the OpinionAnalyzer. | |
| Args: | |
| model: OpenAI model to use | |
| temperature: LLM temperature (0 for deterministic) | |
| max_text_chars: Maximum characters of article text to analyze | |
| """ | |
| self.model = model | |
| self.llm = get_llm(model, temperature).with_structured_output( | |
| ArticleClassification | |
| ) | |
| self.max_text_chars = max_text_chars | |
| def analyze(self, title: str, text: str) -> ArticleClassification: | |
| """ | |
| Classify an article based on its title and text content. | |
| Args: | |
| title: The article headline/title | |
| text: The article body text | |
| Returns: | |
| ArticleClassification with type, confidence, and reasoning | |
| """ | |
| # Truncate text to max_text_chars | |
| text_snippet = text[: self.max_text_chars] if text else "" | |
| user_prompt = f"""Classify the following article: | |
| TITLE: {title} | |
| TEXT (first {self.max_text_chars} characters): | |
| {text_snippet} | |
| Based on the writing style, tone, and content, classify this article.""" | |
| try: | |
| result: ArticleClassification = self.llm.invoke( | |
| [ | |
| {"role": "system", "content": self.SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| ) | |
| return result | |
| except Exception as e: | |
| logger.error(f"OpinionAnalyzer failed: {e}") | |
| # Return a safe default with low confidence | |
| return ArticleClassification( | |
| article_type=ArticleType.NEWS, | |
| confidence=0.0, | |
| reasoning=f"Classification failed due to error: {str(e)}", | |
| ) | |
| def analyze_batch( | |
| self, articles: list[dict[str, str]] | |
| ) -> list[ArticleClassification]: | |
| """ | |
| Classify multiple articles in a single batched LLM call. | |
| Sends all articles in one prompt and gets back all classifications | |
| at once, reducing N LLM calls to 1. | |
| Args: | |
| articles: List of dicts with 'title' and 'text' keys | |
| Returns: | |
| List of ArticleClassification results (same order as input) | |
| """ | |
| if not articles: | |
| return [] | |
| if len(articles) == 1: | |
| return [self.analyze( | |
| title=articles[0].get("title", ""), | |
| text=articles[0].get("text", ""), | |
| )] | |
| # Build a single prompt with all articles numbered | |
| article_sections = [] | |
| for i, article in enumerate(articles): | |
| title = article.get("title", "Untitled") | |
| text = article.get("text", "") | |
| text_snippet = text[: self.max_text_chars] if text else "" | |
| article_sections.append( | |
| f"--- ARTICLE {i + 1} ---\n" | |
| f"TITLE: {title}\n" | |
| f"TEXT (first {self.max_text_chars} characters):\n{text_snippet}" | |
| ) | |
| combined = "\n\n".join(article_sections) | |
| user_prompt = ( | |
| f"Classify each of the following {len(articles)} articles. " | |
| f"Return exactly {len(articles)} classifications in the same order.\n\n" | |
| f"{combined}\n\n" | |
| f"Based on the writing style, tone, and content, classify each article." | |
| ) | |
| try: | |
| batch_llm = get_llm( | |
| self.model, | |
| 0.0, | |
| ).with_structured_output(BatchArticleClassification) | |
| result: BatchArticleClassification = batch_llm.invoke( | |
| [ | |
| {"role": "system", "content": self.SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| ) | |
| # Validate we got the right number of results | |
| if len(result.classifications) == len(articles): | |
| return result.classifications | |
| # If count mismatch, pad or truncate | |
| logger.warning( | |
| f"OpinionAnalyzer batch returned {len(result.classifications)} " | |
| f"classifications for {len(articles)} articles, falling back to individual calls" | |
| ) | |
| except Exception as e: | |
| logger.warning(f"OpinionAnalyzer batch failed ({e}), falling back to individual calls") | |
| # Fallback: individual calls (original behavior) | |
| results = [] | |
| for article in articles: | |
| result = self.analyze( | |
| title=article.get("title", ""), | |
| text=article.get("text", ""), | |
| ) | |
| results.append(result) | |
| return results | |
| # ============================================================================= | |
| # Tranco List Configuration | |
| # ============================================================================= | |
| # Default Tranco list location and URL | |
| TRANCO_DEFAULT_PATH = "tranco_top1m.csv" | |
| TRANCO_DOWNLOAD_URL = "https://tranco-list.eu/top-1m.csv.zip" # Stable URL for latest top 1M list | |
| # Default tier thresholds based on Tranco rank | |
| DEFAULT_TRANCO_THRESHOLDS = { | |
| "HIGH": 10_000, # Rank < 10,000 = HIGH traffic | |
| "MEDIUM": 100_000, # Rank < 100,000 = MEDIUM traffic | |
| "LOW": 1_000_000, # Rank < 1,000,000 = LOW traffic | |
| } | |
| # ============================================================================= | |
| # TrafficLongevityAnalyzer | |
| # ============================================================================= | |
| class TrafficLongevityAnalyzer: | |
| """ | |
| Analyzes domain traffic and longevity using a hybrid deterministic + LLM approach. | |
| This analyzer implements a two-tier strategy: | |
| 1. **Deterministic (Tranco)**: First checks the Tranco Top 1M list for instant, | |
| reproducible ranking data. This replaces the deprecated Alexa Rank. | |
| 2. **LLM Fallback**: For domains not in Tranco, searches for traffic data | |
| via DuckDuckGo and uses LLM to parse the results. | |
| Domain age is always retrieved deterministically via python-whois. | |
| NOTE: MBFC uses SimilarWeb page views for traffic tiers (Under 250K=Minimal, | |
| 250K-3M=Medium, Over 3M=High). We use Tranco ranking as a proxy (rank<10K=High, | |
| 10K-100K=Medium, >100K=Low/Minimal). This is an approximation — Tranco measures | |
| domain popularity differently from SimilarWeb monthly page views. | |
| Attributes: | |
| llm: LangChain LLM with structured output for traffic parsing | |
| search: DuckDuckGo search instance | |
| tranco_data: Dict mapping domain -> rank (loaded from Tranco list) | |
| tranco_loaded: Whether Tranco list is available | |
| thresholds: Dict mapping tier names to rank cutoffs | |
| """ | |
| TRAFFIC_PARSE_PROMPT = """You are analyzing search results to estimate website traffic. | |
| Based on the search snippet provided, determine the traffic tier: | |
| HIGH: Major websites with millions of monthly visits (>10M) | |
| - Nationally/globally recognized brands | |
| - Major news outlets, social media, e-commerce giants | |
| MEDIUM: Established websites with hundreds of thousands to millions of visits (100K-10M) | |
| - Regional news outlets | |
| - Popular niche websites | |
| - Well-known blogs or specialty sites | |
| LOW: Smaller websites with tens of thousands of visits (10K-100K) | |
| - Local news sites | |
| - Small business websites | |
| - Niche community sites | |
| MINIMAL: Very small websites with under 10K visits | |
| - Personal blogs | |
| - New or obscure websites | |
| - Sites with little web presence | |
| UNKNOWN: If the search results don't provide enough information | |
| Look for indicators like: | |
| - Explicit traffic numbers (e.g., "10M monthly visits") | |
| - Tranco/Similarweb/Semrush rankings | |
| - Descriptions of reach/popularity | |
| - Comparisons to known sites""" | |
| def __init__( | |
| self, | |
| model: str = "gpt-5-mini-2025-08-07", | |
| temperature: float = 0.0, | |
| tranco_path: Optional[str] = None, | |
| auto_download_tranco: bool = True, | |
| thresholds: Optional[dict[str, int]] = None, | |
| search_backend=None, | |
| ): | |
| """ | |
| Initialize the TrafficLongevityAnalyzer. | |
| Args: | |
| model: OpenAI model to use | |
| temperature: LLM temperature (0 for deterministic) | |
| tranco_path: Path to Tranco CSV file (default: tranco_top1m.csv) | |
| auto_download_tranco: Whether to auto-download Tranco if missing | |
| thresholds: Custom tier thresholds dict (keys: HIGH, MEDIUM, LOW) | |
| search_backend: Optional SearchBackend instance (defaults to DDGSearchBackend) | |
| """ | |
| self.llm = get_llm(model, temperature).with_structured_output(TrafficEstimate) | |
| if search_backend is not None: | |
| self.search_backend = search_backend | |
| else: | |
| from search_backends import DDGSearchBackend | |
| self.search_backend = DDGSearchBackend() | |
| self.thresholds = thresholds or DEFAULT_TRANCO_THRESHOLDS.copy() | |
| # Initialize Tranco data | |
| self.tranco_data: dict[str, int] = {} | |
| self.tranco_loaded = False | |
| self._tranco_path = tranco_path or TRANCO_DEFAULT_PATH | |
| # Try to load Tranco list | |
| self._load_tranco_list(auto_download=auto_download_tranco) | |
| def _load_tranco_list(self, auto_download: bool = True) -> bool: | |
| """ | |
| Load the Tranco top 1M list into memory. | |
| Args: | |
| auto_download: Whether to download if file doesn't exist | |
| Returns: | |
| True if loaded successfully, False otherwise | |
| """ | |
| import os | |
| tranco_path = self._tranco_path | |
| # Check if file exists and is not a git-lfs pointer | |
| is_lfs_pointer = False | |
| if os.path.exists(tranco_path): | |
| try: | |
| with open(tranco_path, "r", encoding="utf-8") as f: | |
| first_line = f.readline() | |
| if first_line.startswith("version https://git-lfs"): | |
| is_lfs_pointer = True | |
| logger.info(f"Tranco file at {tranco_path} is a git-lfs pointer, need to download actual data") | |
| except Exception: | |
| pass | |
| if not os.path.exists(tranco_path) or is_lfs_pointer: | |
| if auto_download: | |
| logger.info(f"Tranco list not found at {tranco_path}, downloading...") | |
| if not self._download_tranco_list(tranco_path): | |
| logger.warning("Failed to download Tranco list, will use LLM fallback only") | |
| return False | |
| else: | |
| logger.warning(f"Tranco list not found at {tranco_path}, will use LLM fallback only") | |
| return False | |
| # Load CSV into dict (rank -> domain mapping, we need domain -> rank) | |
| try: | |
| with open(tranco_path, "r", encoding="utf-8") as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line or "," not in line: | |
| continue | |
| parts = line.split(",", 1) | |
| if len(parts) == 2: | |
| rank_str, domain = parts | |
| try: | |
| rank = int(rank_str) | |
| # Store domain -> rank mapping | |
| self.tranco_data[domain.lower()] = rank | |
| except ValueError: | |
| continue | |
| self.tranco_loaded = len(self.tranco_data) > 0 | |
| if self.tranco_loaded: | |
| logger.info(f"Loaded Tranco list with {len(self.tranco_data):,} domains") | |
| return self.tranco_loaded | |
| except Exception as e: | |
| logger.error(f"Failed to load Tranco list: {e}") | |
| return False | |
| def _download_tranco_list(self, save_path: str) -> bool: | |
| """ | |
| Download the Tranco top 1M list. | |
| Args: | |
| save_path: Path to save the downloaded file | |
| Returns: | |
| True if download successful, False otherwise | |
| """ | |
| import urllib.request | |
| import zipfile | |
| import io | |
| try: | |
| logger.info(f"Downloading Tranco list from {TRANCO_DOWNLOAD_URL}...") | |
| # Download the file | |
| with urllib.request.urlopen(TRANCO_DOWNLOAD_URL, timeout=60) as response: | |
| content = response.read() | |
| # Check if it's a zip file (Tranco sometimes serves zipped) | |
| if content[:2] == b"PK": # ZIP file magic bytes | |
| with zipfile.ZipFile(io.BytesIO(content)) as zf: | |
| # Extract the first CSV file | |
| for name in zf.namelist(): | |
| if name.endswith(".csv"): | |
| with zf.open(name) as csv_file: | |
| content = csv_file.read() | |
| break | |
| # Save to disk | |
| with open(save_path, "wb") as f: | |
| f.write(content) | |
| logger.info(f"Successfully downloaded Tranco list to {save_path}") | |
| return True | |
| except Exception as e: | |
| logger.error(f"Failed to download Tranco list: {e}") | |
| return False | |
| def _get_tranco_rank(self, domain: str) -> Optional[int]: | |
| """ | |
| Look up a domain's rank in the Tranco list. | |
| Args: | |
| domain: The domain to look up (e.g., "bbc.com") | |
| Returns: | |
| Rank (1 = most popular) or None if not found | |
| """ | |
| if not self.tranco_loaded: | |
| return None | |
| # Normalize domain | |
| domain_lower = domain.lower().strip() | |
| # Direct lookup | |
| if domain_lower in self.tranco_data: | |
| return self.tranco_data[domain_lower] | |
| # Try with www prefix | |
| if not domain_lower.startswith("www."): | |
| www_domain = f"www.{domain_lower}" | |
| if www_domain in self.tranco_data: | |
| return self.tranco_data[www_domain] | |
| return None | |
| def _rank_to_tier(self, rank: int) -> TrafficTier: | |
| """ | |
| Convert a Tranco rank to a traffic tier. | |
| Args: | |
| rank: The Tranco rank (1 = most popular) | |
| Returns: | |
| TrafficTier based on configured thresholds | |
| """ | |
| if rank < self.thresholds["HIGH"]: | |
| return TrafficTier.HIGH | |
| elif rank < self.thresholds["MEDIUM"]: | |
| return TrafficTier.MEDIUM | |
| elif rank < self.thresholds["LOW"]: | |
| return TrafficTier.LOW | |
| else: | |
| return TrafficTier.MINIMAL | |
| def _extract_domain(self, url: str) -> str: | |
| """Extract the root domain from a URL.""" | |
| parsed = urlparse(url if url.startswith("http") else f"https://{url}") | |
| domain = parsed.netloc or parsed.path | |
| # Remove www. prefix for consistency | |
| domain = re.sub(r"^www\.", "", domain) | |
| # Remove any path components | |
| domain = domain.split("/")[0] | |
| return domain.lower() | |
| def _get_whois_data(self, domain: str) -> tuple[Optional[date], bool, Optional[str]]: | |
| """ | |
| Get domain creation date from WHOIS. | |
| Compatible with both 'whois' and 'python-whois' packages. | |
| Set WHOIS_DEBUG=1 environment variable for verbose debug output. | |
| """ | |
| if not WHOIS_AVAILABLE: | |
| msg = "python-whois not installed — skipping WHOIS lookup" | |
| logger.info(f"[WHOIS] {msg} for {domain}") | |
| return None, False, msg | |
| try: | |
| # Try python-whois syntax first | |
| if hasattr(whois, 'whois'): | |
| if WHOIS_DEBUG: | |
| logger.info(f"[WHOIS DEBUG] Using whois.whois() for {domain}") | |
| w = whois.whois(domain) | |
| # Try standard whois syntax second | |
| elif hasattr(whois, 'query'): | |
| if WHOIS_DEBUG: | |
| logger.info(f"[WHOIS DEBUG] Using whois.query() for {domain}") | |
| w = whois.query(domain) | |
| else: | |
| return None, False, "Unknown whois library installed" | |
| if WHOIS_DEBUG: | |
| logger.info(f"[WHOIS DEBUG] Raw response for {domain}: {w}") | |
| # Extract creation date safely | |
| creation_date = w.creation_date | |
| if WHOIS_DEBUG: | |
| logger.info(f"[WHOIS DEBUG] Raw creation_date for {domain}: {creation_date} (type: {type(creation_date).__name__})") | |
| # Handle list of dates (some registrars return multiple) | |
| if isinstance(creation_date, list): | |
| creation_date = creation_date[0] | |
| # Convert datetime to date if needed | |
| if isinstance(creation_date, datetime): | |
| creation_date = creation_date.date() | |
| # Handle string dates (common in 'whois' package) | |
| if isinstance(creation_date, str): | |
| from dateutil import parser | |
| try: | |
| creation_date = parser.parse(creation_date).date() | |
| except Exception as parse_err: | |
| if WHOIS_DEBUG: | |
| logger.warning(f"[WHOIS DEBUG] Failed to parse date string '{creation_date}': {parse_err}") | |
| if creation_date: | |
| if WHOIS_DEBUG: | |
| logger.info(f"[WHOIS DEBUG] Resolved creation_date for {domain}: {creation_date}") | |
| return creation_date, True, None | |
| return None, False, "No creation date in WHOIS response" | |
| except Exception as e: | |
| # Catch-all for other errors | |
| error_msg = f"WHOIS error ({type(e).__name__}): {str(e)}" | |
| logger.warning(f"{error_msg} for {domain}") | |
| if WHOIS_DEBUG: | |
| logger.exception(f"[WHOIS DEBUG] Full traceback for {domain}") | |
| return None, False, error_msg | |
| def _calculate_age_years(self, creation_date: Optional[date]) -> Optional[float]: | |
| """Calculate domain age in years from creation date.""" | |
| if not creation_date: | |
| return None | |
| today = date.today() | |
| delta = today - creation_date | |
| return round(delta.days / 365.25, 2) | |
| def _search_traffic_info(self, domain: str) -> Optional[str]: | |
| """ | |
| Search for traffic information about a domain. | |
| Uses an improved query targeting traffic aggregator sites. | |
| Args: | |
| domain: The domain to search for | |
| Returns: | |
| Search result snippet or None | |
| """ | |
| # Improved query per Gemini's suggestion - targets multiple traffic data sources | |
| query = f"{domain} traffic stats similarweb hypestat semrush" | |
| try: | |
| results = self.search_backend.search(query, max_results=5) | |
| if results: | |
| snippets = [] | |
| for r in results[:5]: | |
| title = r.get("title", "") | |
| body = r.get("body", "") | |
| snippets.append(f"{title}: {body}") | |
| return "\n".join(snippets) | |
| return None | |
| except Exception as e: | |
| logger.warning(f"Traffic search failed for {domain}: {e}") | |
| return None | |
| def _parse_traffic_with_llm(self, domain: str, snippet: str) -> TrafficEstimate: | |
| """ | |
| Use LLM to parse traffic tier from search snippet. | |
| Args: | |
| domain: The domain being analyzed | |
| snippet: Search result snippet to parse | |
| Returns: | |
| TrafficEstimate with tier and reasoning | |
| """ | |
| user_prompt = f"""Analyze the following search results for {domain} and estimate the traffic tier: | |
| SEARCH RESULTS: | |
| {snippet} | |
| Determine the traffic tier based on any traffic data, rankings, or popularity indicators found.""" | |
| try: | |
| result: TrafficEstimate = self.llm.invoke( | |
| [ | |
| {"role": "system", "content": self.TRAFFIC_PARSE_PROMPT}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| ) | |
| return result | |
| except Exception as e: | |
| logger.error(f"Traffic LLM parsing failed: {e}") | |
| return TrafficEstimate( | |
| traffic_tier=TrafficTier.UNKNOWN, | |
| monthly_visits_estimate=None, | |
| confidence=0.0, | |
| reasoning=f"LLM parsing failed: {str(e)}", | |
| ) | |
| def analyze(self, url_or_domain: str) -> TrafficData: | |
| """ | |
| Analyze traffic and longevity for a domain using hybrid approach. | |
| Strategy: | |
| 1. Always get WHOIS data for domain age (deterministic) | |
| 2. Check Tranco list first (deterministic, instant, high confidence) | |
| 3. If not in Tranco, fall back to DuckDuckGo search + LLM parsing | |
| Args: | |
| url_or_domain: URL or domain to analyze | |
| Returns: | |
| TrafficData with WHOIS and traffic information | |
| """ | |
| domain = self._extract_domain(url_or_domain) | |
| # 1. Get deterministic WHOIS data | |
| creation_date, whois_success, whois_error = self._get_whois_data(domain) | |
| age_years = self._calculate_age_years(creation_date) | |
| # 2. Try Tranco lookup first (deterministic) | |
| tranco_rank = self._get_tranco_rank(domain) | |
| if tranco_rank is not None: | |
| # Found in Tranco - use deterministic ranking | |
| traffic_tier = self._rank_to_tier(tranco_rank) | |
| return TrafficData( | |
| domain=domain, | |
| creation_date=creation_date, | |
| age_years=age_years, | |
| traffic_tier=traffic_tier, | |
| monthly_visits_estimate=None, # Tranco doesn't provide this | |
| traffic_confidence=1.0, # Deterministic = 100% confidence | |
| traffic_source=TrafficSource.TRANCO, | |
| tranco_rank=tranco_rank, | |
| whois_success=whois_success, | |
| whois_error=whois_error, | |
| traffic_search_snippet=None, | |
| ) | |
| # 3. Fall back to LLM-based estimation | |
| traffic_snippet = self._search_traffic_info(domain) | |
| if traffic_snippet: | |
| traffic_estimate = self._parse_traffic_with_llm(domain, traffic_snippet) | |
| return TrafficData( | |
| domain=domain, | |
| creation_date=creation_date, | |
| age_years=age_years, | |
| traffic_tier=traffic_estimate.traffic_tier, | |
| monthly_visits_estimate=traffic_estimate.monthly_visits_estimate, | |
| traffic_confidence=traffic_estimate.confidence, | |
| traffic_source=TrafficSource.LLM, | |
| tranco_rank=None, | |
| whois_success=whois_success, | |
| whois_error=whois_error, | |
| traffic_search_snippet=traffic_snippet[:500] if traffic_snippet else None, | |
| ) | |
| # 4. No data available - return fallback | |
| return TrafficData( | |
| domain=domain, | |
| creation_date=creation_date, | |
| age_years=age_years, | |
| traffic_tier=TrafficTier.UNKNOWN, | |
| monthly_visits_estimate=None, | |
| traffic_confidence=0.0, | |
| traffic_source=TrafficSource.FALLBACK, | |
| tranco_rank=None, | |
| whois_success=whois_success, | |
| whois_error=whois_error, | |
| traffic_search_snippet=None, | |
| ) | |
| def get_tranco_stats(self) -> dict: | |
| """ | |
| Get statistics about the loaded Tranco list. | |
| Returns: | |
| Dict with Tranco list stats | |
| """ | |
| return { | |
| "loaded": self.tranco_loaded, | |
| "total_domains": len(self.tranco_data), | |
| "thresholds": self.thresholds, | |
| "path": self._tranco_path, | |
| } | |
| # ============================================================================= | |
| # MediaTypeAnalyzer Configuration | |
| # ============================================================================= | |
| # Default known media types lookup file | |
| KNOWN_MEDIA_TYPES_PATH = "known_media_types.csv" | |
| # ============================================================================= | |
| # MediaTypeAnalyzer | |
| # ============================================================================= | |
| class MediaTypeAnalyzer: | |
| """ | |
| Classifies media outlet type using a hybrid deterministic + LLM approach. | |
| This analyzer implements a two-tier strategy: | |
| 1. **Deterministic (Lookup)**: First checks the known_media_types.csv for instant, | |
| reproducible classification of major outlets. | |
| 2. **LLM Fallback**: For unknown domains, searches for information and uses | |
| LLM to parse the results. | |
| Attributes: | |
| llm: LangChain LLM with structured output for parsing | |
| search: DuckDuckGo search instance | |
| known_types: Dict mapping domain -> MediaType (from lookup file) | |
| lookup_loaded: Whether lookup table is available | |
| """ | |
| SYSTEM_PROMPT = """You are classifying the type of media outlet based on search results. | |
| Media Type Definitions: | |
| TV: Television broadcast networks and channels | |
| - Examples: CNN, BBC, Fox News, NBC, ABC, MSNBC, Al Jazeera | |
| NEWSPAPER: Traditional print newspapers (may have online presence) | |
| - Examples: New York Times, Washington Post, The Guardian, Wall Street Journal | |
| WEBSITE: Online-only news or content sites (digital native) | |
| - Examples: Vox, BuzzFeed News, The Daily Wire, Axios, Politico, HuffPost | |
| MAGAZINE: Periodical publications (weekly/monthly) | |
| - Examples: Time, The Economist, The Atlantic, Newsweek, The New Yorker | |
| RADIO: Radio broadcast networks | |
| - Examples: NPR, BBC Radio, Voice of America | |
| NEWS_AGENCY: Wire services that provide content to other outlets | |
| - Examples: Reuters, Associated Press (AP), AFP, UPI | |
| BLOG: Personal or small group blogs | |
| - Usually individual authors, informal style | |
| PODCAST: Audio-first media | |
| - Examples: The Daily, Pod Save America | |
| STREAMING: Streaming-first media services | |
| - Examples: Netflix, YouTube (when used as primary platform) | |
| UNKNOWN: Cannot determine from available information | |
| Classify based on the PRIMARY format of the outlet, not secondary formats. | |
| For example, NYT is a NEWSPAPER even though they have a website and podcasts.""" | |
| def __init__( | |
| self, | |
| model: str = "gpt-5-mini-2025-08-07", | |
| temperature: float = 0.0, | |
| lookup_path: Optional[str] = None, | |
| search_backend=None, | |
| ): | |
| """ | |
| Initialize the MediaTypeAnalyzer. | |
| Args: | |
| model: OpenAI model to use | |
| temperature: LLM temperature (0 for deterministic) | |
| lookup_path: Path to known_media_types.csv (default: known_media_types.csv) | |
| search_backend: Optional SearchBackend instance (defaults to DDGSearchBackend) | |
| """ | |
| self.llm = get_llm(model, temperature).with_structured_output(MediaTypeLLMOutput) | |
| if search_backend is not None: | |
| self.search_backend = search_backend | |
| else: | |
| from search_backends import DDGSearchBackend | |
| self.search_backend = DDGSearchBackend() | |
| # Initialize lookup data | |
| self.known_types: dict[str, MediaType] = {} | |
| self.lookup_loaded = False | |
| self._lookup_path = lookup_path or KNOWN_MEDIA_TYPES_PATH | |
| # Load lookup table | |
| self._load_known_types() | |
| def _load_known_types(self) -> bool: | |
| """ | |
| Load the known media types lookup table. | |
| Returns: | |
| True if loaded successfully, False otherwise | |
| """ | |
| import os | |
| import csv | |
| lookup_path = self._lookup_path | |
| if not os.path.exists(lookup_path): | |
| logger.warning(f"Known media types file not found at {lookup_path}, will use LLM only") | |
| return False | |
| try: | |
| with open(lookup_path, "r", encoding="utf-8") as f: | |
| reader = csv.reader(f) | |
| for row in reader: | |
| # Skip comments and empty lines | |
| if not row or row[0].startswith("#"): | |
| continue | |
| if len(row) >= 2: | |
| domain = row[0].strip().lower() | |
| media_type_str = row[1].strip() | |
| # Map string to MediaType enum | |
| try: | |
| media_type = MediaType(media_type_str) | |
| self.known_types[domain] = media_type | |
| except ValueError: | |
| # Try case-insensitive match | |
| for mt in MediaType: | |
| if mt.value.lower() == media_type_str.lower(): | |
| self.known_types[domain] = mt | |
| break | |
| self.lookup_loaded = len(self.known_types) > 0 | |
| if self.lookup_loaded: | |
| logger.info(f"Loaded {len(self.known_types)} known media types from {lookup_path}") | |
| return self.lookup_loaded | |
| except Exception as e: | |
| logger.error(f"Failed to load known media types: {e}") | |
| return False | |
| def _lookup_media_type(self, domain: str) -> Optional[MediaType]: | |
| """ | |
| Look up a domain's media type in the known types table. | |
| Args: | |
| domain: The domain to look up (e.g., "bbc.com") | |
| Returns: | |
| MediaType if found, None otherwise | |
| """ | |
| if not self.lookup_loaded: | |
| return None | |
| # Normalize domain | |
| domain_lower = domain.lower().strip() | |
| # Direct lookup | |
| if domain_lower in self.known_types: | |
| return self.known_types[domain_lower] | |
| # Try with www prefix | |
| if not domain_lower.startswith("www."): | |
| www_domain = f"www.{domain_lower}" | |
| if www_domain in self.known_types: | |
| return self.known_types[www_domain] | |
| # Try without subdomain (e.g., news.bbc.com -> bbc.com) | |
| parts = domain_lower.split(".") | |
| if len(parts) > 2: | |
| base_domain = ".".join(parts[-2:]) | |
| if base_domain in self.known_types: | |
| return self.known_types[base_domain] | |
| return None | |
| def _extract_domain(self, url: str) -> str: | |
| """Extract the root domain from a URL.""" | |
| parsed = urlparse(url if url.startswith("http") else f"https://{url}") | |
| domain = parsed.netloc or parsed.path | |
| # Remove www. prefix for consistency | |
| domain = re.sub(r"^www\.", "", domain) | |
| # Remove any path components | |
| domain = domain.split("/")[0] | |
| return domain.lower() | |
| def _extract_site_name(self, url_or_domain: str) -> str: | |
| """ | |
| Extract a clean site name from URL or domain. | |
| Args: | |
| url_or_domain: URL or domain string | |
| Returns: | |
| Clean site name for searching | |
| """ | |
| parsed = urlparse( | |
| url_or_domain if url_or_domain.startswith("http") else f"https://{url_or_domain}" | |
| ) | |
| domain = parsed.netloc or parsed.path | |
| domain = re.sub(r"^www\.", "", domain) | |
| # Extract site name (remove TLD for cleaner search) | |
| site_name = domain.split(".")[0] | |
| # Title case for better search results | |
| return site_name.replace("-", " ").replace("_", " ").title() | |
| def _search_media_type(self, site_name: str, domain: str) -> Optional[str]: | |
| """ | |
| Search for media type information using improved query. | |
| Args: | |
| site_name: Clean site name | |
| domain: Full domain for search | |
| Returns: | |
| Search result snippet or None | |
| """ | |
| # Improved query per PI's suggestion - more direct question format | |
| query = f'"{domain}" type of media outlet newspaper television website magazine' | |
| try: | |
| results = self.search_backend.search(query, max_results=5) | |
| if not results: | |
| # Fallback query - Wikipedia focused | |
| query = f"{site_name} wikipedia media company" | |
| results = self.search_backend.search(query, max_results=3) | |
| if results: | |
| snippets = [] | |
| for r in results[:5]: | |
| title = r.get("title", "") | |
| body = r.get("body", "") | |
| snippets.append(f"{title}: {body}") | |
| return "\n".join(snippets) | |
| return None | |
| except Exception as e: | |
| logger.warning(f"Media type search failed for {site_name}: {e}") | |
| return None | |
| def _parse_with_llm(self, site_name: str, domain: str, snippet: str) -> MediaTypeLLMOutput: | |
| """ | |
| Use LLM to parse media type from search snippet. | |
| Args: | |
| site_name: Clean site name | |
| domain: Full domain | |
| snippet: Search result snippet to parse | |
| Returns: | |
| MediaTypeLLMOutput with type, confidence, and reasoning | |
| """ | |
| user_prompt = f"""Classify the media type for: {site_name} ({domain}) | |
| SEARCH RESULTS: | |
| {snippet} | |
| Based on these search results, what type of media outlet is this?""" | |
| try: | |
| result: MediaTypeLLMOutput = self.llm.invoke( | |
| [ | |
| {"role": "system", "content": self.SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| ) | |
| return result | |
| except Exception as e: | |
| logger.error(f"MediaTypeAnalyzer LLM call failed: {e}") | |
| return MediaTypeLLMOutput( | |
| media_type=MediaType.UNKNOWN, | |
| confidence=0.0, | |
| reasoning=f"LLM classification failed: {str(e)}", | |
| ) | |
| def analyze(self, url_or_domain: str) -> MediaTypeClassification: | |
| """ | |
| Classify the media type for a given URL or domain using hybrid approach. | |
| Strategy: | |
| 1. Check known_media_types.csv first (deterministic, instant) | |
| 2. If not found, search and use LLM to classify | |
| Args: | |
| url_or_domain: URL or domain to classify | |
| Returns: | |
| MediaTypeClassification with type, confidence, source, and reasoning | |
| """ | |
| domain = self._extract_domain(url_or_domain) | |
| site_name = self._extract_site_name(url_or_domain) | |
| # 1. Try deterministic lookup first | |
| known_type = self._lookup_media_type(domain) | |
| if known_type is not None: | |
| return MediaTypeClassification( | |
| media_type=known_type, | |
| confidence=1.0, # Deterministic = 100% confidence | |
| source=MediaTypeSource.LOOKUP, | |
| source_snippet=None, | |
| reasoning=f"Found in known media types database as {known_type.value}", | |
| ) | |
| # 2. Fall back to search + LLM | |
| search_snippet = self._search_media_type(site_name, domain) | |
| if search_snippet: | |
| llm_result = self._parse_with_llm(site_name, domain, search_snippet) | |
| return MediaTypeClassification( | |
| media_type=llm_result.media_type, | |
| confidence=llm_result.confidence, | |
| source=MediaTypeSource.LLM, | |
| source_snippet=search_snippet[:500] if search_snippet else None, | |
| reasoning=llm_result.reasoning, | |
| ) | |
| # 3. No data available - return fallback | |
| return MediaTypeClassification( | |
| media_type=MediaType.UNKNOWN, | |
| confidence=0.0, | |
| source=MediaTypeSource.FALLBACK, | |
| source_snippet=None, | |
| reasoning="Could not find information about this media outlet", | |
| ) | |
| def get_lookup_stats(self) -> dict: | |
| """ | |
| Get statistics about the loaded lookup table. | |
| Returns: | |
| Dict with lookup table stats | |
| """ | |
| # Count by type | |
| type_counts = {} | |
| for media_type in self.known_types.values(): | |
| type_counts[media_type.value] = type_counts.get(media_type.value, 0) + 1 | |
| return { | |
| "loaded": self.lookup_loaded, | |
| "total_domains": len(self.known_types), | |
| "by_type": type_counts, | |
| "path": self._lookup_path, | |
| } | |
| # ============================================================================= | |
| # FactCheckSearcher Configuration | |
| # ============================================================================= | |
| # Sites to search for fact checks — imported from central config. | |
| # mediabiasfactcheck.com is excluded (anti-contamination). | |
| from config import FACTCHECK_SEARCH_SITES as FACTCHECK_SITES | |
| # Mapping of verdicts to their "failed" status | |
| FAILED_VERDICTS = { | |
| FactCheckVerdict.FALSE, | |
| FactCheckVerdict.MOSTLY_FALSE, | |
| FactCheckVerdict.PANTS_ON_FIRE, | |
| FactCheckVerdict.MISLEADING, | |
| } | |
| # ============================================================================= | |
| # FactCheckSearcher | |
| # ============================================================================= | |
| class FactCheckSearcher: | |
| """ | |
| Searches fact-checker sites for fact checks about a media outlet. | |
| This analyzer replaces keyword heuristics with direct search on reputable | |
| fact-checking sites. It searches 5 major fact-checkers and uses an LLM | |
| to parse the results into structured findings. | |
| Strategy: | |
| 1. Search each fact-checker site: `site:{site} "{domain}" OR "{outlet_name}"` | |
| 2. Combine all search snippets | |
| 3. Pass to LLM to extract fact check findings (verdicts, claims) | |
| 4. Calculate score based on failed checks count | |
| Score Calculation: | |
| - 0 failed checks = 0.0 (excellent) | |
| - 1-2 failed checks = 2.0-4.0 | |
| - 3-5 failed checks = 5.0-7.0 | |
| - 6+ failed checks = 8.0-10.0 (very poor) | |
| Attributes: | |
| llm: LangChain LLM with structured output for parsing | |
| search: DuckDuckGo search instance | |
| """ | |
| SYSTEM_PROMPT = """You are an expert at parsing fact-check search results. | |
| Your task is to extract fact check findings from search snippets. For each fact check found: | |
| 1. Identify the fact-checking organization (PolitiFact, Snopes, etc.) | |
| 2. Summarize the claim that was checked | |
| 3. Determine the verdict given | |
| Verdict Categories (map to these): | |
| - TRUE: Claim is accurate | |
| - MOSTLY_TRUE: Claim is mostly accurate with minor issues | |
| - HALF_TRUE: Claim is partly accurate, partly misleading | |
| - MIXED: Contains both accurate and inaccurate elements | |
| - MOSTLY_FALSE: Claim has some truth but is largely inaccurate | |
| - FALSE: Claim is not accurate | |
| - PANTS_ON_FIRE: Claim is extremely false/ridiculous (PolitiFact term) | |
| - MISLEADING: Technically accurate but missing context | |
| - UNPROVEN: Insufficient evidence to verify | |
| - NOT_RATED: Mentioned but no clear verdict given | |
| Count as "failed" fact checks: FALSE, MOSTLY_FALSE, PANTS_ON_FIRE, MISLEADING | |
| Be conservative - only extract fact checks that are clearly about the media outlet or its reporting. | |
| If a snippet is ambiguous or doesn't clearly contain a fact check verdict, skip it. | |
| If the search results are about fact-checking an outlet's OVERALL reliability rating, that's relevant. | |
| If results are about fact checks OF specific claims MADE BY the outlet, those are also relevant.""" | |
| def __init__( | |
| self, | |
| model: str = "gpt-5-mini-2025-08-07", | |
| temperature: float = 0.0, | |
| sites: list[str] | None = None, | |
| search_backend=None, | |
| ): | |
| """ | |
| Initialize the FactCheckSearcher. | |
| Args: | |
| model: OpenAI model to use | |
| temperature: LLM temperature (0 for deterministic) | |
| sites: List of fact-checker sites to search (default: FACTCHECK_SITES) | |
| search_backend: Optional SearchBackend instance (defaults to DDGSearchBackend) | |
| """ | |
| self.llm = get_llm(model, temperature).with_structured_output(FactCheckLLMOutput) | |
| if search_backend is not None: | |
| self.search_backend = search_backend | |
| else: | |
| from search_backends import DDGSearchBackend | |
| self.search_backend = DDGSearchBackend() | |
| self.sites = sites or FACTCHECK_SITES.copy() | |
| def _extract_domain(self, url: str) -> str: | |
| """Extract the root domain from a URL.""" | |
| parsed = urlparse(url if url.startswith("http") else f"https://{url}") | |
| domain = parsed.netloc or parsed.path | |
| domain = re.sub(r"^www\.", "", domain) | |
| domain = domain.split("/")[0] | |
| return domain.lower() | |
| def _extract_outlet_name(self, domain: str) -> str: | |
| """ | |
| Extract a human-readable outlet name from domain. | |
| Args: | |
| domain: The domain (e.g., "nytimes.com") | |
| Returns: | |
| Outlet name (e.g., "New York Times") | |
| """ | |
| # Common mappings | |
| known_names = { | |
| "nytimes.com": "New York Times", | |
| "washingtonpost.com": "Washington Post", | |
| "wsj.com": "Wall Street Journal", | |
| "bbc.com": "BBC", | |
| "cnn.com": "CNN", | |
| "foxnews.com": "Fox News", | |
| "msnbc.com": "MSNBC", | |
| "infowars.com": "InfoWars", | |
| "breitbart.com": "Breitbart", | |
| "dailywire.com": "Daily Wire", | |
| "theguardian.com": "The Guardian", | |
| "reuters.com": "Reuters", | |
| "apnews.com": "Associated Press", | |
| } | |
| if domain in known_names: | |
| return known_names[domain] | |
| # Generate from domain | |
| name = domain.split(".")[0] | |
| return name.replace("-", " ").replace("_", " ").title() | |
| def _search_fact_checks(self, domain: str, outlet_name: str) -> str: | |
| """ | |
| Search all fact-checker sites for fact checks about the outlet. | |
| Args: | |
| domain: The domain to search for | |
| outlet_name: Human-readable outlet name | |
| Returns: | |
| Combined search snippets from all sites | |
| """ | |
| all_snippets = [] | |
| for site in self.sites: | |
| # Query format: site:politifact.com "nytimes.com" OR "New York Times" | |
| query = f'site:{site} "{domain}" OR "{outlet_name}"' | |
| try: | |
| results = self.search_backend.search(query, max_results=3) | |
| for r in results: | |
| title = r.get("title", "") | |
| body = r.get("body", "") | |
| url = r.get("url", "") | |
| snippet = f"[{site}] {title}: {body}" | |
| if url: | |
| snippet += f" (URL: {url})" | |
| all_snippets.append(snippet) | |
| except Exception as e: | |
| logger.warning(f"Fact check search failed for {site}: {e}") | |
| continue | |
| return "\n\n".join(all_snippets) if all_snippets else "" | |
| def _parse_with_llm(self, domain: str, outlet_name: str, snippets: str) -> FactCheckLLMOutput: | |
| """ | |
| Use LLM to parse fact check findings from search snippets. | |
| Args: | |
| domain: The domain being analyzed | |
| outlet_name: Human-readable outlet name | |
| snippets: Combined search snippets | |
| Returns: | |
| FactCheckLLMOutput with findings and counts | |
| """ | |
| user_prompt = f"""Analyze fact check search results for: {outlet_name} ({domain}) | |
| SEARCH RESULTS: | |
| {snippets} | |
| Extract all fact check findings related to this outlet. Count how many have negative verdicts | |
| (FALSE, MOSTLY_FALSE, PANTS_ON_FIRE, MISLEADING).""" | |
| try: | |
| result: FactCheckLLMOutput = self.llm.invoke( | |
| [ | |
| {"role": "system", "content": self.SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| ) | |
| return result | |
| except Exception as e: | |
| logger.error(f"FactCheckSearcher LLM call failed: {e}") | |
| return FactCheckLLMOutput( | |
| findings=[], | |
| failed_count=0, | |
| total_count=0, | |
| confidence=0.0, | |
| reasoning=f"LLM parsing failed: {str(e)}", | |
| ) | |
| def _calculate_score(self, failed_count: int, total_count: int) -> tuple[float, bool]: | |
| """Calculate MBFC-style fact check score (0=excellent, 10=poor). | |
| Per MBFC 2025 methodology, the score maps linearly to failed fact checks: | |
| 0 failed = 0, 1 failed = 1, 2 failed = 2, ..., 10+ failed = 10 | |
| Returns: | |
| Tuple of (score, coverage_sufficient). | |
| coverage_sufficient is False when no fact-check evidence was found | |
| at all — meaning the score is a default, not an observed measurement. | |
| """ | |
| # If we searched approved sources and found checks with NONE failed → excellent. | |
| if total_count > 0 and failed_count == 0: | |
| return 0.0, True | |
| # If we found NO fact-check evidence at all, flag insufficient coverage. | |
| # "No fact checks found" ≠ "0 failed fact checks". Return an uncertain | |
| # score (3.0) instead of 0.0 to avoid systematically underrating outlets | |
| # that simply weren't checked by IFCN fact-checkers. | |
| if total_count == 0: | |
| return 3.0, False | |
| # Linear 1:1 mapping, capped at 10 | |
| return min(float(failed_count), 10.0), True | |
| def analyze(self, url_or_domain: str, outlet_name: str | None = None) -> FactCheckAnalysisResult: | |
| """ | |
| Search for and analyze fact checks about a media outlet. | |
| Args: | |
| url_or_domain: URL or domain to analyze | |
| outlet_name: Optional human-readable outlet name (auto-generated if not provided) | |
| Returns: | |
| FactCheckAnalysisResult with findings and score | |
| """ | |
| domain = self._extract_domain(url_or_domain) | |
| outlet_name = outlet_name or self._extract_outlet_name(domain) | |
| # Search all fact-checker sites | |
| snippets = self._search_fact_checks(domain, outlet_name) | |
| if not snippets: | |
| # No search results at all — insufficient coverage, not "0 failures" | |
| score, coverage = self._calculate_score(0, 0) | |
| return FactCheckAnalysisResult( | |
| domain=domain, | |
| outlet_name=outlet_name, | |
| failed_checks_count=0, | |
| total_checks_count=0, | |
| score=score, | |
| source=FactCheckSource.FALLBACK, | |
| findings=[], | |
| search_snippets=None, | |
| confidence=0.0, | |
| reasoning="No fact check results found for this outlet", | |
| coverage_sufficient=coverage, | |
| ) | |
| # Parse with LLM | |
| llm_output = self._parse_with_llm(domain, outlet_name, snippets) | |
| # Calculate score | |
| score, coverage = self._calculate_score(llm_output.failed_count, llm_output.total_count) | |
| return FactCheckAnalysisResult( | |
| domain=domain, | |
| outlet_name=outlet_name, | |
| failed_checks_count=llm_output.failed_count, | |
| total_checks_count=llm_output.total_count, | |
| score=score, | |
| source=FactCheckSource.SEARCH, | |
| findings=llm_output.findings, | |
| search_snippets=snippets[:1000] if snippets else None, | |
| confidence=llm_output.confidence, | |
| reasoning=llm_output.reasoning, | |
| coverage_sufficient=coverage, | |
| ) | |
| # ============================================================================= | |
| # SourcingAnalyzer | |
| # ============================================================================= | |
| class SourcingAnalyzer: | |
| """ | |
| Analyzes sourcing quality by examining BOTH cited links AND textual attributions. | |
| """ | |
| SYSTEM_PROMPT = """You are an expert at evaluating news source quality and attribution standards. | |
| Your task is to analyze news articles to determine how well they source their claims. | |
| You must look for two things: | |
| 1. **Hyperlinks**: Domains linked directly in the text. | |
| 2. **Textual Citations**: Explicit mentions of sources (e.g., "According to The New York Times", "A study by Harvard University"). | |
| ### QUALITY TIERS (Assess identified sources): | |
| - **PRIMARY**: Official docs, court filings, direct research studies, government data (.gov). | |
| - **WIRE_SERVICE**: Reuters, AP, AFP, UPI. | |
| - **MAJOR_OUTLET**: Established legacy media (NYT, BBC, WSJ, WaPo). | |
| - **CREDIBLE**: Regional papers, specialized trade journals. | |
| - **QUESTIONABLE**: State propaganda, conspiracy sites, tabloids, unverified blogs. | |
| ### VAGUE SOURCING (The "Weasel Words" Check): | |
| You must also detect **Vague Sourcing** or "Anonymous Authority". | |
| - BAD: "Critics say...", "Experts agree...", "British scientists claim...", "Sources close to the matter..." (without explaining why they are anonymous). | |
| - BAD: "Many people are saying...", "It is reported that..." (Passive voice without agent). | |
| ### INSTRUCTIONS: | |
| 1. Analyze the provided source links and article snippets. | |
| 2. Extract named sources found in the text that weren't hyperlinked. | |
| 3. Identify if the text relies heavily on vague/weasel sourcing. | |
| 4. Provide a final sourcing score (0=Excellent/High Transparency, 10=Poor/No Sourcing). | |
| """ | |
| def __init__( | |
| self, | |
| model: str = "gpt-5-mini-2025-08-07", | |
| temperature: float = 0.0, | |
| ): | |
| self.llm = get_llm(model, temperature).with_structured_output(SourcingLLMOutput) | |
| def _extract_links(self, text: str) -> list[str]: | |
| """Extract all URLs from article text.""" | |
| url_pattern = r'https?://[^\s<>"\')\]]+[^\s<>"\')\].,;:!?]' | |
| return re.findall(url_pattern, text) | |
| def _extract_domains(self, urls: list[str]) -> list[str]: | |
| """Extract unique domains from URLs.""" | |
| excluded_domains = { | |
| "twitter.com", "x.com", "facebook.com", "instagram.com", | |
| "youtube.com", "tiktok.com", "linkedin.com", "reddit.com", | |
| "t.co", "google.com" | |
| } | |
| domains = set() | |
| for url in urls: | |
| try: | |
| parsed = urlparse(url) | |
| domain = parsed.netloc.lower() | |
| domain = re.sub(r"^www\.", "", domain) | |
| if domain and domain not in excluded_domains: | |
| domains.add(domain) | |
| except Exception: | |
| continue | |
| return list(domains) | |
| def analyze(self, articles: list[dict[str, str]]) -> SourcingAnalysisResult: | |
| """ | |
| Analyze sourcing quality using links and text analysis. | |
| """ | |
| # 1. Gather Link Evidence | |
| all_links = [] | |
| combined_text_snippets = [] | |
| for i, article in enumerate(articles): | |
| text = article.get("text", "") | |
| if not text: | |
| continue | |
| # Extract links | |
| links = self._extract_links(text) | |
| all_links.extend(links) | |
| # Prepare text snippet for LLM (First 2000 chars is usually where sourcing happens) | |
| snippet = text[:2000].replace("\n", " ") | |
| combined_text_snippets.append(f"ARTICLE {i+1}: {snippet}") | |
| unique_domains = self._extract_domains(all_links) | |
| # 2. Prepare Prompt for LLM | |
| # We give the LLM the hard links we found, PLUS the text to find non-linked citations | |
| domains_str = ", ".join(unique_domains) if unique_domains else "None detected via regex" | |
| # Use all reviewed articles — MBFC methodology requires the full review set | |
| text_context = "\n\n".join(combined_text_snippets) | |
| user_prompt = f"""Analyze the sourcing in these articles. | |
| DETECTED HYPERLINKS (already extracted): | |
| {domains_str} | |
| ARTICLE TEXT SNIPPETS (Look for textual citations and vague sourcing here): | |
| {text_context} | |
| 1. Did you find valid named sources in the text that were NOT linked? (e.g. "According to the AP") | |
| 2. Is there frequent use of vague sourcing? (e.g. "Scientists say", "Critics claim") | |
| 3. Assess the quality of the specific sources found.""" | |
| try: | |
| # 3. Invoke LLM | |
| llm_output: SourcingLLMOutput = self.llm.invoke( | |
| [ | |
| {"role": "system", "content": self.SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| ) | |
| # 4. Calculate Final Score Logic | |
| # Penalize for vague sourcing if quality score is otherwise good | |
| final_score = llm_output.overall_quality_score | |
| # If vague sourcing is found, bump score toward Mixed range but cap at 5.0 | |
| if llm_output.vague_sourcing_detected and final_score < 5.0: | |
| final_score = min(final_score + 1.5, 5.0) | |
| # Calculate stats — use LLM-detected sources as fallback when regex finds none | |
| link_count = len(all_links) | |
| llm_source_count = len(llm_output.sources_assessed) if llm_output.sources_assessed else 0 | |
| # MBFC hard floor: if zero hyperlinks AND zero named citations → score ≥ 5.0 | |
| # "No sourcing" is at minimum a Mixed-level issue per methodology | |
| if link_count == 0 and llm_source_count == 0: | |
| final_score = max(5.0, final_score) | |
| # Cap score at 10 (Poor) | |
| final_score = min(10.0, final_score) | |
| effective_source_count = link_count if link_count > 0 else llm_source_count | |
| avg_sources = effective_source_count / len(articles) if articles else 0.0 | |
| # Format reasoning to include vague sourcing info if present | |
| reasoning = llm_output.overall_assessment | |
| if llm_output.vague_sourcing_detected and llm_output.vague_sourcing_examples: | |
| examples = ", ".join(llm_output.vague_sourcing_examples[:2]) | |
| reasoning += f" (Note: Detected vague sourcing: '{examples}')" | |
| return SourcingAnalysisResult( | |
| score=final_score, | |
| avg_sources_per_article=round(avg_sources, 2), | |
| total_sources_found=effective_source_count, | |
| unique_domains=len(unique_domains) if unique_domains else llm_source_count, | |
| has_hyperlinks=len(all_links) > 0, | |
| source_assessments=llm_output.sources_assessed, | |
| has_primary_sources=llm_output.has_primary_sources, | |
| has_wire_services=llm_output.has_wire_services, | |
| confidence=llm_output.confidence, | |
| reasoning=reasoning | |
| ) | |
| except Exception as e: | |
| logger.error(f"SourcingAnalyzer LLM call failed: {e}") | |
| # Fallback ONLY on error | |
| return SourcingAnalysisResult( | |
| score=5.0, | |
| avg_sources_per_article=0.0, | |
| total_sources_found=len(all_links), | |
| unique_domains=len(unique_domains), | |
| has_hyperlinks=len(all_links) > 0, | |
| source_assessments=[], | |
| has_primary_sources=False, | |
| has_wire_services=False, | |
| confidence=0.0, | |
| reasoning=f"Analysis failed: {str(e)}" | |
| ) | |
| # ============================================================================= | |
| # EditorialBiasAnalyzer | |
| # ============================================================================= | |
| class EditorialBiasAnalyzer: | |
| """ | |
| Analyzes editorial/political bias using LLM content analysis. | |
| This analyzer replaces keyword matching and lexicon-based approaches with | |
| comprehensive LLM analysis of article content. It uses the MBFC methodology | |
| encoded directly in the system prompt. | |
| The analyzer evaluates: | |
| 1. Policy positions across major domains (economic, social, environmental, etc.) | |
| 2. Use of politically loaded language | |
| 3. Story selection bias patterns | |
| 4. Overall editorial slant | |
| Bias Scale: | |
| - -10 to -7: Extreme Left / Left | |
| - -7 to -3: Left-Center | |
| - -3 to +3: Center | |
| - +3 to +7: Right-Center | |
| - +7 to +10: Right / Extreme Right | |
| Attributes: | |
| llm: LangChain LLM with structured output for bias analysis | |
| """ | |
| # Comprehensive system prompt encoding MBFC methodology | |
| SYSTEM_PROMPT = """You are an expert media analyst specializing in detecting editorial and political bias. | |
| Your task is to analyze article content and determine the outlet's political leaning. | |
| IMPORTANT: This analysis uses an AMERICAN political perspective. Left/Right designations | |
| are based on the US political spectrum. | |
| ## BIAS SCALE | |
| Use a scale from -10 (far left) to +10 (far right), with 0 being perfectly centrist: | |
| - Extreme Left (-10 to -8): Proposes revolutionary change, overthrow of capitalism, or violent resistance. Ignores democratic processes. | |
| - Left (-8 to -5): Strong progressive/socialist democrat stance. Advocates for major systemic change within democratic framework (e.g., Green New Deal, Universal Healthcare). | |
| - Left-Center (-5 to -2): Standard liberal/democrat positions. | |
| - Center (-2 to +2): Balanced coverage, minimal editorial slant, presents multiple viewpoints | |
| - Right-Center (+2 to +5): Leans conservative but with some moderate positions | |
| - Right (+5 to +8): Consistently favors conservative policies | |
| - Extreme Right (+8 to +10): Advocates radical conservative/nationalist positions | |
| ## POLICY DOMAIN INDICATORS | |
| ### ECONOMIC POLICY | |
| LEFT indicators: | |
| - Supports income equality, higher taxes on wealthy | |
| - Favors government spending on social programs | |
| - Supports stronger business regulations | |
| - Advocates minimum wage increases, wealth redistribution | |
| - Pro-union, worker protections | |
| RIGHT indicators: | |
| - Supports lower taxes, less regulation | |
| - Favors reduced government spending | |
| - Prefers free-market solutions | |
| - Opposes minimum wage mandates | |
| - Pro-business, lower corporate taxes | |
| ### SOCIAL ISSUES | |
| LEFT indicators: | |
| - Supports abortion rights | |
| - Favors LGBTQ+ rights and protections | |
| - Advocates for diversity, equity, inclusion initiatives | |
| - Supports criminal justice reform | |
| - Favors gun control measures | |
| RIGHT indicators: | |
| - Opposes or seeks to restrict abortion | |
| - Traditional marriage advocacy | |
| - Opposes DEI initiatives | |
| - Tough on crime positions | |
| - Strong Second Amendment support | |
| ### ENVIRONMENTAL POLICY | |
| LEFT indicators: | |
| - Climate change is urgent, human-caused crisis | |
| - Supports strong environmental regulations | |
| - Favors renewable energy transition | |
| - Supports international climate agreements | |
| RIGHT indicators: | |
| - Skepticism about climate urgency | |
| - Prioritizes economic impact of regulations | |
| - Supports fossil fuel industry | |
| - Skeptical of international climate agreements | |
| ### HEALTHCARE | |
| LEFT indicators: | |
| - Supports universal healthcare | |
| - Favors government involvement in healthcare | |
| - Views healthcare as a right | |
| RIGHT indicators: | |
| - Prefers private healthcare solutions | |
| - Opposes government-run healthcare | |
| - Views healthcare as market service | |
| ### IMMIGRATION | |
| LEFT indicators: | |
| - Supports pathways to citizenship | |
| - Opposes harsh enforcement measures | |
| - Favors less restrictive immigration | |
| RIGHT indicators: | |
| - Emphasizes border security | |
| - Opposes amnesty programs | |
| - Favors more restrictive immigration | |
| ### GUN RIGHTS | |
| LEFT indicators: | |
| - Supports background checks, waiting periods | |
| - Favors assault weapon restrictions | |
| - Emphasizes gun violence prevention | |
| RIGHT indicators: | |
| - Strong Second Amendment advocacy | |
| - Opposes gun restrictions | |
| - Emphasizes self-defense rights | |
| ## LOADED LANGUAGE DETECTION | |
| Identify politically loaded terms that reveal bias: | |
| LEFT-LEANING loaded terms: "regime", "far-right", "extremist", "racist", "xenophobic", | |
| "fascist", "climate denier", "voter suppression", "white supremacy", "wealth inequality" | |
| RIGHT-LEANING loaded terms: "radical left", "socialist", "woke", "cancel culture", | |
| "mainstream media", "fake news", "open borders", "defund police", "critical race theory" | |
| ## STORY SELECTION BIAS | |
| Note if the outlet appears to: | |
| - Selectively cover stories that favor one political side | |
| - Ignore stories that would be unfavorable to their preferred side | |
| - Frame neutral events with partisan spin | |
| ## MBFC 4-CATEGORY BIAS SCORING (REQUIRED) | |
| You MUST provide separate scores for ALL FOUR categories below. | |
| The final bias rating is computed as a weighted average: | |
| Bias = Economic(35%) + Social(35%) + News Reporting(15%) + Editorial(15%) | |
| ### Category 1: ECONOMIC SYSTEM SCORE (-10 to +10) | |
| Score the outlet's economic ideology: | |
| -10: Communism (no corporatism, full government ownership) | |
| -7.5: Socialism (minimal corporatism, high regulation) | |
| -5: Democratic Socialism (reduced corporatism, strongly regulated capitalism) | |
| -2.5: Regulated Market Economy (moderate corporatism, balanced regulation) | |
| 0: Centrism (balanced regulation and corporate influence) | |
| +2.5: Moderately Regulated Capitalism (slight lean toward corporatism) | |
| +5: Classical Liberalism (moderate to high corporatism, lower regulation) | |
| +7.5: Libertarianism (low government intervention, high corporate influence) | |
| +10: Radical Laissez-Faire Capitalism (minimal to no regulation) | |
| ### Category 2: SOCIAL PROGRESSIVE vs TRADITIONAL CONSERVATIVE SCORE (-10 to +10) | |
| Score the outlet's stance on social issues (abortion, immigration, climate, LGBTQ+, equity): | |
| -10: Strong Progressive Liberalism | |
| -7.5: Progressive Liberalism | |
| -5: Moderate Progressive | |
| -2.5: Mild Progressive | |
| 0: Balanced | |
| +2.5: Mild Conservative | |
| +5: Moderate Conservative | |
| +7.5: Traditional Conservative | |
| +10: Strong Traditional Conservative | |
| ### Category 3: STRAIGHT NEWS REPORTING BALANCE SCORE (-10 to +10) | |
| Score how well the outlet reports all sides in its STRAIGHT NEWS stories (not opinion): | |
| -10: Exclusively promotes left-leaning perspectives | |
| -5: Often leans left but occasionally includes counterpoints | |
| 0: Equally represents all perspectives | |
| +5: Often leans right but occasionally includes counterpoints | |
| +10: Exclusively promotes right-leaning perspectives | |
| ### Category 4: EDITORIAL/OP-ED BIAS SCORE (-10 to +10) | |
| Score bias in opinion pieces, editorials, and use of loaded emotional language: | |
| -10: Editorials exclusively promote left views with highly manipulative language | |
| -5: Often leans left with some emotional framing | |
| 0: Editorials present perspectives fairly, avoid loaded language | |
| +5: Often leans right with some emotional framing | |
| +10: Editorials exclusively promote right views with highly manipulative language | |
| ## NEWSLETTER / SINGLE-AUTHOR COMMENTARY SITES | |
| For newsletter or single-author commentary outlets (e.g., Substack newsletters, independent blogs): | |
| - ALL content is effectively opinion/editorial — there is no "straight news" to balance against | |
| - Category 3 (news_reporting_score) should reflect the author's ideological framing, NOT be scored as 0 (balanced) | |
| - Category 4 (editorial_bias_score) should carry extra weight since the entire outlet is editorial opinion | |
| - These outlets often have strong, consistent ideological positions that should be scored accordingly | |
| - Do NOT default to centrist scores just because the outlet is small or independent | |
| ## EXTREMISM DETECTION (CRITICAL) | |
| If the outlet promotes ANY of the following, you MUST score it in the extreme range | |
| (-8 to -10 or +8 to +10). Do NOT give moderate scores for fringe outlets: | |
| - Conspiracy theories (QAnon, deep state, NWO, chemtrails, 9/11 truth, etc.) | |
| - Pseudoscience (anti-vaccine, climate denial with no scientific basis, etc.) | |
| - Propaganda for authoritarian regimes | |
| - White supremacy, ethno-nationalism, or racial separatism | |
| - Revolutionary communism or anarchism advocating violence | |
| - Explicit calls for overthrow of democratic institutions | |
| ## STATE PROPAGANDA DIRECTION (CRITICAL - READ CAREFULLY) | |
| Following MBFC methodology, state-controlled propaganda outlets from authoritarian regimes | |
| (Russia, China, Iran, North Korea, Syria, Venezuela, Cuba, etc.) are classified as RIGHT-LEANING | |
| (positive scores, typically +7 to +10), NOT left-leaning, even when their content contains: | |
| - Anti-capitalist or anti-Western rhetoric | |
| - Criticism of U.S. foreign policy or imperialism | |
| - Advocacy for wealth redistribution or socialist economics | |
| - Anti-NATO or anti-EU narratives | |
| WHY: MBFC classifies these outlets based on their SERVICE TO AUTHORITARIAN POWER, not their | |
| surface-level economic rhetoric. State propaganda serves nationalist, authoritarian interests | |
| which align with the RIGHT on the MBFC scale. Anti-Western rhetoric from Russian or Chinese | |
| state media is a geopolitical strategy, not a genuine progressive/leftist position. | |
| Examples of RIGHT-scored state propaganda outlets per MBFC: | |
| - RT (Russia Today): RIGHT — Russian state media | |
| - New Eastern Outlook: RIGHT — linked to Russian intelligence (SVR) | |
| - Sputnik News: RIGHT — Russian state media | |
| - CGTN / China Daily: RIGHT — Chinese state media | |
| - PressTV: RIGHT — Iranian state media | |
| - TeleSUR: RIGHT — Venezuelan state media | |
| If you detect that an outlet is state-controlled or state-linked from an authoritarian regime, | |
| score ALL FOUR bias categories as POSITIVE (right-leaning), regardless of anti-capitalist content. | |
| ## ANALYSIS INSTRUCTIONS | |
| 1. Analyze the actual CONTENT, not the outlet's reputation | |
| 2. Look for patterns across multiple articles if provided | |
| 3. Identify specific policy positions expressed | |
| 4. Note use of loaded/emotional language | |
| 5. Consider both explicit statements and implicit framing | |
| 6. Do NOT default to moderate scores when content clearly shows extreme ideology | |
| 7. Distinguish between NEWS reporting and OPINION pieces | |
| ## CRITICAL: ALWAYS POPULATE POLICY POSITIONS | |
| You MUST extract policy positions even when the outlet takes a neutral/balanced stance: | |
| - If an article discusses climate change factually without taking sides, output a PolicyPosition with domain="Environmental Policy", leaning="Center", and indicators like "Reports factually on climate science without advocacy." | |
| - If an article covers immigration policy neutrally, output a PolicyPosition with domain="Immigration", leaning="Center", and indicators describing the balanced framing. | |
| - Do NOT return an empty list for policy_positions unless the articles are entirely devoid of political or social topics (e.g., only sports scores or recipes). | |
| - For each major topic covered in the articles, create a PolicyPosition documenting the outlet's stance (which may be Center/balanced). | |
| ## CRITICAL: CITE SOURCE ARTICLES | |
| For each PolicyPosition, you MUST populate the 'source_articles' field with the article identifiers (e.g., "Article 1: [title]") where the evidence was found. | |
| - Every indicator MUST be traceable to a specific article. | |
| - Use the article number and title as shown in the input (e.g., "Article 3: Russia warns of consequences after NATO expansion"). | |
| ## CRITICAL: BE EXPLICIT AND DIRECT | |
| Do NOT use vague descriptions. State the outlet's positions directly: | |
| - BAD: "The outlet appears to have some progressive leanings on social issues." | |
| - GOOD: "The outlet advocates for expanded LGBTQ+ protections and frames abortion restrictions as threats to women's rights (Article 2: 'Supreme Court ruling threatens reproductive freedom')." | |
| - BAD: "Economic coverage tends to favor certain perspectives." | |
| - GOOD: "The outlet supports higher taxes on the wealthy, frames income inequality as a crisis, and advocates for stronger union protections (Article 5: 'Workers deserve a living wage, unions say')." | |
| When the outlet is centrist, still be explicit: | |
| - GOOD: "The outlet presents both pro-business and pro-labor arguments without taking sides, quoting economists from both perspectives (Article 1: 'Fed rate decision splits analysts')." | |
| ## PRO-SCIENCE DETECTION | |
| If the outlet is a peer-reviewed scientific journal, academic publisher, or evidence-based scientific publication: | |
| - Set is_pro_science=True | |
| - These outlets publish research-based content that respects scientific consensus | |
| - Examples: Nature, Science, The Lancet, Frontiers journals, PLOS, BMJ | |
| - Do NOT set is_pro_science=True for general news outlets that merely report on science\"""" | |
| def __init__( | |
| self, | |
| model: str = "gpt-5-mini-2025-08-07", | |
| temperature: float = 0.0, | |
| ): | |
| """ | |
| Initialize the EditorialBiasAnalyzer. | |
| Args: | |
| model: OpenAI model to use | |
| temperature: LLM temperature (0 for deterministic) | |
| """ | |
| self.llm = get_llm(model, temperature).with_structured_output(EditorialBiasLLMOutput) | |
| def _extract_domain(self, url: str) -> str: | |
| """Extract the root domain from a URL.""" | |
| parsed = urlparse(url if url.startswith("http") else f"https://{url}") | |
| domain = parsed.netloc or parsed.path | |
| domain = re.sub(r"^www\.", "", domain) | |
| domain = domain.split("/")[0] | |
| return domain.lower() | |
| def _score_to_label(self, score: float) -> str: | |
| """Convert numeric score to MBFC-style label based on MBFC 2025 methodology. | |
| Per MBFC: Far Left at -7.0+, Far Right at +7.0+ | |
| """ | |
| if score <= -8.0: | |
| return "Extreme Left" | |
| elif score <= -7.0: | |
| return "Far Left" | |
| elif score <= -5.0: | |
| return "Left" | |
| elif score <= -2.0: | |
| return "Left-Center" | |
| elif score <= 1.9: | |
| return "Least Biased" | |
| elif score <= 4.9: | |
| return "Right-Center" | |
| elif score <= 6.9: | |
| return "Right" | |
| elif score <= 7.9: | |
| return "Far Right" | |
| else: | |
| return "Extreme Right" | |
| def _analyze_with_llm(self, articles: list[dict[str, str]]) -> EditorialBiasLLMOutput: | |
| """ | |
| Use LLM to analyze editorial bias in articles. | |
| Args: | |
| articles: List of article dicts with 'title' and 'text' keys | |
| Returns: | |
| EditorialBiasLLMOutput with bias assessment | |
| """ | |
| # Format articles for analysis, including type label when available | |
| articles_text = [] | |
| for i, article in enumerate(articles, 1): | |
| title = article.get("title", "Untitled") | |
| url = article.get("url", "") | |
| text = article.get("text", "")[:2000] # Limit text length | |
| url_line = f"\nURL: {url}" if url else "" | |
| type_label = article.get("_type_label", "") | |
| type_line = f"\nType: {type_label}" if type_label else "" | |
| articles_text.append(f"ARTICLE {i}:{type_line}\nTitle: {title}{url_line}\nText: {text}\n") | |
| combined_text = "\n---\n".join(articles_text) | |
| # Check if articles have type labels (pre-classified by OpinionAnalyzer) | |
| has_type_labels = any(a.get("_type_label") for a in articles) | |
| type_instruction = "" | |
| if has_type_labels: | |
| type_instruction = ( | |
| "\nIMPORTANT: Articles are labeled as STRAIGHT NEWS or OPINION/EDITORIAL. " | |
| "Score news_reporting_score (Category 3) based ONLY on articles labeled STRAIGHT NEWS. " | |
| "Score editorial_bias_score (Category 4) based ONLY on articles labeled OPINION/EDITORIAL. " | |
| "Score economic_score and social_score on ALL articles regardless of type.\n" | |
| ) | |
| user_prompt = f"""Analyze the following articles for editorial/political bias. | |
| IMPORTANT: If the articles are not in English, translate their core meaning to English internally before analyzing. | |
| IMPORTANT: For each policy position, cite the specific article(s) where you found the evidence using the format "Article N: [Title]". | |
| {type_instruction} | |
| {combined_text} | |
| You MUST provide ALL of the following: | |
| 1. economic_score (-10 to +10): Score the outlet's economic ideology | |
| 2. social_score (-10 to +10): Score the outlet's social progressive vs conservative stance | |
| 3. news_reporting_score (-10 to +10): Score how balanced the straight news reporting is | |
| 4. editorial_bias_score (-10 to +10): Score editorial/op-ed bias and loaded language | |
| 5. bias_score: Your best overall combined estimate (-10 to +10) | |
| 6. Positions on specific policy domains if detectable — cite source articles for each | |
| 7. Use of loaded language (with examples and source article references) | |
| 8. Any story selection bias patterns""" | |
| try: | |
| result: EditorialBiasLLMOutput = self.llm.invoke( | |
| [ | |
| {"role": "system", "content": self.SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| ) | |
| return result | |
| except Exception as e: | |
| logger.error(f"EditorialBiasAnalyzer LLM call failed: {e}") | |
| return EditorialBiasLLMOutput( | |
| overall_bias=BiasDirection.CENTER, | |
| bias_score=0.0, | |
| economic_score=0.0, | |
| social_score=0.0, | |
| news_reporting_score=0.0, | |
| editorial_bias_score=0.0, | |
| policy_positions=[], | |
| uses_loaded_language=False, | |
| loaded_language_examples=[], | |
| story_selection_bias=None, | |
| confidence=0.0, | |
| reasoning=f"LLM analysis failed: {str(e)}", | |
| ) | |
| def analyze( | |
| self, | |
| articles: list[dict[str, str]], | |
| url_or_domain: str | None = None, | |
| outlet_name: str | None = None, | |
| news_articles: list[dict[str, str]] | None = None, | |
| opinion_articles: list[dict[str, str]] | None = None, | |
| about_page_text: str | None = None, | |
| ) -> EditorialBiasResult: | |
| """ | |
| Analyze editorial bias in articles from a media outlet. | |
| Per MBFC methodology, straight-news balance and editorial bias should | |
| be scored on different article subsets. When news_articles and | |
| opinion_articles are provided (pre-classified by OpinionAnalyzer), | |
| the LLM prompt labels them so it can score categories 3 and 4 | |
| on the correct content. Falls back to the full article list if | |
| pre-classified subsets are not provided. | |
| Args: | |
| articles: Full list of article dicts (used for economic/social scoring) | |
| url_or_domain: Optional URL or domain for context | |
| outlet_name: Optional human-readable outlet name | |
| news_articles: Pre-classified straight-news articles (for category 3) | |
| opinion_articles: Pre-classified opinion/editorial articles (for category 4) | |
| about_page_text: Optional about page text for fallback when no articles available | |
| Returns: | |
| EditorialBiasResult with comprehensive bias analysis | |
| """ | |
| domain = self._extract_domain(url_or_domain) if url_or_domain else "unknown" | |
| if not articles: | |
| # If we have about page text, use it as fallback evidence | |
| if about_page_text and len(about_page_text.strip()) > 50: | |
| logger.info(f"No articles for {outlet_name}, using about page text as fallback evidence") | |
| articles = [{"title": f"About Page - {outlet_name or domain}", "text": about_page_text[:3000]}] | |
| # Continue to LLM analysis below with reduced confidence | |
| else: | |
| return EditorialBiasResult( | |
| domain=domain, | |
| outlet_name=outlet_name, | |
| overall_bias=BiasDirection.CENTER, | |
| bias_score=0.0, | |
| mbfc_label="Least Biased", | |
| policy_positions=[], | |
| uses_loaded_language=False, | |
| loaded_language_examples=[], | |
| story_selection_bias=None, | |
| articles_analyzed=0, | |
| confidence=0.0, | |
| reasoning="No articles provided for analysis", | |
| ) | |
| # Build article input with news/opinion classification labels | |
| if news_articles is not None or opinion_articles is not None: | |
| # Pre-classified: label each subset so the LLM scores category 3 | |
| # (news_reporting_score) on news articles and category 4 | |
| # (editorial_bias_score) on opinion/editorial articles. | |
| labeled_articles = [] | |
| for a in (news_articles or []): | |
| labeled = dict(a) | |
| labeled["_type_label"] = "STRAIGHT NEWS" | |
| labeled_articles.append(labeled) | |
| for a in (opinion_articles or []): | |
| labeled = dict(a) | |
| labeled["_type_label"] = "OPINION/EDITORIAL" | |
| labeled_articles.append(labeled) | |
| # If neither subset has articles, fall back to all articles | |
| analysis_input = labeled_articles if labeled_articles else articles | |
| else: | |
| analysis_input = articles | |
| # Analyze with LLM | |
| llm_output = self._analyze_with_llm(analysis_input) | |
| # Compute MBFC weighted bias score from 4 subcategories | |
| # Weights: Economic 35%, Social 35%, News Reporting 15%, Editorial 15% | |
| weighted_bias_score = ( | |
| llm_output.economic_score * 0.35 | |
| + llm_output.social_score * 0.35 | |
| + llm_output.news_reporting_score * 0.15 | |
| + llm_output.editorial_bias_score * 0.15 | |
| ) | |
| # Clamp to [-10, +10] | |
| weighted_bias_score = max(-10.0, min(10.0, weighted_bias_score)) | |
| # Step 1b: Power-law score amplification to combat centrality bias. | |
| # LLMs systematically underestimate extreme scores, giving -5 to +5 | |
| # when gold expects -10 to +10. Apply power-law spreading to stretch | |
| # scores away from center while preserving direction and keeping | |
| # moderate scores relatively stable. | |
| if llm_output.confidence >= 0.5: | |
| sign = 1.0 if weighted_bias_score >= 0 else -1.0 | |
| magnitude = abs(weighted_bias_score) | |
| if magnitude > 0: | |
| # Power-law: f(x) = 10 * (x/10)^0.8 | |
| # This maps: 1→1.6, 3→4.0, 5→5.7, 7→7.3, 9→9.2 | |
| stretched = 10.0 * (magnitude / 10.0) ** 0.8 | |
| weighted_bias_score = sign * min(stretched, 10.0) | |
| # Convert weighted score to MBFC label | |
| mbfc_label = self._score_to_label(weighted_bias_score) | |
| return EditorialBiasResult( | |
| domain=domain, | |
| outlet_name=outlet_name, | |
| overall_bias=llm_output.overall_bias, | |
| bias_score=weighted_bias_score, | |
| economic_score=llm_output.economic_score, | |
| social_score=llm_output.social_score, | |
| news_reporting_score=llm_output.news_reporting_score, | |
| editorial_bias_score=llm_output.editorial_bias_score, | |
| mbfc_label=mbfc_label, | |
| policy_positions=llm_output.policy_positions, | |
| uses_loaded_language=llm_output.uses_loaded_language, | |
| loaded_language_examples=llm_output.loaded_language_examples, | |
| story_selection_bias=llm_output.story_selection_bias, | |
| ideology_summary=llm_output.ideology_summary, | |
| economy_summary=llm_output.economy_summary, | |
| is_pro_science=llm_output.is_pro_science, | |
| articles_analyzed=len(articles), | |
| confidence=llm_output.confidence, | |
| reasoning=llm_output.reasoning, | |
| ) | |
| # ============================================================================= | |
| # PseudoscienceAnalyzer | |
| # ============================================================================= | |
| class PseudoscienceAnalyzer: | |
| """ | |
| Detects pseudoscience and conspiracy content using LLM analysis. | |
| This analyzer replaces dictionary/keyword matching with comprehensive | |
| LLM-based content analysis. It identifies pseudoscientific claims and | |
| assesses how the outlet treats scientific consensus. | |
| Categories detected: | |
| - Health pseudoscience (anti-vax, alternative medicine, etc.) | |
| - Climate/environmental misinformation | |
| - Paranormal/supernatural claims | |
| - Conspiracy theories | |
| - Other pseudoscience | |
| Severity levels: | |
| - PROMOTES: Actively promotes pseudoscience as fact | |
| - PRESENTS_UNCRITICALLY: Reports without proper scientific context | |
| - MIXED: Sometimes promotes, sometimes critical | |
| - NONE_DETECTED: No pseudoscience found | |
| Attributes: | |
| llm: LangChain LLM with structured output for pseudoscience detection | |
| """ | |
| SYSTEM_PROMPT = """You are an expert science communicator and fact-checker specializing in | |
| identifying pseudoscience, conspiracy theories, and science misinformation. | |
| ## DEFINITION | |
| Pseudoscience: Claims, beliefs, or practices that are presented as scientific but are | |
| incompatible with the scientific method - they are unproven, not testable, or contradict | |
| the scientific consensus. | |
| ## CATEGORIES TO DETECT | |
| ### HEALTH-RELATED PSEUDOSCIENCE | |
| - Anti-Vaccination: Claims vaccines cause autism, are dangerous, contain microchips, etc. | |
| SCIENTIFIC CONSENSUS: Vaccines are safe, effective, and do not cause autism. | |
| - Alternative Medicine promoted as cure: Homeopathy, naturopathy, crystal healing presented | |
| as effective medical treatments. | |
| SCIENTIFIC CONSENSUS: No evidence these treatments work beyond placebo. | |
| - Alternative Cancer Treatments: Claims that essential oils, supplements, or alternative | |
| therapies can cure cancer instead of conventional treatment. | |
| SCIENTIFIC CONSENSUS: Only proven treatments (surgery, chemo, radiation, immunotherapy) are effective. | |
| - COVID-19 Misinformation: False claims about vaccines, treatments (ivermectin, hydroxychloroquine), | |
| origins, or prevention methods. | |
| SCIENTIFIC CONSENSUS: COVID vaccines are safe and effective; unproven treatments are not substitutes. | |
| - Detoxification Claims: Claims that special diets, supplements, or procedures remove "toxins." | |
| SCIENTIFIC CONSENSUS: The liver and kidneys naturally detoxify; "detox" products have no proven benefit. | |
| ### CLIMATE/ENVIRONMENTAL | |
| - Climate Change Denialism: Denying human-caused climate change, claiming it's a hoax, | |
| or minimizing its urgency. | |
| SCIENTIFIC CONSENSUS: Climate change is real, human-caused, and requires urgent action. | |
| - 5G Health Conspiracy: Claims that 5G causes COVID, cancer, or other health problems. | |
| SCIENTIFIC CONSENSUS: 5G radio waves are non-ionizing and not harmful at normal exposure levels. | |
| - Chemtrails: Claims that aircraft condensation trails are chemical/biological agents. | |
| SCIENTIFIC CONSENSUS: Contrails are simply water vapor; no evidence of deliberate spraying. | |
| - GMO Dangers: Claims that GMOs are inherently dangerous or cause health problems. | |
| SCIENTIFIC CONSENSUS: GMOs are extensively tested and safe for consumption. | |
| ### PARANORMAL/SUPERNATURAL | |
| - Astrology: Claims that celestial bodies influence personality or predict events. | |
| SCIENTIFIC CONSENSUS: No mechanism or evidence for astrological effects. | |
| - Psychic Claims: Claims of telepathy, clairvoyance, or communication with the dead. | |
| SCIENTIFIC CONSENSUS: No evidence for psychic phenomena despite extensive testing. | |
| - Faith Healing: Claims that prayer or spiritual intervention can cure disease. | |
| SCIENTIFIC CONSENSUS: No evidence faith healing works; can be dangerous if it replaces medicine. | |
| ### CONSPIRACY THEORIES | |
| - Flat Earth: Claims the Earth is flat and space agencies are lying. | |
| SCIENTIFIC CONSENSUS: The Earth is an oblate spheroid; this is confirmed by countless observations. | |
| - Moon Landing Hoax: Claims the Apollo missions were faked. | |
| SCIENTIFIC CONSENSUS: Moon landings are among the most well-documented events in history. | |
| - QAnon: Claims about secret cabals, child trafficking rings run by elites, etc. | |
| SCIENTIFIC CONSENSUS: These are unfounded conspiracy theories with no evidence. | |
| ## SEVERITY ASSESSMENT | |
| PROMOTES: The outlet actively promotes pseudoscience as fact or truth | |
| - Presents claims without skepticism | |
| - Attacks scientific consensus | |
| - Promotes practitioners/products | |
| - Uses persuasive language to convince readers | |
| PRESENTS_UNCRITICALLY: Reports on pseudoscience without proper context | |
| - Gives "both sides" treatment to science vs. pseudoscience | |
| - Fails to note scientific consensus | |
| - Presents fringe views as legitimate alternatives | |
| MIXED: Sometimes promotes, sometimes critical | |
| - Inconsistent treatment of pseudoscience | |
| - Some articles critical, others not | |
| NONE_DETECTED: No pseudoscience content found | |
| - Content respects scientific consensus | |
| - Properly contextualizes scientific uncertainty | |
| - Does not promote unproven claims | |
| ## ANALYSIS INSTRUCTIONS | |
| 1. Identify specific pseudoscientific claims in the content | |
| 2. Note how the outlet frames these claims (promoting vs. debunking) | |
| 3. Check if scientific consensus is mentioned or ignored | |
| 4. Assess overall pattern across multiple articles if available | |
| 5. Be precise - distinguish between reporting ON pseudoscience (journalism) vs. PROMOTING it | |
| 6. Quote specific evidence when identifying pseudoscience content""" | |
| def __init__( | |
| self, | |
| model: str = "gpt-5-mini-2025-08-07", | |
| temperature: float = 0.0, | |
| ): | |
| """ | |
| Initialize the PseudoscienceAnalyzer. | |
| Args: | |
| model: OpenAI model to use | |
| temperature: LLM temperature (0 for deterministic) | |
| """ | |
| self.llm = get_llm(model, temperature).with_structured_output(PseudoscienceLLMOutput) | |
| def _extract_domain(self, url: str) -> str: | |
| """Extract the root domain from a URL.""" | |
| parsed = urlparse(url if url.startswith("http") else f"https://{url}") | |
| domain = parsed.netloc or parsed.path | |
| domain = re.sub(r"^www\.", "", domain) | |
| domain = domain.split("/")[0] | |
| return domain.lower() | |
| def _analyze_with_llm(self, articles: list[dict[str, str]]) -> PseudoscienceLLMOutput: | |
| """ | |
| Use LLM to detect pseudoscience in articles. | |
| Args: | |
| articles: List of article dicts with 'title' and 'text' keys | |
| Returns: | |
| PseudoscienceLLMOutput with pseudoscience assessment | |
| """ | |
| # Format articles for analysis | |
| articles_text = [] | |
| for i, article in enumerate(articles, 1): | |
| title = article.get("title", "Untitled") | |
| text = article.get("text", "")[:2000] # Limit text length | |
| articles_text.append(f"ARTICLE {i}:\nTitle: {title}\nText: {text}\n") | |
| combined_text = "\n---\n".join(articles_text) | |
| user_prompt = f"""Analyze the following articles for pseudoscience and conspiracy content: | |
| {combined_text} | |
| Identify: | |
| 1. Any pseudoscientific claims or conspiracy theories | |
| 2. How the outlet treats these claims (promoting vs. debunking) | |
| 3. Whether scientific consensus is respected | |
| 4. Overall quality of science reporting (0=excellent, 10=promotes pseudoscience)""" | |
| try: | |
| result: PseudoscienceLLMOutput = self.llm.invoke( | |
| [ | |
| {"role": "system", "content": self.SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| ) | |
| return result | |
| except Exception as e: | |
| logger.error(f"PseudoscienceAnalyzer LLM call failed: {e}") | |
| return PseudoscienceLLMOutput( | |
| indicators=[], | |
| promotes_pseudoscience=False, | |
| overall_severity=PseudoscienceSeverity.NONE_DETECTED, | |
| science_reporting_quality=5.0, | |
| respects_scientific_consensus=True, | |
| confidence=0.0, | |
| reasoning=f"LLM analysis failed: {str(e)}", | |
| ) | |
| def analyze( | |
| self, | |
| articles: list[dict[str, str]], | |
| url_or_domain: str | None = None, | |
| outlet_name: str | None = None, | |
| ) -> PseudoscienceAnalysisResult: | |
| """ | |
| Analyze articles for pseudoscience and conspiracy content. | |
| Args: | |
| articles: List of article dicts with 'title' and 'text' keys | |
| url_or_domain: Optional URL or domain for context | |
| outlet_name: Optional human-readable outlet name | |
| Returns: | |
| PseudoscienceAnalysisResult with comprehensive analysis | |
| """ | |
| domain = self._extract_domain(url_or_domain) if url_or_domain else "unknown" | |
| if not articles: | |
| return PseudoscienceAnalysisResult( | |
| domain=domain, | |
| outlet_name=outlet_name, | |
| score=5.0, # Neutral when no data | |
| promotes_pseudoscience=False, | |
| overall_severity=PseudoscienceSeverity.NONE_DETECTED, | |
| categories_found=[], | |
| indicators=[], | |
| respects_scientific_consensus=True, | |
| articles_analyzed=0, | |
| confidence=0.0, | |
| reasoning="No articles provided for analysis", | |
| ) | |
| # Analyze with LLM | |
| llm_output = self._analyze_with_llm(articles) | |
| # Extract unique categories found | |
| categories_found = list(set( | |
| indicator.category for indicator in llm_output.indicators | |
| )) | |
| return PseudoscienceAnalysisResult( | |
| domain=domain, | |
| outlet_name=outlet_name, | |
| score=llm_output.science_reporting_quality, | |
| promotes_pseudoscience=llm_output.promotes_pseudoscience, | |
| overall_severity=llm_output.overall_severity, | |
| categories_found=categories_found, | |
| indicators=llm_output.indicators, | |
| respects_scientific_consensus=llm_output.respects_scientific_consensus, | |
| articles_analyzed=len(articles), | |
| confidence=llm_output.confidence, | |
| reasoning=llm_output.reasoning, | |
| ) | |
| # ============================================================================= | |
| # TransparencyAnalyzer (MBFC Factuality: 25% weight) | |
| # ============================================================================= | |
| class TransparencyAnalyzer: | |
| """Evaluates outlet transparency per MBFC methodology. | |
| Assesses 5 elements: about page, ownership disclosure, funding disclosure, | |
| author identification, and location disclosure. Score 0-10 where 0 is | |
| fully transparent and 10 is no transparency at all. | |
| """ | |
| SYSTEM_PROMPT = """You are an expert media analyst evaluating the transparency of a news outlet. | |
| Transparency measures how much critical information a source discloses about itself. | |
| You MUST evaluate these 5 elements: | |
| 1. ABOUT PAGE: Does the outlet have a clear, detailed About page explaining its mission and purpose? | |
| 2. OWNERSHIP: Does the outlet disclose who owns it (person, company, or organization)? | |
| 3. FUNDING: Does the outlet disclose its funding sources or revenue model (advertising, subscription, donations, etc.)? | |
| 4. AUTHORS: Do articles identify authors by name (not just "Staff" or "Admin")? | |
| 5. LOCATION: Does the outlet disclose its physical location or headquarters? | |
| SCORING (0-10, lower is better): | |
| 0: Fully transparent — all 5 elements clearly disclosed | |
| 1: Near full transparency — minor gaps in one element | |
| 2: Mostly transparent — 4 of 5 elements disclosed | |
| 3-4: Noticeable gaps — 3 of 5 elements disclosed | |
| 5: Partially transparent — only 2 of 5 elements disclosed | |
| 6-7: Limited transparency — only 1-2 elements with unclear details | |
| 8-9: Very limited — vague or unclear on almost all elements | |
| 10: No transparency — no information about ownership, funding, authors, location, or purpose | |
| Base your assessment on the provided evidence (about page text, Wikipedia, search results, articles). | |
| If Wikipedia is available, use it to fill in gaps about ownership, location, and funding. | |
| If no evidence of transparency is found (no about page, no ownership disclosure, no author identification), score 7-8 (limited transparency). Do NOT default to moderate scores for opaque outlets.""" | |
| def __init__( | |
| self, | |
| model: str = "gpt-5-mini-2025-08-07", | |
| temperature: float = 0.0, | |
| search_backend=None, | |
| ): | |
| self.llm = get_llm(model, temperature).with_structured_output(TransparencyLLMOutput) | |
| if search_backend is not None: | |
| self.search_backend = search_backend | |
| else: | |
| from search_backends import DDGSearchBackend | |
| self.search_backend = DDGSearchBackend() | |
| _WIKIPEDIA_API = "https://en.wikipedia.org/w/api.php" | |
| def _extract_domain(self, url: str) -> str: | |
| parsed = urlparse(url if url.startswith("http") else f"https://{url}") | |
| domain = parsed.netloc or parsed.path | |
| domain = re.sub(r"^www\.", "", domain) | |
| return domain.split("/")[0].lower() | |
| def _fetch_wikipedia_for_transparency(self, query: str) -> str: | |
| """Lightweight Wikipedia fetch for transparency evidence fallback.""" | |
| import requests as _requests | |
| try: | |
| resp = _requests.get( | |
| self._WIKIPEDIA_API, | |
| params={"action": "query", "list": "search", | |
| "srsearch": query, "srlimit": 1, "format": "json"}, | |
| timeout=10, | |
| ) | |
| if resp.status_code != 200: | |
| return "" | |
| results = resp.json().get("query", {}).get("search", []) | |
| if not results: | |
| return "" | |
| title = results[0]["title"] | |
| resp2 = _requests.get( | |
| self._WIKIPEDIA_API, | |
| params={"action": "query", "titles": title, "prop": "extracts", | |
| "exintro": True, "explaintext": True, "format": "json"}, | |
| timeout=10, | |
| ) | |
| pages = resp2.json().get("query", {}).get("pages", {}) | |
| for page in pages.values(): | |
| extract = page.get("extract", "") | |
| if extract: | |
| return extract[:2000] | |
| except Exception: | |
| pass | |
| return "" | |
| def analyze( | |
| self, | |
| url: str, | |
| outlet_name: str | None = None, | |
| articles: list[dict] | None = None, | |
| about_page_text: str = "", | |
| wikipedia_text: str = "", | |
| ) -> TransparencyResult: | |
| """Analyze outlet transparency from about page, search results, and articles. | |
| When about_page_text is unavailable, wikipedia_text serves as | |
| supplementary evidence so the LLM doesn't default to worst-case | |
| transparency scores. | |
| """ | |
| domain = self._extract_domain(url) | |
| # Gather evidence: search for transparency-related info | |
| evidence_parts = [] | |
| if about_page_text: | |
| evidence_parts.append(f"ABOUT PAGE TEXT:\n{about_page_text[:2000]}") | |
| if wikipedia_text: | |
| evidence_parts.append(f"WIKIPEDIA TEXT:\n{wikipedia_text[:2000]}") | |
| # Search for ownership/funding info | |
| name = outlet_name or domain | |
| try: | |
| results = self.search_backend.search( | |
| f'"{name}" ownership funding about us mission', max_results=5 | |
| ) | |
| if results: | |
| snippets = "\n".join( | |
| f"- {r.get('title', '')}: {r.get('body', '')}" for r in results[:5] | |
| ) | |
| evidence_parts.append(f"SEARCH RESULTS:\n{snippets}") | |
| except Exception as e: | |
| logger.warning(f"Transparency search failed: {e}") | |
| # Fallback: fetch Wikipedia if no about page and no wikipedia_text provided | |
| if not about_page_text and not wikipedia_text: | |
| try: | |
| wiki = self._fetch_wikipedia_for_transparency(name) | |
| if wiki: | |
| evidence_parts.append(f"WIKIPEDIA TEXT:\n{wiki[:2000]}") | |
| except Exception as e: | |
| logger.debug(f"Wikipedia fallback for transparency failed: {e}") | |
| # Check if articles have author bylines — check ALL articles, not a subset | |
| if articles: | |
| authored_count = sum( | |
| 1 for a in articles | |
| if a.get("author") or "By " in (a.get("text", "")[:200]) | |
| ) | |
| total_checked = len(articles) | |
| has_authors = authored_count > 0 | |
| evidence_parts.append( | |
| f"ARTICLE AUTHORSHIP: {authored_count}/{total_checked} articles have identified authors" | |
| ) | |
| evidence = "\n\n".join(evidence_parts) if evidence_parts else "No evidence available." | |
| user_prompt = ( | |
| f'Evaluate the transparency of "{name}" (domain: {domain}).\n\n' | |
| f"EVIDENCE:\n{evidence[:4000]}" | |
| ) | |
| try: | |
| result: TransparencyLLMOutput = self.llm.invoke([ | |
| {"role": "system", "content": self.SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_prompt}, | |
| ]) | |
| return TransparencyResult( | |
| domain=domain, | |
| outlet_name=outlet_name, | |
| has_about_page=result.has_about_page, | |
| discloses_ownership=result.discloses_ownership, | |
| discloses_funding=result.discloses_funding, | |
| identifies_authors=result.identifies_authors, | |
| discloses_location=result.discloses_location, | |
| score=result.transparency_score, | |
| confidence=result.confidence, | |
| reasoning=result.reasoning, | |
| ) | |
| except Exception as e: | |
| logger.error(f"Transparency analysis failed: {e}") | |
| return TransparencyResult( | |
| domain=domain, | |
| outlet_name=outlet_name, | |
| score=5.0, # Conservative default | |
| confidence=0.0, | |
| reasoning=f"Transparency analysis failed: {e}", | |
| ) | |
| # ============================================================================= | |
| # OneSidednessAnalyzer (MBFC Factuality: 10% weight) | |
| # ============================================================================= | |
| class OneSidednessAnalyzer: | |
| """Evaluates one-sidedness, bias omission, and propaganda per MBFC methodology. | |
| Measures how well a source provides balanced perspectives versus exhibiting | |
| significant ideological bias or propaganda, including use of emotional or | |
| loaded language. Score 0-10 where 0 is perfectly balanced. | |
| """ | |
| SYSTEM_PROMPT = """You are an expert media analyst evaluating one-sidedness and propaganda in news coverage. | |
| This is SEPARATE from editorial bias scoring. Here you evaluate: | |
| - Whether the outlet presents BALANCED perspectives or is one-sided | |
| - Whether emotional/loaded language is used to PERSUADE readers | |
| - Whether propaganda techniques are employed | |
| SCORING (0-10, lower is better): | |
| 0: Perfect balance — presents all sides equally, no emotional manipulation | |
| 1: Almost balanced — very minor favoritism, minimal emotional cues | |
| 2: Minor bias — slightly favors one side, occasional emotional terms | |
| 3: Small bias — favors one side more frequently, moderate emotional language | |
| 4: Moderate bias — preference for one side, increasingly emotional wording | |
| 5: Noticeable bias — significantly favors one side, frequent emotional language | |
| 6: Frequent bias — dismisses opposing side, regularly provocative language | |
| 7: Significant bias — minimal alternative perspectives, inflammatory rhetoric | |
| 8: Heavy bias — consistently one-sided, pervasive emotional language bordering on propaganda | |
| 9: Strong bias — rarely includes alternatives, extreme emotional framing | |
| 10: Extreme propaganda — exclusively one side, inflammatory/divisive/manipulative language | |
| IMPORTANT: This evaluates BALANCE and RHETORIC, not political direction. | |
| A perfectly balanced outlet scores 0 regardless of which topics it covers. | |
| An outlet that covers only one political perspective with emotional language scores high. | |
| Base your assessment ONLY on the article content provided.""" | |
| def __init__( | |
| self, | |
| model: str = "gpt-5-mini-2025-08-07", | |
| temperature: float = 0.0, | |
| ): | |
| self.llm = get_llm(model, temperature).with_structured_output(OneSidednessLLMOutput) | |
| def analyze(self, articles: list[dict[str, str]]) -> OneSidednessResult: | |
| """Analyze articles for one-sidedness and propaganda.""" | |
| if not articles: | |
| return OneSidednessResult( | |
| score=5.0, | |
| articles_analyzed=0, | |
| confidence=0.0, | |
| reasoning="No articles provided for analysis", | |
| ) | |
| # Format articles — use full review set, not a truncated subset | |
| articles_text = [] | |
| for i, article in enumerate(articles, 1): | |
| title = article.get("title", "Untitled") | |
| text = article.get("text", "")[:2000] | |
| articles_text.append(f"ARTICLE {i}:\nTitle: {title}\nText: {text}\n") | |
| combined = "\n---\n".join(articles_text) | |
| user_prompt = ( | |
| f"Analyze the following articles for one-sidedness, balance, and propaganda.\n\n" | |
| f"{combined}\n\n" | |
| f"Evaluate:\n" | |
| f"1. Does the outlet present opposing viewpoints or only one side?\n" | |
| f"2. Is emotional or loaded language used to persuade?\n" | |
| f"3. Are propaganda techniques present (appeal to fear, bandwagon, etc.)?\n" | |
| f"4. Score from 0 (perfect balance) to 10 (extreme propaganda)" | |
| ) | |
| try: | |
| result: OneSidednessLLMOutput = self.llm.invoke([ | |
| {"role": "system", "content": self.SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_prompt}, | |
| ]) | |
| return OneSidednessResult( | |
| score=result.one_sidedness_score, | |
| uses_emotional_language=result.uses_emotional_language, | |
| propaganda_level=result.propaganda_level, | |
| presents_opposing_views=result.presents_opposing_views, | |
| articles_analyzed=len(articles), | |
| confidence=result.confidence, | |
| reasoning=result.reasoning, | |
| ) | |
| except Exception as e: | |
| logger.error(f"One-sidedness analysis failed: {e}") | |
| return OneSidednessResult( | |
| score=5.0, | |
| articles_analyzed=len(articles), | |
| confidence=0.0, | |
| reasoning=f"One-sidedness analysis failed: {e}", | |
| ) | |
| # ============================================================================= | |
| # CLI / Testing | |
| # ============================================================================= | |
| if __name__ == "__main__": | |
| import sys | |
| # Configure logging for demo | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" | |
| ) | |
| # Example usage | |
| print("=" * 70) | |
| print("REFACTORED ANALYZERS - DEMO") | |
| print("=" * 70) | |
| # Test OpinionAnalyzer | |
| print("\n1. OpinionAnalyzer Test") | |
| print("-" * 50) | |
| sample_title = "The Economic Impact of Climate Change" | |
| sample_text = """ | |
| Climate change is causing significant economic disruption across multiple sectors. | |
| According to a new report from the World Bank, global GDP could decline by 23% | |
| by 2100 if emissions continue at current levels. The study analyzed data from | |
| 150 countries and found that agricultural yields have already decreased by 5% | |
| in affected regions. Economists warn that the poorest nations will be hit hardest. | |
| """ | |
| opinion_analyzer = OpinionAnalyzer() | |
| result = opinion_analyzer.analyze(sample_title, sample_text) | |
| print(f"Title: {sample_title}") | |
| print(f"Classification: {result.article_type.value}") | |
| print(f"Confidence: {result.confidence:.2f}") | |
| print(f"Reasoning: {result.reasoning}") | |
| # Test TrafficLongevityAnalyzer with hybrid approach | |
| print("\n2. TrafficLongevityAnalyzer Test (Hybrid Tranco + LLM)") | |
| print("-" * 50) | |
| # Initialize analyzer (will auto-download Tranco if needed) | |
| traffic_analyzer = TrafficLongevityAnalyzer() | |
| # Show Tranco stats | |
| tranco_stats = traffic_analyzer.get_tranco_stats() | |
| print(f"\nTranco List Status:") | |
| print(f" Loaded: {tranco_stats['loaded']}") | |
| print(f" Domains: {tranco_stats['total_domains']:,}") | |
| print(f" Thresholds: HIGH < {tranco_stats['thresholds']['HIGH']:,}, " | |
| f"MEDIUM < {tranco_stats['thresholds']['MEDIUM']:,}, " | |
| f"LOW < {tranco_stats['thresholds']['LOW']:,}") | |
| # Test with major domain (should be in Tranco) | |
| test_domains = ["bbc.com", "nytimes.com", "obscure-local-news-site.com"] | |
| for test_domain in test_domains: | |
| print(f"\n Testing: {test_domain}") | |
| traffic_data = traffic_analyzer.analyze(test_domain) | |
| print(f" Domain: {traffic_data.domain}") | |
| print(f" Traffic Source: {traffic_data.traffic_source.value}") | |
| if traffic_data.tranco_rank: | |
| print(f" Tranco Rank: #{traffic_data.tranco_rank:,}") | |
| print(f" Traffic Tier: {traffic_data.traffic_tier.value}") | |
| print(f" Confidence: {traffic_data.traffic_confidence:.2f}") | |
| print(f" Creation Date: {traffic_data.creation_date}") | |
| print(f" Age (years): {traffic_data.age_years}") | |
| print(f" WHOIS Success: {traffic_data.whois_success}") | |
| if traffic_data.whois_error: | |
| print(f" WHOIS Error: {traffic_data.whois_error}") | |
| # Test MediaTypeAnalyzer with hybrid approach | |
| print("\n3. MediaTypeAnalyzer Test (Hybrid Lookup + LLM)") | |
| print("-" * 50) | |
| media_analyzer = MediaTypeAnalyzer() | |
| # Show lookup stats | |
| lookup_stats = media_analyzer.get_lookup_stats() | |
| print(f"\nKnown Media Types Status:") | |
| print(f" Loaded: {lookup_stats['loaded']}") | |
| print(f" Total Outlets: {lookup_stats['total_domains']}") | |
| if lookup_stats['by_type']: | |
| print(f" By Type: {lookup_stats['by_type']}") | |
| # Test with outlets (mix of lookup and LLM) | |
| test_outlets = ["nytimes.com", "cnn.com", "obscure-blog-site.com"] | |
| for test_outlet in test_outlets: | |
| print(f"\n Testing: {test_outlet}") | |
| media_result = media_analyzer.analyze(test_outlet) | |
| print(f" Media Type: {media_result.media_type.value}") | |
| print(f" Source: {media_result.source.value}") | |
| print(f" Confidence: {media_result.confidence:.2f}") | |
| print(f" Reasoning: {media_result.reasoning}") | |
| print("\n" + "=" * 70) | |
| print("DEMO COMPLETE") | |
| print("=" * 70) | |