File size: 6,845 Bytes
11e1b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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:
   
    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]:
    
    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:
    
    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):
    
    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:
    
    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]:
    
    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:
    
    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]:
    
    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()]
    
    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   
        if tag == "replace" and position in (0, len(raw_ops) - 1) and len(raw_ops) > 1 and (j2 - j1) > (i2 - i1):
            
            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,
    }