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",
]