Lordkiki commited on
Commit
b54d4ca
·
verified ·
1 Parent(s): e60494d

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +167 -0
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"}