Spaces:
Running
Running
Reliability: enforce JSON output via response_format, robust JSON fallback, dedupe retrieved articles, lightweight health
Browse files- cve_factchecker/analyzer.py +50 -7
- cve_factchecker/llm.py +11 -2
- cve_factchecker/retriever.py +6 -1
cve_factchecker/analyzer.py
CHANGED
|
@@ -29,13 +29,23 @@ class ClaimAnalyzer:
|
|
| 29 |
self.cfg = cfg
|
| 30 |
self.client = build_openrouter_client(cfg)
|
| 31 |
def analyze(self, claim: str, articles: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
if not self.client:
|
| 33 |
# Heuristic fallback: simple keyword overlap scoring.
|
| 34 |
claim_lc = claim.lower()
|
| 35 |
keywords = {w for w in claim_lc.split() if len(w) > 4}
|
| 36 |
supporting: List[str] = []
|
| 37 |
score = 0
|
| 38 |
-
for a in
|
| 39 |
text = (a.get('content','') or '').lower()
|
| 40 |
overlap = sum(1 for k in keywords if k in text)
|
| 41 |
if overlap:
|
|
@@ -51,12 +61,24 @@ class ClaimAnalyzer:
|
|
| 51 |
"contradicting_evidence": [],
|
| 52 |
"context_quality": "medium" if supporting else "low",
|
| 53 |
}
|
| 54 |
-
context = "\n\n".join([f"Article {i+1}:\nTitle: {a.get('title','Unknown')}\nSource: {a.get('source','Unknown')}\nURL: {a.get('url','')}\nContent: {a.get('content','')[:500]}..." for i,a in enumerate(
|
| 55 |
-
prompt = (
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
try:
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
if content.startswith("```"):
|
| 61 |
content = content.strip("`")
|
| 62 |
if "\n" in content:
|
|
@@ -66,5 +88,26 @@ class ClaimAnalyzer:
|
|
| 66 |
content = m.group(0)
|
| 67 |
data = json.loads(content)
|
| 68 |
except Exception as e:
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
return normalize_result(data)
|
|
|
|
| 29 |
self.cfg = cfg
|
| 30 |
self.client = build_openrouter_client(cfg)
|
| 31 |
def analyze(self, claim: str, articles: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 32 |
+
# Deduplicate articles by URL to reduce duplicates and noise
|
| 33 |
+
deduped: List[Dict[str, Any]] = []
|
| 34 |
+
seen = set()
|
| 35 |
+
for a in articles:
|
| 36 |
+
u = (a.get('url') or '').strip()
|
| 37 |
+
if u and u in seen:
|
| 38 |
+
continue
|
| 39 |
+
seen.add(u)
|
| 40 |
+
deduped.append(a)
|
| 41 |
+
|
| 42 |
if not self.client:
|
| 43 |
# Heuristic fallback: simple keyword overlap scoring.
|
| 44 |
claim_lc = claim.lower()
|
| 45 |
keywords = {w for w in claim_lc.split() if len(w) > 4}
|
| 46 |
supporting: List[str] = []
|
| 47 |
score = 0
|
| 48 |
+
for a in deduped:
|
| 49 |
text = (a.get('content','') or '').lower()
|
| 50 |
overlap = sum(1 for k in keywords if k in text)
|
| 51 |
if overlap:
|
|
|
|
| 61 |
"contradicting_evidence": [],
|
| 62 |
"context_quality": "medium" if supporting else "low",
|
| 63 |
}
|
| 64 |
+
context = "\n\n".join([f"Article {i+1}:\nTitle: {a.get('title','Unknown')}\nSource: {a.get('source','Unknown')}\nURL: {a.get('url','')}\nContent: {a.get('content','')[:500]}..." for i,a in enumerate(deduped)])
|
| 65 |
+
prompt = (
|
| 66 |
+
"You are an expert Pakistani fact-checker. Analyze the claim against the retrieved context.\n"
|
| 67 |
+
"Return JSON ONLY. No prose. Use this exact schema keys: \n"
|
| 68 |
+
"{verdict: string, confidence: number between 0 and 1, reasoning: string, supporting_evidence: string[], contradicting_evidence: string[], context_quality: string}.\n"
|
| 69 |
+
"Do not include code fences. Do not include comments."
|
| 70 |
+
f"\n\nNEWS CLAIM: {claim}\n\nRETRIEVED CONTEXT:\n{context}\n"
|
| 71 |
+
)
|
| 72 |
try:
|
| 73 |
+
# Request structured JSON when supported
|
| 74 |
+
content = chat_complete(
|
| 75 |
+
self.client,
|
| 76 |
+
self.cfg.model,
|
| 77 |
+
prompt,
|
| 78 |
+
temperature=self.cfg.temperature,
|
| 79 |
+
max_tokens=self.cfg.max_tokens,
|
| 80 |
+
response_format={"type": "json_object"}
|
| 81 |
+
).strip()
|
| 82 |
if content.startswith("```"):
|
| 83 |
content = content.strip("`")
|
| 84 |
if "\n" in content:
|
|
|
|
| 88 |
content = m.group(0)
|
| 89 |
data = json.loads(content)
|
| 90 |
except Exception as e:
|
| 91 |
+
# Robust fallback instead of returning ERROR: use heuristic pathway on failure
|
| 92 |
+
print(f"⚠️ JSON parse failed, falling back to heuristic: {e}")
|
| 93 |
+
claim_lc = claim.lower()
|
| 94 |
+
keywords = {w for w in claim_lc.split() if len(w) > 4}
|
| 95 |
+
supporting: List[str] = []
|
| 96 |
+
score = 0
|
| 97 |
+
for a in deduped:
|
| 98 |
+
text = (a.get('content','') or '').lower()
|
| 99 |
+
overlap = sum(1 for k in keywords if k in text)
|
| 100 |
+
if overlap:
|
| 101 |
+
supporting.append(f"Match ({overlap}) in {a.get('url','')}")
|
| 102 |
+
score += overlap
|
| 103 |
+
confidence = min(0.6, 0.1 * score) if supporting else 0.05
|
| 104 |
+
verdict = "POSSIBLY TRUE" if confidence > 0.3 else "UNVERIFIED"
|
| 105 |
+
data = {
|
| 106 |
+
"verdict": verdict,
|
| 107 |
+
"confidence": confidence,
|
| 108 |
+
"reasoning": "LLM output invalid; heuristic fallback used.",
|
| 109 |
+
"supporting_evidence": supporting[:5],
|
| 110 |
+
"contradicting_evidence": [],
|
| 111 |
+
"context_quality": "medium" if supporting else "low",
|
| 112 |
+
}
|
| 113 |
return normalize_result(data)
|
cve_factchecker/llm.py
CHANGED
|
@@ -13,7 +13,16 @@ def build_openrouter_client(cfg: OpenRouterConfig) -> Optional[OpenAI]:
|
|
| 13 |
print(f"⚠️ LLM initialization failed: {e}")
|
| 14 |
return None
|
| 15 |
|
| 16 |
-
def chat_complete(client: OpenAI, model: str, prompt: str, temperature: float = 0.0, max_tokens: int = 800) -> str:
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
choice = resp.choices[0]
|
| 19 |
return getattr(getattr(choice, "message", None), "content", None) or getattr(choice, "text", "") or ""
|
|
|
|
| 13 |
print(f"⚠️ LLM initialization failed: {e}")
|
| 14 |
return None
|
| 15 |
|
| 16 |
+
def chat_complete(client: OpenAI, model: str, prompt: str, temperature: float = 0.0, max_tokens: int = 800, response_format: dict | None = None) -> str:
|
| 17 |
+
# Pass response_format when supported by the model/provider (OpenAI/OpenRouter)
|
| 18 |
+
kwargs = {
|
| 19 |
+
"model": model,
|
| 20 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 21 |
+
"temperature": temperature,
|
| 22 |
+
"max_tokens": max_tokens,
|
| 23 |
+
}
|
| 24 |
+
if response_format:
|
| 25 |
+
kwargs["response_format"] = response_format
|
| 26 |
+
resp = client.chat.completions.create(**kwargs)
|
| 27 |
choice = resp.choices[0]
|
| 28 |
return getattr(getattr(choice, "message", None), "content", None) or getattr(choice, "text", "") or ""
|
cve_factchecker/retriever.py
CHANGED
|
@@ -213,6 +213,7 @@ class VectorNewsRetriever:
|
|
| 213 |
print(f"❌ Vector search failed: {e}")
|
| 214 |
return []
|
| 215 |
results: List[Dict[str, Any]] = []
|
|
|
|
| 216 |
for d in docs:
|
| 217 |
meta = getattr(d, "metadata", {}) or {}
|
| 218 |
content = getattr(d, "page_content", "") or ""
|
|
@@ -220,5 +221,9 @@ class VectorNewsRetriever:
|
|
| 220 |
if content.startswith("Title: "):
|
| 221 |
line = content.splitlines()[0]
|
| 222 |
title = line.replace("Title: ", "").strip() or title
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
return results
|
|
|
|
| 213 |
print(f"❌ Vector search failed: {e}")
|
| 214 |
return []
|
| 215 |
results: List[Dict[str, Any]] = []
|
| 216 |
+
seen_urls = set()
|
| 217 |
for d in docs:
|
| 218 |
meta = getattr(d, "metadata", {}) or {}
|
| 219 |
content = getattr(d, "page_content", "") or ""
|
|
|
|
| 221 |
if content.startswith("Title: "):
|
| 222 |
line = content.splitlines()[0]
|
| 223 |
title = line.replace("Title: ", "").strip() or title
|
| 224 |
+
url = meta.get("url", "")
|
| 225 |
+
if url and url in seen_urls:
|
| 226 |
+
continue
|
| 227 |
+
seen_urls.add(url)
|
| 228 |
+
results.append({"title": title, "content": content, "url": url, "source": meta.get("source", "Unknown"), "metadata": meta})
|
| 229 |
return results
|