fborj commited on
Commit
7ee49a0
Β·
1 Parent(s): b721e4a

feat: Add a fact-checking module that extracts and verifies numeric and phrase claims and analyzes bias.

Browse files
check_article.py CHANGED
@@ -200,7 +200,7 @@ def get_user_input():
200
  empty_count = 0
201
  lines.append(line)
202
 
203
- return " ".join(lines).strip()
204
 
205
 
206
  def display_results(result):
@@ -427,7 +427,41 @@ def display_results(result):
427
  print(f" {word:<20} [{bar}] {weight:.3f}")
428
  print()
429
 
430
- # 7. Final Verdict
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  print("=" * 60)
432
  print(f" FINAL VERDICT: {final_verdict}")
433
  if article_date:
 
200
  empty_count = 0
201
  lines.append(line)
202
 
203
+ return "\n".join(lines).strip()
204
 
205
 
206
  def display_results(result):
 
427
  print(f" {word:<20} [{bar}] {weight:.3f}")
428
  print()
429
 
430
+ # 7. Claim Verification
431
+ claim_verification = result.get("claim_verification", [])
432
+ if claim_verification:
433
+ print("=" * 60)
434
+ print(" CLAIM VERIFICATION β€” Per-Claim Check")
435
+ print("=" * 60)
436
+ numeric = [cv for cv in claim_verification if cv.get("type") == "numeric"]
437
+ phrase = [cv for cv in claim_verification if cv.get("type") == "phrase"]
438
+ VERDICT_ICONS = {"CONFIRMED": "βœ…", "UNVERIFIED": "⚠️ ", "CONTRADICTED": "❌"}
439
+
440
+ if numeric:
441
+ print(f" Numeric claims ({len(numeric)}):\n")
442
+ for cv in numeric:
443
+ icon = VERDICT_ICONS.get(cv["verdict"], "?")
444
+ claimed_str = f"{cv['claimed']:,}"
445
+ found_note = ""
446
+ if cv["verdict"] == "CONFIRMED" and cv.get("found"):
447
+ found_note = f" (source says: {cv['found']:,})"
448
+ elif cv["verdict"] == "CONTRADICTED" and cv.get("found"):
449
+ found_note = f" (source says: {cv['found']:,} instead)"
450
+ print(f" {icon} {cv['entity']}: claimed {claimed_str}{found_note}")
451
+ if cv.get("source"):
452
+ print(f" ↳ {cv['source'][:80]}")
453
+ print()
454
+
455
+ if phrase:
456
+ print(f" Phrase claims ({len(phrase)}):\n")
457
+ for cv in phrase:
458
+ icon = VERDICT_ICONS.get(cv["verdict"], "?")
459
+ print(f" {icon} \"{cv['claim_text']}\"")
460
+ if cv.get("source"):
461
+ print(f" ↳ {cv['source'][:80]}")
462
+ print()
463
+
464
+ # 8. Final Verdict
465
  print("=" * 60)
466
  print(f" FINAL VERDICT: {final_verdict}")
467
  if article_date:
checker/external/claim_verifier.py ADDED
@@ -0,0 +1,642 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lightweight Claim Verifier
2
+ ==============================
3
+ Two modes of claim extraction + verification:
4
+
5
+ 1. NUMERIC CLAIMS β€” "Philippines: 299 citizens", "Australia (1,300)"
6
+ Extracts entity + number pairs from list-format articles and verifies
7
+ the number against Google News search results.
8
+
9
+ 2. PHRASE CLAIMS β€” "BBM natagpuang nagshashabu", "VP arrested"
10
+ Extracts subject + predicate claims from prose and checks whether
11
+ Google News supports, contradicts, or cannot verify them.
12
+
13
+ No new models or paid APIs β€” fully reuses search_google_news().
14
+ """
15
+
16
+ import re
17
+ from checker.external.web_search import search_google_news
18
+
19
+
20
+ # ── Shared constants ──────────────────────────────────────────────────────────
21
+
22
+ # Known political/government acronyms and short names to SKIP as numeric claim entities
23
+ # (they're real names but make bad "amount" entities like "BBM 31" or "PRRD 5219")
24
+ _SKIP_ENTITIES = {
25
+ # Philippine political figures
26
+ "BBM",
27
+ "PRRD",
28
+ "Duterte",
29
+ "Marcos",
30
+ "Sara",
31
+ "Leni",
32
+ "Robredo",
33
+ "Aquino",
34
+ "Arroyo",
35
+ "Estrada",
36
+ "Ramos",
37
+ "Lacson",
38
+ "Pacquiao",
39
+ # Government bodies
40
+ "PNP",
41
+ "AFP",
42
+ "DOH",
43
+ "DOJ",
44
+ "COMELEC",
45
+ "CHR",
46
+ "DSWD",
47
+ "CHED",
48
+ "DEPED",
49
+ "BIR",
50
+ "BSP",
51
+ "SEC",
52
+ "LTO",
53
+ "LTFRB",
54
+ "MMDA",
55
+ "NBI",
56
+ "PSA",
57
+ "OVP",
58
+ "ICC",
59
+ "EJK",
60
+ "DDS",
61
+ "SWS",
62
+ "PCSO",
63
+ "PAGCOR",
64
+ # Common English words that pass capitalization but are not entities
65
+ "They",
66
+ "Their",
67
+ "We",
68
+ "Our",
69
+ "Its",
70
+ "One",
71
+ "Two",
72
+ "Three",
73
+ "Four",
74
+ "Five",
75
+ "Six",
76
+ "Seven",
77
+ "Eight",
78
+ "Nine",
79
+ "Ten",
80
+ "While",
81
+ "After",
82
+ "Before",
83
+ "During",
84
+ "Despite",
85
+ "When",
86
+ "Yes",
87
+ "No",
88
+ "Some",
89
+ "But",
90
+ "For",
91
+ "And",
92
+ "The",
93
+ "With",
94
+ "From",
95
+ "Also",
96
+ "Just",
97
+ "Over",
98
+ "Into",
99
+ "Being",
100
+ # Month / day names (e.g. "March 9" should not become March:9)
101
+ "January",
102
+ "February",
103
+ "March",
104
+ "April",
105
+ "May",
106
+ "June",
107
+ "July",
108
+ "August",
109
+ "September",
110
+ "October",
111
+ "November",
112
+ "December",
113
+ "Monday",
114
+ "Tuesday",
115
+ "Wednesday",
116
+ "Thursday",
117
+ "Friday",
118
+ "Saturday",
119
+ "Sunday",
120
+ # Action/gerund words occasionally capitalized
121
+ "Chartering",
122
+ "Carrying",
123
+ "Bringing",
124
+ "Taking",
125
+ "Sending",
126
+ # Flight / registry prefixes
127
+ "PR",
128
+ "RP",
129
+ "VP",
130
+ "UN",
131
+ "US",
132
+ "EU",
133
+ "UK",
134
+ "PH",
135
+ }
136
+
137
+ # Minimum character length for a valid numeric-claim entity
138
+ _MIN_ENTITY_LEN = 4
139
+
140
+ # Filler stop words for search query building
141
+ _STOP = {
142
+ "the",
143
+ "and",
144
+ "for",
145
+ "that",
146
+ "this",
147
+ "with",
148
+ "from",
149
+ "are",
150
+ "was",
151
+ "were",
152
+ "been",
153
+ "has",
154
+ "have",
155
+ "had",
156
+ "but",
157
+ "not",
158
+ "its",
159
+ "his",
160
+ "her",
161
+ "they",
162
+ "them",
163
+ "will",
164
+ "would",
165
+ "could",
166
+ "should",
167
+ "may",
168
+ "can",
169
+ "who",
170
+ "which",
171
+ "what",
172
+ "when",
173
+ "where",
174
+ "how",
175
+ "all",
176
+ "each",
177
+ "than",
178
+ "then",
179
+ "too",
180
+ "very",
181
+ "just",
182
+ "about",
183
+ "over",
184
+ "after",
185
+ "before",
186
+ "also",
187
+ "into",
188
+ "out",
189
+ "being",
190
+ "any",
191
+ "more",
192
+ "some",
193
+ "only",
194
+ "other",
195
+ "now",
196
+ "our",
197
+ "your",
198
+ "their",
199
+ "said",
200
+ "says",
201
+ "Yes",
202
+ "No",
203
+ "Some",
204
+ }
205
+
206
+
207
+ # ── Numeric claim extraction ──────────────────────────────────────────────────
208
+
209
+ _NUMBER_RE = re.compile(
210
+ r"(?:"
211
+ r"(?P<k>\d[\d,]*)\s*[kK]" # e.g. "10k" or "10K"
212
+ r"|"
213
+ r"(?P<n>\d[\d,]*)" # e.g. "1,300" or "299" β€” must start with a digit
214
+ r")"
215
+ )
216
+
217
+ _ENTITY_RE = re.compile(r"\b([A-Z][a-zA-Z]+(?: [A-Z][a-zA-Z]+)*)\b")
218
+
219
+ # Minimum character length for a valid numeric-claim entity
220
+ _MIN_ENTITY_LEN = 4
221
+
222
+ # Currency symbol characters β€” numbers immediately after these are costs, not counts
223
+ _CURRENCY_CHARS = "β‚±$€£Β₯"
224
+
225
+ # Regex to detect whether text looks like a numbered list (at least 3 list items)
226
+ _LIST_MARKER_RE = re.compile(r"""(?m)^\s*\d+[.):]\s+[A-Z]""", re.MULTILINE)
227
+
228
+
229
+ def _is_list_article(text: str) -> bool:
230
+ """Return True when text contains β‰₯3 numbered list items β€” a sign it's a structured list."""
231
+ return len(_LIST_MARKER_RE.findall(text)) >= 3
232
+
233
+
234
+ # Regex to strip leading list markers like "1." "2)" "12." from a line
235
+ _LIST_PREFIX_RE = re.compile(r"^\d+[.):]\s*")
236
+
237
+
238
+ def _parse_number(match) -> int | None:
239
+ """Convert a _NUMBER_RE match to int. Returns None on bad input."""
240
+ if match.group("k"):
241
+ raw = match.group("k").replace(",", "")
242
+ else:
243
+ raw = (match.group("n") or "").replace(",", "")
244
+ if not raw.isdigit():
245
+ return None
246
+ return int(raw) * (1000 if match.group("k") else 1)
247
+
248
+
249
+ # Numbers that are clearly not counts
250
+ _MAX_PLAUSIBLE_COUNT = 10_000_000
251
+ _MIN_PLAUSIBLE_COUNT = 2
252
+
253
+
254
+ def extract_numeric_claims(text: str) -> list[dict]:
255
+ """Extract (entity, number) factual claims from list-format articles.
256
+
257
+ Only runs on list-format text (detected by numbered markers like 1. 2. 3.).
258
+ For prose articles, returns [] immediately to avoid false positives.
259
+
260
+ Key filters applied:
261
+ - Strips list-marker prefixes ("1.", "2)") so they aren't counted as numbers
262
+ - Skips numbers preceded by currency symbols (β‚±, $, €)
263
+ - Skips numbers from "N-day" / "N-year" patterns
264
+ - Skips 4-digit non-comma numbers (serial/flight codes)
265
+ - Skips years (1800-2100)
266
+ - Tight proximity (≀ 80 chars) between entity and number
267
+
268
+ Returns list of {"entity", "number", "context"} dicts.
269
+ """
270
+ # Only process list-format articles
271
+ if not _is_list_article(text):
272
+ return []
273
+
274
+ claims: list[dict] = []
275
+ seen: set[tuple] = set()
276
+
277
+ for raw_line in text.splitlines():
278
+ line = raw_line.strip()
279
+ if not line:
280
+ continue
281
+ # Strip leading list marker so "2. Bangladesh" doesn't yield number 2
282
+ line = _LIST_PREFIX_RE.sub("", line)
283
+
284
+ for num_match in _NUMBER_RE.finditer(line):
285
+ number = _parse_number(num_match)
286
+ if number is None:
287
+ continue
288
+ # Skip implausible counts
289
+ if number < _MIN_PLAUSIBLE_COUNT or number > _MAX_PLAUSIBLE_COUNT:
290
+ continue
291
+ # Skip years
292
+ if 1800 <= number <= 2100:
293
+ continue
294
+ # Skip 4-digit numbers without commas (likely serial/flight codes)
295
+ if 1000 <= number <= 9999 and "," not in num_match.group():
296
+ continue
297
+ # Skip numbers immediately preceded by a currency symbol
298
+ num_start = num_match.start()
299
+ if num_start > 0 and line[num_start - 1] in _CURRENCY_CHARS:
300
+ continue
301
+ # Skip numbers that are part of "N-day", "N-year", "N-hour" patterns
302
+ num_end = num_match.end()
303
+ if num_end < len(line) and line[num_end] == "-":
304
+ continue
305
+
306
+ num_pos = num_match.start()
307
+ best_entity = None
308
+ best_dist = float("inf")
309
+
310
+ for ent_match in _ENTITY_RE.finditer(line):
311
+ entity = ent_match.group(1)
312
+ if entity in _SKIP_ENTITIES or len(entity) < _MIN_ENTITY_LEN:
313
+ continue
314
+ # Skip all-caps single words that look like acronyms
315
+ if entity.isupper() and len(entity) <= 6:
316
+ continue
317
+ # Skip multi-word entities where any component is a skip word
318
+ if any(w in _SKIP_ENTITIES for w in entity.split()):
319
+ continue
320
+ dist = abs(ent_match.start() - num_pos)
321
+ if dist < best_dist:
322
+ best_dist = dist
323
+ best_entity = entity
324
+
325
+ # Tighter proximity (80 chars) to avoid cross-item contamination
326
+ if best_entity and best_dist <= 80:
327
+ key = (best_entity.lower(), number)
328
+ if key not in seen:
329
+ seen.add(key)
330
+ claims.append(
331
+ {"entity": best_entity, "number": number, "context": line}
332
+ )
333
+
334
+ return claims
335
+
336
+
337
+ # ── Phrase claim extraction ───────────────────────────────────────────────────
338
+
339
+ # Subject indicators for phrase claims: names/proper nouns before an action verb
340
+ # e.g. "BBM natagpuan", "VP arrested", "Marcos nagresign"
341
+ _PHRASE_SUBJECT_RE = re.compile(
342
+ r"(?:"
343
+ r"([A-Z][a-zA-Z]{2,}(?:\s+[A-Z][a-zA-Z]{2,})?)" # Proper name, e.g. "Claire Castro"
344
+ r"|"
345
+ r"([A-Z]{2,6})" # Acronym subject, e.g. "BBM", "VP", "PRRD"
346
+ r")"
347
+ )
348
+
349
+ # Action/predicate keywords that signal a factual claim worth verifying
350
+ _PREDICATE_SIGNALS = [
351
+ # Filipino
352
+ "natagpuan",
353
+ "nahuli",
354
+ "napatay",
355
+ "naaksidente",
356
+ "nagresign",
357
+ "nagbitiw",
358
+ "naaresto",
359
+ "inaresto",
360
+ "nakulong",
361
+ "naiwanan",
362
+ "nahulog",
363
+ "nadiskubre",
364
+ "nag-amin",
365
+ "umamin",
366
+ "sinabi",
367
+ "idineklara",
368
+ "inihayag",
369
+ "ipinag-utos",
370
+ "nagpunta",
371
+ "pumunta",
372
+ "namatay",
373
+ "nasugatan",
374
+ "nabiktima",
375
+ "nagtakas",
376
+ "tumakas",
377
+ "nagnanakaw",
378
+ "nagnakaw",
379
+ "nagshabu",
380
+ "nagshashabu",
381
+ "sumusuporta",
382
+ "sumuporta",
383
+ "nagpapatay",
384
+ "nagpapatunay",
385
+ "nagpaliwanag",
386
+ "nagsabi",
387
+ "nagsalita",
388
+ "nagbitiw",
389
+ "natanggal",
390
+ "tinanggal",
391
+ # English
392
+ "arrested",
393
+ "killed",
394
+ "fired",
395
+ "resigned",
396
+ "fled",
397
+ "admitted",
398
+ "denied",
399
+ "confirmed",
400
+ "announced",
401
+ "signed",
402
+ "ordered",
403
+ "suspended",
404
+ "indicted",
405
+ "charged",
406
+ "convicted",
407
+ "acquitted",
408
+ "appointed",
409
+ "promoted",
410
+ "demoted",
411
+ "caught",
412
+ "found",
413
+ "discovered",
414
+ "revealed",
415
+ "exposed",
416
+ "accused",
417
+ ]
418
+
419
+
420
+ def extract_phrase_claims(text: str) -> list[dict]:
421
+ """Extract subject + predicate phrase claims from prose text.
422
+
423
+ Detects patterns like:
424
+ "BBM natagpuang nagshashabu"
425
+ "VP arrested at NAIA"
426
+ "Claire Castro nagresign"
427
+
428
+ Returns list of {"subject", "predicate", "claim_text", "context"} dicts.
429
+ """
430
+ claims: list[dict] = []
431
+ seen: set[str] = set()
432
+ text_lower = text.lower()
433
+
434
+ for pred in _PREDICATE_SIGNALS:
435
+ # Find all occurrences of this predicate in the text
436
+ for m in re.finditer(re.escape(pred), text_lower):
437
+ pred_pos = m.start()
438
+
439
+ # Look for a subject (proper noun or acronym) within 60 chars before the predicate
440
+ window_start = max(0, pred_pos - 60)
441
+ window = text[window_start:pred_pos]
442
+
443
+ # Find the closest proper noun/acronym to the predicate
444
+ best_subject = None
445
+ for sm in _PHRASE_SUBJECT_RE.finditer(window):
446
+ candidate = sm.group(1) or sm.group(2)
447
+ if candidate and candidate not in _STOP and len(candidate) >= 2:
448
+ best_subject = candidate # keep the last one (closest to predicate)
449
+
450
+ if best_subject:
451
+ # Get context line
452
+ line_start = text.rfind("\n", 0, pred_pos) + 1
453
+ line_end = text.find("\n", pred_pos)
454
+ context = text[
455
+ line_start : line_end if line_end != -1 else len(text)
456
+ ].strip()
457
+
458
+ # Build the claim string for display and searching
459
+ claim_text = f"{best_subject} {pred}"
460
+ key = claim_text.lower()
461
+ if key not in seen:
462
+ seen.add(key)
463
+ claims.append(
464
+ {
465
+ "subject": best_subject,
466
+ "predicate": pred,
467
+ "claim_text": claim_text,
468
+ "context": context,
469
+ }
470
+ )
471
+
472
+ return claims
473
+
474
+
475
+ # ── Verification ──────────────────────────────────────────────────────────────
476
+
477
+
478
+ def _numbers_in_text(text: str) -> list[int]:
479
+ """Pull all integer counts from a string (skips years)."""
480
+ nums = []
481
+ for m in _NUMBER_RE.finditer(text):
482
+ n = _parse_number(m)
483
+ if n is None:
484
+ continue
485
+ if 1800 <= n <= 2100:
486
+ continue
487
+ nums.append(n)
488
+ return nums
489
+
490
+
491
+ def _numbers_close(a: int, b: int, tolerance: float = 0.15) -> bool:
492
+ if max(a, b) == 0:
493
+ return True
494
+ return abs(a - b) / max(a, b) <= tolerance
495
+
496
+
497
+ def verify_numeric_claims(
498
+ claims: list[dict],
499
+ topic_context: str = "",
500
+ max_results: int = 3,
501
+ ) -> list[dict]:
502
+ """Verify numeric claims against Google News.
503
+
504
+ Returns list of {"entity", "claimed", "verdict", "found", "source"} dicts.
505
+ Verdict: "CONFIRMED" | "UNVERIFIED" | "CONTRADICTED"
506
+ """
507
+ results = []
508
+ for claim in claims:
509
+ entity = claim["entity"]
510
+ claimed = claim["number"]
511
+
512
+ context_words = [
513
+ w for w in topic_context.split() if w.lower() not in _STOP and len(w) > 2
514
+ ]
515
+ query = f"{entity} {' '.join(context_words[:6])}".strip()
516
+
517
+ articles = search_google_news(query, max_results=max_results)
518
+
519
+ verdict = "UNVERIFIED"
520
+ found_number: int | None = None
521
+ source_headline = ""
522
+
523
+ for article in articles:
524
+ headline = article.get("title", "")
525
+ all_nums = _numbers_in_text(headline)
526
+ for n in all_nums:
527
+ if _numbers_close(claimed, n):
528
+ verdict = "CONFIRMED"
529
+ found_number = n
530
+ source_headline = headline
531
+ break
532
+ elif verdict != "CONFIRMED":
533
+ verdict = "CONTRADICTED"
534
+ found_number = n
535
+ source_headline = headline
536
+ if verdict == "CONFIRMED":
537
+ break
538
+
539
+ results.append(
540
+ {
541
+ "entity": entity,
542
+ "claimed": claimed,
543
+ "verdict": verdict,
544
+ "found": found_number,
545
+ "source": source_headline,
546
+ "type": "numeric",
547
+ }
548
+ )
549
+ return results
550
+
551
+
552
+ def verify_phrase_claims(
553
+ claims: list[dict],
554
+ max_results: int = 3,
555
+ ) -> list[dict]:
556
+ """Verify phrase claims against Google News.
557
+
558
+ Searches for the subject + predicate and checks if results support the claim.
559
+ Returns list of {"claim_text", "verdict", "source"} dicts.
560
+ Verdict: "CONFIRMED" | "UNVERIFIED" | "CONTRADICTED"
561
+ """
562
+ results = []
563
+ for claim in claims:
564
+ query = claim["claim_text"]
565
+ articles = search_google_news(query, max_results=max_results)
566
+
567
+ verdict = "UNVERIFIED"
568
+ source_headline = ""
569
+
570
+ if articles:
571
+ # Simple heuristic: if ANY result title contains the subject
572
+ # AND shows up, the event has some coverage
573
+ subject_lower = claim["subject"].lower()
574
+ pred_lower = claim["predicate"].lower()
575
+
576
+ for article in articles:
577
+ title_lower = article.get("title", "").lower()
578
+ # Strong match: both subject and a related keyword appear
579
+ if subject_lower in title_lower:
580
+ # Check for contradiction signals
581
+ contradiction_signals = [
582
+ "false",
583
+ "fake",
584
+ "hindi totoo",
585
+ "walang katotohanan",
586
+ "hindi umano",
587
+ "denied",
588
+ "denies",
589
+ "not true",
590
+ "debunked",
591
+ "fact check",
592
+ "walang patunay",
593
+ ]
594
+ if any(sig in title_lower for sig in contradiction_signals):
595
+ verdict = "CONTRADICTED"
596
+ source_headline = article.get("title", "")
597
+ break
598
+ else:
599
+ verdict = "CONFIRMED"
600
+ source_headline = article.get("title", "")
601
+ break
602
+
603
+ results.append(
604
+ {
605
+ "claim_text": claim["claim_text"],
606
+ "context": claim.get("context", ""),
607
+ "verdict": verdict,
608
+ "source": source_headline,
609
+ "type": "phrase",
610
+ }
611
+ )
612
+ return results
613
+
614
+
615
+ # ── Convenience wrapper ───────────────────────────────────────────────────────
616
+
617
+
618
+ def check_claims(text: str, topic_context: str = "") -> list[dict]:
619
+ """One-shot: extract and verify both numeric and phrase claims from text.
620
+
621
+ - Numeric claims (entity + number): best for list-format articles
622
+ - Phrase claims (subject + action): best for prose / social media posts
623
+
624
+ Returns combined list of verified claim dicts.
625
+ Each dict has a "type" key: "numeric" or "phrase".
626
+ Returns [] if no claims detected.
627
+ """
628
+ all_results = []
629
+
630
+ # 1. Numeric claims (e.g. repatriation lists)
631
+ numeric_claims = extract_numeric_claims(text)
632
+ if numeric_claims:
633
+ all_results.extend(
634
+ verify_numeric_claims(numeric_claims, topic_context=topic_context)
635
+ )
636
+
637
+ # 2. Phrase claims (e.g. "BBM natagpuang nagshashabu")
638
+ phrase_claims = extract_phrase_claims(text)
639
+ if phrase_claims:
640
+ all_results.extend(verify_phrase_claims(phrase_claims))
641
+
642
+ return all_results
checker/fact_checker.py CHANGED
@@ -24,6 +24,7 @@ from checker.internal.core import (
24
  get_minilm_model,
25
  )
26
  from checker.external.core import ExternalChecker
 
27
  from scipy.sparse import hstack, csr_matrix
28
 
29
 
@@ -62,6 +63,7 @@ class FactChecker:
62
  - "article_date": str or None β€” earliest published date found
63
  - "lime_explanation": list[tuple[str, float]] β€” LIME word weights
64
  - "misinformation_type": str β€” classified type of misinformation (if any)
 
65
  """
66
  # 1. Internal check (ML model + bias)
67
  internal_result = self.internal.check(text)
@@ -94,6 +96,12 @@ class FactChecker:
94
  lime_samples = int(os.environ.get("LIME_SAMPLES", 100))
95
  lime_explanation = self.explain(text, num_samples=lime_samples)
96
 
 
 
 
 
 
 
97
  return {
98
  "internal": internal_result,
99
  "external": external_result,
@@ -108,6 +116,7 @@ class FactChecker:
108
  },
109
  "lime_explanation": lime_explanation,
110
  "misinformation_type": misinfo_type,
 
111
  }
112
 
113
  def explain(self, text, num_samples=100):
 
24
  get_minilm_model,
25
  )
26
  from checker.external.core import ExternalChecker
27
+ from checker.external.claim_verifier import check_claims
28
  from scipy.sparse import hstack, csr_matrix
29
 
30
 
 
63
  - "article_date": str or None β€” earliest published date found
64
  - "lime_explanation": list[tuple[str, float]] β€” LIME word weights
65
  - "misinformation_type": str β€” classified type of misinformation (if any)
66
+ - "claim_verification": list[dict] β€” per-claim verdicts (entity, claimed, verdict, found, source)
67
  """
68
  # 1. Internal check (ML model + bias)
69
  internal_result = self.internal.check(text)
 
96
  lime_samples = int(os.environ.get("LIME_SAMPLES", 100))
97
  lime_explanation = self.explain(text, num_samples=lime_samples)
98
 
99
+ # 8. Lightweight claim verification (regex + existing Google News search)
100
+ # Builds a topic context string from the first web result title (if any)
101
+ web_results = external_result.get("web_results", [])
102
+ topic_context = web_results[0]["title"] if web_results else ""
103
+ claim_verification = check_claims(text, topic_context=topic_context)
104
+
105
  return {
106
  "internal": internal_result,
107
  "external": external_result,
 
116
  },
117
  "lime_explanation": lime_explanation,
118
  "misinformation_type": misinfo_type,
119
+ "claim_verification": claim_verification,
120
  }
121
 
122
  def explain(self, text, num_samples=100):
checker/internal/bias_analyzer.py CHANGED
@@ -1173,6 +1173,11 @@ def _detect_obfuscation(text):
1173
  ]
1174
  if len(suspicious_caps) >= 3:
1175
  signals.append(f"excessive ALL-CAPS: {suspicious_caps[:5]}")
 
 
 
 
 
1176
 
1177
  score = explicit_score + len(signals) - (1 if explicit_score > 0 else 0)
1178
  # ^ explicit hits already added to score; subtract 1 to avoid counting their
 
1173
  ]
1174
  if len(suspicious_caps) >= 3:
1175
  signals.append(f"excessive ALL-CAPS: {suspicious_caps[:5]}")
1176
+ # Heavy ALL-CAPS (β‰₯ 8 non-acronym caps words) alone is a strong social-media
1177
+ # obfuscation signal β€” real journalism never shouts entire sentences in caps.
1178
+ # Boost explicit_score so the flag triggers even without βˆ†/leet/@ substitutions.
1179
+ if len(suspicious_caps) >= 8:
1180
+ explicit_score += 2
1181
 
1182
  score = explicit_score + len(signals) - (1 if explicit_score > 0 else 0)
1183
  # ^ explicit hits already added to score; subtract 1 to avoid counting their