Spaces:
Running
Running
File size: 9,178 Bytes
597dbb9 | 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 | """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,
}
|