Ghada-99-Ragab commited on
Commit
b6094b7
·
verified ·
1 Parent(s): d3dc7b2

Delete verifier.py

Browse files
Files changed (1) hide show
  1. verifier.py +0 -933
verifier.py DELETED
@@ -1,933 +0,0 @@
1
- """Quotation detection, evidence-based verification and source-backed correction for Quran and Hadith.
2
-
3
- This module consolidates the logic of the IslamicEval 2025 research notebook
4
- (``research/IslamicEval_Unified.ipynb``) into one pipeline:
5
-
6
- input text -> detection (1A) -> source retrieval -> verification (1B) -> evidence
7
- -> strong evidence : source-backed correction (1C)
8
- -> weak / ambiguous : human review
9
- -> no source found : reported as unsupported, never "fixed"
10
-
11
- Safety rule: a correction is only ever the exact text of a retrieved source. Nothing is generated.
12
- """
13
- from __future__ import annotations
14
-
15
- import difflib
16
- import logging
17
- import re
18
- import time
19
- import unicodedata
20
- from dataclasses import dataclass, field
21
- from typing import Dict, List, Optional, Sequence
22
-
23
- from retrieval import (
24
- SourceRetriever,
25
- content_words,
26
- normalize_for_matching,
27
- normalize_lenient,
28
- normalize_strict,
29
- tokenize,
30
- )
31
-
32
- try: # optional accelerator; identical formula (1 - distance / max_len)
33
- from rapidfuzz.distance import Levenshtein as _RapidLevenshtein
34
- except ImportError: # pragma: no cover
35
- _RapidLevenshtein = None
36
-
37
- logger = logging.getLogger(__name__)
38
-
39
- MAX_INPUT_CHARS = 20_000
40
-
41
- # --------------------------------------------------------------------------------------------------------------
42
- # Configuration (all tunable numbers live here)
43
- # --------------------------------------------------------------------------------------------------------------
44
- @dataclass
45
- class VerifierConfig:
46
- """Thresholds calibrated in Subtask 1B of the research notebook."""
47
-
48
- quran_correct_threshold: float = 0.94
49
- quran_uncertain_low: float = 0.45
50
- quran_min_coverage: float = 0.40
51
- hadith_correct_threshold: float = 0.79
52
- hadith_uncertain_low: float = 0.30
53
- hadith_min_coverage: float = 0.70
54
- quran_top_k: int = 25
55
- hadith_top_k: int = 15
56
- hadith_retrieval_guard: float = 0.20
57
-
58
-
59
- @dataclass
60
- class CorrectorConfig:
61
- """Correction is proposed only when match strength >= ``*_strong``; between ``*_low`` and ``*_strong`` the
62
- case goes to human review. Hadith is never auto-corrected (``hadith_strong`` > 1), by design: the exact
63
- fragment boundaries of a Hadith quotation cannot be reproduced reliably."""
64
-
65
- max_window: int = 8
66
- hadith_top_k: int = 40
67
- quran_strong: float = 0.65
68
- min_full_ratio: float = 0.40
69
- quran_low: float = 0.55
70
- hadith_strong: float = 1.01
71
- hadith_low: float = 0.45
72
-
73
-
74
- @dataclass
75
- class PipelineConfig:
76
- verifier: VerifierConfig = field(default_factory=VerifierConfig)
77
- corrector: CorrectorConfig = field(default_factory=CorrectorConfig)
78
- min_detection_conf: float = 0.60 # detector confidence below this -> human review
79
- verified_min_conf: float = 0.75 # "Correct" verdicts weaker than this -> human review
80
- unsupported_min_conf: float = 0.70 # "Incorrect + no source" needs this confidence to abstain confidently
81
- unsupported_strength: float = 0.35 # ...or the best source match is this weak (nothing similar exists)
82
-
83
-
84
- # --------------------------------------------------------------------------------------------------------------
85
- # Similarity signals (Subtask 1B)
86
- # --------------------------------------------------------------------------------------------------------------
87
- def _lcs_length(a: Sequence[str], b: Sequence[str]) -> int:
88
- m, n = len(a), len(b)
89
- if m == 0 or n == 0:
90
- return 0
91
- if m < n:
92
- a, b, m, n = b, a, n, m
93
- prev = [0] * (n + 1)
94
- for i in range(m):
95
- curr = [0] * (n + 1)
96
- for j in range(n):
97
- curr[j + 1] = prev[j] + 1 if a[i] == b[j] else max(curr[j], prev[j + 1])
98
- prev = curr
99
- return prev[n]
100
-
101
-
102
- def _edit_similarity(a: str, b: str, max_len: int = 600) -> float:
103
- """Normalised Levenshtein similarity in [0, 1] on the first ``max_len`` characters."""
104
- a, b = a[:max_len], b[:max_len]
105
- if a == b:
106
- return 1.0
107
- if not a or not b:
108
- return 0.0
109
- if _RapidLevenshtein is not None:
110
- return float(_RapidLevenshtein.normalized_similarity(a, b))
111
- prev = list(range(len(b) + 1))
112
- for i, ca in enumerate(a):
113
- curr = [i + 1]
114
- for j, cb in enumerate(b):
115
- curr.append(min(curr[j] + 1, prev[j + 1] + 1, prev[j] + (ca != cb)))
116
- prev = curr
117
- return 1.0 - prev[-1] / max(len(a), len(b))
118
-
119
-
120
- def _light_normalize(text: str) -> str:
121
- """Keeps diacritics (so diacritic changes lower the score) but drops punctuation and tatweel."""
122
- text = unicodedata.normalize("NFC", text)
123
- text = re.sub(r"[،؛؟!.,:;'\"()\[\]{}<>«»\-_/\\|@#$%^&*+=~`\u0640]", " ", text)
124
- return re.sub(r"\s+", " ", text).strip()
125
-
126
-
127
- def compute_signals(claim: str, candidate: str, content_type: str = "Ayah") -> Dict[str, float]:
128
- """Word-, character- and sequence-level similarity indicators between a quotation and a candidate source."""
129
- is_quran = content_type == "Ayah"
130
- normalize = normalize_strict if is_quran else normalize_lenient
131
- norm_claim, norm_cand = normalize(claim), normalize(candidate)
132
-
133
- claim_set, cand_set = set(tokenize(norm_claim)), set(tokenize(norm_cand))
134
- if claim_set and cand_set:
135
- shared = claim_set & cand_set
136
- token_overlap = len(shared) / len(claim_set | cand_set)
137
- coverage = len(shared) / len(claim_set)
138
- else:
139
- token_overlap = coverage = 0.0
140
-
141
- claim_tokens, cand_tokens = tokenize(norm_claim), tokenize(norm_cand)
142
- lcs_ratio = _lcs_length(claim_tokens, cand_tokens) / len(claim_tokens) if claim_tokens else 0.0
143
- edit_sim = _edit_similarity(norm_claim, norm_cand)
144
-
145
- claim_chars, cand_chars = set(norm_claim.replace(" ", "")), set(norm_cand.replace(" ", ""))
146
- char_overlap = len(claim_chars & cand_chars) / len(claim_chars | cand_chars) if (claim_chars or cand_chars) else 0.0
147
-
148
- claim_flat, cand_flat = norm_claim.replace(" ", ""), norm_cand.replace(" ", "")
149
- is_substring = int(bool(claim_flat) and bool(cand_flat) and (claim_flat in cand_flat or cand_flat in claim_flat))
150
-
151
- diacritic_sim = (
152
- _edit_similarity(_light_normalize(claim), _light_normalize(candidate), max_len=800) if is_quran else edit_sim
153
- )
154
- short = len(claim_tokens) < 4
155
-
156
- if is_quran:
157
- w = (
158
- dict(coverage=0.35, diacritic_sim=0.30, lcs_ratio=0.15, token_overlap=0.10, char_overlap=0.05, edit_sim=0.05)
159
- if short
160
- else dict(coverage=0.25, diacritic_sim=0.30, lcs_ratio=0.20, token_overlap=0.10, char_overlap=0.05, edit_sim=0.10)
161
- )
162
- composite = (
163
- w["coverage"] * coverage + w["diacritic_sim"] * diacritic_sim + w["lcs_ratio"] * lcs_ratio
164
- + w["token_overlap"] * token_overlap + w["char_overlap"] * char_overlap + w["edit_sim"] * edit_sim
165
- )
166
- if is_substring and diacritic_sim >= 0.60:
167
- composite = max(composite, 0.88)
168
- elif is_substring:
169
- composite = max(composite, 0.75)
170
- else:
171
- w = (
172
- dict(coverage=0.40, lcs_ratio=0.20, token_overlap=0.20, char_overlap=0.10, edit_sim=0.10)
173
- if short
174
- else dict(coverage=0.30, lcs_ratio=0.28, token_overlap=0.18, char_overlap=0.12, edit_sim=0.12)
175
- )
176
- composite = (
177
- w["coverage"] * coverage + w["lcs_ratio"] * lcs_ratio + w["token_overlap"] * token_overlap
178
- + w["char_overlap"] * char_overlap + w["edit_sim"] * edit_sim
179
- )
180
- if is_substring:
181
- composite = max(composite, 0.82)
182
-
183
- return {
184
- "token_overlap": round(token_overlap, 4),
185
- "coverage": round(coverage, 4),
186
- "lcs_ratio": round(lcs_ratio, 4),
187
- "edit_sim": round(edit_sim, 4),
188
- "diacritic_sim": round(diacritic_sim, 4),
189
- "char_overlap": round(char_overlap, 4),
190
- "is_substring": is_substring,
191
- "composite": round(composite, 4),
192
- }
193
-
194
-
195
- def best_match_score(claim: str, candidates: List[dict], content_type: str = "Ayah"):
196
- """Return ``(score, candidate_with_signals)`` for the best-scoring candidate."""
197
- best_score, best_candidate = 0.0, None
198
- for candidate in candidates:
199
- signals = compute_signals(claim, candidate.get("text", ""), content_type)
200
- if signals["composite"] > best_score:
201
- best_score, best_candidate = signals["composite"], {**candidate, "signals": signals}
202
- return best_score, best_candidate
203
-
204
-
205
- # --------------------------------------------------------------------------------------------------------------
206
- # Detection (Subtask 1A)
207
- # --------------------------------------------------------------------------------------------------------------
208
- @dataclass
209
- class DetectedSpan:
210
- start: int
211
- end: int # exclusive
212
- label: str # 'Ayah' | 'Hadith'
213
- confidence: Optional[float] # None for the rule backend
214
- source: str # 'bert' | 'rules' | 'given'
215
- text: str = ""
216
-
217
-
218
- QUOTE_CHARS = " \t\r\n\"“”«»﴿﴾{}()[]"
219
-
220
-
221
- def trim_span(text: str, start: int, end: int):
222
- """Drop whitespace and quotation marks at both edges (gold spans exclude the quote marks)."""
223
- while start < end and text[start] in QUOTE_CHARS:
224
- start += 1
225
- while end > start and text[end - 1] in QUOTE_CHARS + ".،,؛:":
226
- end -= 1
227
- return start, end
228
-
229
-
230
- AYAH_TRIGGERS = [
231
- "قال الله", "قوله تعالى", "قال تعالى", "يقول الله", "يقول تعالى", "قال سبحانه", "قوله سبحانه", "في كتابه",
232
- "سورة", "الآية", "الاية", "الآيات", "القرآن", "القران", "كتاب الله", "عز وجل", "جل جلاله", "فقال تعالى",
233
- "ذكر الله", "﴿",
234
- ]
235
- HADITH_TRIGGERS = [
236
- "رسول الله", "النبي", "صلى الله عليه وسلم", "ﷺ", "عليه الصلاة والسلام", "حديث", "رواه", "روى", "متفق عليه",
237
- "الحديث", "فقال", "قال ص", "صلى الله عليه", "وسلم",
238
- ]
239
- _FORMULA_WORDS = {
240
- normalize_for_matching(w)
241
- for w in "قال قالت رسول الله صلى عليه وسلم النبي تعالى سبحانه عز وجل فقال يقول الكريم الشريف الحديث الآية روى رواه عن أن أنه البخاري ومسلم".split()
242
- }
243
- _BRACKET_PAIRS = [("“", "”"), ("«", "»"), ("﴿", "﴾"), ("{", "}"), ("(", ")"), ("[", "]")]
244
-
245
-
246
- class RuleDetector:
247
- """Quotation-mark and trigger-phrase detector with an optional corpus lookup (no training, no GPU)."""
248
-
249
- def __init__(self, retriever: Optional[SourceRetriever] = None, min_words: int = 3, context_chars: int = 110,
250
- min_corpus_cov: float = 0.6) -> None:
251
- self.kb, self.min_words, self.context_chars, self.min_corpus_cov = retriever, min_words, context_chars, min_corpus_cov
252
-
253
- @staticmethod
254
- def _segments(text: str):
255
- segments = []
256
- quote_positions = [m.start() for m in re.finditer('"', text)]
257
- if len(quote_positions) % 2 == 0:
258
- pairs = zip(quote_positions[0::2], quote_positions[1::2]) # opening/closing pairs
259
- else: # a stray quote: fall back to every consecutive pair
260
- pairs = zip(quote_positions, quote_positions[1:])
261
- for a, b in pairs:
262
- segments.append((a + 1, b))
263
- for opener, closer in _BRACKET_PAIRS:
264
- for m in re.finditer(re.escape(opener) + r"(.*?)" + re.escape(closer), text, re.S):
265
- segments.append((m.start(1), m.end(1)))
266
- return segments
267
-
268
- @staticmethod
269
- def _trigger_type(context: str) -> Optional[str]:
270
- best_end, best_label = -1, None
271
- for label, triggers in (("Ayah", AYAH_TRIGGERS), ("Hadith", HADITH_TRIGGERS)):
272
- for trigger in triggers:
273
- pos = context.rfind(trigger)
274
- if pos >= 0 and pos + len(trigger) > best_end:
275
- best_end, best_label = pos + len(trigger), label
276
- return best_label
277
-
278
- def _corpus_coverage(self, span: str):
279
- """Highest word coverage of the span by any top Quran ayah / Hadith candidate."""
280
- if self.kb is None:
281
- return 0.0, 0.0
282
- quran_words = set(tokenize(normalize_strict(span)))
283
- if not quran_words:
284
- return 0.0, 0.0
285
- quran_cov = max(
286
- (len(quran_words & set(tokenize(normalize_strict(c["text"])))) / len(quran_words)
287
- for c in self.kb.search_quran_ayahs(span, top_k=5)),
288
- default=0.0,
289
- )
290
- hadith_words = set(tokenize(normalize_lenient(span)))
291
- hadith_cov = max(
292
- (len(hadith_words & set(tokenize(normalize_lenient(c["text"])))) / len(hadith_words)
293
- for c in self.kb.search_hadith(span, top_k=5)),
294
- default=0.0,
295
- ) if hadith_words else 0.0
296
- return quran_cov, hadith_cov
297
-
298
- def detect(self, text: str) -> List[DetectedSpan]:
299
- candidates = []
300
- for start, end in self._segments(text):
301
- start, end = trim_span(text, start, end)
302
- if end <= start:
303
- continue
304
- inner = text[start:end]
305
- words = [w for w in normalize_for_matching(inner).split() if w]
306
- if len(words) < self.min_words or len(inner) > 3000:
307
- continue
308
- if sum(w in _FORMULA_WORDS for w in words) / len(words) >= 0.6:
309
- continue
310
- trigger = self._trigger_type(text[max(0, start - self.context_chars):start])
311
- quran_cov, hadith_cov = self._corpus_coverage(inner)
312
- label = None
313
- if trigger:
314
- label = trigger
315
- other, mine = (hadith_cov, quran_cov) if trigger == "Ayah" else (quran_cov, hadith_cov)
316
- if other >= 0.8 and mine < 0.5:
317
- label = "Hadith" if trigger == "Ayah" else "Ayah"
318
- elif max(quran_cov, hadith_cov) >= self.min_corpus_cov and len(words) >= 4:
319
- label = "Ayah" if quran_cov >= hadith_cov else "Hadith"
320
- if label is None:
321
- continue
322
- candidates.append((bool(trigger), max(quran_cov, hadith_cov), end - start, start, end, label))
323
-
324
- candidates.sort(key=lambda c: (c[0], c[1], c[2]), reverse=True) # trigger first, then corpus match, then length
325
- taken = []
326
- for _, _, _, start, end, label in candidates:
327
- if all(end <= t_start or start >= t_end for t_start, t_end, _ in taken):
328
- taken.append((start, end, label))
329
- taken.sort()
330
- return [DetectedSpan(s, e, label, None, "rules", text[s:e]) for s, e, label in taken]
331
-
332
-
333
- LABEL2ID = {"O": 0, "B-Ayah": 1, "I-Ayah": 2, "B-Hadith": 3, "I-Hadith": 4}
334
- ID2LABEL = {v: k for k, v in LABEL2ID.items()}
335
-
336
-
337
- def _token_labels_to_char_spans(offsets, token_labels):
338
- spans, current = [], None
339
- for (start, end), label_id in zip(offsets, token_labels):
340
- if label_id == -100:
341
- continue
342
- name = ID2LABEL[label_id]
343
- if name == "O":
344
- if current is not None:
345
- spans.append(current)
346
- current = None
347
- continue
348
- prefix, entity = name.split("-")
349
- if prefix == "B" or current is None or current["label"] != entity:
350
- if current is not None:
351
- spans.append(current)
352
- current = {"label": entity, "start": start, "end": end}
353
- else:
354
- current["end"] = end
355
- if current is not None:
356
- spans.append(current)
357
- return spans
358
-
359
-
360
- def _merge_adjacent_spans(spans, max_gap: int = 1):
361
- if not spans:
362
- return []
363
- spans = sorted(spans, key=lambda s: s["start"])
364
- merged = [dict(spans[0])]
365
- for span in spans[1:]:
366
- last = merged[-1]
367
- if span["label"] == last["label"] and 0 <= span["start"] - last["end"] <= max_gap:
368
- last["end"] = max(last["end"], span["end"])
369
- else:
370
- merged.append(dict(span))
371
- return merged
372
-
373
-
374
- class BertDetector:
375
- """Fine-tuned token classifier (BIO tags, sliding window). Requires ``torch`` and ``transformers`` plus a
376
- trained model directory; training code lives in the research notebook."""
377
-
378
- def __init__(self, model_dir: str, device: Optional[str] = None, max_length: int = 512, stride: int = 256) -> None:
379
- import torch
380
- from transformers import AutoModelForTokenClassification, AutoTokenizer
381
-
382
- self.torch = torch
383
- self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
384
- self.tokenizer = AutoTokenizer.from_pretrained(model_dir)
385
- self.model = AutoModelForTokenClassification.from_pretrained(model_dir).to(self.device).eval()
386
- self.max_length, self.stride = max_length, stride
387
-
388
- def detect(self, text: str) -> List[DetectedSpan]:
389
- torch = self.torch
390
- if not text.strip():
391
- return []
392
- encoded = self.tokenizer(text, return_offsets_mapping=True, truncation=False, return_tensors="pt")
393
- ids = encoded["input_ids"][0]
394
- offsets = encoded["offset_mapping"][0].tolist()
395
- total, n_labels = len(ids), len(LABEL2ID)
396
- logits_sum, counts = torch.zeros(total, n_labels), torch.zeros(total)
397
- start = 0
398
- with torch.no_grad():
399
- while start < total:
400
- end = min(start + self.max_length, total)
401
- window = ids[start:end].unsqueeze(0).to(self.device)
402
- logits = self.model(input_ids=window, attention_mask=torch.ones_like(window)).logits[0].cpu()
403
- logits_sum[start:end] += logits
404
- counts[start:end] += 1
405
- if end == total:
406
- break
407
- start += self.stride
408
- logits_sum /= counts.unsqueeze(1).clamp(min=1)
409
- probs = torch.softmax(logits_sum, dim=-1)
410
- predictions = torch.argmax(logits_sum, dim=-1).tolist()
411
- predictions = [(-100 if a == b else p) for p, (a, b) in zip(predictions, offsets)] # skip special tokens
412
- spans = _merge_adjacent_spans(_token_labels_to_char_spans(offsets, predictions), max_gap=1)
413
-
414
- detected = []
415
- for span in spans:
416
- start_c, end_c = trim_span(text, span["start"], span["end"])
417
- if end_c <= start_c:
418
- continue
419
- token_idx = [i for i, (x, y) in enumerate(offsets) if y > x and x >= span["start"] and y <= span["end"]]
420
- label = "Ayah" if span["label"] == "Ayah" else "Hadith"
421
- tag_ids = [LABEL2ID[f"B-{label}"], LABEL2ID[f"I-{label}"]]
422
- confidence = float(sum(probs[i, tag_ids].sum() for i in token_idx) / len(token_idx)) if token_idx else None
423
- detected.append(DetectedSpan(start_c, end_c, label, confidence, "bert", text[start_c:end_c]))
424
- return detected
425
-
426
-
427
- def build_detector(kind: str = "rules", retriever: Optional[SourceRetriever] = None, model_dir: Optional[str] = None,
428
- rules_use_corpus: bool = False):
429
- """``kind``: 'rules' | 'bert' | 'auto' (BERT if a model directory loads, otherwise rules)."""
430
- if kind == "rules":
431
- return RuleDetector(retriever if rules_use_corpus else None)
432
- if kind == "bert":
433
- return BertDetector(model_dir)
434
- if model_dir:
435
- try:
436
- return BertDetector(model_dir)
437
- except Exception as exc:
438
- logger.warning("Could not load BERT detector from %s (%s); using rule-based detector", model_dir, exc)
439
- return RuleDetector(retriever if rules_use_corpus else None)
440
-
441
-
442
- # --------------------------------------------------------------------------------------------------------------
443
- # Verification (Subtask 1B)
444
- # --------------------------------------------------------------------------------------------------------------
445
- @dataclass
446
- class Verification:
447
- verdict: str # 'Correct' | 'Incorrect'
448
- confidence: float
449
- best_score: float
450
- method: str
451
- source: Optional[dict] = None # best matching source record (with 'signals')
452
- n_candidates: int = 0
453
- retrieval_top: float = 0.0
454
-
455
-
456
- class Verifier:
457
- """Compares a quotation with retrieved candidates and returns a verdict with its supporting evidence."""
458
-
459
- def __init__(self, retriever: SourceRetriever, config: Optional[VerifierConfig] = None) -> None:
460
- self.kb = retriever
461
- self.cfg = config or VerifierConfig()
462
-
463
- def verify(self, span_text: str, content_type: str) -> Verification:
464
- if not span_text or not span_text.strip():
465
- return self._result("Incorrect", 0.95, 0.0, None, 0, "empty_span")
466
- if content_type == "Ayah":
467
- return self._verify_quran(span_text)
468
- if content_type == "Hadith":
469
- return self._verify_hadith(span_text)
470
- return self._result("Incorrect", 0.5, 0.0, None, 0, "unknown_type")
471
-
472
- def _verify_quran(self, span: str) -> Verification:
473
- cfg = self.cfg
474
- candidates = self.kb.search_quran_ayahs(span, top_k=cfg.quran_top_k)
475
- if not candidates:
476
- return self._result("Incorrect", 0.8, 0.0, None, 0, "no_candidates")
477
- score, best = best_match_score(span, candidates, "Ayah")
478
- signals = best.get("signals", {}) if best else {}
479
- coverage, is_substring = signals.get("coverage", 0.0), signals.get("is_substring", 0)
480
- n = len(candidates)
481
-
482
- if is_substring and coverage >= cfg.quran_min_coverage:
483
- return self._result("Correct", min(0.98, 0.85 + score * 0.15), score, best, n, "substring_match")
484
- if score >= cfg.quran_correct_threshold and coverage >= cfg.quran_min_coverage:
485
- return self._result("Correct", min(0.95, 0.70 + score * 0.25), score, best, n, "threshold_pass")
486
- if score <= cfg.quran_uncertain_low:
487
- return self._result("Incorrect", min(0.95, 0.70 + (1 - score) * 0.25), score, best, n, "threshold_fail")
488
-
489
- strong = sum(
490
- 1 for cand in candidates[:10]
491
- if (s := compute_signals(span, cand.get("text", ""), "Ayah"))["coverage"] >= 0.80 and s["lcs_ratio"] >= 0.75
492
- )
493
- if strong >= 2:
494
- return self._result("Correct", 0.60 + min(0.20, strong * 0.05), score, best, n, "borderline_multi_cov")
495
- return self._result("Incorrect", 0.58, score, best, n, "borderline_default")
496
-
497
- def _verify_hadith(self, span: str) -> Verification:
498
- cfg = self.cfg
499
- candidates = self.kb.search_hadith(span, top_k=cfg.hadith_top_k)
500
- if not candidates:
501
- return self._result("Incorrect", 0.75, 0.0, None, 0, "no_candidates")
502
- top_retrieval = candidates[0].get("retrieval_score", 0.0)
503
- score, best = best_match_score(span, candidates, "Hadith")
504
- signals = best.get("signals", {}) if best else {}
505
- coverage, is_substring = signals.get("coverage", 0.0), signals.get("is_substring", 0)
506
- n = len(candidates)
507
-
508
- if is_substring and coverage >= cfg.hadith_min_coverage and top_retrieval >= cfg.hadith_retrieval_guard:
509
- return self._result("Correct", min(0.97, 0.80 + score * 0.17), score, best, n, "substring_match", top_retrieval)
510
- if score >= cfg.hadith_correct_threshold and coverage >= cfg.hadith_min_coverage:
511
- return self._result("Correct", min(0.92, 0.65 + score * 0.27), score, best, n, "threshold_pass", top_retrieval)
512
- if score <= cfg.hadith_uncertain_low:
513
- return self._result("Incorrect", min(0.90, 0.65 + (1 - score) * 0.25), score, best, n, "threshold_fail", top_retrieval)
514
-
515
- moderate = sum(
516
- 1 for cand in candidates[:8]
517
- if (s := compute_signals(span, cand.get("text", ""), "Hadith"))["coverage"] >= 0.65 and s["lcs_ratio"] >= 0.55
518
- )
519
- if moderate >= 2 and top_retrieval >= 0.30:
520
- return self._result("Correct", 0.58 + min(0.22, moderate * 0.06), score, best, n, "borderline_multi_cov", top_retrieval)
521
- if top_retrieval < 0.25 or score < 0.45:
522
- return self._result("Incorrect", 0.60, score, best, n, "borderline_low_retrieval", top_retrieval)
523
- return self._result("Incorrect", 0.55, score, best, n, "borderline_default", top_retrieval)
524
-
525
- @staticmethod
526
- def _result(verdict, confidence, score, best, n_candidates, method, top_retrieval=0.0) -> Verification:
527
- return Verification(verdict, round(confidence, 4), round(score, 4), method, best, n_candidates, round(top_retrieval, 4))
528
-
529
-
530
- _MARKER = re.compile(r"^\(\d+\)$")
531
-
532
-
533
- def compare(span_text: str, source_text: str) -> dict:
534
- """Word-level comparison between the quotation and the source (the 'evidence' view)."""
535
- span_words = span_text.split()
536
- source_words = [w for w in source_text.split() if not _MARKER.match(w)]
537
- span_norm = [normalize_for_matching(w) for w in span_words]
538
- source_norm = [normalize_for_matching(w) for w in source_words]
539
- matcher = difflib.SequenceMatcher(None, span_norm, source_norm, autojunk=False)
540
- blocks = [b for b in matcher.get_matching_blocks() if b.size > 0]
541
- if blocks and len(source_words) > 2 * len(span_words) + 10: # long source (e.g. Hadith with chain): keep matched region
542
- lo, hi = max(0, blocks[0].b - 3), min(len(source_words), blocks[-1].b + blocks[-1].size + 3)
543
- source_words, source_norm = source_words[lo:hi], source_norm[lo:hi]
544
- matcher = difflib.SequenceMatcher(None, span_norm, source_norm, autojunk=False)
545
- operations, missing, extra = [], [], []
546
- for tag, i1, i2, j1, j2 in matcher.get_opcodes():
547
- operations.append({"op": tag, "span": " ".join(span_words[i1:i2]), "source": " ".join(source_words[j1:j2])})
548
- if tag in ("delete", "replace"):
549
- extra += span_words[i1:i2]
550
- if tag in ("insert", "replace"):
551
- missing += source_words[j1:j2]
552
- return {
553
- "word_similarity": round(matcher.ratio(), 3),
554
- "word_diff": operations,
555
- "missing_from_span": missing,
556
- "extra_in_span": extra,
557
- "source_excerpt": " ".join(source_words),
558
- }
559
-
560
-
561
- # --------------------------------------------------------------------------------------------------------------
562
- # Idgham rendering (published mushaf convention used by the Subtask 1C gold corrections)
563
- # --------------------------------------------------------------------------------------------------------------
564
- _SUKUN, _SHADDA, _FATHATAN = "\u0652", "\u0651", "\u064B"
565
- _TANWEEN = set("\u064B\u064C\u064D")
566
- _IDGHAM_AFTER_NOON = set("نمرل")
567
- _IDGHAM_AFTER_LAM = set("لر")
568
- _DIACRITIC_CHARS = set(
569
- "\u0610\u0611\u0612\u0613\u0614\u0615\u0616\u0617\u0618\u0619\u061A"
570
- "\u064B\u064C\u064D\u064E\u064F\u0650\u0651\u0652\u0670"
571
- "\u06D6\u06D7\u06D8\u06D9\u06DA\u06DB\u06DC\u06DF\u06E0\u06E1\u06E2\u06E3\u06E4"
572
- "\u06E7\u06E8\u06EA\u06EB\u06EC\u06ED"
573
- )
574
-
575
-
576
- def _base_letters(word: str) -> str:
577
- return "".join(c for c in word if c not in _DIACRITIC_CHARS)
578
-
579
-
580
- def _insert_shadda(word: str) -> str:
581
- return word if not word else word[0] + _SHADDA + word[1:]
582
-
583
-
584
- def _ends_with_tanween(word: str) -> bool:
585
- if not word:
586
- return False
587
- if word[-1] in _TANWEEN:
588
- return True
589
- return len(word) >= 2 and word[-1] in "اى" and word[-2] == _FATHATAN
590
-
591
-
592
- def apply_idgham(text: str, extended: bool = True) -> str:
593
- """Convert the flat Quran text into the mushaf rendering that marks assimilation with a shadda.
594
-
595
- Covers noon sakinah / tanween before ن م ر ل, meem sakinah before م and lam sakinah before ل ر, also across
596
- ayah-number markers such as ``(20)``. The و / ي cases are excluded on purpose because the mushaf convention
597
- is inconsistent there."""
598
- words = text.split(" ")
599
- noon_set = _IDGHAM_AFTER_NOON if extended else set("نم")
600
- i = 0
601
- while i < len(words):
602
- word = words[i]
603
- if _MARKER.match(word) or not word:
604
- i += 1
605
- continue
606
- j = i + 1
607
- while j < len(words) and _MARKER.match(words[j]):
608
- j += 1
609
- if j < len(words):
610
- base = _base_letters(words[j])
611
- first = base[0] if base else ""
612
- if word.endswith("\u0646" + _SUKUN) and first in noon_set:
613
- words[i], words[j] = word[:-1], _insert_shadda(words[j])
614
- elif _ends_with_tanween(word) and first in noon_set:
615
- words[j] = _insert_shadda(words[j])
616
- elif word.endswith("\u0645" + _SUKUN) and first == "\u0645":
617
- words[i], words[j] = word[:-1], _insert_shadda(words[j])
618
- elif extended and word.endswith("\u0644" + _SUKUN) and first in _IDGHAM_AFTER_LAM:
619
- words[i], words[j] = word[:-1], _insert_shadda(words[j])
620
- i += 1
621
- return " ".join(words)
622
-
623
-
624
- # --------------------------------------------------------------------------------------------------------------
625
- # Correction (Subtask 1C): locate the true ayah window / Hadith record and return its exact text
626
- # --------------------------------------------------------------------------------------------------------------
627
- @dataclass
628
- class CorrectionMatch:
629
- kind: str # 'Ayah' | 'Hadith'
630
- strength: float # coverage (Quran) / symmetric containment (Hadith)
631
- full_ratio: float
632
- text: str # proposed correction in the official 1C format (idgham + '(n)' ayah markers)
633
- source: dict # reference metadata
634
- display: str = "" # clean human-readable version
635
-
636
-
637
- class Corrector:
638
- def __init__(self, retriever: SourceRetriever, config: Optional[CorrectorConfig] = None) -> None:
639
- self.kb = retriever
640
- self.cfg = config or CorrectorConfig()
641
-
642
- def match(self, span_text: str, content_type: str) -> Optional[CorrectionMatch]:
643
- return self.match_quran(span_text) if content_type == "Ayah" else self.match_hadith(span_text)
644
-
645
- def match_quran(self, query_text: str) -> Optional[CorrectionMatch]:
646
- kb = self.kb
647
- query_norm = normalize_for_matching(query_text)
648
- query_words = content_words(query_norm.split())
649
- if not query_words:
650
- return None
651
- query_len = len(query_norm)
652
- memo: Dict[tuple, tuple] = {}
653
- best = None # (key, coverage, ratio, surah, start, end)
654
- for seed in kb.quran_seed_ayahs(query_words, top_k=25):
655
- surah, ayah = kb.quran[seed]["surah_id"], kb.quran[seed]["ayah_id"]
656
- ayahs = kb.quran_by_surah[surah]
657
- min_ayah, max_ayah = min(ayahs), max(ayahs)
658
- for offset in range(3):
659
- start = ayah - offset
660
- if start < min_ayah:
661
- continue
662
- window_len = -1
663
- for length in range(1, self.cfg.max_window + 1):
664
- end = start + length - 1
665
- if end > max_ayah:
666
- break
667
- window_len += len(kb.q_norm_match[ayahs[end]]) + 1
668
- len_diff = abs(window_len - query_len)
669
- upper_bound = min(1.0, window_len / max(query_len, 1))
670
- if best is not None: # exact-result pruning
671
- best_cov, best_neg = best[0][0], best[0][1]
672
- if upper_bound < best_cov or (upper_bound == best_cov and -len_diff < best_neg):
673
- continue
674
- key_pos = (surah, start, end)
675
- if key_pos in memo:
676
- continue
677
- window = " ".join(kb.q_norm_match[ayahs[a]] for a in range(start, end + 1))
678
- matcher = difflib.SequenceMatcher(None, query_norm, window, autojunk=False)
679
- matched = sum(b.size for b in matcher.get_matching_blocks() if b.size >= 4)
680
- coverage = matched / max(query_len, 1)
681
- key = (coverage, -len_diff, matcher.ratio())
682
- memo[key_pos] = key
683
- if best is None or key > best[0]:
684
- best = (key, coverage, key[2], surah, start, end)
685
- if best is None:
686
- return None
687
- _, coverage, ratio, surah, start, end = best
688
- display = " ".join(kb.quran[kb.quran_by_surah[surah][a]]["text"] for a in range(start, end + 1))
689
- return CorrectionMatch(
690
- "Ayah", coverage, ratio, self._ayah_text(surah, start, end),
691
- {"type": "Quran", "surah_id": surah, "surah_name": kb.quran[kb.quran_by_surah[surah][start]]["surah_name"],
692
- "ayah_start": start, "ayah_end": end},
693
- display,
694
- )
695
-
696
- def _ayah_text(self, surah: int, start: int, end: int) -> str:
697
- kb, multi = self.kb, end > start
698
- parts = []
699
- for a in range(start, end + 1):
700
- text = kb.quran[kb.quran_by_surah[surah][a]]["text"]
701
- parts.append(f"{text} ({a})" if multi else text)
702
- return apply_idgham(" ".join(parts)).replace("\u0640", "")
703
-
704
- def match_hadith(self, query_text: str) -> Optional[CorrectionMatch]:
705
- kb = self.kb
706
- query_norm = normalize_for_matching(query_text)
707
- query_words = content_words(query_norm.split())
708
- if not query_words:
709
- return None
710
- query_len = len(query_norm)
711
- best = None # (key, idx, field, coverage, candidate_coverage, ratio)
712
- for idx, _ in kb.vote(kb.h_content_index, kb.hadith_idf, query_words, self.cfg.hadith_top_k):
713
- record = kb.hadith[idx]
714
- for field_name in ("norm_matn", "norm_full"):
715
- text = record[field_name]
716
- if not text:
717
- continue
718
- upper_bound = min(1.0, query_len / max(len(text), 1))
719
- if best is not None and upper_bound < best[0][0]:
720
- continue
721
- matcher = difflib.SequenceMatcher(None, query_norm, text, autojunk=False)
722
- matched = sum(b.size for b in matcher.get_matching_blocks() if b.size >= 4)
723
- coverage, candidate_cov = matched / max(query_len, 1), matched / max(len(text), 1)
724
- key = (min(coverage, candidate_cov), matcher.ratio())
725
- if best is None or key > best[0]:
726
- best = (key, idx, field_name, coverage, candidate_cov, key[1])
727
- if best is None:
728
- return None
729
- key, idx, field_name, _, _, ratio = best
730
- record = kb.hadith[idx]
731
- text = (record["matn"] if field_name == "norm_matn" else record["full"]).strip()
732
- return CorrectionMatch(
733
- "Hadith", key[0], ratio, text,
734
- {"type": "Hadith", "hadithID": record["hadithID"], "book": record["book"], "title": record["title"],
735
- "field": "matn" if field_name == "norm_matn" else "full_text"},
736
- text,
737
- )
738
-
739
- def is_exact_ayah(self, span_text: str) -> bool:
740
- """True if the quote (diacritics-insensitive) is a contiguous piece of 1-5 consecutive ayahs."""
741
- kb, query_norm = self.kb, normalize_for_matching(span_text)
742
- if not query_norm:
743
- return False
744
- for seed in kb.quran_seed_ayahs(content_words(query_norm.split()), 25):
745
- ayah = kb.quran[seed]
746
- ayahs = kb.quran_by_surah[ayah["surah_id"]]
747
- for offset in range(4):
748
- start = ayah["ayah_id"] - offset
749
- for length in range(1, 6):
750
- ids = [ayahs.get(x) for x in range(start, start + length)]
751
- if None in ids:
752
- break
753
- if query_norm in " ".join(kb.q_norm_match[i] for i in ids):
754
- return True
755
- return False
756
-
757
-
758
- # --------------------------------------------------------------------------------------------------------------
759
- # End-to-end pipeline
760
- # --------------------------------------------------------------------------------------------------------------
761
- STATUS_INFO = {
762
- "VERIFIED": {"ar": "موثّق — النص مطابق للمصدر", "en": "Verified — matches the source", "group": "verified"},
763
- "CORRECTED": {"ar": "مُصحَّح — دليل قوي على نص المصدر", "en": "Mismatch — source-backed correction available", "group": "mismatch"},
764
- "UNSUPPORTED": {"ar": "غير مدعوم — لا يوجد مصدر مطابق في المراجع", "en": "Mismatch — no matching source in the corpus", "group": "mismatch"},
765
- "HUMAN_REVIEW": {"ar": "مراجعة بشرية — الدليل غير كافٍ", "en": "Needs human review — insufficient evidence", "group": "review"},
766
- }
767
-
768
-
769
- class IslamicContentVerifier:
770
- """Detect quotations, verify them against the corpora and decide: verified, corrected, unsupported or review."""
771
-
772
- def __init__(self, retriever: Optional[SourceRetriever] = None, detector: str = "rules",
773
- model_dir: Optional[str] = None, config: Optional[PipelineConfig] = None) -> None:
774
- self.cfg = config or PipelineConfig()
775
- self.retriever = retriever or SourceRetriever()
776
- self.verifier = Verifier(self.retriever, self.cfg.verifier)
777
- self.corrector = Corrector(self.retriever, self.cfg.corrector)
778
- self.detector = build_detector(detector, self.retriever, model_dir)
779
- self.detector_name = type(self.detector).__name__
780
-
781
- # ---- public API -----------------------------------------------------------------------------------------
782
- def analyze(self, text: str) -> dict:
783
- """Run the full pipeline on a generated text."""
784
- text = self._validate(text)
785
- started = time.time()
786
- spans = self.detector.detect(text) if text.strip() else []
787
- detect_seconds = time.time() - started
788
- result = self._analyze_spans(text, spans)
789
- result["timings"] = {"detect_s": round(detect_seconds, 3), "total_s": round(time.time() - started, 3)}
790
- return result
791
-
792
- def analyze_spans(self, text: str, spans: List[dict]) -> dict:
793
- """Skip detection and use given spans ``[{label, start, end}]`` (evaluation / oracle mode)."""
794
- given = [DetectedSpan(s["start"], s["end"], s["label"], None, "given", text[s["start"]:s["end"]]) for s in spans]
795
- return self._analyze_spans(self._validate(text), given)
796
-
797
- # ---- internals ------------------------------------------------------------------------------------------
798
- @staticmethod
799
- def _validate(text: str) -> str:
800
- if not isinstance(text, str):
801
- raise TypeError("Input text must be a string")
802
- if len(text) > MAX_INPUT_CHARS:
803
- raise ValueError(f"Input is too long ({len(text)} characters); the limit is {MAX_INPUT_CHARS}")
804
- return text
805
-
806
- def _analyze_spans(self, text: str, spans: List[DetectedSpan]) -> dict:
807
- reports = [self._process_span(i + 1, span) for i, span in enumerate(sorted(spans, key=lambda s: s.start))]
808
- counts = {status: 0 for status in STATUS_INFO}
809
- for report in reports:
810
- counts[report["status"]] += 1
811
- return {
812
- "input_text": text,
813
- "detector": self.detector_name,
814
- "spans": reports,
815
- "corrected_text": self._apply_corrections(text, reports),
816
- "summary": {
817
- "n_spans": len(reports),
818
- "n_ayah": sum(r["type"] == "Ayah" for r in reports),
819
- "n_hadith": sum(r["type"] == "Hadith" for r in reports),
820
- **counts,
821
- "needs_human_review": counts["HUMAN_REVIEW"] > 0,
822
- },
823
- }
824
-
825
- def _process_span(self, index: int, span: DetectedSpan) -> dict:
826
- try:
827
- return self._decide(index, span)
828
- except Exception: # a single failing quotation must not break the whole report
829
- logger.exception("Failed to process span %d", index)
830
- report = self._empty_report(index, span)
831
- self._finalize(report, "HUMAN_REVIEW", "internal_error: this quotation could not be processed automatically")
832
- return report
833
-
834
- @staticmethod
835
- def _empty_report(index: int, span: DetectedSpan) -> dict:
836
- return {
837
- "id": index, "type": span.label, "start": span.start, "end": span.end, "text": span.text,
838
- "detection": {"backend": span.source, "confidence": None if span.confidence is None else round(span.confidence, 4)},
839
- "verification": {"verdict": "Incorrect", "confidence": 0.0, "score": 0.0, "method": "error", "n_candidates": 0},
840
- "evidence": None, "correction": None, "suggestion": None,
841
- }
842
-
843
- @staticmethod
844
- def _finalize(report: dict, status: str, reason: str) -> None:
845
- info = STATUS_INFO[status]
846
- report.update(status=status, status_ar=info["ar"], status_en=info["en"], group=info["group"], reason=reason)
847
-
848
- def _decide(self, index: int, span: DetectedSpan) -> dict:
849
- cfg, corr_cfg = self.cfg, self.cfg.corrector
850
- verification = self.verifier.verify(span.text, span.label)
851
- report = self._empty_report(index, span)
852
- report["verification"] = {
853
- "verdict": verification.verdict, "confidence": verification.confidence, "score": verification.best_score,
854
- "method": verification.method, "n_candidates": verification.n_candidates,
855
- }
856
- report["evidence"] = self._evidence(span, verification)
857
-
858
- def proposal(match: Optional[CorrectionMatch]) -> Optional[dict]:
859
- if match is None:
860
- return None
861
- return {
862
- "text": match.text, "display_text": match.display, "source": match.source,
863
- "match_strength": round(match.strength, 4), "full_ratio": round(match.full_ratio, 4),
864
- "comparison": compare(span.text, match.display),
865
- }
866
-
867
- if verification.verdict == "Correct":
868
- exact = self.corrector.is_exact_ayah(span.text) if span.label == "Ayah" else None
869
- report["verification"]["exact_match"] = exact
870
- if verification.method.startswith("borderline") or verification.confidence < cfg.verified_min_conf:
871
- status, reason = "HUMAN_REVIEW", "weak_verification: matched a source but with low confidence"
872
- elif exact is False:
873
- status = "HUMAN_REVIEW"
874
- reason = "near_match: the quote is close to a source ayah but NOT identical (words missing, added or changed)"
875
- report["suggestion"] = proposal(self.corrector.match(span.text, span.label))
876
- else:
877
- status, reason = "VERIFIED", f"matched source ({verification.method})"
878
- else:
879
- match = self.corrector.match(span.text, span.label)
880
- strong, low = (corr_cfg.quran_strong, corr_cfg.quran_low) if span.label == "Ayah" else (corr_cfg.hadith_strong, corr_cfg.hadith_low)
881
- candidate = proposal(match)
882
- if match is None or match.strength < low:
883
- confident_abstain = verification.confidence >= cfg.unsupported_min_conf and not verification.method.startswith("borderline")
884
- if match is None or match.strength < cfg.unsupported_strength or confident_abstain:
885
- status, reason = "UNSUPPORTED", "no source in the corpus matches this quotation"
886
- else:
887
- status, reason = "HUMAN_REVIEW", "insufficient_evidence: no clear source and low verification confidence"
888
- report["suggestion"] = candidate
889
- elif match.strength >= strong and match.full_ratio >= corr_cfg.min_full_ratio:
890
- status = "CORRECTED"
891
- reason = f"strong match to {self.describe_source(match.source)} (strength {match.strength:.2f})"
892
- report["correction"] = {**candidate, "applied": True}
893
- else:
894
- status = "HUMAN_REVIEW"
895
- reason = (f"candidate source found ({self.describe_source(match.source)}, strength {match.strength:.2f}) "
896
- "but evidence is not strong enough for automatic correction")
897
- report["suggestion"] = candidate
898
-
899
- if span.confidence is not None and span.confidence < cfg.min_detection_conf and status != "HUMAN_REVIEW":
900
- reason = f"low detection confidence ({span.confidence:.2f}); was {status}: {reason}"
901
- status = "HUMAN_REVIEW"
902
- if report["correction"]:
903
- report["suggestion"], report["correction"] = {**report["correction"], "applied": False}, None
904
-
905
- self._finalize(report, status, reason)
906
- return report
907
-
908
- @staticmethod
909
- def _evidence(span: DetectedSpan, verification: Verification) -> Optional[dict]:
910
- source = verification.source
911
- if not source:
912
- return None
913
- if span.label == "Ayah":
914
- reference = {"type": "Quran", "surah_id": source["surah_id"], "surah_name": source["surah_name"], "ayah": source["ayah_id"]}
915
- else:
916
- reference = {"type": "Hadith", "hadithID": source["hadithID"], "book": source["book"], "title": source["title"]}
917
- return {"source": reference, "signals": source.get("signals"), "comparison": compare(span.text, source["text"])}
918
-
919
- @staticmethod
920
- def describe_source(source: dict) -> str:
921
- """Human-readable reference, e.g. ``Quran الفاتحة 1-3`` or ``Hadith #123``."""
922
- if source["type"] == "Quran":
923
- start, end = source["ayah_start"], source["ayah_end"]
924
- return f"Quran {source['surah_name']} {start}" + (f"-{end}" if end != start else "")
925
- return f"Hadith #{source['hadithID']}"
926
-
927
- @staticmethod
928
- def _apply_corrections(text: str, reports: List[dict]) -> str:
929
- out = text
930
- for report in sorted(reports, key=lambda r: r["start"], reverse=True):
931
- if report["status"] == "CORRECTED" and report["correction"] and report["correction"].get("applied"):
932
- out = out[: report["start"]] + report["correction"]["display_text"] + out[report["end"]:]
933
- return out