Spaces:
Sleeping
Sleeping
Download app.py from SHK4K/hadith_segmentation: direct link, hf CLI and curl.
- Browser
- Download file 12 kB
-
https://huggingface.co/spaces/SHK4K/hadith_segmentation/resolve/main/app.py
- Command line
-
hf download hf://spaces/SHK4K/hadith_segmentation/app.py
-
curl -L -o app.py https://huggingface.co/spaces/SHK4K/hadith_segmentation/resolve/main/app.py
12 kB
| """Hadith splitter: sanad / matn segmentation for SHK4K/hadith-segmentation-bert.""" | |
| import html | |
| import os | |
| import re | |
| import sys | |
| import gradio as gr | |
| import spaces | |
| import torch | |
| from transformers import AutoModelForTokenClassification, AutoTokenizer | |
| MODEL_ID = os.environ.get("MODEL_ID", "SHK4K/hadith-segmentation-bert") | |
| # The Hub config still ships LABEL_0..3, so model.config.id2label is useless here. | |
| SANAD_IDS, MATN_IDS = (0, 1), (2, 3) | |
| MAX_LEN, STRIDE, BATCH = 512, 128, 8 | |
| # ponytail: hard cap on very long pastes. 64 windows ~= 25k subword tokens, far | |
| # beyond any single hadith; drop the cap if the demo ever faces books. | |
| MAX_WINDOWS = 64 | |
| # The model was trained on undiacritized text; feeding it harakat produces | |
| # garbage, so they are stripped for inference and mapped back for the output. | |
| _HARAKAT = re.compile("[\u064b-\u0652\u0670\u0640]") | |
| EXAMPLES = [ | |
| ['ุญูุฏููุซูููุง ุนูู ูุฑูู ุจููู ุฎูุงููุฏูุ ููุงูู ุญูุฏููุซูููุง ุงููููููุซูุ ุนููู ููุฒููุฏูุ ุนููู ุฃูุจูู ุงููุฎูููุฑูุ ุนููู ุนูุจูุฏู ุงูููููู ุจููู ุนูู ูุฑูู ู ุฑุถู ุงููู ุนููู ุง ู ุฃูููู ุฑูุฌููุงูุ ุณูุฃููู ุงููููุจูููู ุตูู ุงููู ุนููู ูุณูู ุฃูููู ุงูุฅูุณููุงูู ู ุฎูููุฑู ููุงูู \u200f "\u200f ุชูุทูุนูู ู ุงูุทููุนูุงู ูุ ููุชูููุฑูุฃู ุงูุณูููุงูู ู ุนูููู ู ููู ุนูุฑูููุชู ููู ููู ููู ู ุชูุนูุฑููู \u200f"\u200f\u200f.\u200f'], | |
| ['ุญูุฏููุซูููุง ุฃูุจูู ุงููููู ูุงููุ ููุงูู ุฃูุฎูุจูุฑูููุง ุดูุนูููุจูุ ููุงูู ุญูุฏููุซูููุง ุฃูุจูู ุงูุฒููููุงุฏูุ ุนููู ุงูุฃูุนูุฑูุฌูุ ุนููู ุฃูุจูู ููุฑูููุฑูุฉู ู ุฑุถู ุงููู ุนูู ู ุฃูููู ุฑูุณูููู ุงูููููู ุตูู ุงููู ุนููู ูุณูู ููุงูู \u200f "\u200f ููููุงูููุฐูู ููููุณูู ุจูููุฏููู ูุงู ููุคูู ููู ุฃูุญูุฏูููู ู ุญูุชููู ุฃูููููู ุฃูุญูุจูู ุฅููููููู ู ููู ููุงููุฏููู ููููููุฏููู \u200f"\u200f\u200f.\u200f'], | |
| ['ุญูุฏููุซูููุง ุฃูุจูู ุงูููููููุฏูุ ููุงูู ุญูุฏููุซูููุง ุดูุนูุจูุฉูุ ููุงูู ุฃูุฎูุจูุฑูููู ุนูุจูุฏู ุงูููููู ุจููู ุนูุจูุฏู ุงูููููู ุจููู ุฌูุจูุฑูุ ููุงูู ุณูู ูุนูุชู ุฃูููุณูุงุ ุนููู ุงููููุจูููู ุตูู ุงููู ุนููู ูุณูู ููุงูู \u200f "\u200f ุขููุฉู ุงูุฅููู ูุงูู ุญูุจูู ุงูุฃูููุตูุงุฑูุ ููุขููุฉู ุงููููููุงูู ุจูุบูุถู ุงูุฃูููุตูุงุฑู \u200f"\u200f\u200f.\u200f'], | |
| ['ุญูุฏููุซูููุง ุงููุญูู ูููุฏูููู ุนูุจูุฏู ุงูููููู ุจููู ุงูุฒููุจูููุฑู ุ ููุงูู : ุญูุฏููุซูููุง ุณูููููุงูู ุ ููุงูู : ุญูุฏููุซูููุง ููุญูููู ุจููู ุณูุนููุฏู ุงููุฃูููุตูุงุฑูููู ุ ููุงูู : ุฃูุฎูุจูุฑูููู ู ูุญูู ููุฏู ุจููู ุฅูุจูุฑูุงูููู ู ุงูุชููููู ูููู ุ ุฃูููููู ุณูู ูุนู ุนูููููู ูุฉู ุจููู ูููููุงุตู ุงููููููุซูููู ุ ููููููู : ุณูู ูุนูุชู ุนูู ูุฑู ุจููู ุงููุฎูุทููุงุจู ุฑูุถููู ุงูููููู ุนููููู ุนูููู ุงููู ูููุจูุฑูุ ููุงูู : ุณูู ูุนูุชู ุฑูุณูููู ุงูููููู ุตููููู ุงูููููู ุนููููููู ููุณููููู ูุ ููููููู : " ุฅููููู ูุง ุงููุฃูุนูู ูุงูู ุจูุงูููููููุงุชูุ ููุฅููููู ูุง ููููููู ุงู ูุฑูุฆู ู ูุง ูููููุ ููู ููู ููุงููุชู ููุฌูุฑูุชููู ุฅูููู ุฏูููููุง ููุตููุจูููุง ุฃููู ุฅูููู ุงู ูุฑูุฃูุฉู ููููููุญูููุงุ ููููุฌูุฑูุชููู ุฅูููู ู ูุง ููุงุฌูุฑู ุฅููููููู'], | |
| ] | |
| _model = _tokenizer = None | |
| def load(): | |
| global _model, _tokenizer | |
| if _model is None: | |
| _tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) | |
| _model = AutoModelForTokenClassification.from_pretrained(MODEL_ID).eval() | |
| # Called from inside an @GPU scope, where the CUDA device is attached. | |
| if torch.cuda.is_available() and _model.device.type == "cpu": | |
| _model.to("cuda") | |
| return _tokenizer, _model | |
| def segment(text): | |
| """Return {'sanad', 'matn', 'html', 'note'} for raw hadith text.""" | |
| if not text or not text.strip(): | |
| raise ValueError("empty input") | |
| # Strip harakat for inference, remembering where each character came from, | |
| # so the cut can be mapped back onto the original diacritized text. | |
| norm_chars, origin = [], [] | |
| for i, ch in enumerate(text): | |
| if not _HARAKAT.match(ch): | |
| norm_chars.append(ch) | |
| origin.append(i) | |
| norm = "".join(norm_chars) | |
| tok, model = load() | |
| enc = tok( | |
| norm, | |
| truncation=True, | |
| max_length=MAX_LEN, | |
| stride=STRIDE, | |
| return_overflowing_tokens=True, | |
| return_offsets_mapping=True, | |
| return_tensors="pt", | |
| padding=True, | |
| ) | |
| offsets = enc.pop("offset_mapping") | |
| enc.pop("overflow_to_sample_mapping", None) | |
| windows = min(enc["input_ids"].shape[0], MAX_WINDOWS) | |
| preds = [] | |
| with torch.inference_mode(): | |
| for i in range(0, windows, BATCH): | |
| batch = {k: v[i : i + BATCH].to(model.device) for k, v in enc.items()} | |
| preds.append(model(**batch).logits.argmax(-1).cpu()) | |
| preds = torch.cat(preds) | |
| # Majority vote per word. A word straddling two windows is keyed by its | |
| # character start, which both windows report identically. | |
| votes = {} | |
| for w in range(windows): | |
| for pos, word_id in enumerate(enc.word_ids(w)): | |
| if word_id is None: | |
| continue | |
| start, end = (int(x) for x in offsets[w][pos]) | |
| if start == end: | |
| continue | |
| label = int(preds[w][pos]) | |
| votes.setdefault(start, {}).setdefault(label, 0) | |
| votes[start][label] += 1 | |
| # The labels describe a single cut, so take the first matn word as the | |
| # boundary: sanad + matn always cover the whole input, in order. | |
| cut = next( | |
| (s for s in sorted(votes) if max(votes[s], key=votes[s].get) in MATN_IDS), | |
| len(norm), | |
| ) | |
| cut = origin[cut] if cut < len(origin) else len(text) | |
| note = f"{len(text.split())} words ยท {windows} window(s)" | |
| if windows < enc["input_ids"].shape[0]: | |
| note += f" ยท only the first {MAX_WINDOWS} windows were processed" | |
| if not text[cut:].strip(): | |
| note += " ยท model found no matn boundary" | |
| return { | |
| "sanad": text[:cut].strip(), | |
| "matn": text[cut:].strip(), | |
| "html": _highlight(text, cut), | |
| "note": note, | |
| } | |
| def _highlight(text, cut): | |
| escape = html.escape # before wrapping: the text comes from a public textbox | |
| return ( | |
| '<div class="hadith" dir="rtl">' | |
| f'<span class="sanad">{escape(text[:cut])}</span>' | |
| f'<span class="matn">{escape(text[cut:])}</span>' | |
| "</div>" | |
| ) | |
| def run(text): | |
| try: | |
| r = segment(text) | |
| except ValueError: | |
| raise gr.Error("ุฃุฏุฎู ูุต ุงูุญุฏูุซ ุฃููุงู / Please paste a hadith first.") | |
| return r["html"], r["sanad"], r["matn"], r["note"] | |
| load() # warm the Hub cache at startup, outside any GPU allocation | |
| CSS = """ | |
| #hadith-input textarea { direction: rtl; font-size: 1.05rem; line-height: 1.9; } | |
| .hadith { font-size: 1.15rem; line-height: 2.1; padding: 1rem; border-radius: .5rem; | |
| background: #fffdf7; color: #1a1a1a; min-height: 3rem; } | |
| .hadith .sanad { background: #fde68a; } | |
| .hadith .matn { background: #bbf7d0; } | |
| @media (prefers-color-scheme: dark) { | |
| .hadith { background: #1f2937; color: #f3f4f6; } | |
| .hadith .sanad { background: #92400e; } | |
| .hadith .matn { background: #166534; } | |
| } | |
| """ | |
| with gr.Blocks(title="Hadith Splitter") as demo: | |
| gr.Markdown( | |
| "# ูุงุตู ุงูุญุฏูุซ โ Hadith Splitter\n" | |
| "ููุณู ุงูุญุฏูุซ ุงูุฎุงู ุฅูู **ุณูุฏ** ู**ู ุชู**. ยท Splits a raw hadith into its " | |
| "**sanad** (chain of narrators) and **matn** (the saying itself)." | |
| ) | |
| inp = gr.Textbox( | |
| label="ุงูุญุฏูุซ / Hadith", | |
| lines=8, | |
| rtl=True, | |
| elem_id="hadith-input", | |
| placeholder="ุญุฏุซูุง ... ูุงู ุฑุณูู ุงููู ุตูู ุงููู ุนููู ูุณูู ...", | |
| ) | |
| with gr.Row(): | |
| btn = gr.Button("ูุณูู / Segment", variant="primary") | |
| clear = gr.Button("ู ุณุญ / Clear") | |
| out_html = gr.HTML() | |
| note = gr.Markdown() | |
| with gr.Row(): | |
| sanad = gr.Textbox( | |
| label="ุงูุณูุฏ / Sanad", lines=6, buttons=["copy"], rtl=True | |
| ) | |
| matn = gr.Textbox( | |
| label="ุงูู ุชู / Matn", lines=6, buttons=["copy"], rtl=True | |
| ) | |
| gr.Examples(examples=EXAMPLES, inputs=inp, label="ุฃู ุซูุฉ / Examples") | |
| gr.Markdown( | |
| "> **ู ูุงุญุธุงุช / Notes:** ููุฌุฑููุฏ ุงูุชุดููู ุชููุงุฆููุง ูุจู ุงูุชุญูููุ ูููุนุงุฏ ุงููุต ุงูุฃุตูู " | |
| "ุจุชุดูููู ูู ุงูู ุฎุฑุฌุงุช ยท diacritics are stripped for inference, your original " | |
| "text is returned unchanged. " | |
| "ุงููุต ุงูุฃุทูู ู ู 512 ุฑู ุฒูุง ููุนุงูุฌ ุจููุงูุฐ ู ุชุฏุงุฎูุฉ ยท longer text is processed in " | |
| "overlapping windows, never truncated. " | |
| "ุงููู ูุฐุฌ: [SHK4K/hadith-segmentation-bert]" | |
| "(https://huggingface.co/SHK4K/hadith-segmentation-bert)." | |
| ) | |
| outputs = [out_html, sanad, matn, note] | |
| btn.click(run, inp, outputs) | |
| inp.submit(run, inp, outputs) | |
| clear.click(lambda: (None, "", "", "", ""), None, [inp] + outputs) | |
| def _selftest(): | |
| sample = ( | |
| "ุญุฏุซูุง ุงูุญู ูุฏู ุนุจุฏ ุงููู ุจู ุงูุฒุจูุฑ ุ ูุงู : ุญุฏุซูุง ุณููุงู ุ ูุงู : ุญุฏุซูุง ูุญูู ุจู ุณุนูุฏ " | |
| "ุงูุฃูุตุงุฑู ุ ูุงู : ุฃุฎุจุฑูู ู ุญู ุฏ ุจู ุฅุจุฑุงููู ุงูุชูู ู ุ ุฃูู ุณู ุน ุนููู ุฉ ุจู ููุงุต ุงูููุซู ุ " | |
| 'ูููู : ุณู ุนุช ุนู ุฑ ุจู ุงูุฎุทุงุจ ุฑุถู ุงููู ุนูู ุนูู ุงูู ูุจุฑุ ูุงู : ุณู ุนุช ุฑุณูู ุงููู ุตูู ุงููู ' | |
| 'ุนููู ูุณูู ุ ูููู : " ุฅูู ุง ุงูุฃุนู ุงู ุจุงูููุงุชุ ูุฅูู ุง ููู ุงู ุฑุฆ ู ุง ูููุ ูู ู ูุงูุช ูุฌุฑุชู ' | |
| "ุฅูู ุฏููุง ูุตูุจูุง ุฃู ุฅูู ุงู ุฑุฃุฉ ูููุญูุงุ ููุฌุฑุชู ุฅูู ู ุง ูุงุฌุฑ ุฅููู" | |
| ) | |
| r = segment(sample) | |
| print("SANAD:", r["sanad"][:100]) | |
| print("MATN:", r["matn"][:100]) | |
| assert r["sanad"].startswith("ุญุฏุซูุง ุงูุญู ูุฏู"), r["sanad"][:80] | |
| assert "ุฅูู ุง ุงูุฃุนู ุงู ุจุงูููุงุช" in r["matn"] | |
| assert sample.strip().startswith(r["sanad"]) and sample.strip().endswith(r["matn"]) | |
| long_text = " ".join([sample] * 6) | |
| lr = segment(long_text) | |
| assert lr["sanad"] and lr["matn"], "long input lost its boundary" | |
| assert sample.strip()[-40:] in lr["matn"], "tail of a long input was dropped" | |
| xss = '<script>alert(1)</script> ' + sample | |
| assert "<script>" not in segment(xss)["html"], "input was not escaped" | |
| # Diacritized input must segment too, and keep its diacritics in the output. | |
| vocalized = ( | |
| "ุญูุฏููุซูููุง ุฃูุจูู ุงูููููููุฏูุ ููุงูู ุญูุฏููุซูููุง ุดูุนูุจูุฉูุ ุนููู ุฃูููุณูุ ููุงูู: " | |
| "ููุงูู ุฑูุณูููู ุงูููููู ุตููููู ุงูููููู ุนููููููู ููุณููููู ู: ยซุงูุฏููููู ุงููููุตููุญูุฉูยป" | |
| ) | |
| vr = segment(vocalized) | |
| print("VOCALIZED SANAD:", vr["sanad"][:80]) | |
| print("VOCALIZED MATN:", vr["matn"][:80]) | |
| assert vr["sanad"].startswith("ุญูุฏููุซูููุง"), "diacritics broke the sanad" | |
| assert vr["matn"], "diacritized input lost its matn" | |
| print("selftest ok") | |
| if __name__ == "__main__": | |
| if "--selftest" in sys.argv: | |
| _selftest() | |
| else: | |
| demo.launch(css=CSS) | |