Spaces:
Running
Running
Download alignment.py from Ghada-99-Ragab/lastfinal2: direct link, hf CLI and curl.
- Browser
- Download file 9.18 kB
-
https://huggingface.co/spaces/Ghada-99-Ragab/lastfinal2/resolve/main/alignment.py
- Command line
-
hf download hf://spaces/Ghada-99-Ragab/lastfinal2/alignment.py
-
curl -L -o alignment.py https://huggingface.co/spaces/Ghada-99-Ragab/lastfinal2/resolve/main/alignment.py
9.18 kB
| """Word-level sequence alignment between a quotation and a source text. | |
| * Phonetic skeletons (``normalization.aligned_words``) make Uthmani / modern spelling and diacritics irrelevant for | |
| matching, while every substituted, missing, extra or re-ordered word still shows up as an edit operation. | |
| * A dynamic sliding window locates the best-matching region inside long sources (Hadith with chains of narrators). | |
| * The gap threshold scales with quotation length: a short quotation tolerates no stray words, a long one a few. | |
| * Diacritic conflicts are reported separately as notes, because spelling of harakat differs legitimately between prints. | |
| """ | |
| from __future__ import annotations | |
| from difflib import SequenceMatcher | |
| from math import ceil | |
| from typing import Container, Dict, List, Optional, Sequence, Tuple | |
| from idgham import apply_idgham | |
| from normalization import aligned_words, vowel_signature | |
| def lcs_length(a: Sequence[str], b: Sequence[str]) -> int: | |
| m, n = len(a), len(b) | |
| if m == 0 or n == 0: | |
| return 0 | |
| if m < n: | |
| a, b, m, n = b, a, n, m | |
| prev = [0] * (n + 1) | |
| for i in range(m): | |
| curr = [0] * (n + 1) | |
| for j in range(n): | |
| curr[j + 1] = prev[j] + 1 if a[i] == b[j] else max(curr[j], prev[j + 1]) | |
| prev = curr | |
| return prev[n] | |
| def allowed_gap(n_tokens: int) -> int: | |
| """Dynamic gap threshold: how many differing words still count as 'the same passage, altered'.""" | |
| return 0 if n_tokens <= 3 else max(1, ceil(0.2 * n_tokens)) | |
| def find_window(quote: List[str], source: List[str], gap: int) -> Tuple[int, int]: | |
| """Best source region for the quotation: windows of ``len(quote)+gap`` ranked by shared words, refined by LCS.""" | |
| n, window = len(quote), len(quote) + gap | |
| if len(source) <= window + 2 * gap + 8: | |
| return 0, len(source) | |
| quote_set = set(quote) | |
| prefix = [0] | |
| for word in source: | |
| prefix.append(prefix[-1] + (word in quote_set)) | |
| starts = sorted(range(len(source) - window + 1), key=lambda i: prefix[i + window] - prefix[i], reverse=True)[:8] | |
| best_start = max(starts, key=lambda i: lcs_length(quote, source[i : i + window])) | |
| return max(0, best_start - gap), min(len(source), best_start + window + gap) | |
| def contains_sequence(source: Sequence[str], quote: Sequence[str]) -> bool: | |
| """True if ``quote`` appears as a contiguous run of whole words in ``source``.""" | |
| n = len(quote) | |
| return n > 0 and any(source[i : i + n] == list(quote) for i in range(len(source) - n + 1)) | |
| def locate_region(q_norm: List[str], s_norm: List[str], gap: int, slack: Optional[int] = None): | |
| """Region of the source that corresponds to the quotation (matched part plus slack for edge differences). | |
| Returns ``(lo, hi, matcher)`` where the matcher compares the quotation with ``s_norm[lo:hi]``.""" | |
| slack = gap if slack is None else slack | |
| lo, hi = find_window(q_norm, s_norm, gap) | |
| matcher = SequenceMatcher(None, q_norm, s_norm[lo:hi], autojunk=False) | |
| blocks = [b for b in matcher.get_matching_blocks() if b.size] | |
| if blocks: | |
| first, last = blocks[0], blocks[-1] | |
| base = lo | |
| lo = base + max(0, first.b - first.a - slack) | |
| hi = min(hi, base + last.b + last.size + (len(q_norm) - last.a - last.size) + slack) | |
| matcher = SequenceMatcher(None, q_norm, s_norm[lo:hi], autojunk=False) | |
| return lo, hi, matcher | |
| def best_region(quote: str, source: str) -> str: | |
| """The part of ``source`` that a (possibly partial) quotation refers to, as original words. | |
| Scoring a snippet against the whole verse / Hadith would punish it for being shorter than its source; comparing it | |
| with this region keeps the score about what was actually quoted.""" | |
| q_pairs, s_pairs = aligned_words(quote), aligned_words(source) | |
| if not q_pairs or len(s_pairs) <= len(q_pairs) + allowed_gap(len(q_pairs)) + 2: | |
| return source | |
| q_norm, s_norm = [p[1] for p in q_pairs], [p[1] for p in s_pairs] | |
| lo, hi, _ = locate_region(q_norm, s_norm, allowed_gap(len(q_norm)), slack=0) | |
| return " ".join(p[0] for p in s_pairs[lo:hi]) if hi > lo else source | |
| def _diacritic_notes(quote_words: List[str], source_words: List[str], idgham_words: List[str]) -> List[dict]: | |
| """Words whose written harakat contradict the source (missing harakat are fine; idgham spelling is accepted).""" | |
| notes = [] | |
| for q_word, s_word, g_word in zip(quote_words, source_words, idgham_words): | |
| q_sig = vowel_signature(q_word) | |
| if not any(marks for _, marks in q_sig): | |
| continue | |
| conflict = True | |
| for variant in (s_word, g_word): | |
| v_sig = vowel_signature(variant) | |
| if len(v_sig) == len(q_sig) and all(set(qm) <= set(vm) for (_, qm), (_, vm) in zip(q_sig, v_sig)): | |
| conflict = False | |
| break | |
| if conflict: | |
| notes.append({"word": q_word, "source_word": s_word}) | |
| return notes | |
| def _orthographic_variant(q_tokens: Sequence[str], s_tokens: Sequence[str], vocabulary: Optional[Container[str]]) -> bool: | |
| """Same letters written differently: spacing (ياأيها / يا أيها) or a medial alef (إسحق / إسحاق). | |
| The alef variant is tolerated only when the quoted spelling is not itself a word of the corpus (so قتل / قاتل stay a | |
| real difference).""" | |
| if "".join(q_tokens) == "".join(s_tokens): | |
| return True | |
| if vocabulary is not None and len(q_tokens) == 1 and len(s_tokens) == 1: | |
| q, s = q_tokens[0], s_tokens[0] | |
| return q.replace("ا", "") == s.replace("ا", "") and q not in vocabulary | |
| return False | |
| def align(quote: str, source: str, vocabulary: Optional[Container[str]] = None) -> Dict[str, object]: | |
| """Align ``quote`` to the best matching region of ``source`` and describe every difference. | |
| ``vocabulary``: phonetic-skeleton words of the corpus; enables the medial-alef spelling tolerance.""" | |
| q_pairs, s_pairs = aligned_words(quote), aligned_words(source) | |
| idgham_pairs = aligned_words(apply_idgham(source)) if len(source) < 20000 else s_pairs | |
| if len(idgham_pairs) != len(s_pairs): | |
| idgham_pairs = s_pairs | |
| q_orig, q_norm = [p[0] for p in q_pairs], [p[1] for p in q_pairs] | |
| s_orig, s_norm = [p[0] for p in s_pairs], [p[1] for p in s_pairs] | |
| g_orig = [p[0] for p in idgham_pairs] | |
| gap = allowed_gap(len(q_norm)) | |
| lo, hi, matcher = locate_region(q_norm, s_norm, gap) | |
| raw_ops = [(tag, i1, i2, j1 + lo, j2 + lo) for tag, i1, i2, j1, j2 in matcher.get_opcodes()] | |
| # Source words before / after a partial quotation are not errors unless the quotation itself contains them elsewhere | |
| # (a moved word); drop such edge insertions. | |
| deleted = {w for tag, i1, i2, _, _ in raw_ops if tag in ("delete", "replace") for w in q_norm[i1:i2]} | |
| for position in (0, -1): | |
| 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): | |
| raw_ops.pop(position) | |
| ops, quote_side, source_side, missing, extra, notes = [], [], [], [], [], [] | |
| mismatches = 0 | |
| orthographic = 0 | |
| for position, (tag, i1, i2, j1, j2) in enumerate(raw_ops): | |
| source_idx = list(range(j1, j2)) | |
| if tag == "replace" and _orthographic_variant(q_norm[i1:i2], s_norm[j1:j2], vocabulary): | |
| tag, orthographic = "equal", orthographic + 1 # spelling-only difference, not a different word | |
| if tag == "replace" and position in (0, len(raw_ops) - 1) and len(raw_ops) > 1 and (j2 - j1) > (i2 - i1): | |
| # at the edges the slack may have pulled in unrelated source words: keep only words the quotation also uses | |
| keep = [j for j in source_idx if s_norm[j] in set(q_norm[i1:i2]) | deleted] | |
| source_idx = keep | |
| tag = "replace" if keep else "delete" | |
| ops.append({"op": tag, "span": " ".join(q_orig[i1:i2]), "source": " ".join(s_orig[j] for j in source_idx)}) | |
| if tag == "equal": | |
| notes += _diacritic_notes(q_orig[i1:i2], s_orig[j1:j2], g_orig[j1:j2]) | |
| else: | |
| mismatches += max(i2 - i1, len(source_idx)) | |
| quote_side += q_norm[i1:i2] | |
| source_side += [s_norm[j] for j in source_idx] | |
| extra += q_orig[i1:i2] if tag in ("delete", "replace") else [] | |
| missing += [s_orig[j] for j in source_idx] if tag in ("insert", "replace") else [] | |
| matched = sum(i2 - i1 for tag, i1, i2, _, _ in raw_ops if tag == "equal") | |
| exact = bool(q_norm) and mismatches == 0 | |
| return { | |
| "word_similarity": round(2 * matched / max(len(q_norm) + (raw_ops[-1][4] - raw_ops[0][3] if raw_ops else 0), 1), 3), | |
| "word_diff": ops, | |
| "missing_from_span": missing, | |
| "extra_in_span": extra, | |
| "source_excerpt": " ".join(op["source"] for op in ops if op["source"]), | |
| "exact": exact, | |
| "mismatches": mismatches, | |
| "allowed_gap": gap, | |
| "near": (not exact) and matched > 0 and mismatches <= gap, | |
| "reordered": bool(quote_side) and sorted(quote_side) == sorted(source_side), | |
| "diacritic_notes": notes, | |
| "orthographic_variants": orthographic, | |
| } | |