File size: 8,333 Bytes
9e7ba62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Deterministic checks and agreement-based routing.

VLM engines give no calibrated per-word confidence, so routing relies on:
  1. agreement between two engines (or two preprocessing candidates),
  2. script consistency (output must be in the expected Unicode block),
  3. [illegible] markers or low word confidence from the engine,
  4. field format validators.
Thresholds here are starting values; calibrate them on human-verified data.
"""
from __future__ import annotations

import re
import unicodedata
from dataclasses import dataclass, field
from enum import Enum

SCRIPT_RANGES = {
    "hi-IN": (0x0900, 0x097F),   # Devanagari
    "ta-IN": (0x0B80, 0x0BFF),   # Tamil
    "te-IN": (0x0C00, 0x0C7F),   # Telugu
    "kn-IN": (0x0C80, 0x0CFF),   # Kannada
    "ml-IN": (0x0D00, 0x0D7F),   # Malayalam
}
INDIC_BLOCKS = (0x0900, 0x0D80)  # Devanagari .. Malayalam: nine 128-code-point blocks, same layout


def nfc(s: str | None) -> str:
    return unicodedata.normalize("NFC", s or "")


def to_script(text: str, language: str | None) -> str:
    """Move letters the model wrote in a look-alike Indic script into the expected one
    (e.g. Telugu ప్రారం -> Kannada ಪ್ರಾರಂ). The Indic blocks share one layout, so the letter
    keeps its offset; characters with no counterpart in the target script are left as they are."""
    if not text or language not in SCRIPT_RANGES:
        return text
    lo = SCRIPT_RANGES[language][0]
    out = []
    for c in text:
        cp = ord(c)
        if INDIC_BLOCKS[0] <= cp < INDIC_BLOCKS[1] and not lo <= cp <= lo + 0x7F:
            t = chr(lo + (cp & 0x7F))
            if unicodedata.name(t, None):    # assigned in the target block
                c = t
        out.append(c)
    return nfc("".join(out))


def levenshtein(a: str, b: str) -> int:
    if len(a) < len(b):
        a, b = b, a
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]


def cer(hyp: str, ref: str) -> float:
    """Character error rate of hyp against ref (on NFC code points, whitespace collapsed)."""
    hyp, ref = (re.sub(r"\s+", " ", nfc(x)).strip() for x in (hyp, ref))
    if not ref:
        return 0.0 if not hyp else 1.0
    return levenshtein(hyp, ref) / len(ref)


def wer(hyp: str, ref: str) -> float:
    h, r = nfc(hyp).split(), nfc(ref).split()
    if not r:
        return 0.0 if not h else 1.0
    # word-level Levenshtein via token mapping
    vocab = {w: chr(0xE000 + i) for i, w in enumerate(set(h) | set(r))}
    return levenshtein("".join(vocab[w] for w in h), "".join(vocab[w] for w in r)) / len(r)


def pairwise_disagreement(a: str, b: str) -> float:
    """Symmetric CER-like distance between two hypotheses, 0 = identical."""
    a, b = (re.sub(r"\s+", " ", nfc(x)).strip() for x in (a, b))
    if not a and not b:
        return 0.0
    return levenshtein(a, b) / max(len(a), len(b))


def script_purity(text: str, language: str) -> float:
    """Share of letter/mark characters that fall in the expected script block.
    Digits, punctuation, whitespace and Latin are ignored (forms often mix English)."""
    lo, hi = SCRIPT_RANGES.get(language, (0, 0x10FFFF))
    relevant = [c for c in nfc(text) if unicodedata.category(c)[0] in ("L", "M")
                and not ("A" <= c <= "z")]
    if not relevant:
        return 1.0
    return sum(lo <= ord(c) <= hi for c in relevant) / len(relevant)


# --------------------------------------------------------------- field validators
def _digits(s: str) -> str:
    """Map any Unicode decimal digit (Tamil, Telugu, ...) to ASCII."""
    return "".join(str(unicodedata.digit(c)) if unicodedata.category(c) == "Nd" else c for c in s)


VALIDATORS = {
    "pincode": lambda v: bool(re.fullmatch(r"[1-9]\d{5}", _digits(v).replace(" ", ""))),
    "mobile": lambda v: bool(re.fullmatch(r"(\+?91)?[6-9]\d{9}", re.sub(r"[\s-]", "", _digits(v)))),
    "date": lambda v: bool(re.fullmatch(r"\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4}", _digits(v).strip())),
    "amount": lambda v: bool(re.fullmatch(r"₹?\s?\d{1,3}(,\d{2,3})*(\.\d{1,2})?|₹?\s?\d+(\.\d{1,2})?",
                                          _digits(v).strip())),
    "text": lambda v: bool(v.strip()),
}


# --------------------------------------------------------------- routing
class Decision(str, Enum):
    ACCEPT = "ACCEPT"
    RETRY_ALT_CANDIDATE = "RETRY_ALT_CANDIDATE"
    HUMAN_REVIEW = "HUMAN_REVIEW"
    RECAPTURE = "RECAPTURE"


@dataclass
class RoutingConfig:
    max_disagreement: float = 0.05         # full text, between engines
    max_field_disagreement: float = 0.0    # critical fields must match exactly after NFC
    min_script_purity: float = 0.90
    max_uncertain: int = 0                  # [illegible] marks / low-confidence words
    max_attempts: int = 2                  # candidates tried before escalating
    recapture_flags: frozenset[str] = frozenset({"BLURRY", "LOW_RESOLUTION"})


@dataclass
class RoutingOutcome:
    decision: Decision
    reasons: list[str] = field(default_factory=list)
    metrics: dict = field(default_factory=dict)


def route(texts: dict[str, str], language: str, *, quality_flags: list[str],
          attempt: int, uncertain_counts: dict[str, int] | None = None,
          fields: dict[str, dict[str, str | None]] | None = None,
          field_types: dict[str, str] | None = None, critical_fields: set[str] | None = None,
          cfg: RoutingConfig | None = None) -> RoutingOutcome:
    """texts: engine name -> full text. fields: engine name -> {field: value}."""
    cfg = cfg or RoutingConfig()
    reasons: list[str] = []
    metrics: dict = {}

    usable = {k: v for k, v in texts.items() if v.strip()}
    if not usable:
        if cfg.recapture_flags & set(quality_flags):
            return RoutingOutcome(Decision.RECAPTURE, ["no text and poor image quality"], metrics)
        reasons.append("no engine returned text")

    names = sorted(usable)
    if len(names) >= 2:
        d = pairwise_disagreement(usable[names[0]], usable[names[1]])
        metrics["text_disagreement"] = round(d, 4)
        if d > cfg.max_disagreement:
            reasons.append(f"engines disagree on full text ({d:.1%})")
    elif usable:
        reasons.append("single engine only - no agreement signal")

    for n, t in usable.items():
        p = script_purity(t, language)
        metrics[f"script_purity_{n}"] = round(p, 3)
        if p < cfg.min_script_purity:
            reasons.append(f"{n}: unexpected script ({p:.0%} in {language})")

    for n, c in (uncertain_counts or {}).items():
        if c > cfg.max_uncertain:
            reasons.append(f"{n}: {c} illegible/low-confidence tokens")

    critical_fields = critical_fields or set()
    field_types = field_types or {}
    for fname in sorted({f for fv in (fields or {}).values() for f in fv}):
        vals = {e: fv.get(fname) for e, fv in (fields or {}).items()}
        present = {e: v for e, v in vals.items() if v}
        is_crit = fname in critical_fields
        if not present:
            if is_crit:
                reasons.append(f"critical field '{fname}' missing")
            continue
        validator = VALIDATORS.get(field_types.get(fname, "text"), VALIDATORS["text"])
        for e, v in present.items():
            if not validator(v):
                reasons.append(f"{e}: field '{fname}' fails {field_types.get(fname, 'text')} format")
        if is_crit and len(present) >= 2:
            a, b = list(present.values())[:2]
            if pairwise_disagreement(a, b) > cfg.max_field_disagreement:
                reasons.append(f"critical field '{fname}' differs between engines")
        elif is_crit and len(present) < len(vals):
            reasons.append(f"critical field '{fname}' read by only one engine")

    if not reasons:
        return RoutingOutcome(Decision.ACCEPT, [], metrics)
    if cfg.recapture_flags & set(quality_flags) and attempt >= cfg.max_attempts:
        return RoutingOutcome(Decision.RECAPTURE, reasons, metrics)
    if attempt < cfg.max_attempts:
        return RoutingOutcome(Decision.RETRY_ALT_CANDIDATE, reasons, metrics)
    return RoutingOutcome(Decision.HUMAN_REVIEW, reasons, metrics)