Upload app.py with huggingface_hub
Browse files
app.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Dialingua API — always-on translation service, small enough for a free tier.
|
| 4 |
+
|
| 5 |
+
WHY ONNX AND NOT PYTORCH
|
| 6 |
+
------------------------
|
| 7 |
+
Measured on the real model:
|
| 8 |
+
|
| 9 |
+
python + onnxruntime + numpy 47 MB
|
| 10 |
+
+ transformers (WITH torch) 390 MB <- torch alone is +343 MB
|
| 11 |
+
+ tokenizer 418 MB
|
| 12 |
+
+ encoder session 479 MB
|
| 13 |
+
+ decoder session 579 MB
|
| 14 |
+
+ decoder-with-past 672 MB
|
| 15 |
+
|
| 16 |
+
672 MB does not fit a 512 MB free tier. But torch is never used here — ONNX
|
| 17 |
+
Runtime does the inference and the tokenizer is pure sentencepiece. Leaving it
|
| 18 |
+
out of requirements.txt, and skipping the KV-cache session, brings this to
|
| 19 |
+
roughly 330 MB resident.
|
| 20 |
+
|
| 21 |
+
That is why requirements.txt pins `transformers` with NO torch. If torch ever
|
| 22 |
+
sneaks back in as a transitive dependency, this service will OOM on boot.
|
| 23 |
+
|
| 24 |
+
Generation is greedy and hand-rolled against the two ONNX sessions, because
|
| 25 |
+
optimum's generate() imports torch and would undo the whole point. Verses are
|
| 26 |
+
short, so the quadratic cost of re-running the decoder each step is cheap.
|
| 27 |
+
|
| 28 |
+
GET /health
|
| 29 |
+
POST /translate {"text": "..."}
|
| 30 |
+
POST /detect {"text": "..."}
|
| 31 |
+
"""
|
| 32 |
+
import os
|
| 33 |
+
import pathlib
|
| 34 |
+
import time
|
| 35 |
+
|
| 36 |
+
import numpy as np
|
| 37 |
+
import onnxruntime as ort
|
| 38 |
+
from fastapi import FastAPI, HTTPException
|
| 39 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 40 |
+
from pydantic import BaseModel, Field
|
| 41 |
+
from transformers import AutoTokenizer
|
| 42 |
+
|
| 43 |
+
MODEL_ID = os.environ.get("BKV_MODEL", "Lordkiki/dialingua-bkv2eng-web")
|
| 44 |
+
MAX_NEW = int(os.environ.get("BKV_MAX_TOKENS", "160"))
|
| 45 |
+
ORIGINS = [o.strip() for o in os.environ.get("ALLOWED_ORIGINS", "*").split(",")]
|
| 46 |
+
|
| 47 |
+
_state = {}
|
| 48 |
+
app = FastAPI(title="Dialingua API", version="1.0.0")
|
| 49 |
+
app.add_middleware(CORSMiddleware, allow_origins=ORIGINS,
|
| 50 |
+
allow_credentials=False, allow_methods=["*"],
|
| 51 |
+
allow_headers=["*"])
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class TextIn(BaseModel):
|
| 55 |
+
text: str = Field(min_length=1, max_length=2000)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _session(path: str) -> ort.InferenceSession:
|
| 59 |
+
opts = ort.SessionOptions()
|
| 60 |
+
# One thread: free tiers give a fraction of a core, and extra threads cost
|
| 61 |
+
# memory without buying speed.
|
| 62 |
+
opts.intra_op_num_threads = 1
|
| 63 |
+
opts.inter_op_num_threads = 1
|
| 64 |
+
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
| 65 |
+
return ort.InferenceSession(path, opts, providers=["CPUExecutionProvider"])
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@app.on_event("startup")
|
| 69 |
+
def load() -> None:
|
| 70 |
+
from huggingface_hub import snapshot_download
|
| 71 |
+
|
| 72 |
+
t0 = time.perf_counter()
|
| 73 |
+
# Only the two graphs greedy decoding needs. Pulling the KV-cache decoder
|
| 74 |
+
# too would add ~93 MB of resident memory for no benefit here.
|
| 75 |
+
local = snapshot_download(
|
| 76 |
+
MODEL_ID,
|
| 77 |
+
allow_patterns=["*.json", "*.spm", "*.model",
|
| 78 |
+
"onnx/encoder_model_quantized.onnx",
|
| 79 |
+
"onnx/decoder_model_quantized.onnx"],
|
| 80 |
+
)
|
| 81 |
+
root = pathlib.Path(local)
|
| 82 |
+
|
| 83 |
+
_state["tok"] = AutoTokenizer.from_pretrained(local)
|
| 84 |
+
_state["enc"] = _session(str(root / "onnx" / "encoder_model_quantized.onnx"))
|
| 85 |
+
_state["dec"] = _session(str(root / "onnx" / "decoder_model_quantized.onnx"))
|
| 86 |
+
_state["dec_inputs"] = {i.name for i in _state["dec"].get_inputs()}
|
| 87 |
+
print(f"ready in {time.perf_counter() - t0:.0f}s ({MODEL_ID})")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.get("/health")
|
| 91 |
+
def health():
|
| 92 |
+
return {"status": "ok", "model": MODEL_ID, "loaded": "enc" in _state}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@app.post("/translate")
|
| 96 |
+
def translate(body: TextIn):
|
| 97 |
+
if "enc" not in _state:
|
| 98 |
+
raise HTTPException(503, "model still loading")
|
| 99 |
+
|
| 100 |
+
tok, enc, dec = _state["tok"], _state["enc"], _state["dec"]
|
| 101 |
+
ids = tok(body.text, return_tensors="np", truncation=True, max_length=256)
|
| 102 |
+
input_ids = ids["input_ids"].astype(np.int64)
|
| 103 |
+
attention = ids["attention_mask"].astype(np.int64)
|
| 104 |
+
|
| 105 |
+
hidden = enc.run(None, {"input_ids": input_ids,
|
| 106 |
+
"attention_mask": attention})[0]
|
| 107 |
+
|
| 108 |
+
# Marian starts decoding from pad_token_id.
|
| 109 |
+
start = tok.pad_token_id if tok.pad_token_id is not None else 0
|
| 110 |
+
eos = tok.eos_token_id
|
| 111 |
+
out_ids = [start]
|
| 112 |
+
|
| 113 |
+
for _ in range(MAX_NEW):
|
| 114 |
+
feed = {"encoder_attention_mask": attention,
|
| 115 |
+
"encoder_hidden_states": hidden,
|
| 116 |
+
"input_ids": np.array([out_ids], dtype=np.int64)}
|
| 117 |
+
feed = {k: v for k, v in feed.items() if k in _state["dec_inputs"]}
|
| 118 |
+
logits = dec.run(None, feed)[0]
|
| 119 |
+
nxt = int(np.argmax(logits[0, -1]))
|
| 120 |
+
if nxt == eos:
|
| 121 |
+
break
|
| 122 |
+
out_ids.append(nxt)
|
| 123 |
+
|
| 124 |
+
text = tok.decode(out_ids[1:], skip_special_tokens=True).strip()
|
| 125 |
+
return {
|
| 126 |
+
"translation": text,
|
| 127 |
+
"direction": "bkv2eng",
|
| 128 |
+
"caveat": "Trained on ~1,100 scripture verse pairs. Formal register is "
|
| 129 |
+
"reasonable; everyday speech is not. Have a speaker check "
|
| 130 |
+
"anything that matters.",
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@app.post("/detect")
|
| 135 |
+
def detect(body: TextIn):
|
| 136 |
+
"""Bekwarra detection, no model required.
|
| 137 |
+
|
| 138 |
+
Keys on the phonemic apostrophe (k'uchu, ng'amin — a letter here, not
|
| 139 |
+
punctuation), the kp/gb clusters common to Niger-Congo, and the high rate
|
| 140 |
+
of vowel-initial words. English contractions are subtracted so don't/it's
|
| 141 |
+
do not read as Bekwarra.
|
| 142 |
+
"""
|
| 143 |
+
import re
|
| 144 |
+
|
| 145 |
+
text = body.text
|
| 146 |
+
words = re.findall(r"[^\W\d_]+", text.lower(), flags=re.UNICODE)
|
| 147 |
+
if not words:
|
| 148 |
+
return {"code": None, "name": "—", "confidence": 0.0, "reason": "no words"}
|
| 149 |
+
|
| 150 |
+
apo = len(re.findall(
|
| 151 |
+
r"\b(?:ng|kp|gb|ch|sh|[bcdfghjklmnprstvwyz])'\s?[aeiou]", text, re.I))
|
| 152 |
+
contractions = len(re.findall(r"\b\w+'(?:s|t|re|ve|ll|d|m)\b", text, re.I))
|
| 153 |
+
apo = max(0, apo - contractions)
|
| 154 |
+
dig = len(re.findall(r"kp|gb", text, re.I))
|
| 155 |
+
vowel = sum(1 for w in words if w[:1] in "aeiou")
|
| 156 |
+
|
| 157 |
+
score = (min(apo / max(len(words) * .18, 1), 1) * .5
|
| 158 |
+
+ min(dig / max(len(words) * .10, 1), 1) * .2
|
| 159 |
+
+ min(vowel / len(words) / .4, 1) * .3)
|
| 160 |
+
|
| 161 |
+
if score > .42:
|
| 162 |
+
return {"code": "bkv", "name": "Bekwarra",
|
| 163 |
+
"confidence": round(min(.5 + score * .5, .99), 2),
|
| 164 |
+
"reason": f"{apo} phonemic apostrophes, {dig} kp/gb clusters"}
|
| 165 |
+
return {"code": None, "name": "Not Bekwarra",
|
| 166 |
+
"confidence": round(1 - score, 2),
|
| 167 |
+
"reason": "no Bekwarra orthographic signal"}
|