Upload 8 files
Browse files- .gitattributes +1 -0
- README.md +16 -0
- app.py +420 -0
- examples.json +107 -0
- hadith.json +3 -0
- index.html +264 -0
- quran.json +0 -0
- retrieval.py +274 -0
- 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
|