Delete verifier.py
Browse files- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|