FactCheckAI / conflictdetection.py
vishwjeet71's picture
Initial deployment
540b123
import re
import json
import torch
import spacy
from sentence_transformers import SentenceTransformer, util
from transformers import AutoTokenizer, AutoModelForSequenceClassification, logging
from dataclasses import dataclass
from typing import List, Tuple
logging.set_verbosity_error()
# Spacy model — loaded once at module level with proper error handling
_SPACY_MODEL_NAME = "en_core_web_trf"
def _load_spacy_model(model_name: str):
try:
return spacy.load(model_name)
except OSError:
print(f"[ConflictDetector] spaCy model '{model_name}' not found. Downloading...")
try:
spacy.cli.download(model_name)
print(f"[ConflictDetector] Model '{model_name}' downloaded successfully.")
return spacy.load(model_name)
except Exception as e:
raise RuntimeError(f"[ConflictDetector] spaCy model download failed: {e}") from e
except Exception as e:
raise RuntimeError(f"[ConflictDetector] spaCy model loading failed: {e}") from e
try:
_spacy_model = _load_spacy_model(_SPACY_MODEL_NAME)
except RuntimeError as e:
print(f"[ConflictDetector] WARNING: spaCy model could not be loaded: {e}")
print("[ConflictDetector] NER-based conflict classification will fall back to 'Factual Conflict'.")
_spacy_model = None
# Data class
@dataclass
class Conflict:
sentence_a: str
sentence_b: str
conflict_type: str
severity: str
confidence: float
contradiction_score: float
# ConflictDetector
class ConflictDetector:
def __init__(self, strictness: float = 0.7):
if not (0.0 <= strictness <= 1.0):
raise ValueError(f"strictness must be between 0.0 and 1.0, got {strictness}")
self.strictness = strictness
print("[ConflictDetector] Loading semantic similarity model...")
try:
self.similarity_model = SentenceTransformer("all-MiniLM-L6-v2")
except Exception as e:
raise RuntimeError(f"[ConflictDetector] Failed to load similarity model: {e}") from e
print("[ConflictDetector] Loading NLI contradiction detection model...")
try:
_nli_model_name = "cross-encoder/nli-deberta-v3-base"
self.nli_tokenizer = AutoTokenizer.from_pretrained(_nli_model_name)
self.nli_model = AutoModelForSequenceClassification.from_pretrained(_nli_model_name)
self.nli_model.eval()
except Exception as e:
raise RuntimeError(f"[ConflictDetector] Failed to load NLI model: {e}") from e
print("[ConflictDetector] Loading NER model...")
self.nlp = _spacy_model
self.ignore_patterns = [
r"\b(published|updated|posted|written by|author|reporter|editor)\b",
r"\b\d{1,2}:\d{2}\s?(am|pm|AM|PM)\b",
r"\bfollow us\b|\bsubscribe\b|\bclick here\b",
r"\bcopyright\b|\ball rights reserved\b",
]
print("[ConflictDetector] All models loaded.\n")
def split_into_claims(self, text: str) -> List[str]:
if not isinstance(text, str) or not text.strip():
return []
sentences = re.split(r"(?<=[.!?])\s+", text.strip())
claims = []
for sent in sentences:
sent = sent.strip()
if len(sent.split()) < 6:
continue
if any(re.search(p, sent, re.IGNORECASE) for p in self.ignore_patterns):
continue
claims.append(sent)
return claims
def find_similar_pairs(self, claims_a, claims_b):
if not claims_a or not claims_b:
return []
similarity_threshold = 0.75 - (self.strictness * 0.25)
try:
embeddings_a = self.similarity_model.encode(claims_a, batch_size=24, convert_to_tensor=True)
embeddings_b = self.similarity_model.encode(claims_b, batch_size=24, convert_to_tensor=True)
except Exception as e:
print(f"[ConflictDetector] Encoding failed during similarity search: {e}")
return []
cosine_scores = util.cos_sim(embeddings_a, embeddings_b)
pairs = []
for i in range(len(claims_a)):
for j in range(len(claims_b)):
score = cosine_scores[i][j].item()
if score >= similarity_threshold:
pairs.append((claims_a[i], claims_b[j], score))
pairs.sort(key=lambda x: x[2], reverse=True)
return pairs
def check_contradiction(self, sentence_a: str, sentence_b: str) -> float:
try:
inputs = self.nli_tokenizer(
sentence_a, sentence_b,
return_tensors="pt", truncation=True, max_length=512,
)
with torch.no_grad():
outputs = self.nli_model(**inputs)
probs = torch.softmax(outputs.logits, dim=-1)
return probs[0][0].item()
except Exception as e:
print(f"[ConflictDetector] NLI check failed for pair: {e}")
return 0.0
def classify_conflict_type(self, sentence_a: str, sentence_b: str) -> str:
try:
doc_a = self.nlp(sentence_a)
doc_b = self.nlp(sentence_b)
except Exception as e:
print(f"[ConflictDetector] NER classification failed: {e}")
return "Factual Conflict"
entities_a = {ent.label_: ent.text for ent in doc_a.ents}
entities_b = {ent.label_: ent.text for ent in doc_b.ents}
entity_type_map = {
"PERSON": "Name Mismatch",
"ORG": "Organization Mismatch",
"GPE": "Location Mismatch",
"LOC": "Location Mismatch",
"DATE": "Date Mismatch",
"TIME": "Time Mismatch",
"CARDINAL": "Number Mismatch",
"ORDINAL": "Order/Rank Mismatch",
"MONEY": "Financial Mismatch",
"PERCENT": "Statistics Mismatch",
"EVENT": "Event Mismatch",
}
conflicts_found = []
for entity_label, conflict_name in entity_type_map.items():
val_a = entities_a.get(entity_label)
val_b = entities_b.get(entity_label)
if val_a and val_b and val_a.lower() != val_b.lower():
conflicts_found.append(conflict_name)
return " & ".join(set(conflicts_found)) if conflicts_found else "Factual Conflict"
def get_severity(self, contradiction_score: float, conflict_type: str) -> str:
high_priority_types = [
"Date Mismatch", "Location Mismatch", "Number Mismatch",
"Event Mismatch", "Factual Conflict",
]
is_high_priority = any(t in conflict_type for t in high_priority_types)
if contradiction_score >= 0.85:
return "HIGH"
elif contradiction_score >= 0.65:
return "HIGH" if is_high_priority else "MEDIUM"
else:
return "MEDIUM" if is_high_priority else "LOW"
def detect_conflicts(self, doc_a: str, doc_b: str) -> List[Conflict]:
contradiction_threshold = 0.85 - (self.strictness * 0.35)
print(f"[ConflictDetector] Strictness: {self.strictness} | Contradiction threshold: {contradiction_threshold:.2f}")
claims_a = self.split_into_claims(doc_a)
claims_b = self.split_into_claims(doc_b)
print(f"[ConflictDetector] Doc A: {len(claims_a)} claims | Doc B: {len(claims_b)} claims")
if not claims_a or not claims_b:
print("[ConflictDetector] One or both documents produced no claims. Skipping.")
return []
similar_pairs = self.find_similar_pairs(claims_a, claims_b)
print(f"[ConflictDetector] Similar pairs found: {len(similar_pairs)}")
conflicts = []
seen_pairs: set = set()
for sent_a, sent_b, sim_score in similar_pairs:
pair_key = (sent_a[:50], sent_b[:50])
if pair_key in seen_pairs:
continue
seen_pairs.add(pair_key)
contradiction_score = self.check_contradiction(sent_a, sent_b)
if contradiction_score >= contradiction_threshold:
conflict_type = self.classify_conflict_type(sent_a, sent_b)
severity = self.get_severity(contradiction_score, conflict_type)
conflicts.append(Conflict(
sentence_a=sent_a,
sentence_b=sent_b,
conflict_type=conflict_type,
severity=severity,
confidence=round(sim_score, 3),
contradiction_score=round(contradiction_score, 3),
))
severity_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
conflicts.sort(key=lambda x: (severity_order[x.severity], -x.contradiction_score))
return conflicts
def report(self, doc_a: str, doc_b: str, external_source: str = "unknown") -> dict:
"""
Runs conflict detection and returns a structured dict.
Always returns a dict — .
"""
# BUG FIX: Previously, when doc_a had no extractable claims (input too
# short, or all sentences under 6 words), detect_conflicts() returned []
# and report() returned {"status": "NO_CONFLICTS"}. That is a false result —
# the pipeline had no basis to say "no conflicts"; it simply couldn't read
# the input. The AI bot receiving NO_CONFLICTS would tell the user the
# article is consistent, which is a wrong conclusion from an empty analysis.
# Now we detect this before running the full pipeline and return a distinct
# INSUFFICIENT_CONTENT status that accurately describes what happened.
claims_a = self.split_into_claims(doc_a)
if not claims_a:
return {
"status": "INSUFFICIENT_CONTENT",
"error": (
"The input text could not be broken into verifiable claims. "
"It may be too short (under 6 words per sentence) or contain "
"only boilerplate/metadata. Provide a paragraph or more of "
"substantive text for meaningful conflict analysis."
),
"total": 0,
"conflicts": {},
}
try:
conflicts = self.detect_conflicts(doc_a, doc_b)
except Exception as e:
print(f"[ConflictDetector] detect_conflicts raised unexpectedly: {e}")
return {
"status": "ERROR",
"error": f"Detection pipeline failed: {type(e).__name__}: {e}",
"total": 0,
"conflicts": {},
}
if not conflicts:
return {"status": "NO_CONFLICTS", "total": 0, "conflicts": {}}
high = [c for c in conflicts if c.severity == "HIGH"]
medium = [c for c in conflicts if c.severity == "MEDIUM"]
low = [c for c in conflicts if c.severity == "LOW"]
if len(high) >= 3:
verdict = "BIG_MISMATCH"
elif len(high) >= 1:
verdict = "MISMATCH_DETECTED"
elif len(medium) >= 2:
verdict = "MINOR_MISMATCH"
else:
verdict = "MOSTLY_CONSISTENT"
return {
"status": verdict,
"total": len(conflicts),
"high": len(high),
"medium": len(medium),
"low": len(low),
"conflicts": {
f"conflict_{i}": {
"conflict_type": conflict.conflict_type,
"severity": conflict.severity.lower(),
"contradiction_score": conflict.contradiction_score,
"similarity_score": conflict.confidence,
"user_claim": conflict.sentence_a,
external_source: conflict.sentence_b,
}
for i, conflict in enumerate(conflicts, 1)
},
}