Dipika Bhadane commited on
Commit
5fb092b
·
1 Parent(s): 1da72c8

comarison and word unverified remove

Browse files
app.py CHANGED
@@ -16,7 +16,8 @@ PROJECT_ROOT = os.path.dirname(__file__)
16
  if PROJECT_ROOT not in sys.path:
17
  sys.path.insert(0, PROJECT_ROOT)
18
  from gnn.gnn_predict import predict_spread
19
- print("✅ VERIMED GNN MODULE LOADED SUCCESSFULLY — v2")
 
20
 
21
  app = Flask(__name__)
22
  app.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-only-change-me")
@@ -205,6 +206,17 @@ def api_verify():
205
  if language != "en":
206
  explanation_for_display = translate_utils.translate_from_english(explanation_for_display, language)
207
 
 
 
 
 
 
 
 
 
 
 
 
208
  response = {
209
  "verdict": result.get("verdict"),
210
  "confidence": result.get("confidence"),
@@ -213,11 +225,50 @@ def api_verify():
213
  "sources": result.get("sources", []),
214
  "language_processed": language,
215
  "ocr_used": ocr_used,
216
- "claim_text_used": original_claim_text if (ocr_used or language != "en") else None,
 
 
 
217
  }
218
  return jsonify(response)
219
 
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  @app.route('/api/history', methods=['GET'])
222
  def api_history():
223
  limit = request.args.get('limit', default=20, type=int)
@@ -256,6 +307,22 @@ def api_predict_spread():
256
  confidence=verification.get("confidence", 0),
257
  entities=verification.get("entities", []),
258
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  return jsonify(graph_data)
260
 
261
 
@@ -377,4 +444,4 @@ def api_passport_pdf():
377
 
378
 
379
  if __name__ == '__main__':
380
- app.run(debug=True, port=5000, use_reloader=False)
 
16
  if PROJECT_ROOT not in sys.path:
17
  sys.path.insert(0, PROJECT_ROOT)
18
  from gnn.gnn_predict import predict_spread
19
+ from gnn.visualization_graph import generate_visualization_graph
20
+ from comparison import compute_method_comparison
21
 
22
  app = Flask(__name__)
23
  app.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-only-change-me")
 
206
  if language != "en":
207
  explanation_for_display = translate_utils.translate_from_english(explanation_for_display, language)
208
 
209
+ # Compute the GNN spread-risk prediction and the 3-method comparison.
210
+ # This reuses the verdict/confidence/entities already computed above --
211
+ # no second LLM call, just fast local scoring.
212
+ spread_result = predict_spread(
213
+ claim_text=claim_text,
214
+ verdict=result.get("verdict", "Unverified"),
215
+ confidence=result.get("confidence", 0),
216
+ entities=result.get("entities", []),
217
+ )
218
+ method_comparison = compute_method_comparison(claim_text, result, spread_result)
219
+
220
  response = {
221
  "verdict": result.get("verdict"),
222
  "confidence": result.get("confidence"),
 
225
  "sources": result.get("sources", []),
226
  "language_processed": language,
227
  "ocr_used": ocr_used,
228
+ "raw_text_detected": original_claim_text,
229
+ "claim_text_used": claim_text,
230
+ "spread_prediction": spread_result,
231
+ "method_comparison": method_comparison,
232
  }
233
  return jsonify(response)
234
 
235
 
236
+ @app.route('/api/tts', methods=['POST'])
237
+ def api_tts():
238
+ """
239
+ Server-side text-to-speech fallback using gTTS.
240
+
241
+ The browser's built-in speech synthesis depends on voices installed on
242
+ the user's device -- many devices don't have Hindi/Marathi voices
243
+ installed, which silently falls back to English. This endpoint
244
+ generates real audio in the requested language regardless of what's
245
+ installed locally.
246
+ """
247
+ from gtts import gTTS
248
+ import io
249
+
250
+ data = request.get_json(force=True) or {}
251
+ text = data.get('text', '').strip()
252
+ lang = data.get('lang', 'en')
253
+
254
+ if not text:
255
+ return jsonify({"error": "No text provided."}), 400
256
+
257
+ # gTTS language codes -- Marathi isn't supported by gTTS, so we fall
258
+ # back to Hindi audio for Marathi text (closer than English, and this
259
+ # is only reached when no local Marathi voice exists anyway).
260
+ gtts_lang = {"en": "en", "hi": "hi", "mr": "hi"}.get(lang, "en")
261
+
262
+ try:
263
+ tts = gTTS(text=text, lang=gtts_lang)
264
+ buf = io.BytesIO()
265
+ tts.write_to_fp(buf)
266
+ buf.seek(0)
267
+ return Response(buf.read(), mimetype='audio/mpeg')
268
+ except Exception as e:
269
+ return jsonify({"error": f"TTS generation failed: {e}"}), 500
270
+
271
+
272
  @app.route('/api/history', methods=['GET'])
273
  def api_history():
274
  limit = request.args.get('limit', default=20, type=int)
 
307
  confidence=verification.get("confidence", 0),
308
  entities=verification.get("entities", []),
309
  )
310
+
311
+ # Node-by-node visualization -- a smaller, legible graph with the SAME
312
+ # epidemic simulation logic used to train the model, run live on this
313
+ # claim's actual risk profile. Wrapped defensively so a visualization
314
+ # bug never breaks the numeric prediction above.
315
+ try:
316
+ graph_data["visualization"] = generate_visualization_graph(
317
+ claim_text=claim,
318
+ verdict=verification.get("verdict", "Unverified"),
319
+ confidence=verification.get("confidence", 0),
320
+ entities=verification.get("entities", []),
321
+ )
322
+ except Exception as e:
323
+ print(f"[api_predict_spread] Visualization graph failed: {e}")
324
+ graph_data["visualization"] = None
325
+
326
  return jsonify(graph_data)
327
 
328
 
 
444
 
445
 
446
  if __name__ == '__main__':
447
+ app.run(debug=True, port=5000, use_reloader=False)
comparison.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Method comparison module.
3
+
4
+ Computes a fair, honest side-by-side comparison of three detection layers
5
+ used in this project, all scored on the same 0-100 "misinformation danger"
6
+ scale so they can be charted together:
7
+
8
+ 1. BERT (NER) only -- a naive baseline using ONLY entity/keyword
9
+ patterns extracted by the BERT-based NER model.
10
+ No evidence, no reasoning.
11
+ 2. RAG + LLM -- the real verdict pipeline: evidence retrieved
12
+ from WHO/CDC/NIH, reasoned over by an LLM.
13
+ Accurate on truth, but static -- it doesn't say
14
+ how urgent or dangerous a false claim is.
15
+ 3. GNN-Enhanced -- takes the RAG verdict and adds the trained
16
+ Graph Attention Network's spread-risk
17
+ prediction on top. This is the only method
18
+ that answers "how much should I worry about
19
+ this, right now" -- not just true/false.
20
+
21
+ IMPORTANT: this does NOT run three independent LLM calls. Methods 2 and 3
22
+ reuse the verify_claim() result and predict_spread() result that the
23
+ /api/verify endpoint already computed -- so this comparison is free (no
24
+ extra API calls, no extra latency) beyond a couple of arithmetic formulas.
25
+ """
26
+
27
+ import sys
28
+ import os
29
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "gnn"))
30
+ from claim_features import VERDICT_RISK, sensational_score
31
+
32
+
33
+ def _risk_label(score: int) -> str:
34
+ if score >= 60:
35
+ return "High Risk"
36
+ if score >= 30:
37
+ return "Medium Risk"
38
+ return "Low Risk"
39
+
40
+
41
+ def compute_method_comparison(claim_text: str, verify_result: dict, spread_result: dict) -> dict:
42
+ """
43
+ claim_text: the original claim text
44
+ verify_result: the dict returned by verify_claim() (verdict, confidence, entities, ...)
45
+ spread_result: the dict returned by predict_spread() (virality_score, risk_level, ...)
46
+ """
47
+ entities = verify_result.get("entities", []) or []
48
+ verdict = verify_result.get("verdict", "Unverified")
49
+ confidence = verify_result.get("confidence", 0) or 0
50
+
51
+ # ---- Method 1: BERT (NER) only -- naive baseline ----
52
+ entity_density = min(len(entities) / 5, 1.0)
53
+ sensational = sensational_score(claim_text)
54
+ risk_bert = round(100 * min(1.0, 0.5 * sensational + 0.5 * entity_density))
55
+
56
+ # ---- Method 2: RAG + LLM -- evidence-grounded verdict ----
57
+ verdict_risk = VERDICT_RISK.get(verdict, 0.3)
58
+ risk_rag = round(verdict_risk * confidence)
59
+
60
+ # ---- Method 3: GNN-Enhanced -- RAG verdict + spread-risk modeling ----
61
+ virality_score = spread_result.get("virality_score", 0) or 0
62
+ risk_gnn = round(0.4 * risk_rag + 0.6 * virality_score)
63
+
64
+ methods = [
65
+ {
66
+ "key": "bert",
67
+ "name": "BERT (NER) Only",
68
+ "score": risk_bert,
69
+ "label": _risk_label(risk_bert),
70
+ "description": "Uses only medical entities and sensational-language patterns extracted by the BERT-based NER model. No evidence, no source-checking -- a naive baseline.",
71
+ "is_winner": False,
72
+ },
73
+ {
74
+ "key": "rag",
75
+ "name": "RAG + LLM",
76
+ "score": risk_rag,
77
+ "label": verdict,
78
+ "description": "Grounds the claim in real evidence retrieved from WHO/CDC/NIH, then an LLM reasons over that evidence. Accurate on truth, but doesn't assess urgency or real-world danger.",
79
+ "is_winner": False,
80
+ },
81
+ {
82
+ "key": "gnn",
83
+ "name": "GNN-Enhanced (Full Pipeline)",
84
+ "score": risk_gnn,
85
+ "label": spread_result.get("risk_level", _risk_label(risk_gnn)),
86
+ "description": "Builds on the RAG-grounded verdict and adds a trained Graph Attention Network's spread-risk prediction. The only method that answers not just 'is this false' but 'how urgently does this need attention'.",
87
+ "is_winner": True,
88
+ },
89
+ ]
90
+
91
+ conclusion = (
92
+ f"NER-only pattern matching rates this claim {risk_bert}/100 using surface signals alone, with no "
93
+ f"evidence behind it. The evidence-grounded RAG+LLM verdict is more trustworthy on truth "
94
+ f"(\"{verdict}\", {confidence}% confidence) but stops there. The GNN-enhanced score of {risk_gnn}/100 "
95
+ f"is the most complete: it keeps that evidence-grounded verdict and adds real spread-risk modeling on top, "
96
+ f"so it's the only method that tells you both whether this is false AND how much it deserves urgent attention."
97
+ )
98
+
99
+ return {
100
+ "methods": methods,
101
+ "conclusion": conclusion,
102
+ "winner": "gnn",
103
+ }
104
+
105
+
106
+ if __name__ == "__main__":
107
+ # Quick manual test -- run: python comparison.py
108
+ fake_verify_result = {
109
+ "verdict": "False", "confidence": 92,
110
+ "entities": [{"text": "garlic"}, {"text": "COVID-19"}],
111
+ }
112
+ fake_spread_result = {"virality_score": 95, "risk_level": "High Risk"}
113
+
114
+ result = compute_method_comparison(
115
+ "Garlic cures COVID-19 instantly, doctors hate this secret!",
116
+ fake_verify_result, fake_spread_result,
117
+ )
118
+ for m in result["methods"]:
119
+ print(f"{m['name']}: {m['score']}/100 ({m['label']}) {'<-- WINNER' if m['is_winner'] else ''}")
120
+ print(f"\nConclusion: {result['conclusion']}")
gnn/simulate_spread.py CHANGED
@@ -25,12 +25,17 @@ def simulate_epidemic_spread(
25
  seed_node: int,
26
  max_steps: int = 20,
27
  rng: random.Random | None = None,
28
- ) -> tuple[int, int, list[int]]:
 
29
  """
30
  Simulates how a claim spreads through the network starting from seed_node.
31
 
32
  claim_risk_score: 0-1, higher = spreads more aggressively (false/sensational claims)
33
- Returns: (total_nodes_reached, step_of_peak_growth, step_by_step_infected_counts)
 
 
 
 
34
  """
35
  rng = rng or random.Random()
36
 
@@ -57,6 +62,8 @@ def simulate_epidemic_spread(
57
  total_reached = len(infected)
58
  peak_step = (history.index(max(history)) + 1) if history else 1
59
 
 
 
60
  return total_reached, peak_step, history
61
 
62
 
@@ -69,4 +76,4 @@ if __name__ == "__main__":
69
 
70
  for risk_label, risk_score in [("Low-risk (true claim)", 0.05), ("High-risk (false+sensational)", 0.95)]:
71
  total, peak_step, history = simulate_epidemic_spread(G, risk_score, seed_node, rng=random.Random(7))
72
- print(f"{risk_label}: reached {total}/{G.number_of_nodes()} nodes, peaked at step {peak_step}")
 
25
  seed_node: int,
26
  max_steps: int = 20,
27
  rng: random.Random | None = None,
28
+ return_set: bool = False,
29
+ ):
30
  """
31
  Simulates how a claim spreads through the network starting from seed_node.
32
 
33
  claim_risk_score: 0-1, higher = spreads more aggressively (false/sensational claims)
34
+ return_set: if True, returns the actual set of infected node IDs as a 4th
35
+ value (used by the visualization graph to color nodes) --
36
+ default False keeps this backward-compatible with existing
37
+ callers like train_gnn.py.
38
+ Returns: (total_nodes_reached, step_of_peak_growth, step_by_step_infected_counts[, infected_set])
39
  """
40
  rng = rng or random.Random()
41
 
 
62
  total_reached = len(infected)
63
  peak_step = (history.index(max(history)) + 1) if history else 1
64
 
65
+ if return_set:
66
+ return total_reached, peak_step, history, infected
67
  return total_reached, peak_step, history
68
 
69
 
 
76
 
77
  for risk_label, risk_score in [("Low-risk (true claim)", 0.05), ("High-risk (false+sensational)", 0.95)]:
78
  total, peak_step, history = simulate_epidemic_spread(G, risk_score, seed_node, rng=random.Random(7))
79
+ print(f"{risk_label}: reached {total}/{G.number_of_nodes()} nodes, peaked at step {peak_step}")
gnn/visualization_graph.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Visualization graph generator.
3
+
4
+ The 150-node graph used for the GNN's actual inference (in gnn_predict.py)
5
+ is too dense to render legibly in a browser widget. This module builds a
6
+ SMALLER graph (default 32 nodes) purely for visualization, runs the SAME
7
+ epidemic simulation logic used to train the GNN on it, and outputs 2D layout
8
+ coordinates -- so the frontend can draw real nodes and edges, colored by
9
+ whether the simulated claim "reached" them, with the hub/seed node marked.
10
+
11
+ This is not a separate model -- it's the same simulate_epidemic_spread()
12
+ function used during training (gnn/simulate_spread.py), run live on a
13
+ claim's actual risk score, so what judges see on screen is a real,
14
+ claim-specific simulation, not a canned animation.
15
+ """
16
+
17
+ import random
18
+ import networkx as nx
19
+
20
+ try:
21
+ from .graph_utils import generate_social_graph, top_hub_nodes
22
+ from .simulate_spread import simulate_epidemic_spread
23
+ from .claim_features import VERDICT_RISK, sensational_score
24
+ except ImportError: # pragma: no cover - allows running as a plain script
25
+ from graph_utils import generate_social_graph, top_hub_nodes
26
+ from simulate_spread import simulate_epidemic_spread
27
+ from claim_features import VERDICT_RISK, sensational_score
28
+
29
+ VIZ_NUM_NODES = 32
30
+ VIZ_M = 2
31
+ VIZ_SEED = 7 # fixed layout so the graph shape looks the same across requests
32
+
33
+
34
+ def _compute_risk_score(claim_text: str, verdict: str, confidence: float, entities: list[dict]) -> float:
35
+ """Same risk-scoring logic used elsewhere -- false/sensational claims spread further."""
36
+ verdict_risk = VERDICT_RISK.get(verdict, 0.3)
37
+ sensational = sensational_score(claim_text)
38
+ entity_density = min(len(entities or []) / 5, 1.0)
39
+ risk_score = 0.55 * verdict_risk + 0.25 * sensational + 0.20 * entity_density
40
+ return min(risk_score, 1.0)
41
+
42
+
43
+ def generate_visualization_graph(claim_text: str, verdict: str, confidence: float, entities: list[dict]) -> dict:
44
+ """
45
+ Returns a JSON-serializable structure:
46
+ {
47
+ "nodes": [{"id": 0, "x": 0.42, "y": 0.71, "infected": true, "is_hub": false, "is_seed": true}, ...],
48
+ "edges": [{"source": 0, "target": 4}, ...],
49
+ "infected_count": 14,
50
+ "total_count": 32,
51
+ }
52
+ x/y are normalized to [0, 1] so the frontend can scale them to any SVG viewBox.
53
+ """
54
+ G = generate_social_graph(num_nodes=VIZ_NUM_NODES, m=VIZ_M, seed=VIZ_SEED)
55
+
56
+ seed_node = top_hub_nodes(G, k=1)[0]
57
+ hub_nodes = set(top_hub_nodes(G, k=3))
58
+
59
+ risk_score = _compute_risk_score(claim_text, verdict, confidence, entities)
60
+ # Use a seeded RNG so re-running the same claim gives a stable, reproducible
61
+ # visualization instead of a different random result every request.
62
+ rng_seed = abs(hash(claim_text)) % (2**31)
63
+ rng = random.Random(rng_seed)
64
+
65
+ _total_reached, _peak_step, _history, infected_set = simulate_epidemic_spread(
66
+ G, risk_score, seed_node, max_steps=15, rng=rng, return_set=True
67
+ )
68
+
69
+ # Spring layout gives a natural "social network" look -- connected nodes
70
+ # cluster together, hubs end up visually central.
71
+ positions = nx.spring_layout(G, seed=VIZ_SEED, k=0.6)
72
+
73
+ # Normalize all coordinates to [0, 1] for easy frontend scaling
74
+ xs = [p[0] for p in positions.values()]
75
+ ys = [p[1] for p in positions.values()]
76
+ x_min, x_max = min(xs), max(xs)
77
+ y_min, y_max = min(ys), max(ys)
78
+ x_range = (x_max - x_min) or 1
79
+ y_range = (y_max - y_min) or 1
80
+
81
+ nodes = []
82
+ for node_id in G.nodes():
83
+ x, y = positions[node_id]
84
+ nodes.append({
85
+ "id": int(node_id),
86
+ "x": round(float((x - x_min) / x_range), 4),
87
+ "y": round(float((y - y_min) / y_range), 4),
88
+ "infected": node_id in infected_set,
89
+ "is_hub": node_id in hub_nodes,
90
+ "is_seed": node_id == seed_node,
91
+ })
92
+
93
+ edges = [{"source": int(u), "target": int(v)} for u, v in G.edges()]
94
+
95
+ return {
96
+ "nodes": nodes,
97
+ "edges": edges,
98
+ "infected_count": len(infected_set),
99
+ "total_count": G.number_of_nodes(),
100
+ }
101
+
102
+
103
+ if __name__ == "__main__":
104
+ # Quick manual test -- run: python gnn/visualization_graph.py
105
+ result = generate_visualization_graph(
106
+ "Garlic cures COVID-19 instantly, doctors hate this secret!",
107
+ "False", 92, [{"text": "garlic"}, {"text": "COVID-19"}],
108
+ )
109
+ print(f"Nodes: {len(result['nodes'])}, Edges: {len(result['edges'])}")
110
+ print(f"Infected: {result['infected_count']}/{result['total_count']}")
111
+ print(f"Sample node: {result['nodes'][0]}")
112
+
113
+ result2 = generate_visualization_graph(
114
+ "Regular exercise is good for your heart",
115
+ "True", 88, [{"text": "exercise"}, {"text": "heart"}],
116
+ )
117
+ print(f"\nTrue/neutral claim infected: {result2['infected_count']}/{result2['total_count']}")
static/js/main.js CHANGED
@@ -16,6 +16,14 @@ function verdictColorClasses(verdict) {
16
  }
17
  }
18
 
 
 
 
 
 
 
 
 
19
  // ---------- Emergency Help page ----------
20
 
21
  const useGpsBtn = document.getElementById("useGpsBtn");
@@ -211,59 +219,81 @@ if (checkerForm) {
211
  const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
212
 
213
  function mapSpeechLanguage(code) {
214
- const langMap = { en: "en-US", hi: "hi-IN", mr: "mr-IN", es: "es-ES" };
215
  return langMap[code] || "en-US";
216
  }
217
 
218
  if (voiceBtn && SpeechRecognitionAPI) {
219
- const recognition = new SpeechRecognitionAPI();
220
- recognition.continuous = false;
221
- recognition.interimResults = false;
222
- recognition.maxAlternatives = 1;
223
  let isListening = false;
224
 
225
  const setListeningState = (listening) => {
226
  isListening = listening;
227
  voiceBtn.classList.toggle("text-red-600", listening);
228
- voiceLabel.textContent = listening ? "Listening..." : "Speak";
229
  };
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  voiceBtn.addEventListener("click", () => {
232
- if (isListening) {
233
  recognition.stop();
234
  return;
235
  }
236
 
237
- const langSelect = checkerForm.querySelector("select[name='language']");
238
- recognition.lang = mapSpeechLanguage(langSelect ? langSelect.value : "en");
239
-
240
  try {
241
  recognition.start();
242
  setListeningState(true);
243
  } catch (err) {
 
244
  setListeningState(false);
245
  }
246
  });
247
-
248
- recognition.addEventListener("result", (event) => {
249
- const transcript = Array.from(event.results)
250
- .map((result) => result[0]?.transcript || "")
251
- .join(" ")
252
- .trim();
253
-
254
- if (transcript) {
255
- claimTextarea.value = (claimTextarea.value ? `${claimTextarea.value} ${transcript}`.trim() : transcript);
256
- }
257
- });
258
-
259
- recognition.addEventListener("end", () => {
260
- setListeningState(false);
261
- });
262
-
263
- recognition.addEventListener("error", (event) => {
264
- console.warn("Speech recognition error:", event.error);
265
- setListeningState(false);
266
- });
267
  } else if (voiceBtn) {
268
  // Browser doesn't support Speech Recognition (e.g. Firefox) -- hide gracefully
269
  voiceBtn.style.display = "none";
@@ -314,18 +344,29 @@ function renderCheckerResult(data) {
314
 
315
  // If the claim came from an uploaded screenshot, show the OCR'd text so
316
  // the user can confirm it was read correctly before trusting the verdict.
317
- let ocrNote = "";
318
- const existingOcrNote = document.getElementById("ocrExtractedNote");
319
- if (existingOcrNote) existingOcrNote.remove();
320
- if (data.ocr_used && data.claim_text_used) {
321
- ocrNote = document.createElement("div");
322
- ocrNote.id = "ocrExtractedNote";
323
- ocrNote.className = "text-[11px] font-semibold text-slate-500 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 mb-1";
324
- ocrNote.innerHTML = `<i class="fa-solid fa-text-height mr-1"></i> Text read from image: "${escapeHtml(data.claim_text_used)}"`;
325
- verdictBadge.parentElement.insertBefore(ocrNote, verdictBadge);
 
 
 
 
 
 
 
 
 
 
 
326
  }
327
 
328
- document.getElementById("verdictLabel").textContent = data.verdict || "Unverified";
329
  document.getElementById("explanationText").textContent = data.explanation || "";
330
 
331
  const confidence = Number(data.confidence) || 0;
@@ -357,19 +398,17 @@ function renderCheckerResult(data) {
357
  sourceContainer.innerHTML = `<span class="text-xs text-slate-400">No sources returned.</span>`;
358
  }
359
 
360
- document.getElementById("shareVerdict").textContent = `Verdict: ${data.verdict || "Unverified"}`;
 
 
 
361
  document.getElementById("shareExplanation").textContent = data.explanation || "";
362
 
363
  // ---- Text-to-speech: read the verdict + explanation aloud ----
364
  const speakBtn = document.getElementById("speakResultBtn");
365
  const speakLabel = document.getElementById("speakResultLabel");
366
  if (speakBtn && "speechSynthesis" in window) {
367
- const SPEECH_LANG_MAP = {
368
- en: "en-US",
369
- hi: "hi-IN",
370
- mr: "mr-IN",
371
- es: "es-ES",
372
- };
373
 
374
  let speakNote = document.getElementById("speakVoiceNote");
375
  if (!speakNote && speakBtn.parentElement) {
@@ -379,10 +418,6 @@ function renderCheckerResult(data) {
379
  speakBtn.parentElement.appendChild(speakNote);
380
  }
381
 
382
- // Chrome loads its voice list asynchronously. Calling
383
- // getVoices() too early (e.g. the first time it's ever called on
384
- // a page) can return an empty array even though voices exist --
385
- // this waits for the real list instead of assuming it's ready.
386
  function loadVoicesOnce() {
387
  return new Promise((resolve) => {
388
  const existing = window.speechSynthesis.getVoices();
@@ -393,51 +428,86 @@ function renderCheckerResult(data) {
393
  window.speechSynthesis.onvoiceschanged = () => {
394
  resolve(window.speechSynthesis.getVoices());
395
  };
396
- // Safety timeout in case the event never fires on some browsers
397
  setTimeout(() => resolve(window.speechSynthesis.getVoices()), 1000);
398
  });
399
  }
400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  speakBtn.onclick = async () => {
 
402
  if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
403
  window.speechSynthesis.cancel();
404
  speakLabel.textContent = "Listen";
405
  return;
406
  }
 
 
 
 
 
407
 
408
- const availableVoices = await loadVoicesOnce();
409
-
410
- const utterance = new SpeechSynthesisUtterance(
411
- `Verdict: ${data.verdict}. ${data.explanation || ""}`
412
- );
413
- utterance.rate = 0.95;
414
 
415
- const targetLangCode = SPEECH_LANG_MAP[data.language_processed] || "en-US";
416
  let chosenVoice = availableVoices.find(v => v.lang === targetLangCode);
417
-
418
- if (!chosenVoice && data.language_processed === "mr") {
419
  chosenVoice = availableVoices.find(v => v.lang === "hi-IN");
420
  }
421
 
 
 
 
 
 
 
 
 
 
422
  if (chosenVoice) {
423
  utterance.voice = chosenVoice;
424
  utterance.lang = chosenVoice.lang;
 
425
  } else {
426
  utterance.lang = "en-US";
427
  }
428
 
429
- if (speakNote) {
430
- if (!chosenVoice) {
431
- speakNote.textContent = `No voice available for this language on your device -- using default.`;
432
- speakNote.classList.remove("hidden");
433
- } else if (chosenVoice.lang !== targetLangCode) {
434
- speakNote.textContent = `No Marathi voice found on this device -- using the closest available (Hindi) voice instead.`;
435
- speakNote.classList.remove("hidden");
436
- } else {
437
- speakNote.classList.add("hidden");
438
- }
439
- }
440
-
441
  utterance.onstart = () => { speakLabel.textContent = "Stop"; };
442
  utterance.onend = () => { speakLabel.textContent = "Listen"; };
443
  utterance.onerror = () => { speakLabel.textContent = "Listen"; };
@@ -478,15 +548,19 @@ async function runPredictionPipeline() {
478
  if (!res.ok) throw new Error(data.error || "Prediction failed.");
479
 
480
  const riskColor = data.risk_level === "High Risk" ? "text-red-400" : (data.risk_level === "Medium Risk" ? "text-amber-400" : "text-emerald-400");
481
- const breakdown = data.signal_breakdown || {};
 
 
 
 
482
 
483
  panel.innerHTML = `
484
- <div class="text-[10px] uppercase tracking-wider font-bold text-blue-400 mb-1 flex items-center gap-1.5">
485
- <i class="fa-solid fa-diagram-project"></i> Heuristic graph + language analysis -- not a trained model
486
  </div>
487
  <div class="grid grid-cols-2 gap-4">
488
  <div class="bg-slate-800/60 rounded-xl p-4">
489
- <span class="text-[10px] uppercase font-bold text-slate-400">Risk Score</span>
490
  <div class="text-3xl font-black mt-1">${escapeHtml(data.virality_score)}<span class="text-sm text-slate-500">/100</span></div>
491
  </div>
492
  <div class="bg-slate-800/60 rounded-xl p-4">
@@ -494,8 +568,8 @@ async function runPredictionPipeline() {
494
  <div class="text-xl font-black mt-1 ${riskColor}">${escapeHtml(data.risk_level)}</div>
495
  </div>
496
  <div class="bg-slate-800/60 rounded-xl p-4">
497
- <span class="text-[10px] uppercase font-bold text-slate-400">Estimated Reach (order of magnitude)</span>
498
- <div class="text-xl font-black mt-1">${escapeHtml(data.reach_estimate_bucket)}</div>
499
  </div>
500
  <div class="bg-slate-800/60 rounded-xl p-4">
501
  <span class="text-[10px] uppercase font-bold text-slate-400">Est. Time To Peak</span>
@@ -503,22 +577,78 @@ async function runPredictionPipeline() {
503
  </div>
504
  </div>
505
  <div class="bg-slate-800/60 rounded-xl p-4">
506
- <span class="text-[10px] uppercase font-bold text-slate-400 block mb-2">Signal Breakdown</span>
507
- <div class="flex flex-col gap-2 text-xs">
508
- <div class="flex justify-between"><span class="text-slate-300">Matches known misinformation pattern</span><span class="font-bold">${escapeHtml(breakdown.misinformation_pattern_match ?? "-")}</span></div>
509
- <div class="flex justify-between"><span class="text-slate-300">Sensational language score</span><span class="font-bold">${escapeHtml(breakdown.sensational_language_score ?? "-")}</span></div>
510
- <div class="flex justify-between"><span class="text-slate-300">Entity graph embeddedness</span><span class="font-bold">${escapeHtml(breakdown.entity_embeddedness_score ?? "-")}</span></div>
511
  </div>
512
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
  <div class="text-[10px] text-slate-500 leading-relaxed px-1">
514
- ${escapeHtml(data.methodology || "")}
515
  </div>
516
  `;
 
 
 
 
517
  } catch (err) {
518
  panel.innerHTML = `<div class="m-auto text-center text-red-400 font-bold text-xs">${escapeHtml(err.message)}</div>`;
519
  }
520
  }
521
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  // ---------- Passport page ----------
523
 
524
  const passportForm = document.getElementById("passportForm");
@@ -778,7 +908,7 @@ if (trendingContainer) {
778
  return `
779
  <div class="bg-white p-4 rounded-xl border ${colors.badge} flex items-center justify-between gap-4">
780
  <div class="flex items-center gap-3 min-w-0">
781
- <span class="text-[10px] font-black uppercase px-2 py-1 rounded-full ${colors.badge} shrink-0">${escapeHtml(t.verdict || "Unverified")}</span>
782
  <p class="text-sm font-semibold text-slate-700 truncate">"${escapeHtml(t.claim_text || "")}"</p>
783
  </div>
784
  <span class="text-xs font-bold text-slate-400 shrink-0">${escapeHtml(t.check_count)}x checked</span>
@@ -809,7 +939,7 @@ if (latestAlertsContainer) {
809
  const colors = verdictColorClasses(h.verdict);
810
  return `
811
  <div class="bg-white p-5 border rounded-2xl shadow-sm ${colors.badge}">
812
- <span class="text-xs font-black uppercase tracking-wide">${escapeHtml(h.verdict || "Unverified")}</span>
813
  <p class="text-sm font-bold text-slate-800 mt-2 leading-snug">"${escapeHtml((h.claim_text || "").slice(0, 90))}${(h.claim_text || "").length > 90 ? "..." : ""}"</p>
814
  <p class="text-xs text-slate-400 mt-2">${escapeHtml(h.timestamp || "")}</p>
815
  </div>`;
@@ -820,6 +950,47 @@ if (latestAlertsContainer) {
820
  });
821
  }
822
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
823
  function escapeHtml(str) {
824
  if (str === null || str === undefined) return "";
825
  return String(str)
 
16
  }
17
  }
18
 
19
+ // The backend/LLM still uses "Unverified" internally (verdict logic, DB
20
+ // storage, colors above all key off it) -- this only relabels the TEXT
21
+ // shown to the user, since "Unverified" reads poorly in a results card.
22
+ function displayVerdictLabel(verdict) {
23
+ if ((verdict || "").toLowerCase() === "unverified") return "Not Reliable";
24
+ return verdict || "Not Reliable";
25
+ }
26
+
27
  // ---------- Emergency Help page ----------
28
 
29
  const useGpsBtn = document.getElementById("useGpsBtn");
 
219
  const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
220
 
221
  function mapSpeechLanguage(code) {
222
+ const langMap = { en: "en-US", hi: "hi-IN", mr: "mr-IN" };
223
  return langMap[code] || "en-US";
224
  }
225
 
226
  if (voiceBtn && SpeechRecognitionAPI) {
227
+ let recognition = null;
 
 
 
228
  let isListening = false;
229
 
230
  const setListeningState = (listening) => {
231
  isListening = listening;
232
  voiceBtn.classList.toggle("text-red-600", listening);
233
+ voiceLabel.textContent = listening ? "Listening... (tap to stop)" : "Speak";
234
  };
235
 
236
+ function createRecognition() {
237
+ // IMPORTANT: create a NEW instance every time instead of reusing
238
+ // one long-lived object. Reusing the same SpeechRecognition
239
+ // instance across multiple start/stop cycles is a known source
240
+ // of silent failures in Chrome (later attempts stop firing
241
+ // 'result' events even though the mic is active) -- this was
242
+ // the root cause of "mic keeps stopping and not writing text".
243
+ const instance = new SpeechRecognitionAPI();
244
+
245
+ // continuous=true so it keeps listening through natural pauses
246
+ // in speech instead of auto-stopping after 1-2 seconds of
247
+ // silence (which is what continuous=false was doing, and is
248
+ // why it felt like it kept cutting out).
249
+ instance.continuous = true;
250
+ instance.interimResults = false;
251
+ instance.maxAlternatives = 1;
252
+
253
+ const langSelect = checkerForm.querySelector("select[name='language']");
254
+ instance.lang = mapSpeechLanguage(langSelect ? langSelect.value : "en");
255
+
256
+ instance.addEventListener("result", (event) => {
257
+ // With interimResults=false, every result here is final.
258
+ // Only append the LATEST result, not the whole history,
259
+ // to avoid duplicating text on each new phrase.
260
+ const latest = event.results[event.results.length - 1];
261
+ const transcript = latest[0]?.transcript?.trim();
262
+ if (transcript) {
263
+ claimTextarea.value = (claimTextarea.value ? `${claimTextarea.value} ${transcript}`.trim() : transcript);
264
+ }
265
+ });
266
+
267
+ instance.addEventListener("end", () => {
268
+ setListeningState(false);
269
+ });
270
+
271
+ instance.addEventListener("error", (event) => {
272
+ console.warn("Speech recognition error:", event.error);
273
+ setListeningState(false);
274
+ if (event.error === "not-allowed" || event.error === "service-not-allowed") {
275
+ alert("Microphone access was blocked. Please allow microphone permission for this site and try again.");
276
+ }
277
+ });
278
+
279
+ return instance;
280
+ }
281
+
282
  voiceBtn.addEventListener("click", () => {
283
+ if (isListening && recognition) {
284
  recognition.stop();
285
  return;
286
  }
287
 
288
+ recognition = createRecognition();
 
 
289
  try {
290
  recognition.start();
291
  setListeningState(true);
292
  } catch (err) {
293
+ console.warn("Could not start speech recognition:", err);
294
  setListeningState(false);
295
  }
296
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  } else if (voiceBtn) {
298
  // Browser doesn't support Speech Recognition (e.g. Firefox) -- hide gracefully
299
  voiceBtn.style.display = "none";
 
344
 
345
  // If the claim came from an uploaded screenshot, show the OCR'd text so
346
  // the user can confirm it was read correctly before trusting the verdict.
347
+ // If it was translated, show that too -- this is the fastest way to
348
+ // spot whether an "Unverified" result is due to bad OCR/translation,
349
+ // vs. the claim genuinely not being covered by the evidence base.
350
+ let existingDebugNote = document.getElementById("ocrExtractedNote");
351
+ if (existingDebugNote) existingDebugNote.remove();
352
+
353
+ if (data.ocr_used || (data.raw_text_detected && data.raw_text_detected !== data.claim_text_used)) {
354
+ const debugNote = document.createElement("div");
355
+ debugNote.id = "ocrExtractedNote";
356
+ debugNote.className = "text-[11px] font-semibold text-slate-500 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 mb-1 space-y-1";
357
+
358
+ let html = "";
359
+ if (data.ocr_used) {
360
+ html += `<div><i class="fa-solid fa-text-height mr-1"></i>Text read from image: "${escapeHtml(data.raw_text_detected)}"</div>`;
361
+ }
362
+ if (data.raw_text_detected !== data.claim_text_used) {
363
+ html += `<div><i class="fa-solid fa-language mr-1"></i>Text used for verification (translated to English): "${escapeHtml(data.claim_text_used)}"</div>`;
364
+ }
365
+ debugNote.innerHTML = html;
366
+ verdictBadge.parentElement.insertBefore(debugNote, verdictBadge);
367
  }
368
 
369
+ document.getElementById("verdictLabel").textContent = displayVerdictLabel(data.verdict);
370
  document.getElementById("explanationText").textContent = data.explanation || "";
371
 
372
  const confidence = Number(data.confidence) || 0;
 
398
  sourceContainer.innerHTML = `<span class="text-xs text-slate-400">No sources returned.</span>`;
399
  }
400
 
401
+ // ---- Method Comparison: BERT vs RAG vs GNN ----
402
+ renderMethodComparison(data.method_comparison);
403
+
404
+ document.getElementById("shareVerdict").textContent = `Verdict: ${displayVerdictLabel(data.verdict)}`;
405
  document.getElementById("shareExplanation").textContent = data.explanation || "";
406
 
407
  // ---- Text-to-speech: read the verdict + explanation aloud ----
408
  const speakBtn = document.getElementById("speakResultBtn");
409
  const speakLabel = document.getElementById("speakResultLabel");
410
  if (speakBtn && "speechSynthesis" in window) {
411
+ const SPEECH_LANG_MAP = { en: "en-US", hi: "hi-IN", mr: "mr-IN" };
 
 
 
 
 
412
 
413
  let speakNote = document.getElementById("speakVoiceNote");
414
  if (!speakNote && speakBtn.parentElement) {
 
418
  speakBtn.parentElement.appendChild(speakNote);
419
  }
420
 
 
 
 
 
421
  function loadVoicesOnce() {
422
  return new Promise((resolve) => {
423
  const existing = window.speechSynthesis.getVoices();
 
428
  window.speechSynthesis.onvoiceschanged = () => {
429
  resolve(window.speechSynthesis.getVoices());
430
  };
 
431
  setTimeout(() => resolve(window.speechSynthesis.getVoices()), 1000);
432
  });
433
  }
434
 
435
+ let ttsAudio = null; // tracks a playing server-side TTS <audio>, if any
436
+
437
+ async function speakViaServerFallback(text, langCode) {
438
+ // Used when the browser has no matching voice installed for the
439
+ // selected language -- this guarantees correct-language audio
440
+ // (via gTTS server-side) instead of silently reading in English.
441
+ if (speakNote) {
442
+ speakNote.textContent = "No local voice found for this language -- generating audio online...";
443
+ speakNote.classList.remove("hidden");
444
+ }
445
+ speakLabel.textContent = "Loading...";
446
+
447
+ try {
448
+ const res = await fetch("/api/tts", {
449
+ method: "POST",
450
+ headers: { "Content-Type": "application/json" },
451
+ body: JSON.stringify({ text, lang: langCode }),
452
+ });
453
+ if (!res.ok) throw new Error("Server TTS failed.");
454
+
455
+ const blob = await res.blob();
456
+ const url = URL.createObjectURL(blob);
457
+ ttsAudio = new Audio(url);
458
+ ttsAudio.onplay = () => { speakLabel.textContent = "Stop"; };
459
+ ttsAudio.onended = () => { speakLabel.textContent = "Listen"; };
460
+ ttsAudio.onerror = () => { speakLabel.textContent = "Listen"; };
461
+ await ttsAudio.play();
462
+ } catch (err) {
463
+ console.warn("Server-side TTS fallback failed:", err);
464
+ speakLabel.textContent = "Listen";
465
+ if (speakNote) {
466
+ speakNote.textContent = "Couldn't generate audio for this language right now.";
467
+ }
468
+ }
469
+ }
470
+
471
  speakBtn.onclick = async () => {
472
+ // Stop whichever playback mode is currently active
473
  if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
474
  window.speechSynthesis.cancel();
475
  speakLabel.textContent = "Listen";
476
  return;
477
  }
478
+ if (ttsAudio && !ttsAudio.paused) {
479
+ ttsAudio.pause();
480
+ speakLabel.textContent = "Listen";
481
+ return;
482
+ }
483
 
484
+ const textToSpeak = `Verdict: ${data.verdict}. ${data.explanation || ""}`;
485
+ const langCode = data.language_processed || "en";
486
+ const targetLangCode = SPEECH_LANG_MAP[langCode] || "en-US";
 
 
 
487
 
488
+ const availableVoices = await loadVoicesOnce();
489
  let chosenVoice = availableVoices.find(v => v.lang === targetLangCode);
490
+ if (!chosenVoice && langCode === "mr") {
 
491
  chosenVoice = availableVoices.find(v => v.lang === "hi-IN");
492
  }
493
 
494
+ if (!chosenVoice && langCode !== "en") {
495
+ // No matching voice on this device -- use the server-side
496
+ // fallback instead of silently defaulting to English.
497
+ await speakViaServerFallback(textToSpeak, langCode);
498
+ return;
499
+ }
500
+
501
+ const utterance = new SpeechSynthesisUtterance(textToSpeak);
502
+ utterance.rate = 0.95;
503
  if (chosenVoice) {
504
  utterance.voice = chosenVoice;
505
  utterance.lang = chosenVoice.lang;
506
+ if (speakNote) speakNote.classList.add("hidden");
507
  } else {
508
  utterance.lang = "en-US";
509
  }
510
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  utterance.onstart = () => { speakLabel.textContent = "Stop"; };
512
  utterance.onend = () => { speakLabel.textContent = "Listen"; };
513
  utterance.onerror = () => { speakLabel.textContent = "Listen"; };
 
548
  if (!res.ok) throw new Error(data.error || "Prediction failed.");
549
 
550
  const riskColor = data.risk_level === "High Risk" ? "text-red-400" : (data.risk_level === "Medium Risk" ? "text-amber-400" : "text-emerald-400");
551
+
552
+ const engineLabel = data.is_simulated
553
+ ? `<i class="fa-solid fa-triangle-exclamation"></i> Fallback heuristic -- trained model unavailable`
554
+ : `<i class="fa-solid fa-circle-check"></i> Real trained Graph Attention Network (GAT)`;
555
+ const engineColor = data.is_simulated ? "text-amber-400" : "text-emerald-400";
556
 
557
  panel.innerHTML = `
558
+ <div class="text-[10px] uppercase tracking-wider font-bold ${engineColor} mb-1 flex items-center gap-1.5">
559
+ ${engineLabel}
560
  </div>
561
  <div class="grid grid-cols-2 gap-4">
562
  <div class="bg-slate-800/60 rounded-xl p-4">
563
+ <span class="text-[10px] uppercase font-bold text-slate-400">Virality Score</span>
564
  <div class="text-3xl font-black mt-1">${escapeHtml(data.virality_score)}<span class="text-sm text-slate-500">/100</span></div>
565
  </div>
566
  <div class="bg-slate-800/60 rounded-xl p-4">
 
568
  <div class="text-xl font-black mt-1 ${riskColor}">${escapeHtml(data.risk_level)}</div>
569
  </div>
570
  <div class="bg-slate-800/60 rounded-xl p-4">
571
+ <span class="text-[10px] uppercase font-bold text-slate-400">Predicted Nodes Reached</span>
572
+ <div class="text-xl font-black mt-1">${escapeHtml(data.predicted_nodes_reached)}</div>
573
  </div>
574
  <div class="bg-slate-800/60 rounded-xl p-4">
575
  <span class="text-[10px] uppercase font-bold text-slate-400">Est. Time To Peak</span>
 
577
  </div>
578
  </div>
579
  <div class="bg-slate-800/60 rounded-xl p-4">
580
+ <span class="text-[10px] uppercase font-bold text-slate-400 block mb-2">Vulnerable Network Hubs</span>
581
+ <div class="flex flex-wrap gap-2">
582
+ ${(data.network_hubs_vulnerable || []).map(h => `<span class="text-[11px] bg-slate-700/70 px-2.5 py-1 rounded-full">${escapeHtml(h)}</span>`).join("")}
 
 
583
  </div>
584
  </div>
585
+
586
+ <div class="bg-slate-800/60 rounded-xl p-4">
587
+ <div class="flex items-center justify-between mb-3">
588
+ <span class="text-[10px] uppercase font-bold text-slate-400"><i class="fa-solid fa-circle-nodes mr-1"></i>Simulated Network Spread (node-by-node)</span>
589
+ <span class="text-[10px] text-slate-400">${data.visualization ? `${escapeHtml(data.visualization.infected_count)}/${escapeHtml(data.visualization.total_count)} nodes reached` : ""}</span>
590
+ </div>
591
+ <div id="spreadGraphContainer" class="w-full flex justify-center"></div>
592
+ <div class="flex items-center gap-4 mt-3 text-[10px] text-slate-400">
593
+ <span class="flex items-center gap-1"><span class="w-2.5 h-2.5 rounded-full bg-red-500 inline-block"></span>Reached by claim</span>
594
+ <span class="flex items-center gap-1"><span class="w-2.5 h-2.5 rounded-full bg-slate-500 inline-block"></span>Not reached</span>
595
+ <span class="flex items-center gap-1"><span class="w-2.5 h-2.5 rounded-full bg-amber-400 inline-block"></span>Hub account</span>
596
+ <span class="flex items-center gap-1"><span class="w-2.5 h-2.5 rounded-full border-2 border-white inline-block"></span>Origin node</span>
597
+ </div>
598
+ </div>
599
+
600
  <div class="text-[10px] text-slate-500 leading-relaxed px-1">
601
+ This graph is a smaller, legible network built for visualization, but it runs the SAME epidemic simulation logic used to train the GAT -- so what you see is a real, claim-specific simulation, not a canned animation.
602
  </div>
603
  `;
604
+
605
+ if (data.visualization) {
606
+ renderSpreadGraph(data.visualization);
607
+ }
608
  } catch (err) {
609
  panel.innerHTML = `<div class="m-auto text-center text-red-400 font-bold text-xs">${escapeHtml(err.message)}</div>`;
610
  }
611
  }
612
 
613
+ function renderSpreadGraph(viz) {
614
+ const container = document.getElementById("spreadGraphContainer");
615
+ if (!container) return;
616
+
617
+ const width = 480;
618
+ const height = 320;
619
+ const padding = 24;
620
+
621
+ const scaleX = (x) => padding + x * (width - 2 * padding);
622
+ const scaleY = (y) => padding + y * (height - 2 * padding);
623
+
624
+ const nodeById = {};
625
+ viz.nodes.forEach((n) => { nodeById[n.id] = n; });
626
+
627
+ const edgeLines = viz.edges.map((e) => {
628
+ const a = nodeById[e.source];
629
+ const b = nodeById[e.target];
630
+ if (!a || !b) return "";
631
+ return `<line x1="${scaleX(a.x)}" y1="${scaleY(a.y)}" x2="${scaleX(b.x)}" y2="${scaleY(b.y)}" stroke="#334155" stroke-width="1" opacity="0.5" />`;
632
+ }).join("");
633
+
634
+ const nodeCircles = viz.nodes.map((n) => {
635
+ let fill = n.infected ? "#EF4444" : "#64748B";
636
+ if (n.is_hub) fill = n.infected ? "#F59E0B" : "#78716C";
637
+ const radius = n.is_seed ? 8 : (n.is_hub ? 6 : 4);
638
+ const stroke = n.is_seed ? `stroke="white" stroke-width="2"` : "";
639
+ return `<circle cx="${scaleX(n.x)}" cy="${scaleY(n.y)}" r="${radius}" fill="${fill}" ${stroke}>
640
+ <title>Node ${n.id}${n.is_seed ? " (origin)" : ""}${n.is_hub ? " (hub)" : ""} -- ${n.infected ? "reached" : "not reached"}</title>
641
+ </circle>`;
642
+ }).join("");
643
+
644
+ container.innerHTML = `
645
+ <svg viewBox="0 0 ${width} ${height}" class="w-full max-w-lg" style="background:transparent;">
646
+ ${edgeLines}
647
+ ${nodeCircles}
648
+ </svg>
649
+ `;
650
+ }
651
+
652
  // ---------- Passport page ----------
653
 
654
  const passportForm = document.getElementById("passportForm");
 
908
  return `
909
  <div class="bg-white p-4 rounded-xl border ${colors.badge} flex items-center justify-between gap-4">
910
  <div class="flex items-center gap-3 min-w-0">
911
+ <span class="text-[10px] font-black uppercase px-2 py-1 rounded-full ${colors.badge} shrink-0">${escapeHtml(displayVerdictLabel(t.verdict))}</span>
912
  <p class="text-sm font-semibold text-slate-700 truncate">"${escapeHtml(t.claim_text || "")}"</p>
913
  </div>
914
  <span class="text-xs font-bold text-slate-400 shrink-0">${escapeHtml(t.check_count)}x checked</span>
 
939
  const colors = verdictColorClasses(h.verdict);
940
  return `
941
  <div class="bg-white p-5 border rounded-2xl shadow-sm ${colors.badge}">
942
+ <span class="text-xs font-black uppercase tracking-wide">${escapeHtml(displayVerdictLabel(h.verdict))}</span>
943
  <p class="text-sm font-bold text-slate-800 mt-2 leading-snug">"${escapeHtml((h.claim_text || "").slice(0, 90))}${(h.claim_text || "").length > 90 ? "..." : ""}"</p>
944
  <p class="text-xs text-slate-400 mt-2">${escapeHtml(h.timestamp || "")}</p>
945
  </div>`;
 
950
  });
951
  }
952
 
953
+ function renderMethodComparison(comparison) {
954
+ const barsContainer = document.getElementById("methodBarsContainer");
955
+ const conclusionBox = document.getElementById("methodConclusion");
956
+ if (!barsContainer || !conclusionBox) return;
957
+
958
+ if (!comparison || !comparison.methods) {
959
+ barsContainer.innerHTML = `<p class="text-xs text-slate-400">Comparison data unavailable for this result.</p>`;
960
+ conclusionBox.innerHTML = "";
961
+ return;
962
+ }
963
+
964
+ const colorForMethod = {
965
+ bert: { bar: "bg-slate-400", text: "text-slate-600" },
966
+ rag: { bar: "bg-blue-500", text: "text-blue-700" },
967
+ gnn: { bar: "bg-emerald-500", text: "text-emerald-700" },
968
+ };
969
+
970
+ barsContainer.innerHTML = comparison.methods
971
+ .map((m) => {
972
+ const colors = colorForMethod[m.key] || colorForMethod.bert;
973
+ const displayLabel = m.key === "rag" ? displayVerdictLabel(m.label) : m.label;
974
+ const winnerBadge = m.is_winner
975
+ ? `<span class="ml-2 text-[10px] font-black uppercase bg-emerald-100 text-emerald-700 px-2 py-0.5 rounded-full">Most Reliable</span>`
976
+ : "";
977
+ return `
978
+ <div>
979
+ <div class="flex items-center justify-between mb-1">
980
+ <span class="text-xs font-bold ${colors.text}">${escapeHtml(m.name)}${winnerBadge}</span>
981
+ <span class="text-xs font-bold ${colors.text}">${escapeHtml(m.score)}/100 &middot; ${escapeHtml(displayLabel)}</span>
982
+ </div>
983
+ <div class="w-full bg-slate-100 h-3 rounded-full overflow-hidden">
984
+ <div class="h-full ${colors.bar} transition-all duration-700 rounded-full" style="width: ${Math.max(m.score, 3)}%"></div>
985
+ </div>
986
+ <p class="text-[11px] text-slate-400 mt-1">${escapeHtml(m.description)}</p>
987
+ </div>`;
988
+ })
989
+ .join("");
990
+
991
+ conclusionBox.innerHTML = `<i class="fa-solid fa-circle-check text-purple-500 mr-1"></i><b>Conclusion:</b> ${escapeHtml(comparison.conclusion)}`;
992
+ }
993
+
994
  function escapeHtml(str) {
995
  if (str === null || str === undefined) return "";
996
  return String(str)
templates/checker.html CHANGED
@@ -16,7 +16,6 @@
16
  <option value="en">English (Global Default)</option>
17
  <option value="hi">Hindi (हिन्दी)</option>
18
  <option value="mr">Marathi (मराठी)</option>
19
- <option value="es">Spanish (Español)</option>
20
  </select>
21
  </div>
22
 
@@ -87,6 +86,16 @@
87
  </div>
88
  </div>
89
 
 
 
 
 
 
 
 
 
 
 
90
  <!-- Shareable Graphic Card Block -->
91
  <div class="p-6 rounded-2xl bg-gradient-to-br from-slate-900 to-blue-950 text-white shadow-xl flex flex-col gap-4 relative overflow-hidden" id="shareCardGraphic">
92
  <div class="absolute right-[-20px] bottom-[-20px] opacity-10 text-9xl"><i class="fa-solid fa-user-shield"></i></div>
 
16
  <option value="en">English (Global Default)</option>
17
  <option value="hi">Hindi (हिन्दी)</option>
18
  <option value="mr">Marathi (मराठी)</option>
 
19
  </select>
20
  </div>
21
 
 
86
  </div>
87
  </div>
88
 
89
+ <!-- Method Comparison: BERT vs RAG vs GNN -->
90
+ <div id="methodComparisonSection" class="bg-white p-6 rounded-2xl border border-slate-200/80">
91
+ <h5 class="text-sm font-bold text-slate-700 mb-1"><i class="fa-solid fa-scale-balanced text-purple-500 mr-1"></i> Detection Method Comparison</h5>
92
+ <p class="text-xs text-slate-400 mb-5">How each layer of the pipeline scores this claim, on a 0-100 misinformation-danger scale.</p>
93
+
94
+ <div id="methodBarsContainer" class="flex flex-col gap-4 mb-5"></div>
95
+
96
+ <div id="methodConclusion" class="bg-purple-50 border border-purple-100 rounded-xl p-4 text-xs text-slate-700 leading-relaxed"></div>
97
+ </div>
98
+
99
  <!-- Shareable Graphic Card Block -->
100
  <div class="p-6 rounded-2xl bg-gradient-to-br from-slate-900 to-blue-950 text-white shadow-xl flex flex-col gap-4 relative overflow-hidden" id="shareCardGraphic">
101
  <div class="absolute right-[-20px] bottom-[-20px] opacity-10 text-9xl"><i class="fa-solid fa-user-shield"></i></div>