Spaces:
Sleeping
Sleeping
Download data_collector.py from infoclassifier/InfoClassifier: direct link, hf CLI and curl.
- Browser
- Download file 48.3 kB
-
https://huggingface.co/spaces/infoclassifier/InfoClassifier/resolve/main/data_collector.py
- Command line
-
hf download hf://spaces/infoclassifier/InfoClassifier/data_collector.py
-
curl -L -o data_collector.py https://huggingface.co/spaces/infoclassifier/InfoClassifier/resolve/main/data_collector.py
48.3 kB
| """ | |
| data_collector.py | |
| ================= | |
| Production data-collection pipeline for InformationIntegrity OpenEnv. | |
| Takes any claim string(s), fetches real evidence from multiple search/news | |
| backends, scores source reliability, classifies stance, and caches results | |
| in Redis. The output is a Scenario JSON ready for the environment. | |
| Backends (all optional β falls back gracefully): | |
| 1. SerpAPI SERPAPI_KEY | |
| 2. NewsAPI NEWSAPI_KEY | |
| 3. Brave Search BRAVE_SEARCH_KEY | |
| 4. Google Fact Check GOOGLE_FACTCHECK_KEY | |
| 5. DuckDuckGo HTML (no key, rate-limited fallback) | |
| Environment variables: | |
| SERPAPI_KEY | |
| NEWSAPI_KEY | |
| BRAVE_SEARCH_KEY | |
| GOOGLE_FACTCHECK_KEY | |
| REDIS_URL (default: redis://localhost:6379) | |
| CACHE_TTL_SECONDS (default: 86400 β 24h) | |
| RELIABILITY_CONFIG (default: domain_reliability.yaml) | |
| MAX_SOURCES_PER_CLAIM (default: 8) | |
| REQUEST_TIMEOUT (default: 10s) | |
| Usage: | |
| python data_collector.py --claims "Claim one" "Claim two" \ | |
| --content "The original post text" \ | |
| --difficulty medium \ | |
| --out scenario.json | |
| from data_collector import DataCollector, CollectionRequest | |
| collector = DataCollector() | |
| scenario = collector.collect(CollectionRequest( | |
| content="Post text", | |
| claims=["Claim A"], | |
| difficulty="medium", | |
| )) | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import time | |
| import urllib.parse | |
| import uuid | |
| from abc import ABC, abstractmethod | |
| from dataclasses import dataclass, field | |
| from typing import Any, Dict, List, Optional | |
| from urllib.parse import urlparse | |
| import fnmatch | |
| import requests | |
| import yaml | |
| # Load .env BEFORE any os.getenv() calls so API keys are available | |
| try: | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| except ImportError: | |
| pass # python-dotenv not installed; rely on shell environment | |
| try: | |
| from retrieval import QueryRewriter as _QueryRewriter | |
| _QUERY_REWRITER = _QueryRewriter() | |
| except Exception: | |
| _QUERY_REWRITER = None # type: ignore | |
| # Import the enhanced stance classifier with fallback to original | |
| try: | |
| from enhanced_stance_classifier import EnhancedStanceClassifier as _StanceClassifier, build_enhanced_stance_classifier | |
| _USE_ENHANCED_STANCE = True | |
| logger_tmp = logging.getLogger("data_collector") | |
| logger_tmp.info("Using enhanced stance classifier with improved context understanding") | |
| except Exception as _e: | |
| logger_tmp = logging.getLogger("data_collector") | |
| logger_tmp.warning("Enhanced stance_classifier unavailable (%s) β falling back to original", _e) | |
| try: | |
| from stance_classifier import StanceClassifier as _StanceClassifier, build_stance_classifier | |
| _USE_ENHANCED_STANCE = False | |
| _USE_NEW_STANCE = True | |
| except Exception as _e2: | |
| logger_tmp.warning("Original stance_classifier also unavailable (%s) β using legacy keywords", _e2) | |
| _USE_ENHANCED_STANCE = False | |
| _USE_NEW_STANCE = False | |
| try: | |
| import redis | |
| _REDIS_AVAILABLE = True | |
| except ImportError: | |
| _REDIS_AVAILABLE = False | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [%(levelname)s] %(name)s β %(message)s", | |
| ) | |
| logger = logging.getLogger("data_collector") | |
| # --------------------------------------------------------------------------- | |
| # Config | |
| # --------------------------------------------------------------------------- | |
| RELIABILITY_CONFIG_PATH = os.getenv("RELIABILITY_CONFIG", "domain_reliability.yaml") | |
| REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") | |
| CACHE_TTL = int(os.getenv("CACHE_TTL_SECONDS", 86400)) | |
| MAX_SOURCES = int(os.getenv("MAX_SOURCES_PER_CLAIM", 8)) | |
| REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", 10)) | |
| # --------------------------------------------------------------------------- | |
| # Domain Reliability Scorer | |
| # --------------------------------------------------------------------------- | |
| class DomainReliabilityScorer: | |
| """ | |
| Loads domain_reliability.yaml and scores any URL. | |
| Fully enterprise-configurable without touching Python code. | |
| """ | |
| def __init__(self, config_path: str = RELIABILITY_CONFIG_PATH) -> None: | |
| self._config = self._load(config_path) | |
| self._exact: Dict[str, tuple[float, str]] = {} # domain β (score, type) | |
| self._patterns: List[tuple[str, float, str]] = [] # (glob, score, type) | |
| self._build_index() | |
| def _load(self, path: str) -> dict: | |
| if not os.path.exists(path): | |
| logger.warning("Reliability config not found at %s β using defaults", path) | |
| return {} | |
| with open(path, "r", encoding="utf-8") as fh: | |
| return yaml.safe_load(fh) or {} | |
| def _build_index(self) -> None: | |
| skip = {"version", "defaults", "stance_thresholds", "evidence_thresholds"} | |
| for section_name, section in self._config.items(): | |
| if section_name in skip or not isinstance(section, dict): | |
| continue | |
| score = float(section.get("score", 0.35)) | |
| stype = str(section.get("source_type", "unknown")) | |
| for domain in section.get("domains", []): | |
| self._exact[domain.lower().lstrip("www.")] = (score, stype) | |
| for pat in section.get("patterns", []): | |
| self._patterns.append((pat.lower(), score, stype)) | |
| def score(self, url: str) -> tuple[float, str]: | |
| """Return (reliability_score, source_type) for a URL.""" | |
| try: | |
| host = urlparse(url).hostname or "" | |
| except Exception: | |
| host = "" | |
| host = host.lower().lstrip("www.") | |
| # Exact match (including subdomains stripped to base) | |
| for candidate in self._candidates(host): | |
| if candidate in self._exact: | |
| return self._exact[candidate] | |
| # Glob patterns (e.g. *.blogspot.com) | |
| for pat, score, stype in self._patterns: | |
| if fnmatch.fnmatch(host, pat): | |
| return score, stype | |
| defaults = self._config.get("defaults", {}) | |
| return ( | |
| float(defaults.get("unknown_domain_score", 0.35)), | |
| str(defaults.get("unknown_source_type", "unknown")), | |
| ) | |
| def _candidates(host: str) -> List[str]: | |
| """Generate host + progressively stripped parent domains.""" | |
| parts = host.split(".") | |
| candidates = [] | |
| for i in range(len(parts)): | |
| candidates.append(".".join(parts[i:])) | |
| return candidates | |
| def stance_config(self) -> dict: | |
| return self._config.get("stance_thresholds", {}) | |
| def evidence_thresholds(self) -> dict: | |
| return self._config.get("evidence_thresholds", {}) | |
| # --------------------------------------------------------------------------- | |
| # Stance Classifier | |
| # --------------------------------------------------------------------------- | |
| class StanceClassifier: | |
| """ | |
| Heuristic stance classification using keyword lists from the YAML config. | |
| No ML dependency β deterministic and inspectable. | |
| """ | |
| def __init__(self, scorer: DomainReliabilityScorer) -> None: | |
| cfg = scorer.stance_config | |
| self._contra_kw: List[str] = [k.lower() for k in cfg.get("contradicting_keywords", [])] | |
| self._supp_kw: List[str] = [k.lower() for k in cfg.get("supporting_keywords", [])] | |
| self._contra_min: int = int(cfg.get("contradicting_keyword_min", 2)) | |
| self._supp_min: int = int(cfg.get("supporting_keyword_min", 2)) | |
| def classify(self, text: str, claim: str) -> str: | |
| """Return 'supporting' | 'contradicting' | 'neutral'.""" | |
| lowered = text.lower() | |
| contra_hits = sum(1 for kw in self._contra_kw if kw in lowered) | |
| supp_hits = sum(1 for kw in self._supp_kw if kw in lowered) | |
| if contra_hits >= self._contra_min and contra_hits > supp_hits: | |
| return "contradicting" | |
| if supp_hits >= self._supp_min and supp_hits >= contra_hits: | |
| return "supporting" | |
| return "neutral" | |
| # --------------------------------------------------------------------------- | |
| # Data models for collection pipeline | |
| # --------------------------------------------------------------------------- | |
| class RawResult: | |
| title: str | |
| snippet: str | |
| url: str | |
| source: str # which backend returned this | |
| class CollectionRequest: | |
| content: str | |
| claims: List[str] | |
| difficulty: str = "medium" | |
| scenario_id: str = field(default_factory=lambda: str(uuid.uuid4())) | |
| tags: List[str] = field(default_factory=list) | |
| topic: str = "" | |
| force_refresh: bool = False | |
| # --------------------------------------------------------------------------- | |
| # Search backend interface + implementations | |
| # --------------------------------------------------------------------------- | |
| class SearchBackend(ABC): | |
| name: str = "base" | |
| def search(self, query: str, num_results: int = 8) -> List[RawResult]: | |
| ... | |
| def available(self) -> bool: | |
| return True | |
| class SerpAPIBackend(SearchBackend): | |
| name = "serpapi" | |
| def __init__(self) -> None: | |
| self._key = os.getenv("SERPAPI_KEY", "") | |
| def available(self) -> bool: | |
| return bool(self._key) | |
| def search(self, query: str, num_results: int = 8) -> List[RawResult]: | |
| # Detect query intent to choose the right SerpAPI parameters | |
| is_factcheck = re.search(r'\bfact.?check\b|debunked|hoax|false|misleading', query, re.I) | |
| is_news = re.search(r'\bofficial\b|\bgovernment\b|\bannouncement\b', query, re.I) | |
| params: dict = { | |
| "q": query, | |
| "api_key": self._key, | |
| "num": num_results, | |
| "hl": "en", | |
| "gl": "us", # default country; overridden below if entity found | |
| "safe": "off", | |
| } | |
| # For very recent claims (date entities present) restrict to last month | |
| if re.search(r'\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b|\b\d{4}\b', | |
| query, re.I): | |
| params["tbs"] = "qdr:m" # last month | |
| # Fact-check / debunking queries β force news tab | |
| if is_factcheck or is_news: | |
| params["tbm"] = "nws" | |
| # Adjust country context from entity | |
| country_gl = { | |
| "india": "in", "pakistan": "pk", "uk": "uk", "australia": "au", | |
| "canada": "ca", "germany": "de", "france": "fr", "japan": "jp", | |
| "china": "cn", "brazil": "br", "nigeria": "ng", | |
| } | |
| low_q = query.lower() | |
| for country, gl in country_gl.items(): | |
| if country in low_q: | |
| params["gl"] = gl | |
| break | |
| resp = requests.get( | |
| "https://serpapi.com/search", | |
| params=params, | |
| timeout=REQUEST_TIMEOUT, | |
| ) | |
| resp.raise_for_status() | |
| data = resp.json() | |
| results: List[RawResult] = [] | |
| # Answer box β featured snippet is very high signal | |
| if ab := data.get("answer_box"): | |
| snippet = ab.get("answer") or ab.get("snippet") or ab.get("result", "") | |
| link = ab.get("link", "") | |
| if snippet and link: | |
| results.append(RawResult( | |
| title = ab.get("title", "Featured Answer"), | |
| snippet = str(snippet)[:600], | |
| url = link, | |
| source = f"{self.name}:answer_box", | |
| )) | |
| # Top stories (news carousel) | |
| for story in data.get("top_stories", [])[:4]: | |
| link = story.get("link", "") | |
| if link: | |
| results.append(RawResult( | |
| title = story.get("title", ""), | |
| snippet = story.get("snippet", story.get("title", "")), | |
| url = link, | |
| source = f"{self.name}:news", | |
| )) | |
| # Organic results | |
| for item in data.get("organic_results", [])[:num_results]: | |
| link = item.get("link", "") | |
| if link: | |
| results.append(RawResult( | |
| title = item.get("title", ""), | |
| snippet = item.get("snippet", ""), | |
| url = link, | |
| source = self.name, | |
| )) | |
| return results | |
| class NewsAPIBackend(SearchBackend): | |
| name = "newsapi" | |
| def __init__(self) -> None: | |
| self._key = os.getenv("NEWSAPI_KEY", "") | |
| def available(self) -> bool: | |
| return bool(self._key) | |
| def search(self, query: str, num_results: int = 8) -> List[RawResult]: | |
| params = { | |
| "q": query, | |
| "apiKey": self._key, | |
| "language": "en", | |
| "sortBy": "relevancy", | |
| "pageSize": num_results, | |
| } | |
| resp = requests.get( | |
| "https://newsapi.org/v2/everything", | |
| params=params, | |
| timeout=REQUEST_TIMEOUT, | |
| ) | |
| resp.raise_for_status() | |
| data = resp.json() | |
| results = [] | |
| for article in data.get("articles", [])[:num_results]: | |
| content = article.get("content") or "" | |
| # NewsAPI truncates content with "[+N chars]" β strip that artifact | |
| content = re.sub(r"\s*\[\+\d+ chars\]$", "", content).strip() | |
| snippet = article.get("description") or content or "" | |
| results.append(RawResult( | |
| title=article.get("title", ""), | |
| snippet=snippet[:500], | |
| url=article.get("url", ""), | |
| source=self.name, | |
| )) | |
| return results | |
| class BraveSearchBackend(SearchBackend): | |
| name = "brave" | |
| def __init__(self) -> None: | |
| self._key = os.getenv("BRAVE_SEARCH_KEY", "") | |
| def available(self) -> bool: | |
| return bool(self._key) | |
| def search(self, query: str, num_results: int = 8) -> List[RawResult]: | |
| headers = { | |
| "Accept": "application/json", | |
| "Accept-Encoding": "gzip", | |
| "X-Subscription-Token": self._key, | |
| } | |
| params = {"q": query, "count": num_results, "text_decorations": False} | |
| resp = requests.get( | |
| "https://api.search.brave.com/res/v1/web/search", | |
| headers=headers, | |
| params=params, | |
| timeout=REQUEST_TIMEOUT, | |
| ) | |
| resp.raise_for_status() | |
| data = resp.json() | |
| results = [] | |
| for item in data.get("web", {}).get("results", [])[:num_results]: | |
| results.append(RawResult( | |
| title=item.get("title", ""), | |
| snippet=item.get("description", ""), | |
| url=item.get("url", ""), | |
| source=self.name, | |
| )) | |
| return results | |
| class GoogleFactCheckBackend(SearchBackend): | |
| """ | |
| Google Fact Check Tools API. | |
| Returns structured fact-check claims with verdicts from IFCN signatories. | |
| """ | |
| name = "google_factcheck" | |
| def __init__(self) -> None: | |
| self._key = os.getenv("GOOGLE_FACTCHECK_KEY", "") | |
| def available(self) -> bool: | |
| return bool(self._key) | |
| def search(self, query: str, num_results: int = 8) -> List[RawResult]: | |
| params = { | |
| "query": query, | |
| "key": self._key, | |
| "languageCode": "en", | |
| "pageSize": num_results, | |
| } | |
| resp = requests.get( | |
| "https://factchecktools.googleapis.com/v1alpha1/claims:search", | |
| params=params, | |
| timeout=REQUEST_TIMEOUT, | |
| ) | |
| resp.raise_for_status() | |
| data = resp.json() | |
| results = [] | |
| for item in data.get("claims", [])[:num_results]: | |
| reviews = item.get("claimReview", []) | |
| for review in reviews[:1]: | |
| publisher = review.get("publisher", {}).get("name", "") | |
| rating = review.get("textualRating", "") | |
| title = f"{publisher}: {rating}" | |
| snippet = ( | |
| f"Claim: {item.get('text', '')} | " | |
| f"Rating: {rating} | " | |
| f"Review: {review.get('title', '')}" | |
| ) | |
| url = review.get("url", "") | |
| results.append(RawResult( | |
| title=title, | |
| snippet=snippet, | |
| url=url, | |
| source=self.name, | |
| )) | |
| return results | |
| class DuckDuckGoFallbackBackend(SearchBackend): | |
| """ | |
| No-key fallback using DuckDuckGo. | |
| Rate-limit strategy | |
| ------------------- | |
| DDG aggressively rate-limits when the same IP fires multiple queries | |
| in quick succession. To work around this: | |
| 1. Only 1 query is ever sent per search() call (controlled by the | |
| n_queries=1 limit set in _fetch_all_backends). | |
| 2. Exponential back-off: 5s β 15s β 45s between retries. | |
| 3. On the first RateLimitException, promote directly to the HTML | |
| fallback (no waiting) β the HTML endpoint has a separate rate limit. | |
| 4. A class-level cooldown flag prevents hammering DDG when it's known | |
| to be rate-limiting; the flag resets after _COOLDOWN_SECS seconds. | |
| """ | |
| name = "duckduckgo" | |
| _MAX_RETRIES = 2 | |
| _BASE_DELAY = 5.0 # seconds β first retry wait | |
| _BACKOFF_MULT = 3.0 # each retry waits BASE * MULT^attempt | |
| _COOLDOWN_SECS = 60.0 # after a rate-limit, skip DDG for this many seconds | |
| # Class-level cooldown state (shared across all calls in the process) | |
| _cooldown_until: float = 0.0 | |
| def available(self) -> bool: | |
| return True | |
| def search(self, query: str, num_results: int = 8) -> List[RawResult]: | |
| import time as _time | |
| # Respect session-level cooldown | |
| if _time.time() < DuckDuckGoFallbackBackend._cooldown_until: | |
| remaining = DuckDuckGoFallbackBackend._cooldown_until - _time.time() | |
| logger.debug("DDG in cooldown for %.0fs β skipping to HTML fallback", remaining) | |
| return self._html_fallback(query, num_results) | |
| for attempt in range(self._MAX_RETRIES): | |
| try: | |
| from duckduckgo_search import DDGS | |
| from duckduckgo_search.exceptions import RatelimitException | |
| results = [] | |
| with DDGS() as ddgs: | |
| for r in ddgs.text(query, max_results=num_results): | |
| results.append(RawResult( | |
| title = r.get("title", ""), | |
| snippet = r.get("body", ""), | |
| url = r.get("href", ""), | |
| source = self.name, | |
| )) | |
| if results: | |
| return results | |
| logger.warning("DuckDuckGo returned 0 results (attempt %d)", attempt + 1) | |
| time.sleep(self._BASE_DELAY * (self._BACKOFF_MULT ** attempt)) | |
| except ImportError: | |
| return self._html_fallback(query, num_results) | |
| except Exception as exc: | |
| exc_str = str(exc) | |
| is_ratelimit = ( | |
| "202" in exc_str | |
| or "Ratelimit" in exc_str | |
| or "ratelimit" in exc_str.lower() | |
| or "rate" in exc_str.lower() | |
| ) | |
| if is_ratelimit: | |
| # Set cooldown and immediately try HTML β no point waiting | |
| DuckDuckGoFallbackBackend._cooldown_until = ( | |
| time.time() + self._COOLDOWN_SECS | |
| ) | |
| logger.warning( | |
| "DDG rate-limited β switching to HTML fallback " | |
| "(cooldown %ds)", self._COOLDOWN_SECS | |
| ) | |
| return self._html_fallback(query, num_results) | |
| logger.warning("DuckDuckGo attempt %d failed: %s", attempt + 1, exc) | |
| time.sleep(self._BASE_DELAY * (self._BACKOFF_MULT ** attempt)) | |
| # All DDGS attempts exhausted β try HTML | |
| return self._html_fallback(query, num_results) | |
| def _html_fallback(self, query: str, num_results: int) -> List[RawResult]: | |
| """Scrape DuckDuckGo HTML endpoint β separate rate limit from the API.""" | |
| import random | |
| _USER_AGENTS = [ | |
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", | |
| "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", | |
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", | |
| ] | |
| encoded = urllib.parse.quote_plus(query) | |
| url = f"https://html.duckduckgo.com/html/?q={encoded}" | |
| headers = { | |
| "User-Agent": random.choice(_USER_AGENTS), | |
| "Accept-Language": "en-US,en;q=0.9", | |
| "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", | |
| } | |
| try: | |
| resp = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT) | |
| resp.raise_for_status() | |
| clean_tag = re.compile(r"<[^>]+>") | |
| titles = re.findall(r'class="result__title">.*?<a[^>]+>(.*?)</a>', resp.text, re.S) | |
| snippets = re.findall(r'class="result__snippet">(.*?)</a>', resp.text, re.S) | |
| urls_raw = re.findall(r'class="result__url">(.*?)</span>', resp.text, re.S) | |
| results = [] | |
| for t, s, u in zip(titles, snippets, urls_raw): | |
| u = u.strip() | |
| results.append(RawResult( | |
| title = clean_tag.sub("", t).strip()[:200], | |
| snippet = clean_tag.sub("", s).strip()[:500], | |
| url = ("https://" + u) if not u.startswith("http") else u, | |
| source = self.name, | |
| )) | |
| if len(results) >= num_results: | |
| break | |
| return results | |
| except Exception as exc: | |
| logger.error("DuckDuckGo HTML fallback failed: %s", exc) | |
| return [] | |
| class WikipediaBackend(SearchBackend): | |
| """ | |
| Wikipedia REST API β zero rate-limits, no key, always returns results. | |
| Used as the guaranteed last-resort fallback when all other backends fail. | |
| """ | |
| name = "wikipedia" | |
| def available(self) -> bool: | |
| return True | |
| def search(self, query: str, num_results: int = 5) -> List[RawResult]: | |
| results = [] | |
| # 1. Search for matching article titles | |
| try: | |
| search_resp = requests.get( | |
| "https://en.wikipedia.org/w/api.php", | |
| params={ | |
| "action": "query", "list": "search", | |
| "srsearch": query, "srlimit": num_results, | |
| "format": "json", "utf8": 1, | |
| }, | |
| timeout=REQUEST_TIMEOUT, | |
| headers={"User-Agent": "InformationIntegrityEnv/1.0 (research)"}, | |
| ) | |
| search_resp.raise_for_status() | |
| hits = search_resp.json().get("query", {}).get("search", []) | |
| except Exception as exc: | |
| logger.warning("Wikipedia search failed: %s", exc) | |
| return [] | |
| # 2. Fetch intro paragraph for each hit | |
| for hit in hits[:num_results]: | |
| title = hit.get("title", "") | |
| snippet = re.sub(r"<[^>]+>", "", hit.get("snippet", "")).strip() | |
| try: | |
| summ_resp = requests.get( | |
| f"https://en.wikipedia.org/api/rest_v1/page/summary/{urllib.parse.quote(title)}", | |
| timeout=REQUEST_TIMEOUT, | |
| headers={"User-Agent": "InformationIntegrityEnv/1.0 (research)"}, | |
| ) | |
| if summ_resp.ok: | |
| data = summ_resp.json() | |
| snippet = data.get("extract", snippet)[:600] | |
| url = data.get("content_urls", {}).get("desktop", {}).get("page", | |
| f"https://en.wikipedia.org/wiki/{urllib.parse.quote(title)}") | |
| else: | |
| url = f"https://en.wikipedia.org/wiki/{urllib.parse.quote(title)}" | |
| except Exception: | |
| url = f"https://en.wikipedia.org/wiki/{urllib.parse.quote(title)}" | |
| results.append(RawResult( | |
| title = f"Wikipedia: {title}", | |
| snippet = snippet, | |
| url = url, | |
| source = self.name, | |
| )) | |
| return results | |
| # --------------------------------------------------------------------------- | |
| # Full Content Fetcher | |
| # --------------------------------------------------------------------------- | |
| class FullContentFetcher: | |
| """ | |
| Fetches and extracts the main article body from a URL. | |
| Uses newspaper3k when available (best extraction quality), then falls back | |
| to a BeautifulSoup heuristic that strips boilerplate and joins <p> tags. | |
| Only called for sources whose reliability score >= FULL_CONTENT_MIN_RELIABILITY. | |
| Returns None on any failure so callers can fall back to the snippet. | |
| """ | |
| MIN_RELIABILITY: float = float(os.getenv("FULL_CONTENT_MIN_RELIABILITY", "0.6")) | |
| MAX_CHARS: int = int(os.getenv("FULL_CONTENT_MAX_CHARS", "4000")) | |
| FETCH_TIMEOUT: int = int(os.getenv("FULL_CONTENT_TIMEOUT", "12")) | |
| _USER_AGENTS = [ | |
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", | |
| "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", | |
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", | |
| ] | |
| # Simple cache to avoid re-fetching the same URL within a single run | |
| _cache: Dict[str, Optional[str]] = {} | |
| def fetch(self, url: str) -> Optional[str]: | |
| """Return extracted article text (up to MAX_CHARS) or None on failure.""" | |
| if url in self._cache: | |
| return self._cache[url] | |
| text = self._fetch_newspaper(url) or self._fetch_bs4(url) | |
| if text: | |
| text = text[:self.MAX_CHARS] | |
| self._cache[url] = text | |
| return text | |
| def _fetch_newspaper(self, url: str) -> Optional[str]: | |
| """Extract article using newspaper3k (best quality).""" | |
| try: | |
| from newspaper import Article # type: ignore | |
| article = Article(url, request_timeout=self.FETCH_TIMEOUT) | |
| article.download() | |
| article.parse() | |
| body = (article.text or "").strip() | |
| return body if len(body) > 100 else None | |
| except Exception: | |
| return None | |
| def _fetch_bs4(self, url: str) -> Optional[str]: | |
| """Extract main content using BeautifulSoup as fallback.""" | |
| import random | |
| try: | |
| from bs4 import BeautifulSoup # type: ignore | |
| except ImportError: | |
| return None | |
| try: | |
| headers = { | |
| "User-Agent": random.choice(self._USER_AGENTS), | |
| "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", | |
| "Accept-Language": "en-US,en;q=0.9", | |
| } | |
| resp = requests.get(url, headers=headers, timeout=self.FETCH_TIMEOUT, allow_redirects=True) | |
| if not resp.ok or "text/html" not in resp.headers.get("Content-Type", ""): | |
| return None | |
| soup = BeautifulSoup(resp.text, "html.parser") | |
| # Remove boilerplate elements | |
| for tag in soup(["script", "style", "nav", "header", "footer", | |
| "aside", "form", "noscript", "iframe", "figure"]): | |
| tag.decompose() | |
| # Heuristic: find the container with the most <p> text | |
| candidates = soup.find_all(["article", "main", "div", "section"]) | |
| best_block = None | |
| best_len = 0 | |
| for block in candidates: | |
| text = " ".join(p.get_text(" ", strip=True) for p in block.find_all("p")) | |
| if len(text) > best_len: | |
| best_len = len(text) | |
| best_block = text | |
| if best_block and best_len > 150: | |
| # Normalise whitespace | |
| return re.sub(r"\s{2,}", " ", best_block).strip() | |
| # Last resort: just get all paragraph text | |
| all_p = " ".join(p.get_text(" ", strip=True) for p in soup.find_all("p")) | |
| cleaned = re.sub(r"\s{2,}", " ", all_p).strip() | |
| return cleaned if len(cleaned) > 150 else None | |
| except Exception as exc: | |
| logger.debug("BS4 content fetch failed for %s: %s", url[:60], exc) | |
| return None | |
| # --------------------------------------------------------------------------- | |
| # Redis Cache | |
| # --------------------------------------------------------------------------- | |
| class ScenarioCache: | |
| """ | |
| Redis-backed cache for collected scenarios. | |
| Key = sha256(claim_fingerprint). | |
| Falls back to an in-process dict if Redis is unavailable. | |
| """ | |
| def __init__(self, redis_url: str = REDIS_URL, ttl: int = CACHE_TTL) -> None: | |
| self._ttl = ttl | |
| self._mem: Dict[str, Any] = {} | |
| self._redis: Optional[Any] = None | |
| if _REDIS_AVAILABLE: | |
| try: | |
| self._redis = redis.from_url(redis_url, decode_responses=True) | |
| self._redis.ping() | |
| logger.info("Redis cache connected at %s", redis_url) | |
| except Exception as exc: | |
| logger.warning("Redis unavailable (%s) β falling back to in-process cache", exc) | |
| self._redis = None | |
| def _key(request: CollectionRequest) -> str: | |
| fingerprint = json.dumps({ | |
| "claims": sorted(request.claims), | |
| "difficulty": request.difficulty, | |
| }, sort_keys=True) | |
| return "scenario:" + hashlib.sha256(fingerprint.encode()).hexdigest() | |
| def get(self, request: CollectionRequest) -> Optional[dict]: | |
| k = self._key(request) | |
| if self._redis: | |
| raw = self._redis.get(k) | |
| if raw: | |
| return json.loads(raw) | |
| return self._mem.get(k) | |
| def set(self, request: CollectionRequest, scenario: dict) -> None: | |
| k = self._key(request) | |
| if self._redis: | |
| self._redis.setex(k, self._ttl, json.dumps(scenario)) | |
| self._mem[k] = scenario | |
| def invalidate(self, request: CollectionRequest) -> None: | |
| k = self._key(request) | |
| if self._redis: | |
| self._redis.delete(k) | |
| self._mem.pop(k, None) | |
| # --------------------------------------------------------------------------- | |
| # Main DataCollector | |
| # --------------------------------------------------------------------------- | |
| class DataCollector: | |
| """ | |
| Orchestrates multi-backend search, reliability scoring, stance | |
| classification, deduplication, and caching into a Scenario dict. | |
| """ | |
| def __init__( | |
| self, | |
| backends: Optional[List[SearchBackend]] = None, | |
| cache: Optional[ScenarioCache] = None, | |
| scorer: Optional[DomainReliabilityScorer] = None, | |
| ) -> None: | |
| self._scorer = scorer or DomainReliabilityScorer() | |
| self._cache = cache or ScenarioCache() | |
| self._backends = backends or self._default_backends() | |
| # Use enhanced stance classifier with fallbacks | |
| if _USE_ENHANCED_STANCE: | |
| self._stancer = build_enhanced_stance_classifier(self._scorer) | |
| elif _USE_NEW_STANCE: | |
| self._stancer = build_stance_classifier(self._scorer) | |
| else: | |
| self._stancer = StanceClassifier(self._scorer) # type: ignore | |
| def _default_backends() -> List[SearchBackend]: | |
| # Disable DuckDuckGo due to rate limiting and reliability issues | |
| candidates = [ | |
| SerpAPIBackend(), | |
| NewsAPIBackend(), | |
| BraveSearchBackend(), | |
| GoogleFactCheckBackend(), | |
| # DuckDuckGoFallbackBackend(), # Disabled - causes rate limiting issues | |
| WikipediaBackend(), # guaranteed fallback β no rate-limits | |
| ] | |
| available = [b for b in candidates if b.available()] | |
| if not available: | |
| logger.warning("No search backends available β results will be empty") | |
| logger.info( | |
| "Active search backends: %s", | |
| [b.name for b in available], | |
| ) | |
| return available | |
| # ------------------------------------------------------------------ | |
| # Public API | |
| # ------------------------------------------------------------------ | |
| def collect(self, request: CollectionRequest) -> dict: | |
| """ | |
| Main entry point. Returns a Scenario-compatible dict. | |
| Checks Redis cache first; fetches from APIs on cache miss. | |
| """ | |
| if not request.force_refresh: | |
| cached = self._cache.get(request) | |
| if cached: | |
| logger.info("Cache hit for scenario %s", request.scenario_id) | |
| return cached | |
| scenario = self._build_scenario(request) | |
| self._cache.set(request, scenario) | |
| logger.info( | |
| "Collected scenario %s β %d claims, %d sources", | |
| scenario["id"], | |
| len(scenario["claims"]), | |
| len(scenario["sources"]), | |
| ) | |
| return scenario | |
| def collect_batch(self, requests: List[CollectionRequest]) -> List[dict]: | |
| return [self.collect(r) for r in requests] | |
| # ------------------------------------------------------------------ | |
| # Internal pipeline | |
| # ------------------------------------------------------------------ | |
| def _build_scenario(self, req: CollectionRequest) -> dict: | |
| all_sources = [] | |
| seen_urls: set[str] = set() | |
| fetcher = FullContentFetcher() | |
| for claim in req.claims: | |
| raw_results = self._fetch_all_backends(claim) | |
| for raw in raw_results: | |
| norm_url = self._normalise_url(raw.url) | |
| if not norm_url or norm_url in seen_urls: | |
| continue | |
| seen_urls.add(norm_url) | |
| score, stype = self._scorer.score(norm_url) | |
| # For high-reliability sources, fetch the full article body so | |
| # stance classification has rich context rather than a 500-char snippet. | |
| full_text: Optional[str] = None | |
| if score >= FullContentFetcher.MIN_RELIABILITY: | |
| full_text = fetcher.fetch(norm_url) | |
| if full_text: | |
| logger.info( | |
| "Full content fetched for %s (%d chars, rel=%.2f)", | |
| norm_url[:70], len(full_text), score, | |
| ) | |
| # The text used for classification is full article when available, | |
| # otherwise fall back to the snippet returned by the backend. | |
| text_for_classification = full_text or raw.snippet | |
| # Use enhanced classifier when available (returns detailed tuple), | |
| # fall back to legacy interface (returns string only) | |
| try: | |
| if (_USE_ENHANCED_STANCE or _USE_NEW_STANCE) and hasattr(self._stancer, "classify_detailed"): | |
| stance, _conf, _layer = self._stancer.classify_detailed( | |
| title=raw.title, | |
| text=text_for_classification, | |
| claim=claim, | |
| source_reliability=score, | |
| ) | |
| logger.debug( | |
| "Stance '%s' via %s (conf=%.2f): %s", | |
| stance, _layer, _conf, raw.title[:60], | |
| ) | |
| else: | |
| stance = self._stancer.classify(text_for_classification + " " + raw.title, claim) | |
| logger.debug("Stance '%s' via legacy: %s", stance, raw.title[:60]) | |
| except Exception as stance_exc: | |
| logger.warning( | |
| "Stance classification failed for '%s': %s - defaulting to neutral", | |
| raw.title[:60], stance_exc | |
| ) | |
| stance = "neutral" | |
| src_id = f"src_{len(all_sources) + 1:03d}" | |
| all_sources.append({ | |
| "id": src_id, | |
| "title": raw.title[:200], | |
| "text": (full_text or raw.snippet)[:800], | |
| "url": norm_url, | |
| "stance": stance, | |
| "reliability": round(score, 3), | |
| "source_type": stype, | |
| "verification_weight": round(score, 3), | |
| "fetched_by": raw.source, | |
| "has_full_content": full_text is not None, | |
| }) | |
| if len(all_sources) >= MAX_SOURCES * len(req.claims): | |
| break | |
| claims_payload = [ | |
| { | |
| "id": f"claim_{i+1:03d}", | |
| "text": c, | |
| "ground_truth_status": self._infer_ground_truth_status(c, all_sources), | |
| "ground_truth_confidence": self._infer_confidence(all_sources), | |
| } | |
| for i, c in enumerate(req.claims) | |
| ] | |
| ground_truth = self._infer_decision(claims_payload, all_sources) | |
| return { | |
| "id": req.scenario_id, | |
| "difficulty": req.difficulty, | |
| "topic": req.topic or self._guess_topic(req.content), | |
| "tags": req.tags, | |
| "content": req.content, | |
| "claims": claims_payload, | |
| "sources": all_sources, | |
| "ground_truth": ground_truth, | |
| "explanation_hint": self._build_explanation_hint( | |
| req.claims, all_sources, ground_truth | |
| ), | |
| "_meta": { | |
| "collected_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), | |
| "backends_used": list({s["fetched_by"] for s in all_sources}), | |
| "total_sources": len(all_sources), | |
| }, | |
| } | |
| def _fetch_all_backends(self, query: str) -> List[RawResult]: | |
| """Try backends in priority order; aggregate unique results.""" | |
| results: List[RawResult] = [] | |
| seen_urls: set[str] = set() | |
| # Use QueryRewriter for semantically rich query variants | |
| if _QUERY_REWRITER is not None: | |
| enriched_queries = _QUERY_REWRITER.expand(query) | |
| else: | |
| keywords = query.strip().rstrip("?.") | |
| enriched_queries = [ | |
| keywords, | |
| f'fact check "{keywords}"', | |
| f'"{keywords}" evidence', | |
| f'is it true that {keywords}', | |
| ] | |
| for backend in self._backends: | |
| # SerpAPI gets all query variants (primary backend, paid, high rate-limit) | |
| # Other backends get 2 queries for better coverage | |
| if backend.name == "serpapi": | |
| n_queries = len(enriched_queries) | |
| else: | |
| n_queries = 2 | |
| try: | |
| for q in enriched_queries[:n_queries]: | |
| for r in backend.search(q, num_results=MAX_SOURCES): | |
| if r.url and r.url not in seen_urls and (r.snippet or r.title): | |
| seen_urls.add(r.url) | |
| results.append(r) | |
| except Exception as exc: | |
| logger.warning("Backend %s failed for query '%s': %s", backend.name, query, exc) | |
| # ββ Keyword gate: drop results with <2 overlapping claim keywords ββββββ | |
| gated = [r for r in results if self._keyword_overlap(query, r) >= self._min_overlap(query)] | |
| # Safety net: if gate drops everything, keep original results (no empty pool) | |
| if not gated and results: | |
| logger.debug("Keyword gate dropped all results for '%sβ¦' β keeping unfiltered", query[:60]) | |
| return results[:MAX_SOURCES * 3] | |
| return gated[:MAX_SOURCES * 3] | |
| def _keyword_overlap(claim: str, raw: RawResult) -> int: | |
| """Count shared non-trivial keywords between the claim and a result.""" | |
| _stop = { | |
| "the","a","an","is","are","was","were","in","on","at","to","from", | |
| "of","and","or","but","that","this","it","be","will","has","have", | |
| "its","by","for","not","with","also","into","than","more","about", | |
| } | |
| claim_kws = {w for w in re.findall(r'\b[a-z]{3,}\b', claim.lower()) if w not in _stop} | |
| result_kws = {w for w in re.findall(r'\b[a-z]{3,}\b', | |
| (raw.title + " " + raw.snippet).lower()) if w not in _stop} | |
| return len(claim_kws & result_kws) | |
| def _min_overlap(claim: str) -> int: | |
| """ | |
| Adaptive minimum overlap threshold based on claim length. | |
| Short claims (β€4 meaningful words) β require 1 match. | |
| Longer claims β require 2 matches (avoids generic Wikipedia hits). | |
| """ | |
| _stop = {"the","a","an","is","are","was","were","in","on","at","to", | |
| "from","of","and","or","but","that","this","it","be","will"} | |
| n_kws = len({w for w in re.findall(r'\b[a-z]{3,}\b', claim.lower()) if w not in _stop}) | |
| return 1 if n_kws <= 4 else 2 | |
| def _normalise_url(url: str) -> str: | |
| url = url.strip() | |
| if not url.startswith(("http://", "https://")): | |
| url = "https://" + url | |
| try: | |
| parsed = urlparse(url) | |
| return parsed.geturl() | |
| except Exception: | |
| return url | |
| def _infer_ground_truth_status(self, claim: str, sources: List[dict]) -> str: | |
| """ | |
| Heuristic: look at stance distribution weighted by reliability. | |
| Returns 'true' | 'false' | 'partially_true' | 'unknown' | |
| """ | |
| contra_score = sum( | |
| s["reliability"] for s in sources if s["stance"] == "contradicting" | |
| ) | |
| support_score = sum( | |
| s["reliability"] for s in sources if s["stance"] == "supporting" | |
| ) | |
| total = contra_score + support_score | |
| if total == 0: | |
| return "unknown" | |
| ratio = support_score / total | |
| if ratio < 0.2: | |
| return "false" | |
| if ratio > 0.7: | |
| return "true" | |
| return "partially_true" | |
| def _infer_confidence(self, sources: List[dict]) -> float: | |
| if not sources: | |
| return 0.0 | |
| max_rel = max(s["reliability"] for s in sources) | |
| return round(min(max_rel, 1.0), 3) | |
| def _infer_decision(self, claims: List[dict], sources: List[dict]) -> str: | |
| """ | |
| Derive a moderation recommendation from weighted evidence. | |
| remove β weighted false evidence dominant, high reliability | |
| flag β mixed or uncertain evidence | |
| allow β predominantly true, high-reliability support | |
| """ | |
| false_weight = sum( | |
| s["reliability"] | |
| for s in sources | |
| if s["stance"] == "contradicting" | |
| ) | |
| support_weight = sum( | |
| s["reliability"] | |
| for s in sources | |
| if s["stance"] == "supporting" | |
| ) | |
| neutral_weight = sum( | |
| s["reliability"] | |
| for s in sources | |
| if s["stance"] == "neutral" | |
| ) | |
| total = false_weight + support_weight + neutral_weight or 1.0 | |
| false_pct = false_weight / total | |
| support_pct = support_weight / total | |
| # Strong contradicting signal β remove | |
| if false_pct > 0.55 and false_weight > 1.5: | |
| return "remove" | |
| # Weak or mixed signal β flag | |
| if false_pct > 0.30 or (abs(false_pct - support_pct) < 0.20): | |
| return "flag" | |
| # Predominantly supported and reliable β allow | |
| if support_pct > 0.55 and support_weight > 1.0: | |
| return "allow" | |
| return "flag" | |
| def _guess_topic(content: str) -> str: | |
| TOPICS = [ | |
| ("health", ["vaccine", "covid", "cancer", "virus", "drug", "health", "disease"]), | |
| ("politics", ["election", "president", "government", "vote", "congress", "parliament"]), | |
| ("technology", ["5g", "ai", "chip", "microchip", "algorithm", "robot", "data"]), | |
| ("climate", ["climate", "warming", "carbon", "emission", "fossil", "renewable"]), | |
| ("economics", ["inflation", "gdp", "unemployment", "economy", "recession", "market"]), | |
| ("science", ["study", "research", "scientist", "experiment", "proof", "evidence"]), | |
| ] | |
| low = content.lower() | |
| for topic, keywords in TOPICS: | |
| if any(kw in low for kw in keywords): | |
| return topic | |
| return "general" | |
| def _build_explanation_hint(claims: List[str], sources: List[dict], decision: str) -> str: | |
| contra = [s for s in sources if s["stance"] == "contradicting"] | |
| supp = [s for s in sources if s["stance"] == "supporting"] | |
| lines = [ | |
| f"Decision: {decision}.", | |
| f"Claims examined: {len(claims)}.", | |
| f"Contradicting sources: {len(contra)}, Supporting sources: {len(supp)}.", | |
| ] | |
| if contra: | |
| top = max(contra, key=lambda s: s["reliability"]) | |
| lines.append(f"Strongest contradicting: {top['title'][:80]} (reliability {top['reliability']}).") | |
| if supp: | |
| top = max(supp, key=lambda s: s["reliability"]) | |
| lines.append(f"Strongest supporting: {top['title'][:80]} (reliability {top['reliability']}).") | |
| return " ".join(lines) | |
| # --------------------------------------------------------------------------- | |
| # CLI | |
| # --------------------------------------------------------------------------- | |
| def _cli() -> None: | |
| import argparse | |
| parser = argparse.ArgumentParser( | |
| description="Collect real-world evidence for a set of claims." | |
| ) | |
| parser.add_argument( | |
| "--claims", nargs="+", required=True, | |
| help="One or more claim strings to investigate" | |
| ) | |
| parser.add_argument( | |
| "--content", default="", | |
| help="The original piece of content containing the claims" | |
| ) | |
| parser.add_argument( | |
| "--difficulty", default="medium", | |
| choices=["easy", "medium", "hard"], | |
| ) | |
| parser.add_argument( | |
| "--topic", default="", | |
| help="Optional topic label" | |
| ) | |
| parser.add_argument( | |
| "--tags", nargs="*", default=[], | |
| help="Optional tags" | |
| ) | |
| parser.add_argument( | |
| "--out", default="scenario.json", | |
| help="Output file path" | |
| ) | |
| parser.add_argument( | |
| "--force-refresh", action="store_true", | |
| help="Ignore Redis cache and re-fetch" | |
| ) | |
| args = parser.parse_args() | |
| collector = DataCollector() | |
| request = CollectionRequest( | |
| content=args.content or " ".join(args.claims), | |
| claims=args.claims, | |
| difficulty=args.difficulty, | |
| topic=args.topic, | |
| tags=args.tags, | |
| force_refresh=args.force_refresh, | |
| ) | |
| scenario = collector.collect(request) | |
| with open(args.out, "w", encoding="utf-8") as fh: | |
| json.dump(scenario, fh, indent=2, ensure_ascii=False) | |
| logger.info("Scenario written to %s", args.out) | |
| print(json.dumps(scenario, indent=2, ensure_ascii=False)) | |
| if __name__ == "__main__": | |
| _cli() | |