Adedoyinjames commited on
Commit
d426636
·
verified ·
1 Parent(s): c008f0e

Upload 2 files

Browse files
Files changed (2) hide show
  1. app-1.py +850 -0
  2. 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