Ghada-99-Ragab commited on
Commit
597dbb9
·
verified ·
1 Parent(s): 63bb5d8

Upload 26 files

Browse files
Files changed (27) hide show
  1. .gitattributes +1 -0
  2. README.md +12 -5
  3. README_AR.md +5 -0
  4. _headers +7 -0
  5. alignment.py +180 -0
  6. app.py +177 -0
  7. benchmark_format.py +115 -0
  8. camelbert_adapter.py +103 -0
  9. config.json +7 -0
  10. detector.py +159 -0
  11. examples.json +147 -0
  12. hadith.idx.gz +3 -0
  13. hadith.json +3 -0
  14. idgham.py +71 -0
  15. index.html +513 -17
  16. index_builder.py +163 -0
  17. islamic_unified_dataset.jsonl +0 -0
  18. islamiceval_dev_subset.jsonl +0 -0
  19. llm_client.py +96 -0
  20. normalization.py +117 -0
  21. quran.idx.gz +3 -0
  22. quran.json +0 -0
  23. retrieval.py +187 -0
  24. scanner.py +316 -0
  25. similarity.py +121 -0
  26. ui.py +537 -0
  27. verifier.py +504 -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 CHANGED
@@ -1,10 +1,17 @@
1
  ---
2
- title: Finallll
3
- emoji: 📊
4
- colorFrom: red
5
- colorTo: green
6
  sdk: static
 
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
1
  ---
2
+ title: Quran & Hadith Verification
3
+ emoji: 🕌
4
+ colorFrom: green
5
+ colorTo: yellow
6
  sdk: static
7
+ app_file: index.html
8
  pinned: false
9
+ license: mit
10
+ short_description: Verify Quran and Hadith quotations
11
  ---
12
 
13
+ # Quran & Hadith Verification
14
+
15
+ Finds Quran verses and Hadith in text and verifies them against their sources.
16
+
17
+ Arabic version: [README_AR.md](README_AR.md)
README_AR.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # التحقق من هلوسة القرآن والحديث وتصحيحها
2
+
3
+ يكتشف الآيات والأحاديث في أي نص، ويعرض الدليل، ويقترح التصحيح من نص المصدر.
4
+ يعمل الكود كاملًا داخل متصفحك (بايثون عبر Pyodide)، ولا يُرسل النص الملصوق إلى أي خادم.
5
+ النسخة الإنجليزية: [README.md](README.md).
_headers ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ /*
2
+ X-Content-Type-Options: nosniff
3
+ Referrer-Policy: no-referrer
4
+ /index/*
5
+ Cache-Control: public, max-age=86400
6
+ /config.json
7
+ Cache-Control: no-store
alignment.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Word-level sequence alignment between a quotation and a source text.
2
+
3
+ * Phonetic skeletons (``normalization.aligned_words``) make Uthmani / modern spelling and diacritics irrelevant for
4
+ matching, while every substituted, missing, extra or re-ordered word still shows up as an edit operation.
5
+ * A dynamic sliding window locates the best-matching region inside long sources (Hadith with chains of narrators).
6
+ * The gap threshold scales with quotation length: a short quotation tolerates no stray words, a long one a few.
7
+ * Diacritic conflicts are reported separately as notes, because spelling of harakat differs legitimately between prints.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from difflib import SequenceMatcher
12
+ from math import ceil
13
+ from typing import Container, Dict, List, Optional, Sequence, Tuple
14
+
15
+ from idgham import apply_idgham
16
+ from normalization import aligned_words, vowel_signature
17
+
18
+
19
+ def lcs_length(a: Sequence[str], b: Sequence[str]) -> int:
20
+ m, n = len(a), len(b)
21
+ if m == 0 or n == 0:
22
+ return 0
23
+ if m < n:
24
+ a, b, m, n = b, a, n, m
25
+ prev = [0] * (n + 1)
26
+ for i in range(m):
27
+ curr = [0] * (n + 1)
28
+ for j in range(n):
29
+ curr[j + 1] = prev[j] + 1 if a[i] == b[j] else max(curr[j], prev[j + 1])
30
+ prev = curr
31
+ return prev[n]
32
+
33
+
34
+ def allowed_gap(n_tokens: int) -> int:
35
+ """Dynamic gap threshold: how many differing words still count as 'the same passage, altered'."""
36
+ return 0 if n_tokens <= 3 else max(1, ceil(0.2 * n_tokens))
37
+
38
+
39
+ def find_window(quote: List[str], source: List[str], gap: int) -> Tuple[int, int]:
40
+ """Best source region for the quotation: windows of ``len(quote)+gap`` ranked by shared words, refined by LCS."""
41
+ n, window = len(quote), len(quote) + gap
42
+ if len(source) <= window + 2 * gap + 8:
43
+ return 0, len(source)
44
+ quote_set = set(quote)
45
+ prefix = [0]
46
+ for word in source:
47
+ prefix.append(prefix[-1] + (word in quote_set))
48
+ starts = sorted(range(len(source) - window + 1), key=lambda i: prefix[i + window] - prefix[i], reverse=True)[:8]
49
+ best_start = max(starts, key=lambda i: lcs_length(quote, source[i : i + window]))
50
+ return max(0, best_start - gap), min(len(source), best_start + window + gap)
51
+
52
+
53
+ def contains_sequence(source: Sequence[str], quote: Sequence[str]) -> bool:
54
+ """True if ``quote`` appears as a contiguous run of whole words in ``source``."""
55
+ n = len(quote)
56
+ return n > 0 and any(source[i : i + n] == list(quote) for i in range(len(source) - n + 1))
57
+
58
+
59
+ def locate_region(q_norm: List[str], s_norm: List[str], gap: int, slack: Optional[int] = None):
60
+ """Region of the source that corresponds to the quotation (matched part plus slack for edge differences).
61
+
62
+ Returns ``(lo, hi, matcher)`` where the matcher compares the quotation with ``s_norm[lo:hi]``."""
63
+ slack = gap if slack is None else slack
64
+ lo, hi = find_window(q_norm, s_norm, gap)
65
+ matcher = SequenceMatcher(None, q_norm, s_norm[lo:hi], autojunk=False)
66
+ blocks = [b for b in matcher.get_matching_blocks() if b.size]
67
+ if blocks:
68
+ first, last = blocks[0], blocks[-1]
69
+ base = lo
70
+ lo = base + max(0, first.b - first.a - slack)
71
+ hi = min(hi, base + last.b + last.size + (len(q_norm) - last.a - last.size) + slack)
72
+ matcher = SequenceMatcher(None, q_norm, s_norm[lo:hi], autojunk=False)
73
+ return lo, hi, matcher
74
+
75
+
76
+ def best_region(quote: str, source: str) -> str:
77
+ """The part of ``source`` that a (possibly partial) quotation refers to, as original words.
78
+
79
+ Scoring a snippet against the whole verse / Hadith would punish it for being shorter than its source; comparing it
80
+ with this region keeps the score about what was actually quoted."""
81
+ q_pairs, s_pairs = aligned_words(quote), aligned_words(source)
82
+ if not q_pairs or len(s_pairs) <= len(q_pairs) + allowed_gap(len(q_pairs)) + 2:
83
+ return source
84
+ q_norm, s_norm = [p[1] for p in q_pairs], [p[1] for p in s_pairs]
85
+ lo, hi, _ = locate_region(q_norm, s_norm, allowed_gap(len(q_norm)), slack=0)
86
+ return " ".join(p[0] for p in s_pairs[lo:hi]) if hi > lo else source
87
+
88
+
89
+ def _diacritic_notes(quote_words: List[str], source_words: List[str], idgham_words: List[str]) -> List[dict]:
90
+ """Words whose written harakat contradict the source (missing harakat are fine; idgham spelling is accepted)."""
91
+ notes = []
92
+ for q_word, s_word, g_word in zip(quote_words, source_words, idgham_words):
93
+ q_sig = vowel_signature(q_word)
94
+ if not any(marks for _, marks in q_sig):
95
+ continue
96
+ conflict = True
97
+ for variant in (s_word, g_word):
98
+ v_sig = vowel_signature(variant)
99
+ if len(v_sig) == len(q_sig) and all(set(qm) <= set(vm) for (_, qm), (_, vm) in zip(q_sig, v_sig)):
100
+ conflict = False
101
+ break
102
+ if conflict:
103
+ notes.append({"word": q_word, "source_word": s_word})
104
+ return notes
105
+
106
+
107
+ def _orthographic_variant(q_tokens: Sequence[str], s_tokens: Sequence[str], vocabulary: Optional[Container[str]]) -> bool:
108
+ """Same letters written differently: spacing (ياأيها / يا أيها) or a medial alef (إسحق / إسحاق).
109
+
110
+ The alef variant is tolerated only when the quoted spelling is not itself a word of the corpus (so قتل / قاتل stay a
111
+ real difference)."""
112
+ if "".join(q_tokens) == "".join(s_tokens):
113
+ return True
114
+ if vocabulary is not None and len(q_tokens) == 1 and len(s_tokens) == 1:
115
+ q, s = q_tokens[0], s_tokens[0]
116
+ return q.replace("ا", "") == s.replace("ا", "") and q not in vocabulary
117
+ return False
118
+
119
+
120
+ def align(quote: str, source: str, vocabulary: Optional[Container[str]] = None) -> Dict[str, object]:
121
+ """Align ``quote`` to the best matching region of ``source`` and describe every difference.
122
+
123
+ ``vocabulary``: phonetic-skeleton words of the corpus; enables the medial-alef spelling tolerance."""
124
+ q_pairs, s_pairs = aligned_words(quote), aligned_words(source)
125
+ idgham_pairs = aligned_words(apply_idgham(source)) if len(source) < 20000 else s_pairs
126
+ if len(idgham_pairs) != len(s_pairs):
127
+ idgham_pairs = s_pairs
128
+ q_orig, q_norm = [p[0] for p in q_pairs], [p[1] for p in q_pairs]
129
+ s_orig, s_norm = [p[0] for p in s_pairs], [p[1] for p in s_pairs]
130
+ g_orig = [p[0] for p in idgham_pairs]
131
+
132
+ gap = allowed_gap(len(q_norm))
133
+ lo, hi, matcher = locate_region(q_norm, s_norm, gap)
134
+
135
+ raw_ops = [(tag, i1, i2, j1 + lo, j2 + lo) for tag, i1, i2, j1, j2 in matcher.get_opcodes()]
136
+ # Source words before / after a partial quotation are not errors unless the quotation itself contains them elsewhere
137
+ # (a moved word); drop such edge insertions.
138
+ deleted = {w for tag, i1, i2, _, _ in raw_ops if tag in ("delete", "replace") for w in q_norm[i1:i2]}
139
+ for position in (0, -1):
140
+ if len(raw_ops) > 1 and raw_ops[position][0] == "insert" and not (set(s_norm[raw_ops[position][3]:raw_ops[position][4]]) & deleted):
141
+ raw_ops.pop(position)
142
+
143
+ ops, quote_side, source_side, missing, extra, notes = [], [], [], [], [], []
144
+ mismatches = 0
145
+ orthographic = 0
146
+ for position, (tag, i1, i2, j1, j2) in enumerate(raw_ops):
147
+ source_idx = list(range(j1, j2))
148
+ if tag == "replace" and _orthographic_variant(q_norm[i1:i2], s_norm[j1:j2], vocabulary):
149
+ tag, orthographic = "equal", orthographic + 1 # spelling-only difference, not a different word
150
+ if tag == "replace" and position in (0, len(raw_ops) - 1) and len(raw_ops) > 1 and (j2 - j1) > (i2 - i1):
151
+ # at the edges the slack may have pulled in unrelated source words: keep only words the quotation also uses
152
+ keep = [j for j in source_idx if s_norm[j] in set(q_norm[i1:i2]) | deleted]
153
+ source_idx = keep
154
+ tag = "replace" if keep else "delete"
155
+ ops.append({"op": tag, "span": " ".join(q_orig[i1:i2]), "source": " ".join(s_orig[j] for j in source_idx)})
156
+ if tag == "equal":
157
+ notes += _diacritic_notes(q_orig[i1:i2], s_orig[j1:j2], g_orig[j1:j2])
158
+ else:
159
+ mismatches += max(i2 - i1, len(source_idx))
160
+ quote_side += q_norm[i1:i2]
161
+ source_side += [s_norm[j] for j in source_idx]
162
+ extra += q_orig[i1:i2] if tag in ("delete", "replace") else []
163
+ missing += [s_orig[j] for j in source_idx] if tag in ("insert", "replace") else []
164
+
165
+ matched = sum(i2 - i1 for tag, i1, i2, _, _ in raw_ops if tag == "equal")
166
+ exact = bool(q_norm) and mismatches == 0
167
+ return {
168
+ "word_similarity": round(2 * matched / max(len(q_norm) + (raw_ops[-1][4] - raw_ops[0][3] if raw_ops else 0), 1), 3),
169
+ "word_diff": ops,
170
+ "missing_from_span": missing,
171
+ "extra_in_span": extra,
172
+ "source_excerpt": " ".join(op["source"] for op in ops if op["source"]),
173
+ "exact": exact,
174
+ "mismatches": mismatches,
175
+ "allowed_gap": gap,
176
+ "near": (not exact) and matched > 0 and mismatches <= gap,
177
+ "reordered": bool(quote_side) and sorted(quote_side) == sorted(source_side),
178
+ "diacritic_notes": notes,
179
+ "orthographic_variants": orthographic,
180
+ }
app.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """التحقق من هلوسة القرآن والحديث وتصحيحها: Gradio interface.
2
+
3
+ python app.py # http://127.0.0.1:7860
4
+
5
+ Mode A verifies pasted text with the chosen detection engine. Mode B ("ask then verify") sends the question to a single
6
+ pre-configured OpenAI client and verifies the answer; the key comes from the OPENAI_API_KEY environment variable only
7
+ (never from the form, never from the repository). The in-browser page (build_static_space.py) shares these handlers.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import threading
15
+ from pathlib import Path
16
+ from typing import List, Optional
17
+
18
+ import ui
19
+ from camelbert_adapter import analyze_with_spans, entities_to_spans, query_hosted_model, simulate_spans
20
+ from llm_client import LLMError, generate
21
+ from verifier import MAX_INPUT_CHARS, IslamicContentVerifier
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ EXAMPLES_PATH = Path(__file__).resolve().parent / "demo" / "examples.json"
26
+
27
+ # Detection engines offered in the UI (English labels by design; the first is the default).
28
+ ENGINES = [
29
+ ("Standard · Rules + Corpus Scan", "standard"),
30
+ ("CAMeLBERT-MSA · Fine-tuned (Hugging Face)", "camelbert"),
31
+ ]
32
+
33
+ _pipeline: Optional[IslamicContentVerifier] = None
34
+ _lock = threading.Lock()
35
+
36
+
37
+ def get_pipeline() -> IslamicContentVerifier:
38
+ """Created once. The Quran index loads immediately; the Hadith index loads lazily (see ``warm_in_background``)."""
39
+ global _pipeline
40
+ with _lock:
41
+ if _pipeline is None:
42
+ _pipeline = IslamicContentVerifier()
43
+ return _pipeline
44
+
45
+
46
+ def warm_in_background() -> None:
47
+ threading.Thread(target=lambda: get_pipeline().retriever.warm(), daemon=True).start()
48
+
49
+
50
+ def load_examples(path: Path = EXAMPLES_PATH) -> List[dict]:
51
+ try:
52
+ with open(path, encoding="utf-8") as handle:
53
+ return json.load(handle)
54
+ except (OSError, json.JSONDecodeError):
55
+ logger.exception("Could not load demo examples from %s", path)
56
+ return []
57
+
58
+
59
+ def _analyze(text: str, engine: str = "standard") -> dict:
60
+ """Run the pipeline with the selected detection engine. The CAMeLBERT engine uses a hosted model when ``ICV_HF_MODEL``
61
+ is set and reachable; otherwise it runs as a simulation on top of the bundled detector (and says so)."""
62
+ pipeline = get_pipeline()
63
+ if engine != "camelbert":
64
+ return pipeline.analyze(text)
65
+ model = os.environ.get("ICV_HF_MODEL", "").strip()
66
+ if model:
67
+ try:
68
+ spans = query_hosted_model(text, model, os.environ.get("HF_TOKEN", ""))
69
+ return analyze_with_spans(pipeline, text, spans, engine="camelbert")
70
+ except RuntimeError:
71
+ logger.warning("Hosted CAMeLBERT model unavailable; using the simulation")
72
+ return analyze_with_spans(pipeline, text, simulate_spans(pipeline, text), engine="camelbert-simulated")
73
+
74
+
75
+ def verify_with_entities(text: str, entities_json: str, engine: str = "camelbert", generated: bool = False) -> str:
76
+ """Browser path: the page called the hosted token-classification model and passes its raw entities here."""
77
+ try:
78
+ spans = entities_to_spans(text, json.loads(entities_json or "[]"))
79
+ result = analyze_with_spans(get_pipeline(), text, spans, engine=engine)
80
+ return ui.render_results(result, generated_answer=text if generated else None)
81
+ except ValueError:
82
+ return ui.render_message(f"النص طويل جدًا (الحد الأقصى {MAX_INPUT_CHARS} حرف).", "warn")
83
+ except Exception:
84
+ logger.exception("Verification with external spans failed")
85
+ return ui.render_message("حدث خطأ غير متوقع أثناء التحقق.", "bad")
86
+
87
+
88
+ def verify_text(text: str, engine: str = "standard") -> str:
89
+ """Mode A. Never raises: problems become Arabic notices."""
90
+ if not text or not text.strip():
91
+ return ui.render_message("الرجاء إدخال نص للتحقق منه.", "warn")
92
+ try:
93
+ return ui.render_results(_analyze(text, engine))
94
+ except ValueError:
95
+ return ui.render_message(f"النص طويل جدًا (الحد الأقصى {MAX_INPUT_CHARS} حرف).", "warn")
96
+ except Exception:
97
+ logger.exception("Verification failed")
98
+ return ui.render_message("حدث خطأ غير متوقع أثناء التحقق.", "bad")
99
+
100
+
101
+ def verify_generated_answer(answer: str, engine: str = "standard") -> str:
102
+ """Verify a model answer and show it above the report (also used by the in-browser page)."""
103
+ try:
104
+ return ui.render_results(_analyze(answer, engine), generated_answer=answer)
105
+ except Exception:
106
+ logger.exception("Verification of the generated answer failed")
107
+ return ui.render_message("تعذّر التحقق من إجابة النموذج.", "bad")
108
+
109
+
110
+ def ask_then_verify(prompt: str, engine: str = "standard") -> str:
111
+ """Mode B: ask the pre-configured OpenAI client, then verify every quotation in its answer."""
112
+ try:
113
+ answer = generate(None, prompt)
114
+ except LLMError as exc:
115
+ return ui.render_message(str(exc), "warn")
116
+ return verify_generated_answer(answer, engine)
117
+
118
+
119
+ def build_interface():
120
+ import gradio as gr
121
+
122
+ examples = load_examples()
123
+
124
+ def next_example(index: int):
125
+ if not examples:
126
+ return "", 0
127
+ return examples[index % len(examples)]["text"], (index + 1) % len(examples)
128
+
129
+ theme = gr.themes.Base(primary_hue="emerald", neutral_hue="stone")
130
+ with gr.Blocks(title=ui.APP_TITLE, css=ui.CSS, theme=theme, head=f"<script>{ui.COPY_JS}</script>") as demo:
131
+ gr.HTML(ui.HERO)
132
+ with gr.Tabs():
133
+ with gr.Tab("تحقّق مباشر"):
134
+ example_index = gr.State(0)
135
+ engine = gr.Dropdown(choices=ENGINES, value="standard", label="Detection engine", elem_classes="engine-select")
136
+ text_input = gr.Textbox(label="النص المراد التحقق منه", lines=9, max_lines=24, placeholder=ui.PLACEHOLDER,
137
+ rtl=True, elem_classes="input-area")
138
+ with gr.Row():
139
+ verify_button = gr.Button("تحقّق من النص", variant="primary", scale=3)
140
+ example_button = gr.Button("جرّب مثالًا", variant="secondary", scale=2)
141
+ results = gr.HTML(elem_classes="results")
142
+ verify_button.click(verify_text, inputs=[text_input, engine], outputs=results)
143
+ example_button.click(next_example, inputs=example_index, outputs=[text_input, example_index]).then(
144
+ verify_text, inputs=[text_input, engine], outputs=results)
145
+ with gr.Tab("اسأل ثم تحقّق"):
146
+ gr.HTML('<div class="icv"><div class="notice">اكتب سؤالًا، وسيجيب عنه النظام مباشرةً، ثم يفحص كل آية '
147
+ 'وحديث ورد في الإجابة ويعرض الأخطاء والتصحيحات.</div></div>')
148
+ prompt = gr.Textbox(label="سؤالك", lines=3, placeholder=ui.PROMPT_PLACEHOLDER, rtl=True, elem_classes="input-area")
149
+ ask_button = gr.Button("اسأل ثم تحقّق", variant="primary")
150
+ answer_results = gr.HTML(elem_classes="results")
151
+ ask_button.click(ask_then_verify, inputs=[prompt, engine], outputs=answer_results)
152
+ gr.HTML(ui.DISCLAIMER)
153
+ return demo
154
+
155
+
156
+ def _load_dotenv(path: Path = Path(__file__).resolve().parent / ".env") -> None:
157
+ """Minimal ``.env`` reader for local runs (no extra dependency); existing environment variables win."""
158
+ try:
159
+ lines = path.read_text(encoding="utf-8").splitlines()
160
+ except OSError:
161
+ return
162
+ for line in lines:
163
+ name, sep, value = line.strip().partition("=")
164
+ if sep and name and not name.startswith("#") and value.strip():
165
+ os.environ.setdefault(name.strip(), value.strip().strip('"').strip("'"))
166
+
167
+
168
+ def main() -> None:
169
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
170
+ _load_dotenv()
171
+ get_pipeline()
172
+ warm_in_background()
173
+ build_interface().queue().launch(share=os.environ.get("ICV_SHARE") == "1")
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()
benchmark_format.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """IslamicEval 2025 (Subtask 1) input / output formats.
2
+
3
+ The pipeline result is converted to the three official submission files:
4
+
5
+ 1A Question_ID <TAB> Span_Start <TAB> Span_End <TAB> Span_Type Span_Type = Ayah | Hadith | No_Spans (end exclusive)
6
+ 1B Sequence_ID <TAB> Label Sequence_ID = <QID>_<annotation number>; Correct | Incorrect
7
+ 1C Sequence_ID <TAB> Correction Sequence_ID = <QID>_<start>_<end>; only spans judged Incorrect;
8
+ 'خطأ' when there is no source / no safe correction
9
+
10
+ The layout follows the competition-format routine of the project's research notebook, which was written against the
11
+ organisers' scoring code. It is **not** re-validated against the live CodaBench scorer here (the organisers' repository
12
+ could not be fetched when this module was written), so compare with the official sample files before a real submission.
13
+
14
+ Command line: python benchmark_format.py responses.xml outputs/ (XML: <Question><ID/><Response/></Question> ...)
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import csv
19
+ import io
20
+ import json
21
+ import re
22
+ import sys
23
+ from pathlib import Path
24
+ from typing import Dict, Iterable, List, Sequence, Tuple
25
+
26
+ NO_SOURCE = "خطأ"
27
+ FILE_NAMES = ("task1A_predictions.tsv", "task1B_predictions.tsv", "task1C_predictions.tsv")
28
+ Row = Tuple
29
+
30
+
31
+ def _clean(value) -> str:
32
+ return str(value).replace("\t", " ").replace("\r", " ").replace("\n", " ").strip()
33
+
34
+
35
+ def competition_rows(result: dict, qid: str) -> Tuple[List[Row], List[Row], List[Row]]:
36
+ """``(rows_1A, rows_1B, rows_1C)`` for one analysed response."""
37
+ a, b, c = [], [], []
38
+ spans = result["spans"]
39
+ if not spans:
40
+ a.append((qid, 0, 0, "No_Spans"))
41
+ for k, span in enumerate(spans, 1):
42
+ a.append((qid, span["start"], span["end"], span["type"]))
43
+ verdict = span["verification"]["verdict"]
44
+ b.append((f"{qid}_{k}", verdict))
45
+ if verdict == "Incorrect":
46
+ correction = span["correction"]["text"] if (span["status"] == "CORRECTED" and span["correction"]) else NO_SOURCE
47
+ c.append((f"{qid}_{span['start']}_{span['end']}", _clean(correction)))
48
+ return a, b, c
49
+
50
+
51
+ def to_tsv(rows: Iterable[Row]) -> str:
52
+ return "".join("\t".join(str(x) for x in row) + "\n" for row in rows)
53
+
54
+
55
+ def official_payload(result: dict, qid: str = "Q1") -> Dict[str, str]:
56
+ """The three files as TSV strings (used by the browser page for downloads)."""
57
+ return {name: to_tsv(rows) for name, rows in zip(FILE_NAMES, competition_rows(result, qid))}
58
+
59
+
60
+ def write_competition_files(all_rows: Sequence[Sequence[Row]], out_dir: str = "outputs") -> List[Path]:
61
+ out = Path(out_dir)
62
+ out.mkdir(parents=True, exist_ok=True)
63
+ paths = []
64
+ for name, rows in zip(FILE_NAMES, all_rows):
65
+ path = out / name
66
+ path.write_text(to_tsv(rows), encoding="utf-8", newline="")
67
+ paths.append(path)
68
+ return paths
69
+
70
+
71
+ def load_responses(xml_path: str) -> Dict[str, str]:
72
+ """``{question id: response}`` from the competition XML."""
73
+ data = Path(xml_path).read_text(encoding="utf-8")
74
+ out = {}
75
+ for block in re.findall(r"<Question>(.*?)</Question>", data, re.S):
76
+ ident, response = re.search(r"<ID>(.*?)</ID>", block, re.S), re.search(r"<Response>(.*?)</Response>", block, re.S)
77
+ if ident and response:
78
+ out[ident.group(1).strip()] = response.group(1)
79
+ return out
80
+
81
+
82
+ def read_tsv(path: str) -> List[dict]:
83
+ """Rows of a TSV with a header line (gold files)."""
84
+ with open(path, encoding="utf-8", newline="") as handle:
85
+ rows = list(csv.reader(handle, delimiter="\t"))
86
+ return [dict(zip(rows[0], row)) for row in rows[1:]] if rows else []
87
+
88
+
89
+ def run_on_texts(pipeline, texts: Dict[str, str], out_dir: str = "outputs") -> Dict[str, dict]:
90
+ """Analyse ``{qid: text}``, write the three official TSVs plus ``full_report.json`` into ``out_dir``."""
91
+ acc: Tuple[List[Row], List[Row], List[Row]] = ([], [], [])
92
+ results = {}
93
+ for qid, text in texts.items():
94
+ result = pipeline.analyze(text)
95
+ results[qid] = result
96
+ for target, part in zip(acc, competition_rows(result, qid)):
97
+ target.extend(part)
98
+ write_competition_files(acc, out_dir)
99
+ (Path(out_dir) / "full_report.json").write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
100
+ return results
101
+
102
+
103
+ def main(argv: Sequence[str]) -> int:
104
+ if len(argv) < 2:
105
+ print(__doc__)
106
+ return 2
107
+ from verifier import IslamicContentVerifier
108
+
109
+ results = run_on_texts(IslamicContentVerifier(), load_responses(argv[1]), argv[2] if len(argv) > 2 else "outputs")
110
+ print(f"{len(results)} responses written to {argv[2] if len(argv) > 2 else 'outputs'}/")
111
+ return 0
112
+
113
+
114
+ if __name__ == "__main__":
115
+ sys.exit(main(sys.argv))
camelbert_adapter.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adapter for a fine-tuned CAMeLBERT-MSA span detector (Subtask 1A) hosted on Hugging Face, plus a simulation mode.
2
+
3
+ Two ways to get detection spans into the verification pipeline:
4
+
5
+ * ``entities_to_spans`` turns the output of a Hugging Face *token-classification* call (aggregated entities, or raw
6
+ ``B-Ayah`` / ``I-Ayah`` / ``B-Hadith`` / ``I-Hadith`` tokens) into ``[{label, start, end, score}]``.
7
+ The browser page calls the hosted model and hands the entities to ``verify_with_entities``.
8
+ * ``simulate_spans`` a stand-in used when no hosted model is configured or reachable: the bundled hybrid detector
9
+ produces the spans, and they are labelled as simulated so nobody mistakes them for model output.
10
+
11
+ No model weights live in this repository. Train with ``research/train_detector.py`` (``--model CAMeL-Lab/bert-base-arabic-camelbert-msa``),
12
+ push the result to the Hub and set its id in the page configuration (see docs/DEPLOYMENT.md).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import urllib.error
19
+ import urllib.request
20
+ from typing import Dict, Iterable, List, Optional
21
+
22
+ HF_ENDPOINT = "https://router.huggingface.co/hf-inference/models/{model}" # configurable; not verified against the live service
23
+
24
+ LABEL_ALIASES = {"ayah": "Ayah", "quran": "Ayah", "label_1": "Ayah", "label_2": "Ayah",
25
+ "hadith": "Hadith", "label_3": "Hadith", "label_4": "Hadith"}
26
+ DEFAULT_MIN_SCORE = 0.5
27
+
28
+
29
+ def _label_of(raw: str) -> Optional[str]:
30
+ name = re.sub(r"^[BI]-", "", str(raw or ""), flags=re.I).strip().lower()
31
+ return LABEL_ALIASES.get(name)
32
+
33
+
34
+ def entities_to_spans(text: str, entities: Iterable[dict], min_score: float = DEFAULT_MIN_SCORE,
35
+ max_gap: int = 1) -> List[dict]:
36
+ """Merge Hugging Face token-classification output into character spans.
37
+
38
+ Works with ``aggregation_strategy="simple"`` output (``entity_group``) and with raw output (``entity`` = ``B-Ayah`` ...).
39
+ Pieces of the same label separated by at most ``max_gap`` characters are joined; low-score pieces are dropped."""
40
+ pieces = []
41
+ for item in entities or []:
42
+ label = _label_of(item.get("entity_group") or item.get("entity"))
43
+ start, end = item.get("start"), item.get("end")
44
+ if label is None or start is None or end is None or end <= start:
45
+ continue
46
+ pieces.append({"label": label, "start": int(start), "end": int(end), "score": float(item.get("score", 1.0)),
47
+ "begin": str(item.get("entity", "")).upper().startswith("B-")})
48
+ pieces.sort(key=lambda p: (p["start"], p["end"]))
49
+ merged: List[dict] = []
50
+ for piece in pieces:
51
+ last = merged[-1] if merged else None
52
+ if last and last["label"] == piece["label"] and not piece["begin"] and piece["start"] - last["end"] <= max_gap:
53
+ last["end"] = max(last["end"], piece["end"])
54
+ last["scores"].append(piece["score"])
55
+ else:
56
+ merged.append({"label": piece["label"], "start": piece["start"], "end": piece["end"], "scores": [piece["score"]]})
57
+ spans = []
58
+ for item in merged:
59
+ start, end = item["start"], item["end"]
60
+ while start < end and text[start].isspace():
61
+ start += 1
62
+ while end > start and text[end - 1].isspace():
63
+ end -= 1
64
+ score = sum(item["scores"]) / len(item["scores"])
65
+ if end > start and score >= min_score:
66
+ spans.append({"label": item["label"], "start": start, "end": end, "score": round(score, 4)})
67
+ return spans
68
+
69
+
70
+ def simulate_spans(pipeline, text: str) -> List[dict]:
71
+ """Spans from the bundled hybrid detector, flagged as simulated (``score`` is ``None``)."""
72
+ return [{"label": s.label, "start": s.start, "end": s.end, "score": None} for s in pipeline.detect(text)]
73
+
74
+
75
+ def analyze_with_spans(pipeline, text: str, spans: List[dict], engine: str = "camelbert") -> dict:
76
+ """Run verification + correction on externally detected spans and record which engine produced them."""
77
+ result = pipeline.analyze_spans(text, [{"label": s["label"], "start": s["start"], "end": s["end"]} for s in spans])
78
+ scores: Dict[tuple, Optional[float]] = {(s["start"], s["end"]): s.get("score") for s in spans}
79
+ for report in result["spans"]:
80
+ score = scores.get((report["start"], report["end"]))
81
+ report["detection"] = {"backend": engine, "confidence": None if score is None else round(score, 4)}
82
+ result["detector"] = engine
83
+ return result
84
+
85
+
86
+ def query_hosted_model(text: str, model: str, token: str = "", endpoint: str = HF_ENDPOINT, timeout: float = 30.0) -> List[dict]:
87
+ """Call a hosted token-classification model (server-side use: the local Gradio app and tests with a mock server).
88
+
89
+ Returns ``entities_to_spans`` output. Raises ``RuntimeError`` with a short message on any failure so the caller can fall
90
+ back to the simulation."""
91
+ headers = {"Content-Type": "application/json"}
92
+ if token:
93
+ headers["Authorization"] = f"Bearer {token}"
94
+ body = json.dumps({"inputs": text, "parameters": {"aggregation_strategy": "simple"}}).encode("utf-8")
95
+ request = urllib.request.Request(endpoint.format(model=model), data=body, headers=headers, method="POST")
96
+ try:
97
+ with urllib.request.urlopen(request, timeout=timeout) as response:
98
+ payload = json.loads(response.read().decode("utf-8"))
99
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
100
+ raise RuntimeError("hosted model unavailable") from exc
101
+ if not isinstance(payload, list):
102
+ raise RuntimeError("unexpected response from the hosted model")
103
+ return entities_to_spans(text, payload)
config.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "askEndpoint": "https://icv-ask-proxy.ghada-islamic-verifier-2026.workers.dev",
3
+ "hf": {
4
+ "model": "",
5
+ "endpoint": "https://router.huggingface.co/hf-inference/models/{model}"
6
+ }
7
+ }
detector.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quotation detection (Subtask 1A): finds Quran / Hadith quotations in a text and their boundaries.
2
+
3
+ The detector is rule-based: quotation marks and brackets mark candidate segments. The *type* of a segment (Ayah /
4
+ Hadith) is decided by the reference corpora themselves (word coverage against the Quran and the six Hadith books).
5
+ Introductory phrases such as "قال الله تعالى" or "قال رسول الله ﷺ" are only a tie-breaker / fallback hint, never a
6
+ requirement: a quotation is found and typed even when no such phrase precedes it. No model weights are loaded."""
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from typing import List, Optional
12
+
13
+ from normalization import normalize_for_matching, normalize_lenient, normalize_strict, tokenize
14
+
15
+
16
+ # --------------------------------------------------------------------------------------------------------------
17
+ @dataclass
18
+ class DetectedSpan:
19
+ start: int
20
+ end: int # exclusive
21
+ label: str # 'Ayah' | 'Hadith'
22
+ confidence: Optional[float] # None for the rule backend
23
+ source: str # 'rules' | 'given'
24
+ text: str = ""
25
+
26
+
27
+ QUOTE_CHARS = " \t\r\n\"“”«»﴿﴾{}()[]"
28
+
29
+
30
+ def trim_span(text: str, start: int, end: int):
31
+ """Drop whitespace and quotation marks at both edges (gold spans exclude the quote marks)."""
32
+ while start < end and text[start] in QUOTE_CHARS:
33
+ start += 1
34
+ while end > start and text[end - 1] in QUOTE_CHARS + ".،,؛:":
35
+ end -= 1
36
+ return start, end
37
+
38
+
39
+ AYAH_TRIGGERS = [
40
+ "قال الله", "قوله تعالى", "قال تعالى", "يقول الله", "يقول تعالى", "قال سبحانه", "قوله سبحانه", "في كتابه",
41
+ "سورة", "الآية", "الاية", "الآيات", "القرآن", "القران", "كتاب الله", "عز وجل", "جل جلاله", "فقال تعالى",
42
+ "ذكر الله", "﴿",
43
+ ]
44
+ HADITH_TRIGGERS = [
45
+ "رسول الله", "النبي", "صلى الله عليه وسلم", "ﷺ", "عليه الصلاة والسلام", "حديث", "رواه", "روى", "متفق عليه",
46
+ "الحديث", "فقال", "قال ص", "صلى الله عليه", "وسلم",
47
+ ]
48
+ _FORMULA_WORDS = {
49
+ normalize_for_matching(w)
50
+ for w in "قال قالت رسول الله صلى عليه وسلم النبي تعالى سبحانه عز وجل فقال يقول الكريم الشريف الحديث الآية روى رواه عن أن أنه البخاري ومسلم".split()
51
+ }
52
+ _BRACKET_PAIRS = [("“", "”"), ("«", "»"), ("﴿", "﴾"), ("{", "}"), ("(", ")"), ("[", "]")]
53
+
54
+
55
+ class RuleDetector:
56
+ """Quotation-mark and trigger-phrase detector with an optional corpus lookup (no training, no GPU)."""
57
+
58
+ def __init__(self, retriever=None, min_words: int = 3, context_chars: int = 110,
59
+ min_corpus_cov: float = 0.6, decouple_triggers: bool = True, quoted_min_cov: float = 0.5,
60
+ quoted_min_cov_hadith: float = 0.75) -> None:
61
+ """``decouple_triggers``: type a delimited segment from the corpora first and use introductory phrases only as a
62
+ hint (needs a retriever). ``quoted_min_cov``: lower coverage bar for text the author explicitly delimited."""
63
+ self.kb, self.min_words, self.context_chars, self.min_corpus_cov = retriever, min_words, context_chars, min_corpus_cov
64
+ self.decouple_triggers = decouple_triggers and retriever is not None
65
+ self.quoted_min_cov, self.quoted_min_cov_hadith = quoted_min_cov, quoted_min_cov_hadith
66
+
67
+ @staticmethod
68
+ def _segments(text: str):
69
+ segments = []
70
+ quote_positions = [m.start() for m in re.finditer('"', text)]
71
+ if len(quote_positions) % 2 == 0:
72
+ pairs = zip(quote_positions[0::2], quote_positions[1::2]) # opening/closing pairs
73
+ else: # a stray quote: fall back to every consecutive pair
74
+ pairs = zip(quote_positions, quote_positions[1:])
75
+ for a, b in pairs:
76
+ segments.append((a + 1, b))
77
+ for opener, closer in _BRACKET_PAIRS:
78
+ for m in re.finditer(re.escape(opener) + r"(.*?)" + re.escape(closer), text, re.S):
79
+ segments.append((m.start(1), m.end(1)))
80
+ return segments
81
+
82
+ @staticmethod
83
+ def _trigger_type(context: str) -> Optional[str]:
84
+ best_end, best_label = -1, None
85
+ for label, triggers in (("Ayah", AYAH_TRIGGERS), ("Hadith", HADITH_TRIGGERS)):
86
+ for trigger in triggers:
87
+ pos = context.rfind(trigger)
88
+ if pos >= 0 and pos + len(trigger) > best_end:
89
+ best_end, best_label = pos + len(trigger), label
90
+ return best_label
91
+
92
+ def _corpus_coverage(self, span: str):
93
+ """Highest word coverage of the span by any top Quran ayah / Hadith candidate."""
94
+ if self.kb is None:
95
+ return 0.0, 0.0
96
+ quran_words = set(tokenize(normalize_strict(span)))
97
+ if not quran_words:
98
+ return 0.0, 0.0
99
+ quran_cov = max(
100
+ (len(quran_words & set(tokenize(normalize_strict(c["text"])))) / len(quran_words)
101
+ for c in self.kb.search_quran_ayahs(span, top_k=5)),
102
+ default=0.0,
103
+ )
104
+ hadith_words = set(tokenize(normalize_lenient(span)))
105
+ hadith_cov = max(
106
+ (len(hadith_words & set(tokenize(normalize_lenient(c["text"])))) / len(hadith_words)
107
+ for c in self.kb.search_hadith(span, top_k=5)),
108
+ default=0.0,
109
+ ) if hadith_words else 0.0
110
+ return quran_cov, hadith_cov
111
+
112
+ def _label_from_corpus(self, n_words: int, trigger: Optional[str], quran_cov: float, hadith_cov: float) -> Optional[str]:
113
+ """Corpus-first typing. The introductory phrase only breaks near-ties or types an altered quotation whose
114
+ coverage is too low for the corpus to decide on its own."""
115
+ quran_ok = quran_cov >= self.quoted_min_cov
116
+ hadith_ok = hadith_cov >= self.quoted_min_cov_hadith # Hadith records are long: stricter, ordinary prose overlaps them
117
+ if (quran_ok or hadith_ok) and n_words >= 4:
118
+ if trigger and abs(quran_cov - hadith_cov) < 0.1:
119
+ return trigger
120
+ if quran_ok and hadith_ok:
121
+ return "Ayah" if quran_cov >= hadith_cov else "Hadith"
122
+ return "Ayah" if quran_ok else "Hadith"
123
+ return trigger # may be None: a delimited segment that matches nothing and has no hint is not reported
124
+
125
+ def detect(self, text: str) -> List[DetectedSpan]:
126
+ candidates = []
127
+ for start, end in self._segments(text):
128
+ start, end = trim_span(text, start, end)
129
+ if end <= start:
130
+ continue
131
+ inner = text[start:end]
132
+ words = [w for w in normalize_for_matching(inner).split() if w]
133
+ if len(words) < self.min_words or len(inner) > 3000:
134
+ continue
135
+ if sum(w in _FORMULA_WORDS for w in words) / len(words) >= 0.6:
136
+ continue
137
+ trigger = self._trigger_type(text[max(0, start - self.context_chars):start])
138
+ quran_cov, hadith_cov = self._corpus_coverage(inner)
139
+ label = None
140
+ if self.decouple_triggers:
141
+ label = self._label_from_corpus(len(words), trigger, quran_cov, hadith_cov)
142
+ elif trigger:
143
+ label = trigger
144
+ other, mine = (hadith_cov, quran_cov) if trigger == "Ayah" else (quran_cov, hadith_cov)
145
+ if other >= 0.8 and mine < 0.5:
146
+ label = "Hadith" if trigger == "Ayah" else "Ayah"
147
+ elif max(quran_cov, hadith_cov) >= self.min_corpus_cov and len(words) >= 4:
148
+ label = "Ayah" if quran_cov >= hadith_cov else "Hadith"
149
+ if label is None:
150
+ continue
151
+ candidates.append((bool(trigger), max(quran_cov, hadith_cov), end - start, start, end, label))
152
+
153
+ candidates.sort(key=lambda c: (c[0], c[1], c[2]), reverse=True) # trigger first, then corpus match, then length
154
+ taken = []
155
+ for _, _, _, start, end, label in candidates:
156
+ if all(end <= t_start or start >= t_end for t_start, t_end, _ in taken):
157
+ taken.append((start, end, label))
158
+ taken.sort()
159
+ return [DetectedSpan(s, e, label, None, "rules", text[s:e]) for s, e, label in taken]
examples.json ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "correct_ayah",
4
+ "title": "آية صحيحة",
5
+ "scenario": "correct",
6
+ "text": "قال الله تعالى: \"يَا أَيُّهَا الَّذِينَ آمَنُوا اسْتَعِينُوا بِالصَّبْرِ وَالصَّلَاةِ ۚ إِنَّ اللَّهَ مَعَ الصَّابِرِينَ\". وفي الآية بشارة لمن صبر.",
7
+ "built_from": [
8
+ "سورة البقرة 153"
9
+ ],
10
+ "observed_statuses": [
11
+ "VERIFIED"
12
+ ]
13
+ },
14
+ {
15
+ "id": "correct_hadith",
16
+ "title": "حديث صحيح",
17
+ "scenario": "correct",
18
+ "text": "وقال رسول الله ﷺ: \"إِنَّمَا الْأَعْمَالُ بِالنِّيَّاتِ ، وَإِنَّمَا لِكُلِّ امْرِئٍ مَا نَوَى ، فَمَنْ كَانَتْ هِجْرَتُهُ إِلَى دُنْيَا يُصِيبُهَا ، أَوْ إِلَى امْرَأَةٍ يَنْكِحُهَا ، فَهِجْرَتُهُ إِلَى مَا هَاجَرَ إِلَيْهِ\". ويستفاد منه أهمية النية.",
19
+ "built_from": [
20
+ "حديث رقم 5"
21
+ ],
22
+ "observed_statuses": [
23
+ "VERIFIED"
24
+ ]
25
+ },
26
+ {
27
+ "id": "substituted_word",
28
+ "title": "كلمة مستبدلة: «ثم استقم» بدل «فاستقم»",
29
+ "scenario": "altered",
30
+ "text": "قال الله تعالى: \"ثُمَّ اسْتَقِمْ كَمَا أُمِرْتَ وَمَنْ تَابَ مَعَكَ\".",
31
+ "built_from": [
32
+ "سورة هود 112 (فَاسْتَقِمْ ← ثُمَّ اسْتَقِمْ)"
33
+ ],
34
+ "observed_statuses": [
35
+ "CORRECTED"
36
+ ]
37
+ },
38
+ {
39
+ "id": "swapped_order",
40
+ "title": "تبديل ترتيب الكلمات",
41
+ "scenario": "altered",
42
+ "text": "قال الله تعالى: \"كَمَا أُمِرْتَ فَاسْتَقِمْ وَمَنْ تَابَ مَعَكَ\".",
43
+ "built_from": [
44
+ "سورة هود 112 (تقديم وتأخير)"
45
+ ],
46
+ "observed_statuses": [
47
+ "CORRECTED"
48
+ ]
49
+ },
50
+ {
51
+ "id": "wrong_diacritic",
52
+ "title": "تشكيل خاطئ لكلمة",
53
+ "scenario": "altered",
54
+ "text": "قال الله تعالى: \"فَاسْتَقَمْ كَمَا أُمِرْتَ وَمَنْ تَابَ مَعَكَ\".",
55
+ "built_from": [
56
+ "سورة هود 112 (فَاسْتَقِمْ ← فَاسْتَقَمْ)"
57
+ ],
58
+ "observed_statuses": [
59
+ "VERIFIED"
60
+ ]
61
+ },
62
+ {
63
+ "id": "missing_diacritics",
64
+ "title": "بدون تشكيل (مقبول)",
65
+ "scenario": "correct",
66
+ "text": "قال الله تعالى: \"فاستقم كما أمرت ومن تاب معك\".",
67
+ "built_from": [
68
+ "سورة هود 112 بلا تشكيل"
69
+ ],
70
+ "observed_statuses": [
71
+ "VERIFIED"
72
+ ]
73
+ },
74
+ {
75
+ "id": "partial_citation",
76
+ "title": "اقتباس جزئي من آية",
77
+ "scenario": "partial",
78
+ "text": "قال الله تعالى: \"۞ وَقَضَىٰ رَبُّكَ أَلَّا تَعْبُدُوا إِلَّا\".",
79
+ "built_from": [
80
+ "سورة الإسراء 23 (أول ست كلمات)"
81
+ ],
82
+ "observed_statuses": [
83
+ "VERIFIED"
84
+ ]
85
+ },
86
+ {
87
+ "id": "missing_word",
88
+ "title": "كلمة ناقصة",
89
+ "scenario": "altered",
90
+ "text": "قال الله تعالى: \"يَا أَيُّهَا آمَنُوا اسْتَعِينُوا بِالصَّبْرِ وَالصَّلَاةِ ۚ إِنَّ اللَّهَ مَعَ الصَّابِرِينَ\".",
91
+ "built_from": [
92
+ "سورة البقرة 153 (حُذفت الكلمة الثالثة)"
93
+ ],
94
+ "observed_statuses": [
95
+ "CORRECTED"
96
+ ]
97
+ },
98
+ {
99
+ "id": "unannounced_quote",
100
+ "title": "اقتباس غير معلَن بلا علامات تنصيص",
101
+ "scenario": "unannounced",
102
+ "text": "وهنا نتذكر أن العبد عليه أن يثبت وألا يتراجع، فاستقم كما أمرت ومن تاب معك ولا تطغوا، وهذا من أعظم ما يعين على الطاعة.",
103
+ "built_from": [
104
+ "سورة هود 112 داخل الجملة"
105
+ ],
106
+ "observed_statuses": [
107
+ "VERIFIED"
108
+ ]
109
+ },
110
+ {
111
+ "id": "uncertain_hadith",
112
+ "title": "حديث مركّب من حديثين",
113
+ "scenario": "uncertain",
114
+ "text": "روى البخاري أن النبي ﷺ قال: \"إِنَّمَا الْأَعْمَالُ بِالنِّيَّاتِ ، وَإِنَّمَا لِكُلِّ امْرِئٍ مَا نَوَى ، فَمَنْ كَانَتْ هِجْرَتُهُ أَهْلَ الْأَرْضِ يَرْحَمْكُمْ مَنْ فِي السَّمَاءِ\".",
115
+ "built_from": [
116
+ "حديث رقم 5 (النصف الأول)",
117
+ "حديث رقم 95766 (النصف الثاني)"
118
+ ],
119
+ "observed_statuses": [
120
+ "HUMAN_REVIEW"
121
+ ]
122
+ },
123
+ {
124
+ "id": "no_quotation",
125
+ "title": "نص بلا اقتباسات",
126
+ "scenario": "none",
127
+ "text": "الصبر من أهم الصفات التي ينبغي أن يتحلى بها الإنسان في حياته، ويساعده على مواجهة المصاعب بثبات وهدوء.",
128
+ "built_from": [],
129
+ "observed_statuses": []
130
+ },
131
+ {
132
+ "id": "mixed_response",
133
+ "title": "رد مختلط",
134
+ "scenario": "mixed",
135
+ "text": "بخصوص بر الوالدين، قال الله تعالى: \"۞ وَقَضَىٰ رَبُّكَ أَلَّا تَعْبُدُوا إِلَّا إِيَّاهُ وَبِالْوَالِدَيْنِ إِحْسَانًا ۚ إِمَّا يَبْلُغَنَّ عِنْدَكَ الْكِبَرَ أَحَدُهُمَا أَوْ كِلَاهُمَا فَلَا تَقُلْ لَهُمَا أُفٍّ وَلَا تَنْهَرْهُمَا وَقُلْ لَهُمَا قَوْلًا كَرِيمًا\". وقال رسول الله ﷺ: \"الرَّاحِمُونَ يَرْحَمُهُمُ الرَّحْمَنُ ، ارْحَمُوا أَهْلَ الْأَرْضِ يَرْحَمْكُمْ مَنْ فِي السَّمَاءِ\". وقال سبحانه: \"يَا أَيُّهَا آمَنُوا اسْتَعِينُوا بِالصَّبْرِ وَالصَّلَاةِ ۚ إِنَّ اللَّهَ مَعَ الصَّابِرِينَ\". وهذا من مكارم الأخلاق.",
136
+ "built_from": [
137
+ "سورة الإسراء 23",
138
+ "حديث رقم 95766",
139
+ "سورة البقرة 153 (كلمة ناقصة)"
140
+ ],
141
+ "observed_statuses": [
142
+ "VERIFIED",
143
+ "VERIFIED",
144
+ "CORRECTED"
145
+ ]
146
+ }
147
+ ]
hadith.idx.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6f5792317d7577220e0bd4b1aab84be1ed665c17f974ab7ba1cd8920f34c9955
3
+ size 13597012
hadith.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f1f514d7a2de2d4179d9588a861cedf07e81630886444f37d0c08c2cc777075a
3
+ size 48498788
idgham.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Idgham rendering of the Quran text in the published mushaf convention (used by the Subtask 1C gold corrections).
2
+
3
+ The flat Quran text writes assimilated letters without a shadda; the mushaf marks the assimilation. Covers noon
4
+ sakinah / tanween before ن م ر ل, meem sakinah before م and lam sakinah before ل ر, also across ayah-number markers
5
+ such as ``(20)``. The و / ي cases are excluded on purpose because the mushaf convention is inconsistent there."""
6
+ from __future__ import annotations
7
+
8
+ import re
9
+
10
+ _MARKER = re.compile(r"^\(\d+\)$")
11
+
12
+ _SUKUN, _SHADDA, _FATHATAN = "\u0652", "\u0651", "\u064B"
13
+ _TANWEEN = set("\u064B\u064C\u064D")
14
+ _IDGHAM_AFTER_NOON = set("نمرل")
15
+ _IDGHAM_AFTER_LAM = set("لر")
16
+ _DIACRITIC_CHARS = set(
17
+ "\u0610\u0611\u0612\u0613\u0614\u0615\u0616\u0617\u0618\u0619\u061A"
18
+ "\u064B\u064C\u064D\u064E\u064F\u0650\u0651\u0652\u0670"
19
+ "\u06D6\u06D7\u06D8\u06D9\u06DA\u06DB\u06DC\u06DF\u06E0\u06E1\u06E2\u06E3\u06E4"
20
+ "\u06E7\u06E8\u06EA\u06EB\u06EC\u06ED"
21
+ )
22
+
23
+
24
+ def _base_letters(word: str) -> str:
25
+ return "".join(c for c in word if c not in _DIACRITIC_CHARS)
26
+
27
+
28
+ def _insert_shadda(word: str) -> str:
29
+ return word if not word else word[0] + _SHADDA + word[1:]
30
+
31
+
32
+ def _ends_with_tanween(word: str) -> bool:
33
+ if not word:
34
+ return False
35
+ if word[-1] in _TANWEEN:
36
+ return True
37
+ return len(word) >= 2 and word[-1] in "اى" and word[-2] == _FATHATAN
38
+
39
+
40
+ def apply_idgham(text: str, extended: bool = True) -> str:
41
+ """Convert the flat Quran text into the mushaf rendering that marks assimilation with a shadda.
42
+
43
+ Covers noon sakinah / tanween before ن م ر ل, meem sakinah before م and lam sakinah before ل ر, also across
44
+ ayah-number markers such as ``(20)``. The و / ي cases are excluded on purpose because the mushaf convention
45
+ is inconsistent there."""
46
+ words = text.split(" ")
47
+ noon_set = _IDGHAM_AFTER_NOON if extended else set("نم")
48
+ i = 0
49
+ while i < len(words):
50
+ word = words[i]
51
+ if _MARKER.match(word) or not word:
52
+ i += 1
53
+ continue
54
+ j = i + 1
55
+ while j < len(words) and _MARKER.match(words[j]):
56
+ j += 1
57
+ if j < len(words):
58
+ base = _base_letters(words[j])
59
+ first = base[0] if base else ""
60
+ if word.endswith("\u0646" + _SUKUN) and first in noon_set:
61
+ words[i], words[j] = word[:-1], _insert_shadda(words[j])
62
+ elif _ends_with_tanween(word) and first in noon_set:
63
+ words[j] = _insert_shadda(words[j])
64
+ elif word.endswith("\u0645" + _SUKUN) and first == "\u0645":
65
+ words[i], words[j] = word[:-1], _insert_shadda(words[j])
66
+ elif extended and word.endswith("\u0644" + _SUKUN) and first in _IDGHAM_AFTER_LAM:
67
+ words[i], words[j] = word[:-1], _insert_shadda(words[j])
68
+ i += 1
69
+ return " ".join(words)
70
+
71
+
index.html CHANGED
@@ -1,19 +1,515 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
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>التحقق من هلوسة القرآن والحديث وتصحيحها</title>
7
+ <meta name="description" content="تحقّق من آيات القرآن والأحاديث النبوية بالدليل">
8
+ <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link rel="preload" href="https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js" as="script" crossorigin>
12
+ <link rel="preload" href="index/quran.idx.gz" as="fetch" crossorigin>
13
+ <link rel="prefetch" href="index/hadith.idx.gz" as="fetch" crossorigin>
14
+ <style>
15
+ @import url('https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&family=Amiri:wght@400;700&display=swap');
16
+ :root { --green:#0F4C3A; --green-2:#17694F; --gold:#B8912F; --gold-2:#E2C06E; --cream:#FBF6EA; --paper:#FFFFFF;
17
+ --ink:#1F2933; --muted:#55626D; --line:#E6DCC3; --ok:#1B7A4B; --ok-bg:#EAF6EF; --ok-line:#BFE3CD;
18
+ --bad:#B3261E; --bad-bg:#FDECEA; --bad-line:#F4B8B3; --warn:#7A5B0C; --warn-bg:#FFF6DA; --warn-line:#EBD28A; }
19
+ .gradio-container, body.icv-page { background:var(--cream) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='88' viewBox='0 0 88 88'%3E%3Cg fill='none' stroke='%230F4C3A' stroke-opacity='0.07' stroke-width='1'%3E%3Crect x='22' y='22' width='44' height='44'/%3E%3Crect x='22' y='22' width='44' height='44' transform='rotate(45 44 44)'/%3E%3C/g%3E%3C/svg%3E") !important; font-family:'Cairo','Segoe UI',Tahoma,sans-serif; color:var(--ink); }
20
+ .gradio-container { max-width:1060px !important; --body-text-color:#1F2933; --block-background-fill:#FFFFFF; --block-border-color:#E6DCC3;
21
+ --input-background-fill:#FFFFFF; --block-label-text-color:#55626D; --button-primary-background-fill:#0F4C3A;
22
+ --button-primary-background-fill-hover:#17694F; --button-primary-text-color:#FFFFFF; --button-secondary-background-fill:#FFFFFF;
23
+ --button-secondary-text-color:#0F4C3A; --button-secondary-border-color:#B8912F; --color-accent:#B8912F; }
24
+ .icv { direction:rtl; text-align:right; color:var(--ink); line-height:1.9; }
25
+ .icv .hero { background:linear-gradient(135deg,#0F4C3A,#17694F); border-radius:20px; padding:30px 22px 26px; margin:10px 0 18px; text-align:center;
26
+ box-shadow:0 6px 22px rgba(15,76,58,.18); border-bottom:4px solid var(--gold); }
27
+ .icv .hero .mark { width:54px; height:54px; display:block; margin:0 auto 4px; }
28
+ .icv .hero h1 { font-size:2.3rem; margin:.1rem 0; color:#FFFFFF; font-weight:700; }
29
+ .icv .hero .tagline { color:var(--gold-2); font-size:1.2rem; font-weight:600; margin:.1rem 0 .6rem; }
30
+ .icv .hero .sub { max-width:720px; margin:0 auto; color:#E9F2EE; font-size:1rem; }
31
+ .icv .flow { display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:8px; margin-top:16px; }
32
+ .icv .flow span { background:rgba(255,255,255,.12); border:1px solid rgba(226,192,110,.55); color:#FFF; border-radius:999px; padding:3px 16px; font-size:.9rem; }
33
+ .icv .flow i { color:var(--gold-2); font-style:normal; font-size:1.2rem; }
34
+ .icv .disclaimer { font-size:.85rem; color:var(--muted); text-align:center; padding:14px 8px; }
35
+ .icv .en-free { direction:rtl; }
36
+ .input-area textarea { direction:rtl; text-align:right; font-family:'Amiri','Cairo',serif !important; font-size:1.25rem !important; line-height:2.1 !important; background:#FFFFFF !important; color:#1F2933 !important; }
37
+ .llm-row label, .gradio-container label span { font-family:'Cairo',sans-serif; }
38
+ .results { direction:rtl; }
39
+ .icv .summary { display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); gap:10px; margin:8px 0 14px; }
40
+ .icv .tile { background:var(--paper); border:1px solid var(--line); border-radius:14px; padding:12px 8px; text-align:center; }
41
+ .icv .tile b { display:block; font-size:1.8rem; color:var(--green); line-height:1.3; } .icv .tile span { font-size:.92rem; color:var(--muted); }
42
+ .icv .tile.verified { background:var(--ok-bg); border-color:var(--ok-line); } .icv .tile.verified b { color:var(--ok); }
43
+ .icv .tile.mismatch { background:var(--bad-bg); border-color:var(--bad-line); } .icv .tile.mismatch b { color:var(--bad); }
44
+ .icv .tile.review { background:var(--warn-bg); border-color:var(--warn-line); } .icv .tile.review b { color:var(--warn); }
45
+ .icv .tile.zero { background:var(--paper); border-color:var(--line); opacity:.6; } .icv .tile.zero b { color:var(--muted); }
46
+ .icv .highlight, .icv .generated, .icv .final { background:var(--paper); border:1px solid var(--line); border-radius:14px; padding:14px 18px; margin-bottom:14px; }
47
+ .icv label { display:block; color:var(--muted); font-size:.82rem; font-weight:600; margin-bottom:4px; }
48
+ .icv .highlight p, .icv .generated p, .icv .final-text { font-family:'Amiri','Cairo',serif; font-size:1.2rem; line-height:2.2; margin:0; white-space:pre-wrap; }
49
+ .icv mark { color:var(--ink); border-radius:6px; padding:1px 5px; }
50
+ .icv mark.verified { background:#D5EEDF; } .icv mark.mismatch { background:#F8CFCB; } .icv mark.review { background:#F7E3A6; }
51
+ .icv .hl-legend { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; font-size:.78rem; }
52
+ .icv .qcard { background:var(--paper); border:1px solid var(--line); border-inline-start:6px solid var(--line); border-radius:16px; padding:16px 18px; margin:12px 0; }
53
+ .icv .qcard.verified { border-inline-start-color:var(--ok); } .icv .qcard.mismatch { border-inline-start-color:var(--bad); } .icv .qcard.review { border-inline-start-color:var(--gold); }
54
+ .icv .qhead { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:8px; }
55
+ .icv .idx { background:var(--green); color:#FFF; border-radius:50%; width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; font-weight:700; font-size:.9rem; }
56
+ .icv .badge { padding:2px 14px; border-radius:999px; font-size:.84rem; background:#F3EEDD; border:1px solid var(--line); color:var(--ink); }
57
+ .icv .badge.st.verified { background:var(--ok-bg); border-color:var(--ok-line); color:var(--ok); font-weight:600; }
58
+ .icv .badge.st.mismatch { background:var(--bad-bg); border-color:var(--bad-line); color:var(--bad); font-weight:600; }
59
+ .icv .badge.st.review { background:var(--warn-bg); border-color:var(--warn-line); color:var(--warn); font-weight:600; }
60
+ .icv .badge.scan { background:#EEF3FB; border-color:#C9D8EE; color:#2F4F7F; }
61
+ .icv .conf { margin-inline-start:auto; color:var(--muted); font-size:.88rem; } .icv .conf b { color:var(--green); }
62
+ .icv .field { margin:8px 0; } .icv .field p { margin:0; }
63
+ .icv .quote { font-family:'Amiri','Cairo',serif; font-size:1.25rem; line-height:2.2; color:var(--ink); } .icv .quote.small { font-size:1.05rem; color:#3a4651; }
64
+ .icv .diff { font-family:'Amiri','Cairo',serif; font-size:1.2rem; line-height:2.2; }
65
+ .icv .w-extra { background:var(--bad-bg); color:var(--bad); border-radius:4px; padding:0 4px; text-decoration:line-through; }
66
+ .icv .w-missing { background:var(--ok-bg); color:var(--ok); border-radius:4px; padding:0 4px; font-weight:700; }
67
+ .icv .legend { color:var(--muted); font-size:.82rem; margin:4px 0 0; } .icv .legend span { text-decoration:none; font-size:.8rem; }
68
+ .icv .reason { color:var(--muted); font-size:.92rem; margin:6px 0 0; }
69
+ .icv .note { color:var(--warn); background:var(--warn-bg); border:1px solid var(--warn-line); border-radius:10px; padding:5px 12px; font-size:.88rem; margin:8px 0 0; }
70
+ .icv .action { border-radius:12px; padding:10px 14px; margin-top:10px; }
71
+ .icv .action.ok { background:var(--ok-bg); border:1px solid var(--ok-line); } .icv .action.ok b { color:var(--ok); }
72
+ .icv .action.warn { background:var(--warn-bg); border:1px solid var(--warn-line); } .icv .action.warn b { color:var(--warn); }
73
+ .icv .action.bad { background:var(--bad-bg); border:1px solid var(--bad-line); } .icv .action.bad b { color:var(--bad); }
74
+ .icv .evidence { margin-top:12px; border-top:1px dashed var(--line); padding-top:8px; }
75
+ .icv .evidence summary { cursor:pointer; color:var(--green); font-weight:700; }
76
+ .icv .ev-row { margin:10px 0; } .icv .ev-row b { color:var(--gold); font-size:.88rem; } .icv .ev-row p { margin:2px 0; }
77
+ .icv .indicators { display:grid; gap:6px; margin-top:6px; }
78
+ .icv .ind { display:grid; grid-template-columns:150px 1fr 48px; gap:10px; align-items:center; font-size:.88rem; }
79
+ .icv .bar { background:#EFE8D3; border-radius:999px; height:9px; overflow:hidden; direction:rtl; } .icv .bar span { display:block; height:100%; background:linear-gradient(270deg,var(--green),var(--gold)); }
80
+ .icv .ind-val { text-align:left; direction:ltr; color:var(--ink); } .icv .ind-val.wide { grid-column:2 / span 2; text-align:right; direction:rtl; }
81
+ .icv .final { border-color:var(--gold); background:#FFFDF6; }
82
+ .icv .final-head { display:flex; justify-content:space-between; align-items:center; } .icv .final-head b { color:var(--green); font-size:1.05rem; }
83
+ .icv .copy { background:var(--green); color:#FFF; border:0; border-radius:10px; padding:5px 16px; font-family:inherit; cursor:pointer; }
84
+ .icv .notice { background:var(--paper); border:1px solid var(--line); border-radius:14px; padding:16px; }
85
+ .icv .notice.warn { background:var(--warn-bg); border-color:var(--warn-line); color:var(--warn); } .icv .notice.bad { background:var(--bad-bg); border-color:var(--bad-line); color:var(--bad); }
86
+ @media (max-width:640px){ .icv .hero h1{font-size:1.7rem;} .icv .ind{grid-template-columns:104px 1fr 40px;} }
87
+
88
+ :root { --glass:rgba(255,255,255,.62); --glass-strong:rgba(255,255,255,.82); --glass-line:rgba(255,255,255,.75);
89
+ --shadow-1:0 1px 2px rgba(15,76,58,.06), 0 8px 24px rgba(15,76,58,.08); --shadow-2:0 2px 4px rgba(15,76,58,.08), 0 18px 44px rgba(15,76,58,.16); }
90
+ .gradio-container, body.icv-page { background:
91
+ radial-gradient(900px 520px at 88% -8%, rgba(226,192,110,.34), transparent 60%),
92
+ radial-gradient(760px 520px at 6% 4%, rgba(23,105,79,.20), transparent 62%),
93
+ linear-gradient(180deg,#FBF6EA 0%,#F3EBD3 100%) !important; background-attachment:fixed !important; }
94
+ /* RTL safety: long words, URLs and mixed Latin/digit runs wrap inside their box instead of spilling out */
95
+ .icv, .icv * { box-sizing:border-box; min-width:0; }
96
+ .icv p, .icv span, .icv b, .icv label, .icv summary, .icv button, .icv .badge, .icv .tile { overflow-wrap:anywhere; }
97
+ .icv .quote, .icv .diff, .icv .final-text, .icv .highlight p, .icv .generated p { unicode-bidi:plaintext; text-align:start; }
98
+ .icv .hero { position:relative; overflow:hidden; border-bottom:0; border:1px solid rgba(226,192,110,.45);
99
+ background:linear-gradient(120deg,#0B3B2D,#17694F 45%,#0F4C3A 70%,#1d7a5c); background-size:240% 240%; animation:icv-flow 16s ease-in-out infinite;
100
+ box-shadow:var(--shadow-2); }
101
+ .icv .hero::before { content:""; position:absolute; inset:-40% -10% auto auto; width:60%; aspect-ratio:1; border-radius:50%;
102
+ background:radial-gradient(circle, rgba(226,192,110,.38), transparent 65%); pointer-events:none; }
103
+ .icv .hero::after { content:""; position:absolute; inset:auto auto 0 0; width:100%; height:4px; background:linear-gradient(90deg,transparent,var(--gold-2),transparent); }
104
+ .icv .hero h1 { font-size:clamp(1.45rem,4.2vw,2.3rem); line-height:1.5; text-wrap:balance; position:relative; }
105
+ .icv .hero .tagline, .icv .hero .sub, .icv .flow { position:relative; }
106
+ .icv .flow span { backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px); }
107
+ @keyframes icv-flow { 0%,100%{background-position:0% 50%} 50%{background-position:100% 50%} }
108
+ .icv .tile, .icv .highlight, .icv .generated, .icv .final, .icv .qcard, .icv .notice, .icv .export {
109
+ background:var(--glass); border:1px solid var(--glass-line); box-shadow:var(--shadow-1);
110
+ backdrop-filter:blur(14px) saturate(150%); -webkit-backdrop-filter:blur(14px) saturate(150%); transition:transform .25s ease, box-shadow .25s ease; }
111
+ .icv .qcard { border-inline-start:6px solid var(--line); }
112
+ .icv .qcard:hover, .icv .tile:hover { transform:translateY(-2px); box-shadow:var(--shadow-2); }
113
+ .icv .tile.verified { background:linear-gradient(160deg,rgba(234,246,239,.92),rgba(255,255,255,.6)); }
114
+ .icv .tile.mismatch { background:linear-gradient(160deg,rgba(253,236,234,.92),rgba(255,255,255,.6)); }
115
+ .icv .tile.review { background:linear-gradient(160deg,rgba(255,246,218,.95),rgba(255,255,255,.6)); }
116
+ .icv .final { background:linear-gradient(160deg,rgba(255,253,246,.92),rgba(250,240,208,.55)); border-color:rgba(184,145,47,.55); }
117
+ .icv .final-head { flex-wrap:wrap; gap:10px; }
118
+ .icv .qhead .badge { max-width:100%; white-space:normal; }
119
+ .icv .ind { grid-template-columns:minmax(96px,150px) minmax(0,1fr) 48px; }
120
+ .icv .bar span { background:linear-gradient(270deg,var(--green),var(--gold-2)); transition:width .6s ease; }
121
+ .icv .copy, .icv .dl { background:linear-gradient(135deg,var(--green),var(--green-2)); color:#FFF; border:0; border-radius:12px; padding:7px 18px;
122
+ font-family:inherit; font-weight:600; cursor:pointer; box-shadow:0 4px 12px rgba(15,76,58,.25); transition:transform .15s ease, box-shadow .15s ease, filter .15s ease; }
123
+ .icv .copy:hover, .icv .dl:hover { transform:translateY(-1px); filter:brightness(1.08); box-shadow:0 8px 18px rgba(15,76,58,.3); }
124
+ .icv .copy:active, .icv .dl:active { transform:translateY(0); }
125
+ .icv .copy:focus-visible, .icv .dl:focus-visible { outline:3px solid rgba(184,145,47,.55); outline-offset:2px; }
126
+ .icv .copy.done { background:linear-gradient(135deg,#1B7A4B,#2a9d66); }
127
+ .icv .export { border-radius:16px; padding:12px 18px; margin:12px 0; }
128
+ .icv .export summary { cursor:pointer; color:var(--green); font-weight:700; }
129
+ .icv .dls { display:flex; flex-wrap:wrap; gap:10px; margin-top:10px; }
130
+ /* toast */
131
+ #icv-toast { position:fixed; inset-inline:0; bottom:28px; margin-inline:auto; width:max-content; max-width:calc(100vw - 32px); z-index:99999;
132
+ direction:rtl; text-align:center; font:600 1rem 'Cairo','Segoe UI',Tahoma,sans-serif; color:#FFF; padding:12px 24px; border-radius:999px;
133
+ background:linear-gradient(135deg,rgba(15,76,58,.94),rgba(23,105,79,.94)); border:1px solid rgba(226,192,110,.6);
134
+ box-shadow:0 14px 40px rgba(15,76,58,.35); backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px);
135
+ opacity:0; transform:translateY(18px) scale(.97); pointer-events:none; transition:opacity .28s ease, transform .28s cubic-bezier(.2,.9,.3,1.2); }
136
+ #icv-toast.show { opacity:1; transform:none; } #icv-toast.bad { background:linear-gradient(135deg,rgba(179,38,30,.95),rgba(214,69,58,.95)); }
137
+ /* skeleton loaders */
138
+ .skel { position:relative; overflow:hidden; background:rgba(15,76,58,.08); border-radius:10px; }
139
+ .skel::after { content:""; position:absolute; inset:0; transform:translateX(100%); animation:icv-shimmer 1.4s infinite;
140
+ background:linear-gradient(90deg,transparent,rgba(255,255,255,.75),transparent); }
141
+ @keyframes icv-shimmer { 100% { transform:translateX(-100%); } }
142
+ .skel-card { background:var(--glass); border:1px solid var(--glass-line); border-radius:16px; padding:16px 18px; margin:12px 0; box-shadow:var(--shadow-1); }
143
+ .skel-line { height:14px; margin:10px 0; } .skel-line.w60 { width:60%; } .skel-line.w85 { width:85%; } .skel-line.w40 { width:40%; }
144
+ .skel-tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:10px; margin:8px 0 14px; } .skel-tile { height:78px; border-radius:14px; }
145
+ .icv-spinner { width:18px; height:18px; border-radius:50%; border:3px solid rgba(15,76,58,.18); border-top-color:var(--gold); display:inline-block;
146
+ vertical-align:middle; margin-inline-end:10px; animation:icv-spin .8s linear infinite; }
147
+ @keyframes icv-spin { to { transform:rotate(360deg); } }
148
+ @media (prefers-reduced-motion: reduce) { .icv .hero, .skel::after, .icv-spinner { animation:none; } .icv .qcard, .icv .tile, #icv-toast { transition:none; } }
149
+ @media (max-width:640px){ .icv .ind{grid-template-columns:minmax(84px,104px) minmax(0,1fr) 40px;} .icv .final-head{flex-direction:column; align-items:stretch;} .icv .copy{width:100%;} }
150
+
151
+ body.icv-page{margin:0;min-height:100vh;} .page{max-width:1060px;margin:0 auto;padding:10px 16px 28px;box-sizing:border-box;}
152
+ .glass{background:var(--glass);border:1px solid var(--glass-line);border-radius:20px;box-shadow:var(--shadow-1);
153
+ backdrop-filter:blur(16px) saturate(150%);-webkit-backdrop-filter:blur(16px) saturate(150%);}
154
+ .tabs{display:flex;gap:6px;margin:6px 0 14px;direction:rtl;padding:6px;border-radius:18px;}
155
+ .tab{flex:1;border:0;background:transparent;color:var(--green);border-radius:13px;padding:11px 14px;font:700 1rem 'Cairo',sans-serif;cursor:pointer;transition:background .2s,color .2s,box-shadow .2s;}
156
+ .tab:hover{background:rgba(15,76,58,.07);}
157
+ .tab.active{background:linear-gradient(135deg,var(--green),var(--green-2));color:#FFF;box-shadow:0 6px 16px rgba(15,76,58,.28);}
158
+ .tab:focus-visible,.btns button:focus-visible{outline:3px solid rgba(184,145,47,.55);outline-offset:2px;}
159
+ section[hidden]{display:none;}
160
+ .form{padding:18px 20px 14px;margin-bottom:14px;}
161
+ .form label{display:block;color:var(--muted);font-size:.88rem;font-weight:600;margin:10px 0 5px;direction:rtl;text-align:right;}
162
+ .form textarea,.form select{width:100%;box-sizing:border-box;background:rgba(255,255,255,.88);color:var(--ink);border:1px solid var(--line);border-radius:14px;padding:12px 14px;font:1rem 'Cairo',sans-serif;direction:rtl;text-align:right;outline:none;transition:border-color .2s,box-shadow .2s;}
163
+ .form textarea{min-height:200px;resize:vertical;font-family:'Amiri','Cairo',serif;font-size:1.25rem;line-height:2.1;}
164
+ .form textarea.short{min-height:96px;}
165
+ .form textarea:focus,.form select:focus{border-color:var(--gold);box-shadow:0 0 0 4px rgba(184,145,47,.18);}
166
+ .engine{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-bottom:6px;}
167
+ .engine select{direction:ltr;text-align:left;font-family:'Segoe UI',Tahoma,Arial,sans-serif;appearance:none;-webkit-appearance:none;cursor:pointer;
168
+ background:rgba(255,255,255,.88) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='9' viewBox='0 0 14 9'%3E%3Cpath d='M1 1l6 6 6-6' fill='none' stroke='%230F4C3A' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 14px center;padding-right:38px;}
169
+ .engine label{direction:ltr;text-align:left;font-family:'Segoe UI',Tahoma,Arial,sans-serif;}
170
+ .hint{font-size:.84rem;color:var(--muted);direction:rtl;margin:2px 0 0;}
171
+ .btns{display:flex;gap:12px;margin:14px 0 6px;flex-wrap:wrap;}
172
+ .btns button{flex:1 1 200px;border:0;border-radius:14px;padding:13px 18px;font:700 1.02rem 'Cairo',sans-serif;cursor:pointer;transition:transform .15s,box-shadow .15s,filter .15s;}
173
+ .btn-main{background:linear-gradient(135deg,var(--green),var(--green-2));color:#FFF;box-shadow:0 8px 20px rgba(15,76,58,.28);}
174
+ .btn-main:hover{transform:translateY(-1px);filter:brightness(1.07);}
175
+ .btn-alt{background:rgba(255,255,255,.85);color:var(--green);border:1.5px solid var(--gold)!important;} .btn-alt:hover{background:#FFFBEF;transform:translateY(-1px);}
176
+ .btns button:disabled{opacity:.5;cursor:not-allowed;transform:none;}
177
+ #status{direction:rtl;text-align:center;color:var(--muted);font-size:.93rem;margin:8px 0;min-height:1.4em;}
178
+ .boot{padding:14px 18px;margin:0 0 14px;direction:rtl;}
179
+ .boot h2{margin:0 0 8px;font-size:.98rem;color:var(--green);}
180
+ .boot .step{display:grid;grid-template-columns:22px minmax(0,1fr) 54px;gap:10px;align-items:center;margin:8px 0;font-size:.92rem;color:var(--muted);}
181
+ .boot .dot{width:18px;height:18px;border-radius:50%;border:2px solid rgba(15,76,58,.22);display:inline-block;}
182
+ .boot .step.active .dot{border-color:rgba(15,76,58,.18);border-top-color:var(--gold);animation:icv-spin .8s linear infinite;}
183
+ .boot .step.done .dot{background:var(--ok);border-color:var(--ok);position:relative;} .boot .step.done .dot::after{content:"";position:absolute;inset:3px 5px 5px 5px;border:solid #FFF;border-width:0 2px 2px 0;transform:rotate(40deg);}
184
+ .boot .step.done{color:var(--ok);} .boot .step.active{color:var(--ink);font-weight:600;}
185
+ .boot .meter{height:8px;border-radius:999px;background:rgba(15,76,58,.1);overflow:hidden;} .boot .meter i{display:block;height:100%;width:0;background:linear-gradient(270deg,var(--green),var(--gold-2));transition:width .3s ease;}
186
+ .boot .step.pending .meter{position:relative;} .boot .pct{font-size:.8rem;direction:ltr;text-align:left;}
187
+ .boot.ready{padding:8px 16px;} .boot.ready h2{margin:0;} .boot.ready .step{display:none;}
188
+ .banner{margin:0 0 12px;}
189
+ </style>
190
+ </head>
191
+ <body class="icv-page">
192
+ <div class="page">
193
+
194
+ <div class="icv"><div class="hero">
195
+ <svg class="mark" viewBox="0 0 64 64" fill="none" stroke="#E2C06E" stroke-width="1.8" aria-hidden="true"><rect x="14" y="14" width="36" height="36"/><rect x="14" y="14" width="36" height="36" transform="rotate(45 32 32)"/><circle cx="32" cy="32" r="7" fill="#E2C06E" stroke="none"/></svg>
196
+ <h1>التحقق من هلوسة القرآن والحديث وتصحيحها</h1>
197
+ <p class="tagline">تحقّق من آيات القرآن والأحاديث النبوية بالدليل</p>
198
+ <p class="sub">يكتشف النظام الاقتباسات داخل أي نص، ويسترجع نصوصها من المصادر، ويقارنها كلمةً بكلمة، ثم يعرض الدليل.
199
+ وإذا لم تكفِ الأدلة فلن يختلق تصحيحًا، بل يحيل الحالة إلى المراجعة البشرية.</p>
200
+ <div class="flow"><span>كشف</span><i>‹</i><span>استرجاع</span><i>‹</i><span>محاذاة</span><i>‹</i><span>دليل</span><i>‹</i><span>قرار</span></div>
201
+ </div></div>
202
+
203
+ <div class="boot glass" id="boot" aria-live="polite">
204
+ <h2 id="boot-title">جارٍ تجهيز النظام في متصفحك…</h2>
205
+ <div class="step pending" data-step="python"><i class="dot"></i><span>بيئة بايثون</span><span class="pct"></span><div class="meter" style="grid-column:2 / span 2"><i></i></div></div>
206
+ <div class="step pending" data-step="code"><i class="dot"></i><span>الكود وفهرس القرآن</span><span class="pct"></span><div class="meter" style="grid-column:2 / span 2"><i></i></div></div>
207
+ <div class="step pending" data-step="hadith"><i class="dot"></i><span>كتب الحديث (تُحمَّل بالتوازي)</span><span class="pct"></span><div class="meter" style="grid-column:2 / span 2"><i></i></div></div>
208
+ </div>
209
+ <div class="tabs glass" role="tablist"><button class="tab active" role="tab" aria-selected="true" data-tab="direct">تحقّق مباشر</button><button class="tab" role="tab" aria-selected="false" data-tab="ask">اسأل ثم تحقّق</button></div>
210
+ <div class="form glass engine-card" style="padding-bottom:12px">
211
+ <div class="engine"><label for="engine">Detection engine</label>
212
+ <select id="engine"><option value="standard">Standard · Rules + Corpus Scan</option><option value="camelbert">CAMeLBERT-MSA · Fine-tuned (Hugging Face)</option></select></div>
213
+ <p class="hint" id="engine-hint"></p>
214
+ </div>
215
+ <section id="tab-direct" class="form glass">
216
+ <label for="text">النص المراد التحقق منه</label>
217
+ <textarea id="text" placeholder="الصق هنا النص الذي ولّده نموذج لغوي. يكتشف النظام الآيات والأحاديث الواردة فيه، بعلامات تنصيص أو بدونها…"></textarea>
218
+ <div class="btns"><button id="verify" class="btn-main">تحقّق من النص</button><button id="example" class="btn-alt">جرّب مثالًا</button></div>
219
+ </section>
220
+ <section id="tab-ask" class="form glass" hidden>
221
+ <div class="icv"><div class="notice">اكتب سؤالًا، وسيجيب عنه النظام مباشرةً، ثم يفحص كل آية وحديث في الإجابة ويعرض الأخطاء والتصحيحات.</div></div>
222
+ <label for="prompt">سؤالك</label>
223
+ <textarea id="prompt" class="short" maxlength="1500" placeholder="اكتب سؤالك، مثل: اشرح لي فضل الصبر في القرآن والسنة مع ذكر الأدلة."></textarea>
224
+ <div class="btns"><button id="ask" class="btn-main">اسأل ثم تحقّق</button></div>
225
+ </section>
226
+ <div id="status" role="status"></div>
227
+ <div id="results" class="results"></div>
228
+ <div class="icv"><div class="disclaimer">أداة مساعدة للتدقيق النصي وليست فتوى ولا بديلًا عن المراجعة المتخصصة.
229
+ النتائج مبنية على مراجع القرآن الكريم والكتب الستة المضمّنة فقط.</div></div>
230
+ </div>
231
+ <script>
232
+ (function () {
233
+ if (window.icvCopy) return;
234
+ function toast(message, bad) {
235
+ var t = document.getElementById('icv-toast');
236
+ if (!t) { t = document.createElement('div'); t.id = 'icv-toast'; t.setAttribute('role', 'status'); t.setAttribute('aria-live', 'polite'); document.body.appendChild(t); }
237
+ t.textContent = message; t.className = 'show' + (bad ? ' bad' : '');
238
+ clearTimeout(window.__icvToast); window.__icvToast = setTimeout(function () { t.className = bad ? 'bad' : ''; }, 2400);
239
+ }
240
+ function legacyCopy(text) {
241
+ var area = document.createElement('textarea'); area.value = text; area.setAttribute('readonly', '');
242
+ area.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none'; document.body.appendChild(area);
243
+ area.select(); area.setSelectionRange(0, text.length); var ok = false;
244
+ try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
245
+ document.body.removeChild(area); return ok;
246
+ }
247
+ window.icvToast = toast;
248
+ window.icvCopy = function (button) {
249
+ var text = button.getAttribute('data-text') || '';
250
+ function finish(ok) {
251
+ toast(ok ? 'تم نسخ النص بنجاح' : 'تعذّر النسخ، حدّد النص وانسخه يدويًا', !ok);
252
+ if (ok) { button.classList.add('done'); setTimeout(function () { button.classList.remove('done'); }, 1600); }
253
+ }
254
+ if (navigator.clipboard && window.isSecureContext) {
255
+ navigator.clipboard.writeText(text).then(function () { finish(true); }, function () { finish(legacyCopy(text)); });
256
+ } else { finish(legacyCopy(text)); }
257
+ };
258
+ window.icvDownload = function (button) {
259
+ var blob = new Blob([button.getAttribute('data-text') || ''], { type: 'text/tab-separated-values;charset=utf-8' });
260
+ var link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = button.getAttribute('data-name') || 'results.tsv';
261
+ document.body.appendChild(link); link.click(); document.body.removeChild(link); setTimeout(function () { URL.revokeObjectURL(link.href); }, 1000);
262
+ };
263
+ })();
264
+ </script>
265
+ <script type="text/plain" id="worker-src">
266
+ // Python (Pyodide) and the whole verification pipeline run here, off the main thread.
267
+ let py = null, booted = null, hadithReady = null;
268
+ const post = (message) => self.postMessage(message);
269
+ const step = (name, state, extra) => post(Object.assign({ type: "step", name, state }, extra || {}));
270
+
271
+ async function openCache(buildId) {
272
+ try {
273
+ const names = await caches.keys();
274
+ await Promise.all(names.filter((n) => n.startsWith("icv-") && n !== "icv-" + buildId).map((n) => caches.delete(n))); // purge old builds
275
+ return await caches.open("icv-" + buildId);
276
+ } catch (e) { return null; }
277
+ }
278
+
279
+ async function fetchBytes(url, cache, onProgress) {
280
+ if (cache) { try { const hit = await cache.match(url); if (hit) return { bytes: new Uint8Array(await hit.arrayBuffer()), cached: true }; } catch (e) { /* fall through */ } }
281
+ const response = await fetch(url);
282
+ if (!response.ok) throw new Error("تعذّر تحميل " + url + " (" + response.status + ")");
283
+ if (cache) { try { await cache.put(url, response.clone()); } catch (e) { /* the cache is an optimisation only */ } }
284
+ const total = Number(response.headers.get("Content-Length")) || 0;
285
+ if (!response.body || !onProgress) return { bytes: new Uint8Array(await response.arrayBuffer()), cached: false };
286
+ const reader = response.body.getReader(), chunks = []; let received = 0;
287
+ for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); received += value.length; onProgress(received, total); }
288
+ const bytes = new Uint8Array(received); let offset = 0;
289
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
290
+ return { bytes, cached: false };
291
+ }
292
+
293
+ // Accept both layouts: files inside folders (index/, demo/) or uploaded flat next to index.html.
294
+ async function fetchAny(file, base, cache, onProgress) {
295
+ let lastError = null;
296
+ for (const path of [file, file.split("/").pop()]) {
297
+ try { return await fetchBytes(new URL(path, base).href, cache, onProgress); } catch (error) { lastError = error; }
298
+ }
299
+ throw lastError;
300
+ }
301
+
302
+ // A server that sets Content-Encoding: gzip on .gz files makes the browser inflate them; Python expects gzip, so re-pack.
303
+ async function ensureGzip(file, bytes) {
304
+ if (!file.endsWith(".gz") || (bytes[0] === 0x1f && bytes[1] === 0x8b)) return bytes;
305
+ const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"));
306
+ return new Uint8Array(await new Response(stream).arrayBuffer());
307
+ }
308
+
309
+ async function boot(message) {
310
+ const cache = await openCache(message.buildId);
311
+ step("python", "active");
312
+ importScripts("https://cdn.jsdelivr.net/pyodide/v" + message.pyodideVersion + "/full/pyodide.js");
313
+ const hadithFile = message.lazy[0];
314
+ // the large Hadith index starts downloading now, in parallel with the Python runtime
315
+ const hadithDownload = fetchAny(hadithFile, message.base, cache, (got, total) => step("hadith", "active", { pct: total ? Math.round((got / total) * 100) : null, mb: (got / 1048576).toFixed(1) }));
316
+ hadithDownload.catch(() => {});
317
+ py = await loadPyodide();
318
+ step("python", "done");
319
+ step("code", "active", { pct: 0 });
320
+ for (const dir of ["/app", "/app/index", "/app/demo", "/app/data"]) py.FS.mkdir(dir);
321
+ let cached = true, n = 0;
322
+ for (const file of message.files) {
323
+ const result = await fetchAny(file, message.base, cache);
324
+ cached = cached && result.cached;
325
+ py.FS.writeFile("/app/" + file, await ensureGzip(file, result.bytes));
326
+ step("code", "active", { pct: Math.round((++n / message.files.length) * 100) });
327
+ }
328
+ const examplesText = new TextDecoder().decode(py.FS.readFile("/app/demo/examples.json"));
329
+ py.runPython("import sys; sys.path.insert(0, '/app')");
330
+ py.runPython("from app import get_pipeline, verify_text, verify_generated_answer, verify_with_entities; get_pipeline()");
331
+ step("code", "done", { cached });
332
+ post({ type: "ready", examples: JSON.parse(examplesText) });
333
+ hadithReady = (async () => {
334
+ const result = await hadithDownload;
335
+ step("hadith", "active", { pct: 100, prepare: true });
336
+ py.FS.writeFile("/app/" + hadithFile, await ensureGzip(hadithFile, result.bytes));
337
+ py.runPython("get_pipeline().retriever.warm()");
338
+ step("hadith", "done", { cached: result.cached });
339
+ post({ type: "hadith" });
340
+ })();
341
+ await hadithReady;
342
+ }
343
+
344
+ self.onmessage = async (event) => {
345
+ const message = event.data;
346
+ if (message.type === "boot") {
347
+ booted = boot(message);
348
+ try { await booted; } catch (error) { post({ type: "error", text: String((error && error.message) || error) }); }
349
+ } else if (message.type === "verify") {
350
+ try {
351
+ await booted;
352
+ let html;
353
+ if (message.entities) html = py.globals.get("verify_with_entities")(message.text, message.entities, "camelbert", !!message.generated);
354
+ else html = py.globals.get(message.generated ? "verify_generated_answer" : "verify_text")(message.text, message.engine || "standard");
355
+ post({ type: "result", id: message.id, html });
356
+ } catch (error) { post({ type: "result", id: message.id, html: null }); }
357
+ }
358
+ };
359
+ </script>
360
+ <script>
361
+ const DEFAULT_CONFIG = {"askEndpoint": "/api/ask", "hf": {"model": "", "endpoint": "https://router.huggingface.co/hf-inference/models/{model}"}};
362
+ const BUILD_ID = "fc19c57cae1d";
363
+ const SKELETON = "<div class=\"icv\" aria-busy=\"true\" aria-label=\"جارٍ التحقق\"><div class=\"skel-tiles\"><div class=\"skel skel-tile\"></div><div class=\"skel skel-tile\"></div><div class=\"skel skel-tile\"></div><div class=\"skel skel-tile\"></div></div><div class=\"skel-card\"><div class=\"skel skel-line w40\"></div><div class=\"skel skel-line w85\"></div><div class=\"skel skel-line\"></div><div class=\"skel skel-line w60\"></div></div><div class=\"skel-card\"><div class=\"skel skel-line w40\"></div><div class=\"skel skel-line w85\"></div><div class=\"skel skel-line\"></div><div class=\"skel skel-line w60\"></div></div></div>";
364
+ const MAX_PROMPT = 1500;
365
+ const $ = (id) => document.getElementById(id);
366
+ let cfg = DEFAULT_CONFIG, examples = [], exampleIndex = 0, nextId = 0, worker = null, ready = false;
367
+ const pending = new Map();
368
+
369
+ const store = {
370
+ get(key) { try { return localStorage.getItem(key); } catch (e) { return null; } },
371
+ set(key, value) { try { localStorage.setItem(key, value); } catch (e) { /* private mode / quota: caching is optional */ } },
372
+ };
373
+ function purgeOldLocalCache() {
374
+ try { for (const key of Object.keys(localStorage)) if (key.startsWith("icv:examples:") && key !== "icv:examples:" + BUILD_ID) localStorage.removeItem(key); } catch (e) { /* ignore */ }
375
+ }
376
+
377
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
378
+ function setStatus(text, busy = true) { $("status").innerHTML = (busy && text ? '<span class="icv-spinner"></span>' : "") + esc(text); }
379
+ function setBusy(flag) { for (const id of ["verify", "example", "ask"]) $(id).disabled = flag; }
380
+ function notice(text, kind) { return '<div class="icv banner"><div class="notice ' + kind + '">' + esc(text) + "</div></div>"; }
381
+ function show(html, banner) { $("results").innerHTML = (banner || "") + html; }
382
+ function showSkeleton() { $("results").innerHTML = SKELETON; }
383
+
384
+ function verifyRemote(payload) {
385
+ return new Promise((resolve) => { const id = ++nextId; pending.set(id, resolve); worker.postMessage(Object.assign({ type: "verify", id }, payload)); });
386
+ }
387
+
388
+ // ---- detection engine: standard, or a fine-tuned CAMeLBERT-MSA hosted on Hugging Face (simulated when not configured) ---------
389
+ async function hostedEntities(text) {
390
+ const model = (cfg.hf && cfg.hf.model || "").trim();
391
+ if (!model) return { entities: null, reason: "not_configured" };
392
+ try {
393
+ const response = await fetch(cfg.hf.endpoint.replace("{model}", encodeURIComponent(model)), {
394
+ method: "POST", headers: { "Content-Type": "application/json" },
395
+ body: JSON.stringify({ inputs: text, parameters: { aggregation_strategy: "simple" } }),
396
+ });
397
+ if (!response.ok) return { entities: null, reason: "unavailable" };
398
+ const data = await response.json();
399
+ return Array.isArray(data) ? { entities: JSON.stringify(data) } : { entities: null, reason: "unavailable" };
400
+ } catch (e) { return { entities: null, reason: "unavailable" }; }
401
+ }
402
+
403
+ async function verifyWithEngine(text, generated) {
404
+ const engine = $("engine").value;
405
+ if (engine !== "camelbert") return { html: await verifyRemote({ text, generated, engine: "standard" }), banner: "" };
406
+ const hosted = await hostedEntities(text);
407
+ if (hosted.entities !== null) return { html: await verifyRemote({ text, generated, entities: hosted.entities }), banner: "" };
408
+ const banner = notice(hosted.reason === "unavailable"
409
+ ? "تعذّر الوصول إلى النموذج المستضاف الآن؛ استُخدمت المحاكاة بالكاشف المدمج."
410
+ : "لم يُضبط نموذج مستضاف بعد؛ يعمل هذا الخيار كمحاكاة بالكاشف المدمج.", "warn");
411
+ return { html: await verifyRemote({ text, generated, engine: "camelbert" }), banner };
412
+ }
413
+
414
+ function updateEngineHint() {
415
+ const hosted = (cfg.hf && cfg.hf.model || "").trim();
416
+ $("engine-hint").textContent = $("engine").value === "camelbert"
417
+ ? (hosted ? "يُستدعى نموذج مضبوط مستضاف على Hugging Face لتحديد حدود الاقتباسات." : "لم يُضبط نموذج مستضاف؛ سيعمل هذا الخيار كمحاكاة ويُعلَّم ذلك في النتائج.")
418
+ : "كشف سريع بالقواعد ومطابقة النص مع المراجع، يعمل كله داخل المتصفح.";
419
+ }
420
+
421
+ async function runDirect() {
422
+ if (!$("text").value.trim()) return show(notice("الرجاء إدخال نص للتحقق منه.", "warn"));
423
+ setBusy(true); showSkeleton(); setStatus(ready ? "جارٍ التحقق…" : "جارٍ تجهيز النظام ثم التحقق…");
424
+ const { html, banner } = await verifyWithEngine($("text").value, false);
425
+ show(html !== null ? html : notice("حدث خطأ غير متوقع أثناء التحقق.", "bad"), banner);
426
+ setStatus("", false); setBusy(false);
427
+ }
428
+
429
+ // ---- ask then verify: same-origin proxy that holds the key; saved sample answer when it is not reachable ----------------
430
+ async function askServer(prompt) {
431
+ const response = await fetch(cfg.askEndpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt }) });
432
+ if (!response.ok) {
433
+ const error = new Error("http_" + response.status); error.status = response.status;
434
+ try { error.code = (await response.json()).error; } catch (e) { error.code = ""; }
435
+ throw error;
436
+ }
437
+ const data = await response.json();
438
+ if (!data.answer) throw new Error("empty");
439
+ return String(data.answer);
440
+ }
441
+
442
+ async function runAsk() {
443
+ const prompt = $("prompt").value.trim();
444
+ if (!prompt) return show(notice("اكتب سؤالًا أولًا.", "warn"));
445
+ if (prompt.length > MAX_PROMPT) return show(notice("السؤال طويل جدًا (الحد الأقصى " + MAX_PROMPT + " حرف).", "warn"));
446
+ setBusy(true); showSkeleton(); setStatus("جارٍ إعداد الإجابة…");
447
+ let answer = null, banner = "";
448
+ try { answer = await askServer(prompt); }
449
+ catch (error) {
450
+ const stop = (message) => { show(notice(message, "warn")); setStatus("", false); setBusy(false); };
451
+ if (error.status === 402) return stop("رصيد حساب OpenAI المرتبط بالخدمة غير كافٍ؛ على صاحب الحساب مراجعة الفوترة ثم المحاولة مجددًا.");
452
+ if (error.status === 429) return stop("الطلبات كثيرة الآن؛ انتظر قليلًا ثم أعد المحاولة.");
453
+ if (error.status === 413) return stop("السؤال طويل جدًا.");
454
+ if (error.code === "upstream_auth") return stop("مفتاح الخدمة على الخادم غير صالح؛ يلزم تحديثه من صاحب الحساب.");
455
+ if (examples.length) {
456
+ answer = examples[exampleIndex++ % examples.length].text;
457
+ banner = notice("خدمة الإجابة المباشرة غير متاحة الآن؛ عُرضت إجابة تجريبية محفوظة لتوضيح خطوات التحقق.", "warn");
458
+ }
459
+ }
460
+ if (answer === null) { show(notice("تعذّر الحصول على إجابة الآن؛ حاول لاحقًا.", "warn")); setStatus("", false); return setBusy(false); }
461
+ setStatus("جارٍ التحقق من الإجابة…");
462
+ const result = await verifyWithEngine(answer, true);
463
+ show(result.html !== null ? result.html : notice("تعذّر التحقق من الإجابة.", "bad"), banner + result.banner);
464
+ setStatus("", false); setBusy(false);
465
+ }
466
+
467
+ // ---- boot panel ------------------------------------------------------------------------------------------------------
468
+ function applyStep(m) {
469
+ const row = document.querySelector('.boot .step[data-step="' + m.name + '"]');
470
+ if (!row) return;
471
+ row.className = "step " + m.state;
472
+ const pct = m.state === "done" ? 100 : (m.pct == null ? 0 : m.pct);
473
+ row.querySelector(".meter i").style.width = pct + "%";
474
+ row.querySelector(".pct").textContent = m.state === "done" ? (m.cached ? "محفوظ" : "تم") : (m.prepare ? "…" : (m.pct == null ? (m.mb ? m.mb + " MB" : "") : pct + "%"));
475
+ if (document.querySelectorAll(".boot .step.done").length === 3) { $("boot").classList.add("ready"); $("boot-title").textContent = "النظام جاهز · يعمل كله داخل متصفحك ولا يُرسل نصك إلى أي خادم"; }
476
+ }
477
+
478
+ function start() {
479
+ try { worker = new Worker(URL.createObjectURL(new Blob([$("worker-src").textContent], { type: "text/javascript" }))); }
480
+ catch (error) { return setStatus("تعذّر تشغيل النظام: " + error.message, false); }
481
+ worker.onerror = (event) => setStatus("تعذّر تشغيل النظام: " + (event.message || ""), false);
482
+ worker.onmessage = (event) => {
483
+ const m = event.data;
484
+ if (m.type === "step") applyStep(m);
485
+ else if (m.type === "error") setStatus("تعذّر تشغيل النظام: " + m.text + ". جرّب متصفح كمبيوتر حديثًا ثم أعد تحميل الصفحة.", false);
486
+ else if (m.type === "result") { const resolve = pending.get(m.id); pending.delete(m.id); resolve(m.html); }
487
+ else if (m.type === "ready") { examples = m.examples; ready = true; store.set("icv:examples:" + BUILD_ID, JSON.stringify(m.examples)); }
488
+ };
489
+ worker.postMessage({ type: "boot", base: location.href, buildId: BUILD_ID, pyodideVersion: "0.26.4", files: ["app.py", "verifier.py", "retrieval.py", "index_builder.py", "normalization.py", "alignment.py", "similarity.py", "detector.py", "scanner.py", "idgham.py", "ui.py", "llm_client.py", "benchmark_format.py", "camelbert_adapter.py", "index/quran.idx.gz", "demo/examples.json"], lazy: ["index/hadith.idx.gz"] });
490
+ }
491
+
492
+ async function loadConfig() {
493
+ try {
494
+ const response = await fetch("config.json", { cache: "no-store" });
495
+ if (response.ok) { cfg = Object.assign({}, DEFAULT_CONFIG, await response.json()); store.set("icv:config", JSON.stringify(cfg)); return; }
496
+ } catch (e) { /* offline or missing: fall back to the last known configuration */ }
497
+ try { const saved = store.get("icv:config"); if (saved) cfg = Object.assign({}, DEFAULT_CONFIG, JSON.parse(saved)); } catch (e) { /* ignore */ }
498
+ }
499
+
500
+ document.querySelectorAll(".tab").forEach((tab) => tab.addEventListener("click", () => {
501
+ document.querySelectorAll(".tab").forEach((t) => { const on = t === tab; t.classList.toggle("active", on); t.setAttribute("aria-selected", on); });
502
+ for (const name of ["direct", "ask"]) $("tab-" + name).hidden = tab.dataset.tab !== name;
503
+ }));
504
+ $("engine").addEventListener("change", updateEngineHint);
505
+ $("verify").addEventListener("click", runDirect);
506
+ $("ask").addEventListener("click", runAsk);
507
+ $("example").addEventListener("click", () => { if (!examples.length) return; $("text").value = examples[exampleIndex++ % examples.length].text; runDirect(); });
508
+
509
+ purgeOldLocalCache();
510
+ try { const saved = store.get("icv:examples:" + BUILD_ID); if (saved) examples = JSON.parse(saved); } catch (e) { examples = []; }
511
+ loadConfig().then(updateEngineHint);
512
+ start();
513
+ </script>
514
+ </body>
515
  </html>
index_builder.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the pre-tokenised search indexes shipped in ``index/``.
2
+
3
+ python index_builder.py # writes index/quran.idx.gz and index/hadith.idx.gz
4
+
5
+ An index is a gzip-compressed pickle holding the raw records, their phonetic skeletons and BM25 postings, so the
6
+ application never re-tokenises the corpora at start-up. The pickles are produced locally by this script and read
7
+ back only by this project; never load an index file from an untrusted source.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import gzip
12
+ import json
13
+ import math
14
+ import pickle
15
+ import sys
16
+ import time
17
+ import zlib
18
+ from array import array
19
+ from collections import Counter, defaultdict
20
+ from pathlib import Path
21
+ from typing import Dict, List, Sequence, Tuple
22
+
23
+ from normalization import content_words, normalize_for_matching, normalize_strict, phonetic_key, tokenize
24
+
25
+ INDEX_VERSION = 3
26
+ BM25_K1, BM25_B = 1.2, 0.75
27
+
28
+
29
+ class BM25Index:
30
+ """Okapi BM25 over a postings table ``term -> (doc ids, term frequencies)``."""
31
+
32
+ def __init__(self, postings: Dict[str, Tuple[array, array]], doc_len: array) -> None:
33
+ self.postings, self.doc_len = postings, doc_len
34
+ self.n_docs = len(doc_len)
35
+ self.avg_len = (sum(doc_len) / self.n_docs) if self.n_docs else 1.0
36
+ self.idf = {t: math.log(1 + (self.n_docs - len(d) + 0.5) / (len(d) + 0.5)) for t, (d, _) in postings.items()}
37
+
38
+ def search(self, terms: Sequence[str], top_k: int) -> List[Tuple[int, float]]:
39
+ scores: Dict[int, float] = defaultdict(float)
40
+ for term in set(terms):
41
+ entry = self.postings.get(term)
42
+ if entry is None:
43
+ continue
44
+ idf = self.idf[term]
45
+ for doc, tf in zip(*entry):
46
+ norm = 1 - BM25_B + BM25_B * self.doc_len[doc] / self.avg_len
47
+ scores[doc] += idf * tf * (BM25_K1 + 1) / (tf + BM25_K1 * norm)
48
+ return sorted(scores.items(), key=lambda item: item[1], reverse=True)[:top_k]
49
+
50
+
51
+ def _postings(documents: List[List[str]]) -> Tuple[Dict[str, Tuple[array, array]], array]:
52
+ docs: Dict[str, array] = defaultdict(lambda: array("I"))
53
+ tfs: Dict[str, array] = defaultdict(lambda: array("H"))
54
+ doc_len = array("I")
55
+ for doc_id, tokens in enumerate(documents):
56
+ doc_len.append(len(tokens))
57
+ for term, tf in Counter(tokens).items():
58
+ docs[term].append(doc_id)
59
+ tfs[term].append(min(tf, 65535))
60
+ return {term: (docs[term], tfs[term]) for term in docs}, doc_len
61
+
62
+
63
+ def _read_json(path: Path) -> list:
64
+ opener = gzip.open if path.suffix == ".gz" else open
65
+ with opener(path, "rt", encoding="utf-8") as handle:
66
+ data = json.load(handle)
67
+ if not isinstance(data, list):
68
+ raise ValueError(f"Unexpected corpus format in {path}: expected a JSON list")
69
+ return data
70
+
71
+
72
+
73
+ def anchor_keys(tokens: Sequence[str]) -> List[Tuple[int, int]]:
74
+ """Seed keys for unannounced-quotation search: ``(crc32, position)`` for every phonetic trigram plus two gapped
75
+ trigrams that survive a single substituted or inserted word."""
76
+ keys = [phonetic_key(t) for t in tokens]
77
+ out = []
78
+ for i in range(len(keys) - 2):
79
+ out.append((zlib.crc32(f"{keys[i]} {keys[i + 1]} {keys[i + 2]}".encode()), i))
80
+ if i + 3 < len(keys):
81
+ out.append((zlib.crc32(f"{keys[i]} {keys[i + 1]} _ {keys[i + 3]}".encode()), i))
82
+ out.append((zlib.crc32(f"{keys[i]} _ {keys[i + 2]} {keys[i + 3]}".encode()), i))
83
+ return out
84
+
85
+
86
+ def build_quran_index(path: Path) -> dict:
87
+ records, norm, word_count = [], [], []
88
+ all_index: Dict[str, List[int]] = defaultdict(list)
89
+ by_surah: Dict[int, Dict[int, int]] = defaultdict(dict)
90
+ documents: List[List[str]] = []
91
+ anchors: Dict[int, List[Tuple[int, int]]] = defaultdict(list)
92
+ for entry in _read_json(Path(path)):
93
+ text = (entry.get("ayah_text") or "").strip()
94
+ if not text:
95
+ continue
96
+ idx = len(records)
97
+ records.append({"surah_id": entry.get("surah_id"), "surah_name": entry.get("surah_name", ""),
98
+ "ayah_id": entry.get("ayah_id"), "text": text})
99
+ by_surah[entry.get("surah_id")][entry.get("ayah_id")] = idx
100
+ norm.append(normalize_for_matching(text))
101
+ strict_tokens = tokenize(normalize_strict(text))
102
+ word_count.append(len(strict_tokens))
103
+ for word in set(strict_tokens):
104
+ all_index[word].append(idx)
105
+ documents.append(content_words(norm[-1].split()))
106
+ for key, pos in anchor_keys(norm[-1].split()):
107
+ anchors[key].append((idx, pos))
108
+ postings, doc_len = _postings(documents)
109
+ return {"version": INDEX_VERSION, "records": records, "norm": norm, "word_count": word_count,
110
+ "all_index": dict(all_index), "by_surah": dict(by_surah), "postings": postings, "doc_len": doc_len,
111
+ "anchors": dict(anchors)}
112
+
113
+
114
+ def build_hadith_index(path: Path) -> dict:
115
+ records, documents = [], []
116
+ for entry in _read_json(Path(path)):
117
+ if not entry:
118
+ continue
119
+ matn = (entry.get("Matn") or "").strip() or None
120
+ full = (entry.get("hadithTxt") or "").strip() or None
121
+ if not (matn or full):
122
+ continue
123
+ records.append({"hadithID": entry.get("hadithID"), "book": entry.get("BookID"), "title": entry.get("title"),
124
+ "matn": matn, "full": full})
125
+ words = content_words(normalize_for_matching(full or matn).split())
126
+ if matn and full: # words of the matn that the full text may lack
127
+ extra = set(content_words(normalize_for_matching(matn).split())) - set(words)
128
+ words += sorted(extra)
129
+ documents.append(words)
130
+ postings, doc_len = _postings(documents)
131
+ return {"version": INDEX_VERSION, "records": records, "postings": postings, "doc_len": doc_len}
132
+
133
+
134
+ def save_index(index: dict, path: Path) -> None:
135
+ path = Path(path)
136
+ path.parent.mkdir(parents=True, exist_ok=True)
137
+ with gzip.open(path, "wb", compresslevel=6) as handle:
138
+ pickle.dump(index, handle, protocol=pickle.HIGHEST_PROTOCOL)
139
+
140
+
141
+ def load_index(path: Path) -> dict:
142
+ with gzip.open(path, "rb") as handle:
143
+ index = pickle.load(handle) # produced by save_index() from this project's own data
144
+ if index.get("version") != INDEX_VERSION:
145
+ raise ValueError("Index version mismatch; rebuild with index_builder.py")
146
+ return index
147
+
148
+
149
+ def main() -> None:
150
+ base = Path(__file__).resolve().parent
151
+ out = base / "index"
152
+ for name, builder, source in (("quran", build_quran_index, base / "data" / "quran.json"),
153
+ ("hadith", build_hadith_index, base / "data" / "hadith.json")):
154
+ started = time.time()
155
+ source = source if source.is_file() else source.with_name(source.name + ".gz")
156
+ index = builder(source)
157
+ save_index(index, out / f"{name}.idx.gz")
158
+ size = (out / f"{name}.idx.gz").stat().st_size / 1e6
159
+ print(f"{name}: {len(index['records'])} records -> index/{name}.idx.gz ({size:.1f} MB, {time.time() - started:.1f}s)")
160
+
161
+
162
+ if __name__ == "__main__":
163
+ sys.exit(main())
islamic_unified_dataset.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
islamiceval_dev_subset.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
llm_client.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ask-then-verify backend: one pre-configured OpenAI (ChatGPT) client.
2
+
3
+ There is no provider or model choice for the user. The API key is **never** part of the source code, the page or the
4
+ repository (the competition rules forbid secrets in the repository, and a key shipped to a browser is public): it is read
5
+ from the ``OPENAI_API_KEY`` environment variable on the server side. The browser page talks to a same-origin proxy
6
+ (``functions/api/ask.js``, a Cloudflare Pages Function) that holds the key as a platform secret; this module serves the
7
+ local Gradio app and the tests with the same request shapes.
8
+
9
+ Environment: OPENAI_API_KEY (required) OPENAI_MODEL (optional, default below) OPENAI_BASE_URL (optional, tests / gateways)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import urllib.error
16
+ import urllib.request
17
+ from dataclasses import dataclass
18
+ from typing import Dict, Optional, Tuple
19
+
20
+ SYSTEM_PROMPT = (
21
+ "أنت مساعد معرفي في العلوم الإسلامية. أجب بالعربية بإيجاز ودقة. عند الاستشهاد بآية قرآنية أو حديث نبوي اكتب نصه كاملًا "
22
+ "بين علامتي تنصيص مزدوجتين \"...\" بعد عبارة تمهيدية مثل: قال الله تعالى: أو قال رسول الله ﷺ:. "
23
+ "لا تضع بين علامات التنصيص إلا نص الآية أو الحديث، واذكر السورة ورقم الآية أو مصدر الحديث بعد الاقتباس."
24
+ )
25
+
26
+ DEFAULT_MODEL = "gpt-4o-mini"
27
+ DEFAULT_BASE_URL = "https://api.openai.com/v1"
28
+ MAX_PROMPT_CHARS = 1500
29
+
30
+
31
+ class LLMError(RuntimeError):
32
+ """Raised with a user-presentable Arabic message."""
33
+
34
+
35
+ @dataclass
36
+ class LLMSettings:
37
+ api_key: str = ""
38
+ model: str = ""
39
+ base_url: str = "" # override for tests or a compatible gateway
40
+ timeout: float = 60.0
41
+
42
+ @classmethod
43
+ def from_env(cls) -> "LLMSettings":
44
+ return cls(api_key=os.environ.get("OPENAI_API_KEY", ""), model=os.environ.get("OPENAI_MODEL", ""),
45
+ base_url=os.environ.get("OPENAI_BASE_URL", ""))
46
+
47
+
48
+ def build_request(settings: LLMSettings, prompt: str) -> Tuple[str, Dict[str, str], dict]:
49
+ """``(url, headers, json_body)`` for OpenAI chat completions."""
50
+ base = (settings.base_url or DEFAULT_BASE_URL).rstrip("/")
51
+ body = {"model": settings.model.strip() or DEFAULT_MODEL,
52
+ "messages": [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt}]}
53
+ headers = {"Content-Type": "application/json", "Authorization": f"Bearer {settings.api_key}"}
54
+ return f"{base}/chat/completions", headers, body
55
+
56
+
57
+ def parse_response(payload: dict) -> str:
58
+ try:
59
+ text = payload["choices"][0]["message"]["content"]
60
+ except (KeyError, IndexError, TypeError) as exc:
61
+ raise LLMError("وصلت استجابة غير متوقعة من النموذج.") from exc
62
+ if not text or not text.strip():
63
+ raise LLMError("لم يُرجع النموذج أي نص.")
64
+ return text.strip()
65
+
66
+
67
+ _HTTP_MESSAGES = {
68
+ 400: "رفض المزوّد الطلب.",
69
+ 401: "خدمة الإجابة غير مهيّأة بعد (المفتاح غير صالح).",
70
+ 403: "الخدمة غير مصرّح لها باستخدام هذا النموذج.",
71
+ 404: "النموذج غير متاح لدى المزوّد.",
72
+ 429: "تجاوزت حد الاستخدام المسموح؛ حاول لاحقًا.",
73
+ }
74
+
75
+
76
+ def generate(settings: Optional[LLMSettings], prompt: str) -> str:
77
+ """Return the model's answer. Raises ``LLMError`` with an Arabic message on any failure."""
78
+ settings = settings or LLMSettings.from_env()
79
+ if not prompt or not prompt.strip():
80
+ raise LLMError("اكتب سؤالًا أولًا.")
81
+ if len(prompt) > MAX_PROMPT_CHARS:
82
+ raise LLMError(f"السؤال طويل جدًا (الحد الأقصى {MAX_PROMPT_CHARS} حرف).")
83
+ if not settings.api_key or not settings.api_key.strip():
84
+ raise LLMError("خدمة الإجابة غير مهيّأة: لم يُضبط مفتاح الخادم.")
85
+ url, headers, body = build_request(settings, prompt.strip())
86
+ request = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST")
87
+ try:
88
+ with urllib.request.urlopen(request, timeout=settings.timeout) as response:
89
+ payload = json.loads(response.read().decode("utf-8"))
90
+ except urllib.error.HTTPError as exc:
91
+ raise LLMError(_HTTP_MESSAGES.get(exc.code, f"فشل الطلب (الرمز {exc.code}).")) from exc
92
+ except (urllib.error.URLError, TimeoutError) as exc:
93
+ raise LLMError("تعذّر الاتصال بالمزوّد؛ تحقق من الإنترنت.") from exc
94
+ except json.JSONDecodeError as exc:
95
+ raise LLMError("وصلت استجابة غير مقروءة من المزوّد.") from exc
96
+ return parse_response(payload)
normalization.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phonetic-aware Arabic normalisation shared by indexing, retrieval, alignment and verification.
2
+
3
+ Three levels, from gentle to aggressive:
4
+
5
+ * ``normalize_strict`` diacritics / tatweel / Quranic marks removed, alef forms unified (hamza on waw / ya kept).
6
+ * ``normalize_lenient`` strict + ta marbuta -> ha and alef maqsura -> ya (used for Hadith, whose spelling varies).
7
+ * ``normalize_for_matching`` the phonetic skeleton used for retrieval and word alignment: additionally folds hamza
8
+ carriers and keeps Arabic letters only.
9
+
10
+ All levels also reconcile the Uthmani mushaf script with Modern Standard Arabic: alef wasla (ٱ) becomes alef, the
11
+ dagger alef / "alif khanjariyah" (ـٰ) and tatweel are removed, Persian ya / kaf and the ligature ﷲ are mapped to Arabic
12
+ letters, and zero-width / bidi control characters are dropped.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ import unicodedata
18
+ from typing import List, Tuple
19
+
20
+ _ZERO_WIDTH = re.compile("[\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]")
21
+ _MARKS = re.compile(r"[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0640]")
22
+ _MATCH_MARKS = re.compile(r"[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E8\u06EA-\u06ED\u0640]")
23
+ _PUNCTUATION = re.compile(r"[،؛؟!،.,:;'\"()\[\]{}<>«»\-_/\\|@#$%^&*+=~`﴿﴾]")
24
+ _SPACES = re.compile(r"\s+")
25
+ _ALEF_FORMS = re.compile(r"[أإآٱ]")
26
+ _PERSIAN = str.maketrans({"ی": "ي", "ې": "ي", "ک": "ك", "ە": "ه", "ہ": "ه", "ۀ": "ه"})
27
+
28
+ STOPWORDS = frozenset(
29
+ """من في على ان أن إن الى إلى عن مع ما لا لم لن قد و ثم أو او هو هي هم انت أنتم كان كانت يكون تكون قال قالت
30
+ هذا هذه ذلك تلك الذي التي الذين اللاتي اللائي كل بعض غير عند بين حتى إذا اذا لو لكن بل يا أيها ايها""".split()
31
+ )
32
+
33
+
34
+ def _prefold(text: str) -> str:
35
+ """Script-level clean-up common to every normalisation level."""
36
+ text = unicodedata.normalize("NFC", text).replace("ﷲ", "الله")
37
+ return _ZERO_WIDTH.sub("", text).translate(_PERSIAN)
38
+
39
+
40
+ def normalize_strict(text) -> str:
41
+ if not text:
42
+ return ""
43
+ text = _MARKS.sub("", _prefold(text))
44
+ text = _ALEF_FORMS.sub("ا", text)
45
+ return _SPACES.sub(" ", _PUNCTUATION.sub(" ", text)).strip()
46
+
47
+
48
+ def normalize_lenient(text) -> str:
49
+ return normalize_strict(text).replace("ة", "ه").replace("ى", "ي")
50
+
51
+
52
+ def normalize_for_matching(text) -> str:
53
+ """Phonetic skeleton: Arabic letters only, hamza carriers / ta marbuta / alef maqsura folded."""
54
+ if not text:
55
+ return ""
56
+ text = _MATCH_MARKS.sub("", _prefold(text))
57
+ for source, target in (("أ", "ا"), ("إ", "ا"), ("آ", "ا"), ("ٱ", "ا"), ("ؤ", "و"), ("ئ", "ي"), ("ة", "ه"), ("ى", "ي")):
58
+ text = text.replace(source, target)
59
+ text = re.sub(r"[^\u0621-\u064A\s]", " ", text) # Arabic letters only: drops Arabic punctuation, digits, Latin
60
+ return _SPACES.sub(" ", text).strip()
61
+
62
+
63
+ def tokenize(text: str) -> List[str]:
64
+ return [token for token in text.split() if token]
65
+
66
+
67
+ def content_words(tokens) -> List[str]:
68
+ """Drop stop-words and single-letter tokens."""
69
+ return [t for t in tokens if t not in STOPWORDS and len(t) > 1]
70
+
71
+
72
+ def char_ngrams(text: str, n: int = 4) -> set:
73
+ return {text[i : i + n] for i in range(len(text) - n + 1)}
74
+
75
+
76
+ # ---- word-level helpers used by the aligner -----------------------------------------------------------------------
77
+ _ARABIC_LETTER = re.compile(r"[\u0621-\u064A]")
78
+
79
+
80
+ def aligned_words(text: str) -> List[Tuple[str, str]]:
81
+ """``[(original_word, phonetic_skeleton)]``; ayah markers like ``(12)`` and mark-only tokens are dropped."""
82
+ pairs = []
83
+ for word in text.split():
84
+ skeleton = normalize_for_matching(word)
85
+ if skeleton and _ARABIC_LETTER.search(skeleton):
86
+ pairs.append((word, skeleton.replace(" ", "")))
87
+ return pairs
88
+
89
+
90
+ _VOWELS = {"\u064E": "a", "\u064F": "u", "\u0650": "i", "\u064B": "A", "\u064C": "U", "\u064D": "I", "\u0651": "~"}
91
+
92
+
93
+ def vowel_signature(word: str) -> List[Tuple[str, str]]:
94
+ """Per base letter, the set of short vowels / tanween / shadda written on it (sukun and Quranic marks ignored)."""
95
+ word = _prefold(word).replace("ٱ", "ا")
96
+ signature: List[Tuple[str, str]] = []
97
+ for char in word:
98
+ if char in _VOWELS:
99
+ if signature:
100
+ letter, marks = signature[-1]
101
+ signature[-1] = (letter, "".join(sorted(set(marks + _VOWELS[char]))))
102
+ elif "\u0621" <= char <= "\u064A":
103
+ signature.append((char, ""))
104
+ return signature
105
+
106
+
107
+ # ---- phonetic sequence matching --------------------------------------------------------------------------------
108
+ # Letters that are commonly confused in writing or dictation are folded into one class, so an altered or mis-spelled
109
+ # quotation can still be *found*; the verifier then reports the exact differences.
110
+ _PHONETIC_CLASSES = {"ص": "س", "ث": "س", "ذ": "ز", "ظ": "ز", "ض": "ز", "ط": "ت", "ك": "ق", "ح": "ه", "غ": "ع"}
111
+ _PHONETIC_TABLE = str.maketrans(_PHONETIC_CLASSES)
112
+
113
+
114
+ def phonetic_key(skeleton_word: str) -> str:
115
+ """Sound-alike key of a phonetic-skeleton word (confusable consonants folded, doubled letters collapsed)."""
116
+ word = skeleton_word.translate(_PHONETIC_TABLE)
117
+ return "".join(ch for i, ch in enumerate(word) if i == 0 or ch != word[i - 1])
quran.idx.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:87eb1db1dffa15d95d757b157a1569cad93e6265c15736c9f4ee62c17b2a2c98
3
+ size 2181000
quran.json ADDED
The diff for this file is too large to render. See raw diff
 
retrieval.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source retrieval over the Quran and the six Hadith books: BM25 candidate search with pre-built, lazily loaded indexes.
2
+
3
+ Start-up cost is kept small by shipping the corpora as pre-tokenised, gzip-compressed pickle indexes
4
+ (``index/quran.idx.gz``, ``index/hadith.idx.gz``, produced by ``index_builder.py``):
5
+
6
+ * the small Quran index loads with the retriever;
7
+ * the large Hadith index loads on first use (or via ``warm()``);
8
+ * if an index file is missing it is rebuilt from ``data/*.json`` (slower, a few seconds) so the code never breaks.
9
+
10
+ Retrieval pipeline
11
+ Quran : word-vote F1 (coverage x precision, exact-quote friendly) + BM25 candidates
12
+ Hadith: BM25 recall, then character 4-gram cosine re-rank
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import threading
18
+ from collections import OrderedDict
19
+ from pathlib import Path
20
+ from typing import Dict, Iterable, List, Optional, Sequence, Tuple
21
+
22
+ from index_builder import BM25Index, build_hadith_index, build_quran_index, load_index, save_index
23
+ from normalization import char_ngrams, content_words, normalize_for_matching, normalize_lenient, normalize_strict, tokenize
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ BASE_DIR = Path(__file__).resolve().parent
28
+ DATA_DIR = BASE_DIR / "data"
29
+ INDEX_DIR = BASE_DIR / "index"
30
+
31
+
32
+ class CorpusError(RuntimeError):
33
+ """Raised when a corpus or index file is missing or malformed."""
34
+
35
+
36
+ class SourceRetriever:
37
+ """Quran + Hadith retriever. ``quran`` is available immediately; ``hadith`` is loaded lazily."""
38
+
39
+ def __init__(self, index_dir: Path = INDEX_DIR, data_dir: Path = DATA_DIR) -> None:
40
+ self.index_dir, self.data_dir = Path(index_dir), Path(data_dir)
41
+ self._hadith_lock = threading.Lock()
42
+ self._hadith: Optional[dict] = None
43
+ self._norm_cache: "OrderedDict[Tuple[int, str], str]" = OrderedDict()
44
+
45
+ quran = self._load("quran", build_quran_index)
46
+ self.quran: List[dict] = quran["records"]
47
+ self.q_norm_match: List[str] = quran["norm"]
48
+ self.q_word_count: List[int] = quran["word_count"]
49
+ self.q_all_index: Dict[str, List[int]] = quran["all_index"]
50
+ self.quran_by_surah: Dict[int, Dict[int, int]] = quran["by_surah"]
51
+ self.quran_bm25 = BM25Index(quran["postings"], quran["doc_len"])
52
+ self.quran_anchors: Dict[int, List[Tuple[int, int]]] = quran["anchors"]
53
+ logger.info("Quran index ready: %d ayahs", len(self.quran))
54
+
55
+ # ---- loading ----------------------------------------------------------------------------------------------
56
+ def _load(self, name: str, builder) -> dict:
57
+ path = self.index_dir / f"{name}.idx.gz"
58
+ if path.is_file():
59
+ try:
60
+ return load_index(path)
61
+ except Exception:
62
+ logger.warning("Index %s is unreadable; rebuilding from data/", path)
63
+ source = self.data_dir / ("quran.json" if name == "quran" else "hadith.json")
64
+ gz = source.with_name(source.name + ".gz")
65
+ source = source if source.is_file() else gz
66
+ if not source.is_file():
67
+ raise CorpusError(f"Neither {path} nor the source corpus {source} was found")
68
+ index = builder(source)
69
+ try:
70
+ save_index(index, path)
71
+ except OSError:
72
+ logger.info("Could not cache %s (read-only file system); continuing in memory", path)
73
+ return index
74
+
75
+ def warm(self) -> None:
76
+ """Load the Hadith index now (otherwise it loads on the first Hadith query)."""
77
+ _ = self.hadith_data
78
+
79
+ @property
80
+ def hadith_loaded(self) -> bool:
81
+ return self._hadith is not None
82
+
83
+ @property
84
+ def hadith_data(self) -> dict:
85
+ if self._hadith is None:
86
+ with self._hadith_lock:
87
+ if self._hadith is None:
88
+ data = self._load("hadith", build_hadith_index)
89
+ data["bm25"] = BM25Index(data["postings"], data["doc_len"])
90
+ self._hadith = data
91
+ logger.info("Hadith index ready: %d records", len(data["records"]))
92
+ return self._hadith
93
+
94
+ @property
95
+ def hadith(self) -> List[dict]:
96
+ return self.hadith_data["records"]
97
+
98
+ # ---- Quran ------------------------------------------------------------------------------------------------
99
+ @property
100
+ def quran_vocabulary(self) -> frozenset:
101
+ """Phonetic-skeleton words that occur in the Quran (lets the aligner tell spelling variants from real words)."""
102
+ if getattr(self, "_vocab", None) is None:
103
+ self._vocab = frozenset(word for text in self.q_norm_match for word in text.split())
104
+ return self._vocab
105
+
106
+ def search_quran_ayahs(self, query: str, top_k: int = 25, extra_bm25: int = 10) -> List[dict]:
107
+ """Single-ayah candidates: word-vote F1 first, then the best BM25 matches (rare-word hits for partial quotes)."""
108
+ query_words = tokenize(normalize_strict(query))
109
+ if not query_words:
110
+ return []
111
+ votes: Dict[int, int] = {}
112
+ for word in query_words:
113
+ for idx in self.q_all_index.get(word, ()):
114
+ votes[idx] = votes.get(idx, 0) + 1
115
+ scored: List[Tuple[int, float]] = []
116
+ for idx, vote in votes.items():
117
+ coverage = vote / len(query_words)
118
+ precision = vote / self.q_word_count[idx] if self.q_word_count[idx] else 0.0
119
+ f1 = 2 * coverage * precision / (coverage + precision) if coverage + precision > 0 else 0.0
120
+ scored.append((idx, f1))
121
+ scored.sort(key=lambda item: item[1], reverse=True)
122
+ scores = dict(scored)
123
+ ranked = [idx for idx, _ in scored[:top_k]]
124
+ seen = set(ranked)
125
+ bm25_hits = self.quran_bm25.search(content_words(normalize_for_matching(query).split()), extra_bm25)
126
+ ranked += [idx for idx, _ in bm25_hits if idx not in seen]
127
+ results = []
128
+ for idx in ranked:
129
+ candidate = dict(self.quran[idx])
130
+ candidate.update(type="Quran", retrieval_score=scores.get(idx, 0.0))
131
+ results.append(candidate)
132
+ return results
133
+
134
+ def quran_seed_ayahs(self, query_words: Sequence[str], top_k: int = 25) -> List[int]:
135
+ """BM25-ranked ayah indices used to seed the multi-ayah window search."""
136
+ return [idx for idx, _ in self.quran_bm25.search(query_words, top_k)]
137
+
138
+ # ---- Hadith -----------------------------------------------------------------------------------------------
139
+ def hadith_candidates(self, query_words: Sequence[str], top_k: int) -> List[int]:
140
+ return [idx for idx, _ in self.hadith_data["bm25"].search(query_words, top_k)]
141
+
142
+ def hadith_norm(self, idx: int, field: str) -> Optional[str]:
143
+ """Phonetic skeleton of a Hadith field (``matn`` or ``full``), computed on demand and cached."""
144
+ record = self.hadith[idx]
145
+ raw = record.get(field)
146
+ if not raw:
147
+ return None
148
+ key = (idx, field)
149
+ if key in self._norm_cache:
150
+ self._norm_cache.move_to_end(key)
151
+ return self._norm_cache[key]
152
+ value = normalize_for_matching(raw)
153
+ self._norm_cache[key] = value
154
+ if len(self._norm_cache) > 4096:
155
+ self._norm_cache.popitem(last=False)
156
+ return value
157
+
158
+ def search_hadith(self, query: str, top_k: int = 15, pool: int = 60) -> List[dict]:
159
+ """BM25 recall, then character 4-gram cosine re-rank."""
160
+ words = content_words(normalize_for_matching(query).split())
161
+ if not words:
162
+ return []
163
+ query_grams = char_ngrams(normalize_lenient(query))
164
+ results = []
165
+ for idx in self.hadith_candidates(words, pool):
166
+ record = self.hadith[idx]
167
+ text = record["matn"] or record["full"]
168
+ doc_grams = char_ngrams(normalize_lenient(text))
169
+ cosine = (
170
+ len(query_grams & doc_grams) / ((len(query_grams) * len(doc_grams)) ** 0.5)
171
+ if query_grams and doc_grams
172
+ else 0.0
173
+ )
174
+ results.append(
175
+ {
176
+ "type": "Hadith",
177
+ "idx": idx,
178
+ "hadithID": record["hadithID"],
179
+ "book": record["book"],
180
+ "title": record["title"],
181
+ "text": text,
182
+ "has_matn": bool(record["matn"]),
183
+ "retrieval_score": cosine,
184
+ }
185
+ )
186
+ results.sort(key=lambda c: c["retrieval_score"], reverse=True)
187
+ return results[:top_k]
scanner.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Detection of unannounced and embedded quotations (no quotation marks, no introductory phrase needed).
2
+
3
+ The scanner reads the text as a stream of phonetic-skeleton words and looks for stretches that follow the Quran or a
4
+ Hadith closely:
5
+
6
+ 1. *Seeds* a sliding window of 3 words (plus two gapped variants that survive one changed word) is looked up in the
7
+ pre-built phonetic n-gram index of the Quran.
8
+ 2. *Chaining* seeds of the same ayah on a consistent diagonal are chained into a candidate region; regions of adjacent
9
+ ayahs that touch in the text are merged.
10
+ 3. *Boundaries* the region grows word by word (tolerating one substituted word) while the text keeps following the
11
+ source, and never crosses a sentence boundary on its own; this is the contextual boundary step.
12
+ 4. *Evidence* a region is kept only if enough of its words match and the matched words are rare enough (summed IDF), so
13
+ everyday phrases that merely occur in the Quran are not reported.
14
+ 5. *Hadith* clause-sized windows are sent to BM25, and the best record is aligned word by word with the same rules.
15
+
16
+ Phonetic keys make the search tolerant to sound-alike spelling, but the verifier still reports every real difference.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from collections import defaultdict
22
+ from dataclasses import dataclass
23
+ from difflib import SequenceMatcher
24
+ from typing import Dict, List, Optional, Sequence, Tuple
25
+
26
+ from alignment import aligned_words
27
+ from detector import DetectedSpan, RuleDetector, trim_span
28
+ from index_builder import anchor_keys
29
+ from normalization import content_words, normalize_for_matching, phonetic_key
30
+ from retrieval import SourceRetriever
31
+
32
+ _ARABIC = re.compile(r"[\u0621-\u064A]")
33
+ _STRONG_BOUNDARY = re.compile(r"[.؟?!؛;:\n…]")
34
+
35
+
36
+ @dataclass
37
+ class Token:
38
+ skeleton: str
39
+ key: str
40
+ start: int
41
+ end: int
42
+ boundary_after: bool # a sentence-level punctuation mark or line break follows this word
43
+
44
+
45
+ def tokenize_with_offsets(text: str) -> List[Token]:
46
+ matches = list(re.finditer(r"\S+", text))
47
+ tokens: List[Token] = []
48
+ for i, match in enumerate(matches):
49
+ skeleton = normalize_for_matching(match.group()).replace(" ", "")
50
+ if not skeleton or not _ARABIC.search(skeleton):
51
+ if tokens and _STRONG_BOUNDARY.search(match.group()):
52
+ tokens[-1].boundary_after = True
53
+ continue
54
+ gap_end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
55
+ trailing = text[match.end():gap_end] + match.group()[-2:]
56
+ tokens.append(Token(skeleton, phonetic_key(skeleton), match.start(), match.end(),
57
+ bool(_STRONG_BOUNDARY.search(trailing))))
58
+ return tokens
59
+
60
+
61
+ @dataclass
62
+ class _Region:
63
+ start: int # token index in the run (inclusive)
64
+ end: int # exclusive
65
+ label: str
66
+ matched: int
67
+ ratio: float
68
+ idf: float
69
+ surah: Optional[int] = None
70
+ first_ayah: Optional[int] = None
71
+ last_ayah: Optional[int] = None
72
+
73
+
74
+ class CorpusScanner:
75
+ """Finds Quran / Hadith stretches in free text. Thresholds are deliberately conservative."""
76
+
77
+ def __init__(self, retriever: SourceRetriever, min_tokens: int = 4, min_ratio: float = 0.7, min_idf: float = 8.0,
78
+ hadith_min_tokens: int = 6, hadith_min_idf: float = 14.0, scan_hadith: bool = True) -> None:
79
+ self.kb = retriever
80
+ self.min_tokens, self.min_ratio, self.min_idf = min_tokens, min_ratio, min_idf
81
+ self.hadith_min_tokens, self.hadith_min_idf = hadith_min_tokens, hadith_min_idf
82
+ self.scan_hadith = scan_hadith
83
+
84
+ # ---- public -----------------------------------------------------------------------------------------------
85
+ def scan(self, text: str, exclude: Sequence[Tuple[int, int]] = ()) -> List[DetectedSpan]:
86
+ tokens = tokenize_with_offsets(text)
87
+ runs = self._runs(tokens, exclude)
88
+ spans: List[DetectedSpan] = []
89
+ for run in runs:
90
+ for region in self._merge(self._scan_quran(run)):
91
+ spans.append(self._to_span(text, run, region))
92
+ if self.scan_hadith:
93
+ for run in runs:
94
+ taken = [(s.start, s.end) for s in spans]
95
+ for region in self._scan_hadith(run, taken):
96
+ spans.append(self._to_span(text, run, region))
97
+ return sorted(spans, key=lambda s: s.start)
98
+
99
+ # ---- helpers ----------------------------------------------------------------------------------------------
100
+ @staticmethod
101
+ def _runs(tokens: List[Token], exclude: Sequence[Tuple[int, int]]) -> List[List[Token]]:
102
+ runs, current = [], []
103
+ for token in tokens:
104
+ if any(token.start < e and token.end > s for s, e in exclude):
105
+ if current:
106
+ runs.append(current)
107
+ current = []
108
+ else:
109
+ current.append(token)
110
+ if current:
111
+ runs.append(current)
112
+ return runs
113
+
114
+ @staticmethod
115
+ def _to_span(text: str, run: List[Token], region: _Region) -> DetectedSpan:
116
+ start, end = trim_span(text, run[region.start].start, run[region.end - 1].end)
117
+ return DetectedSpan(start, end, region.label, round(region.ratio, 3), "scan", text[start:end])
118
+
119
+ # ---- Quran ------------------------------------------------------------------------------------------------
120
+ def _scan_quran(self, run: List[Token]) -> List[_Region]:
121
+ kb = self.kb
122
+ if len(run) < self.min_tokens:
123
+ return []
124
+ hits: Dict[int, List[Tuple[int, int]]] = defaultdict(list)
125
+ for key_hash, pos in anchor_keys([t.skeleton for t in run]):
126
+ postings = kb.quran_anchors.get(key_hash)
127
+ if not postings or len(postings) > 60: # skip phrases that occur everywhere
128
+ continue
129
+ for ayah, source_pos in postings:
130
+ hits[ayah].append((pos, source_pos))
131
+
132
+ regions: List[_Region] = []
133
+ for ayah, found in hits.items():
134
+ found.sort()
135
+ chains: List[dict] = []
136
+ for pos, source_pos in found:
137
+ diagonal = source_pos - pos
138
+ for chain in chains:
139
+ if abs(diagonal - chain["d"]) <= 2 and pos - chain["last"] <= 5:
140
+ chain["hits"].append((pos, source_pos))
141
+ chain["last"], chain["d"] = pos, diagonal
142
+ break
143
+ else:
144
+ chains.append({"d": diagonal, "last": pos, "hits": [(pos, source_pos)]})
145
+ for chain in chains:
146
+ region = self._grow(run, ayah, chain)
147
+ if region is not None:
148
+ regions.append(region)
149
+ return self._non_overlapping(regions)
150
+
151
+ def _grow(self, run: List[Token], ayah: int, chain: dict) -> Optional[_Region]:
152
+ kb = self.kb
153
+ source = kb.q_norm_match[ayah].split()
154
+ source_keys = [phonetic_key(w) for w in source]
155
+ diagonal = sorted(source_pos - pos for pos, source_pos in chain["hits"])[len(chain["hits"]) // 2]
156
+ start = min(pos for pos, _ in chain["hits"])
157
+ end = min(len(run), max(pos for pos, _ in chain["hits"]) + 3)
158
+
159
+ def key_at(i: int) -> Optional[str]:
160
+ return source_keys[i + diagonal] if 0 <= i + diagonal < len(source_keys) else None
161
+
162
+ while start > 0 and not run[start - 1].boundary_after: # grow left while the text keeps following the source
163
+ if run[start - 1].key == key_at(start - 1):
164
+ start -= 1
165
+ elif start >= 2 and not run[start - 2].boundary_after and run[start - 2].key == key_at(start - 2):
166
+ start -= 2 # one substituted word
167
+ else:
168
+ break
169
+ while end < len(run) and not run[end - 1].boundary_after:
170
+ if run[end].key == key_at(end):
171
+ end += 1
172
+ elif end + 1 < len(run) and run[end + 1].key == key_at(end + 1):
173
+ end += 2
174
+ else:
175
+ break
176
+
177
+ start = self._soft_left(run, start, diagonal, source_keys)
178
+ end = self._soft_right(run, end, diagonal, source_keys)
179
+ positions = range(start, end)
180
+ matched_idx = [i for i in positions if run[i].key == key_at(i)]
181
+ matched = len(matched_idx)
182
+ n = end - start
183
+ idf = sum(kb.quran_bm25.idf.get(run[i].skeleton, 0.0) for i in matched_idx)
184
+ ratio = matched / n if n else 0.0
185
+ needed_idf = self.min_idf if n >= 5 else self.min_idf + 6.0 # very short stretches must be rare phrases
186
+ if n < self.min_tokens or matched < self.min_tokens or ratio < self.min_ratio or idf < needed_idf:
187
+ return None
188
+ record = kb.quran[ayah]
189
+ return _Region(start, end, "Ayah", matched, ratio, idf, record["surah_id"], record["ayah_id"], record["ayah_id"])
190
+
191
+ @staticmethod
192
+ def _closest(run: List[Token], candidates, target: str):
193
+ """Among candidate token ranges, the one whose joined phonetic key best resembles ``target``; near-ties prefer the
194
+ longer range (words next to a changed word usually belong to the same altered quotation)."""
195
+ scored = [(SequenceMatcher(None, "".join(t.key for t in run[a:b]), target).ratio(), a, b) for a, b in candidates]
196
+ if not scored:
197
+ return None
198
+ top = max(score for score, _, _ in scored)
199
+ if top < 0.6:
200
+ return None
201
+ return max((c for c in scored if c[0] >= top - 0.2), key=lambda c: c[2] - c[1])
202
+
203
+ def _soft_left(self, run: List[Token], start: int, diagonal: int, source_keys: List[str]) -> int:
204
+ """Pull in up to two words before the region that look like the source words missing at its beginning."""
205
+ missing = min(start + diagonal, 2)
206
+ if missing <= 0:
207
+ return start
208
+ target = "".join(source_keys[start + diagonal - missing : start + diagonal])
209
+ candidates = [(start - n, start) for n in range(max(1, missing - 1), missing + 2)
210
+ if start - n >= 0 and not any(run[i].boundary_after for i in range(start - n, start))]
211
+ best = self._closest(run, candidates, target)
212
+ return best[1] if best else start
213
+
214
+ def _soft_right(self, run: List[Token], end: int, diagonal: int, source_keys: List[str]) -> int:
215
+ missing = min(len(source_keys) - (end + diagonal), 2)
216
+ if missing <= 0 or end >= len(run) or run[end - 1].boundary_after:
217
+ return end
218
+ target = "".join(source_keys[end + diagonal : end + diagonal + missing])
219
+ candidates = [(end, end + n) for n in range(max(1, missing - 1), missing + 2)
220
+ if end + n <= len(run) and not any(run[i].boundary_after for i in range(end, end + n - 1))]
221
+ best = self._closest(run, candidates, target)
222
+ return best[2] if best else end
223
+
224
+ @staticmethod
225
+ def _non_overlapping(regions: List[_Region]) -> List[_Region]:
226
+ chosen: List[_Region] = []
227
+ for region in sorted(regions, key=lambda r: (r.matched, r.ratio), reverse=True):
228
+ if all(region.end <= c.start or region.start >= c.end for c in chosen):
229
+ chosen.append(region)
230
+ return sorted(chosen, key=lambda r: r.start)
231
+
232
+ @staticmethod
233
+ def _merge(regions: List[_Region]) -> List[_Region]:
234
+ """Join regions of consecutive ayahs that follow each other in the text (a quotation spanning several ayahs)."""
235
+ merged: List[_Region] = []
236
+ for region in regions:
237
+ last = merged[-1] if merged else None
238
+ if (last and last.surah == region.surah and region.first_ayah == last.last_ayah + 1
239
+ and region.start - last.end <= 1):
240
+ last.end, last.matched = region.end, last.matched + region.matched
241
+ last.ratio = last.matched / (last.end - last.start)
242
+ last.idf += region.idf
243
+ last.last_ayah = region.last_ayah
244
+ else:
245
+ merged.append(region)
246
+ return merged
247
+
248
+ # ---- Hadith -----------------------------------------------------------------------------------------------
249
+ def _scan_hadith(self, run: List[Token], taken: Sequence[Tuple[int, int]]) -> List[_Region]:
250
+ kb = self.kb
251
+ regions: List[_Region] = []
252
+ segment_start = 0
253
+ for i, token in enumerate(run):
254
+ if token.boundary_after or i == len(run) - 1:
255
+ segment = (segment_start, i + 1)
256
+ segment_start = i + 1
257
+ if segment[1] - segment[0] < self.hadith_min_tokens:
258
+ continue
259
+ for a, b in self._windows(*segment):
260
+ region = self._match_hadith(run, a, b)
261
+ if region is not None:
262
+ regions.append(region)
263
+ return self._non_overlapping(regions)
264
+
265
+ @staticmethod
266
+ def _windows(start: int, end: int, size: int = 24, stride: int = 12):
267
+ if end - start <= 40:
268
+ yield start, end
269
+ else:
270
+ for a in range(start, end - 6, stride):
271
+ yield a, min(end, a + size)
272
+
273
+ def _match_hadith(self, run: List[Token], a: int, b: int) -> Optional[_Region]:
274
+ kb = self.kb
275
+ window = run[a:b]
276
+ words = content_words(t.skeleton for t in window)
277
+ if len(words) < 4:
278
+ return None
279
+ window_keys = [t.key for t in window]
280
+ best = None
281
+ for idx in kb.hadith_candidates(words, 3):
282
+ record = kb.hadith[idx]
283
+ source = [p[1] for p in aligned_words(record["matn"] or record["full"])]
284
+ matcher = SequenceMatcher(None, window_keys, [phonetic_key(w) for w in source], autojunk=False)
285
+ blocks = [blk for blk in matcher.get_matching_blocks() if blk.size >= 3]
286
+ matched = sum(blk.size for blk in blocks)
287
+ if matched >= self.hadith_min_tokens and (best is None or matched > best[0]):
288
+ best = (matched, blocks)
289
+ if best is None:
290
+ return None
291
+ matched, blocks = best
292
+ start, end = blocks[0].a, blocks[-1].a + blocks[-1].size
293
+ ratio = matched / (end - start)
294
+ idf = sum(kb.hadith_data["bm25"].idf.get(window[i].skeleton, 0.0) for blk in blocks for i in range(blk.a, blk.a + blk.size))
295
+ if ratio < self.min_ratio or idf < self.hadith_min_idf:
296
+ return None
297
+ return _Region(a + start, a + end, "Hadith", matched, ratio, idf)
298
+
299
+
300
+ class HybridDetector:
301
+ """Rule-based detection first (quotation marks / brackets, typed by the corpora; phrases are only a hint), then the corpus scanner on the rest of the
302
+ text to find unannounced and embedded quotations. Rule spans always win overlaps: an author-delimited quotation is
303
+ verified exactly as written."""
304
+
305
+ def __init__(self, retriever: SourceRetriever, use_scanner: bool = True, rules_use_corpus: bool = True,
306
+ decouple_triggers: bool = True) -> None:
307
+ """``rules_use_corpus`` / ``decouple_triggers``: type delimited quotations from the corpora instead of from
308
+ introductory phrases (set both to False to reproduce the earlier trigger-driven behaviour for ablations)."""
309
+ self.rules = RuleDetector(retriever if rules_use_corpus else None, decouple_triggers=decouple_triggers)
310
+ self.scanner = CorpusScanner(retriever) if use_scanner else None
311
+
312
+ def detect(self, text: str) -> List[DetectedSpan]:
313
+ spans = self.rules.detect(text)
314
+ if self.scanner is not None:
315
+ spans += self.scanner.scan(text, [(s.start, s.end) for s in spans])
316
+ return sorted(spans, key=lambda s: s.start)
similarity.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Similarity indicators between a quotation and a candidate source (the signals behind the verification score)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import unicodedata
6
+ from typing import Dict, List
7
+
8
+ from alignment import best_region, lcs_length
9
+ from normalization import normalize_lenient, normalize_strict, tokenize
10
+
11
+ try: # optional accelerator; identical formula (1 - distance / max_len)
12
+ from rapidfuzz.distance import Levenshtein as _RapidLevenshtein
13
+ except ImportError: # pragma: no cover
14
+ _RapidLevenshtein = None
15
+
16
+
17
+ def _edit_similarity(a: str, b: str, max_len: int = 600) -> float:
18
+ """Normalised Levenshtein similarity in [0, 1] on the first ``max_len`` characters."""
19
+ a, b = a[:max_len], b[:max_len]
20
+ if a == b:
21
+ return 1.0
22
+ if not a or not b:
23
+ return 0.0
24
+ if _RapidLevenshtein is not None:
25
+ return float(_RapidLevenshtein.normalized_similarity(a, b))
26
+ prev = list(range(len(b) + 1))
27
+ for i, ca in enumerate(a):
28
+ curr = [i + 1]
29
+ for j, cb in enumerate(b):
30
+ curr.append(min(curr[j] + 1, prev[j + 1] + 1, prev[j] + (ca != cb)))
31
+ prev = curr
32
+ return 1.0 - prev[-1] / max(len(a), len(b))
33
+
34
+
35
+ def _light_normalize(text: str) -> str:
36
+ """Keeps diacritics (so diacritic changes lower the score) but drops punctuation and tatweel."""
37
+ text = unicodedata.normalize("NFC", text)
38
+ text = re.sub(r"[،؛؟!.,:;'\"()\[\]{}<>«»\-_/\\|@#$%^&*+=~`\u0640]", " ", text)
39
+ return re.sub(r"\s+", " ", text).strip()
40
+
41
+
42
+ def compute_signals(claim: str, candidate: str, content_type: str = "Ayah", local: bool = True) -> Dict[str, float]:
43
+ """Word-, character- and sequence-level similarity indicators between a quotation and a candidate source."""
44
+ is_quran = content_type == "Ayah"
45
+ full_candidate = candidate
46
+ if local: # score the snippet against the region it refers to, not against the whole verse / Hadith
47
+ candidate = best_region(claim, candidate)
48
+ normalize = normalize_strict if is_quran else normalize_lenient
49
+ norm_claim, norm_cand = normalize(claim), normalize(candidate)
50
+
51
+ claim_set, cand_set = set(tokenize(norm_claim)), set(tokenize(norm_cand))
52
+ if claim_set and cand_set:
53
+ shared = claim_set & cand_set
54
+ token_overlap = len(shared) / len(claim_set | cand_set)
55
+ coverage = len(shared) / len(claim_set)
56
+ else:
57
+ token_overlap = coverage = 0.0
58
+
59
+ claim_tokens, cand_tokens = tokenize(norm_claim), tokenize(norm_cand)
60
+ lcs_ratio = lcs_length(claim_tokens, cand_tokens) / len(claim_tokens) if claim_tokens else 0.0
61
+ edit_sim = _edit_similarity(norm_claim, norm_cand)
62
+
63
+ claim_chars, cand_chars = set(norm_claim.replace(" ", "")), set(norm_cand.replace(" ", ""))
64
+ char_overlap = len(claim_chars & cand_chars) / len(claim_chars | cand_chars) if (claim_chars or cand_chars) else 0.0
65
+
66
+ claim_flat, cand_flat = norm_claim.replace(" ", ""), norm_cand.replace(" ", "")
67
+ is_substring = int(bool(claim_flat) and bool(cand_flat) and (claim_flat in cand_flat or cand_flat in claim_flat))
68
+
69
+ diacritic_sim = (
70
+ _edit_similarity(_light_normalize(claim), _light_normalize(candidate), max_len=800) if is_quran else edit_sim
71
+ )
72
+ short = len(claim_tokens) < 4
73
+
74
+ if is_quran:
75
+ w = (
76
+ dict(coverage=0.35, diacritic_sim=0.30, lcs_ratio=0.15, token_overlap=0.10, char_overlap=0.05, edit_sim=0.05)
77
+ if short
78
+ 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)
79
+ )
80
+ composite = (
81
+ w["coverage"] * coverage + w["diacritic_sim"] * diacritic_sim + w["lcs_ratio"] * lcs_ratio
82
+ + w["token_overlap"] * token_overlap + w["char_overlap"] * char_overlap + w["edit_sim"] * edit_sim
83
+ )
84
+ if is_substring and diacritic_sim >= 0.60:
85
+ composite = max(composite, 0.88)
86
+ elif is_substring:
87
+ composite = max(composite, 0.75)
88
+ else:
89
+ w = (
90
+ dict(coverage=0.40, lcs_ratio=0.20, token_overlap=0.20, char_overlap=0.10, edit_sim=0.10)
91
+ if short
92
+ else dict(coverage=0.30, lcs_ratio=0.28, token_overlap=0.18, char_overlap=0.12, edit_sim=0.12)
93
+ )
94
+ composite = (
95
+ w["coverage"] * coverage + w["lcs_ratio"] * lcs_ratio + w["token_overlap"] * token_overlap
96
+ + w["char_overlap"] * char_overlap + w["edit_sim"] * edit_sim
97
+ )
98
+ if is_substring:
99
+ composite = max(composite, 0.82)
100
+
101
+ return {
102
+ "token_overlap": round(token_overlap, 4),
103
+ "coverage": round(coverage, 4),
104
+ "lcs_ratio": round(lcs_ratio, 4),
105
+ "edit_sim": round(edit_sim, 4),
106
+ "diacritic_sim": round(diacritic_sim, 4),
107
+ "char_overlap": round(char_overlap, 4),
108
+ "is_substring": is_substring,
109
+ "source_fraction": round(min(1.0, len(claim_tokens) / max(len(tokenize(normalize(full_candidate))), 1)), 4),
110
+ "composite": round(composite, 4),
111
+ }
112
+
113
+
114
+ def best_match_score(claim: str, candidates: List[dict], content_type: str = "Ayah", local: bool = True):
115
+ """Return ``(score, candidate_with_signals)`` for the best-scoring candidate."""
116
+ best_score, best_candidate = 0.0, None
117
+ for candidate in candidates:
118
+ signals = compute_signals(claim, candidate.get("text", ""), content_type, local)
119
+ if signals["composite"] > best_score:
120
+ best_score, best_candidate = signals["composite"], {**candidate, "signals": signals}
121
+ return best_score, best_candidate
ui.py ADDED
@@ -0,0 +1,537 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arabic-only presentation layer shared by the Gradio app and the static (in-browser) page.
2
+
3
+ Colour rules: green = verified, red = a real mismatch or a missing reference, amber = needs a human. Verified items
4
+ never use red. Every user-facing string is Arabic.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import html
9
+ from typing import List, Optional
10
+
11
+ from benchmark_format import official_payload
12
+
13
+ _e = html.escape
14
+
15
+ APP_TITLE = "التحقق من هلوسة القرآن والحديث وتصحيحها"
16
+ APP_TAGLINE = "تحقّق من آيات القرآن والأحاديث النبوية بالدليل"
17
+ COPIED_MESSAGE = "تم نسخ النص بنجاح"
18
+
19
+ GROUP_LABEL = {"verified": "موثّق", "mismatch": "غير مطابق", "review": "تحتاج مراجعة بشرية"}
20
+ TYPE_LABEL = {"Ayah": "آية قرآنية", "Hadith": "حديث نبوي"}
21
+ INDICATORS = [
22
+ ("composite", "الدرجة المركبة"),
23
+ ("coverage", "تغطية الكلمات"),
24
+ ("lcs_ratio", "التسلسل النصي"),
25
+ ("token_overlap", "تداخل الكلمات"),
26
+ ("edit_sim", "تشابه الحروف"),
27
+ ("diacritic_sim", "التشكيل"),
28
+ ]
29
+ METHODS = {
30
+ "substring_match": "الاقتباس واردٌ كاملًا في المصدر",
31
+ "threshold_pass": "تجاوز عتبة التطابق",
32
+ "threshold_fail": "دون عتبة التطابق",
33
+ "borderline_multi_cov": "حالة حدّية: عدة مصادر متقاربة",
34
+ "borderline_default": "حالة حدّية",
35
+ "borderline_low_retrieval": "حالة حدّية: استرجاع ضعيف",
36
+ "no_candidates": "لا توجد مصادر مرشحة",
37
+ "empty_span": "نص فارغ",
38
+ "error": "تعذّرت المعالجة",
39
+ }
40
+
41
+
42
+ def _pct(value: float) -> str:
43
+ return f"{round(value * 100)}%"
44
+
45
+
46
+ def source_label(source: dict) -> str:
47
+ if source["type"] == "Quran":
48
+ start, end = source["ayah_start"], source["ayah_end"]
49
+ verses = f"{start}" if start == end else f"{start}–{end}"
50
+ return f"سورة {source['surah_name']} — الآية {verses}"
51
+ return f"حديث رقم {source['hadithID']} — {source['title']}"
52
+
53
+
54
+ def reason_text(reason: dict) -> str:
55
+ """Arabic explanation of a decision code produced by the pipeline."""
56
+ code = reason["code"]
57
+ src = reason.get("source", "")
58
+ if code == "exact_match":
59
+ return f"تطابق تام مع {src} بعد تجاهل اختلافات الرسم والتشكيل."
60
+ if code == "close_match":
61
+ return f"تطابق شبه كامل مع {src}."
62
+ if code == "altered_passage":
63
+ n = reason.get("n", 0)
64
+ how = "تغيّر ترتيب الكلمات" if reason.get("reordered") else f"{n} موضع مختلف"
65
+ return f"النص يخالف {src} ({how}). التصحيح المقترح هو نص المصدر حرفيًا ولم يُولَّد."
66
+ if code == "weak_match":
67
+ return "وُجد مصدر قريب لكن التطابق غير كافٍ للحكم الآلي."
68
+ if code == "no_source":
69
+ return "لا يوجد في المراجع المضمّنة نصٌّ يشبه هذا الاقتباس، وقد يكون مختلَقًا."
70
+ if code == "insufficient_evidence":
71
+ return "الأدلة غير كافية: لا مصدر واضح، ودرجة اليقين منخفضة."
72
+ if code == "candidate_not_strong":
73
+ return f"وُجد مرشح ({src}، قوة المطابقة {round(reason.get('strength', 0) * 100)}%) لكن الدليل لا يكفي للتصحيح الآلي."
74
+ if code == "hadith_candidate":
75
+ return f"وُجدت رواية مشابهة ({src}) لكن لا يُصحَّح الحديث آليًا لاختلاف الروايات؛ يلزم الرجوع إلى مختص."
76
+ return "تعذّرت معالجة هذا الاقتباس آليًا."
77
+
78
+
79
+ def note_text(note: dict) -> str:
80
+ n = note.get("n", 0)
81
+ code = note["code"]
82
+ if code == "diacritic_conflict":
83
+ return f"تنبيه: تشكيل {n} كلمة يخالف المصحف (الكلمات صحيحة لكن الحركات مختلفة)."
84
+ if code == "orthographic_variant":
85
+ return f"ملاحظة: {n} اختلاف إملائي (رسم أو مسافات) لا يغيّر الكلمة."
86
+ if code == "hadith_minor_diffs":
87
+ return f"ملاحظة: {n} اختلاف طفيف عن أقرب رواية في المراجع."
88
+ return ""
89
+
90
+
91
+ def _diff_html(comparison: dict) -> str:
92
+ """Red = words only in the quotation; green = the source words that should be there."""
93
+ parts = []
94
+ for op in comparison["word_diff"]:
95
+ if op["op"] == "equal":
96
+ parts.append(f'<span class="w-eq">{_e(op["span"])}</span>')
97
+ continue
98
+ if op["span"]:
99
+ parts.append(f'<span class="w-extra">{_e(op["span"])}</span>')
100
+ if op["source"]:
101
+ parts.append(f'<span class="w-missing">{_e(op["source"])}</span>')
102
+ return " ".join(parts)
103
+
104
+
105
+ def _legend(group: str, comparison: Optional[dict]) -> str:
106
+ if group == "verified" or not comparison or all(op["op"] == "equal" for op in comparison["word_diff"]):
107
+ return ""
108
+ return '<p class="legend"><span class="w-extra">كلمات في الاقتباس تخالف المصدر</span> <span class="w-missing">الصواب من المصدر</span></p>'
109
+
110
+
111
+ def _indicator_table(signals: Optional[dict]) -> str:
112
+ if not signals:
113
+ return ""
114
+ rows = []
115
+ for key, label in INDICATORS:
116
+ value = float(signals.get(key, 0.0))
117
+ rows.append(
118
+ f'<div class="ind"><div class="ind-name">{label}</div>'
119
+ f'<div class="bar"><span style="width:{_pct(value)}"></span></div><div class="ind-val">{_pct(value)}</div></div>'
120
+ )
121
+ rows.append(f'<div class="ind"><div class="ind-name">وروده كاملًا في المصدر</div><div class="ind-val wide">{"نعم" if signals.get("is_substring") else "لا"}</div></div>')
122
+ return '<div class="indicators">' + "".join(rows) + "</div>"
123
+
124
+
125
+ def _match_percent(span: dict) -> int:
126
+ evidence = span["evidence"]
127
+ if evidence and evidence.get("comparison"):
128
+ return round(evidence["comparison"]["word_similarity"] * 100)
129
+ if evidence and evidence.get("signals"):
130
+ return round(evidence["signals"]["composite"] * 100)
131
+ return 0
132
+
133
+
134
+ def _evidence_panel(span: dict) -> str:
135
+ evidence, verification = span["evidence"], span["verification"]
136
+ rows = [
137
+ f'<div class="ev-row"><b>الاقتباس المكتشف</b><p class="quote">{_e(span["text"])}</p></div>',
138
+ f'<div class="ev-row"><b>النوع</b><p>{TYPE_LABEL[span["type"]]}</p></div>',
139
+ ]
140
+ if evidence:
141
+ comparison = evidence["comparison"]
142
+ rows += [
143
+ f'<div class="ev-row"><b>المصدر المرشح</b><p>{_e(source_label(evidence["source"]))}</p></div>',
144
+ f'<div class="ev-row"><b>نص المصدر الأصلي</b><p class="quote">{_e(comparison["source_excerpt"])}</p></div>',
145
+ f'<div class="ev-row"><b>المقارنة كلمةً بكلمة</b><p class="diff">{_diff_html(comparison)}</p>{_legend(span["group"], comparison)}</div>',
146
+ f'<div class="ev-row"><b>مؤشرات التحقق</b>{_indicator_table(evidence.get("signals"))}</div>',
147
+ ]
148
+ if comparison["diacritic_notes"]:
149
+ items = "، ".join(f"{_e(n['word'])} ← {_e(n['source_word'])}" for n in comparison["diacritic_notes"])
150
+ rows.append(f'<div class="ev-row"><b>اختلافات التشكيل</b><p>{items}</p></div>')
151
+ else:
152
+ rows.append('<div class="ev-row"><b>المصدر المرشح</b><p>لم يُسترجع أي مصدر.</p></div>')
153
+ rows += [
154
+ f'<div class="ev-row"><b>درجة اليقين في الحكم</b><p>{_pct(verification["confidence"])} · {METHODS.get(verification["method"], "")}</p></div>',
155
+ f'<div class="ev-row"><b>القرار</b><p>{_e(span["status_ar"])}</p></div>',
156
+ f'<div class="ev-row"><b>سبب القرار</b><p>{_e(reason_text(span["reason"]))}</p></div>',
157
+ ]
158
+ return "".join(rows)
159
+
160
+
161
+ def _card(span: dict) -> str:
162
+ group, evidence = span["group"], span["evidence"]
163
+ label = GROUP_LABEL[group]
164
+ chip = f'<span class="conf">نسبة المطابقة <b>{_match_percent(span)}%</b></span>'
165
+ scan_badge = ('<span class="badge scan" title="اكتُشف بمطابقة النص مع المراجع دون علامات تنصيص">اقتباس غير معلَن</span>'
166
+ if span["detection"]["backend"] == "scan" else "")
167
+ engine_badge = {
168
+ "camelbert": '<span class="badge scan" title="حدّد النموذج المضبوط حدود هذا الاقتباس">كشف بنموذج مضبوط</span>',
169
+ "camelbert-simulated": '<span class="badge scan" title="لا يوجد نموذج مستضاف مضبوط الآن؛ الكشف هنا محاكاة بالكاشف المدمج">كشف تجريبي (محاكاة)</span>',
170
+ }.get(span["detection"]["backend"], "")
171
+ scan_badge += engine_badge
172
+
173
+ fields = [f'<div class="field"><label>الاقتباس المكتشف</label><p class="quote">{_e(span["text"])}</p></div>']
174
+ if evidence:
175
+ comparison = evidence["comparison"]
176
+ fields.append(f'<div class="field"><label>المصدر المرشح</label><p>{_e(source_label(evidence["source"]))}</p></div>')
177
+ if group != "verified": # a verified quotation equals its source: no need to print it twice
178
+ fields.append(
179
+ f'<div class="field"><label>المقارنة بالمصدر</label><p class="diff">{_diff_html(comparison)}</p>{_legend(group, comparison)}</div>'
180
+ )
181
+ else:
182
+ fields.append('<div class="field"><label>المصدر المرشح</label><p>لا يوجد</p></div>')
183
+ fields.append(f'<p class="reason">{_e(reason_text(span["reason"]))}</p>')
184
+
185
+ notes = "".join(f'<p class="note">{_e(note_text(n))}</p>' for n in span["notes"] if note_text(n))
186
+ action = ""
187
+ correction, suggestion = span["correction"], span["suggestion"]
188
+ if correction:
189
+ action = (
190
+ '<div class="action ok"><b>التصحيح المقترح من المصدر</b>'
191
+ f'<p class="quote">{_e(correction["display_text"])}</p>'
192
+ f'<p class="legend">{_e(source_label(correction["source"]))}</p></div>'
193
+ )
194
+ elif group == "review":
195
+ closest = ""
196
+ if suggestion:
197
+ closest = (f'<p class="legend">أقرب مصدر وُجد للمراجِع، وليس تصحيحًا آليًا: {_e(source_label(suggestion["source"]))}</p>'
198
+ f'<p class="quote small">{_e(suggestion["display_text"][:420])}</p>')
199
+ action = ('<div class="action warn"><b>الأدلة غير كافية للتصحيح الآلي.</b> يُوصى بالمراجعة البشرية.' + closest + "</div>")
200
+ elif span["status"] == "UNSUPPORTED":
201
+ action = '<div class="action bad"><b>لا يوجد مصدر مطابق في المراجع المتاحة.</b> لم يُقترح أي نص بديل.</div>'
202
+
203
+ return f"""
204
+ <div class="qcard {group}">
205
+ <div class="qhead">
206
+ <span class="idx">{span["id"]}</span>
207
+ <span class="badge type">{TYPE_LABEL[span["type"]]}</span>{scan_badge}
208
+ <span class="badge st {group}">{label}</span>
209
+ {chip}
210
+ </div>
211
+ {"".join(fields)}
212
+ {notes}
213
+ {action}
214
+ <details class="evidence"><summary>عرض الدليل</summary><div class="ev-body">{_evidence_panel(span)}</div></details>
215
+ </div>"""
216
+
217
+
218
+ def _summary(summary: dict) -> str:
219
+ tiles = [
220
+ (summary["n_spans"], "إجمالي الاقتباسات", ""),
221
+ (summary["n_ayah"], "آيات قرآنية", ""),
222
+ (summary["n_hadith"], "أحاديث نبوية", ""),
223
+ (summary["VERIFIED"], "موثّق", "verified"),
224
+ (summary["CORRECTED"] + summary["UNSUPPORTED"], "غير مطابق", "mismatch"),
225
+ (summary["HUMAN_REVIEW"], "تحتاج مراجعة", "review"),
226
+ ]
227
+ return '<div class="summary">' + "".join(
228
+ f'<div class="tile {cls}{" zero" if value == 0 and cls else ""}"><b>{value}</b><span>{label}</span></div>'
229
+ for value, label, cls in tiles
230
+ ) + "</div>"
231
+
232
+
233
+ def _highlighted_text(result: dict, title: str) -> str:
234
+ text, pieces, cursor = result["input_text"], [], 0
235
+ for span in result["spans"]:
236
+ pieces.append(_e(text[cursor:span["start"]]))
237
+ pieces.append(f'<mark class="{span["group"]}">{_e(text[span["start"]:span["end"]])}</mark>')
238
+ cursor = span["end"]
239
+ pieces.append(_e(text[cursor:]))
240
+ legend = ('<div class="hl-legend"><mark class="verified">موثّق</mark><mark class="mismatch">غير مطابق</mark>'
241
+ '<mark class="review">مراجعة بشرية</mark></div>')
242
+ return f'<div class="highlight"><label>{title}</label><p>' + "".join(pieces) + "</p>" + legend + "</div>"
243
+
244
+
245
+ def final_text(result: dict) -> str:
246
+ """Corrected text with an inline Arabic flag after every quotation that still needs attention."""
247
+ text, reports = result["input_text"], result["spans"]
248
+ for report in sorted(reports, key=lambda r: r["start"], reverse=True):
249
+ if report["status"] == "CORRECTED" and report["correction"]:
250
+ text = text[: report["start"]] + report["correction"]["display_text"] + text[report["end"]:]
251
+ elif report["status"] == "UNSUPPORTED":
252
+ text = text[: report["end"]] + " [⚠ لا يوجد مصدر مطابق]" + text[report["end"]:]
253
+ elif report["status"] == "HUMAN_REVIEW":
254
+ text = text[: report["end"]] + " [⚠ يحتاج مراجعة بشرية]" + text[report["end"]:]
255
+ return text
256
+
257
+
258
+ def _final_block(result: dict) -> str:
259
+ summary = result["summary"]
260
+ fixed = summary["CORRECTED"]
261
+ open_items = summary["UNSUPPORTED"] + summary["HUMAN_REVIEW"]
262
+ if fixed == 0 and open_items == 0:
263
+ headline = "كل الاقتباسات المكتشفة مطابقة للمصادر."
264
+ else:
265
+ headline = f"صُحِّح {fixed} اقتباس من نص المصدر، وبقي {open_items} اقتباس يحتاج مراجعة بشرية ومعلَّم بعلامة تنبيه."
266
+ text = final_text(result)
267
+ return (
268
+ '<div class="final"><div class="final-head"><b>النسخة المصحّحة</b>'
269
+ f'<button class="copy" type="button" data-text="{_e(text, quote=True)}" '
270
+ 'onclick="window.icvCopy&&window.icvCopy(this)">نسخ النص</button></div>'
271
+ f'<p class="legend">{headline}</p><p class="final-text">{_e(text)}</p></div>'
272
+ )
273
+
274
+
275
+ _EXPORT_LABELS = (("task1A_predictions.tsv", "الكشف (1A)"), ("task1B_predictions.tsv", "التحقق (1B)"),
276
+ ("task1C_predictions.tsv", "التصحيح (1C)"))
277
+
278
+
279
+ def _export_block(result: dict) -> str:
280
+ """Download buttons for the three official submission files (client-side Blob download, nothing is uploaded)."""
281
+ payload = official_payload(result)
282
+ buttons = "".join(
283
+ f'<button class="dl" type="button" data-name="{name}" data-text="{_e(payload[name], quote=True)}" '
284
+ f'onclick="window.icvDownload&&window.icvDownload(this)">{label}</button>'
285
+ for name, label in _EXPORT_LABELS
286
+ )
287
+ return ('<details class="export"><summary>تصدير النتائج بصيغة ملفات المسابقة الرسمية</summary>'
288
+ f'<p class="legend">ملفات نصية بفواصل جدولة تطابق مخرجات المهمة الأولى (الكشف والتحقق والتصحيح).</p><div class="dls">{buttons}</div></details>')
289
+
290
+
291
+ def render_results(result: dict, generated_answer: Optional[str] = None) -> str:
292
+ """HTML report for a pipeline result. Pass the model's answer (mode B) to show it first and label the text as generated."""
293
+ header = render_generated_header(generated_answer) if generated_answer is not None else ""
294
+ if not result["spans"]:
295
+ body = (
296
+ '<div class="notice">لم يُعثر على اقتباسات قرآنية أو حديثية في هذا النص. يتعرّف النظام على الاقتباسات بمطابقتها مع المراجع، '
297
+ 'سواء وُضعت بين علامات تنصيص أو أقواس أو وردت داخل الكلام دون أي عبارة تمهيدية.</div>'
298
+ )
299
+ return f'<div class="icv">{header}{body}</div>'
300
+ generated = generated_answer is not None
301
+ title = "إجابة النموذج مع الاقتباسات المكتشفة" if generated else "النص مع الاقتباسات المكتشفة"
302
+ parts = [_summary(result["summary"]), _highlighted_text(result, title), "".join(_card(s) for s in result["spans"])]
303
+ summary = result["summary"]
304
+ if generated or summary["CORRECTED"] or summary["UNSUPPORTED"] or summary["HUMAN_REVIEW"]:
305
+ parts.append(_final_block(result))
306
+ parts.append(_export_block(result))
307
+ return f'<div class="icv">{header}{"".join(parts)}</div>'
308
+
309
+
310
+ def render_generated_header(answer: str) -> str:
311
+ return f'<div class="generated"><label>إجابة النموذج اللغوي كما وصلت</label><p>{_e(answer)}</p></div>'
312
+
313
+
314
+ def render_message(message: str, kind: str = "info") -> str:
315
+ return f'<div class="icv"><div class="notice {kind}">{_e(message)}</div></div>'
316
+
317
+
318
+ # --------------------------------------------------------------------------------------------------------------
319
+ # Static markup and CSS
320
+ # --------------------------------------------------------------------------------------------------------------
321
+ STAR = (
322
+ '<svg class="mark" viewBox="0 0 64 64" fill="none" stroke="#E2C06E" stroke-width="1.8" aria-hidden="true">'
323
+ '<rect x="14" y="14" width="36" height="36"/><rect x="14" y="14" width="36" height="36" transform="rotate(45 32 32)"/>'
324
+ '<circle cx="32" cy="32" r="7" fill="#E2C06E" stroke="none"/></svg>'
325
+ )
326
+
327
+ HERO = f"""
328
+ <div class="icv"><div class="hero">
329
+ {STAR}
330
+ <h1>{APP_TITLE}</h1>
331
+ <p class="tagline">{APP_TAGLINE}</p>
332
+ <p class="sub">يكتشف النظام الاقتباسات داخل أي نص، ويسترجع نصوصها من المصادر، ويقارنها كلمةً بكلمة، ثم يعرض الدليل.
333
+ وإذا لم تكفِ الأدلة فلن يختلق تصحيحًا، بل يحيل الحالة إلى المراجعة البشرية.</p>
334
+ <div class="flow"><span>كشف</span><i>‹</i><span>استرجاع</span><i>‹</i><span>محاذاة</span><i>‹</i><span>دليل</span><i>‹</i><span>قرار</span></div>
335
+ </div></div>
336
+ """
337
+
338
+ DISCLAIMER = """<div class="icv"><div class="disclaimer">أداة مساعدة للتدقيق النصي وليست فتوى ولا بديلًا عن المراجعة المتخصصة.
339
+ النتائج مبنية على مراجع القرآن الكريم والكتب الستة المضمّنة فقط.</div></div>"""
340
+
341
+ PLACEHOLDER = "الصق هنا النص الذي ولّده نموذج لغوي. يكتشف النظام الآيات والأحاديث الواردة فيه، بعلامات تنصيص أو بدونها…"
342
+ PROMPT_PLACEHOLDER = "اكتب سؤالك، مثل: اشرح لي فضل الصبر في القرآن والسنة مع ذكر الأدلة."
343
+
344
+ _PATTERN = (
345
+ "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='88' viewBox='0 0 88 88'%3E"
346
+ "%3Cg fill='none' stroke='%230F4C3A' stroke-opacity='0.07' stroke-width='1'%3E"
347
+ "%3Crect x='22' y='22' width='44' height='44'/%3E%3Crect x='22' y='22' width='44' height='44' transform='rotate(45 44 44)'/%3E"
348
+ "%3C/g%3E%3C/svg%3E\")"
349
+ )
350
+
351
+ CSS = """
352
+ @import url('https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&family=Amiri:wght@400;700&display=swap');
353
+ :root { --green:#0F4C3A; --green-2:#17694F; --gold:#B8912F; --gold-2:#E2C06E; --cream:#FBF6EA; --paper:#FFFFFF;
354
+ --ink:#1F2933; --muted:#55626D; --line:#E6DCC3; --ok:#1B7A4B; --ok-bg:#EAF6EF; --ok-line:#BFE3CD;
355
+ --bad:#B3261E; --bad-bg:#FDECEA; --bad-line:#F4B8B3; --warn:#7A5B0C; --warn-bg:#FFF6DA; --warn-line:#EBD28A; }
356
+ .gradio-container, body.icv-page { background:var(--cream) PATTERN !important; font-family:'Cairo','Segoe UI',Tahoma,sans-serif; color:var(--ink); }
357
+ .gradio-container { max-width:1060px !important; --body-text-color:#1F2933; --block-background-fill:#FFFFFF; --block-border-color:#E6DCC3;
358
+ --input-background-fill:#FFFFFF; --block-label-text-color:#55626D; --button-primary-background-fill:#0F4C3A;
359
+ --button-primary-background-fill-hover:#17694F; --button-primary-text-color:#FFFFFF; --button-secondary-background-fill:#FFFFFF;
360
+ --button-secondary-text-color:#0F4C3A; --button-secondary-border-color:#B8912F; --color-accent:#B8912F; }
361
+ .icv { direction:rtl; text-align:right; color:var(--ink); line-height:1.9; }
362
+ .icv .hero { background:linear-gradient(135deg,#0F4C3A,#17694F); border-radius:20px; padding:30px 22px 26px; margin:10px 0 18px; text-align:center;
363
+ box-shadow:0 6px 22px rgba(15,76,58,.18); border-bottom:4px solid var(--gold); }
364
+ .icv .hero .mark { width:54px; height:54px; display:block; margin:0 auto 4px; }
365
+ .icv .hero h1 { font-size:2.3rem; margin:.1rem 0; color:#FFFFFF; font-weight:700; }
366
+ .icv .hero .tagline { color:var(--gold-2); font-size:1.2rem; font-weight:600; margin:.1rem 0 .6rem; }
367
+ .icv .hero .sub { max-width:720px; margin:0 auto; color:#E9F2EE; font-size:1rem; }
368
+ .icv .flow { display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:8px; margin-top:16px; }
369
+ .icv .flow span { background:rgba(255,255,255,.12); border:1px solid rgba(226,192,110,.55); color:#FFF; border-radius:999px; padding:3px 16px; font-size:.9rem; }
370
+ .icv .flow i { color:var(--gold-2); font-style:normal; font-size:1.2rem; }
371
+ .icv .disclaimer { font-size:.85rem; color:var(--muted); text-align:center; padding:14px 8px; }
372
+ .icv .en-free { direction:rtl; }
373
+ .input-area textarea { direction:rtl; text-align:right; font-family:'Amiri','Cairo',serif !important; font-size:1.25rem !important; line-height:2.1 !important; background:#FFFFFF !important; color:#1F2933 !important; }
374
+ .llm-row label, .gradio-container label span { font-family:'Cairo',sans-serif; }
375
+ .results { direction:rtl; }
376
+ .icv .summary { display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); gap:10px; margin:8px 0 14px; }
377
+ .icv .tile { background:var(--paper); border:1px solid var(--line); border-radius:14px; padding:12px 8px; text-align:center; }
378
+ .icv .tile b { display:block; font-size:1.8rem; color:var(--green); line-height:1.3; } .icv .tile span { font-size:.92rem; color:var(--muted); }
379
+ .icv .tile.verified { background:var(--ok-bg); border-color:var(--ok-line); } .icv .tile.verified b { color:var(--ok); }
380
+ .icv .tile.mismatch { background:var(--bad-bg); border-color:var(--bad-line); } .icv .tile.mismatch b { color:var(--bad); }
381
+ .icv .tile.review { background:var(--warn-bg); border-color:var(--warn-line); } .icv .tile.review b { color:var(--warn); }
382
+ .icv .tile.zero { background:var(--paper); border-color:var(--line); opacity:.6; } .icv .tile.zero b { color:var(--muted); }
383
+ .icv .highlight, .icv .generated, .icv .final { background:var(--paper); border:1px solid var(--line); border-radius:14px; padding:14px 18px; margin-bottom:14px; }
384
+ .icv label { display:block; color:var(--muted); font-size:.82rem; font-weight:600; margin-bottom:4px; }
385
+ .icv .highlight p, .icv .generated p, .icv .final-text { font-family:'Amiri','Cairo',serif; font-size:1.2rem; line-height:2.2; margin:0; white-space:pre-wrap; }
386
+ .icv mark { color:var(--ink); border-radius:6px; padding:1px 5px; }
387
+ .icv mark.verified { background:#D5EEDF; } .icv mark.mismatch { background:#F8CFCB; } .icv mark.review { background:#F7E3A6; }
388
+ .icv .hl-legend { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; font-size:.78rem; }
389
+ .icv .qcard { background:var(--paper); border:1px solid var(--line); border-inline-start:6px solid var(--line); border-radius:16px; padding:16px 18px; margin:12px 0; }
390
+ .icv .qcard.verified { border-inline-start-color:var(--ok); } .icv .qcard.mismatch { border-inline-start-color:var(--bad); } .icv .qcard.review { border-inline-start-color:var(--gold); }
391
+ .icv .qhead { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:8px; }
392
+ .icv .idx { background:var(--green); color:#FFF; border-radius:50%; width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; font-weight:700; font-size:.9rem; }
393
+ .icv .badge { padding:2px 14px; border-radius:999px; font-size:.84rem; background:#F3EEDD; border:1px solid var(--line); color:var(--ink); }
394
+ .icv .badge.st.verified { background:var(--ok-bg); border-color:var(--ok-line); color:var(--ok); font-weight:600; }
395
+ .icv .badge.st.mismatch { background:var(--bad-bg); border-color:var(--bad-line); color:var(--bad); font-weight:600; }
396
+ .icv .badge.st.review { background:var(--warn-bg); border-color:var(--warn-line); color:var(--warn); font-weight:600; }
397
+ .icv .badge.scan { background:#EEF3FB; border-color:#C9D8EE; color:#2F4F7F; }
398
+ .icv .conf { margin-inline-start:auto; color:var(--muted); font-size:.88rem; } .icv .conf b { color:var(--green); }
399
+ .icv .field { margin:8px 0; } .icv .field p { margin:0; }
400
+ .icv .quote { font-family:'Amiri','Cairo',serif; font-size:1.25rem; line-height:2.2; color:var(--ink); } .icv .quote.small { font-size:1.05rem; color:#3a4651; }
401
+ .icv .diff { font-family:'Amiri','Cairo',serif; font-size:1.2rem; line-height:2.2; }
402
+ .icv .w-extra { background:var(--bad-bg); color:var(--bad); border-radius:4px; padding:0 4px; text-decoration:line-through; }
403
+ .icv .w-missing { background:var(--ok-bg); color:var(--ok); border-radius:4px; padding:0 4px; font-weight:700; }
404
+ .icv .legend { color:var(--muted); font-size:.82rem; margin:4px 0 0; } .icv .legend span { text-decoration:none; font-size:.8rem; }
405
+ .icv .reason { color:var(--muted); font-size:.92rem; margin:6px 0 0; }
406
+ .icv .note { color:var(--warn); background:var(--warn-bg); border:1px solid var(--warn-line); border-radius:10px; padding:5px 12px; font-size:.88rem; margin:8px 0 0; }
407
+ .icv .action { border-radius:12px; padding:10px 14px; margin-top:10px; }
408
+ .icv .action.ok { background:var(--ok-bg); border:1px solid var(--ok-line); } .icv .action.ok b { color:var(--ok); }
409
+ .icv .action.warn { background:var(--warn-bg); border:1px solid var(--warn-line); } .icv .action.warn b { color:var(--warn); }
410
+ .icv .action.bad { background:var(--bad-bg); border:1px solid var(--bad-line); } .icv .action.bad b { color:var(--bad); }
411
+ .icv .evidence { margin-top:12px; border-top:1px dashed var(--line); padding-top:8px; }
412
+ .icv .evidence summary { cursor:pointer; color:var(--green); font-weight:700; }
413
+ .icv .ev-row { margin:10px 0; } .icv .ev-row b { color:var(--gold); font-size:.88rem; } .icv .ev-row p { margin:2px 0; }
414
+ .icv .indicators { display:grid; gap:6px; margin-top:6px; }
415
+ .icv .ind { display:grid; grid-template-columns:150px 1fr 48px; gap:10px; align-items:center; font-size:.88rem; }
416
+ .icv .bar { background:#EFE8D3; border-radius:999px; height:9px; overflow:hidden; direction:rtl; } .icv .bar span { display:block; height:100%; background:linear-gradient(270deg,var(--green),var(--gold)); }
417
+ .icv .ind-val { text-align:left; direction:ltr; color:var(--ink); } .icv .ind-val.wide { grid-column:2 / span 2; text-align:right; direction:rtl; }
418
+ .icv .final { border-color:var(--gold); background:#FFFDF6; }
419
+ .icv .final-head { display:flex; justify-content:space-between; align-items:center; } .icv .final-head b { color:var(--green); font-size:1.05rem; }
420
+ .icv .copy { background:var(--green); color:#FFF; border:0; border-radius:10px; padding:5px 16px; font-family:inherit; cursor:pointer; }
421
+ .icv .notice { background:var(--paper); border:1px solid var(--line); border-radius:14px; padding:16px; }
422
+ .icv .notice.warn { background:var(--warn-bg); border-color:var(--warn-line); color:var(--warn); } .icv .notice.bad { background:var(--bad-bg); border-color:var(--bad-line); color:var(--bad); }
423
+ @media (max-width:640px){ .icv .hero h1{font-size:1.7rem;} .icv .ind{grid-template-columns:104px 1fr 40px;} }
424
+ """.replace("PATTERN", _PATTERN)
425
+
426
+
427
+ # --------------------------------------------------------------------------------------------------------------
428
+ # Visual layer: soft glassmorphism, dynamic gradients, RTL-safe text, toast and skeleton loaders
429
+ # --------------------------------------------------------------------------------------------------------------
430
+ GLASS_CSS = """
431
+ :root { --glass:rgba(255,255,255,.62); --glass-strong:rgba(255,255,255,.82); --glass-line:rgba(255,255,255,.75);
432
+ --shadow-1:0 1px 2px rgba(15,76,58,.06), 0 8px 24px rgba(15,76,58,.08); --shadow-2:0 2px 4px rgba(15,76,58,.08), 0 18px 44px rgba(15,76,58,.16); }
433
+ .gradio-container, body.icv-page { background:
434
+ radial-gradient(900px 520px at 88% -8%, rgba(226,192,110,.34), transparent 60%),
435
+ radial-gradient(760px 520px at 6% 4%, rgba(23,105,79,.20), transparent 62%),
436
+ linear-gradient(180deg,#FBF6EA 0%,#F3EBD3 100%) !important; background-attachment:fixed !important; }
437
+ /* RTL safety: long words, URLs and mixed Latin/digit runs wrap inside their box instead of spilling out */
438
+ .icv, .icv * { box-sizing:border-box; min-width:0; }
439
+ .icv p, .icv span, .icv b, .icv label, .icv summary, .icv button, .icv .badge, .icv .tile { overflow-wrap:anywhere; }
440
+ .icv .quote, .icv .diff, .icv .final-text, .icv .highlight p, .icv .generated p { unicode-bidi:plaintext; text-align:start; }
441
+ .icv .hero { position:relative; overflow:hidden; border-bottom:0; border:1px solid rgba(226,192,110,.45);
442
+ background:linear-gradient(120deg,#0B3B2D,#17694F 45%,#0F4C3A 70%,#1d7a5c); background-size:240% 240%; animation:icv-flow 16s ease-in-out infinite;
443
+ box-shadow:var(--shadow-2); }
444
+ .icv .hero::before { content:""; position:absolute; inset:-40% -10% auto auto; width:60%; aspect-ratio:1; border-radius:50%;
445
+ background:radial-gradient(circle, rgba(226,192,110,.38), transparent 65%); pointer-events:none; }
446
+ .icv .hero::after { content:""; position:absolute; inset:auto auto 0 0; width:100%; height:4px; background:linear-gradient(90deg,transparent,var(--gold-2),transparent); }
447
+ .icv .hero h1 { font-size:clamp(1.45rem,4.2vw,2.3rem); line-height:1.5; text-wrap:balance; position:relative; }
448
+ .icv .hero .tagline, .icv .hero .sub, .icv .flow { position:relative; }
449
+ .icv .flow span { backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px); }
450
+ @keyframes icv-flow { 0%,100%{background-position:0% 50%} 50%{background-position:100% 50%} }
451
+ .icv .tile, .icv .highlight, .icv .generated, .icv .final, .icv .qcard, .icv .notice, .icv .export {
452
+ background:var(--glass); border:1px solid var(--glass-line); box-shadow:var(--shadow-1);
453
+ backdrop-filter:blur(14px) saturate(150%); -webkit-backdrop-filter:blur(14px) saturate(150%); transition:transform .25s ease, box-shadow .25s ease; }
454
+ .icv .qcard { border-inline-start:6px solid var(--line); }
455
+ .icv .qcard:hover, .icv .tile:hover { transform:translateY(-2px); box-shadow:var(--shadow-2); }
456
+ .icv .tile.verified { background:linear-gradient(160deg,rgba(234,246,239,.92),rgba(255,255,255,.6)); }
457
+ .icv .tile.mismatch { background:linear-gradient(160deg,rgba(253,236,234,.92),rgba(255,255,255,.6)); }
458
+ .icv .tile.review { background:linear-gradient(160deg,rgba(255,246,218,.95),rgba(255,255,255,.6)); }
459
+ .icv .final { background:linear-gradient(160deg,rgba(255,253,246,.92),rgba(250,240,208,.55)); border-color:rgba(184,145,47,.55); }
460
+ .icv .final-head { flex-wrap:wrap; gap:10px; }
461
+ .icv .qhead .badge { max-width:100%; white-space:normal; }
462
+ .icv .ind { grid-template-columns:minmax(96px,150px) minmax(0,1fr) 48px; }
463
+ .icv .bar span { background:linear-gradient(270deg,var(--green),var(--gold-2)); transition:width .6s ease; }
464
+ .icv .copy, .icv .dl { background:linear-gradient(135deg,var(--green),var(--green-2)); color:#FFF; border:0; border-radius:12px; padding:7px 18px;
465
+ font-family:inherit; font-weight:600; cursor:pointer; box-shadow:0 4px 12px rgba(15,76,58,.25); transition:transform .15s ease, box-shadow .15s ease, filter .15s ease; }
466
+ .icv .copy:hover, .icv .dl:hover { transform:translateY(-1px); filter:brightness(1.08); box-shadow:0 8px 18px rgba(15,76,58,.3); }
467
+ .icv .copy:active, .icv .dl:active { transform:translateY(0); }
468
+ .icv .copy:focus-visible, .icv .dl:focus-visible { outline:3px solid rgba(184,145,47,.55); outline-offset:2px; }
469
+ .icv .copy.done { background:linear-gradient(135deg,#1B7A4B,#2a9d66); }
470
+ .icv .export { border-radius:16px; padding:12px 18px; margin:12px 0; }
471
+ .icv .export summary { cursor:pointer; color:var(--green); font-weight:700; }
472
+ .icv .dls { display:flex; flex-wrap:wrap; gap:10px; margin-top:10px; }
473
+ /* toast */
474
+ #icv-toast { position:fixed; inset-inline:0; bottom:28px; margin-inline:auto; width:max-content; max-width:calc(100vw - 32px); z-index:99999;
475
+ direction:rtl; text-align:center; font:600 1rem 'Cairo','Segoe UI',Tahoma,sans-serif; color:#FFF; padding:12px 24px; border-radius:999px;
476
+ background:linear-gradient(135deg,rgba(15,76,58,.94),rgba(23,105,79,.94)); border:1px solid rgba(226,192,110,.6);
477
+ box-shadow:0 14px 40px rgba(15,76,58,.35); backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px);
478
+ opacity:0; transform:translateY(18px) scale(.97); pointer-events:none; transition:opacity .28s ease, transform .28s cubic-bezier(.2,.9,.3,1.2); }
479
+ #icv-toast.show { opacity:1; transform:none; } #icv-toast.bad { background:linear-gradient(135deg,rgba(179,38,30,.95),rgba(214,69,58,.95)); }
480
+ /* skeleton loaders */
481
+ .skel { position:relative; overflow:hidden; background:rgba(15,76,58,.08); border-radius:10px; }
482
+ .skel::after { content:""; position:absolute; inset:0; transform:translateX(100%); animation:icv-shimmer 1.4s infinite;
483
+ background:linear-gradient(90deg,transparent,rgba(255,255,255,.75),transparent); }
484
+ @keyframes icv-shimmer { 100% { transform:translateX(-100%); } }
485
+ .skel-card { background:var(--glass); border:1px solid var(--glass-line); border-radius:16px; padding:16px 18px; margin:12px 0; box-shadow:var(--shadow-1); }
486
+ .skel-line { height:14px; margin:10px 0; } .skel-line.w60 { width:60%; } .skel-line.w85 { width:85%; } .skel-line.w40 { width:40%; }
487
+ .skel-tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:10px; margin:8px 0 14px; } .skel-tile { height:78px; border-radius:14px; }
488
+ .icv-spinner { width:18px; height:18px; border-radius:50%; border:3px solid rgba(15,76,58,.18); border-top-color:var(--gold); display:inline-block;
489
+ vertical-align:middle; margin-inline-end:10px; animation:icv-spin .8s linear infinite; }
490
+ @keyframes icv-spin { to { transform:rotate(360deg); } }
491
+ @media (prefers-reduced-motion: reduce) { .icv .hero, .skel::after, .icv-spinner { animation:none; } .icv .qcard, .icv .tile, #icv-toast { transition:none; } }
492
+ @media (max-width:640px){ .icv .ind{grid-template-columns:minmax(84px,104px) minmax(0,1fr) 40px;} .icv .final-head{flex-direction:column; align-items:stretch;} .icv .copy{width:100%;} }
493
+ """
494
+ CSS += GLASS_CSS
495
+
496
+ SKELETON = (
497
+ '<div class="icv" aria-busy="true" aria-label="جارٍ التحقق">'
498
+ '<div class="skel-tiles">' + '<div class="skel skel-tile"></div>' * 4 + '</div>'
499
+ + ('<div class="skel-card"><div class="skel skel-line w40"></div><div class="skel skel-line w85"></div>'
500
+ '<div class="skel skel-line"></div><div class="skel skel-line w60"></div></div>') * 2
501
+ + '</div>'
502
+ )
503
+
504
+ COPY_JS = r"""
505
+ (function () {
506
+ if (window.icvCopy) return;
507
+ function toast(message, bad) {
508
+ var t = document.getElementById('icv-toast');
509
+ if (!t) { t = document.createElement('div'); t.id = 'icv-toast'; t.setAttribute('role', 'status'); t.setAttribute('aria-live', 'polite'); document.body.appendChild(t); }
510
+ t.textContent = message; t.className = 'show' + (bad ? ' bad' : '');
511
+ clearTimeout(window.__icvToast); window.__icvToast = setTimeout(function () { t.className = bad ? 'bad' : ''; }, 2400);
512
+ }
513
+ function legacyCopy(text) {
514
+ var area = document.createElement('textarea'); area.value = text; area.setAttribute('readonly', '');
515
+ area.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none'; document.body.appendChild(area);
516
+ area.select(); area.setSelectionRange(0, text.length); var ok = false;
517
+ try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
518
+ document.body.removeChild(area); return ok;
519
+ }
520
+ window.icvToast = toast;
521
+ window.icvCopy = function (button) {
522
+ var text = button.getAttribute('data-text') || '';
523
+ function finish(ok) {
524
+ toast(ok ? '__COPIED__' : 'تعذّر النسخ، حدّد النص وانسخه يدويًا', !ok);
525
+ if (ok) { button.classList.add('done'); setTimeout(function () { button.classList.remove('done'); }, 1600); }
526
+ }
527
+ if (navigator.clipboard && window.isSecureContext) {
528
+ navigator.clipboard.writeText(text).then(function () { finish(true); }, function () { finish(legacyCopy(text)); });
529
+ } else { finish(legacyCopy(text)); }
530
+ };
531
+ window.icvDownload = function (button) {
532
+ var blob = new Blob([button.getAttribute('data-text') || ''], { type: 'text/tab-separated-values;charset=utf-8' });
533
+ var link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = button.getAttribute('data-name') || 'results.tsv';
534
+ document.body.appendChild(link); link.click(); document.body.removeChild(link); setTimeout(function () { URL.revokeObjectURL(link.href); }, 1000);
535
+ };
536
+ })();
537
+ """.replace("__COPIED__", COPIED_MESSAGE)
verifier.py ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evidence-based verification and source-backed correction of Quran and Hadith quotations.
2
+
3
+ Pipeline (see ``IslamicContentVerifier``):
4
+
5
+ input text -> detection -> BM25 retrieval -> alignment (phonetic skeleton, sliding window, LCS, dynamic gap)
6
+ -> exact match : verified
7
+ -> altered Quran passage : mismatch with the exact source text as the correction
8
+ -> anything uncertain : human review (no correction is produced)
9
+ -> nothing similar found : no matching source
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 time
18
+ from dataclasses import dataclass, field
19
+ from typing import Dict, List, Optional
20
+
21
+ from alignment import align
22
+ from detector import DetectedSpan
23
+ from scanner import HybridDetector
24
+ from idgham import apply_idgham
25
+ from normalization import content_words, normalize_for_matching
26
+ from retrieval import SourceRetriever
27
+ from similarity import best_match_score, compute_signals
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ MAX_INPUT_CHARS = 20_000
32
+ MIN_EXACT_TOKENS = 3 # shorter quotations are too generic to be verified by exact containment alone
33
+
34
+
35
+ # --------------------------------------------------------------------------------------------------------------
36
+ # Configuration (all tunable numbers live here)
37
+ # --------------------------------------------------------------------------------------------------------------
38
+ @dataclass
39
+ class VerifierConfig:
40
+ """Thresholds calibrated in Subtask 1B of the research notebook."""
41
+
42
+ quran_correct_threshold: float = 0.94
43
+ quran_uncertain_low: float = 0.45
44
+ quran_min_coverage: float = 0.40
45
+ hadith_correct_threshold: float = 0.88
46
+ hadith_uncertain_low: float = 0.30
47
+ hadith_min_coverage: float = 0.70
48
+ quran_top_k: int = 25
49
+ hadith_top_k: int = 15
50
+ hadith_retrieval_guard: float = 0.20
51
+
52
+
53
+ @dataclass
54
+ class CorrectorConfig:
55
+ """Correction is proposed only when match strength >= ``*_strong``; between ``*_low`` and ``*_strong`` the
56
+ case goes to human review. Hadith is never auto-corrected (``hadith_strong`` > 1), by design: the exact
57
+ fragment boundaries of a Hadith quotation cannot be reproduced reliably, and narrations differ legitimately."""
58
+
59
+ max_window: int = 8
60
+ hadith_top_k: int = 40
61
+ quran_strong: float = 0.65
62
+ min_full_ratio: float = 0.40
63
+ quran_low: float = 0.55
64
+ hadith_strong: float = 1.01
65
+ hadith_low: float = 0.45
66
+
67
+
68
+ @dataclass
69
+ class PipelineConfig:
70
+ verifier: VerifierConfig = field(default_factory=VerifierConfig)
71
+ corrector: CorrectorConfig = field(default_factory=CorrectorConfig)
72
+ verified_min_conf: float = 0.75 # "Correct" verdicts weaker than this -> human review
73
+ unsupported_min_conf: float = 0.70 # "Incorrect + no source" needs this confidence to abstain confidently
74
+ unsupported_strength: float = 0.35 # ...or the best source match is this weak (nothing similar exists)
75
+
76
+
77
+ # --------------------------------------------------------------------------------------------------------------
78
+ # Verification (Subtask 1B)
79
+ # --------------------------------------------------------------------------------------------------------------
80
+ @dataclass
81
+ class Verification:
82
+ verdict: str # 'Correct' | 'Incorrect'
83
+ confidence: float
84
+ best_score: float
85
+ method: str
86
+ source: Optional[dict] = None # best matching source record (with 'signals')
87
+ n_candidates: int = 0
88
+ retrieval_top: float = 0.0
89
+
90
+
91
+ class Verifier:
92
+ """Scores a quotation against retrieved candidates and returns a verdict with its supporting source."""
93
+
94
+ def __init__(self, retriever: SourceRetriever, config: Optional[VerifierConfig] = None) -> None:
95
+ self.kb = retriever
96
+ self.cfg = config or VerifierConfig()
97
+
98
+ def verify(self, span_text: str, content_type: str) -> Verification:
99
+ if not span_text or not span_text.strip():
100
+ return self._result("Incorrect", 0.95, 0.0, None, 0, "empty_span")
101
+ if content_type == "Ayah":
102
+ return self._verify_quran(span_text)
103
+ if content_type == "Hadith":
104
+ return self._verify_hadith(span_text)
105
+ return self._result("Incorrect", 0.5, 0.0, None, 0, "unknown_type")
106
+
107
+ def _verify_quran(self, span: str) -> Verification:
108
+ cfg = self.cfg
109
+ candidates = self.kb.search_quran_ayahs(span, top_k=cfg.quran_top_k)
110
+ if not candidates:
111
+ return self._result("Incorrect", 0.8, 0.0, None, 0, "no_candidates")
112
+ score, best = best_match_score(span, candidates, "Ayah")
113
+ signals = best.get("signals", {}) if best else {}
114
+ coverage, is_substring = signals.get("coverage", 0.0), signals.get("is_substring", 0)
115
+ n = len(candidates)
116
+
117
+ if is_substring and coverage >= cfg.quran_min_coverage:
118
+ return self._result("Correct", min(0.98, 0.85 + score * 0.15), score, best, n, "substring_match")
119
+ if score >= cfg.quran_correct_threshold and coverage >= cfg.quran_min_coverage:
120
+ return self._result("Correct", min(0.95, 0.70 + score * 0.25), score, best, n, "threshold_pass")
121
+ if score <= cfg.quran_uncertain_low:
122
+ return self._result("Incorrect", min(0.95, 0.70 + (1 - score) * 0.25), score, best, n, "threshold_fail")
123
+
124
+ strong = sum(
125
+ 1 for cand in candidates[:10]
126
+ if (s := compute_signals(span, cand.get("text", ""), "Ayah"))["coverage"] >= 0.80 and s["lcs_ratio"] >= 0.75
127
+ )
128
+ if strong >= 2:
129
+ return self._result("Correct", 0.60 + min(0.20, strong * 0.05), score, best, n, "borderline_multi_cov")
130
+ return self._result("Incorrect", 0.58, score, best, n, "borderline_default")
131
+
132
+ def _verify_hadith(self, span: str) -> Verification:
133
+ cfg = self.cfg
134
+ candidates = self.kb.search_hadith(span, top_k=cfg.hadith_top_k)
135
+ if not candidates:
136
+ return self._result("Incorrect", 0.75, 0.0, None, 0, "no_candidates")
137
+ top_retrieval = candidates[0].get("retrieval_score", 0.0)
138
+ score, best = best_match_score(span, candidates, "Hadith")
139
+ signals = best.get("signals", {}) if best else {}
140
+ coverage, is_substring = signals.get("coverage", 0.0), signals.get("is_substring", 0)
141
+ n = len(candidates)
142
+
143
+ if is_substring and coverage >= cfg.hadith_min_coverage and top_retrieval >= cfg.hadith_retrieval_guard:
144
+ return self._result("Correct", min(0.97, 0.80 + score * 0.17), score, best, n, "substring_match", top_retrieval)
145
+ if score >= cfg.hadith_correct_threshold and coverage >= cfg.hadith_min_coverage:
146
+ return self._result("Correct", min(0.92, 0.65 + score * 0.27), score, best, n, "threshold_pass", top_retrieval)
147
+ if score <= cfg.hadith_uncertain_low:
148
+ return self._result("Incorrect", min(0.90, 0.65 + (1 - score) * 0.25), score, best, n, "threshold_fail", top_retrieval)
149
+
150
+ moderate = sum(
151
+ 1 for cand in candidates[:8]
152
+ if (s := compute_signals(span, cand.get("text", ""), "Hadith"))["coverage"] >= 0.65 and s["lcs_ratio"] >= 0.55
153
+ )
154
+ if moderate >= 2 and top_retrieval >= 0.30:
155
+ return self._result("Correct", 0.58 + min(0.22, moderate * 0.06), score, best, n, "borderline_multi_cov", top_retrieval)
156
+ if top_retrieval < 0.25 or score < 0.45:
157
+ return self._result("Incorrect", 0.60, score, best, n, "borderline_low_retrieval", top_retrieval)
158
+ return self._result("Incorrect", 0.55, score, best, n, "borderline_default", top_retrieval)
159
+
160
+ @staticmethod
161
+ def _result(verdict, confidence, score, best, n_candidates, method, top_retrieval=0.0) -> Verification:
162
+ return Verification(verdict, round(confidence, 4), round(score, 4), method, best, n_candidates, round(top_retrieval, 4))
163
+
164
+
165
+ # --------------------------------------------------------------------------------------------------------------
166
+ # Correction (Subtask 1C): locate the true ayah window / Hadith record and return its exact text
167
+ # --------------------------------------------------------------------------------------------------------------
168
+ @dataclass
169
+ class CorrectionMatch:
170
+ kind: str # 'Ayah' | 'Hadith'
171
+ strength: float # coverage (Quran) / symmetric containment (Hadith)
172
+ full_ratio: float
173
+ text: str # proposed correction in the official 1C format (idgham + '(n)' ayah markers)
174
+ source: dict # reference metadata
175
+ display: str = "" # clean human-readable version
176
+
177
+
178
+ class Corrector:
179
+ def __init__(self, retriever: SourceRetriever, config: Optional[CorrectorConfig] = None) -> None:
180
+ self.kb = retriever
181
+ self.cfg = config or CorrectorConfig()
182
+
183
+ def match(self, span_text: str, content_type: str) -> Optional[CorrectionMatch]:
184
+ return self.match_quran(span_text) if content_type == "Ayah" else self.match_hadith(span_text)
185
+
186
+ def match_quran(self, query_text: str) -> Optional[CorrectionMatch]:
187
+ """Best window of 1-8 consecutive ayahs (dynamic sliding window over BM25 seeds, exact-result pruning)."""
188
+ kb = self.kb
189
+ query_norm = normalize_for_matching(query_text)
190
+ query_words = content_words(query_norm.split())
191
+ if not query_words:
192
+ return None
193
+ query_len = len(query_norm)
194
+ memo: Dict[tuple, tuple] = {}
195
+ best = None # (key, coverage, ratio, surah, start, end)
196
+ for seed in kb.quran_seed_ayahs(query_words, top_k=25):
197
+ surah, ayah = kb.quran[seed]["surah_id"], kb.quran[seed]["ayah_id"]
198
+ ayahs = kb.quran_by_surah[surah]
199
+ min_ayah, max_ayah = min(ayahs), max(ayahs)
200
+ for offset in range(3):
201
+ start = ayah - offset
202
+ if start < min_ayah:
203
+ continue
204
+ window_len = -1
205
+ for length in range(1, self.cfg.max_window + 1):
206
+ end = start + length - 1
207
+ if end > max_ayah:
208
+ break
209
+ window_len += len(kb.q_norm_match[ayahs[end]]) + 1
210
+ len_diff = abs(window_len - query_len)
211
+ upper_bound = min(1.0, window_len / max(query_len, 1))
212
+ if best is not None: # exact-result pruning
213
+ best_cov, best_neg = best[0][0], best[0][1]
214
+ if upper_bound < best_cov or (upper_bound == best_cov and -len_diff < best_neg):
215
+ continue
216
+ key_pos = (surah, start, end)
217
+ if key_pos in memo:
218
+ continue
219
+ window = " ".join(kb.q_norm_match[ayahs[a]] for a in range(start, end + 1))
220
+ matcher = difflib.SequenceMatcher(None, query_norm, window, autojunk=False)
221
+ matched = sum(b.size for b in matcher.get_matching_blocks() if b.size >= 4)
222
+ coverage = matched / max(query_len, 1)
223
+ key = (coverage, -len_diff, matcher.ratio())
224
+ memo[key_pos] = key
225
+ if best is None or key > best[0]:
226
+ best = (key, coverage, key[2], surah, start, end)
227
+ if best is None:
228
+ return None
229
+ _, coverage, ratio, surah, start, end = best
230
+ display = " ".join(kb.quran[kb.quran_by_surah[surah][a]]["text"] for a in range(start, end + 1))
231
+ return CorrectionMatch(
232
+ "Ayah", coverage, ratio, self._ayah_text(surah, start, end),
233
+ {"type": "Quran", "surah_id": surah, "surah_name": kb.quran[kb.quran_by_surah[surah][start]]["surah_name"],
234
+ "ayah_start": start, "ayah_end": end},
235
+ display,
236
+ )
237
+
238
+ def _ayah_text(self, surah: int, start: int, end: int) -> str:
239
+ kb, multi = self.kb, end > start
240
+ parts = []
241
+ for a in range(start, end + 1):
242
+ text = kb.quran[kb.quran_by_surah[surah][a]]["text"]
243
+ parts.append(f"{text} ({a})" if multi else text)
244
+ return apply_idgham(" ".join(parts)).replace("\u0640", "")
245
+
246
+ def match_hadith(self, query_text: str) -> Optional[CorrectionMatch]:
247
+ kb = self.kb
248
+ query_norm = normalize_for_matching(query_text)
249
+ query_words = content_words(query_norm.split())
250
+ if not query_words:
251
+ return None
252
+ query_len = len(query_norm)
253
+ best = None # (key, idx, field, ratio)
254
+ for idx in kb.hadith_candidates(query_words, self.cfg.hadith_top_k):
255
+ for field_name in ("matn", "full"):
256
+ text = kb.hadith_norm(idx, field_name)
257
+ if not text:
258
+ continue
259
+ upper_bound = min(1.0, query_len / max(len(text), 1))
260
+ if best is not None and upper_bound < best[0][0]:
261
+ continue
262
+ matcher = difflib.SequenceMatcher(None, query_norm, text, autojunk=False)
263
+ matched = sum(b.size for b in matcher.get_matching_blocks() if b.size >= 4)
264
+ coverage, candidate_cov = matched / max(query_len, 1), matched / max(len(text), 1)
265
+ key = (min(coverage, candidate_cov), matcher.ratio())
266
+ if best is None or key > best[0]:
267
+ best = (key, idx, field_name, key[1])
268
+ if best is None:
269
+ return None
270
+ key, idx, field_name, ratio = best
271
+ record = kb.hadith[idx]
272
+ text = record[field_name].strip()
273
+ return CorrectionMatch(
274
+ "Hadith", key[0], ratio, text,
275
+ {"type": "Hadith", "hadithID": record["hadithID"], "book": record["book"], "title": record["title"],
276
+ "field": "matn" if field_name == "matn" else "full_text"},
277
+ text,
278
+ )
279
+
280
+
281
+ # --------------------------------------------------------------------------------------------------------------
282
+ # End-to-end pipeline
283
+ # --------------------------------------------------------------------------------------------------------------
284
+ STATUS_INFO = {
285
+ "VERIFIED": {"ar": "موثّق", "group": "verified"},
286
+ "CORRECTED": {"ar": "غير مطابق — يوجد تصحيح من المصدر", "group": "mismatch"},
287
+ "UNSUPPORTED": {"ar": "غير مطابق — لا يوجد مصدر مطابق", "group": "mismatch"},
288
+ "HUMAN_REVIEW": {"ar": "يحتاج مراجعة بشرية", "group": "review"},
289
+ }
290
+
291
+
292
+ class IslamicContentVerifier:
293
+ """Detect quotations, verify them against the corpora and decide: verified, corrected, unsupported or review."""
294
+
295
+ def __init__(self, retriever: Optional[SourceRetriever] = None, config: Optional[PipelineConfig] = None,
296
+ use_scanner: bool = True, decouple_triggers: bool = True) -> None:
297
+ self.cfg = config or PipelineConfig()
298
+ self.retriever = retriever or SourceRetriever()
299
+ self.verifier = Verifier(self.retriever, self.cfg.verifier)
300
+ self.corrector = Corrector(self.retriever, self.cfg.corrector)
301
+ self.detector = HybridDetector(self.retriever, use_scanner, rules_use_corpus=decouple_triggers,
302
+ decouple_triggers=decouple_triggers)
303
+ self.detector_name = type(self.detector).__name__
304
+
305
+ # ---- public API -----------------------------------------------------------------------------------------
306
+ def detect(self, text: str) -> List[DetectedSpan]:
307
+ return self.detector.detect(self._validate(text)) if text.strip() else []
308
+
309
+ def needs_hadith(self, text: str) -> bool:
310
+ """True if the text contains a Hadith quotation (so the large Hadith index must be loaded)."""
311
+ return any(span.label == "Hadith" for span in self.detect(text))
312
+
313
+ def analyze(self, text: str) -> dict:
314
+ """Run the full pipeline on a generated text."""
315
+ text = self._validate(text)
316
+ started = time.time()
317
+ spans = self.detector.detect(text) if text.strip() else []
318
+ detect_seconds = time.time() - started
319
+ result = self._analyze_spans(text, spans)
320
+ result["timings"] = {"detect_s": round(detect_seconds, 3), "total_s": round(time.time() - started, 3)}
321
+ return result
322
+
323
+ def analyze_spans(self, text: str, spans: List[dict]) -> dict:
324
+ """Skip detection and use given spans ``[{label, start, end}]`` (evaluation / oracle mode)."""
325
+ given = [DetectedSpan(s["start"], s["end"], s["label"], None, "given", text[s["start"]:s["end"]]) for s in spans]
326
+ return self._analyze_spans(self._validate(text), given)
327
+
328
+ # ---- internals ------------------------------------------------------------------------------------------
329
+ @staticmethod
330
+ def _validate(text: str) -> str:
331
+ if not isinstance(text, str):
332
+ raise TypeError("Input text must be a string")
333
+ if len(text) > MAX_INPUT_CHARS:
334
+ raise ValueError(f"Input is too long ({len(text)} characters); the limit is {MAX_INPUT_CHARS}")
335
+ return text
336
+
337
+ def _analyze_spans(self, text: str, spans: List[DetectedSpan]) -> dict:
338
+ reports = [self._process_span(i + 1, span) for i, span in enumerate(sorted(spans, key=lambda s: s.start))]
339
+ counts = {status: 0 for status in STATUS_INFO}
340
+ for report in reports:
341
+ counts[report["status"]] += 1
342
+ return {
343
+ "input_text": text,
344
+ "detector": self.detector_name,
345
+ "spans": reports,
346
+ "corrected_text": self._apply_corrections(text, reports),
347
+ "summary": {
348
+ "n_spans": len(reports),
349
+ "n_ayah": sum(r["type"] == "Ayah" for r in reports),
350
+ "n_hadith": sum(r["type"] == "Hadith" for r in reports),
351
+ **counts,
352
+ "needs_human_review": counts["HUMAN_REVIEW"] > 0,
353
+ },
354
+ }
355
+
356
+ def _process_span(self, index: int, span: DetectedSpan) -> dict:
357
+ try:
358
+ return self._decide(index, span)
359
+ except Exception: # a single failing quotation must not break the whole report
360
+ logger.exception("Failed to process span %d", index)
361
+ report = self._empty_report(index, span)
362
+ self._finalize(report, "HUMAN_REVIEW", {"code": "internal_error"})
363
+ return report
364
+
365
+ @staticmethod
366
+ def _empty_report(index: int, span: DetectedSpan) -> dict:
367
+ return {
368
+ "id": index, "type": span.label, "start": span.start, "end": span.end, "text": span.text,
369
+ "detection": {"backend": span.source, "confidence": None if span.confidence is None else round(span.confidence, 4)},
370
+ "verification": {"verdict": "Incorrect", "confidence": 0.0, "score": 0.0, "method": "error", "n_candidates": 0},
371
+ "evidence": None, "correction": None, "suggestion": None, "notes": [],
372
+ }
373
+
374
+ @staticmethod
375
+ def _finalize(report: dict, status: str, reason: dict) -> None:
376
+ info = STATUS_INFO[status]
377
+ report.update(status=status, status_ar=info["ar"], group=info["group"], reason=reason)
378
+
379
+ @staticmethod
380
+ def source_label(source: dict) -> str:
381
+ """Plain reference used in reasons, e.g. ``سورة البقرة 153`` or ``حديث رقم 5``."""
382
+ if source["type"] == "Quran":
383
+ start, end = source["ayah_start"], source["ayah_end"]
384
+ return f"سورة {source['surah_name']} {start}" + (f"–{end}" if end != start else "")
385
+ return f"حديث رقم {source['hadithID']}"
386
+
387
+ def _decide(self, index: int, span: DetectedSpan) -> dict:
388
+ report = self._empty_report(index, span)
389
+ verification = self.verifier.verify(span.text, span.label)
390
+ report["verification"] = {
391
+ "verdict": verification.verdict, "confidence": verification.confidence, "score": verification.best_score,
392
+ "method": verification.method, "n_candidates": verification.n_candidates,
393
+ }
394
+ if span.label == "Ayah":
395
+ self._decide_quran(report, span, verification)
396
+ else:
397
+ self._decide_hadith(report, span, verification)
398
+ return report
399
+
400
+ @staticmethod
401
+ def _proposal(span: DetectedSpan, match: Optional[CorrectionMatch]) -> Optional[dict]:
402
+ if match is None:
403
+ return None
404
+ return {"text": match.text, "display_text": match.display, "source": match.source,
405
+ "match_strength": round(match.strength, 4), "full_ratio": round(match.full_ratio, 4)}
406
+
407
+ def _decide_quran(self, report: dict, span: DetectedSpan, verification: Verification) -> None:
408
+ cfg, corr_cfg = self.cfg, self.cfg.corrector
409
+ match = self.corrector.match_quran(span.text)
410
+ if match is not None:
411
+ source_text, source_ref = match.display, match.source
412
+ elif verification.source:
413
+ candidate = verification.source
414
+ source_text = candidate["text"]
415
+ source_ref = {"type": "Quran", "surah_id": candidate["surah_id"], "surah_name": candidate["surah_name"],
416
+ "ayah_start": candidate["ayah_id"], "ayah_end": candidate["ayah_id"]}
417
+ else:
418
+ source_text, source_ref = "", None
419
+ alignment = align(span.text, source_text, self.retriever.quran_vocabulary) if source_text else None
420
+ if source_ref:
421
+ report["evidence"] = {"source": source_ref, "signals": compute_signals(span.text, source_text, "Ayah"),
422
+ "comparison": alignment}
423
+ proposal = self._proposal(span, match)
424
+ n_tokens = len(content_words(normalize_for_matching(span.text).split())) if span.text else 0
425
+ has_tokens = alignment is not None and alignment["exact"] and len(alignment["word_diff"]) >= 1
426
+
427
+ if has_tokens and sum(len(op["span"].split()) for op in alignment["word_diff"]) >= MIN_EXACT_TOKENS:
428
+ self._finalize(report, "VERIFIED", {"code": "exact_match", "source": self.source_label(source_ref)})
429
+ if alignment["diacritic_notes"]:
430
+ report["notes"].append({"code": "diacritic_conflict", "n": len(alignment["diacritic_notes"])})
431
+ if alignment["orthographic_variants"]:
432
+ report["notes"].append({"code": "orthographic_variant", "n": alignment["orthographic_variants"]})
433
+ return
434
+
435
+ strong = match is not None and match.strength >= corr_cfg.quran_strong and match.full_ratio >= corr_cfg.min_full_ratio
436
+ if strong:
437
+ self._finalize(report, "CORRECTED", {"code": "altered_passage", "source": self.source_label(source_ref),
438
+ "n": alignment["mismatches"] if alignment else 0,
439
+ "reordered": bool(alignment and alignment["reordered"])})
440
+ # replace only the passage the author quoted, not the whole ayah that contains it
441
+ excerpt = alignment["source_excerpt"] if alignment and alignment["source_excerpt"] else proposal["display_text"]
442
+ report["correction"] = {**proposal, "display_text": excerpt, "applied": True}
443
+ return
444
+
445
+ if verification.verdict == "Correct":
446
+ self._finalize(report, "HUMAN_REVIEW", {"code": "weak_match"})
447
+ report["suggestion"] = proposal
448
+ elif match is None or match.strength < corr_cfg.quran_low:
449
+ confident = verification.confidence >= cfg.unsupported_min_conf and not verification.method.startswith("borderline")
450
+ if match is None or match.strength < cfg.unsupported_strength or confident:
451
+ self._finalize(report, "UNSUPPORTED", {"code": "no_source"})
452
+ else:
453
+ self._finalize(report, "HUMAN_REVIEW", {"code": "insufficient_evidence"})
454
+ report["suggestion"] = proposal
455
+ else:
456
+ self._finalize(report, "HUMAN_REVIEW", {"code": "candidate_not_strong", "source": self.source_label(source_ref),
457
+ "strength": round(match.strength, 2)})
458
+ report["suggestion"] = proposal
459
+
460
+ def _decide_hadith(self, report: dict, span: DetectedSpan, verification: Verification) -> None:
461
+ cfg, corr_cfg = self.cfg, self.cfg.corrector
462
+ best = verification.source
463
+ alignment = None
464
+ if best:
465
+ alignment = align(span.text, best["text"])
466
+ report["evidence"] = {
467
+ "source": {"type": "Hadith", "hadithID": best["hadithID"], "book": best["book"], "title": best["title"]},
468
+ "signals": best.get("signals"), "comparison": alignment,
469
+ }
470
+ exact_tokens = alignment is not None and alignment["exact"] and sum(len(op["span"].split()) for op in alignment["word_diff"]) >= 4
471
+
472
+ if verification.verdict == "Correct" or exact_tokens:
473
+ if exact_tokens or (not verification.method.startswith("borderline") and verification.confidence >= cfg.verified_min_conf):
474
+ self._finalize(report, "VERIFIED", {"code": "exact_match" if exact_tokens else "close_match",
475
+ "source": f"حديث رقم {best['hadithID']}"})
476
+ if alignment and not alignment["exact"]:
477
+ report["notes"].append({"code": "hadith_minor_diffs", "n": alignment["mismatches"]})
478
+ if alignment and alignment["diacritic_notes"]:
479
+ report["notes"].append({"code": "diacritic_conflict", "n": len(alignment["diacritic_notes"])})
480
+ else:
481
+ self._finalize(report, "HUMAN_REVIEW", {"code": "weak_match"})
482
+ return
483
+
484
+ match = self.corrector.match_hadith(span.text)
485
+ proposal = self._proposal(span, match)
486
+ if match is None or match.strength < corr_cfg.hadith_low:
487
+ confident = verification.confidence >= cfg.unsupported_min_conf and not verification.method.startswith("borderline")
488
+ if match is None or match.strength < cfg.unsupported_strength or confident:
489
+ self._finalize(report, "UNSUPPORTED", {"code": "no_source"})
490
+ else:
491
+ self._finalize(report, "HUMAN_REVIEW", {"code": "insufficient_evidence"})
492
+ report["suggestion"] = proposal
493
+ else: # a similar narration exists, but Hadith is never corrected automatically
494
+ self._finalize(report, "HUMAN_REVIEW", {"code": "hadith_candidate", "source": self.source_label(match.source),
495
+ "strength": round(match.strength, 2)})
496
+ report["suggestion"] = proposal
497
+
498
+ @staticmethod
499
+ def _apply_corrections(text: str, reports: List[dict]) -> str:
500
+ out = text
501
+ for report in sorted(reports, key=lambda r: r["start"], reverse=True):
502
+ if report["status"] == "CORRECTED" and report["correction"] and report["correction"].get("applied"):
503
+ out = out[: report["start"]] + report["correction"]["display_text"] + out[report["end"]:]
504
+ return out