Spaces:
Sleeping
Sleeping
Upload 2 files
Browse files- app-1.py +850 -0
- requirements-2.txt +5 -0
app-1.py
ADDED
|
@@ -0,0 +1,850 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Truth Verification Pipeline API
|
| 3 |
+
================================
|
| 4 |
+
|
| 5 |
+
CHANGES IN THIS VERSION (accuracy / reliability hardening pass)
|
| 6 |
+
-----------------------------------------------------------------
|
| 7 |
+
The Gradio API surface is UNCHANGED: same Blocks layout, same input/output
|
| 8 |
+
components, same `api_name="verify"`. Everything below is internal.
|
| 9 |
+
|
| 10 |
+
Two things were fixed that were silently making every "fallback" request fail:
|
| 11 |
+
1. `gemini-1.5-flash` is already fully shut down (Gemini 1.0/1.5 all return
|
| 12 |
+
404) and `google.generativeai` is a deprecated SDK. Both are replaced
|
| 13 |
+
with the current `google-genai` SDK + `gemini-3.1-flash-lite`.
|
| 14 |
+
https://ai.google.dev/gemini-api/docs/deprecations
|
| 15 |
+
2. `llama-3.3-70b-versatile` on Groq is deprecated with a shutdown date of
|
| 16 |
+
2026-08-16. Replaced with Groq's own recommended replacement,
|
| 17 |
+
`openai/gpt-oss-120b`. https://console.groq.com/docs/deprecations
|
| 18 |
+
3. `duckduckgo_search` is frozen/renamed upstream to `ddgs`, which is now a
|
| 19 |
+
multi-engine metasearch library (bing/brave/duckduckgo/google/mojeek/
|
| 20 |
+
yahoo/yandex/wikipedia for text search) with built-in result de-dup and
|
| 21 |
+
ranking -- meaningfully more resilient than the old single-engine
|
| 22 |
+
scraper. Same result shape (title/href/body), verified against the
|
| 23 |
+
installed package source.
|
| 24 |
+
|
| 25 |
+
On top of that, the pipeline itself was rebuilt around one core idea: every
|
| 26 |
+
single stage used to do `json.loads(call_llm(...))` with zero error handling,
|
| 27 |
+
so any malformed JSON from the model (extremely common -- markdown fences,
|
| 28 |
+
a "Sure, here's the JSON:" preamble, etc.) crashed the entire request. Now:
|
| 29 |
+
- Every LLM call goes through a JSON-repair pipeline (strip fences -> parse
|
| 30 |
+
-> extract the first balanced JSON block -> one corrective LLM retry ->
|
| 31 |
+
typed default) so a bad response degrades a single stage instead of
|
| 32 |
+
killing the whole pipeline.
|
| 33 |
+
- Every stage has a typed fallback default so downstream code never KeyErrors.
|
| 34 |
+
- Claims that are opinions/subjective are detected and short-circuited
|
| 35 |
+
instead of being forced through a Verified/Debunked verdict.
|
| 36 |
+
- Evidence extraction is grounded: the model is only allowed to cite URLs
|
| 37 |
+
that were actually returned by search, and a final programmatic audit
|
| 38 |
+
strips any source that doesn't match a retrieved URL -- this is the main
|
| 39 |
+
defense against fabricated citations.
|
| 40 |
+
- Source reliability blends the model's own judgment with a rule-based
|
| 41 |
+
domain-credibility prior, so one confident-sounding LLM guess isn't the
|
| 42 |
+
only signal.
|
| 43 |
+
- "Verified"/"Debunked" verdicts require a minimum number of independent
|
| 44 |
+
sources; thin evidence is downgraded to "Uncertain" deterministically.
|
| 45 |
+
- Confidence is calibrated in plain Python (source count, grounding
|
| 46 |
+
failures, empty search results) rather than trusting the model's raw
|
| 47 |
+
self-reported number.
|
| 48 |
+
- The whole pipeline is wrapped in a top-level try/except so the API always
|
| 49 |
+
returns a well-formed JSON object -- even total failure returns a
|
| 50 |
+
structured "Error" verdict instead of an unhandled exception.
|
| 51 |
+
|
| 52 |
+
Original output fields (answer, confidence, reasoningSummary,
|
| 53 |
+
supportingEvidence, sources) are preserved with the same names and types.
|
| 54 |
+
New fields (verdict, claimsAnalyzed, domains, caveats, sourceCount,
|
| 55 |
+
knowledgeGraph, pipelineWarnings, verifiedAt) are additive.
|
| 56 |
+
|
| 57 |
+
MAINTENANCE NOTE: model IDs get deprecated on a rolling basis. Before
|
| 58 |
+
assuming a model still works, check:
|
| 59 |
+
- Groq: https://console.groq.com/docs/deprecations
|
| 60 |
+
- Gemini: https://ai.google.dev/gemini-api/docs/deprecations
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
import gradio as gr
|
| 64 |
+
import os
|
| 65 |
+
import re
|
| 66 |
+
import json
|
| 67 |
+
import time
|
| 68 |
+
import random
|
| 69 |
+
from datetime import datetime, timezone
|
| 70 |
+
from typing import List, Dict, Any, Optional, Tuple
|
| 71 |
+
|
| 72 |
+
from groq import Groq
|
| 73 |
+
from google import genai
|
| 74 |
+
from google.genai import types as genai_types
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
from ddgs import DDGS # current, maintained package
|
| 78 |
+
except ImportError:
|
| 79 |
+
from duckduckgo_search import DDGS # frozen upstream; kept only as a transitional fallback
|
| 80 |
+
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
# Configuration
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
|
| 85 |
+
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY")
|
| 86 |
+
|
| 87 |
+
# Groq recommends openai/gpt-oss-120b as the replacement for the deprecated
|
| 88 |
+
# llama-3.3-70b-versatile (shutdown 2026-08-16). qwen/qwen3.6-27b is Groq's
|
| 89 |
+
# other listed alternative if you want to compare quality/latency.
|
| 90 |
+
GROQ_MODEL = "openai/gpt-oss-120b"
|
| 91 |
+
|
| 92 |
+
# gemini-1.5-flash is already shut down (404). gemini-3.1-flash-lite is GA
|
| 93 |
+
# and cost-efficient; swap to "gemini-3.5-flash" for a higher-quality (and
|
| 94 |
+
# pricier) fallback if the Gemini path gets hit often.
|
| 95 |
+
GEMINI_MODEL = "gemini-3.1-flash-lite"
|
| 96 |
+
|
| 97 |
+
MAX_LLM_RETRIES = 2 # per provider, before moving on (Groq -> Gemini)
|
| 98 |
+
MAX_SEARCH_RETRIES = 3
|
| 99 |
+
MIN_SOURCES_FOR_VERIFIED = 2 # independent sources required for a strong verdict
|
| 100 |
+
|
| 101 |
+
RATE_LIMIT_WINDOW = 60
|
| 102 |
+
MAX_REQUESTS = 5
|
| 103 |
+
|
| 104 |
+
HIGH_STAKES_DOMAINS = {
|
| 105 |
+
"health", "medical", "medicine", "legal", "law", "finance",
|
| 106 |
+
"financial", "election", "elections", "politics", "safety",
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
HIGH_CREDIBILITY_HINTS = (
|
| 110 |
+
".gov", ".edu", "who.int", "un.org", "reuters.com", "apnews.com",
|
| 111 |
+
"bbc.co", "bbc.com", "nature.com", "science.org", "nih.gov",
|
| 112 |
+
"ncbi.nlm.nih.gov", "worldbank.org", "imf.org", "unesco.org",
|
| 113 |
+
"wikipedia.org",
|
| 114 |
+
)
|
| 115 |
+
LOW_CREDIBILITY_HINTS = ("blogspot.", "wordpress.com/", "medium.com/@")
|
| 116 |
+
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
# Rate limiter
|
| 119 |
+
# ---------------------------------------------------------------------------
|
| 120 |
+
rate_limit_store: Dict[str, List[float]] = {}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def is_rate_limited(client_ip: str) -> bool:
|
| 124 |
+
now = time.time()
|
| 125 |
+
bucket = rate_limit_store.setdefault(client_ip, [])
|
| 126 |
+
bucket[:] = [t for t in bucket if now - t < RATE_LIMIT_WINDOW]
|
| 127 |
+
|
| 128 |
+
if len(bucket) >= MAX_REQUESTS:
|
| 129 |
+
return True
|
| 130 |
+
|
| 131 |
+
bucket.append(now)
|
| 132 |
+
|
| 133 |
+
# light housekeeping so this dict doesn't grow forever under long uptime
|
| 134 |
+
if len(rate_limit_store) > 5000:
|
| 135 |
+
for k in [k for k, v in rate_limit_store.items() if not v]:
|
| 136 |
+
del rate_limit_store[k]
|
| 137 |
+
|
| 138 |
+
return False
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
# Clients
|
| 143 |
+
# ---------------------------------------------------------------------------
|
| 144 |
+
groq_client = Groq(api_key=GROQ_API_KEY) if GROQ_API_KEY else None
|
| 145 |
+
gemini_client = genai.Client(api_key=GEMINI_API_KEY) if GEMINI_API_KEY else None
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# ---------------------------------------------------------------------------
|
| 149 |
+
# Robust JSON handling
|
| 150 |
+
#
|
| 151 |
+
# This is the backbone of pipeline accuracy: every stage depends on getting
|
| 152 |
+
# well-formed JSON back from an LLM, and LLMs reliably wrap JSON in prose,
|
| 153 |
+
# markdown code fences, or trailing commentary. All three helpers below are
|
| 154 |
+
# unit-tested against realistic malformed outputs (fences, prose preambles,
|
| 155 |
+
# nested braces inside string values, mismatched bracket types).
|
| 156 |
+
# ---------------------------------------------------------------------------
|
| 157 |
+
|
| 158 |
+
def _strip_code_fences(text: str) -> str:
|
| 159 |
+
text = text.strip()
|
| 160 |
+
text = re.sub(r"^```(?:json)?\s*", "", text)
|
| 161 |
+
text = re.sub(r"\s*```$", "", text)
|
| 162 |
+
return text.strip()
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _extract_json_block(text: str) -> Optional[str]:
|
| 166 |
+
"""Grab the first balanced top-level {...} or [...] block, whichever starts first."""
|
| 167 |
+
starts = [(text.find(ch), ch) for ch in ("{", "[") if text.find(ch) != -1]
|
| 168 |
+
if not starts:
|
| 169 |
+
return None
|
| 170 |
+
starts.sort(key=lambda t: t[0])
|
| 171 |
+
start, open_ch = starts[0]
|
| 172 |
+
close_ch = "}" if open_ch == "{" else "]"
|
| 173 |
+
|
| 174 |
+
depth = 0
|
| 175 |
+
in_string = False
|
| 176 |
+
escape = False
|
| 177 |
+
for i in range(start, len(text)):
|
| 178 |
+
ch = text[i]
|
| 179 |
+
if in_string:
|
| 180 |
+
if escape:
|
| 181 |
+
escape = False
|
| 182 |
+
elif ch == "\\":
|
| 183 |
+
escape = True
|
| 184 |
+
elif ch == '"':
|
| 185 |
+
in_string = False
|
| 186 |
+
continue
|
| 187 |
+
if ch == '"':
|
| 188 |
+
in_string = True
|
| 189 |
+
elif ch == open_ch:
|
| 190 |
+
depth += 1
|
| 191 |
+
elif ch == close_ch:
|
| 192 |
+
depth -= 1
|
| 193 |
+
if depth == 0:
|
| 194 |
+
return text[start:i + 1]
|
| 195 |
+
return None
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def safe_json_parse(raw: str, default: Any = None) -> Any:
|
| 199 |
+
"""Best-effort JSON parsing that tolerates the ways LLMs mangle JSON."""
|
| 200 |
+
if not raw:
|
| 201 |
+
return default
|
| 202 |
+
stripped = _strip_code_fences(raw)
|
| 203 |
+
candidates = [raw, stripped]
|
| 204 |
+
block = _extract_json_block(stripped)
|
| 205 |
+
if block:
|
| 206 |
+
candidates.append(block)
|
| 207 |
+
for candidate in candidates:
|
| 208 |
+
try:
|
| 209 |
+
return json.loads(candidate)
|
| 210 |
+
except (json.JSONDecodeError, TypeError):
|
| 211 |
+
continue
|
| 212 |
+
return default
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# ---------------------------------------------------------------------------
|
| 216 |
+
# LLM calling: Groq primary, Gemini fallback, retries + backoff on both,
|
| 217 |
+
# always requesting native JSON mode, plus one corrective repair pass if
|
| 218 |
+
# parsing still fails.
|
| 219 |
+
# ---------------------------------------------------------------------------
|
| 220 |
+
|
| 221 |
+
def call_llm(
|
| 222 |
+
prompt: str,
|
| 223 |
+
stage_name: str = "llm_call",
|
| 224 |
+
json_mode: bool = True,
|
| 225 |
+
temperature: float = 0.1,
|
| 226 |
+
reasoning_effort: str = "medium",
|
| 227 |
+
) -> str:
|
| 228 |
+
"""Call Groq, falling back to Gemini, with retries + backoff on both."""
|
| 229 |
+
if not groq_client and not gemini_client:
|
| 230 |
+
return json.dumps({"error": "No LLM provider configured: set GROQ_API_KEY and/or GEMINI_API_KEY."})
|
| 231 |
+
|
| 232 |
+
last_error: Any = None
|
| 233 |
+
|
| 234 |
+
if groq_client:
|
| 235 |
+
for attempt in range(MAX_LLM_RETRIES):
|
| 236 |
+
try:
|
| 237 |
+
kwargs: Dict[str, Any] = dict(
|
| 238 |
+
messages=[{"role": "user", "content": prompt}],
|
| 239 |
+
model=GROQ_MODEL,
|
| 240 |
+
temperature=temperature,
|
| 241 |
+
)
|
| 242 |
+
if json_mode:
|
| 243 |
+
kwargs["response_format"] = {"type": "json_object"}
|
| 244 |
+
if "gpt-oss" in GROQ_MODEL:
|
| 245 |
+
kwargs["reasoning_effort"] = reasoning_effort
|
| 246 |
+
completion = groq_client.chat.completions.create(**kwargs)
|
| 247 |
+
content = completion.choices[0].message.content
|
| 248 |
+
if content and content.strip():
|
| 249 |
+
return content
|
| 250 |
+
except Exception as e:
|
| 251 |
+
last_error = e
|
| 252 |
+
print(f"[{stage_name}] Groq attempt {attempt + 1} failed: {e}")
|
| 253 |
+
time.sleep(min(2 ** attempt, 8) + random.random())
|
| 254 |
+
print(f"[{stage_name}] Groq exhausted retries, falling back to Gemini. Last error: {last_error}")
|
| 255 |
+
|
| 256 |
+
if gemini_client:
|
| 257 |
+
for attempt in range(MAX_LLM_RETRIES):
|
| 258 |
+
try:
|
| 259 |
+
config_kwargs: Dict[str, Any] = {"temperature": temperature}
|
| 260 |
+
if json_mode:
|
| 261 |
+
config_kwargs["response_mime_type"] = "application/json"
|
| 262 |
+
response = gemini_client.models.generate_content(
|
| 263 |
+
model=GEMINI_MODEL,
|
| 264 |
+
contents=prompt,
|
| 265 |
+
config=genai_types.GenerateContentConfig(**config_kwargs),
|
| 266 |
+
)
|
| 267 |
+
if response.text and response.text.strip():
|
| 268 |
+
return response.text
|
| 269 |
+
except Exception as e:
|
| 270 |
+
last_error = e
|
| 271 |
+
print(f"[{stage_name}] Gemini attempt {attempt + 1} failed: {e}")
|
| 272 |
+
time.sleep(min(2 ** attempt, 8) + random.random())
|
| 273 |
+
print(f"[{stage_name}] Gemini also exhausted retries. Last error: {last_error}")
|
| 274 |
+
|
| 275 |
+
return json.dumps({"error": f"All LLM providers failed for stage '{stage_name}': {last_error}"})
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def call_llm_json(
|
| 279 |
+
prompt: str,
|
| 280 |
+
stage_name: str = "llm_call",
|
| 281 |
+
default: Any = None,
|
| 282 |
+
temperature: float = 0.1,
|
| 283 |
+
reasoning_effort: str = "medium",
|
| 284 |
+
) -> Any:
|
| 285 |
+
"""call_llm + robust parsing + one corrective retry before giving up."""
|
| 286 |
+
raw = call_llm(prompt, stage_name, json_mode=True, temperature=temperature, reasoning_effort=reasoning_effort)
|
| 287 |
+
parsed = safe_json_parse(raw)
|
| 288 |
+
if parsed is not None:
|
| 289 |
+
return parsed
|
| 290 |
+
|
| 291 |
+
repair_prompt = (
|
| 292 |
+
"The following text was supposed to be valid JSON but failed to parse. "
|
| 293 |
+
"Return ONLY the corrected, valid JSON with no commentary and no markdown fences:\n\n"
|
| 294 |
+
f"{raw[:2000]}"
|
| 295 |
+
)
|
| 296 |
+
raw_retry = call_llm(repair_prompt, f"{stage_name}_repair", json_mode=True, temperature=0.0, reasoning_effort=reasoning_effort)
|
| 297 |
+
parsed_retry = safe_json_parse(raw_retry)
|
| 298 |
+
if parsed_retry is not None:
|
| 299 |
+
return parsed_retry
|
| 300 |
+
|
| 301 |
+
print(f"[{stage_name}] JSON parsing failed twice; using default.")
|
| 302 |
+
return default
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# ---------------------------------------------------------------------------
|
| 306 |
+
# Web search: retries + backoff, multi-engine via ddgs (backend="auto" tries
|
| 307 |
+
# bing/brave/duckduckgo/google/mojeek/yahoo/yandex/wikipedia and aggregates),
|
| 308 |
+
# plus de-duplication across the multiple queries the pipeline issues.
|
| 309 |
+
# ---------------------------------------------------------------------------
|
| 310 |
+
|
| 311 |
+
def web_search(query: str, max_results: int = 6) -> List[Dict[str, str]]:
|
| 312 |
+
for attempt in range(MAX_SEARCH_RETRIES):
|
| 313 |
+
try:
|
| 314 |
+
with DDGS() as ddgs:
|
| 315 |
+
results = [
|
| 316 |
+
{"title": r.get("title", ""), "body": r.get("body", ""), "href": r.get("href", "")}
|
| 317 |
+
for r in ddgs.text(query, max_results=max_results, backend="auto")
|
| 318 |
+
]
|
| 319 |
+
if results:
|
| 320 |
+
return results
|
| 321 |
+
return [] # legitimately no results; don't retry forever on an empty-but-successful search
|
| 322 |
+
except Exception as e:
|
| 323 |
+
print(f"Search attempt {attempt + 1} failed for '{query}': {e}")
|
| 324 |
+
time.sleep(min(2 ** attempt, 6) + random.random())
|
| 325 |
+
return []
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def dedupe_results(results: List[Dict[str, str]]) -> List[Dict[str, str]]:
|
| 329 |
+
seen = set()
|
| 330 |
+
deduped = []
|
| 331 |
+
for r in results:
|
| 332 |
+
href = (r.get("href") or "").strip().rstrip("/")
|
| 333 |
+
if href:
|
| 334 |
+
if href in seen:
|
| 335 |
+
continue
|
| 336 |
+
seen.add(href)
|
| 337 |
+
deduped.append(r)
|
| 338 |
+
return deduped
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def domain_credibility_hint(url: str) -> float:
|
| 342 |
+
"""A rule-based prior, NOT a verdict on truth -- just a floor/ceiling nudge
|
| 343 |
+
blended with the model's own reliability judgment so a single LLM call
|
| 344 |
+
isn't the only signal on source quality."""
|
| 345 |
+
url_lower = (url or "").lower()
|
| 346 |
+
if any(d in url_lower for d in HIGH_CREDIBILITY_HINTS):
|
| 347 |
+
return 0.85
|
| 348 |
+
if any(d in url_lower for d in LOW_CREDIBILITY_HINTS):
|
| 349 |
+
return 0.35
|
| 350 |
+
return 0.55
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def _now_iso() -> str:
|
| 354 |
+
return datetime.now(timezone.utc).isoformat()
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
# ---------------------------------------------------------------------------
|
| 358 |
+
# Pipeline stages. Each has a typed default so a stage failure degrades
|
| 359 |
+
# gracefully instead of raising.
|
| 360 |
+
# ---------------------------------------------------------------------------
|
| 361 |
+
|
| 362 |
+
def stage_analyze_query(query: str) -> Dict[str, Any]:
|
| 363 |
+
default = {"complexity": "moderate", "intent": "Verify a factual claim", "isVerifiable": True, "isOpinion": False}
|
| 364 |
+
prompt = f"""You are the Query Analyzer in a fact-verification pipeline.
|
| 365 |
+
|
| 366 |
+
Determine whether the following input is an objectively verifiable factual claim, or a subjective opinion/preference/unanswerable question.
|
| 367 |
+
|
| 368 |
+
Input: {query!r}
|
| 369 |
+
|
| 370 |
+
Return ONLY valid JSON, no markdown, no commentary, in exactly this shape:
|
| 371 |
+
{{"complexity": "simple" | "moderate" | "complex", "intent": "<one short sentence>", "isVerifiable": true | false, "isOpinion": true | false}}
|
| 372 |
+
|
| 373 |
+
isVerifiable should be false for pure opinions, preferences, or requests that aren't actually claims (e.g. "what's the best pizza topping").
|
| 374 |
+
isVerifiable should be true for concrete factual, historical, scientific, statistical, or current-events claims that evidence could confirm or refute."""
|
| 375 |
+
result = call_llm_json(prompt, "query_analyzer", default=default, temperature=0.0, reasoning_effort="low")
|
| 376 |
+
if not isinstance(result, dict):
|
| 377 |
+
return default
|
| 378 |
+
return {**default, **result}
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def stage_extract_claims(query: str) -> List[str]:
|
| 382 |
+
prompt = f"""Extract the distinct, atomic, independently-checkable factual claims from this input.
|
| 383 |
+
Input: {query!r}
|
| 384 |
+
|
| 385 |
+
Return ONLY valid JSON: {{"claims": ["claim 1", "claim 2", ...]}}
|
| 386 |
+
If the input is already a single atomic claim, return it as the only item. Do not add claims that weren't stated or implied."""
|
| 387 |
+
result = call_llm_json(prompt, "claim_extractor", default={"claims": [query]}, temperature=0.0, reasoning_effort="low")
|
| 388 |
+
claims = result.get("claims") if isinstance(result, dict) else None
|
| 389 |
+
if not isinstance(claims, list):
|
| 390 |
+
return [query]
|
| 391 |
+
cleaned = [str(c).strip() for c in claims if str(c).strip()]
|
| 392 |
+
return cleaned or [query]
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def stage_classify_domains(claims: List[str]) -> List[str]:
|
| 396 |
+
prompt = f"""Classify the subject-matter domain(s) of these claims (e.g. Science, Technology, Politics, Health, History, Finance, Geography, Sports, Entertainment).
|
| 397 |
+
Claims: {claims}
|
| 398 |
+
|
| 399 |
+
Return ONLY valid JSON: {{"domains": ["Domain1", "Domain2", ...]}}"""
|
| 400 |
+
result = call_llm_json(prompt, "domain_classifier", default={"domains": ["General"]}, temperature=0.0, reasoning_effort="low")
|
| 401 |
+
domains = result.get("domains") if isinstance(result, dict) else None
|
| 402 |
+
if not isinstance(domains, list):
|
| 403 |
+
return ["General"]
|
| 404 |
+
return [str(d).strip() for d in domains if str(d).strip()] or ["General"]
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def stage_generate_search_queries(claims: List[str]) -> List[str]:
|
| 408 |
+
prompt = f"""Generate search-engine queries to investigate these claims: {claims}
|
| 409 |
+
|
| 410 |
+
Generate up to 2 queries PER claim: one phrased to find evidence that would CONFIRM it, and one phrased to find evidence that would CONTRADICT/refute it. This avoids one-sided, confirmation-biased research.
|
| 411 |
+
Keep each query short (3-8 words), like a real search-engine query, not a full sentence.
|
| 412 |
+
|
| 413 |
+
Return ONLY valid JSON: {{"queries": ["query 1", "query 2", ...]}}
|
| 414 |
+
Maximum 6 queries total -- prioritize the most important ones."""
|
| 415 |
+
result = call_llm_json(prompt, "search_query_generator", default={"queries": claims[:3]}, temperature=0.2, reasoning_effort="low")
|
| 416 |
+
queries = result.get("queries") if isinstance(result, dict) else None
|
| 417 |
+
if not isinstance(queries, list):
|
| 418 |
+
return claims[:3]
|
| 419 |
+
cleaned = [str(q).strip() for q in queries if str(q).strip()]
|
| 420 |
+
return cleaned[:6] or claims[:3]
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def stage_retrieve_evidence(search_queries: List[str]) -> List[Dict[str, str]]:
|
| 424 |
+
all_results: List[Dict[str, str]] = []
|
| 425 |
+
for sq in search_queries:
|
| 426 |
+
all_results.extend(web_search(sq))
|
| 427 |
+
return dedupe_results(all_results)
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def stage_extract_evidence(claims: List[str], search_results: List[Dict[str, str]]) -> List[Dict[str, str]]:
|
| 431 |
+
if not search_results:
|
| 432 |
+
return []
|
| 433 |
+
trimmed = search_results[:15]
|
| 434 |
+
indexed = [{"idx": i, "title": r["title"], "body": r["body"], "url": r["href"]} for i, r in enumerate(trimmed)]
|
| 435 |
+
prompt = f"""You are the Evidence Extractor. Below are search results (with an index and url) and a list of claims to check.
|
| 436 |
+
|
| 437 |
+
Claims: {claims}
|
| 438 |
+
Search results: {json.dumps(indexed, ensure_ascii=False)}
|
| 439 |
+
|
| 440 |
+
Extract factual snippets from these search results directly relevant to confirming or refuting the claims.
|
| 441 |
+
CRITICAL: only use the "url" values exactly as given above. Never invent, guess, or modify a URL.
|
| 442 |
+
|
| 443 |
+
Return ONLY valid JSON: {{"snippets": [{{"fact": "<factual statement from the result>", "source": "<the exact url it came from>"}}, ...]}}
|
| 444 |
+
If nothing is relevant, return {{"snippets": []}}."""
|
| 445 |
+
result = call_llm_json(prompt, "evidence_extractor", default={"snippets": []}, temperature=0.0, reasoning_effort="medium")
|
| 446 |
+
snippets = result.get("snippets") if isinstance(result, dict) else None
|
| 447 |
+
if not isinstance(snippets, list):
|
| 448 |
+
return []
|
| 449 |
+
|
| 450 |
+
valid_urls = {r["href"] for r in search_results if r.get("href")}
|
| 451 |
+
cleaned = []
|
| 452 |
+
for s in snippets:
|
| 453 |
+
if not isinstance(s, dict):
|
| 454 |
+
continue
|
| 455 |
+
fact = str(s.get("fact", "")).strip()
|
| 456 |
+
source = str(s.get("source", "")).strip()
|
| 457 |
+
if fact and source in valid_urls:
|
| 458 |
+
cleaned.append({"fact": fact, "source": source})
|
| 459 |
+
return cleaned
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def stage_score_evidence(claims: List[str], evidence: List[Dict[str, str]]) -> List[Dict[str, Any]]:
|
| 463 |
+
if not evidence:
|
| 464 |
+
return []
|
| 465 |
+
prompt = f"""You are the Evidence Scorer. Score each piece of evidence for RELEVANCE to the claims (0.0-1.0) and apparent RELIABILITY of the source (0.0-1.0).
|
| 466 |
+
|
| 467 |
+
Claims: {claims}
|
| 468 |
+
Evidence: {json.dumps(evidence, ensure_ascii=False)}
|
| 469 |
+
|
| 470 |
+
Return ONLY valid JSON: {{"scored_evidence": [{{"fact": "...", "source": "...", "relevance": 0.0-1.0, "reliability": 0.0-1.0}}, ...]}}
|
| 471 |
+
Keep fact/source EXACTLY as given, only add the two score fields."""
|
| 472 |
+
default_scored = [{**e, "relevance": 0.5, "reliability": 0.5} for e in evidence]
|
| 473 |
+
result = call_llm_json(prompt, "evidence_scorer", default={"scored_evidence": default_scored}, temperature=0.0, reasoning_effort="medium")
|
| 474 |
+
scored = result.get("scored_evidence") if isinstance(result, dict) else None
|
| 475 |
+
if not isinstance(scored, list) or not scored:
|
| 476 |
+
scored = default_scored
|
| 477 |
+
|
| 478 |
+
valid_sources = {e["source"] for e in evidence}
|
| 479 |
+
blended = []
|
| 480 |
+
for item in scored:
|
| 481 |
+
if not isinstance(item, dict):
|
| 482 |
+
continue
|
| 483 |
+
source = str(item.get("source", "")).strip()
|
| 484 |
+
if source not in valid_sources:
|
| 485 |
+
continue # drop anything not grounded in real retrieved evidence
|
| 486 |
+
fact = str(item.get("fact", "")).strip()
|
| 487 |
+
try:
|
| 488 |
+
relevance = float(item.get("relevance", 0.5))
|
| 489 |
+
except (TypeError, ValueError):
|
| 490 |
+
relevance = 0.5
|
| 491 |
+
try:
|
| 492 |
+
llm_reliability = float(item.get("reliability", 0.5))
|
| 493 |
+
except (TypeError, ValueError):
|
| 494 |
+
llm_reliability = 0.5
|
| 495 |
+
rule_reliability = domain_credibility_hint(source)
|
| 496 |
+
reliability = round((llm_reliability + rule_reliability) / 2, 2)
|
| 497 |
+
blended.append({
|
| 498 |
+
"fact": fact,
|
| 499 |
+
"source": source,
|
| 500 |
+
"relevance": max(0.0, min(1.0, relevance)),
|
| 501 |
+
"reliability": max(0.0, min(1.0, reliability)),
|
| 502 |
+
})
|
| 503 |
+
return blended
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
def stage_verify_claims(claims: List[str], scored_evidence: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 507 |
+
default = {"verdict": "Uncertain", "detailed_analysis": "Insufficient evidence was available to reach a confident verdict."}
|
| 508 |
+
if not scored_evidence:
|
| 509 |
+
return default
|
| 510 |
+
prompt = f"""You are the Verification Engine. Compare the claims against the scored evidence and determine a verdict.
|
| 511 |
+
|
| 512 |
+
Claims: {claims}
|
| 513 |
+
Scored evidence: {json.dumps(scored_evidence, ensure_ascii=False)}
|
| 514 |
+
|
| 515 |
+
Rules:
|
| 516 |
+
- Only say "Verified" if multiple independent, credible sources clearly confirm the claim.
|
| 517 |
+
- Only say "Debunked" if multiple independent, credible sources clearly contradict the claim.
|
| 518 |
+
- Say "Uncertain" if evidence is thin, mixed, low-relevance, or low-reliability. Do not guess to sound confident.
|
| 519 |
+
- Be specific: cite which facts support your verdict and which, if any, conflict with it.
|
| 520 |
+
|
| 521 |
+
Return ONLY valid JSON: {{"verdict": "Verified"|"Debunked"|"Uncertain", "detailed_analysis": "<specific reasoning citing the evidence>"}}"""
|
| 522 |
+
result = call_llm_json(prompt, "verification_engine", default=default, temperature=0.0, reasoning_effort="high")
|
| 523 |
+
if not isinstance(result, dict) or "verdict" not in result:
|
| 524 |
+
return default
|
| 525 |
+
return {**default, **result}
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
def stage_resolve_conflicts(claims: List[str], verification: Dict[str, Any], scored_evidence: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 529 |
+
default = {"resolved_verdict": verification.get("verdict", "Uncertain"), "conflict_notes": "No conflicts identified."}
|
| 530 |
+
prompt = f"""You are an adversarial Conflict Resolver. Your job is to actively look for flaws in the proposed verdict below, not to rubber-stamp it.
|
| 531 |
+
|
| 532 |
+
Claims: {claims}
|
| 533 |
+
Proposed verdict: {verification.get("verdict")}
|
| 534 |
+
Reasoning given: {verification.get("detailed_analysis")}
|
| 535 |
+
Evidence used: {json.dumps(scored_evidence, ensure_ascii=False)}
|
| 536 |
+
|
| 537 |
+
Actively check for: contradicting evidence that was underweighted, sources that disagree with each other, low-reliability sources being treated as decisive, or reasoning that doesn't actually follow from the evidence.
|
| 538 |
+
If you find a genuine problem, change the verdict (e.g. downgrade "Verified" to "Uncertain" if support is weaker than claimed). If it holds up, keep it.
|
| 539 |
+
|
| 540 |
+
Return ONLY valid JSON: {{"resolved_verdict": "Verified"|"Debunked"|"Uncertain"|"Disputed", "conflict_notes": "<what you checked and what you found>"}}
|
| 541 |
+
Use "Disputed" if credible sources genuinely disagree with each other."""
|
| 542 |
+
result = call_llm_json(prompt, "conflict_resolver", default=default, temperature=0.0, reasoning_effort="high")
|
| 543 |
+
if not isinstance(result, dict) or "resolved_verdict" not in result:
|
| 544 |
+
return default
|
| 545 |
+
return {**default, **result}
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
def stage_policy_check(resolved: Dict[str, Any]) -> Dict[str, Any]:
|
| 549 |
+
default = {"is_safe": True, "adjustment_needed": ""}
|
| 550 |
+
prompt = f"""Review this verification result for responsible-communication concerns (e.g. medical/legal advice framing, potential for real-world harm if misread, defamation risk for named individuals).
|
| 551 |
+
|
| 552 |
+
Result: {json.dumps(resolved, ensure_ascii=False)}
|
| 553 |
+
|
| 554 |
+
Return ONLY valid JSON: {{"is_safe": true|false, "adjustment_needed": "<specific instruction for phrasing the final answer more safely, or empty string if no change needed>"}}"""
|
| 555 |
+
result = call_llm_json(prompt, "policy_check", default=default, temperature=0.0, reasoning_effort="low")
|
| 556 |
+
if not isinstance(result, dict):
|
| 557 |
+
return default
|
| 558 |
+
return {**default, **result}
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def stage_build_knowledge_graph(resolved: Dict[str, Any], scored_evidence: List[Dict[str, Any]]) -> List[Dict[str, str]]:
|
| 562 |
+
if not scored_evidence:
|
| 563 |
+
return []
|
| 564 |
+
prompt = f"""Structure the verified information into subject-predicate-object triplets for a knowledge graph.
|
| 565 |
+
|
| 566 |
+
Verdict: {resolved.get("resolved_verdict")}
|
| 567 |
+
Evidence: {json.dumps(scored_evidence, ensure_ascii=False)}
|
| 568 |
+
|
| 569 |
+
Return ONLY valid JSON: {{"triplets": [{{"subject": "...", "predicate": "...", "object": "..."}}, ...]}}
|
| 570 |
+
Only include triplets directly supported by the evidence above."""
|
| 571 |
+
result = call_llm_json(prompt, "knowledge_graph", default={"triplets": []}, temperature=0.0, reasoning_effort="low")
|
| 572 |
+
triplets = result.get("triplets") if isinstance(result, dict) else None
|
| 573 |
+
if not isinstance(triplets, list):
|
| 574 |
+
return []
|
| 575 |
+
return [t for t in triplets if isinstance(t, dict) and t.get("subject")]
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
def stage_synthesize_response(
|
| 579 |
+
query: str,
|
| 580 |
+
claims: List[str],
|
| 581 |
+
scored_evidence: List[Dict[str, Any]],
|
| 582 |
+
resolved: Dict[str, Any],
|
| 583 |
+
policy: Dict[str, Any],
|
| 584 |
+
) -> Dict[str, Any]:
|
| 585 |
+
default = {
|
| 586 |
+
"answer": "Verification could not be completed with confidence based on available evidence.",
|
| 587 |
+
"confidence": 0.0,
|
| 588 |
+
"reasoningSummary": "The synthesis step failed to produce a valid structured response.",
|
| 589 |
+
"supportingEvidence": [{"fact": e["fact"], "source": e["source"]} for e in scored_evidence[:5]],
|
| 590 |
+
"sources": list({e["source"] for e in scored_evidence}),
|
| 591 |
+
}
|
| 592 |
+
adjustment = policy.get("adjustment_needed") or ""
|
| 593 |
+
prompt = f"""You are the Response Generator, the final step of a fact-verification pipeline. Write the final authoritative report.
|
| 594 |
+
|
| 595 |
+
Original query: {query!r}
|
| 596 |
+
Claims checked: {claims}
|
| 597 |
+
Resolved verdict: {resolved.get("resolved_verdict")}
|
| 598 |
+
Conflict/dispute notes: {resolved.get("conflict_notes")}
|
| 599 |
+
Evidence: {json.dumps(scored_evidence, ensure_ascii=False)}
|
| 600 |
+
|
| 601 |
+
Write a clear, precise, appropriately hedged answer. Do not state anything as fact that isn't backed by the evidence above. If evidence is thin or mixed, say so plainly rather than sounding more confident than the evidence supports.
|
| 602 |
+
{"Apply this safety adjustment to your phrasing: " + adjustment if adjustment else ""}
|
| 603 |
+
|
| 604 |
+
Return ONLY valid JSON in exactly this shape:
|
| 605 |
+
{{"answer": "<final answer, 2-5 sentences>", "confidence": <float 0.0-1.0 reflecting how well-supported the answer is by the evidence>, "reasoningSummary": "<1-3 sentences on how the verdict was reached>", "supportingEvidence": [{{"fact": "...", "source": "..."}}, ...], "sources": ["<url>", ...]}}
|
| 606 |
+
Use the EXACT source URLs from the evidence above -- never invent a URL."""
|
| 607 |
+
result = call_llm_json(prompt, "response_generator", default=default, temperature=0.15, reasoning_effort="medium")
|
| 608 |
+
if not isinstance(result, dict):
|
| 609 |
+
return default
|
| 610 |
+
return {**default, **result}
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
# ---------------------------------------------------------------------------
|
| 614 |
+
# Deterministic (non-LLM) audit stages. These cost no extra latency and are
|
| 615 |
+
# the most direct defense against fabricated citations and over-confident verdicts.
|
| 616 |
+
# ---------------------------------------------------------------------------
|
| 617 |
+
|
| 618 |
+
def grounding_audit(final_response: Dict[str, Any], retrieved_urls: set) -> Tuple[Dict[str, Any], List[str]]:
|
| 619 |
+
"""Strip any cited source that wasn't actually retrieved during search --
|
| 620 |
+
the main defense against the model citing a plausible-looking but fabricated URL."""
|
| 621 |
+
warnings: List[str] = []
|
| 622 |
+
|
| 623 |
+
sources = final_response.get("sources", [])
|
| 624 |
+
if isinstance(sources, list):
|
| 625 |
+
clean_sources = [s for s in sources if s in retrieved_urls]
|
| 626 |
+
if len(clean_sources) < len(sources):
|
| 627 |
+
warnings.append(f"Removed {len(sources) - len(clean_sources)} cited source(s) not present in retrieved evidence.")
|
| 628 |
+
final_response["sources"] = clean_sources
|
| 629 |
+
|
| 630 |
+
supporting = final_response.get("supportingEvidence", [])
|
| 631 |
+
if isinstance(supporting, list):
|
| 632 |
+
clean_supporting = [e for e in supporting if isinstance(e, dict) and e.get("source") in retrieved_urls]
|
| 633 |
+
if len(clean_supporting) < len(supporting):
|
| 634 |
+
warnings.append(f"Removed {len(supporting) - len(clean_supporting)} supporting-evidence item(s) with an unverifiable source.")
|
| 635 |
+
final_response["supportingEvidence"] = clean_supporting
|
| 636 |
+
|
| 637 |
+
return final_response, warnings
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
def calibrate_confidence(
|
| 641 |
+
llm_confidence: Any,
|
| 642 |
+
verdict: str,
|
| 643 |
+
unique_source_count: int,
|
| 644 |
+
search_returned_nothing: bool,
|
| 645 |
+
grounding_had_removals: bool,
|
| 646 |
+
) -> float:
|
| 647 |
+
"""Blend the model's self-reported confidence with deterministic caps so
|
| 648 |
+
a single confident-sounding LLM number isn't the only signal."""
|
| 649 |
+
try:
|
| 650 |
+
conf = float(llm_confidence)
|
| 651 |
+
except (TypeError, ValueError):
|
| 652 |
+
conf = 0.3
|
| 653 |
+
conf = max(0.0, min(1.0, conf))
|
| 654 |
+
|
| 655 |
+
if search_returned_nothing:
|
| 656 |
+
return min(conf, 0.15)
|
| 657 |
+
if verdict in ("Uncertain", "Disputed"):
|
| 658 |
+
conf = min(conf, 0.55)
|
| 659 |
+
if unique_source_count < MIN_SOURCES_FOR_VERIFIED and verdict in ("Verified", "Debunked"):
|
| 660 |
+
conf = min(conf, 0.5)
|
| 661 |
+
if grounding_had_removals:
|
| 662 |
+
conf = min(conf, 0.4)
|
| 663 |
+
|
| 664 |
+
return round(conf, 2)
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
def _base_response(**overrides: Any) -> Dict[str, Any]:
|
| 668 |
+
"""A schema-complete response shell, so every return path -- rate limit,
|
| 669 |
+
empty input, opinion short-circuit, crash -- has every field the API contract promises."""
|
| 670 |
+
base = {
|
| 671 |
+
"answer": "",
|
| 672 |
+
"confidence": 0.0,
|
| 673 |
+
"reasoningSummary": "",
|
| 674 |
+
"supportingEvidence": [],
|
| 675 |
+
"sources": [],
|
| 676 |
+
"verdict": "Uncertain",
|
| 677 |
+
"claimsAnalyzed": [],
|
| 678 |
+
"domains": [],
|
| 679 |
+
"caveats": [],
|
| 680 |
+
"sourceCount": 0,
|
| 681 |
+
"knowledgeGraph": [],
|
| 682 |
+
"pipelineWarnings": [],
|
| 683 |
+
"verifiedAt": _now_iso(),
|
| 684 |
+
}
|
| 685 |
+
base.update(overrides)
|
| 686 |
+
return base
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
# ---------------------------------------------------------------------------
|
| 690 |
+
# Orchestrator
|
| 691 |
+
# ---------------------------------------------------------------------------
|
| 692 |
+
|
| 693 |
+
def verify_claim_pipeline(query: str, request: gr.Request = None) -> Dict[str, Any]:
|
| 694 |
+
"""The full verification pipeline with real search. Always returns a
|
| 695 |
+
complete, well-typed response -- never raises."""
|
| 696 |
+
client_ip = request.client.host if request else "unknown"
|
| 697 |
+
if is_rate_limited(client_ip):
|
| 698 |
+
return _base_response(
|
| 699 |
+
answer="Rate limit exceeded",
|
| 700 |
+
reasoningSummary=f"Too many requests from {client_ip}. Please wait a minute and try again.",
|
| 701 |
+
verdict="Rate Limited",
|
| 702 |
+
)
|
| 703 |
+
|
| 704 |
+
if not query or not query.strip():
|
| 705 |
+
return _base_response(
|
| 706 |
+
answer="Please enter a claim or question to verify.",
|
| 707 |
+
reasoningSummary="No input was provided.",
|
| 708 |
+
verdict="No Input",
|
| 709 |
+
)
|
| 710 |
+
|
| 711 |
+
warnings: List[str] = []
|
| 712 |
+
|
| 713 |
+
try:
|
| 714 |
+
# 1. Query Analyzer
|
| 715 |
+
analysis = stage_analyze_query(query)
|
| 716 |
+
|
| 717 |
+
# Short-circuit opinions/non-verifiable input instead of forcing a verdict.
|
| 718 |
+
if analysis.get("isOpinion") or not analysis.get("isVerifiable", True):
|
| 719 |
+
return _base_response(
|
| 720 |
+
answer=(
|
| 721 |
+
"This reads as a subjective opinion, preference, or otherwise non-factual "
|
| 722 |
+
"statement rather than a claim that evidence can confirm or refute, so it "
|
| 723 |
+
"hasn't been run through the verification pipeline."
|
| 724 |
+
),
|
| 725 |
+
reasoningSummary=analysis.get("intent", "Not a verifiable factual claim."),
|
| 726 |
+
verdict="Not Applicable — Opinion/Subjective",
|
| 727 |
+
claimsAnalyzed=[query],
|
| 728 |
+
)
|
| 729 |
+
|
| 730 |
+
# 2. Claim Extractor
|
| 731 |
+
claims = stage_extract_claims(query)
|
| 732 |
+
|
| 733 |
+
# 3. Domain Classifier
|
| 734 |
+
domains = stage_classify_domains(claims)
|
| 735 |
+
caveats: List[str] = []
|
| 736 |
+
if any(d.lower() in HIGH_STAKES_DOMAINS for d in domains):
|
| 737 |
+
caveats.append(
|
| 738 |
+
"This claim touches a high-stakes domain (health, legal, financial, or similar). "
|
| 739 |
+
"Treat this as a starting point, not a substitute for a qualified professional or primary source."
|
| 740 |
+
)
|
| 741 |
+
|
| 742 |
+
# 4. Search Query Generator (confirming + disconfirming queries)
|
| 743 |
+
search_queries = stage_generate_search_queries(claims)
|
| 744 |
+
|
| 745 |
+
# 5. Retrieval Engine (real-time, multi-engine, retried, de-duplicated)
|
| 746 |
+
search_results = stage_retrieve_evidence(search_queries)
|
| 747 |
+
retrieved_urls = {r["href"] for r in search_results if r.get("href")}
|
| 748 |
+
search_returned_nothing = len(search_results) == 0
|
| 749 |
+
if search_returned_nothing:
|
| 750 |
+
warnings.append("Web search returned no results for any generated query; verdict reflects limited or no external evidence.")
|
| 751 |
+
|
| 752 |
+
# 6. Evidence Extractor (grounded to retrieved URLs only)
|
| 753 |
+
evidence = stage_extract_evidence(claims, search_results)
|
| 754 |
+
|
| 755 |
+
# 7. Evidence Scorer (LLM judgment blended with rule-based domain credibility)
|
| 756 |
+
scored_evidence = stage_score_evidence(claims, evidence)
|
| 757 |
+
unique_sources = {e["source"] for e in scored_evidence}
|
| 758 |
+
|
| 759 |
+
# 8. Verification Engine
|
| 760 |
+
verification = stage_verify_claims(claims, scored_evidence)
|
| 761 |
+
|
| 762 |
+
# 9. Adversarial Conflict Resolver
|
| 763 |
+
resolved = stage_resolve_conflicts(claims, verification, scored_evidence)
|
| 764 |
+
|
| 765 |
+
# Deterministically enforce the minimum-source rule for strong verdicts.
|
| 766 |
+
if resolved.get("resolved_verdict") in ("Verified", "Debunked") and len(unique_sources) < MIN_SOURCES_FOR_VERIFIED:
|
| 767 |
+
warnings.append(
|
| 768 |
+
f"Downgraded verdict from '{resolved.get('resolved_verdict')}' to 'Uncertain': "
|
| 769 |
+
f"fewer than {MIN_SOURCES_FOR_VERIFIED} independent sources were found."
|
| 770 |
+
)
|
| 771 |
+
resolved["resolved_verdict"] = "Uncertain"
|
| 772 |
+
|
| 773 |
+
# 10. Truth & Safety Policy
|
| 774 |
+
policy = stage_policy_check(resolved)
|
| 775 |
+
|
| 776 |
+
# 11. Knowledge Graph (now actually surfaced in the response, not discarded)
|
| 777 |
+
knowledge_graph = stage_build_knowledge_graph(resolved, scored_evidence)
|
| 778 |
+
|
| 779 |
+
# 12. Response Generator
|
| 780 |
+
final_response = stage_synthesize_response(query, claims, scored_evidence, resolved, policy)
|
| 781 |
+
|
| 782 |
+
# 13. Grounding / hallucination audit -- deterministic, not LLM self-report
|
| 783 |
+
final_response, grounding_warnings = grounding_audit(final_response, retrieved_urls)
|
| 784 |
+
warnings.extend(grounding_warnings)
|
| 785 |
+
|
| 786 |
+
# 14. Confidence calibration -- deterministic, not raw LLM self-report
|
| 787 |
+
final_response["confidence"] = calibrate_confidence(
|
| 788 |
+
llm_confidence=final_response.get("confidence", 0.3),
|
| 789 |
+
verdict=resolved.get("resolved_verdict", "Uncertain"),
|
| 790 |
+
unique_source_count=len(unique_sources),
|
| 791 |
+
search_returned_nothing=search_returned_nothing,
|
| 792 |
+
grounding_had_removals=bool(grounding_warnings),
|
| 793 |
+
)
|
| 794 |
+
|
| 795 |
+
if policy.get("is_safe") is False:
|
| 796 |
+
caveats.append(policy.get("adjustment_needed") or "This result required a safety-related caveat.")
|
| 797 |
+
final_response["confidence"] = min(final_response["confidence"], 0.4)
|
| 798 |
+
|
| 799 |
+
final_response["verdict"] = resolved.get("resolved_verdict", "Uncertain")
|
| 800 |
+
final_response["claimsAnalyzed"] = claims
|
| 801 |
+
final_response["domains"] = domains
|
| 802 |
+
final_response["caveats"] = caveats
|
| 803 |
+
final_response["sourceCount"] = len(unique_sources)
|
| 804 |
+
final_response["knowledgeGraph"] = knowledge_graph
|
| 805 |
+
final_response["pipelineWarnings"] = warnings
|
| 806 |
+
final_response["verifiedAt"] = _now_iso()
|
| 807 |
+
|
| 808 |
+
# Belt-and-suspenders: guarantee the original 5 fields always exist with the right shape.
|
| 809 |
+
final_response.setdefault("answer", "Unable to produce a final answer.")
|
| 810 |
+
final_response.setdefault("reasoningSummary", "")
|
| 811 |
+
final_response.setdefault("supportingEvidence", [])
|
| 812 |
+
final_response.setdefault("sources", [])
|
| 813 |
+
|
| 814 |
+
return final_response
|
| 815 |
+
|
| 816 |
+
except Exception as e:
|
| 817 |
+
print(f"verify_claim_pipeline crashed: {type(e).__name__}: {e}")
|
| 818 |
+
return _base_response(
|
| 819 |
+
answer="The verification pipeline hit an unexpected internal error and could not complete.",
|
| 820 |
+
reasoningSummary=f"Internal error: {type(e).__name__}. This has been logged.",
|
| 821 |
+
verdict="Error",
|
| 822 |
+
pipelineWarnings=[f"Unhandled exception: {type(e).__name__}: {e}"],
|
| 823 |
+
)
|
| 824 |
+
|
| 825 |
+
|
| 826 |
+
# ---------------------------------------------------------------------------
|
| 827 |
+
# Gradio Interface -- structure and api_name are unchanged from the original.
|
| 828 |
+
# ---------------------------------------------------------------------------
|
| 829 |
+
with gr.Blocks(title="Truth API Backend") as demo:
|
| 830 |
+
gr.Markdown("# Truth Verification Pipeline API")
|
| 831 |
+
gr.Markdown(
|
| 832 |
+
"14-stage distributed verification pipeline with real-time multi-engine web search, "
|
| 833 |
+
"grounded citations, and calibrated confidence scoring."
|
| 834 |
+
)
|
| 835 |
+
|
| 836 |
+
with gr.Row():
|
| 837 |
+
query_input = gr.Textbox(label="Claim to Verify", placeholder="Enter a claim...", lines=3)
|
| 838 |
+
verify_btn = gr.Button("Verify", variant="primary")
|
| 839 |
+
|
| 840 |
+
output_json = gr.JSON(label="Verification Result")
|
| 841 |
+
|
| 842 |
+
verify_btn.click(
|
| 843 |
+
fn=verify_claim_pipeline,
|
| 844 |
+
inputs=query_input,
|
| 845 |
+
outputs=output_json,
|
| 846 |
+
api_name="verify"
|
| 847 |
+
)
|
| 848 |
+
|
| 849 |
+
if __name__ == "__main__":
|
| 850 |
+
demo.launch()
|
requirements-2.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio
|
| 2 |
+
groq
|
| 3 |
+
google-genai
|
| 4 |
+
python-dotenv
|
| 5 |
+
ddgs
|