InfoClassifier / decision_engine.py
UTKARSH MISHRA
Initial commit: InfoClassifier project
e5e36db
Raw History Blame Contribute Delete
19.4 kB
"""
decision_engine.py
==================
High-level moderation decision engine.
Takes a summarised claim_result dict (produced after stance classification and
source retrieval) and returns a final moderation decision with recalibrated
confidence and a human-readable reason.
This layer sits *above* reasoning.py:
DataCollector β†’ StanceClassifier β†’ reasoning.py β†’ decision_engine.py β†’ UI
Why a separate layer?
reasoning.py works on raw ScoredSource objects (per-source contributions).
This module works on already-aggregated claim summaries, which is the shape
that the UI, API, and tests produce. The two layers complement each other β€”
reasoning.py computes the evidence score; this module applies the final
decision policy (thresholds, pattern boosts, confidence blending).
Usage
-----
from decision_engine import make_moderation_decision
result = make_moderation_decision({
"claim": "Humans only use 10% of their brain",
"status": "FALSE",
"confidence": 0.35,
"supporting_sources": 0,
"contradicting_sources": 3,
"neutral_sources": 1,
"avg_source_reliability": 0.72,
})
# β†’ {"decision": "AUTO_REMOVE", "final_confidence": 0.88, "reason": "..."}
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Optional
# ─────────────────────────────────────────────────────────────────────────────
# Configurable thresholds
# All values can be overridden per-call via the `config` parameter.
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class DecisionConfig:
# ── AUTO_REMOVE thresholds ────────────────────────────────────────────────
# Minimum contradicting sources required for AUTO_REMOVE when status=FALSE
auto_remove_min_contra: int = 2
# OR: single-source override when that one source is very reliable
auto_remove_single_src_rel: float = 0.85
# Settled-science / known-misinformation pattern always triggers AUTO_REMOVE
# with just 1 contradicting source
auto_remove_pattern_min_contra: int = 1
# ── ALLOW thresholds ──────────────────────────────────────────────────────
allow_min_support: int = 2 # min supporting sources for ALLOW
allow_single_src_rel: float = 0.90 # single very-reliable source is enough
# ── Confidence blend weights (must sum to 1.0) ────────────────────────────
weight_stance: float = 0.50 # from status + incoming confidence
weight_evidence: float = 0.30 # from source counts
weight_reliability: float = 0.20 # from avg_source_reliability
# ── Evidence strength formula ─────────────────────────────────────────────
# evidence_strength = contra / (contra + support + 1) for FALSE claims
# evidence_strength = support / (support + contra + 1) for TRUE claims
# The +1 in denominator prevents division-by-zero and dampens single-source certainty.
# ── Pattern boost ─────────────────────────────────────────────────────────
pattern_boost: float = 0.15 # added to final_confidence when a known pattern matches
# ── Confidence floor/ceiling ──────────────────────────────────────────────
min_confidence: float = 0.10
max_confidence: float = 0.99
# Default singleton β€” callers can pass a custom config to override
_DEFAULT_CONFIG = DecisionConfig()
# ─────────────────────────────────────────────────────────────────────────────
# Known misinformation patterns
# ─────────────────────────────────────────────────────────────────────────────
# Each entry: (label, regex)
# When a claim text matches, the pattern_boost is applied AND the
# auto_remove threshold is lowered to auto_remove_pattern_min_contra.
_KNOWN_MISINFO: list[tuple[str, re.Pattern]] = [
# Neuroscience myth
("10pct_brain", re.compile(
r"\b(10\s*%|ten\s+percent).{0,30}brain\b|\bbrain.{0,30}(10\s*%|ten\s+percent)\b",
re.IGNORECASE,
)),
# Flat earth
("flat_earth", re.compile(
r"\b(earth\s+is\s+flat|flat\s+earth|earth\s+isn.t?\s+(round|spherical|globe))\b",
re.IGNORECASE,
)),
# Bleach / MMS as cure
("bleach_cure", re.compile(
r"\b(drink(ing)?\s+bleach|bleach\s+cure[sd]?|mms\s+cure[sd]?|"
r"chlorine\s+dioxide\s+cure|inject(ing)?\s+disinfectant)\b",
re.IGNORECASE,
)),
# Vaccine-autism link
("vaccines_autism", re.compile(
r"\bvaccines?\s+(cause[sd]?|linked?\s+to|lead[s]?\s+to)\s+autism\b",
re.IGNORECASE,
)),
# 5G / COVID
("5g_covid", re.compile(
r"\b5g\s+(causes?|spreads?|created?|transmits?)\s+(covid|coronavirus|virus)\b",
re.IGNORECASE,
)),
# Climate hoax
("climate_hoax", re.compile(
r"\bclimate\s+change\s+is\s+(a\s+)?(hoax|fake|not\s+real|fabricated)\b",
re.IGNORECASE,
)),
# Moon landing
("moon_landing", re.compile(
r"\b(moon\s+landing\s+(was\s+)?(fake|faked|hoax|staged)|never\s+landed\s+on.{0,10}moon)\b",
re.IGNORECASE,
)),
# HIV denial
("hiv_denial", re.compile(
r"\b(hiv\s+(doesn.t?\s+cause|doesn.t?\s+exist|is\s+fake)|aids\s+is\s+(a\s+)?(hoax|fake))\b",
re.IGNORECASE,
)),
# Evolution denial
("evolution_denial", re.compile(
r"\b(evolution\s+is\s+(false|fake|a\s+lie|wrong|disproven)|humans?\s+didn.t?\s+evolve)\b",
re.IGNORECASE,
)),
]
def _match_known_misinfo(claim: str) -> Optional[str]:
"""
Returns the pattern label if the claim matches a known misinformation
pattern, otherwise None.
"""
for label, pattern in _KNOWN_MISINFO:
if pattern.search(claim):
return label
return None
# ─────────────────────────────────────────────────────────────────────────────
# Confidence calculation
# ─────────────────────────────────────────────────────────────────────────────
def _compute_confidence(
status: str,
incoming_confidence: float,
supporting: int,
contradicting: int,
avg_reliability: float,
pattern_label: Optional[str],
cfg: DecisionConfig,
) -> float:
"""
Blend three independent signals into a single calibrated confidence score.
Component 1 β€” Stance confidence (weight=0.50)
Directly from the upstream classifier. Clamped to [0, 1].
Component 2 β€” Evidence strength (weight=0.30)
For FALSE claims: contra / (contra + support + 1)
For TRUE claims: support / (support + contra + 1)
For UNKNOWN: 0.0 (no directional evidence)
The +1 dampens over-confidence from a single source.
Component 3 β€” Source reliability (weight=0.20)
avg_source_reliability passed through directly.
Pattern boost (additive, after blend)
+cfg.pattern_boost when a known misinformation pattern is matched.
"""
# Component 1
stance_conf = max(0.0, min(1.0, incoming_confidence))
# Component 2
total_directional = supporting + contradicting
if status == "FALSE" and contradicting > 0:
evidence_strength = contradicting / (total_directional + 1)
elif status == "TRUE" and supporting > 0:
evidence_strength = supporting / (total_directional + 1)
else:
evidence_strength = 0.0
# Component 3
reliability = max(0.0, min(1.0, avg_reliability))
# Weighted blend
blended = (
cfg.weight_stance * stance_conf +
cfg.weight_evidence * evidence_strength +
cfg.weight_reliability * reliability
)
# Pattern boost
if pattern_label is not None:
blended += cfg.pattern_boost
return round(max(cfg.min_confidence, min(cfg.max_confidence, blended)), 4)
# ─────────────────────────────────────────────────────────────────────────────
# Core decision logic
# ─────────────────────────────────────────────────────────────────────────────
def _decide(
status: str,
contradicting: int,
supporting: int,
avg_reliability: float,
pattern_label: Optional[str],
cfg: DecisionConfig,
) -> tuple[str, str]:
"""
Returns (decision, reason_fragment).
Decision priority:
1. AUTO_REMOVE β€” status is FALSE + sufficient contradicting evidence
2. ALLOW β€” status is TRUE + sufficient supporting evidence
3. FLAG_FOR_REVIEW β€” everything else
"""
# ── Path 1: AUTO_REMOVE ───────────────────────────────────────────────────
if status == "FALSE":
# Known misinformation pattern: lower bar to 1 source
if pattern_label is not None and contradicting >= cfg.auto_remove_pattern_min_contra:
return (
"AUTO_REMOVE",
f"Matches known misinformation pattern '{pattern_label}' "
f"with {contradicting} contradicting source(s).",
)
# Standard: β‰₯2 contradicting sources
if contradicting >= cfg.auto_remove_min_contra:
return (
"AUTO_REMOVE",
f"{contradicting} contradicting source(s) confirm this claim is false.",
)
# Single very-reliable contradicting source
if contradicting >= 1 and avg_reliability >= cfg.auto_remove_single_src_rel:
return (
"AUTO_REMOVE",
f"Single highly reliable source (reliability={avg_reliability:.2f}) "
f"contradicts this claim.",
)
# ── Path 2: ALLOW ─────────────────────────────────────────────────────────
if status == "TRUE":
if supporting >= cfg.allow_min_support:
return (
"ALLOW",
f"{supporting} supporting source(s) confirm this claim is true.",
)
if supporting >= 1 and avg_reliability >= cfg.allow_single_src_rel:
return (
"ALLOW",
f"Single authoritative source (reliability={avg_reliability:.2f}) "
f"confirms this claim.",
)
# ── Path 3: FLAG_FOR_REVIEW ───────────────────────────────────────────────
if status == "UNKNOWN":
reason = "Claim status is UNKNOWN β€” insufficient evidence to decide."
elif status == "FALSE" and contradicting == 0:
reason = "Claim is marked FALSE but no contradicting sources were retrieved."
elif status == "FALSE" and contradicting < cfg.auto_remove_min_contra:
reason = (
f"Claim is FALSE but only {contradicting} contradicting source(s) found "
f"(need β‰₯{cfg.auto_remove_min_contra} for auto-removal)."
)
elif status == "TRUE" and supporting < cfg.allow_min_support:
reason = (
f"Claim is TRUE but only {supporting} supporting source(s) found "
f"(need β‰₯{cfg.allow_min_support} for auto-allow)."
)
else:
reason = "Evidence is insufficient or ambiguous."
return "FLAG_FOR_REVIEW", reason
# ─────────────────────────────────────────────────────────────────────────────
# Public API
# ─────────────────────────────────────────────────────────────────────────────
def make_moderation_decision(
claim_result: dict,
config: Optional[DecisionConfig] = None,
) -> dict:
"""
Make a final moderation decision for a single claim.
Parameters
----------
claim_result : dict
{
"claim": str,
"status": "TRUE" | "FALSE" | "UNKNOWN",
"confidence": float, # from upstream classifier (0–1)
"supporting_sources": int,
"contradicting_sources": int,
"neutral_sources": int,
"avg_source_reliability": float, # 0–1
}
config : DecisionConfig | None
Override default thresholds. Pass None to use defaults.
Returns
-------
dict
{
"decision": "AUTO_REMOVE" | "ALLOW" | "FLAG_FOR_REVIEW",
"final_confidence": float, # 0.10–0.99
"reason": str,
"pattern_matched": str | None, # known misinformation label if triggered
}
"""
cfg = config or _DEFAULT_CONFIG
# ── Extract fields with safe defaults ─────────────────────────────────────
claim = str(claim_result.get("claim", ""))
status = str(claim_result.get("status", "UNKNOWN")).upper()
confidence = float(claim_result.get("confidence", 0.0))
supporting = int(claim_result.get("supporting_sources", 0))
contradicting = int(claim_result.get("contradicting_sources", 0))
avg_rel = float(claim_result.get("avg_source_reliability", 0.5))
# Normalise status to our three canonical values
if status not in ("TRUE", "FALSE", "UNKNOWN"):
status = "UNKNOWN"
# ── Known misinformation pattern check ────────────────────────────────────
pattern_label = _match_known_misinfo(claim)
# ── Decision ──────────────────────────────────────────────────────────────
decision, reason_fragment = _decide(
status, contradicting, supporting, avg_rel, pattern_label, cfg
)
# ── Confidence recalibration ──────────────────────────────────────────────
final_confidence = _compute_confidence(
status, confidence, supporting, contradicting,
avg_rel, pattern_label, cfg,
)
# ── Build human-readable reason ───────────────────────────────────────────
reason = _build_reason(
claim, decision, status, final_confidence,
supporting, contradicting, avg_rel,
pattern_label, reason_fragment,
)
return {
"decision": decision,
"final_confidence": final_confidence,
"reason": reason,
"pattern_matched": pattern_label,
}
def _build_reason(
claim: str,
decision: str,
status: str,
confidence: float,
supporting: int,
contradicting: int,
avg_rel: float,
pattern_label: Optional[str],
core_reason: str,
) -> str:
"""Compose a full human-readable explanation."""
lines = []
# Decision headline
headline = {
"AUTO_REMOVE": "Content removed: claim is false and clearly contradicted.",
"ALLOW": "Content allowed: claim is supported by evidence.",
"FLAG_FOR_REVIEW": "Content flagged: insufficient or ambiguous evidence.",
}.get(decision, "Decision made.")
lines.append(headline)
# Core reason from decision logic
lines.append(core_reason)
# Evidence summary
lines.append(
f"Evidence: {supporting} supporting, {contradicting} contradicting "
f"(avg source reliability: {avg_rel:.0%})."
)
# Confidence
lines.append(f"Final confidence: {confidence:.0%}.")
return " ".join(lines)
# ─────────────────────────────────────────────────────────────────────────────
# Batch helper
# ─────────────────────────────────────────────────────────────────────────────
def make_moderation_decisions(
claim_results: list[dict],
config: Optional[DecisionConfig] = None,
) -> dict:
"""
Run make_moderation_decision over multiple claims and derive a single
aggregate content-level decision.
Aggregate logic (matches real-world platforms):
ANY AUTO_REMOVE β†’ content = AUTO_REMOVE
ANY FLAG_FOR_REVIEW (and no remove) β†’ content = FLAG_FOR_REVIEW
ALL ALLOW β†’ content = ALLOW
Returns
-------
{
"content_decision": "AUTO_REMOVE" | "ALLOW" | "FLAG_FOR_REVIEW",
"per_claim": [ {claim_result + decision fields}, ... ],
}
"""
per_claim = []
for cr in claim_results:
result = make_moderation_decision(cr, config)
per_claim.append({**cr, **result})
decisions = [r["decision"] for r in per_claim]
if "AUTO_REMOVE" in decisions:
content_decision = "AUTO_REMOVE"
elif "FLAG_FOR_REVIEW" in decisions:
content_decision = "FLAG_FOR_REVIEW"
else:
content_decision = "ALLOW"
return {
"content_decision": content_decision,
"per_claim": per_claim,
}