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

Upload 8 files

Browse files
Files changed (9) hide show
  1. .gitattributes +1 -0
  2. README.md +16 -0
  3. app.py +420 -0
  4. examples.json +107 -0
  5. hadith.json +3 -0
  6. index.html +264 -0
  7. quran.json +0 -0
  8. retrieval.py +274 -0
  9. verifier.py +933 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ hadith.json filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Islamic Content Verifier
3
+ emoji: 🕌
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: static
7
+ app_file: index.html
8
+ pinned: false
9
+ license: mit
10
+ short_description: Verify Quran and Hadith quotations with evidence
11
+ ---
12
+
13
+ # Islamic Content Verifier
14
+
15
+ Verify Quranic and Hadith quotations with evidence. The verification code runs entirely in your browser
16
+ (Python via Pyodide); no text is sent to a server. Source code and documentation: see the GitHub repository.
app.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Islamic Content Verifier: Gradio interface around the verification pipeline in ``verifier.py``.
2
+
3
+ Run locally with ``python app.py``. The rendering helpers are plain functions that return HTML, so they can be
4
+ tested without Gradio; Gradio is imported only when the interface is built.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import html
9
+ import json
10
+ import logging
11
+ import os
12
+ import threading
13
+ from pathlib import Path
14
+ from typing import List, Optional
15
+
16
+ from verifier import MAX_INPUT_CHARS, IslamicContentVerifier
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ EXAMPLES_PATH = Path(__file__).resolve().parent / "demo" / "examples.json"
21
+ DEFAULT_MODEL_DIR = Path(__file__).resolve().parent / "models" / "span_detector"
22
+
23
+ # --------------------------------------------------------------------------------------------------------------
24
+ # Pipeline access
25
+ # --------------------------------------------------------------------------------------------------------------
26
+ _pipeline: Optional[IslamicContentVerifier] = None
27
+ _pipeline_lock = threading.Lock()
28
+
29
+
30
+ def get_pipeline() -> IslamicContentVerifier:
31
+ """Create the pipeline once (the corpus index builds in a few seconds) and reuse it for every request."""
32
+ global _pipeline
33
+ with _pipeline_lock:
34
+ if _pipeline is None:
35
+ model_dir = os.environ.get("ICV_DETECTOR_MODEL") or (str(DEFAULT_MODEL_DIR) if DEFAULT_MODEL_DIR.is_dir() else None)
36
+ # With a trained model folder the BERT detector is used; if it cannot be loaded the rules detector takes over.
37
+ _pipeline = IslamicContentVerifier(detector="auto" if model_dir else "rules", model_dir=model_dir)
38
+ return _pipeline
39
+
40
+
41
+ def load_examples(path: Path = EXAMPLES_PATH) -> List[dict]:
42
+ try:
43
+ with open(path, encoding="utf-8") as handle:
44
+ return json.load(handle)
45
+ except (OSError, json.JSONDecodeError):
46
+ logger.exception("Could not load demo examples from %s", path)
47
+ return []
48
+
49
+
50
+ # --------------------------------------------------------------------------------------------------------------
51
+ # HTML rendering
52
+ # --------------------------------------------------------------------------------------------------------------
53
+ _e = html.escape
54
+
55
+ GROUP_LABEL = {
56
+ "verified": ("موثّق", "Verified"),
57
+ "mismatch": ("غير مطابق", "Mismatch"),
58
+ "review": ("يحتاج مراجعة بشرية", "Needs Human Review"),
59
+ }
60
+ TYPE_LABEL = {"Ayah": ("آية قرآنية", "Quran"), "Hadith": ("حديث نبوي", "Hadith")}
61
+ INDICATORS = [
62
+ ("composite", "الدرجة المركبة", "Composite score"),
63
+ ("coverage", "تغطية الكلمات", "Word coverage"),
64
+ ("lcs_ratio", "التسلسل النصي", "Sequence (LCS)"),
65
+ ("token_overlap", "تداخل الكلمات", "Token overlap"),
66
+ ("edit_sim", "تشابه الأحرف", "Character similarity"),
67
+ ("diacritic_sim", "مع التشكيل", "With diacritics"),
68
+ ]
69
+
70
+
71
+ def _pct(value: float) -> str:
72
+ return f"{round(value * 100)}%"
73
+
74
+
75
+ def _source_label(source: dict) -> str:
76
+ if source["type"] == "Quran":
77
+ start = source.get("ayah", source.get("ayah_start"))
78
+ end = source.get("ayah_end", start)
79
+ verses = f"{start}" if start == end else f"{start}–{end}"
80
+ return f"سورة {source['surah_name']} — الآية {verses}"
81
+ return f"حديث رقم {source['hadithID']} — {source['title']}"
82
+
83
+
84
+ def _truncate(text: str, limit: int) -> str:
85
+ return text if len(text) <= limit else text[:limit].rstrip() + " …"
86
+
87
+
88
+ def _diff_html(comparison: dict) -> str:
89
+ """Word-level diff: green = words only in the source, red = words only in the quotation."""
90
+ parts = []
91
+ for op in comparison["word_diff"]:
92
+ if op["op"] == "equal":
93
+ parts.append(f'<span class="w-eq">{_e(op["span"])}</span>')
94
+ else:
95
+ if op["span"]:
96
+ parts.append(f'<span class="w-extra" title="في الاقتباس فقط · only in the quotation">{_e(op["span"])}</span>')
97
+ if op["source"]:
98
+ parts.append(f'<span class="w-missing" title="في المصدر فقط · only in the source">{_e(op["source"])}</span>')
99
+ return " ".join(parts)
100
+
101
+
102
+ def _indicator_table(signals: Optional[dict]) -> str:
103
+ if not signals:
104
+ return ""
105
+ rows = []
106
+ for key, ar, en in INDICATORS:
107
+ value = float(signals.get(key, 0.0))
108
+ rows.append(
109
+ f'<div class="ind"><div class="ind-name">{ar}<small>{en}</small></div>'
110
+ f'<div class="bar"><span style="width:{_pct(value)}"></span></div><div class="ind-val">{_pct(value)}</div></div>'
111
+ )
112
+ substring = "نعم · Yes" if signals.get("is_substring") else "لا · No"
113
+ rows.append(f'<div class="ind"><div class="ind-name">احتواء كامل<small>Contained in source</small></div><div class="ind-val wide">{substring}</div></div>')
114
+ return '<div class="indicators">' + "".join(rows) + "</div>"
115
+
116
+
117
+ def _evidence_panel(span: dict) -> str:
118
+ evidence, verification = span["evidence"], span["verification"]
119
+ blocks = [
120
+ f'<div class="ev-row"><b>الاقتباس المكتشف · Detected quotation</b><p class="quote">{_e(span["text"])}</p></div>',
121
+ f'<div class="ev-row"><b>النوع · Type</b><p>{TYPE_LABEL[span["type"]][0]} · {TYPE_LABEL[span["type"]][1]}</p></div>',
122
+ ]
123
+ if evidence:
124
+ comparison = evidence["comparison"]
125
+ blocks += [
126
+ f'<div class="ev-row"><b>المصدر المرشح · Candidate source</b><p>{_e(_source_label(evidence["source"]))}</p></div>',
127
+ f'<div class="ev-row"><b>نص المصدر الأصلي · Original source text</b><p class="quote">{_e(comparison["source_excerpt"])}</p></div>',
128
+ f'<div class="ev-row"><b>المقارنة · Comparison</b><p class="diff">{_diff_html(comparison)}</p>'
129
+ f'<p class="legend"><span class="w-extra">في الاقتباس فقط</span> <span class="w-missing">في المصدر فقط</span> '
130
+ f'· تشابه الكلمات {_pct(comparison["word_similarity"])}</p></div>',
131
+ f'<div class="ev-row"><b>مؤشرات التحقق · Similarity indicators</b>{_indicator_table(evidence.get("signals"))}</div>',
132
+ ]
133
+ else:
134
+ blocks.append('<div class="ev-row"><b>المصدر · Source</b><p>لم يُسترجع أي مصدر مرشح · No candidate source was retrieved.</p></div>')
135
+ blocks += [
136
+ f'<div class="ev-row"><b>الثقة · Confidence</b><p>{_pct(verification["confidence"])}</p>'
137
+ f'<p class="legend en">verdict: {_e(verification["verdict"])} · method: {_e(verification["method"])} · '
138
+ f'candidates checked: {verification["n_candidates"]}</p></div>',
139
+ f'<div class="ev-row"><b>القرار · Decision</b><p>{_e(span["status_ar"])}<br><span class="en">{_e(span["status_en"])}</span></p></div>',
140
+ f'<div class="ev-row"><b>سبب القرار · Decision reason</b><p class="en">{_e(span["reason"])}</p></div>',
141
+ ]
142
+ return "".join(blocks)
143
+
144
+
145
+ def _card(span: dict) -> str:
146
+ group = span["group"]
147
+ ar_label, en_label = GROUP_LABEL[group]
148
+ type_ar, type_en = TYPE_LABEL[span["type"]]
149
+ evidence = span["evidence"]
150
+ confidence = span["verification"]["confidence"]
151
+
152
+ source_html = comparison_html = ""
153
+ if evidence:
154
+ comparison = evidence["comparison"]
155
+ source_html = (
156
+ f'<div class="field"><label>المصدر المرشح · Candidate source</label><p>{_e(_source_label(evidence["source"]))}</p></div>'
157
+ f'<div class="field"><label>نص المصدر الأصلي · Original source text</label>'
158
+ f'<p class="quote small">{_e(_truncate(comparison["source_excerpt"], 360))}</p></div>'
159
+ )
160
+ comparison_html = (
161
+ f'<div class="field"><label>المقارنة · Comparison</label><p class="diff">{_diff_html(comparison)}</p></div>'
162
+ )
163
+ else:
164
+ source_html = '<div class="field"><label>المصدر المرشح · Candidate source</label><p>لا يوجد · None found</p></div>'
165
+
166
+ action = ""
167
+ correction, suggestion = span["correction"], span["suggestion"]
168
+ if correction:
169
+ action = (
170
+ '<div class="action ok"><b>تصحيح مدعوم بالمصدر · Source-backed correction</b>'
171
+ f'<p class="quote">{_e(correction["display_text"])}</p>'
172
+ f'<p class="legend">{_e(_source_label({"type": "Quran", **correction["source"]}) if correction["source"]["type"] == "Quran" else _source_label(correction["source"]))}'
173
+ f' · قوة المطابقة {_pct(correction["match_strength"])}</p></div>'
174
+ )
175
+ elif group == "review":
176
+ closest = ""
177
+ if suggestion:
178
+ src = suggestion["source"]
179
+ label = _source_label({"type": "Quran", **src}) if src["type"] == "Quran" else _source_label(src)
180
+ closest = (f'<p class="legend">أقرب مصدر وُجد للمراجِع (ليس تصحيحًا آليًا) · Closest source for the reviewer (not an automatic correction): '
181
+ f'{_e(label)}</p><p class="quote small">{_e(_truncate(suggestion["display_text"], 360))}</p>')
182
+ action = (
183
+ '<div class="action warn"><b>الأدلة غير كافية للتصحيح الآلي.</b> يُوصى بالمراجعة البشرية.'
184
+ '<p class="en">Insufficient evidence for automatic correction. Human review is recommended.</p>' + closest + "</div>"
185
+ )
186
+ elif span["status"] == "UNSUPPORTED":
187
+ action = (
188
+ '<div class="action bad"><b>لا يوجد مصدر مطابق في المراجع المتاحة للنظام.</b> لم يُقترح أي نص بديل.'
189
+ '<p class="en">No matching source in the corpus. No replacement text is proposed.</p></div>'
190
+ )
191
+
192
+ return f"""
193
+ <div class="qcard {group}">
194
+ <div class="qhead">
195
+ <span class="idx">{span["id"]}</span>
196
+ <span class="badge type">{type_ar} · {type_en}</span>
197
+ <span class="badge st {group}">{ar_label} · {en_label}</span>
198
+ <span class="conf">الثقة · Confidence <b>{_pct(confidence)}</b></span>
199
+ </div>
200
+ <div class="field"><label>الاقتباس المكتشف · Detected quotation</label><p class="quote">{_e(span["text"])}</p></div>
201
+ {source_html}
202
+ {comparison_html}
203
+ <div class="field"><label>القرار · Decision</label><p>{_e(span["status_ar"])}<br><span class="en">{_e(span["status_en"])}</span></p></div>
204
+ {action}
205
+ <details class="evidence"><summary>عرض الدليل · View Evidence</summary><div class="ev-body">{_evidence_panel(span)}</div></details>
206
+ </div>"""
207
+
208
+
209
+ def _summary(summary: dict) -> str:
210
+ tiles = [
211
+ (summary["n_spans"], "إجمالي الاقتباسات", "Total quotations", ""),
212
+ (summary["n_ayah"], "آيات قرآنية", "Quranic", ""),
213
+ (summary["n_hadith"], "أحاديث", "Hadith", ""),
214
+ (summary["VERIFIED"], "موثّق", "Verified", "verified"),
215
+ (summary["CORRECTED"] + summary["UNSUPPORTED"], "غير مطابق", "Mismatch", "mismatch"),
216
+ (summary["HUMAN_REVIEW"], "يحتاج مراجعة بشرية", "Needs Human Review", "review"),
217
+ ]
218
+ return '<div class="summary">' + "".join(
219
+ f'<div class="tile {cls}{" zero" if value == 0 and cls else ""}"><b>{value}</b><span>{ar}</span><small>{en}</small></div>'
220
+ for value, ar, en, cls in tiles
221
+ ) + "</div>"
222
+
223
+
224
+ def _highlighted_text(result: dict) -> str:
225
+ text, pieces, cursor = result["input_text"], [], 0
226
+ for span in result["spans"]:
227
+ pieces.append(_e(text[cursor:span["start"]]))
228
+ pieces.append(f'<mark class="{span["group"]}" title="{_e(span["status_en"])}">{_e(text[span["start"]:span["end"]])}</mark>')
229
+ cursor = span["end"]
230
+ pieces.append(_e(text[cursor:]))
231
+ legend = (
232
+ '<div class="hl-legend"><mark class="verified">موثّق · Verified</mark><mark class="mismatch">غير مطابق · Mismatch</mark>'
233
+ '<mark class="review">مراجعة بشرية · Review</mark></div>'
234
+ )
235
+ return (
236
+ '<div class="highlight"><label>النص مع الاقتباسات المكتشفة · Text with detected quotations</label><p>'
237
+ + "".join(pieces) + "</p>" + legend + "</div>"
238
+ )
239
+
240
+
241
+ def render_results(result: dict) -> str:
242
+ """HTML report for a pipeline result."""
243
+ if not result["spans"]:
244
+ return (
245
+ '<div class="notice">لم يُعثر على اقتباسات قرآنية أو حديثية في هذا النص. '
246
+ '<span class="en">No Quranic or Hadith quotations were detected. Quotations are currently detected when they are '
247
+ 'enclosed in quotation marks or brackets and introduced by a typical phrase (see Limitations).</span></div>'
248
+ )
249
+ names = {"RuleDetector": ("القواعد", "Rule-based"), "BertDetector": ("نموذج BERT", "BERT model")}
250
+ ar, en = names.get(result["detector"], (result["detector"], result["detector"]))
251
+ chip = f'<div class="det-chip">الكاشف · Detector: <b>{ar} · {en}</b></div>'
252
+ return chip + _summary(result["summary"]) + _highlighted_text(result) + "".join(_card(s) for s in result["spans"])
253
+
254
+
255
+ def render_message(message_ar: str, message_en: str, kind: str = "info") -> str:
256
+ return f'<div class="notice {kind}">{_e(message_ar)}<span class="en">{_e(message_en)}</span></div>'
257
+
258
+
259
+ def verify_text(text: str) -> str:
260
+ """Gradio handler: validate the input, run the pipeline and return HTML (never raises)."""
261
+ if not text or not text.strip():
262
+ return render_message("الرجاء إدخال نص للتحقق منه.", "Please enter a text to verify.", "warn")
263
+ try:
264
+ return render_results(get_pipeline().analyze(text))
265
+ except ValueError:
266
+ return render_message(
267
+ f"النص طويل جدًا (الحد الأقصى {MAX_INPUT_CHARS} حرف).", f"The text is too long (maximum {MAX_INPUT_CHARS} characters).", "warn"
268
+ )
269
+ except Exception:
270
+ logger.exception("Verification failed")
271
+ return render_message("حدث خطأ غير متوقع أثناء التحقق.", "An unexpected error occurred during verification.", "bad")
272
+
273
+
274
+ # --------------------------------------------------------------------------------------------------------------
275
+ # Interface
276
+ # --------------------------------------------------------------------------------------------------------------
277
+ _PATTERN = (
278
+ "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='80' viewBox='0 0 80 80'%3E"
279
+ "%3Cg fill='none' stroke='%2322d3ee' stroke-opacity='0.07' stroke-width='1'%3E"
280
+ "%3Crect x='20' y='20' width='40' height='40'/%3E%3Crect x='20' y='20' width='40' height='40' transform='rotate(45 40 40)'/%3E"
281
+ "%3C/g%3E%3C/svg%3E\")"
282
+ )
283
+
284
+ CSS = """
285
+ @import url('https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&family=Amiri:wght@400;700&display=swap');
286
+ .gradio-container {
287
+ --body-background-fill:#0a1128; --block-background-fill:#101a38; --block-border-color:#1e2d57;
288
+ --input-background-fill:#0d1631; --body-text-color:#e8eefc; --block-label-text-color:#9fb3da;
289
+ --button-primary-background-fill:#14b8a6; --button-primary-background-fill-hover:#22d3ee; --button-primary-text-color:#04202a;
290
+ --button-secondary-background-fill:#16244a; --button-secondary-text-color:#e8eefc; --button-secondary-border-color:#2a3d73;
291
+ background:#0a1128 PATTERN; font-family:'Cairo','Segoe UI',Tahoma,sans-serif; max-width:1080px !important; color:#e8eefc;
292
+ }
293
+ .hero { text-align:center; padding:34px 12px 18px; direction:rtl; }
294
+ .hero .eyebrow { color:#22d3ee; letter-spacing:.14em; font-size:.8rem; text-transform:uppercase; }
295
+ .hero h1 { font-size:2.3rem; margin:.3rem 0 .1rem; color:#fff; }
296
+ .hero h2 { font-size:1.05rem; font-weight:400; color:#9fb3da; margin:0 0 .9rem; direction:ltr; }
297
+ .hero p { max-width:760px; margin:.3rem auto; color:#c7d4f0; line-height:1.9; }
298
+ .hero .en { direction:ltr; color:#8ea3cf; font-size:.92rem; }
299
+ .input-area textarea { direction:rtl; text-align:right; font-family:'Amiri','Cairo',serif !important; font-size:1.2rem !important; line-height:2 !important; }
300
+ .btn-row button { font-weight:700 !important; border-radius:12px !important; }
301
+ .disclaimer { font-size:.82rem; color:#8ea3cf; text-align:center; padding:12px 8px; direction:rtl; line-height:1.8; }
302
+ .disclaimer .en { display:block; direction:ltr; }
303
+ .results { direction:rtl; text-align:right; }
304
+ .results .en, .notice .en { display:block; direction:ltr; text-align:left; color:#8ea3cf; font-size:.88rem; font-weight:400; }
305
+ .summary { display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); gap:10px; margin:6px 0 14px; }
306
+ .tile { background:#101a38; border:1px solid #1e2d57; border-radius:14px; padding:12px; text-align:center; }
307
+ .tile b { display:block; font-size:1.8rem; color:#fff; } .tile span { display:block; font-size:.9rem; } .tile small { color:#8ea3cf; }
308
+ .tile.verified { border-color:#14b8a6; } .tile.verified b { color:#2dd4bf; }
309
+ .tile.mismatch { border-color:#f43f5e; } .tile.mismatch b { color:#fb7185; }
310
+ .tile.review { border-color:#f59e0b; } .tile.review b { color:#fbbf24; }
311
+ .highlight { background:#0d1631; border:1px solid #1e2d57; border-radius:14px; padding:14px 16px; margin-bottom:14px; }
312
+ .highlight label, .field label { display:block; color:#7e93c0; font-size:.78rem; margin-bottom:4px; }
313
+ .highlight p { font-family:'Amiri','Cairo',serif; font-size:1.15rem; line-height:2.1; margin:0; white-space:pre-wrap; }
314
+ mark { color:#fff; border-radius:6px; padding:1px 4px; }
315
+ mark.verified { background:rgba(20,184,166,.35); } mark.mismatch { background:rgba(244,63,94,.35); } mark.review { background:rgba(245,158,11,.35); }
316
+ .qcard { background:#101a38; border:1px solid #1e2d57; border-inline-start:5px solid #3b4b7a; border-radius:16px; padding:16px 18px; margin:12px 0; }
317
+ .qcard.verified { border-inline-start-color:#14b8a6; } .qcard.mismatch { border-inline-start-color:#f43f5e; } .qcard.review { border-inline-start-color:#f59e0b; }
318
+ .qhead { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:10px; }
319
+ .idx { background:#1b2a55; border-radius:50%; width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; font-weight:700; }
320
+ .badge { padding:3px 12px; border-radius:999px; font-size:.82rem; background:#16244a; border:1px solid #2a3d73; }
321
+ .badge.st.verified { background:rgba(20,184,166,.18); border-color:#14b8a6; color:#5eead4; }
322
+ .badge.st.mismatch { background:rgba(244,63,94,.16); border-color:#f43f5e; color:#fda4af; }
323
+ .badge.st.review { background:rgba(245,158,11,.16); border-color:#f59e0b; color:#fcd34d; }
324
+ .conf { margin-inline-start:auto; color:#9fb3da; font-size:.88rem; } .conf b { color:#22d3ee; }
325
+ .field { margin:8px 0; } .field p { margin:0; line-height:1.8; }
326
+ .quote { font-family:'Amiri','Cairo',serif; font-size:1.2rem; line-height:2.1; color:#fff; } .quote.small { font-size:1.05rem; color:#d6e0f7; }
327
+ .diff { font-family:'Amiri','Cairo',serif; font-size:1.1rem; line-height:2.1; }
328
+ .w-extra { background:rgba(244,63,94,.25); border-radius:4px; padding:0 3px; text-decoration:line-through; }
329
+ .w-missing { background:rgba(20,184,166,.28); border-radius:4px; padding:0 3px; }
330
+ .legend { color:#8ea3cf; font-size:.82rem; margin:4px 0 0; } .legend .w-extra { text-decoration:none; }
331
+ .tile.zero { opacity:.5; }
332
+ .det-chip { text-align:center; color:#8ea3cf; font-size:.82rem; margin:2px 0 8px; } .det-chip b { color:#22d3ee; font-weight:600; }
333
+ .hl-legend { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; font-size:.78rem; } .hl-legend mark { padding:2px 10px; }
334
+ .flow { display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:8px; margin:16px 0 4px; direction:rtl; }
335
+ .flow span { background:#101a38; border:1px solid #1e2d57; border-radius:999px; padding:5px 14px; font-size:.85rem; color:#cfe0ff; }
336
+ .flow span small { color:#7e93c0; margin-inline-start:6px; direction:ltr; display:inline-block; }
337
+ .flow i { color:#22d3ee; font-style:normal; }
338
+ .hero .mark { width:52px; height:52px; margin:0 auto 6px; display:block; }
339
+ .hero h1 { background:linear-gradient(90deg,#fff,#a5f3fc); -webkit-background-clip:text; background-clip:text; color:transparent; }
340
+ .action { border-radius:12px; padding:10px 14px; margin-top:10px; line-height:1.8; }
341
+ .action.ok { background:rgba(20,184,166,.12); border:1px solid #14b8a6; }
342
+ .action.warn { background:rgba(245,158,11,.12); border:1px solid #f59e0b; }
343
+ .action.bad { background:rgba(244,63,94,.12); border:1px solid #f43f5e; }
344
+ .evidence { margin-top:12px; border-top:1px dashed #2a3d73; padding-top:8px; }
345
+ .evidence summary { cursor:pointer; color:#22d3ee; font-weight:700; }
346
+ .ev-row { margin:10px 0; } .ev-row b { color:#9fb3da; font-size:.85rem; } .ev-row p { margin:2px 0; }
347
+ .indicators { display:grid; gap:6px; margin-top:6px; }
348
+ .ind { display:grid; grid-template-columns:150px 1fr 48px; gap:10px; align-items:center; font-size:.85rem; }
349
+ .ind-name small { display:block; color:#7e93c0; font-size:.72rem; direction:ltr; text-align:right; }
350
+ .bar { background:#0d1631; border-radius:999px; height:8px; overflow:hidden; } .bar span { display:block; height:100%; background:linear-gradient(90deg,#14b8a6,#22d3ee); }
351
+ .ind-val { text-align:left; direction:ltr; color:#cfe0ff; } .ind-val.wide { grid-column:2 / span 2; text-align:right; }
352
+ .notice { background:#101a38; border:1px solid #1e2d57; border-radius:14px; padding:16px; direction:rtl; line-height:1.9; }
353
+ .notice.warn { border-color:#f59e0b; } .notice.bad { border-color:#f43f5e; }
354
+ @media (max-width:640px){ .hero h1{font-size:1.7rem;} .ind{grid-template-columns:110px 1fr 40px;} }
355
+ """.replace("PATTERN", _PATTERN)
356
+
357
+ HERO = """
358
+ <div class="hero">
359
+ <svg class="mark" viewBox="0 0 52 52" fill="none" stroke="#22d3ee" stroke-width="1.6" aria-hidden="true">
360
+ <rect x="11" y="11" width="30" height="30"/><rect x="11" y="11" width="30" height="30" transform="rotate(45 26 26)"/>
361
+ <circle cx="26" cy="26" r="6" fill="#14b8a6" stroke="none"/></svg>
362
+ <div class="eyebrow">Islamic Content Verifier</div>
363
+ <h1>مُدقِّق المحتوى الإسلامي</h1>
364
+ <h2>Verify Quranic and Hadith quotations with evidence.</h2>
365
+ <p>الصق نصًا ولّده نموذج لغوي، وسيكتشف النظام الآيات والأحاديث الواردة فيه، ويسترجع نصوصها من المصادر، ويقارنها كلمةً بكلمة،
366
+ ثم يعرض الدليل. وإذا لم تكفِ الأدلة فلن يختلق تصحيحًا، بل يحيل الحالة إلى المراجعة البشرية.</p>
367
+ <p class="en">Paste text generated by a language model. The system detects Quran and Hadith quotations, retrieves the source texts,
368
+ compares them word by word and shows the evidence. When evidence is insufficient it never invents a correction: it recommends human review.</p>
369
+ <div class="flow"><span>كشف<small>Detect</small></span><i>←</i><span>استرجاع<small>Retrieve</small></span><i>←</i>
370
+ <span>تحقق<small>Verify</small></span><i>←</i><span>دليل<small>Evidence</small></span><i>←</i><span>قرار<small>Decide</small></span></div>
371
+ </div>
372
+ """
373
+
374
+ DISCLAIMER = """
375
+ <div class="disclaimer">أداة مساعدة للتدقيق النصي وليست فتوى ولا بديلًا عن المراجعة المتخصصة. النتائج مبنية على مراجع القرآن والكتب الستة المضمّنة فقط.
376
+ <span class="en">A text-verification aid, not a religious ruling and not a substitute for expert review. Results rely only on the bundled Quran and Six Books corpora.</span></div>
377
+ """
378
+
379
+ PLACEHOLDER = "الصق هنا الرد الذي ولّده النموذج اللغوي، ويفضَّل أن تكون الاقتباسات بين علامات تنصيص بعد عبارة مثل «قال الله تعالى» أو «قال رسول الله ﷺ»…"
380
+
381
+
382
+ def build_interface():
383
+ import gradio as gr
384
+
385
+ examples = load_examples()
386
+
387
+ def next_example(index: int):
388
+ if not examples:
389
+ return "", 0
390
+ return examples[index % len(examples)]["text"], (index + 1) % len(examples)
391
+
392
+ with gr.Blocks(title="Islamic Content Verifier", css=CSS, theme=gr.themes.Base(primary_hue="teal", neutral_hue="slate")) as demo:
393
+ gr.HTML(HERO)
394
+ example_index = gr.State(0)
395
+ text_input = gr.Textbox(
396
+ label="النص المراد التحقق منه · Text to verify", lines=9, max_lines=24, placeholder=PLACEHOLDER,
397
+ rtl=True, elem_classes="input-area",
398
+ )
399
+ with gr.Row(elem_classes="btn-row"):
400
+ verify_button = gr.Button("تحقق من النص · Verify Text", variant="primary", scale=3)
401
+ example_button = gr.Button("جرّب مثالًا · Try an Example", variant="secondary", scale=2)
402
+ results = gr.HTML(elem_classes="results")
403
+ gr.HTML(DISCLAIMER)
404
+
405
+ verify_button.click(verify_text, inputs=text_input, outputs=results)
406
+ text_input.submit(verify_text, inputs=text_input, outputs=results)
407
+ example_button.click(next_example, inputs=example_index, outputs=[text_input, example_index]).then(
408
+ verify_text, inputs=text_input, outputs=results
409
+ )
410
+ return demo
411
+
412
+
413
+ def main() -> None:
414
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
415
+ get_pipeline() # build the index before serving the first request
416
+ build_interface().queue().launch(share=os.environ.get("ICV_SHARE") == "1") # ICV_SHARE=1 -> temporary public link (e.g. on Colab)
417
+
418
+
419
+ if __name__ == "__main__":
420
+ main()
examples.json ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "correct_ayah",
4
+ "title_ar": "آية صحيحة",
5
+ "title_en": "Correct Quranic quotation",
6
+ "scenario": "correct",
7
+ "text": "يقول الله تعالى: \"يَا أَيُّهَا الَّذِينَ آمَنُوا اسْتَعِينُوا بِالصَّبْرِ وَالصَّلَاةِ ۚ إِنَّ اللَّهَ مَعَ الصَّابِرِينَ\". وهذه الآية تدل على معية الله للصابرين.",
8
+ "built_from": [
9
+ "Quran البقرة 2:153"
10
+ ],
11
+ "observed_statuses": [
12
+ "VERIFIED"
13
+ ]
14
+ },
15
+ {
16
+ "id": "correct_hadith",
17
+ "title_ar": "حديث صحيح",
18
+ "title_en": "Correct Hadith quotation",
19
+ "scenario": "correct",
20
+ "text": "وقال رسول الله صلى الله عليه وسلم: \"إِنَّمَا الْأَعْمَالُ بِالنِّيَّاتِ ، وَإِنَّمَا لِكُلِّ امْرِئٍ مَا نَوَى ، فَمَنْ كَانَتْ هِجْرَتُهُ إِلَى دُنْيَا يُصِيبُهَا ، أَوْ إِلَى امْرَأَةٍ يَنْكِحُهَا ، فَهِجْرَتُهُ إِلَى مَا هَاجَرَ إِلَيْهِ\". ويستفاد منه أهمية النية في الأعمال.",
21
+ "built_from": [
22
+ "Hadith #5"
23
+ ],
24
+ "observed_statuses": [
25
+ "VERIFIED"
26
+ ]
27
+ },
28
+ {
29
+ "id": "modified_missing_word",
30
+ "title_ar": "آية ناقصة كلمة",
31
+ "title_en": "Quranic quotation with one word removed",
32
+ "scenario": "modified",
33
+ "text": "قال الله تعالى: \"يَا أَيُّهَا آمَنُوا اسْتَعِينُوا بِالصَّبْرِ وَالصَّلَاةِ ۚ إِنَّ اللَّهَ مَعَ الصَّابِرِينَ\".",
34
+ "built_from": [
35
+ "Quran 2:153 (word 3 removed)"
36
+ ],
37
+ "observed_statuses": [
38
+ "HUMAN_REVIEW"
39
+ ]
40
+ },
41
+ {
42
+ "id": "modified_replaced_word",
43
+ "title_ar": "آية بكلمة مستبدلة",
44
+ "title_en": "Quranic quotation with one word replaced",
45
+ "scenario": "modified",
46
+ "text": "قال تعالى: \"يَا أَيُّهَا الَّذِينَ إِلَـٰهَ اسْتَعِينُوا بِالصَّبْرِ وَالصَّلَاةِ ۚ إِنَّ اللَّهَ مَعَ الصَّابِرِينَ\"",
47
+ "built_from": [
48
+ "Quran 2:153 (word 4 replaced by a word from 2:255)"
49
+ ],
50
+ "observed_statuses": [
51
+ "CORRECTED"
52
+ ]
53
+ },
54
+ {
55
+ "id": "partial_ayah",
56
+ "title_ar": "اقتباس جزئي من آية",
57
+ "title_en": "Exact partial quotation of a longer ayah",
58
+ "scenario": "partial",
59
+ "text": "قال الله تعالى: \"۞ وَقَضَىٰ رَبُّكَ أَلَّا تَعْبُدُوا إِلَّا\".",
60
+ "built_from": [
61
+ "Quran 17:23 (first six words)"
62
+ ],
63
+ "observed_statuses": [
64
+ "VERIFIED"
65
+ ]
66
+ },
67
+ {
68
+ "id": "uncertain_hadith",
69
+ "title_ar": "حديث مركّب من حديثين",
70
+ "title_en": "Hadith assembled from halves of two different Hadith",
71
+ "scenario": "uncertain",
72
+ "text": "روى البخاري أن النبي صلى الله عليه وسلم قال: \"إِنَّمَا الْأَعْمَالُ بِالنِّيَّاتِ ، وَإِنَّمَا لِكُلِّ امْرِئٍ مَا نَوَى ، فَمَنْ كَانَتْ هِجْرَتُهُ أَهْلَ الْأَرْضِ يَرْحَمْكُمْ مَنْ فِي السَّمَاءِ\".",
73
+ "built_from": [
74
+ "Hadith #5 (first half)",
75
+ "Hadith #95766 (second half)"
76
+ ],
77
+ "observed_statuses": [
78
+ "HUMAN_REVIEW"
79
+ ]
80
+ },
81
+ {
82
+ "id": "no_quotation",
83
+ "title_ar": "نص بلا اقتباسات",
84
+ "title_en": "Text without any quotation",
85
+ "scenario": "none",
86
+ "text": "الصبر من أهم الصفات التي ينبغي أن يتحلى بها الإنسان في حياته، ويساعده على مواجهة المصاعب بثبات وهدوء.",
87
+ "built_from": [],
88
+ "observed_statuses": []
89
+ },
90
+ {
91
+ "id": "mixed_response",
92
+ "title_ar": "رد مختلط",
93
+ "title_en": "Mixed response: correct ayah, correct Hadith, modified ayah",
94
+ "scenario": "mixed",
95
+ "text": "بخصوص بر الوالدين، قال الله تعالى: \"۞ وَقَضَىٰ رَبُّكَ أَلَّا تَعْبُدُوا إِلَّا إِيَّاهُ وَبِالْوَالِدَيْنِ إِحْسَانًا ۚ إِمَّا يَبْلُغَنَّ عِنْدَكَ الْكِبَرَ أَحَدُهُمَا أَوْ كِلَاهُمَا فَلَا تَقُلْ لَهُمَا أُفٍّ وَلَا تَنْهَرْهُمَا وَقُلْ لَهُمَا قَوْلًا كَرِيمًا\". وقال رسول الله صلى الله عليه وسلم: \"الرَّاحِمُونَ يَرْحَمُهُمُ الرَّحْمَنُ ، ارْحَمُوا أَهْلَ الْأَرْضِ يَرْحَمْكُمْ مَنْ فِي السَّمَاءِ\". وقال سبحانه: \"يَا أَيُّهَا آمَنُوا اسْتَعِينُوا بِالصَّبْرِ وَالصَّلَاةِ ۚ إِنَّ اللَّهَ مَعَ الصَّابِرِينَ\". وهذا من مكارم الأخلاق.",
96
+ "built_from": [
97
+ "Quran 17:23",
98
+ "Hadith #95766",
99
+ "Quran 2:153 (word 3 removed)"
100
+ ],
101
+ "observed_statuses": [
102
+ "VERIFIED",
103
+ "VERIFIED",
104
+ "HUMAN_REVIEW"
105
+ ]
106
+ }
107
+ ]
hadith.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f1f514d7a2de2d4179d9588a861cedf07e81630886444f37d0c08c2cc777075a
3
+ size 48498788
index.html ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="ar" dir="rtl">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Islamic Content Verifier</title>
7
+ <style>
8
+ @import url('https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&family=Amiri:wght@400;700&display=swap');
9
+ .gradio-container {
10
+ --body-background-fill:#0a1128; --block-background-fill:#101a38; --block-border-color:#1e2d57;
11
+ --input-background-fill:#0d1631; --body-text-color:#e8eefc; --block-label-text-color:#9fb3da;
12
+ --button-primary-background-fill:#14b8a6; --button-primary-background-fill-hover:#22d3ee; --button-primary-text-color:#04202a;
13
+ --button-secondary-background-fill:#16244a; --button-secondary-text-color:#e8eefc; --button-secondary-border-color:#2a3d73;
14
+ background:#0a1128 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='80' viewBox='0 0 80 80'%3E%3Cg fill='none' stroke='%2322d3ee' stroke-opacity='0.07' stroke-width='1'%3E%3Crect x='20' y='20' width='40' height='40'/%3E%3Crect x='20' y='20' width='40' height='40' transform='rotate(45 40 40)'/%3E%3C/g%3E%3C/svg%3E"); font-family:'Cairo','Segoe UI',Tahoma,sans-serif; max-width:1080px !important; color:#e8eefc;
15
+ }
16
+ .hero { text-align:center; padding:34px 12px 18px; direction:rtl; }
17
+ .hero .eyebrow { color:#22d3ee; letter-spacing:.14em; font-size:.8rem; text-transform:uppercase; }
18
+ .hero h1 { font-size:2.3rem; margin:.3rem 0 .1rem; color:#fff; }
19
+ .hero h2 { font-size:1.05rem; font-weight:400; color:#9fb3da; margin:0 0 .9rem; direction:ltr; }
20
+ .hero p { max-width:760px; margin:.3rem auto; color:#c7d4f0; line-height:1.9; }
21
+ .hero .en { direction:ltr; color:#8ea3cf; font-size:.92rem; }
22
+ .input-area textarea { direction:rtl; text-align:right; font-family:'Amiri','Cairo',serif !important; font-size:1.2rem !important; line-height:2 !important; }
23
+ .btn-row button { font-weight:700 !important; border-radius:12px !important; }
24
+ .disclaimer { font-size:.82rem; color:#8ea3cf; text-align:center; padding:12px 8px; direction:rtl; line-height:1.8; }
25
+ .disclaimer .en { display:block; direction:ltr; }
26
+ .results { direction:rtl; text-align:right; }
27
+ .results .en, .notice .en { display:block; direction:ltr; text-align:left; color:#8ea3cf; font-size:.88rem; font-weight:400; }
28
+ .summary { display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); gap:10px; margin:6px 0 14px; }
29
+ .tile { background:#101a38; border:1px solid #1e2d57; border-radius:14px; padding:12px; text-align:center; }
30
+ .tile b { display:block; font-size:1.8rem; color:#fff; } .tile span { display:block; font-size:.9rem; } .tile small { color:#8ea3cf; }
31
+ .tile.verified { border-color:#14b8a6; } .tile.verified b { color:#2dd4bf; }
32
+ .tile.mismatch { border-color:#f43f5e; } .tile.mismatch b { color:#fb7185; }
33
+ .tile.review { border-color:#f59e0b; } .tile.review b { color:#fbbf24; }
34
+ .highlight { background:#0d1631; border:1px solid #1e2d57; border-radius:14px; padding:14px 16px; margin-bottom:14px; }
35
+ .highlight label, .field label { display:block; color:#7e93c0; font-size:.78rem; margin-bottom:4px; }
36
+ .highlight p { font-family:'Amiri','Cairo',serif; font-size:1.15rem; line-height:2.1; margin:0; white-space:pre-wrap; }
37
+ mark { color:#fff; border-radius:6px; padding:1px 4px; }
38
+ mark.verified { background:rgba(20,184,166,.35); } mark.mismatch { background:rgba(244,63,94,.35); } mark.review { background:rgba(245,158,11,.35); }
39
+ .qcard { background:#101a38; border:1px solid #1e2d57; border-inline-start:5px solid #3b4b7a; border-radius:16px; padding:16px 18px; margin:12px 0; }
40
+ .qcard.verified { border-inline-start-color:#14b8a6; } .qcard.mismatch { border-inline-start-color:#f43f5e; } .qcard.review { border-inline-start-color:#f59e0b; }
41
+ .qhead { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:10px; }
42
+ .idx { background:#1b2a55; border-radius:50%; width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; font-weight:700; }
43
+ .badge { padding:3px 12px; border-radius:999px; font-size:.82rem; background:#16244a; border:1px solid #2a3d73; }
44
+ .badge.st.verified { background:rgba(20,184,166,.18); border-color:#14b8a6; color:#5eead4; }
45
+ .badge.st.mismatch { background:rgba(244,63,94,.16); border-color:#f43f5e; color:#fda4af; }
46
+ .badge.st.review { background:rgba(245,158,11,.16); border-color:#f59e0b; color:#fcd34d; }
47
+ .conf { margin-inline-start:auto; color:#9fb3da; font-size:.88rem; } .conf b { color:#22d3ee; }
48
+ .field { margin:8px 0; } .field p { margin:0; line-height:1.8; }
49
+ .quote { font-family:'Amiri','Cairo',serif; font-size:1.2rem; line-height:2.1; color:#fff; } .quote.small { font-size:1.05rem; color:#d6e0f7; }
50
+ .diff { font-family:'Amiri','Cairo',serif; font-size:1.1rem; line-height:2.1; }
51
+ .w-extra { background:rgba(244,63,94,.25); border-radius:4px; padding:0 3px; text-decoration:line-through; }
52
+ .w-missing { background:rgba(20,184,166,.28); border-radius:4px; padding:0 3px; }
53
+ .legend { color:#8ea3cf; font-size:.82rem; margin:4px 0 0; } .legend .w-extra { text-decoration:none; }
54
+ .tile.zero { opacity:.5; }
55
+ .det-chip { text-align:center; color:#8ea3cf; font-size:.82rem; margin:2px 0 8px; } .det-chip b { color:#22d3ee; font-weight:600; }
56
+ .hl-legend { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; font-size:.78rem; } .hl-legend mark { padding:2px 10px; }
57
+ .flow { display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:8px; margin:16px 0 4px; direction:rtl; }
58
+ .flow span { background:#101a38; border:1px solid #1e2d57; border-radius:999px; padding:5px 14px; font-size:.85rem; color:#cfe0ff; }
59
+ .flow span small { color:#7e93c0; margin-inline-start:6px; direction:ltr; display:inline-block; }
60
+ .flow i { color:#22d3ee; font-style:normal; }
61
+ .hero .mark { width:52px; height:52px; margin:0 auto 6px; display:block; }
62
+ .hero h1 { background:linear-gradient(90deg,#fff,#a5f3fc); -webkit-background-clip:text; background-clip:text; color:transparent; }
63
+ .action { border-radius:12px; padding:10px 14px; margin-top:10px; line-height:1.8; }
64
+ .action.ok { background:rgba(20,184,166,.12); border:1px solid #14b8a6; }
65
+ .action.warn { background:rgba(245,158,11,.12); border:1px solid #f59e0b; }
66
+ .action.bad { background:rgba(244,63,94,.12); border:1px solid #f43f5e; }
67
+ .evidence { margin-top:12px; border-top:1px dashed #2a3d73; padding-top:8px; }
68
+ .evidence summary { cursor:pointer; color:#22d3ee; font-weight:700; }
69
+ .ev-row { margin:10px 0; } .ev-row b { color:#9fb3da; font-size:.85rem; } .ev-row p { margin:2px 0; }
70
+ .indicators { display:grid; gap:6px; margin-top:6px; }
71
+ .ind { display:grid; grid-template-columns:150px 1fr 48px; gap:10px; align-items:center; font-size:.85rem; }
72
+ .ind-name small { display:block; color:#7e93c0; font-size:.72rem; direction:ltr; text-align:right; }
73
+ .bar { background:#0d1631; border-radius:999px; height:8px; overflow:hidden; } .bar span { display:block; height:100%; background:linear-gradient(90deg,#14b8a6,#22d3ee); }
74
+ .ind-val { text-align:left; direction:ltr; color:#cfe0ff; } .ind-val.wide { grid-column:2 / span 2; text-align:right; }
75
+ .notice { background:#101a38; border:1px solid #1e2d57; border-radius:14px; padding:16px; direction:rtl; line-height:1.9; }
76
+ .notice.warn { border-color:#f59e0b; } .notice.bad { border-color:#f43f5e; }
77
+ @media (max-width:640px){ .hero h1{font-size:1.7rem;} .ind{grid-template-columns:110px 1fr 40px;} }
78
+
79
+ html,body{margin:0;background:#0a1128;}
80
+ .gradio-container{min-height:100vh;box-sizing:border-box;padding:8px 16px 24px;margin:0 auto;}
81
+ .input-area textarea{width:100%;box-sizing:border-box;min-height:220px;background:#0d1631;color:#e8eefc;border:1px solid #1e2d57;border-radius:14px;padding:14px;resize:vertical;outline:none;}
82
+ .input-area textarea:focus{border-color:#22d3ee;}
83
+ .input-area label{display:block;color:#9fb3da;font-size:.85rem;margin:0 0 6px;direction:rtl;text-align:right;}
84
+ .btn-row{display:flex;gap:12px;margin:12px 0 18px;flex-wrap:wrap;}
85
+ .btn-row button{flex:1 1 220px;border:0;border-radius:12px;padding:13px 18px;font-size:1rem;font-weight:700;cursor:pointer;font-family:inherit;}
86
+ #verify{background:#14b8a6;color:#04202a;} #verify:hover{background:#22d3ee;}
87
+ #example{background:#16244a;color:#e8eefc;border:1px solid #2a3d73;} #example:hover{border-color:#22d3ee;}
88
+ .btn-row button:disabled{opacity:.45;cursor:not-allowed;}
89
+ #status{direction:rtl;text-align:center;color:#9fb3da;font-size:.92rem;margin:0 0 10px;min-height:1.4em;}
90
+ #status .en{display:block;direction:ltr;color:#7e93c0;font-size:.82rem;}
91
+ .spinner{display:inline-block;width:14px;height:14px;border:2px solid #2a3d73;border-top-color:#22d3ee;border-radius:50%;animation:spin 1s linear infinite;vertical-align:middle;margin-inline-end:8px;}
92
+ @keyframes spin{to{transform:rotate(360deg)}}
93
+ </style>
94
+ </head>
95
+ <body>
96
+ <div class="gradio-container">
97
+
98
+ <div class="hero">
99
+ <svg class="mark" viewBox="0 0 52 52" fill="none" stroke="#22d3ee" stroke-width="1.6" aria-hidden="true">
100
+ <rect x="11" y="11" width="30" height="30"/><rect x="11" y="11" width="30" height="30" transform="rotate(45 26 26)"/>
101
+ <circle cx="26" cy="26" r="6" fill="#14b8a6" stroke="none"/></svg>
102
+ <div class="eyebrow">Islamic Content Verifier</div>
103
+ <h1>مُدقِّق المحتوى الإسلامي</h1>
104
+ <h2>Verify Quranic and Hadith quotations with evidence.</h2>
105
+ <p>الصق نصًا ولّده نموذج لغوي، وسيكتشف النظام الآيات والأحاديث الواردة فيه، ويسترجع نصوصها من المصادر، ويقارنها كلمةً بكلمة،
106
+ ثم يعرض الدليل. وإذا لم تكفِ الأدلة فلن يختلق تصحيحًا، بل يحيل الحالة إلى المراجعة البشرية.</p>
107
+ <p class="en">Paste text generated by a language model. The system detects Quran and Hadith quotations, retrieves the source texts,
108
+ compares them word by word and shows the evidence. When evidence is insufficient it never invents a correction: it recommends human review.</p>
109
+ <div class="flow"><span>كشف<small>Detect</small></span><i>←</i><span>استرجاع<small>Retrieve</small></span><i>←</i>
110
+ <span>تح��ق<small>Verify</small></span><i>←</i><span>دليل<small>Evidence</small></span><i>←</i><span>قرار<small>Decide</small></span></div>
111
+ </div>
112
+
113
+ <div class="input-area">
114
+ <label for="text">النص المراد التحقق منه · Text to verify</label>
115
+ <textarea id="text" dir="rtl" placeholder="الصق هنا الرد الذي ولّده النموذج اللغوي، ويفضَّل أن تكون الاقتباسات بين علامات تنصيص بعد عبارة مثل «قال الله تعالى» أو «قال رسول الله ﷺ»…"></textarea>
116
+ </div>
117
+ <div class="btn-row">
118
+ <button id="verify" disabled>تحقق من النص · Verify Text</button>
119
+ <button id="example" disabled>جرّب مثالًا · Try an Example</button>
120
+ </div>
121
+ <div id="status"></div>
122
+ <div id="results" class="results"></div>
123
+
124
+ <div class="disclaimer">أداة مساعدة للتدقيق النصي وليست فتوى ولا بديلًا عن المراجعة المتخصصة. النتائج مبنية على مراجع القرآن والكتب الستة المضمّنة فقط.
125
+ <span class="en">A text-verification aid, not a religious ruling and not a substitute for expert review. Results rely only on the bundled Quran and Six Books corpora.</span></div>
126
+
127
+ </div>
128
+ <script type="text/plain" id="worker-src">
129
+ // Runs Pyodide and the verification code off the main thread so the page stays responsive while the index builds.
130
+ let verify = null;
131
+ const post = (message) => self.postMessage(message);
132
+ const status = (ar, en) => post({ type: "status", ar, en });
133
+
134
+ async function fetchBytes(url, onProgress) {
135
+ const response = await fetch(url);
136
+ if (!response.ok) throw new Error("Could not download " + url + " (" + response.status + ")");
137
+ const total = Number(response.headers.get("Content-Length")) || 0;
138
+ if (!response.body || !onProgress) return new Uint8Array(await response.arrayBuffer());
139
+ const reader = response.body.getReader(), chunks = []; let received = 0;
140
+ for (;;) {
141
+ const { done, value } = await reader.read();
142
+ if (done) break;
143
+ chunks.push(value); received += value.length; onProgress(received, total);
144
+ }
145
+ const bytes = new Uint8Array(received); let offset = 0;
146
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
147
+ return bytes;
148
+ }
149
+
150
+ // Accept both layouts: files inside data/ and demo/ folders, or uploaded flat next to index.html.
151
+ async function fetchAny(names, base, onProgress) {
152
+ let lastError = null;
153
+ for (const name of names) {
154
+ for (const path of [name, name.split("/").pop()]) {
155
+ try { return await fetchBytes(new URL(path, base).href, onProgress); } catch (error) { lastError = error; }
156
+ }
157
+ }
158
+ throw lastError;
159
+ }
160
+
161
+ async function boot(message) {
162
+ const url = (file) => new URL(file, message.base).href;
163
+ status("جارٍ تحميل بيئة بايثون…", "Loading the Python runtime…");
164
+ importScripts("https://cdn.jsdelivr.net/pyodide/v" + message.pyodideVersion + "/full/pyodide.js");
165
+ const pyodide = await loadPyodide();
166
+
167
+ status("جارٍ تحميل الكود والمراجع…", "Downloading code and corpora…");
168
+ for (const dir of ["/app", "/app/data", "/app/demo"]) pyodide.FS.mkdir(dir);
169
+ for (const file of message.files) pyodide.FS.writeFile("/app/" + file, await fetchAny([file], message.base));
170
+ const hadith = await fetchAny(["data/hadith.json.gz", "data/hadith.json"], message.base, (got, total) => {
171
+ const mb = (got / 1048576).toFixed(1), of = total ? " / " + (total / 1048576).toFixed(1) : "";
172
+ status("جارٍ تحميل كتب الحديث… " + mb + of + " MB", "Downloading the Hadith corpus… " + mb + of + " MB");
173
+ });
174
+ const isGzip = hadith[0] === 0x1f && hadith[1] === 0x8b; // the file may be the compressed or the plain JSON copy
175
+ pyodide.FS.writeFile(isGzip ? "/app/data/hadith.json.gz" : "/app/data/hadith.json", hadith);
176
+ const examples = JSON.parse(new TextDecoder().decode(pyodide.FS.readFile("/app/demo/examples.json")));
177
+
178
+ status("جارٍ بناء فهرس المصادر (مرة واحدة، قد يستغرق دقيقة)…", "Building the source index (once, may take up to a minute)…");
179
+ pyodide.runPython("import sys; sys.path.insert(0, '/app')");
180
+ pyodide.runPython("from app import get_pipeline, verify_text; get_pipeline()");
181
+ verify = pyodide.globals.get("verify_text");
182
+ post({ type: "ready", examples });
183
+ }
184
+
185
+ self.onmessage = async (event) => {
186
+ const message = event.data;
187
+ if (message.type === "boot") {
188
+ try { await boot(message); }
189
+ catch (error) { post({ type: "error", message: String((error && error.message) || error) }); }
190
+ } else if (message.type === "verify") {
191
+ try { post({ type: "result", id: message.id, html: verify(message.text) }); }
192
+ catch (error) { post({ type: "result", id: message.id, html: null }); }
193
+ }
194
+ };
195
+ </script>
196
+ <script>
197
+ const PYODIDE_VERSION = "0.26.4";
198
+ const PYTHON_FILES = ["app.py", "verifier.py", "retrieval.py"];
199
+ const $ = (id) => document.getElementById(id);
200
+ let examples = [], exampleIndex = 0, nextId = 0, worker = null;
201
+ const pending = new Map();
202
+
203
+ function setStatus(ar, en, busy = true) {
204
+ $("status").innerHTML = (busy ? '<span class="spinner"></span>' : "") + ar + '<span class="en">' + en + "</span>";
205
+ }
206
+
207
+ function fail(message) {
208
+ console.error(message);
209
+ setStatus("تعذّر تشغيل النظام. جرّب متصفح كمبيوتر حديثًا ثم أعد تحميل الصفحة.",
210
+ "The verifier could not start (" + message + "). Try a modern desktop browser and reload the page.", false);
211
+ }
212
+
213
+ function ask(text) {
214
+ return new Promise((resolve) => {
215
+ const id = ++nextId;
216
+ pending.set(id, resolve);
217
+ worker.postMessage({ type: "verify", id, text });
218
+ });
219
+ }
220
+
221
+ async function run() {
222
+ if (!worker) return;
223
+ $("verify").disabled = $("example").disabled = true;
224
+ setStatus("جارٍ التحقق…", "Verifying…");
225
+ const html = await ask($("text").value);
226
+ $("results").innerHTML = html !== null ? html
227
+ : '<div class="notice bad">حدث خطأ غير متوقع.<span class="en">An unexpected error occurred.</span></div>';
228
+ setStatus("", "", false);
229
+ $("verify").disabled = $("example").disabled = false;
230
+ }
231
+
232
+ function start() {
233
+ try {
234
+ const source = $("worker-src").textContent;
235
+ worker = new Worker(URL.createObjectURL(new Blob([source], { type: "text/javascript" })));
236
+ } catch (error) { return fail(error.message); }
237
+ worker.onerror = (event) => fail(event.message || "worker error");
238
+ worker.onmessage = (event) => {
239
+ const message = event.data;
240
+ if (message.type === "status") setStatus(message.ar, message.en);
241
+ else if (message.type === "error") fail(message.message);
242
+ else if (message.type === "result") { const resolve = pending.get(message.id); pending.delete(message.id); resolve(message.html); }
243
+ else if (message.type === "ready") {
244
+ examples = message.examples;
245
+ $("verify").disabled = $("example").disabled = false;
246
+ setStatus("جاهز. الكود يعمل داخل متصفحك ولا يُرسل النص إلى أي خادم.",
247
+ "Ready. The code runs in your browser; your text is not sent to any server.", false);
248
+ }
249
+ };
250
+ worker.postMessage({ type: "boot", base: location.href, pyodideVersion: PYODIDE_VERSION,
251
+ files: PYTHON_FILES.concat(["demo/examples.json", "data/quran.json"]) });
252
+ }
253
+
254
+ $("verify").addEventListener("click", run);
255
+ $("example").addEventListener("click", () => {
256
+ if (!examples.length) return;
257
+ $("text").value = examples[exampleIndex % examples.length].text;
258
+ exampleIndex += 1;
259
+ run();
260
+ });
261
+ start();
262
+ </script>
263
+ </body>
264
+ </html>
quran.json ADDED
The diff for this file is too large to render. See raw diff
 
retrieval.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arabic text normalisation and source retrieval over the Quran and Hadith corpora.
2
+
3
+ The retriever is the single shared index used by both verification and correction. It was extracted from the
4
+ IslamicEval 2025 research notebook (``research/IslamicEval_Unified.ipynb``) and keeps its retrieval logic:
5
+
6
+ * Quran: word-vote retrieval of single ayahs (coverage / precision F1) plus IDF-weighted seeds for
7
+ multi-ayah window search.
8
+ * Hadith: IDF word-vote recall followed by a character 4-gram cosine re-rank.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import gzip
13
+ import json
14
+ import logging
15
+ import math
16
+ import os
17
+ import re
18
+ import unicodedata
19
+ from collections import Counter, defaultdict
20
+ from pathlib import Path
21
+ from typing import Dict, Iterable, List, Optional, Sequence, Tuple
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ BASE_DIR = Path(__file__).resolve().parent
26
+ DEFAULT_QURAN_PATH = BASE_DIR / "data" / "quran.json"
27
+ DEFAULT_HADITH_PATH = BASE_DIR / "data" / "hadith.json"
28
+
29
+ # --------------------------------------------------------------------------------------------------------------
30
+ # Arabic normalisation
31
+ # --------------------------------------------------------------------------------------------------------------
32
+ _DIACRITICS = re.compile(
33
+ r"[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0640]"
34
+ )
35
+ _PUNCTUATION = re.compile(r"[،؛؟!،.,:;'\"()\[\]{}<>«»\-_/\\|@#$%^&*+=~`]")
36
+ _MULTI_SPACE = re.compile(r"\s+")
37
+ _ALEF_VARIANTS = re.compile(r"[أإآٱ]")
38
+ _MATCH_DIACRITICS = re.compile(r"[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E8\u06EA-\u06ED\u0640]")
39
+
40
+ STOPWORDS = frozenset(
41
+ """من في على ان أن إن الى إلى عن مع ما لا لم لن قد و ثم أو او هو هي هم انت أنتم كان كانت يكون تكون قال قالت
42
+ هذا هذه ذلك تلك الذي التي الذين اللاتي اللائي كل بعض غير عند بين حتى إذا اذا لو لكن بل يا أيها ايها""".split()
43
+ )
44
+
45
+
46
+ def normalize_strict(text: Optional[str]) -> str:
47
+ """Remove diacritics, unify alef variants and strip punctuation (keeps ta marbuta / alef maqsura)."""
48
+ if not text:
49
+ return ""
50
+ text = unicodedata.normalize("NFC", text)
51
+ text = _DIACRITICS.sub("", text)
52
+ text = _ALEF_VARIANTS.sub("ا", text)
53
+ text = _PUNCTUATION.sub(" ", text)
54
+ return _MULTI_SPACE.sub(" ", text).strip()
55
+
56
+
57
+ def normalize_lenient(text: Optional[str]) -> str:
58
+ """Strict normalisation plus ta-marbuta/ha and alef-maqsura/ya folding (used for Hadith, whose spelling varies)."""
59
+ return normalize_strict(text).replace("ة", "ه").replace("ى", "ي")
60
+
61
+
62
+ def normalize_for_matching(text: Optional[str]) -> str:
63
+ """Aggressive normalisation used for window matching: keeps only Arabic letters and single spaces."""
64
+ if not text:
65
+ return ""
66
+ text = _MATCH_DIACRITICS.sub("", text)
67
+ for src, dst in (("أ", "ا"), ("إ", "ا"), ("آ", "ا"), ("ٱ", "ا"), ("ؤ", "و"), ("ئ", "ي"), ("ة", "ه"), ("ى", "ي")):
68
+ text = text.replace(src, dst)
69
+ text = re.sub(r"[^\u0600-\u06FF\s]", " ", text)
70
+ return re.sub(r"\s+", " ", text).strip()
71
+
72
+
73
+ def tokenize(text: str) -> List[str]:
74
+ return [token for token in text.split() if token]
75
+
76
+
77
+ def content_words(tokens: Iterable[str]) -> List[str]:
78
+ """Drop stop-words and single-letter tokens."""
79
+ return [t for t in tokens if t not in STOPWORDS and len(t) > 1]
80
+
81
+
82
+ def _char_ngrams(text: str, n: int = 4) -> set:
83
+ return {text[i : i + n] for i in range(len(text) - n + 1)}
84
+
85
+
86
+ # --------------------------------------------------------------------------------------------------------------
87
+ # Retriever
88
+ # --------------------------------------------------------------------------------------------------------------
89
+ class CorpusError(RuntimeError):
90
+ """Raised when a corpus file is missing or malformed."""
91
+
92
+
93
+ class SourceRetriever:
94
+ """In-memory index over ``quran.json`` and ``hadith.json`` (builds in a few seconds)."""
95
+
96
+ def __init__(
97
+ self,
98
+ quran_path: os.PathLike = DEFAULT_QURAN_PATH,
99
+ hadith_path: os.PathLike = DEFAULT_HADITH_PATH,
100
+ ) -> None:
101
+ quran_path, hadith_path = Path(quran_path), Path(hadith_path)
102
+ gzipped = hadith_path.with_name(hadith_path.name + ".gz")
103
+ if not hadith_path.is_file() and gzipped.is_file(): # compressed copy used by the in-browser deployment
104
+ hadith_path = gzipped
105
+ for path in (quran_path, hadith_path):
106
+ if not path.is_file():
107
+ raise CorpusError(f"Corpus file not found: {path}")
108
+
109
+ self.__dict__.update(self._build(quran_path, hadith_path))
110
+
111
+ n_ayahs, n_hadith = len(self.quran), len(self.hadith)
112
+ self.quran_idf = {w: math.log((n_ayahs + 1) / (len(d) + 1)) + 1 for w, d in self.q_content_index.items()}
113
+ self.hadith_idf = {w: math.log((n_hadith + 1) / (len(d) + 1)) + 1 for w, d in self.h_content_index.items()}
114
+ logger.info("Retriever ready: %d ayahs, %d hadith records", n_ayahs, n_hadith)
115
+
116
+ # ---- construction ---------------------------------------------------------------------------------------
117
+ @staticmethod
118
+ def _read_json(path: Path) -> list:
119
+ try:
120
+ opener = gzip.open if path.suffix == ".gz" else open
121
+ with opener(path, "rt", encoding="utf-8") as handle:
122
+ data = json.load(handle)
123
+ except (OSError, json.JSONDecodeError) as exc:
124
+ raise CorpusError(f"Cannot read corpus file {path}: {exc}") from exc
125
+ if not isinstance(data, list):
126
+ raise CorpusError(f"Unexpected corpus format in {path}: expected a JSON list")
127
+ return data
128
+
129
+ def _build(self, quran_path: Path, hadith_path: Path) -> dict:
130
+ quran: List[dict] = []
131
+ q_norm_match: List[str] = []
132
+ q_all_index: Dict[str, List[int]] = defaultdict(list)
133
+ q_content_index: Dict[str, List[int]] = defaultdict(list)
134
+ q_word_count: List[int] = []
135
+ by_surah: Dict[int, Dict[int, int]] = defaultdict(dict)
136
+
137
+ for entry in self._read_json(quran_path):
138
+ text = (entry.get("ayah_text") or "").strip()
139
+ if not text:
140
+ continue
141
+ idx = len(quran)
142
+ quran.append(
143
+ {
144
+ "surah_id": entry.get("surah_id"),
145
+ "surah_name": entry.get("surah_name", ""),
146
+ "ayah_id": entry.get("ayah_id"),
147
+ "text": text,
148
+ }
149
+ )
150
+ by_surah[entry.get("surah_id")][entry.get("ayah_id")] = idx
151
+ q_norm_match.append(normalize_for_matching(text))
152
+ tokens = tokenize(normalize_strict(text))
153
+ q_word_count.append(len(tokens))
154
+ for word in set(tokens):
155
+ q_all_index[word].append(idx)
156
+ for word in set(content_words(q_norm_match[-1].split())):
157
+ q_content_index[word].append(idx)
158
+
159
+ hadith: List[dict] = []
160
+ h_content_index: Dict[str, List[int]] = defaultdict(list)
161
+ for entry in self._read_json(hadith_path):
162
+ if not entry:
163
+ continue
164
+ matn = (entry.get("Matn") or "").strip() or None
165
+ full = (entry.get("hadithTxt") or "").strip() or None
166
+ if not (matn or full):
167
+ continue
168
+ record = {
169
+ "hadithID": entry.get("hadithID"),
170
+ "book": entry.get("BookID"),
171
+ "title": entry.get("title"),
172
+ "matn": matn,
173
+ "full": full,
174
+ "norm_matn": normalize_for_matching(matn) if matn else None,
175
+ "norm_full": normalize_for_matching(full) if full else None,
176
+ }
177
+ idx = len(hadith)
178
+ hadith.append(record)
179
+ words = set()
180
+ for key in ("norm_matn", "norm_full"):
181
+ if record[key]:
182
+ words |= set(content_words(record[key].split()))
183
+ for word in words:
184
+ h_content_index[word].append(idx)
185
+
186
+ return {
187
+ "quran": quran,
188
+ "quran_by_surah": dict(by_surah),
189
+ "q_norm_match": q_norm_match,
190
+ "q_all_index": dict(q_all_index),
191
+ "q_content_index": dict(q_content_index),
192
+ "q_word_count": q_word_count,
193
+ "hadith": hadith,
194
+ "h_content_index": dict(h_content_index),
195
+ }
196
+
197
+ # ---- Quran ----------------------------------------------------------------------------------------------
198
+ def search_quran_ayahs(self, query: str, top_k: int = 25, extra_seeds: int = 10) -> List[dict]:
199
+ """Single-ayah candidates ranked by the F1 of query coverage and ayah precision.
200
+
201
+ The F1 ranking favours short ayahs, so a fragment of a long ayah can be missed; the top IDF-voted ayahs
202
+ (rare-word matches) are appended as extra candidates to recover those partial quotations."""
203
+ query_words = tokenize(normalize_strict(query))
204
+ if not query_words:
205
+ return []
206
+ votes: Dict[int, int] = defaultdict(int)
207
+ for word in query_words:
208
+ for idx in self.q_all_index.get(word, ()):
209
+ votes[idx] += 1
210
+ scored: List[Tuple[int, float]] = []
211
+ for idx, vote in votes.items():
212
+ coverage = vote / len(query_words)
213
+ precision = vote / self.q_word_count[idx] if self.q_word_count[idx] else 0.0
214
+ f1 = 2 * coverage * precision / (coverage + precision) if coverage + precision > 0 else 0.0
215
+ scored.append((idx, f1))
216
+ scored.sort(key=lambda item: item[1], reverse=True)
217
+ ranked = [idx for idx, _ in scored[:top_k]]
218
+ scores = dict(scored)
219
+ seeds = self.quran_seed_ayahs(content_words(normalize_for_matching(query).split()), extra_seeds)
220
+ ranked += [idx for idx in seeds if idx not in set(ranked)]
221
+ results = []
222
+ for idx in ranked:
223
+ candidate = dict(self.quran[idx])
224
+ candidate.update(type="Quran", retrieval_score=scores.get(idx, 0.0))
225
+ results.append(candidate)
226
+ return results
227
+
228
+ def quran_seed_ayahs(self, query_words: Sequence[str], top_k: int = 25) -> List[int]:
229
+ """IDF-voted ayah indices used to seed the multi-ayah window search."""
230
+ return [idx for idx, _ in self.vote(self.q_content_index, self.quran_idf, query_words, top_k)]
231
+
232
+ # ---- Hadith ---------------------------------------------------------------------------------------------
233
+ def search_hadith(self, query: str, top_k: int = 15, pool: int = 40) -> List[dict]:
234
+ """IDF word-vote recall, then re-rank by character 4-gram cosine similarity."""
235
+ words = content_words(normalize_for_matching(query).split())
236
+ if not words:
237
+ return []
238
+ query_grams = _char_ngrams(normalize_lenient(query))
239
+ results = []
240
+ for idx, _ in self.vote(self.h_content_index, self.hadith_idf, words, pool):
241
+ record = self.hadith[idx]
242
+ text = record["matn"] or record["full"]
243
+ doc_grams = _char_ngrams(normalize_lenient(text))
244
+ cosine = (
245
+ len(query_grams & doc_grams) / ((len(query_grams) * len(doc_grams)) ** 0.5)
246
+ if query_grams and doc_grams
247
+ else 0.0
248
+ )
249
+ results.append(
250
+ {
251
+ "type": "Hadith",
252
+ "idx": idx,
253
+ "hadithID": record["hadithID"],
254
+ "book": record["book"],
255
+ "title": record["title"],
256
+ "text": text,
257
+ "has_matn": bool(record["matn"]),
258
+ "retrieval_score": cosine,
259
+ }
260
+ )
261
+ results.sort(key=lambda c: c["retrieval_score"], reverse=True)
262
+ return results[:top_k]
263
+
264
+ # ---- shared ---------------------------------------------------------------------------------------------
265
+ @staticmethod
266
+ def vote(index: Dict[str, List[int]], idf: Dict[str, float], words: Iterable[str], top_k: int) -> List[Tuple[int, float]]:
267
+ scores: Counter = Counter()
268
+ for word in set(words):
269
+ weight = idf.get(word, 0.0)
270
+ if weight <= 0:
271
+ continue
272
+ for doc in index.get(word, ()):
273
+ scores[doc] += weight
274
+ return scores.most_common(top_k)
verifier.py ADDED
@@ -0,0 +1,933 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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