File size: 6,310 Bytes
b54d4ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""
Dialingua API — always-on translation service, small enough for a free tier.

WHY ONNX AND NOT PYTORCH
------------------------
Measured on the real model:

    python + onnxruntime + numpy      47 MB
    + transformers (WITH torch)      390 MB   <- torch alone is +343 MB
    + tokenizer                      418 MB
    + encoder session                479 MB
    + decoder session                579 MB
    + decoder-with-past              672 MB

672 MB does not fit a 512 MB free tier. But torch is never used here — ONNX
Runtime does the inference and the tokenizer is pure sentencepiece. Leaving it
out of requirements.txt, and skipping the KV-cache session, brings this to
roughly 330 MB resident.

That is why requirements.txt pins `transformers` with NO torch. If torch ever
sneaks back in as a transitive dependency, this service will OOM on boot.

Generation is greedy and hand-rolled against the two ONNX sessions, because
optimum's generate() imports torch and would undo the whole point. Verses are
short, so the quadratic cost of re-running the decoder each step is cheap.

    GET  /health
    POST /translate   {"text": "..."}
    POST /detect      {"text": "..."}
"""
import os
import pathlib
import time

import numpy as np
import onnxruntime as ort
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from transformers import AutoTokenizer

MODEL_ID = os.environ.get("BKV_MODEL", "Lordkiki/dialingua-bkv2eng-web")
MAX_NEW = int(os.environ.get("BKV_MAX_TOKENS", "160"))
ORIGINS = [o.strip() for o in os.environ.get("ALLOWED_ORIGINS", "*").split(",")]

_state = {}
app = FastAPI(title="Dialingua API", version="1.0.0")
app.add_middleware(CORSMiddleware, allow_origins=ORIGINS,
                   allow_credentials=False, allow_methods=["*"],
                   allow_headers=["*"])


class TextIn(BaseModel):
    text: str = Field(min_length=1, max_length=2000)


def _session(path: str) -> ort.InferenceSession:
    opts = ort.SessionOptions()
    # One thread: free tiers give a fraction of a core, and extra threads cost
    # memory without buying speed.
    opts.intra_op_num_threads = 1
    opts.inter_op_num_threads = 1
    opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
    return ort.InferenceSession(path, opts, providers=["CPUExecutionProvider"])


@app.on_event("startup")
def load() -> None:
    from huggingface_hub import snapshot_download

    t0 = time.perf_counter()
    # Only the two graphs greedy decoding needs. Pulling the KV-cache decoder
    # too would add ~93 MB of resident memory for no benefit here.
    local = snapshot_download(
        MODEL_ID,
        allow_patterns=["*.json", "*.spm", "*.model",
                        "onnx/encoder_model_quantized.onnx",
                        "onnx/decoder_model_quantized.onnx"],
    )
    root = pathlib.Path(local)

    _state["tok"] = AutoTokenizer.from_pretrained(local)
    _state["enc"] = _session(str(root / "onnx" / "encoder_model_quantized.onnx"))
    _state["dec"] = _session(str(root / "onnx" / "decoder_model_quantized.onnx"))
    _state["dec_inputs"] = {i.name for i in _state["dec"].get_inputs()}
    print(f"ready in {time.perf_counter() - t0:.0f}s ({MODEL_ID})")


@app.get("/health")
def health():
    return {"status": "ok", "model": MODEL_ID, "loaded": "enc" in _state}


@app.post("/translate")
def translate(body: TextIn):
    if "enc" not in _state:
        raise HTTPException(503, "model still loading")

    tok, enc, dec = _state["tok"], _state["enc"], _state["dec"]
    ids = tok(body.text, return_tensors="np", truncation=True, max_length=256)
    input_ids = ids["input_ids"].astype(np.int64)
    attention = ids["attention_mask"].astype(np.int64)

    hidden = enc.run(None, {"input_ids": input_ids,
                            "attention_mask": attention})[0]

    # Marian starts decoding from pad_token_id.
    start = tok.pad_token_id if tok.pad_token_id is not None else 0
    eos = tok.eos_token_id
    out_ids = [start]

    for _ in range(MAX_NEW):
        feed = {"encoder_attention_mask": attention,
                "encoder_hidden_states": hidden,
                "input_ids": np.array([out_ids], dtype=np.int64)}
        feed = {k: v for k, v in feed.items() if k in _state["dec_inputs"]}
        logits = dec.run(None, feed)[0]
        nxt = int(np.argmax(logits[0, -1]))
        if nxt == eos:
            break
        out_ids.append(nxt)

    text = tok.decode(out_ids[1:], skip_special_tokens=True).strip()
    return {
        "translation": text,
        "direction": "bkv2eng",
        "caveat": "Trained on ~1,100 scripture verse pairs. Formal register is "
                  "reasonable; everyday speech is not. Have a speaker check "
                  "anything that matters.",
    }


@app.post("/detect")
def detect(body: TextIn):
    """Bekwarra detection, no model required.

    Keys on the phonemic apostrophe (k'uchu, ng'amin — a letter here, not
    punctuation), the kp/gb clusters common to Niger-Congo, and the high rate
    of vowel-initial words. English contractions are subtracted so don't/it's
    do not read as Bekwarra.
    """
    import re

    text = body.text
    words = re.findall(r"[^\W\d_]+", text.lower(), flags=re.UNICODE)
    if not words:
        return {"code": None, "name": "—", "confidence": 0.0, "reason": "no words"}

    apo = len(re.findall(
        r"\b(?:ng|kp|gb|ch|sh|[bcdfghjklmnprstvwyz])'\s?[aeiou]", text, re.I))
    contractions = len(re.findall(r"\b\w+'(?:s|t|re|ve|ll|d|m)\b", text, re.I))
    apo = max(0, apo - contractions)
    dig = len(re.findall(r"kp|gb", text, re.I))
    vowel = sum(1 for w in words if w[:1] in "aeiou")

    score = (min(apo / max(len(words) * .18, 1), 1) * .5
             + min(dig / max(len(words) * .10, 1), 1) * .2
             + min(vowel / len(words) / .4, 1) * .3)

    if score > .42:
        return {"code": "bkv", "name": "Bekwarra",
                "confidence": round(min(.5 + score * .5, .99), 2),
                "reason": f"{apo} phonemic apostrophes, {dig} kp/gb clusters"}
    return {"code": None, "name": "Not Bekwarra",
            "confidence": round(1 - score, 2),
            "reason": "no Bekwarra orthographic signal"}