File size: 25,635 Bytes
1fe445a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
"""Islamic Content Verifier: Gradio interface around the verification pipeline in ``verifier.py``.

Run locally with ``python app.py``. The rendering helpers are plain functions that return HTML, so they can be
tested without Gradio; Gradio is imported only when the interface is built.
"""
from __future__ import annotations

import html
import json
import logging
import os
import threading
from pathlib import Path
from typing import List, Optional

from verifier import MAX_INPUT_CHARS, IslamicContentVerifier

logger = logging.getLogger(__name__)

EXAMPLES_PATH = Path(__file__).resolve().parent / "demo" / "examples.json"
DEFAULT_MODEL_DIR = Path(__file__).resolve().parent / "models" / "span_detector"

# --------------------------------------------------------------------------------------------------------------
# Pipeline access
# --------------------------------------------------------------------------------------------------------------
_pipeline: Optional[IslamicContentVerifier] = None
_pipeline_lock = threading.Lock()


def get_pipeline() -> IslamicContentVerifier:
    """Create the pipeline once (the corpus index builds in a few seconds) and reuse it for every request."""
    global _pipeline
    with _pipeline_lock:
        if _pipeline is None:
            model_dir = os.environ.get("ICV_DETECTOR_MODEL") or (str(DEFAULT_MODEL_DIR) if DEFAULT_MODEL_DIR.is_dir() else None)
            # With a trained model folder the BERT detector is used; if it cannot be loaded the rules detector takes over.
            _pipeline = IslamicContentVerifier(detector="auto" if model_dir else "rules", model_dir=model_dir)
        return _pipeline


def load_examples(path: Path = EXAMPLES_PATH) -> List[dict]:
    try:
        with open(path, encoding="utf-8") as handle:
            return json.load(handle)
    except (OSError, json.JSONDecodeError):
        logger.exception("Could not load demo examples from %s", path)
        return []


# --------------------------------------------------------------------------------------------------------------
# HTML rendering
# --------------------------------------------------------------------------------------------------------------
_e = html.escape

GROUP_LABEL = {
    "verified": ("موثّق", "Verified"),
    "mismatch": ("غير مطابق", "Mismatch"),
    "review": ("يحتاج مراجعة بشرية", "Needs Human Review"),
}
TYPE_LABEL = {"Ayah": ("آية قرآنية", "Quran"), "Hadith": ("حديث نبوي", "Hadith")}
INDICATORS = [
    ("composite", "الدرجة المركبة", "Composite score"),
    ("coverage", "تغطية الكلمات", "Word coverage"),
    ("lcs_ratio", "التسلسل النصي", "Sequence (LCS)"),
    ("token_overlap", "تداخل الكلمات", "Token overlap"),
    ("edit_sim", "تشابه الأحرف", "Character similarity"),
    ("diacritic_sim", "مع التشكيل", "With diacritics"),
]


def _pct(value: float) -> str:
    return f"{round(value * 100)}%"


def _source_label(source: dict) -> str:
    if source["type"] == "Quran":
        start = source.get("ayah", source.get("ayah_start"))
        end = source.get("ayah_end", start)
        verses = f"{start}" if start == end else f"{start}–{end}"
        return f"سورة {source['surah_name']} — الآية {verses}"
    return f"حديث رقم {source['hadithID']} — {source['title']}"


def _truncate(text: str, limit: int) -> str:
    return text if len(text) <= limit else text[:limit].rstrip() + " …"


def _diff_html(comparison: dict) -> str:
    """Word-level diff: green = words only in the source, red = words only in the quotation."""
    parts = []
    for op in comparison["word_diff"]:
        if op["op"] == "equal":
            parts.append(f'<span class="w-eq">{_e(op["span"])}</span>')
        else:
            if op["span"]:
                parts.append(f'<span class="w-extra" title="في الاقتباس فقط · only in the quotation">{_e(op["span"])}</span>')
            if op["source"]:
                parts.append(f'<span class="w-missing" title="في المصدر فقط · only in the source">{_e(op["source"])}</span>')
    return " ".join(parts)


def _indicator_table(signals: Optional[dict]) -> str:
    if not signals:
        return ""
    rows = []
    for key, ar, en in INDICATORS:
        value = float(signals.get(key, 0.0))
        rows.append(
            f'<div class="ind"><div class="ind-name">{ar}<small>{en}</small></div>'
            f'<div class="bar"><span style="width:{_pct(value)}"></span></div><div class="ind-val">{_pct(value)}</div></div>'
        )
    substring = "نعم · Yes" if signals.get("is_substring") else "لا · No"
    rows.append(f'<div class="ind"><div class="ind-name">احتواء كامل<small>Contained in source</small></div><div class="ind-val wide">{substring}</div></div>')
    return '<div class="indicators">' + "".join(rows) + "</div>"


def _evidence_panel(span: dict) -> str:
    evidence, verification = span["evidence"], span["verification"]
    blocks = [
        f'<div class="ev-row"><b>الاقتباس المكتشف · Detected quotation</b><p class="quote">{_e(span["text"])}</p></div>',
        f'<div class="ev-row"><b>النوع · Type</b><p>{TYPE_LABEL[span["type"]][0]} · {TYPE_LABEL[span["type"]][1]}</p></div>',
    ]
    if evidence:
        comparison = evidence["comparison"]
        blocks += [
            f'<div class="ev-row"><b>المصدر المرشح · Candidate source</b><p>{_e(_source_label(evidence["source"]))}</p></div>',
            f'<div class="ev-row"><b>نص المصدر الأصلي · Original source text</b><p class="quote">{_e(comparison["source_excerpt"])}</p></div>',
            f'<div class="ev-row"><b>المقارنة · Comparison</b><p class="diff">{_diff_html(comparison)}</p>'
            f'<p class="legend"><span class="w-extra">في الاقتباس فقط</span> <span class="w-missing">في المصدر فقط</span> '
            f'· تشابه الكلمات {_pct(comparison["word_similarity"])}</p></div>',
            f'<div class="ev-row"><b>مؤشرات التحقق · Similarity indicators</b>{_indicator_table(evidence.get("signals"))}</div>',
        ]
    else:
        blocks.append('<div class="ev-row"><b>المصدر · Source</b><p>لم يُسترجع أي مصدر مرشح · No candidate source was retrieved.</p></div>')
    blocks += [
        f'<div class="ev-row"><b>الثقة · Confidence</b><p>{_pct(verification["confidence"])}</p>'
        f'<p class="legend en">verdict: {_e(verification["verdict"])} · method: {_e(verification["method"])} · '
        f'candidates checked: {verification["n_candidates"]}</p></div>',
        f'<div class="ev-row"><b>القرار · Decision</b><p>{_e(span["status_ar"])}<br><span class="en">{_e(span["status_en"])}</span></p></div>',
        f'<div class="ev-row"><b>سبب القرار · Decision reason</b><p class="en">{_e(span["reason"])}</p></div>',
    ]
    return "".join(blocks)


def _card(span: dict) -> str:
    group = span["group"]
    ar_label, en_label = GROUP_LABEL[group]
    type_ar, type_en = TYPE_LABEL[span["type"]]
    evidence = span["evidence"]
    confidence = span["verification"]["confidence"]

    source_html = comparison_html = ""
    if evidence:
        comparison = evidence["comparison"]
        source_html = (
            f'<div class="field"><label>المصدر المرشح · Candidate source</label><p>{_e(_source_label(evidence["source"]))}</p></div>'
            f'<div class="field"><label>نص المصدر الأصلي · Original source text</label>'
            f'<p class="quote small">{_e(_truncate(comparison["source_excerpt"], 360))}</p></div>'
        )
        comparison_html = (
            f'<div class="field"><label>المقارنة · Comparison</label><p class="diff">{_diff_html(comparison)}</p></div>'
        )
    else:
        source_html = '<div class="field"><label>المصدر المرشح · Candidate source</label><p>لا يوجد · None found</p></div>'

    action = ""
    correction, suggestion = span["correction"], span["suggestion"]
    if correction:
        action = (
            '<div class="action ok"><b>تصحيح مدعوم بالمصدر · Source-backed correction</b>'
            f'<p class="quote">{_e(correction["display_text"])}</p>'
            f'<p class="legend">{_e(_source_label({"type": "Quran", **correction["source"]}) if correction["source"]["type"] == "Quran" else _source_label(correction["source"]))}'
            f' · قوة المطابقة {_pct(correction["match_strength"])}</p></div>'
        )
    elif group == "review":
        closest = ""
        if suggestion:
            src = suggestion["source"]
            label = _source_label({"type": "Quran", **src}) if src["type"] == "Quran" else _source_label(src)
            closest = (f'<p class="legend">أقرب مصدر وُجد للمراجِع (ليس تصحيحًا آليًا) · Closest source for the reviewer (not an automatic correction): '
                       f'{_e(label)}</p><p class="quote small">{_e(_truncate(suggestion["display_text"], 360))}</p>')
        action = (
            '<div class="action warn"><b>الأدلة غير كافية للتصحيح الآلي.</b> يُوصى بالمراجعة البشرية.'
            '<p class="en">Insufficient evidence for automatic correction. Human review is recommended.</p>' + closest + "</div>"
        )
    elif span["status"] == "UNSUPPORTED":
        action = (
            '<div class="action bad"><b>لا يوجد مصدر مطابق في المراجع المتاحة للنظام.</b> لم يُقترح أي نص بديل.'
            '<p class="en">No matching source in the corpus. No replacement text is proposed.</p></div>'
        )

    return f"""
<div class="qcard {group}">
  <div class="qhead">
    <span class="idx">{span["id"]}</span>
    <span class="badge type">{type_ar} · {type_en}</span>
    <span class="badge st {group}">{ar_label} · {en_label}</span>
    <span class="conf">الثقة · Confidence <b>{_pct(confidence)}</b></span>
  </div>
  <div class="field"><label>الاقتباس المكتشف · Detected quotation</label><p class="quote">{_e(span["text"])}</p></div>
  {source_html}
  {comparison_html}
  <div class="field"><label>القرار · Decision</label><p>{_e(span["status_ar"])}<br><span class="en">{_e(span["status_en"])}</span></p></div>
  {action}
  <details class="evidence"><summary>عرض الدليل · View Evidence</summary><div class="ev-body">{_evidence_panel(span)}</div></details>
</div>"""


def _summary(summary: dict) -> str:
    tiles = [
        (summary["n_spans"], "إجمالي الاقتباسات", "Total quotations", ""),
        (summary["n_ayah"], "آيات قرآنية", "Quranic", ""),
        (summary["n_hadith"], "أحاديث", "Hadith", ""),
        (summary["VERIFIED"], "موثّق", "Verified", "verified"),
        (summary["CORRECTED"] + summary["UNSUPPORTED"], "غير مطابق", "Mismatch", "mismatch"),
        (summary["HUMAN_REVIEW"], "يحتاج مراجعة بشرية", "Needs Human Review", "review"),
    ]
    return '<div class="summary">' + "".join(
        f'<div class="tile {cls}{" zero" if value == 0 and cls else ""}"><b>{value}</b><span>{ar}</span><small>{en}</small></div>'
        for value, ar, en, cls in tiles
    ) + "</div>"


def _highlighted_text(result: dict) -> str:
    text, pieces, cursor = result["input_text"], [], 0
    for span in result["spans"]:
        pieces.append(_e(text[cursor:span["start"]]))
        pieces.append(f'<mark class="{span["group"]}" title="{_e(span["status_en"])}">{_e(text[span["start"]:span["end"]])}</mark>')
        cursor = span["end"]
    pieces.append(_e(text[cursor:]))
    legend = (
        '<div class="hl-legend"><mark class="verified">موثّق · Verified</mark><mark class="mismatch">غير مطابق · Mismatch</mark>'
        '<mark class="review">مراجعة بشرية · Review</mark></div>'
    )
    return (
        '<div class="highlight"><label>النص مع الاقتباسات المكتشفة · Text with detected quotations</label><p>'
        + "".join(pieces) + "</p>" + legend + "</div>"
    )


def render_results(result: dict) -> str:
    """HTML report for a pipeline result."""
    if not result["spans"]:
        return (
            '<div class="notice">لم يُعثر على اقتباسات قرآنية أو حديثية في هذا النص. '
            '<span class="en">No Quranic or Hadith quotations were detected. Quotations are currently detected when they are '
            'enclosed in quotation marks or brackets and introduced by a typical phrase (see Limitations).</span></div>'
        )
    names = {"RuleDetector": ("القواعد", "Rule-based"), "BertDetector": ("نموذج BERT", "BERT model")}
    ar, en = names.get(result["detector"], (result["detector"], result["detector"]))
    chip = f'<div class="det-chip">الكاشف · Detector: <b>{ar} · {en}</b></div>'
    return chip + _summary(result["summary"]) + _highlighted_text(result) + "".join(_card(s) for s in result["spans"])


def render_message(message_ar: str, message_en: str, kind: str = "info") -> str:
    return f'<div class="notice {kind}">{_e(message_ar)}<span class="en">{_e(message_en)}</span></div>'


def verify_text(text: str) -> str:
    """Gradio handler: validate the input, run the pipeline and return HTML (never raises)."""
    if not text or not text.strip():
        return render_message("الرجاء إدخال نص للتحقق منه.", "Please enter a text to verify.", "warn")
    try:
        return render_results(get_pipeline().analyze(text))
    except ValueError:
        return render_message(
            f"النص طويل جدًا (الحد الأقصى {MAX_INPUT_CHARS} حرف).", f"The text is too long (maximum {MAX_INPUT_CHARS} characters).", "warn"
        )
    except Exception:
        logger.exception("Verification failed")
        return render_message("حدث خطأ غير متوقع أثناء التحقق.", "An unexpected error occurred during verification.", "bad")


# --------------------------------------------------------------------------------------------------------------
# Interface
# --------------------------------------------------------------------------------------------------------------
_PATTERN = (
    "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='80' viewBox='0 0 80 80'%3E"
    "%3Cg fill='none' stroke='%2322d3ee' stroke-opacity='0.07' stroke-width='1'%3E"
    "%3Crect x='20' y='20' width='40' height='40'/%3E%3Crect x='20' y='20' width='40' height='40' transform='rotate(45 40 40)'/%3E"
    "%3C/g%3E%3C/svg%3E\")"
)

CSS = """
@import url('https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&family=Amiri:wght@400;700&display=swap');
.gradio-container {
  --body-background-fill:#0a1128; --block-background-fill:#101a38; --block-border-color:#1e2d57;
  --input-background-fill:#0d1631; --body-text-color:#e8eefc; --block-label-text-color:#9fb3da;
  --button-primary-background-fill:#14b8a6; --button-primary-background-fill-hover:#22d3ee; --button-primary-text-color:#04202a;
  --button-secondary-background-fill:#16244a; --button-secondary-text-color:#e8eefc; --button-secondary-border-color:#2a3d73;
  background:#0a1128 PATTERN; font-family:'Cairo','Segoe UI',Tahoma,sans-serif; max-width:1080px !important; color:#e8eefc;
}
.hero { text-align:center; padding:34px 12px 18px; direction:rtl; }
.hero .eyebrow { color:#22d3ee; letter-spacing:.14em; font-size:.8rem; text-transform:uppercase; }
.hero h1 { font-size:2.3rem; margin:.3rem 0 .1rem; color:#fff; }
.hero h2 { font-size:1.05rem; font-weight:400; color:#9fb3da; margin:0 0 .9rem; direction:ltr; }
.hero p { max-width:760px; margin:.3rem auto; color:#c7d4f0; line-height:1.9; }
.hero .en { direction:ltr; color:#8ea3cf; font-size:.92rem; }
.input-area textarea { direction:rtl; text-align:right; font-family:'Amiri','Cairo',serif !important; font-size:1.2rem !important; line-height:2 !important; }
.btn-row button { font-weight:700 !important; border-radius:12px !important; }
.disclaimer { font-size:.82rem; color:#8ea3cf; text-align:center; padding:12px 8px; direction:rtl; line-height:1.8; }
.disclaimer .en { display:block; direction:ltr; }
.results { direction:rtl; text-align:right; }
.results .en, .notice .en { display:block; direction:ltr; text-align:left; color:#8ea3cf; font-size:.88rem; font-weight:400; }
.summary { display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); gap:10px; margin:6px 0 14px; }
.tile { background:#101a38; border:1px solid #1e2d57; border-radius:14px; padding:12px; text-align:center; }
.tile b { display:block; font-size:1.8rem; color:#fff; } .tile span { display:block; font-size:.9rem; } .tile small { color:#8ea3cf; }
.tile.verified { border-color:#14b8a6; } .tile.verified b { color:#2dd4bf; }
.tile.mismatch { border-color:#f43f5e; } .tile.mismatch b { color:#fb7185; }
.tile.review { border-color:#f59e0b; } .tile.review b { color:#fbbf24; }
.highlight { background:#0d1631; border:1px solid #1e2d57; border-radius:14px; padding:14px 16px; margin-bottom:14px; }
.highlight label, .field label { display:block; color:#7e93c0; font-size:.78rem; margin-bottom:4px; }
.highlight p { font-family:'Amiri','Cairo',serif; font-size:1.15rem; line-height:2.1; margin:0; white-space:pre-wrap; }
mark { color:#fff; border-radius:6px; padding:1px 4px; }
mark.verified { background:rgba(20,184,166,.35); } mark.mismatch { background:rgba(244,63,94,.35); } mark.review { background:rgba(245,158,11,.35); }
.qcard { background:#101a38; border:1px solid #1e2d57; border-inline-start:5px solid #3b4b7a; border-radius:16px; padding:16px 18px; margin:12px 0; }
.qcard.verified { border-inline-start-color:#14b8a6; } .qcard.mismatch { border-inline-start-color:#f43f5e; } .qcard.review { border-inline-start-color:#f59e0b; }
.qhead { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:10px; }
.idx { background:#1b2a55; border-radius:50%; width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; font-weight:700; }
.badge { padding:3px 12px; border-radius:999px; font-size:.82rem; background:#16244a; border:1px solid #2a3d73; }
.badge.st.verified { background:rgba(20,184,166,.18); border-color:#14b8a6; color:#5eead4; }
.badge.st.mismatch { background:rgba(244,63,94,.16); border-color:#f43f5e; color:#fda4af; }
.badge.st.review { background:rgba(245,158,11,.16); border-color:#f59e0b; color:#fcd34d; }
.conf { margin-inline-start:auto; color:#9fb3da; font-size:.88rem; } .conf b { color:#22d3ee; }
.field { margin:8px 0; } .field p { margin:0; line-height:1.8; }
.quote { font-family:'Amiri','Cairo',serif; font-size:1.2rem; line-height:2.1; color:#fff; } .quote.small { font-size:1.05rem; color:#d6e0f7; }
.diff { font-family:'Amiri','Cairo',serif; font-size:1.1rem; line-height:2.1; }
.w-extra { background:rgba(244,63,94,.25); border-radius:4px; padding:0 3px; text-decoration:line-through; }
.w-missing { background:rgba(20,184,166,.28); border-radius:4px; padding:0 3px; }
.legend { color:#8ea3cf; font-size:.82rem; margin:4px 0 0; } .legend .w-extra { text-decoration:none; }
.tile.zero { opacity:.5; }
.det-chip { text-align:center; color:#8ea3cf; font-size:.82rem; margin:2px 0 8px; } .det-chip b { color:#22d3ee; font-weight:600; }
.hl-legend { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; font-size:.78rem; } .hl-legend mark { padding:2px 10px; }
.flow { display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:8px; margin:16px 0 4px; direction:rtl; }
.flow span { background:#101a38; border:1px solid #1e2d57; border-radius:999px; padding:5px 14px; font-size:.85rem; color:#cfe0ff; }
.flow span small { color:#7e93c0; margin-inline-start:6px; direction:ltr; display:inline-block; }
.flow i { color:#22d3ee; font-style:normal; }
.hero .mark { width:52px; height:52px; margin:0 auto 6px; display:block; }
.hero h1 { background:linear-gradient(90deg,#fff,#a5f3fc); -webkit-background-clip:text; background-clip:text; color:transparent; }
.action { border-radius:12px; padding:10px 14px; margin-top:10px; line-height:1.8; }
.action.ok { background:rgba(20,184,166,.12); border:1px solid #14b8a6; }
.action.warn { background:rgba(245,158,11,.12); border:1px solid #f59e0b; }
.action.bad { background:rgba(244,63,94,.12); border:1px solid #f43f5e; }
.evidence { margin-top:12px; border-top:1px dashed #2a3d73; padding-top:8px; }
.evidence summary { cursor:pointer; color:#22d3ee; font-weight:700; }
.ev-row { margin:10px 0; } .ev-row b { color:#9fb3da; font-size:.85rem; } .ev-row p { margin:2px 0; }
.indicators { display:grid; gap:6px; margin-top:6px; }
.ind { display:grid; grid-template-columns:150px 1fr 48px; gap:10px; align-items:center; font-size:.85rem; }
.ind-name small { display:block; color:#7e93c0; font-size:.72rem; direction:ltr; text-align:right; }
.bar { background:#0d1631; border-radius:999px; height:8px; overflow:hidden; } .bar span { display:block; height:100%; background:linear-gradient(90deg,#14b8a6,#22d3ee); }
.ind-val { text-align:left; direction:ltr; color:#cfe0ff; } .ind-val.wide { grid-column:2 / span 2; text-align:right; }
.notice { background:#101a38; border:1px solid #1e2d57; border-radius:14px; padding:16px; direction:rtl; line-height:1.9; }
.notice.warn { border-color:#f59e0b; } .notice.bad { border-color:#f43f5e; }
@media (max-width:640px){ .hero h1{font-size:1.7rem;} .ind{grid-template-columns:110px 1fr 40px;} }
""".replace("PATTERN", _PATTERN)

HERO = """
<div class="hero">
  <svg class="mark" viewBox="0 0 52 52" fill="none" stroke="#22d3ee" stroke-width="1.6" aria-hidden="true">
    <rect x="11" y="11" width="30" height="30"/><rect x="11" y="11" width="30" height="30" transform="rotate(45 26 26)"/>
    <circle cx="26" cy="26" r="6" fill="#14b8a6" stroke="none"/></svg>
  <div class="eyebrow">Islamic Content Verifier</div>
  <h1>مُدقِّق المحتوى الإسلامي</h1>
  <h2>Verify Quranic and Hadith quotations with evidence.</h2>
  <p>الصق نصًا ولّده نموذج لغوي، وسيكتشف النظام الآيات والأحاديث الواردة فيه، ويسترجع نصوصها من المصادر، ويقارنها كلمةً بكلمة،
     ثم يعرض الدليل. وإذا لم تكفِ الأدلة فلن يختلق تصحيحًا، بل يحيل الحالة إلى المراجعة البشرية.</p>
  <p class="en">Paste text generated by a language model. The system detects Quran and Hadith quotations, retrieves the source texts,
     compares them word by word and shows the evidence. When evidence is insufficient it never invents a correction: it recommends human review.</p>
  <div class="flow"><span>كشف<small>Detect</small></span><i>←</i><span>استرجاع<small>Retrieve</small></span><i>←</i>
    <span>تحقق<small>Verify</small></span><i>←</i><span>دليل<small>Evidence</small></span><i>←</i><span>قرار<small>Decide</small></span></div>
</div>
"""

DISCLAIMER = """
<div class="disclaimer">أداة مساعدة للتدقيق النصي وليست فتوى ولا بديلًا عن المراجعة المتخصصة. النتائج مبنية على مراجع القرآن والكتب الستة المضمّنة فقط.
<span class="en">A text-verification aid, not a religious ruling and not a substitute for expert review. Results rely only on the bundled Quran and Six Books corpora.</span></div>
"""

PLACEHOLDER = "الصق هنا الرد الذي ولّده النموذج اللغوي، ويفضَّل أن تكون الاقتباسات بين علامات تنصيص بعد عبارة مثل «قال الله تعالى» أو «قال رسول الله ﷺ»…"


def build_interface():
    import gradio as gr

    examples = load_examples()

    def next_example(index: int):
        if not examples:
            return "", 0
        return examples[index % len(examples)]["text"], (index + 1) % len(examples)

    with gr.Blocks(title="Islamic Content Verifier", css=CSS, theme=gr.themes.Base(primary_hue="teal", neutral_hue="slate")) as demo:
        gr.HTML(HERO)
        example_index = gr.State(0)
        text_input = gr.Textbox(
            label="النص المراد التحقق منه · Text to verify", lines=9, max_lines=24, placeholder=PLACEHOLDER,
            rtl=True, elem_classes="input-area",
        )
        with gr.Row(elem_classes="btn-row"):
            verify_button = gr.Button("تحقق من النص · Verify Text", variant="primary", scale=3)
            example_button = gr.Button("جرّب مثالًا · Try an Example", variant="secondary", scale=2)
        results = gr.HTML(elem_classes="results")
        gr.HTML(DISCLAIMER)

        verify_button.click(verify_text, inputs=text_input, outputs=results)
        text_input.submit(verify_text, inputs=text_input, outputs=results)
        example_button.click(next_example, inputs=example_index, outputs=[text_input, example_index]).then(
            verify_text, inputs=text_input, outputs=results
        )
    return demo


def main() -> None:
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
    get_pipeline()   # build the index before serving the first request
    build_interface().queue().launch(share=os.environ.get("ICV_SHARE") == "1")   # ICV_SHARE=1 -> temporary public link (e.g. on Colab)


if __name__ == "__main__":
    main()