Shaankar39 commited on
Commit
eb82ac3
·
verified ·
1 Parent(s): 28a0e7a

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +67 -176
app.py CHANGED
@@ -1,18 +1,18 @@
1
- """Vaaani Flagship — CPU inference API for the free Hugging Face Space.
2
-
3
- Loads the Qwen2.5-3B base GGUF + the curriculum LoRA (downloaded from an HF model
4
- repo at first request) and serves three shapes:
5
- GET / health (returns immediately so the Space boots fast)
6
- POST /chat {messages:[...]} -> {reply}
7
- POST /chat/stream Server-Sent Events, token-by-token (perceived speed)
8
- POST /v1/chat/completions OpenAI-compatible drop-in for the existing gateway
9
-
10
- Design notes for the free tier (2 vCPU / 16 GB):
11
- * Model loads LAZILY on the first chat call, NOT at boot — so the health check
12
- passes instantly and the Space goes "Running" without a startup timeout.
13
- * n_threads = 2 (both free vCPUs); n_ctx kept small (curriculum prompts are short).
14
- * Default GGUF is Q4_K_M (~2 GB) faster on CPU than Q8 at ~no quality loss for
15
- this rigidly-scripted task. Override with env vars to serve Q8 on a paid Space.
16
  """
17
  import os
18
  import re
@@ -20,36 +20,40 @@ import json
20
  from typing import List, Optional
21
 
22
  from fastapi import FastAPI
23
- from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
24
  from pydantic import BaseModel
25
  from huggingface_hub import hf_hub_download
26
 
27
- HERE = os.path.dirname(os.path.abspath(__file__))
28
-
29
- # ── config (all overridable via Space "Variables" — no secrets in code) ──────
30
- REPO = os.environ.get("VAAANI_MODEL_REPO", "Shaankar39/vaaani-flagship-gguf")
31
- BASE_FILE = os.environ.get("VAAANI_BASE_FILE", "vaaani-base-q4_k_m.gguf")
32
- LORA_FILE = os.environ.get("VAAANI_LORA_FILE", "vaaani-flagship-lora-f16.gguf")
33
  N_THREADS = int(os.environ.get("N_THREADS", "2"))
34
  N_CTX = int(os.environ.get("N_CTX", "2048"))
35
  N_BATCH = int(os.environ.get("N_BATCH", "256"))
36
  MAX_TOK = int(os.environ.get("MAX_TOKENS", "512"))
37
 
38
- app = FastAPI(title="Vaaani Flagship (CPU)")
39
- _llm = None # lazy singleton
40
 
41
 
42
- def get_llm():
43
- global _llm
44
- if _llm is None:
45
  from llama_cpp import Llama
46
- base = hf_hub_download(REPO, BASE_FILE)
47
- lora = hf_hub_download(REPO, LORA_FILE)
48
- _llm = Llama(
49
- model_path=base, lora_path=lora,
50
- n_ctx=N_CTX, n_threads=N_THREADS, n_batch=N_BATCH, verbose=False,
51
- )
52
- return _llm
 
 
 
 
53
 
54
 
55
  class Msg(BaseModel):
@@ -59,6 +63,7 @@ class Msg(BaseModel):
59
 
60
  class ChatReq(BaseModel):
61
  messages: List[Msg]
 
62
  temperature: float = 0.2
63
  max_tokens: Optional[int] = None
64
  stream: bool = False
@@ -69,10 +74,6 @@ def _msgs(req: ChatReq):
69
 
70
 
71
  # ── "no symbol before Grade 5" firewall (deterministic, serving-layer) ───────
72
- # The model gets this right ~6/7 of the time, but a hard rule needs a hard guarantee.
73
- # For any sub-G5 lesson we strip slash-phoneme notation (/b/, /th/) and IPA chars from
74
- # the OUTPUT — the same scrub sound_lessons.strip_symbols() applies to the training data.
75
- # G5 is allowed to reveal symbols, so the firewall never touches it.
76
  _SLASH_PHONEME = re.compile(r"/[A-Za-zθðŋʃʒʧʤ]+/")
77
  _IPA_CHARS = re.compile(r"[θðŋʃʒʧʤæɪʊəɔɑːʰˈˌ]")
78
  _GRADE_RE = re.compile(r"Grade\s+(\d+)")
@@ -99,29 +100,27 @@ def _firewall(text: str) -> str:
99
 
100
 
101
  class StreamScrubber:
102
- """Streaming-safe version of the firewall for sub-G5. Lets G1–G4 stream
103
- token-by-token (so it feels alive at ~2.5 tok/s) while still removing
104
- /phoneme/ slash-notation and IPA chars across token boundaries. When a '/'
105
- arrives we hold text until we can tell a phoneme (/b/) from a real slash
106
- (on/off); a run longer than a phoneme is flushed as-is."""
107
 
108
  def __init__(self):
109
- self.hold = "" # buffered text from an unresolved '/'
110
 
111
  def feed(self, s: str) -> str:
112
  out = []
113
  for ch in s:
114
  if self.hold:
115
- if ch == "/": # closing slash -> it was a phoneme
116
  out.append("that sound")
117
  self.hold = ""
118
  elif (ch.isalpha() or ch in "θðŋʃʒʧʤ") and len(self.hold) <= 6:
119
- self.hold += ch # still possibly a phoneme
120
- else: # not a phoneme -> flush held + this char
121
  out.append(self.hold + ch)
122
  self.hold = ""
123
  elif ch == "/":
124
- self.hold = "/" # start holding
125
  else:
126
  out.append(ch)
127
  return _IPA_CHARS.sub("", "".join(out))
@@ -131,123 +130,16 @@ class StreamScrubber:
131
  return _IPA_CHARS.sub("", rest)
132
 
133
 
134
- @app.get("/health")
135
  def health():
136
- return {"status": "ok", "model": BASE_FILE, "lora": LORA_FILE,
137
- "loaded": _llm is not None}
138
-
139
-
140
- @app.get("/lessons")
141
- def lessons():
142
- with open(os.path.join(HERE, "lessons.json"), encoding="utf-8") as f:
143
- return JSONResponse(json.load(f))
144
-
145
-
146
- @app.get("/", response_class=HTMLResponse)
147
- def home():
148
- return UI_HTML
149
-
150
-
151
- UI_HTML = """<!doctype html>
152
- <html lang="en"><head>
153
- <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
154
- <title>Vaaani — learn English by discovery</title>
155
- <style>
156
- :root{--ink:#1a2238;--bg:#f6f7fb;--card:#fff;--accent:#4f46e5;--accent2:#0e9f6e;--muted:#6b7280}
157
- *{box-sizing:border-box}
158
- body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink)}
159
- header{padding:20px 24px;background:linear-gradient(120deg,#4f46e5,#0e9f6e);color:#fff}
160
- header h1{margin:0;font-size:22px;letter-spacing:.3px}
161
- header p{margin:4px 0 0;opacity:.9;font-size:13px}
162
- main{max-width:820px;margin:0 auto;padding:18px}
163
- .lessons{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}
164
- .lessons button{border:1px solid #d7dae5;background:var(--card);color:var(--ink);
165
- padding:8px 12px;border-radius:999px;cursor:pointer;font-size:13px}
166
- .lessons button:hover{border-color:var(--accent);color:var(--accent)}
167
- #chat{background:var(--card);border:1px solid #e6e8f0;border-radius:14px;min-height:320px;
168
- padding:14px;overflow-y:auto;max-height:60vh}
169
- .bubble{padding:10px 13px;border-radius:12px;margin:8px 0;max-width:88%;white-space:pre-wrap;line-height:1.5}
170
- .user{background:#eef0ff;margin-left:auto;color:#312e81}
171
- .bot{background:#f0faf5;border:1px solid #d6f0e3}
172
- .meta{font-size:11px;color:var(--muted);margin:2px 4px}
173
- form{display:flex;gap:8px;margin-top:12px}
174
- input{flex:1;padding:11px 13px;border:1px solid #d7dae5;border-radius:10px;font-size:15px}
175
- .send{background:var(--accent);color:#fff;border:none;padding:0 18px;border-radius:10px;cursor:pointer;font-size:15px}
176
- .send:disabled{opacity:.5;cursor:default}
177
- .hint{font-size:12px;color:var(--muted);margin-top:8px}
178
- </style></head>
179
- <body>
180
- <header>
181
- <h1>🌉 Vaaani</h1>
182
- <p>English sounds &amp; word-roots, taught by discovery — runs fully on CPU.</p>
183
- </header>
184
- <main>
185
- <div class="lessons" id="lessons"></div>
186
- <div id="chat"></div>
187
- <form id="f"><input id="q" placeholder="Pick a lesson above, or type to the child's tutor…" autocomplete="off"><button class="send" id="send">Send</button></form>
188
- <div class="hint">Free CPU tier: the first reply is slow (model loads once); after that ~a minute per lesson, streaming as it thinks.</div>
189
- </main>
190
- <script>
191
- let messages = []; // full chat incl. system
192
- const chat = document.getElementById('chat');
193
- const q = document.getElementById('q');
194
- const sendBtn = document.getElementById('send');
195
-
196
- function add(role, text){
197
- const b = document.createElement('div');
198
- b.className = 'bubble ' + (role === 'user' ? 'user' : 'bot');
199
- b.textContent = text;
200
- chat.appendChild(b); chat.scrollTop = chat.scrollHeight; return b;
201
- }
202
-
203
- async function stream(){
204
- sendBtn.disabled = true;
205
- const bot = add('bot', '…');
206
- let acc = '';
207
- try{
208
- const res = await fetch('/chat/stream', {method:'POST',headers:{'Content-Type':'application/json'},
209
- body: JSON.stringify({messages, max_tokens: 220})});
210
- const reader = res.body.getReader(); const dec = new TextDecoder(); let buf='';
211
- while(true){
212
- const {value, done} = await reader.read(); if(done) break;
213
- buf += dec.decode(value, {stream:true});
214
- let i;
215
- while((i = buf.indexOf('\\n\\n')) >= 0){
216
- const line = buf.slice(0, i).trim(); buf = buf.slice(i+2);
217
- if(!line.startsWith('data:')) continue;
218
- const payload = line.slice(5).trim();
219
- if(payload === '[DONE]') continue;
220
- try{ const d = JSON.parse(payload).delta; if(d){ acc += d; bot.textContent = acc; chat.scrollTop = chat.scrollHeight; } }catch(e){}
221
- }
222
- }
223
- }catch(e){ bot.textContent = 'Error: ' + e; }
224
- if(acc) messages.push({role:'assistant', content: acc});
225
- sendBtn.disabled = false; q.focus();
226
- }
227
-
228
- document.getElementById('f').addEventListener('submit', e=>{
229
- e.preventDefault(); const t = q.value.trim(); if(!t) return;
230
- if(messages.length === 0){ messages.push({role:'system', content:'You are Vaaani, a warm, encouraging tutor for a child learning English. Teach by asking and playing. Tiny warm sentences, emoji.'}); }
231
- messages.push({role:'user', content:t}); add('user', t); q.value=''; stream();
232
- });
233
-
234
- fetch('/lessons').then(r=>r.json()).then(ls=>{
235
- const box = document.getElementById('lessons');
236
- ls.forEach(l=>{
237
- const btn = document.createElement('button'); btn.textContent = l.label;
238
- btn.onclick = ()=>{ chat.innerHTML=''; messages=[{role:'system',content:l.system},{role:'user',content:l.user}];
239
- add('user', l.user); stream(); };
240
- box.appendChild(btn);
241
- });
242
- });
243
- </script>
244
- </body></html>"""
245
 
246
 
247
  @app.post("/chat")
248
  def chat(req: ChatReq):
249
  sub_g5 = _is_sub_g5(_system_text(_msgs(req)))
250
- out = get_llm().create_chat_completion(
251
  messages=_msgs(req), temperature=req.temperature,
252
  max_tokens=req.max_tokens or MAX_TOK, stream=False)
253
  reply = out["choices"][0]["message"]["content"]
@@ -258,11 +150,8 @@ def chat(req: ChatReq):
258
 
259
  @app.post("/chat/stream")
260
  def chat_stream(req: ChatReq):
261
- """SSE: emit {'delta': '<token>'} chunks, then [DONE]. The child sees words appear
262
- as they generate. For sub-G5 the firewall must see the full text, so we generate
263
- fully then emit the scrubbed result (turns are short — still fast); G5 streams live."""
264
  sub_g5 = _is_sub_g5(_system_text(_msgs(req)))
265
- llm = get_llm()
266
 
267
  def gen():
268
  scrub = StreamScrubber() if sub_g5 else None
@@ -285,23 +174,25 @@ def chat_stream(req: ChatReq):
285
 
286
  @app.post("/v1/chat/completions")
287
  def openai_compat(req: ChatReq):
288
- """OpenAI-shaped, so the existing gateway can point here unchanged."""
 
289
  sub_g5 = _is_sub_g5(_system_text(_msgs(req)))
290
- llm = get_llm()
291
  if req.stream:
292
  def gen():
293
- if sub_g5:
294
- out = llm.create_chat_completion(
295
  messages=_msgs(req), temperature=req.temperature,
296
- max_tokens=req.max_tokens or MAX_TOK, stream=False)
297
- out["choices"][0]["message"]["content"] = _firewall(
298
- out["choices"][0]["message"]["content"])
299
- yield f"data: {json.dumps(out)}\n\n"
300
- else:
301
- for chunk in llm.create_chat_completion(
302
- messages=_msgs(req), temperature=req.temperature,
303
- max_tokens=req.max_tokens or MAX_TOK, stream=True):
304
- yield f"data: {json.dumps(chunk)}\n\n"
 
305
  yield "data: [DONE]\n\n"
306
  return StreamingResponse(gen(), media_type="text/event-stream")
307
  out = llm.create_chat_completion(
 
1
+ """Vaaani engine — CPU, OpenAI-compatible inference for the Vaaani RAG backend.
2
+
3
+ This is a HEADLESS model engine (no standalone UI). It is the drop-in for the RAG
4
+ product's VAAANI_LLM_BASE_URL, and it routes by the request's `model` field:
5
+
6
+ vaaani-base -> base Qwen2.5-3B GGUF, NO adapter (general RAG / chat / ingest)
7
+ vaaani-flagship -> base + curriculum LoRA (the Root-Bridge tutor)
8
+
9
+ The "no symbol before Grade 5" firewall is applied to sub-G5 tutor output only
10
+ (detected from "Grade N" in the system prompt); general RAG and G5 are untouched.
11
+
12
+ Endpoints:
13
+ GET / health JSON
14
+ POST /v1/chat/completions OpenAI-compatible, model-routed (the RAG calls this)
15
+ POST /chat, /chat/stream simple test helpers
16
  """
17
  import os
18
  import re
 
20
  from typing import List, Optional
21
 
22
  from fastapi import FastAPI
23
+ from fastapi.responses import JSONResponse, StreamingResponse
24
  from pydantic import BaseModel
25
  from huggingface_hub import hf_hub_download
26
 
27
+ # ── config (override via Space Variables; secrets like HF_TOKEN stay secrets) ──
28
+ REPO = os.environ.get("VAAANI_MODEL_REPO", "Shaankar39/vaaani-flagship-gguf")
29
+ BASE_FILE = os.environ.get("VAAANI_BASE_FILE", "vaaani-base-q4_k_m.gguf")
30
+ LORA_FILE = os.environ.get("VAAANI_LORA_FILE", "vaaani-flagship-lora-f16.gguf")
31
+ BASE_NAME = os.environ.get("VAAANI_LLM_MODEL", "vaaani-base")
32
+ FLAGSHIP_NAME = os.environ.get("VAAANI_FLAGSHIP_MODEL", "vaaani-flagship")
33
  N_THREADS = int(os.environ.get("N_THREADS", "2"))
34
  N_CTX = int(os.environ.get("N_CTX", "2048"))
35
  N_BATCH = int(os.environ.get("N_BATCH", "256"))
36
  MAX_TOK = int(os.environ.get("MAX_TOKENS", "512"))
37
 
38
+ app = FastAPI(title="Vaaani Engine (CPU)")
39
+ _llms = {} # "base" | "flagship" -> Llama (lazy)
40
 
41
 
42
+ def get_llm(use_lora: bool):
43
+ key = "flagship" if use_lora else "base"
44
+ if key not in _llms:
45
  from llama_cpp import Llama
46
+ kw = dict(model_path=hf_hub_download(REPO, BASE_FILE),
47
+ n_ctx=N_CTX, n_threads=N_THREADS, n_batch=N_BATCH, verbose=False)
48
+ if use_lora:
49
+ kw["lora_path"] = hf_hub_download(REPO, LORA_FILE)
50
+ _llms[key] = Llama(**kw)
51
+ return _llms[key]
52
+
53
+
54
+ def _use_lora(model_name: Optional[str]) -> bool:
55
+ """Apply the curriculum adapter only when the flagship model is requested."""
56
+ return (model_name or "").strip() == FLAGSHIP_NAME
57
 
58
 
59
  class Msg(BaseModel):
 
63
 
64
  class ChatReq(BaseModel):
65
  messages: List[Msg]
66
+ model: Optional[str] = None
67
  temperature: float = 0.2
68
  max_tokens: Optional[int] = None
69
  stream: bool = False
 
74
 
75
 
76
  # ── "no symbol before Grade 5" firewall (deterministic, serving-layer) ───────
 
 
 
 
77
  _SLASH_PHONEME = re.compile(r"/[A-Za-zθðŋʃʒʧʤ]+/")
78
  _IPA_CHARS = re.compile(r"[θðŋʃʒʧʤæɪʊəɔɑːʰˈˌ]")
79
  _GRADE_RE = re.compile(r"Grade\s+(\d+)")
 
100
 
101
 
102
  class StreamScrubber:
103
+ """Streaming-safe firewall for sub-G5: scrubs /phoneme/ and IPA across token
104
+ boundaries while still streaming token-by-token. Holds text after a '/' until
105
+ it can tell a phoneme (/b/) from a real slash (on/off)."""
 
 
106
 
107
  def __init__(self):
108
+ self.hold = ""
109
 
110
  def feed(self, s: str) -> str:
111
  out = []
112
  for ch in s:
113
  if self.hold:
114
+ if ch == "/":
115
  out.append("that sound")
116
  self.hold = ""
117
  elif (ch.isalpha() or ch in "θðŋʃʒʧʤ") and len(self.hold) <= 6:
118
+ self.hold += ch
119
+ else:
120
  out.append(self.hold + ch)
121
  self.hold = ""
122
  elif ch == "/":
123
+ self.hold = "/"
124
  else:
125
  out.append(ch)
126
  return _IPA_CHARS.sub("", "".join(out))
 
130
  return _IPA_CHARS.sub("", rest)
131
 
132
 
133
+ @app.get("/")
134
  def health():
135
+ return {"status": "ok", "engine": "vaaani", "base": BASE_FILE, "lora": LORA_FILE,
136
+ "models": [BASE_NAME, FLAGSHIP_NAME], "loaded": list(_llms.keys())}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
 
139
  @app.post("/chat")
140
  def chat(req: ChatReq):
141
  sub_g5 = _is_sub_g5(_system_text(_msgs(req)))
142
+ out = get_llm(_use_lora(req.model)).create_chat_completion(
143
  messages=_msgs(req), temperature=req.temperature,
144
  max_tokens=req.max_tokens or MAX_TOK, stream=False)
145
  reply = out["choices"][0]["message"]["content"]
 
150
 
151
  @app.post("/chat/stream")
152
  def chat_stream(req: ChatReq):
 
 
 
153
  sub_g5 = _is_sub_g5(_system_text(_msgs(req)))
154
+ llm = get_llm(_use_lora(req.model))
155
 
156
  def gen():
157
  scrub = StreamScrubber() if sub_g5 else None
 
174
 
175
  @app.post("/v1/chat/completions")
176
  def openai_compat(req: ChatReq):
177
+ """OpenAI-compatible the Vaaani RAG backend points VAAANI_LLM_BASE_URL here and
178
+ calls /v1/chat/completions with model=vaaani-base or vaaani-flagship."""
179
  sub_g5 = _is_sub_g5(_system_text(_msgs(req)))
180
+ llm = get_llm(_use_lora(req.model))
181
  if req.stream:
182
  def gen():
183
+ scrub = StreamScrubber() if sub_g5 else None
184
+ for chunk in llm.create_chat_completion(
185
  messages=_msgs(req), temperature=req.temperature,
186
+ max_tokens=req.max_tokens or MAX_TOK, stream=True):
187
+ if sub_g5:
188
+ delta = chunk["choices"][0]["delta"].get("content")
189
+ if delta:
190
+ chunk["choices"][0]["delta"]["content"] = scrub.feed(delta)
191
+ yield f"data: {json.dumps(chunk)}\n\n"
192
+ if scrub:
193
+ tail = scrub.flush()
194
+ if tail:
195
+ yield f"data: {json.dumps({'choices':[{'index':0,'delta':{'content':tail},'finish_reason':None}]})}\n\n"
196
  yield "data: [DONE]\n\n"
197
  return StreamingResponse(gen(), media_type="text/event-stream")
198
  out = llm.create_chat_completion(