Spaces:
Sleeping
Sleeping
File size: 9,200 Bytes
f3e3caf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | """
Source credibility β deterministic defense against low-trust / planted data.
Quote-anchor verification (src/verification/quote_anchor.py) proves a signal's
snippet really appears in the fetched page; it does NOT prove the *source* is
truthful. A fabricated article indexed by web search / NewsAPI would still pass
quote verification. This module adds the missing axis: how much do we trust the
*source*, graded into tiers and combined with independent cross-source
corroboration.
It is intentionally deterministic β a tier lookup from the pipeline's existing
``SourceType`` classification plus a small curated domain allow/deny list. No
network calls, no LLM, so it adds zero latency/cost.
Consumed by the risk-analysis agent to:
1. haircut ``confidence_score`` for low-trust sources (flows into category
scores via synthesis, which already multiplies by confidence), and
2. cap + flag a HIGH/CRITICAL finding that rests on a single low-credibility,
uncorroborated source (it is never deleted β recall is preserved β but its
severity is capped to MEDIUM, marked ``is_unverified`` and routed to HITL).
"""
from __future__ import annotations
import re
from enum import Enum
from src.models.signals import RiskSignal, Severity, SourceType
class CredibilityTier(str, Enum):
PRIMARY = "PRIMARY" # authoritative primary records β structurally hard to fake
ESTABLISHED = "ESTABLISHED" # reputable outlets / the company's own site
GENERAL = "GENERAL" # a real-looking publication not on the allowlist
LOW = "LOW" # PR wires, blogs, forums, UGC, content farms
TIER_WEIGHT: dict[CredibilityTier, float] = {
CredibilityTier.PRIMARY: 1.0,
CredibilityTier.ESTABLISHED: 0.85,
CredibilityTier.GENERAL: 0.6,
CredibilityTier.LOW: 0.35,
}
# ββ Severity-gate tuning ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
_HIGH_SEVERITIES = {Severity.CRITICAL, Severity.HIGH}
_CAPPABLE_TIERS = {CredibilityTier.GENERAL, CredibilityTier.LOW}
_SEVERITY_CAP = Severity.MEDIUM
MIN_INDEPENDENT_FOR_HIGH = 2 # independent domains needed to keep HIGH/CRITICAL on a low-trust source
# ββ Domain knowledge (small, curated, no PSL dependency) ββββββββββββββββββββββ
# Public suffixes with two labels β so registrable_domain keeps the right eTLD+1.
_TWO_LEVEL_TLDS = {
"co.uk", "gov.uk", "org.uk", "ac.uk", "com.au", "co.in", "co.jp", "com.br",
"co.za", "com.sg", "com.hk", "co.nz",
}
# Reputable news / data outlets β ESTABLISHED.
_ESTABLISHED_DOMAINS = {
"reuters.com", "bloomberg.com", "wsj.com", "ft.com", "nytimes.com",
"washingtonpost.com", "apnews.com", "theguardian.com", "bbc.com", "bbc.co.uk",
"cnbc.com", "forbes.com", "economist.com", "politico.com", "axios.com",
"npr.org", "latimes.com", "marketwatch.com", "barrons.com", "fortune.com",
"businessinsider.com", "theverge.com", "techcrunch.com", "arstechnica.com",
"wired.com", "nature.com", "sciencemag.org", "propublica.org", "law360.com",
"courthousenews.com", "thetimes.co.uk", "telegraph.co.uk", "nikkei.com",
"scmp.com", "aljazeera.com", "cnn.com", "nbcnews.com", "cbsnews.com",
"abcnews.go.com", "usatoday.com", "theinformation.com", "ap.org",
}
# PR wires (company-controlled), blogs, social / UGC, content farms β LOW.
# registrable_domain() collapses sub-domains, so "x.substack.com" maps here too.
_LOW_DOMAINS = {
"prnewswire.com", "businesswire.com", "globenewswire.com", "prweb.com",
"newswire.com", "einnews.com", "openpr.com", "accesswire.com", "issuewire.com",
"medium.com", "substack.com", "blogspot.com", "wordpress.com", "wix.com",
"weebly.com", "blogger.com", "tumblr.com", "reddit.com", "quora.com",
"facebook.com", "twitter.com", "x.com", "linkedin.com", "instagram.com",
"tiktok.com", "youtube.com",
}
_PRIMARY_TYPES = {
SourceType.SEC_FILING,
SourceType.COMPANY_REGISTRY,
SourceType.COURT_RECORD,
SourceType.SANCTIONS_LIST,
}
# ββ Domain helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def registrable_domain(url: str) -> str:
"""Best-effort eTLD+1, e.g. 'https://www.a.reuters.co.uk/x' -> 'reuters.co.uk'.
Avoids the tldextract dependency by special-casing a small set of two-level
public suffixes β enough for source-independence counting and tier lookup.
"""
m = re.match(r"https?://([^/]+)", (url or "").strip().lower())
host = (m.group(1) if m else (url or "").strip().lower()).split(":")[0]
if host.startswith("www."):
host = host[4:]
parts = [p for p in host.split(".") if p]
if len(parts) <= 2:
return ".".join(parts)
if ".".join(parts[-2:]) in _TWO_LEVEL_TLDS:
return ".".join(parts[-3:])
return ".".join(parts[-2:])
# ββ Classification ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def classify_credibility(source_url: str, source_type: SourceType) -> tuple[CredibilityTier, float]:
"""Map a source to a (tier, weight). Deterministic β no I/O.
Keys primarily off ``source_type`` (the pipeline already routes sec.gov β
SEC_FILING, .gov β COURT_RECORD, registries β COMPANY_REGISTRY, etc. in
research_agent._classify_source_type), refining only NEWS_ARTICLE by domain.
"""
if source_type in _PRIMARY_TYPES:
tier = CredibilityTier.PRIMARY
elif source_type in (SourceType.COMPANY_WEBSITE, SourceType.INTERNAL_DOC):
tier = CredibilityTier.ESTABLISHED
else: # NEWS_ARTICLE and any unknown β refine by domain
dom = registrable_domain(source_url)
if dom in _ESTABLISHED_DOMAINS:
tier = CredibilityTier.ESTABLISHED
elif dom in _LOW_DOMAINS:
tier = CredibilityTier.LOW
else:
tier = CredibilityTier.GENERAL
return tier, TIER_WEIGHT[tier]
def _tier_of(signal: RiskSignal) -> CredibilityTier:
try:
return CredibilityTier(signal.credibility_tier)
except ValueError:
# Unclassified (e.g. legacy signal) β treat as GENERAL (conservative).
return CredibilityTier.GENERAL
# ββ Confidence fusion (pure) ββββββββββββββββββββββββββββββββββββββββββββββββββ
def effective_trust(source_credibility: float, independent_source_count: int) -> float:
"""Source trust lifted by independent corroboration, capped at 1.0."""
return min(1.0, source_credibility + 0.15 * max(0, independent_source_count - 1))
def credibility_confidence_multiplier(source_credibility: float, independent_source_count: int) -> float:
"""0.4β1.0 multiplier: PRIMARY/corroborated β 1.0; a lone LOW source β 0.61."""
return 0.4 + 0.6 * effective_trust(source_credibility, independent_source_count)
def apply_credibility_adjustment(signal: RiskSignal) -> RiskSignal:
"""Haircut ``confidence_score`` by source credibility + corroboration.
Replaces the old flat +0.1 corroboration boost: trust is now graded and only
ever lowers confidence (never inflates the extractor's own estimate). A
PRIMARY or well-corroborated signal is left effectively unchanged.
"""
mult = credibility_confidence_multiplier(signal.source_credibility, signal.independent_source_count)
new_conf = max(0.0, min(1.0, signal.confidence_score * mult))
return signal.model_copy(update={"confidence_score": new_conf})
# ββ Severity gate (pure) ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def apply_credibility_gate(signal: RiskSignal) -> RiskSignal:
"""Cap + flag a HIGH/CRITICAL finding resting on a single low-trust source.
A HIGH/CRITICAL signal whose source is GENERAL/LOW tier AND is not
independently corroborated (fewer than ``MIN_INDEPENDENT_FOR_HIGH`` distinct
domains) is capped to MEDIUM, marked ``is_unverified``, and routed to human
review. It is never dropped. A single PRIMARY/ESTABLISHED source, or β₯2
independent domains, is left untouched.
"""
if (
signal.severity in _HIGH_SEVERITIES
and _tier_of(signal) in _CAPPABLE_TIERS
and signal.independent_source_count < MIN_INDEPENDENT_FOR_HIGH
):
return signal.model_copy(update={
"severity": _SEVERITY_CAP,
"is_unverified": True,
"requires_human_review": True,
})
return signal
__all__ = [
"CredibilityTier",
"TIER_WEIGHT",
"MIN_INDEPENDENT_FOR_HIGH",
"registrable_domain",
"classify_credibility",
"effective_trust",
"credibility_confidence_multiplier",
"apply_credibility_adjustment",
"apply_credibility_gate",
]
|