File size: 22,880 Bytes
29816c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
883df0c
 
 
 
 
 
 
 
 
29816c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
883df0c
29816c3
 
 
 
 
 
 
 
 
 
 
 
 
 
883df0c
29816c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
883df0c
 
 
 
 
 
 
 
 
 
 
29816c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
883df0c
 
29816c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
883df0c
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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
"""gemmaExtract: typed field extraction in one forward pass (no text generation) on a Gemma 4 backbone."""
import math
import torch
import torch.nn as nn

TYPES = {"text": 0, "list": 1, "number": 2, "date": 3, "time": 4, "duration": 5, "enum": 6}
OPS = ["none", "sum", "diff", "avg", "count", "div", "mul"]  # none = chep span; con lai tinh tren toan hang (kieu TagOp)
ORDERED = {"diff", "div"}


def num(t, en=False):
    """Chuoi -> float hoac None. en=True: ',' hang nghin, '.' thap phan (DROP, TAT-QA). Mac dinh (hoa don VN/ID, kieu hon hop):
    2 loai dau -> dau cuoi la thap phan; 1 loai dau -> thap phan tru khi theo sau dung 3 chu so; '(12)' -> -12.
    Giong data/common.py parse_num."""
    import re
    m = re.search(r"-?\d[\d.,]*\d|-?\d", t or "")
    if not m:
        return None
    x = m.group()
    neg = x.startswith("-") or ("(" in t and ")" in t)
    x = x.lstrip("-")
    if en:
        x = x.replace(",", "")
        if x.count(".") > 1:
            return None
    else:
        seps = [c for c in x if c in ".,"]
        if seps:
            last = max(x.rfind("."), x.rfind(","))
            dec = len(set(seps)) == 2 or (len(seps) == 1 and len(x) - last - 1 != 3)
            x = re.sub(r"[.,]", "", x[:last]) + "." + x[last + 1:] if dec else re.sub(r"[.,]", "", x)
    try:
        v = float(x)
    except ValueError:
        return None
    return -v if neg else v


MONTHS = {m: i + 1 for i, m in enumerate(["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"])}


def date(t, dayfirst=True):
    """Chuoi ngay -> 'YYYY-MM-DD' hoac None (ngay truoc thang mac dinh, nhu hoa don VN/MY). Giong data/common.py parse_date."""
    import datetime, re
    m = re.search(r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})", t or "")
    vn = re.search(r"(\d{1,2})\s*tháng\s*(\d{1,2})\s*,?\s*năm\s*(\d{4})", t or "", re.I)  # "20 tháng 10 năm 2026"
    if m:
        y, mo, d = map(int, m.groups())
    elif vn:
        d, mo, y = map(int, vn.groups())
    else:
        m = re.search(r"(\d{1,2})[-/.](\d{1,2})[-/.](\d{4}|\d{2})", t or "")
        m2 = re.search(r"(\d{1,2})[-/ .]*([A-Za-z]{3})[A-Za-z]*[-/ .,]*(\d{4}|\d{2})", t or "")
        m3 = re.search(r"([A-Za-z]{3})[A-Za-z]*[-/ .]*(\d{1,2})[-/ .,]+(\d{4}|\d{2})", t or "")
        if m:
            d, mo, y = map(int, m.groups())
            if not dayfirst:
                d, mo = mo, d
        elif m2 and m2.group(2).lower() in MONTHS:
            d, mo, y = int(m2.group(1)), MONTHS[m2.group(2).lower()], int(m2.group(3))
        elif m3 and m3.group(1).lower() in MONTHS:
            mo, d, y = MONTHS[m3.group(1).lower()], int(m3.group(2)), int(m3.group(3))
        else:
            return None
        y += 2000 if y < 100 else 0
    try:
        return datetime.date(y, mo, d).isoformat()
    except ValueError:
        return None


def number_style(text):
    """Tu nhan kieu so cua van ban: True = EN (1,234.5), False = kieu cham hang nghin (1.234,5 VN/ID).
    Dem '1,234' + '5.56' so voi '1.234' + '5,56'; ngang nhau -> False (num() kieu hon hop van doc dung phan lon)."""
    import re
    en = len(re.findall(r"\d,\d{3}(?!\d)", text)) + len(re.findall(r"\d\.\d{1,2}(?!\d)", text))
    vn = len(re.findall(r"\d\.\d{3}(?!\d)", text)) + len(re.findall(r"\d,\d{1,2}(?!\d)", text))
    return en > vn


def snap(text, a, b):
    """Noi span ra tron con so: tokenizer Gemma tach tung chu so nen dau ra hay cat vun ('1951' -> '1' + '51'; dev DROP/TAT-QA:
    152/280 loi so phai tinh la vi vay). Gom ca dau ngoac am '(46)'."""
    dig = lambda i: 0 <= i < len(text) and text[i].isdigit()
    while a > 0 and (dig(a - 1) or (text[a - 1] in ".," and dig(a - 2))):
        a -= 1
    while b < len(text) and (dig(b) or (text[b] in ".," and dig(b + 1))):
        b += 1
    if a > 0 and b < len(text) and text[a - 1] == "(" and text[b] == ")":
        a, b = a - 1, b + 1
    return a, b


def trim(text, a, b):
    """Bo khoang trang hai dau span (offset token Gemma kem dau cach dung truoc)."""
    while a < b and text[a].isspace():
        a += 1
    while b > a and text[b - 1].isspace():
        b -= 1
    return a, b


def merge(spans):
    out = []
    for a, b in sorted(spans):
        if out and a <= out[-1][1]:
            out[-1] = (out[-1][0], max(out[-1][1], b))
        else:
            out.append((a, b))
    return out


def compute(op, vals, reverse=False):
    if op == "count":
        return float(len(vals))
    if not vals:
        return None
    if op == "sum":
        return sum(vals)
    if op == "avg":
        return sum(vals) / len(vals)
    if op == "mul":
        out = 1.0
        for v in vals:
            out *= v
        return out
    if len(vals) < 2:
        return None
    a, b = (vals[1], vals[0]) if reverse else (vals[0], vals[1])
    if op == "diff":
        return a - b
    return a / b if b else None


SPAN_TYPES = {"text", "number", "date", "time", "duration"}  # 1 gia tri = 1 span
MAX_SPAN_TOK = 64


def _ids(tok, text):
    return tok(text, add_special_tokens=False)["input_ids"]


def encode(tok, rec, max_len=1024):
    """-> dict: ids, state (a, b) vi tri token state, off (offset ky tu cua token state), fields [(ten, kieu, (a, b) span instruction)]."""
    schema, fields = _ids(tok, "\n\nSCHEMA FIELDS:\n"), []
    for i, (name, f) in enumerate(rec["fields"].items()):
        schema += _ids(tok, "\nFIELD %d\nID: %s\nTYPE: %s\nINSTRUCTION: " % (i + 1, name, f["type"]))
        a = len(schema)
        schema += _ids(tok, f.get("instructions") or name)
        fields.append((name, f["type"], (a, len(schema))))
        if f.get("options"):
            schema += _ids(tok, "\nOPTIONS: " + " | ".join(map(str, f["options"])))
        schema += _ids(tok, "\nEND FIELD\n")
    prefix = [tok.bos_token_id] + _ids(tok, "STATE:\n")
    t = tok(rec["state"], add_special_tokens=False, return_offsets_mapping=True)
    room = max_len - len(prefix) - len(schema)
    if room < 16:
        raise ValueError("schema qua dai: %d token" % len(schema))
    sid, soff = t["input_ids"][:room], [tuple(o) for o in t["offset_mapping"][:room]]
    base = len(prefix) + len(sid)
    return {"ids": prefix + sid + schema, "state": (len(prefix), base), "off": soff,
            "fields": [(n, ty, (a + base, b + base)) for n, ty, (a, b) in fields]}


def labels(enc, rec):
    """Nhan train cho moi field: null, cap (start, end) token chap nhan duoc (moi lan xuat hien), nhan B/I/O cho list.
    Field co dap an nhung moi span bi cat do max_len -> bo khoi loss (None)."""
    off, out = enc["off"], []
    for name, ty, _ in enc["fields"]:
        ans = rec["answers"].get(name)
        if ans is None:
            out.append(None); continue
        pairs, tags, opnd = [], [0] * len(off), [0] * len(off)
        der = None if ans["null"] else ans.get("derivation")
        if der and der.get("op") in OPS and der["op"] != "none":
            spans_txt = []
            for s, e in sorted(der["operands"]):
                toks = [i for i, (a, b) in enumerate(off) if a < e and b > s]
                for i in toks:
                    opnd[i] = 1
                spans_txt.append(rec["state"][s:e])
            if not any(opnd):
                out.append(None); continue
            vals, order = [num(t, en=True) for t in spans_txt], None  # derivation chi co o DROP/TAT-QA (EN)
            if der["op"] in ORDERED and len(vals) == 2 and None not in vals and isinstance(ans.get("value"), (int, float)):
                for rev in (False, True):
                    v = compute(der["op"], vals, rev)
                    if v is not None and abs(v - ans["value"]) <= 1e-6 + 1e-3 * abs(ans["value"]):
                        order = int(rev); break
            out.append({"null": False, "pairs": [], "tags": tags, "op": OPS.index(der["op"]), "opnd": opnd, "order": order})
            continue
        for s, e in ([] if ans["null"] else ans["spans"]):
            toks = [i for i, (a, b) in enumerate(off) if a < e and b > s]
            if toks:
                pairs.append((toks[0], toks[-1]))
                tags[toks[0]] = 1
                for i in toks[1:]:
                    tags[i] = 2
        if not ans["null"] and not pairs:
            out.append(None); continue
        out.append({"null": ans["null"], "pairs": pairs, "tags": tags, "op": 0 if ty == "number" and not ans["null"] else None,
                    "opnd": None, "order": None})
    return out


class Routing(nn.Module):
    """Query field cross-attention vao memory + FFN (EvidenceRoutingLayer cua Clef)."""

    def __init__(self, w, heads, ff):
        super().__init__()
        self.qn, self.mn, self.fn = nn.LayerNorm(w), nn.LayerNorm(w), nn.LayerNorm(w)
        self.att = nn.MultiheadAttention(w, heads, batch_first=True)
        self.ff = nn.Sequential(nn.Linear(w, ff), nn.GELU(), nn.Linear(ff, w))

    def forward(self, q, mem):
        m = self.mn(mem)
        q = q + self.att(self.qn(q), m, m, need_weights=False)[0]
        return q + self.ff(self.fn(q))


class S1Head(nn.Module):
    def __init__(self, hidden, width=1024, routing=2, layers=4, heads=16, ff=4096):
        super().__init__()
        self.w = width
        self.norm = nn.LayerNorm(hidden)
        self.mem = nn.Linear(hidden, width, bias=False)
        self.qproj = nn.Linear(hidden, width, bias=False)
        self.gproj = nn.Linear(hidden, width, bias=False)
        self.type_emb = nn.Embedding(len(TYPES), width)
        self.route = nn.ModuleList([Routing(width, heads, ff) for _ in range(routing)])
        self.joint = nn.ModuleList([nn.TransformerDecoderLayer(width, heads, ff, activation="gelu", batch_first=True, norm_first=True)
                                    for _ in range(layers)])
        self.fnorm = nn.LayerNorm(width)
        self.null = nn.Linear(width, 1)
        self.ks, self.ke, self.kt = (nn.Linear(hidden, width, bias=False) for _ in range(3))
        self.qs, self.qe = nn.Linear(width, width), nn.Linear(width, width)
        self.qt = nn.Linear(width, 3 * width)
        self.op = nn.Linear(width, len(OPS))
        self.kn, self.qn = nn.Linear(hidden, width, bias=False), nn.Linear(width, width)
        self.order = nn.Linear(width, 2)

    def forward(self, hidden, enc):
        """hidden: (L, H) cua 1 record. -> null (F,), start/end (F, S), tag (F, S, 3)."""
        h = self.norm(hidden.float())
        mem = self.mem(h).unsqueeze(0)
        q = torch.stack([h[a:b].mean(0) for _, _, (a, b) in enc["fields"]])
        ty = torch.tensor([TYPES[t] for _, t, _ in enc["fields"]], device=h.device)
        q = (self.qproj(q) + self.gproj(h[-1]) + self.type_emb(ty)).unsqueeze(0)
        for layer in self.route:
            q = layer(q, mem)
        for layer in self.joint:
            q = layer(q, mem)
        f = self.fnorm(q[0])
        a, b = enc["state"]
        hs = h[a:b]
        sc = 1 / math.sqrt(self.w)
        start = self.qs(f) @ self.ks(hs).T * sc
        end = self.qe(f) @ self.ke(hs).T * sc
        tag = torch.einsum("fcw,sw->fsc", self.qt(f).view(len(f), 3, self.w), self.kt(hs)) * sc
        opnd = self.qn(f) @ self.kn(hs).T * sc
        return self.null(f).squeeze(-1), start, end, tag, self.op(f), opnd, self.order(f)


def loss(out, labs, types):
    null, start, end, tag, op, opnd, order = out
    terms = []
    for i, (lab, ty) in enumerate(zip(labs, types)):
        if lab is None:
            continue
        terms.append(nn.functional.binary_cross_entropy_with_logits(null[i], torch.tensor(float(lab["null"]), device=null.device)))
        if ty == "list":
            terms.append(nn.functional.cross_entropy(tag[i], torch.tensor(lab["tags"], device=tag.device)))
        elif not lab["null"] and lab["pairs"]:  # chap nhan bat ky lan xuat hien nao
            ls, le = start[i].log_softmax(-1), end[i].log_softmax(-1)
            terms.append(-torch.logsumexp(torch.stack([ls[s] + le[e] for s, e in lab["pairs"]]), 0))
        if lab.get("op") is not None:
            terms.append(nn.functional.cross_entropy(op[i:i + 1], torch.tensor([lab["op"]], device=op.device)))
        if lab.get("opnd") is not None:
            terms.append(nn.functional.binary_cross_entropy_with_logits(opnd[i], torch.tensor(lab["opnd"], dtype=torch.float, device=opnd.device)))
        if lab.get("order") is not None:
            terms.append(nn.functional.cross_entropy(order[i:i + 1], torch.tensor([lab["order"]], device=order.device)))
    return torch.stack(terms).sum() / max(1, len(terms)) if terms else None


@torch.no_grad()
def decode(out, enc, state_text, null_threshold=0.5, en=None):
    """-> {ten: {null, p_null, guess, confidence, spans}}. guess tra ca khi null (nhu Clairic)."""
    null, start, end, tag, op, opnd, order = out
    off, res = enc["off"], {}
    if en is None:  # khong truyen locale -> tu nhan theo van ban
        en = number_style(state_text)
    for i, (name, ty, _) in enumerate(enc["fields"]):
        p_null = torch.sigmoid(null[i]).item()
        if ty == "list":
            tg = tag[i].argmax(-1).tolist()
            pr = tag[i].softmax(-1)
            spans, j = [], 0
            while j < len(tg):
                if tg[j] == 1 or (tg[j] == 2 and (j == 0 or tg[j - 1] == 0)):
                    k = j + 1
                    while k < len(tg) and tg[k] == 2:
                        k += 1
                    spans.append((*trim(state_text, off[j][0], off[k - 1][1]), pr[j, tg[j]].item()))
                    j = k
                else:
                    j += 1
            vals = list(dict.fromkeys(state_text[s:e].strip() for s, e, _ in spans if state_text[s:e].strip()))
            conf = min((p for *_, p in spans), default=1 - p_null)
            res[name] = {"null": p_null > null_threshold or not vals, "p_null": p_null, "guess": vals, "confidence": conf,
                         "spans": [[s, e] for s, e, _ in spans]}
            continue
        ps, pe = start[i].softmax(-1), end[i].softmax(-1)
        S = len(off)
        band = torch.triu(torch.ones(S, S, device=ps.device)) - torch.triu(torch.ones(S, S, device=ps.device), diagonal=MAX_SPAN_TOK)
        joint = ps[:, None] * pe[None, :] * band
        best = int(joint.argmax())
        s, e = divmod(best, S)
        cs, ce = trim(state_text, off[s][0], off[e][1])
        r = {"null": p_null > null_threshold, "p_null": p_null, "guess": state_text[cs:ce].strip(),
             "confidence": joint.view(-1)[best].item() * (1 - p_null), "spans": [[cs, ce]]}
        if ty == "number":
            o = OPS[int(op[i].argmax())]
            r["op"] = o
            if o != "none":  # gia tri tinh tu toan hang da danh dau
                tg = (opnd[i] > 0).tolist()
                spans, j = [], 0
                while j < len(tg):
                    if tg[j]:
                        k = j
                        while k + 1 < len(tg) and tg[k + 1]:
                            k += 1
                        spans.append((off[j][0], off[k][1])); j = k + 1
                    else:
                        j += 1
                spans = merge(snap(state_text, a, b) for a, b in spans)
                vals = [v for v in (num(state_text[a:b], en) for a, b in spans) if v is not None]
                rev = bool(order[i].argmax())
                v = compute(o, vals, rev)
                r.update({"value": v, "guess": ("%g" % v) if v is not None else r["guess"], "operands": [[a, b] for a, b in spans],
                          "reverse": rev})
            else:
                cs, ce = snap(state_text, cs, ce)
                r.update({"guess": state_text[cs:ce].strip(), "spans": [[cs, ce]]})
                r["value"] = num(r["guess"], en)
        elif ty == "date":
            r["value"] = date(r["guess"])
        res[name] = r
    return res


# ---- bidirectional full-attention layers ---------------------------------------------------------------------
from transformers.masking_utils import create_masks_for_generate


def enable_noncausal(wrapper, sliding=False):
    cfg = wrapper.config.get_text_config() if hasattr(wrapper.config, "get_text_config") else wrapper.config
    assert getattr(cfg, "_attn_implementation", "sdpa") == "sdpa", "noncausal can sdpa"
    W = getattr(cfg, "sliding_window", None)
    if sliding and not W:
        raise ValueError("noncausal sliding: model khong co sliding_window")

    def pre(module, args, kwargs):
        if args or kwargs.get("past_key_values") is not None or kwargs.get("use_cache"):
            raise ValueError("noncausal: chi goi keyword, khong KV cache")
        ids, emb = kwargs.get("input_ids"), kwargs.get("inputs_embeds")
        B, L = (ids.shape if ids is not None else emb.shape[:2])
        dev = ids.device if ids is not None else emb.device
        pad = kwargs.get("attention_mask")
        if pad is None:
            pad = torch.ones((B, L), dtype=torch.long, device=dev)
        if isinstance(pad, dict):
            return args, kwargs
        like = torch.empty((B, L, 1), dtype=next(module.parameters()).dtype, device=dev)
        pos = kwargs.get("position_ids")
        if pos is None:
            pos = torch.arange(L, device=dev).unsqueeze(0).expand(B, -1)
        m = create_masks_for_generate(config=cfg, inputs_embeds=like, attention_mask=pad, past_key_values=None, position_ids=pos)
        if not isinstance(m, dict):
            m = {"full_attention": m}
        m = dict(m)
        m["full_attention"] = pad[:, None, None, :].bool()
        if sliding:
            i = torch.arange(L, device=dev)
            # | eye: hang cua token pad xa hon W sau token that cuoi se rong -> sdpa ra NaN, 0*NaN lan sang token that
            m["sliding_attention"] = (((i[:, None] - i[None, :]).abs() < W) & pad[:, None, None, :].bool()) | torch.eye(L, dtype=torch.bool, device=dev)
        kwargs["attention_mask"] = m
        return args, kwargs

    wrapper.register_forward_pre_hook(pre, with_kwargs=True)


# ---- nap model + API ------------------------------------------------------------------------------------------
import json as _json, os as _os, re as _re

_VI = _re.compile(r"[ăâđêôơưạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹ]", _re.I)
SUPPORTED = {"text", "list", "number", "date"}


def detect_language(text):
    """'vi' neu co dau tieng Viet; 'en' neu chu cai gan nhu toan ASCII; con lai 'default' (nguong null cao hon:
    do tren XQuAD 7 ngon ngu khac, nguong 0.4 tra null nham 3-8%, 0.8 con 0.3-3%)."""
    if len(_VI.findall(text)) > 3:
        return "vi"
    letters = [c for c in text if c.isalpha()]
    if letters and sum(c.isascii() for c in letters) / len(letters) > 0.98:
        return "en"
    return "default"


class GemmaExtract:
    """Typed field extraction in one forward pass, no text generation.

        from gemma_extract import GemmaExtract
        m = GemmaExtract.from_pretrained("beyoru/gemmaExtract")
        m.extract("Invoice No: INV-0042 ... Total: 1,200.00",
                  {"invoice_no": {"type": "text", "instructions": "Invoice number"},
                   "total": {"type": "number", "instructions": "Total amount due"}})
    """

    def __init__(self, path, device="cuda", dtype=torch.bfloat16):
        from transformers import AutoModelForCausalLM, AutoTokenizer
        self.cfg = _json.load(open(_os.path.join(path, "extract_config.json")))
        self.tok = AutoTokenizer.from_pretrained(path)
        top = AutoModelForCausalLM.from_pretrained(path, dtype=dtype, attn_implementation="sdpa").to(device)
        self.enc = top.model
        enable_noncausal(self.enc, sliding=False)
        self.enc.eval()
        h = self.cfg["head"]
        self.head = S1Head(self.cfg["hidden"], width=h["width"], routing=h["routing"], layers=h["layers"], heads=h["heads"], ff=h["ff"]).to(device)
        from safetensors.torch import load_file
        self.head.load_state_dict(load_file(_os.path.join(path, "head.safetensors")))
        self.head.eval()
        self.device, self.dtype = device, dtype

    @classmethod
    def from_pretrained(cls, repo_or_path, **kw):
        if not _os.path.isdir(repo_or_path):
            from huggingface_hub import snapshot_download
            repo_or_path = snapshot_download(repo_or_path)
        return cls(repo_or_path, **kw)

    def _passes(self, fields):
        names, m = list(fields), self.cfg["max_fields_per_pass"]
        return [{n: fields[n] for n in names[i:i + m]} for i in range(0, len(names), m)]

    @torch.no_grad()
    def extract(self, state, fields, null_threshold=None, number_style=None):
        """state: document text. fields: {name: {"type": "text|list|number|date", "instructions": "...", "null_threshold": float}}.
        null_threshold: default per language (auto-detected). number_style: "en" (1,234.5) / "vi" (1.234,5) / None = auto.
        Returns {"answers": {name: {value, guess, null, p_null, confidence, spans, ...}}, "usage": {...}}."""
        for k, v in fields.items():
            if v.get("type") not in SUPPORTED:
                raise ValueError("field %s: type must be one of %s" % (k, sorted(SUPPORTED)))
        lang = detect_language(state)
        thr_all = self.cfg["null_threshold"].get(lang, self.cfg["null_threshold"]["default"]) if null_threshold is None else null_threshold
        en = {"en": True, "vi": False}.get(number_style)
        answers, usage = {}, {"state_tokens": 0, "passes": 0}
        queue = self._passes({k: {"type": v["type"], "instructions": v.get("instructions") or k} for k, v in fields.items()})
        for part in queue:
            try:
                e = encode(self.tok, {"state": state, "fields": part}, self.cfg["max_length"])
            except ValueError:
                if len(part) > 1:
                    queue += [{k: v} for k, v in part.items()]
                    continue
                raise
            with torch.autocast("cuda" if "cuda" in str(self.device) else "cpu", dtype=self.dtype):
                h = self.enc(input_ids=torch.tensor([e["ids"]], device=self.device)).last_hidden_state[0]
                out = self.head(h, e)
            for k, r in decode(out, e, state, 0.5, en=en).items():
                t = fields[k].get("null_threshold", thr_all)
                r["null"] = bool(r["p_null"] > t) or (isinstance(r["guess"], list) and not r["guess"])
                r["spans"] = [{"start": a, "end": b, "text": state[a:b]} for a, b in r.get("spans", [])]
                r["value"] = None if r["null"] else r.get("value", r["guess"])
                answers[k] = r
            usage["state_tokens"] = max(usage["state_tokens"], e["state"][1] - e["state"][0])
            usage["passes"] += 1
        usage["truncated"] = usage["state_tokens"] < len(self.tok(state, add_special_tokens=False)["input_ids"])
        return {"answers": answers, "usage": usage, "language": lang}