Truth_API / app.py
Adedoyinjames's picture
Update app.py
0fbe612 verified
Raw
History Blame Contribute Delete
35.3 kB
import gradio as gr
import os
import re
import json
import time
import random
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional, Tuple
from groq import Groq
from google import genai
from google.genai import types as genai_types
try:
from ddgs import DDGS # current, maintained package
except ImportError:
from duckduckgo_search import DDGS # frozen upstream; kept only as a transitional fallback
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY")
# Groq recommends openai/gpt-oss-120b as the replacement for the deprecated
# llama-3.3-70b-versatile (shutdown 2026-08-16). qwen/qwen3.6-27b is Groq's
# other listed alternative if you want to compare quality/latency.
GROQ_MODEL = "openai/gpt-oss-120b"
# gemini-1.5-flash is already shut down (404). gemini-3.1-flash-lite is GA
# and cost-efficient; swap to "gemini-3.5-flash" for a higher-quality (and
# pricier) fallback if the Gemini path gets hit often.
GEMINI_MODEL = "gemini-3.1-flash-lite"
MAX_LLM_RETRIES = 2 # per provider, before moving on (Groq -> Gemini)
MAX_SEARCH_RETRIES = 3
MIN_SOURCES_FOR_VERIFIED = 2 # independent sources required for a strong verdict
RATE_LIMIT_WINDOW = 60
MAX_REQUESTS = 5
HIGH_STAKES_DOMAINS = {
"health", "medical", "medicine", "legal", "law", "finance",
"financial", "election", "elections", "politics", "safety",
}
HIGH_CREDIBILITY_HINTS = (
".gov", ".edu", "who.int", "un.org", "reuters.com", "apnews.com",
"bbc.co", "bbc.com", "nature.com", "science.org", "nih.gov",
"ncbi.nlm.nih.gov", "worldbank.org", "imf.org", "unesco.org",
"wikipedia.org",
)
LOW_CREDIBILITY_HINTS = ("blogspot.", "wordpress.com/", "medium.com/@")
# ---------------------------------------------------------------------------
# Rate limiter
# ---------------------------------------------------------------------------
rate_limit_store: Dict[str, List[float]] = {}
def is_rate_limited(client_ip: str) -> bool:
now = time.time()
bucket = rate_limit_store.setdefault(client_ip, [])
bucket[:] = [t for t in bucket if now - t < RATE_LIMIT_WINDOW]
if len(bucket) >= MAX_REQUESTS:
return True
bucket.append(now)
# light housekeeping so this dict doesn't grow forever under long uptime
if len(rate_limit_store) > 5000:
for k in [k for k, v in rate_limit_store.items() if not v]:
del rate_limit_store[k]
return False
# ---------------------------------------------------------------------------
# Clients
# ---------------------------------------------------------------------------
groq_client = Groq(api_key=GROQ_API_KEY) if GROQ_API_KEY else None
gemini_client = genai.Client(api_key=GEMINI_API_KEY) if GEMINI_API_KEY else None
# ---------------------------------------------------------------------------
# Robust JSON handling
#
# This is the backbone of pipeline accuracy: every stage depends on getting
# well-formed JSON back from an LLM, and LLMs reliably wrap JSON in prose,
# markdown code fences, or trailing commentary. All three helpers below are
# unit-tested against realistic malformed outputs (fences, prose preambles,
# nested braces inside string values, mismatched bracket types).
# ---------------------------------------------------------------------------
def _strip_code_fences(text: str) -> str:
text = text.strip()
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
return text.strip()
def _extract_json_block(text: str) -> Optional[str]:
"""Grab the first balanced top-level {...} or [...] block, whichever starts first."""
starts = [(text.find(ch), ch) for ch in ("{", "[") if text.find(ch) != -1]
if not starts:
return None
starts.sort(key=lambda t: t[0])
start, open_ch = starts[0]
close_ch = "}" if open_ch == "{" else "]"
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
ch = text[i]
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
elif ch == open_ch:
depth += 1
elif ch == close_ch:
depth -= 1
if depth == 0:
return text[start:i + 1]
return None
def safe_json_parse(raw: str, default: Any = None) -> Any:
"""Best-effort JSON parsing that tolerates the ways LLMs mangle JSON."""
if not raw:
return default
stripped = _strip_code_fences(raw)
candidates = [raw, stripped]
block = _extract_json_block(stripped)
if block:
candidates.append(block)
for candidate in candidates:
try:
return json.loads(candidate)
except (json.JSONDecodeError, TypeError):
continue
return default
# ---------------------------------------------------------------------------
# LLM calling: Groq primary, Gemini fallback, retries + backoff on both,
# always requesting native JSON mode, plus one corrective repair pass if
# parsing still fails.
# ---------------------------------------------------------------------------
def call_llm(
prompt: str,
stage_name: str = "llm_call",
json_mode: bool = True,
temperature: float = 0.1,
reasoning_effort: str = "medium",
) -> str:
"""Call Groq, falling back to Gemini, with retries + backoff on both."""
if not groq_client and not gemini_client:
return json.dumps({"error": "No LLM provider configured: set GROQ_API_KEY and/or GEMINI_API_KEY."})
last_error: Any = None
if groq_client:
for attempt in range(MAX_LLM_RETRIES):
try:
kwargs: Dict[str, Any] = dict(
messages=[{"role": "user", "content": prompt}],
model=GROQ_MODEL,
temperature=temperature,
)
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
if "gpt-oss" in GROQ_MODEL:
kwargs["reasoning_effort"] = reasoning_effort
completion = groq_client.chat.completions.create(**kwargs)
content = completion.choices[0].message.content
if content and content.strip():
return content
except Exception as e:
last_error = e
print(f"[{stage_name}] Groq attempt {attempt + 1} failed: {e}")
time.sleep(min(2 ** attempt, 8) + random.random())
print(f"[{stage_name}] Groq exhausted retries, falling back to Gemini. Last error: {last_error}")
if gemini_client:
for attempt in range(MAX_LLM_RETRIES):
try:
config_kwargs: Dict[str, Any] = {"temperature": temperature}
if json_mode:
config_kwargs["response_mime_type"] = "application/json"
response = gemini_client.models.generate_content(
model=GEMINI_MODEL,
contents=prompt,
config=genai_types.GenerateContentConfig(**config_kwargs),
)
if response.text and response.text.strip():
return response.text
except Exception as e:
last_error = e
print(f"[{stage_name}] Gemini attempt {attempt + 1} failed: {e}")
time.sleep(min(2 ** attempt, 8) + random.random())
print(f"[{stage_name}] Gemini also exhausted retries. Last error: {last_error}")
return json.dumps({"error": f"All LLM providers failed for stage '{stage_name}': {last_error}"})
def call_llm_json(
prompt: str,
stage_name: str = "llm_call",
default: Any = None,
temperature: float = 0.1,
reasoning_effort: str = "medium",
) -> Any:
"""call_llm + robust parsing + one corrective retry before giving up."""
raw = call_llm(prompt, stage_name, json_mode=True, temperature=temperature, reasoning_effort=reasoning_effort)
parsed = safe_json_parse(raw)
if parsed is not None:
return parsed
repair_prompt = (
"The following text was supposed to be valid JSON but failed to parse. "
"Return ONLY the corrected, valid JSON with no commentary and no markdown fences:\n\n"
f"{raw[:2000]}"
)
raw_retry = call_llm(repair_prompt, f"{stage_name}_repair", json_mode=True, temperature=0.0, reasoning_effort=reasoning_effort)
parsed_retry = safe_json_parse(raw_retry)
if parsed_retry is not None:
return parsed_retry
print(f"[{stage_name}] JSON parsing failed twice; using default.")
return default
# ---------------------------------------------------------------------------
# Web search: retries + backoff, multi-engine via ddgs (backend="auto" tries
# bing/brave/duckduckgo/google/mojeek/yahoo/yandex/wikipedia and aggregates),
# plus de-duplication across the multiple queries the pipeline issues.
# ---------------------------------------------------------------------------
def web_search(query: str, max_results: int = 6) -> List[Dict[str, str]]:
for attempt in range(MAX_SEARCH_RETRIES):
try:
with DDGS() as ddgs:
results = [
{"title": r.get("title", ""), "body": r.get("body", ""), "href": r.get("href", "")}
for r in ddgs.text(query, max_results=max_results, backend="auto")
]
if results:
return results
return [] # legitimately no results; don't retry forever on an empty-but-successful search
except Exception as e:
print(f"Search attempt {attempt + 1} failed for '{query}': {e}")
time.sleep(min(2 ** attempt, 6) + random.random())
return []
def dedupe_results(results: List[Dict[str, str]]) -> List[Dict[str, str]]:
seen = set()
deduped = []
for r in results:
href = (r.get("href") or "").strip().rstrip("/")
if href:
if href in seen:
continue
seen.add(href)
deduped.append(r)
return deduped
def domain_credibility_hint(url: str) -> float:
"""A rule-based prior, NOT a verdict on truth -- just a floor/ceiling nudge
blended with the model's own reliability judgment so a single LLM call
isn't the only signal on source quality."""
url_lower = (url or "").lower()
if any(d in url_lower for d in HIGH_CREDIBILITY_HINTS):
return 0.85
if any(d in url_lower for d in LOW_CREDIBILITY_HINTS):
return 0.35
return 0.55
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
# ---------------------------------------------------------------------------
# Pipeline stages. Each has a typed default so a stage failure degrades
# gracefully instead of raising.
# ---------------------------------------------------------------------------
def stage_analyze_query(query: str) -> Dict[str, Any]:
default = {"complexity": "moderate", "intent": "Verify a factual claim", "isVerifiable": True, "isOpinion": False}
prompt = f"""You are the Query Analyzer in a fact-verification pipeline.
Determine whether the following input is an objectively verifiable factual claim, or a subjective opinion/preference/unanswerable question.
Input: {query!r}
Return ONLY valid JSON, no markdown, no commentary, in exactly this shape:
{{"complexity": "simple" | "moderate" | "complex", "intent": "<one short sentence>", "isVerifiable": true | false, "isOpinion": true | false}}
isVerifiable should be false for pure opinions, preferences, or requests that aren't actually claims (e.g. "what's the best pizza topping").
isVerifiable should be true for concrete factual, historical, scientific, statistical, or current-events claims that evidence could confirm or refute."""
result = call_llm_json(prompt, "query_analyzer", default=default, temperature=0.0, reasoning_effort="low")
if not isinstance(result, dict):
return default
return {**default, **result}
def stage_extract_claims(query: str) -> List[str]:
prompt = f"""Extract the distinct, atomic, independently-checkable factual claims from this input.
Input: {query!r}
Return ONLY valid JSON: {{"claims": ["claim 1", "claim 2", ...]}}
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."""
result = call_llm_json(prompt, "claim_extractor", default={"claims": [query]}, temperature=0.0, reasoning_effort="low")
claims = result.get("claims") if isinstance(result, dict) else None
if not isinstance(claims, list):
return [query]
cleaned = [str(c).strip() for c in claims if str(c).strip()]
return cleaned or [query]
def stage_classify_domains(claims: List[str]) -> List[str]:
prompt = f"""Classify the subject-matter domain(s) of these claims (e.g. Science, Technology, Politics, Health, History, Finance, Geography, Sports, Entertainment).
Claims: {claims}
Return ONLY valid JSON: {{"domains": ["Domain1", "Domain2", ...]}}"""
result = call_llm_json(prompt, "domain_classifier", default={"domains": ["General"]}, temperature=0.0, reasoning_effort="low")
domains = result.get("domains") if isinstance(result, dict) else None
if not isinstance(domains, list):
return ["General"]
return [str(d).strip() for d in domains if str(d).strip()] or ["General"]
def stage_generate_search_queries(claims: List[str]) -> List[str]:
prompt = f"""Generate search-engine queries to investigate these claims: {claims}
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.
Keep each query short (3-8 words), like a real search-engine query, not a full sentence.
Return ONLY valid JSON: {{"queries": ["query 1", "query 2", ...]}}
Maximum 6 queries total -- prioritize the most important ones."""
result = call_llm_json(prompt, "search_query_generator", default={"queries": claims[:3]}, temperature=0.2, reasoning_effort="low")
queries = result.get("queries") if isinstance(result, dict) else None
if not isinstance(queries, list):
return claims[:3]
cleaned = [str(q).strip() for q in queries if str(q).strip()]
return cleaned[:6] or claims[:3]
def stage_retrieve_evidence(search_queries: List[str]) -> List[Dict[str, str]]:
all_results: List[Dict[str, str]] = []
for sq in search_queries:
all_results.extend(web_search(sq))
return dedupe_results(all_results)
def stage_extract_evidence(claims: List[str], search_results: List[Dict[str, str]]) -> List[Dict[str, str]]:
if not search_results:
return []
trimmed = search_results[:15]
indexed = [{"idx": i, "title": r["title"], "body": r["body"], "url": r["href"]} for i, r in enumerate(trimmed)]
prompt = f"""You are the Evidence Extractor. Below are search results (with an index and url) and a list of claims to check.
Claims: {claims}
Search results: {json.dumps(indexed, ensure_ascii=False)}
Extract factual snippets from these search results directly relevant to confirming or refuting the claims.
CRITICAL: only use the "url" values exactly as given above. Never invent, guess, or modify a URL.
Return ONLY valid JSON: {{"snippets": [{{"fact": "<factual statement from the result>", "source": "<the exact url it came from>"}}, ...]}}
If nothing is relevant, return {{"snippets": []}}."""
result = call_llm_json(prompt, "evidence_extractor", default={"snippets": []}, temperature=0.0, reasoning_effort="medium")
snippets = result.get("snippets") if isinstance(result, dict) else None
if not isinstance(snippets, list):
return []
valid_urls = {r["href"] for r in search_results if r.get("href")}
cleaned = []
for s in snippets:
if not isinstance(s, dict):
continue
fact = str(s.get("fact", "")).strip()
source = str(s.get("source", "")).strip()
if fact and source in valid_urls:
cleaned.append({"fact": fact, "source": source})
return cleaned
def stage_score_evidence(claims: List[str], evidence: List[Dict[str, str]]) -> List[Dict[str, Any]]:
if not evidence:
return []
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).
Claims: {claims}
Evidence: {json.dumps(evidence, ensure_ascii=False)}
Return ONLY valid JSON: {{"scored_evidence": [{{"fact": "...", "source": "...", "relevance": 0.0-1.0, "reliability": 0.0-1.0}}, ...]}}
Keep fact/source EXACTLY as given, only add the two score fields."""
default_scored = [{**e, "relevance": 0.5, "reliability": 0.5} for e in evidence]
result = call_llm_json(prompt, "evidence_scorer", default={"scored_evidence": default_scored}, temperature=0.0, reasoning_effort="medium")
scored = result.get("scored_evidence") if isinstance(result, dict) else None
if not isinstance(scored, list) or not scored:
scored = default_scored
valid_sources = {e["source"] for e in evidence}
blended = []
for item in scored:
if not isinstance(item, dict):
continue
source = str(item.get("source", "")).strip()
if source not in valid_sources:
continue # drop anything not grounded in real retrieved evidence
fact = str(item.get("fact", "")).strip()
try:
relevance = float(item.get("relevance", 0.5))
except (TypeError, ValueError):
relevance = 0.5
try:
llm_reliability = float(item.get("reliability", 0.5))
except (TypeError, ValueError):
llm_reliability = 0.5
rule_reliability = domain_credibility_hint(source)
reliability = round((llm_reliability + rule_reliability) / 2, 2)
blended.append({
"fact": fact,
"source": source,
"relevance": max(0.0, min(1.0, relevance)),
"reliability": max(0.0, min(1.0, reliability)),
})
return blended
def stage_verify_claims(claims: List[str], scored_evidence: List[Dict[str, Any]]) -> Dict[str, Any]:
default = {"verdict": "Uncertain", "detailed_analysis": "Insufficient evidence was available to reach a confident verdict."}
if not scored_evidence:
return default
prompt = f"""You are the Verification Engine. Compare the claims against the scored evidence and determine a verdict.
Claims: {claims}
Scored evidence: {json.dumps(scored_evidence, ensure_ascii=False)}
Rules:
- Only say "Verified" if multiple independent, credible sources clearly confirm the claim.
- Only say "Debunked" if multiple independent, credible sources clearly contradict the claim.
- Say "Uncertain" if evidence is thin, mixed, low-relevance, or low-reliability. Do not guess to sound confident.
- Be specific: cite which facts support your verdict and which, if any, conflict with it.
Return ONLY valid JSON: {{"verdict": "Verified"|"Debunked"|"Uncertain", "detailed_analysis": "<specific reasoning citing the evidence>"}}"""
result = call_llm_json(prompt, "verification_engine", default=default, temperature=0.0, reasoning_effort="high")
if not isinstance(result, dict) or "verdict" not in result:
return default
return {**default, **result}
def stage_resolve_conflicts(claims: List[str], verification: Dict[str, Any], scored_evidence: List[Dict[str, Any]]) -> Dict[str, Any]:
default = {"resolved_verdict": verification.get("verdict", "Uncertain"), "conflict_notes": "No conflicts identified."}
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.
Claims: {claims}
Proposed verdict: {verification.get("verdict")}
Reasoning given: {verification.get("detailed_analysis")}
Evidence used: {json.dumps(scored_evidence, ensure_ascii=False)}
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.
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.
Return ONLY valid JSON: {{"resolved_verdict": "Verified"|"Debunked"|"Uncertain"|"Disputed", "conflict_notes": "<what you checked and what you found>"}}
Use "Disputed" if credible sources genuinely disagree with each other."""
result = call_llm_json(prompt, "conflict_resolver", default=default, temperature=0.0, reasoning_effort="high")
if not isinstance(result, dict) or "resolved_verdict" not in result:
return default
return {**default, **result}
def stage_policy_check(resolved: Dict[str, Any]) -> Dict[str, Any]:
default = {"is_safe": True, "adjustment_needed": ""}
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).
Result: {json.dumps(resolved, ensure_ascii=False)}
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>"}}"""
result = call_llm_json(prompt, "policy_check", default=default, temperature=0.0, reasoning_effort="low")
if not isinstance(result, dict):
return default
return {**default, **result}
def stage_build_knowledge_graph(resolved: Dict[str, Any], scored_evidence: List[Dict[str, Any]]) -> List[Dict[str, str]]:
if not scored_evidence:
return []
prompt = f"""Structure the verified information into subject-predicate-object triplets for a knowledge graph.
Verdict: {resolved.get("resolved_verdict")}
Evidence: {json.dumps(scored_evidence, ensure_ascii=False)}
Return ONLY valid JSON: {{"triplets": [{{"subject": "...", "predicate": "...", "object": "..."}}, ...]}}
Only include triplets directly supported by the evidence above."""
result = call_llm_json(prompt, "knowledge_graph", default={"triplets": []}, temperature=0.0, reasoning_effort="low")
triplets = result.get("triplets") if isinstance(result, dict) else None
if not isinstance(triplets, list):
return []
return [t for t in triplets if isinstance(t, dict) and t.get("subject")]
def stage_synthesize_response(
query: str,
claims: List[str],
scored_evidence: List[Dict[str, Any]],
resolved: Dict[str, Any],
policy: Dict[str, Any],
) -> Dict[str, Any]:
default = {
"answer": "Verification could not be completed with confidence based on available evidence.",
"confidence": 0.0,
"reasoningSummary": "The synthesis step failed to produce a valid structured response.",
"supportingEvidence": [{"fact": e["fact"], "source": e["source"]} for e in scored_evidence[:5]],
"sources": list({e["source"] for e in scored_evidence}),
}
adjustment = policy.get("adjustment_needed") or ""
prompt = f"""You are the Response Generator, the final step of a fact-verification pipeline. Write the final authoritative report.
Original query: {query!r}
Claims checked: {claims}
Resolved verdict: {resolved.get("resolved_verdict")}
Conflict/dispute notes: {resolved.get("conflict_notes")}
Evidence: {json.dumps(scored_evidence, ensure_ascii=False)}
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.
{"Apply this safety adjustment to your phrasing: " + adjustment if adjustment else ""}
Return ONLY valid JSON in exactly this shape:
{{"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>", ...]}}
Use the EXACT source URLs from the evidence above -- never invent a URL."""
result = call_llm_json(prompt, "response_generator", default=default, temperature=0.15, reasoning_effort="medium")
if not isinstance(result, dict):
return default
return {**default, **result}
# ---------------------------------------------------------------------------
# Deterministic (non-LLM) audit stages. These cost no extra latency and are
# the most direct defense against fabricated citations and over-confident verdicts.
# ---------------------------------------------------------------------------
def grounding_audit(final_response: Dict[str, Any], retrieved_urls: set) -> Tuple[Dict[str, Any], List[str]]:
"""Strip any cited source that wasn't actually retrieved during search --
the main defense against the model citing a plausible-looking but fabricated URL."""
warnings: List[str] = []
sources = final_response.get("sources", [])
if isinstance(sources, list):
clean_sources = [s for s in sources if s in retrieved_urls]
if len(clean_sources) < len(sources):
warnings.append(f"Removed {len(sources) - len(clean_sources)} cited source(s) not present in retrieved evidence.")
final_response["sources"] = clean_sources
supporting = final_response.get("supportingEvidence", [])
if isinstance(supporting, list):
clean_supporting = [e for e in supporting if isinstance(e, dict) and e.get("source") in retrieved_urls]
if len(clean_supporting) < len(supporting):
warnings.append(f"Removed {len(supporting) - len(clean_supporting)} supporting-evidence item(s) with an unverifiable source.")
final_response["supportingEvidence"] = clean_supporting
return final_response, warnings
def calibrate_confidence(
llm_confidence: Any,
verdict: str,
unique_source_count: int,
search_returned_nothing: bool,
grounding_had_removals: bool,
) -> float:
"""Blend the model's self-reported confidence with deterministic caps so
a single confident-sounding LLM number isn't the only signal."""
try:
conf = float(llm_confidence)
except (TypeError, ValueError):
conf = 0.3
conf = max(0.0, min(1.0, conf))
if search_returned_nothing:
return min(conf, 0.15)
if verdict in ("Uncertain", "Disputed"):
conf = min(conf, 0.55)
if unique_source_count < MIN_SOURCES_FOR_VERIFIED and verdict in ("Verified", "Debunked"):
conf = min(conf, 0.5)
if grounding_had_removals:
conf = min(conf, 0.4)
return round(conf, 2)
def _base_response(**overrides: Any) -> Dict[str, Any]:
"""A schema-complete response shell, so every return path -- rate limit,
empty input, opinion short-circuit, crash -- has every field the API contract promises."""
base = {
"answer": "",
"confidence": 0.0,
"reasoningSummary": "",
"supportingEvidence": [],
"sources": [],
"verdict": "Uncertain",
"claimsAnalyzed": [],
"domains": [],
"caveats": [],
"sourceCount": 0,
"knowledgeGraph": [],
"pipelineWarnings": [],
"verifiedAt": _now_iso(),
}
base.update(overrides)
return base
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
def verify_claim_pipeline(query: str, request: gr.Request = None) -> Dict[str, Any]:
"""The full verification pipeline with real search. Always returns a
complete, well-typed response -- never raises."""
client_ip = request.client.host if request else "unknown"
if is_rate_limited(client_ip):
return _base_response(
answer="Rate limit exceeded",
reasoningSummary=f"Too many requests from {client_ip}. Please wait a minute and try again.",
verdict="Rate Limited",
)
if not query or not query.strip():
return _base_response(
answer="Please enter a claim or question to verify.",
reasoningSummary="No input was provided.",
verdict="No Input",
)
warnings: List[str] = []
try:
# 1. Query Analyzer
analysis = stage_analyze_query(query)
# Short-circuit opinions/non-verifiable input instead of forcing a verdict.
if analysis.get("isOpinion") or not analysis.get("isVerifiable", True):
return _base_response(
answer=(
"This reads as a subjective opinion, preference, or otherwise non-factual "
"statement rather than a claim that evidence can confirm or refute, so it "
"hasn't been run through the verification pipeline."
),
reasoningSummary=analysis.get("intent", "Not a verifiable factual claim."),
verdict="Not Applicable — Opinion/Subjective",
claimsAnalyzed=[query],
)
# 2. Claim Extractor
claims = stage_extract_claims(query)
# 3. Domain Classifier
domains = stage_classify_domains(claims)
caveats: List[str] = []
if any(d.lower() in HIGH_STAKES_DOMAINS for d in domains):
caveats.append(
"This claim touches a high-stakes domain (health, legal, financial, or similar). "
"Treat this as a starting point, not a substitute for a qualified professional or primary source."
)
# 4. Search Query Generator (confirming + disconfirming queries)
search_queries = stage_generate_search_queries(claims)
# 5. Retrieval Engine (real-time, multi-engine, retried, de-duplicated)
search_results = stage_retrieve_evidence(search_queries)
retrieved_urls = {r["href"] for r in search_results if r.get("href")}
search_returned_nothing = len(search_results) == 0
if search_returned_nothing:
warnings.append("Web search returned no results for any generated query; verdict reflects limited or no external evidence.")
# 6. Evidence Extractor (grounded to retrieved URLs only)
evidence = stage_extract_evidence(claims, search_results)
# 7. Evidence Scorer (LLM judgment blended with rule-based domain credibility)
scored_evidence = stage_score_evidence(claims, evidence)
unique_sources = {e["source"] for e in scored_evidence}
# 8. Verification Engine
verification = stage_verify_claims(claims, scored_evidence)
# 9. Adversarial Conflict Resolver
resolved = stage_resolve_conflicts(claims, verification, scored_evidence)
# Deterministically enforce the minimum-source rule for strong verdicts.
if resolved.get("resolved_verdict") in ("Verified", "Debunked") and len(unique_sources) < MIN_SOURCES_FOR_VERIFIED:
warnings.append(
f"Downgraded verdict from '{resolved.get('resolved_verdict')}' to 'Uncertain': "
f"fewer than {MIN_SOURCES_FOR_VERIFIED} independent sources were found."
)
resolved["resolved_verdict"] = "Uncertain"
# 10. Truth & Safety Policy
policy = stage_policy_check(resolved)
# 11. Knowledge Graph (now actually surfaced in the response, not discarded)
knowledge_graph = stage_build_knowledge_graph(resolved, scored_evidence)
# 12. Response Generator
final_response = stage_synthesize_response(query, claims, scored_evidence, resolved, policy)
# 13. Grounding / hallucination audit -- deterministic, not LLM self-report
final_response, grounding_warnings = grounding_audit(final_response, retrieved_urls)
warnings.extend(grounding_warnings)
# 14. Confidence calibration -- deterministic, not raw LLM self-report
final_response["confidence"] = calibrate_confidence(
llm_confidence=final_response.get("confidence", 0.3),
verdict=resolved.get("resolved_verdict", "Uncertain"),
unique_source_count=len(unique_sources),
search_returned_nothing=search_returned_nothing,
grounding_had_removals=bool(grounding_warnings),
)
if policy.get("is_safe") is False:
caveats.append(policy.get("adjustment_needed") or "This result required a safety-related caveat.")
final_response["confidence"] = min(final_response["confidence"], 0.4)
final_response["verdict"] = resolved.get("resolved_verdict", "Uncertain")
final_response["claimsAnalyzed"] = claims
final_response["domains"] = domains
final_response["caveats"] = caveats
final_response["sourceCount"] = len(unique_sources)
final_response["knowledgeGraph"] = knowledge_graph
final_response["pipelineWarnings"] = warnings
final_response["verifiedAt"] = _now_iso()
# Belt-and-suspenders: guarantee the original 5 fields always exist with the right shape.
final_response.setdefault("answer", "Unable to produce a final answer.")
final_response.setdefault("reasoningSummary", "")
final_response.setdefault("supportingEvidence", [])
final_response.setdefault("sources", [])
return final_response
except Exception as e:
print(f"verify_claim_pipeline crashed: {type(e).__name__}: {e}")
return _base_response(
answer="The verification pipeline hit an unexpected internal error and could not complete.",
reasoningSummary=f"Internal error: {type(e).__name__}. This has been logged.",
verdict="Error",
pipelineWarnings=[f"Unhandled exception: {type(e).__name__}: {e}"],
)
# ---------------------------------------------------------------------------
# Gradio Interface -- structure and api_name are unchanged from the original.
# ---------------------------------------------------------------------------
with gr.Blocks(title="Truth API Backend") as demo:
gr.Markdown("# Truth Verification Pipeline API")
gr.Markdown(
"14-stage distributed verification pipeline with real-time multi-engine web search, "
"grounded citations, and calibrated confidence scoring."
)
with gr.Row():
query_input = gr.Textbox(label="Claim to Verify", placeholder="Enter a claim...", lines=3)
verify_btn = gr.Button("Verify", variant="primary")
output_json = gr.JSON(label="Verification Result")
verify_btn.click(
fn=verify_claim_pipeline,
inputs=query_input,
outputs=output_json,
api_name="verify"
)
if __name__ == "__main__":
demo.launch()