Black Goku commited on
Commit
faeca74
·
verified ·
1 Parent(s): 19f0c1b

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +420 -0
app.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Yappy - AI motivation/discipline/support chatbot backend
3
+ Created by Rik
4
+
5
+ Serves Qwen2.5-3B-Instruct (GGUF, CPU) via llama-cpp-python, streamed over
6
+ Server-Sent Events.
7
+
8
+ STATELESS BY DESIGN: this server keeps no conversation memory of its own.
9
+ The frontend stores chat history in the browser's localStorage (per mode)
10
+ and sends the relevant history back with every request. This means chat
11
+ history survives Space sleep/restarts, since it never depended on the
12
+ server remembering anything.
13
+ """
14
+
15
+ import os
16
+ from typing import Optional, List, Dict, Literal
17
+
18
+ from fastapi import FastAPI, HTTPException, Request
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+ from pydantic import BaseModel, Field
21
+ from sse_starlette.sse import EventSourceResponse
22
+ from huggingface_hub import hf_hub_download
23
+ from llama_cpp import Llama
24
+ import threading
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Config
28
+ # ---------------------------------------------------------------------------
29
+
30
+ MODEL_REPO = os.environ.get("MODEL_REPO", "Qwen/Qwen2.5-3B-Instruct-GGUF")
31
+ MODEL_FILE = os.environ.get("MODEL_FILE", "qwen2.5-3b-instruct-q4_k_m.gguf")
32
+ MODEL_DIR = os.environ.get("MODEL_DIR", "/app/models")
33
+
34
+ N_CTX = int(os.environ.get("N_CTX", "8192"))
35
+ MAX_NEW_TOKENS = int(os.environ.get("MAX_NEW_TOKENS", "900"))
36
+ N_THREADS = int(os.environ.get("N_THREADS", str(os.cpu_count() or 4)))
37
+
38
+ API_KEY = os.environ.get("YAPPY_API_KEY", "")
39
+
40
+ MAX_HISTORY_MESSAGES = int(os.environ.get("MAX_HISTORY_MESSAGES", "24"))
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Model loading
44
+ # ---------------------------------------------------------------------------
45
+
46
+ os.makedirs(MODEL_DIR, exist_ok=True)
47
+
48
+ print(f"[Yappy] Downloading/locating model {MODEL_REPO}/{MODEL_FILE} ...")
49
+ model_path = hf_hub_download(
50
+ repo_id=MODEL_REPO,
51
+ filename=MODEL_FILE,
52
+ local_dir=MODEL_DIR,
53
+ )
54
+ print(f"[Yappy] Model ready at {model_path}")
55
+
56
+ print("[Yappy] Loading model into llama.cpp ...")
57
+ llm = Llama(
58
+ model_path=model_path,
59
+ n_ctx=N_CTX,
60
+ n_threads=N_THREADS,
61
+ n_gpu_layers=0,
62
+ chat_format="chatml",
63
+ verbose=False,
64
+ )
65
+ print("[Yappy] Model loaded. Ready to yap.")
66
+
67
+ model_lock = threading.Lock()
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Mode system prompts
71
+ # ---------------------------------------------------------------------------
72
+
73
+ BASE_IDENTITY = (
74
+ "You are Yappy, an AI companion created by Rik. You are not a generic "
75
+ "assistant - you have a distinct personality that adapts to the mode "
76
+ "the user picked, but you always remember you are Yappy. Keep replies "
77
+ "conversational (like texting a person), not like an encyclopedia. "
78
+ "Never use markdown headers or bullet-point essays unless the user "
79
+ "explicitly asks for a list. Speak in plain paragraphs or short lines.\n\n"
80
+ "Before every reply, think first. Put your private reasoning inside "
81
+ "<thinking>...</thinking> tags - in there, briefly work out what the "
82
+ "user actually needs, what mode-appropriate angle to take, and what "
83
+ "tone fits. Keep the thinking short (2-5 sentences), not a full essay. "
84
+ "Immediately after the closing </thinking> tag, write your real reply "
85
+ "to the user as normal text, with no other tags. Always include both "
86
+ "the <thinking> block and the reply - never skip the thinking block."
87
+ )
88
+
89
+ MODE_PROMPTS = {
90
+ "motivation": (
91
+ BASE_IDENTITY + "\n\n"
92
+ "MODE: MOTIVATION (roast-to-motivate).\n"
93
+ "Your job is to fire the user up by roasting them - hard. Be blunt, "
94
+ "sarcastic, and call out their excuses directly. Mock the excuse, "
95
+ "not the person's worth as a human being. No slurs, no attacks on "
96
+ "protected traits (race, gender, disability, etc), no body-shaming, "
97
+ "and never mock someone who mentions real distress, self-harm, or "
98
+ "a mental health crisis - if that happens, drop the roast instantly "
99
+ "and respond with genuine care instead. Otherwise: be savage about "
100
+ "laziness, procrastination, and excuses, then pivot hard into 'now "
101
+ "get up and do it' energy. Short, punchy sentences. End most replies "
102
+ "by pushing them toward one concrete next action."
103
+ ),
104
+ "discipline": (
105
+ BASE_IDENTITY + "\n\n"
106
+ "MODE: DISCIPLINE.\n"
107
+ "You are serious, direct, and unemotional - like a strict mentor "
108
+ "or drill coach who respects the user too much to coddle them. "
109
+ "For every situation, lay out clearly what happens if they follow "
110
+ "through (the compounding payoff of consistency) versus what "
111
+ "happens if they don't (the compounding cost of quitting). Use "
112
+ "concrete, realistic consequences, not vague platitudes. No jokes, "
113
+ "no roasting, no softness - just clear-eyed truth and structure. "
114
+ "Give clear, specific, actionable steps or systems, not just "
115
+ "abstract encouragement."
116
+ ),
117
+ "support": (
118
+ BASE_IDENTITY + "\n\n"
119
+ "MODE: SUPPORT.\n"
120
+ "You are warm, gentle, and validating. Listen first. Reflect back "
121
+ "what the user is feeling before offering any advice. Use a soft, "
122
+ "caring tone - like a close friend who is genuinely in their "
123
+ "corner. Never minimize their feelings, never rush to fix things "
124
+ "before they feel heard. Offer encouragement and gentle, optional "
125
+ "suggestions rather than commands. If they mention self-harm, "
126
+ "suicide, or crisis, respond with care and gently encourage them "
127
+ "to reach out to a crisis line or trusted person, without being "
128
+ "clinical or robotic about it."
129
+ ),
130
+ "chat": (
131
+ BASE_IDENTITY + "\n\n"
132
+ "MODE: JUST CHAT.\n"
133
+ "You're just vibing with the user as a friendly, casual companion. "
134
+ "No agenda, no forced motivation or coaching - just be genuinely "
135
+ "fun, curious, and easy to talk to. Match their energy. It's fine "
136
+ "to be a little playful or funny here."
137
+ ),
138
+ }
139
+
140
+ DEFAULT_MODE = "chat"
141
+
142
+
143
+ def build_system_prompt(mode: str, profile: Optional[Dict]) -> str:
144
+ prompt = MODE_PROMPTS.get(mode, MODE_PROMPTS[DEFAULT_MODE])
145
+
146
+ if profile:
147
+ details = []
148
+ if profile.get("name"):
149
+ details.append(f"Name: {profile['name']}")
150
+ if profile.get("age"):
151
+ details.append(f"Age: {profile['age']}")
152
+ if profile.get("description"):
153
+ details.append(f"About them: {profile['description']}")
154
+ if details:
155
+ prompt += (
156
+ "\n\nWhat you know about this user (use it naturally, don't "
157
+ "just recite it back): " + " | ".join(details)
158
+ )
159
+ return prompt
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # API schema
164
+ # ---------------------------------------------------------------------------
165
+
166
+ class HistoryTurn(BaseModel):
167
+ role: Literal["user", "assistant"]
168
+ content: str
169
+
170
+
171
+ class ChatRequest(BaseModel):
172
+ message: str
173
+ mode: str = Field(DEFAULT_MODE, description="motivation | discipline | support | chat")
174
+ history: List[HistoryTurn] = Field(default_factory=list)
175
+ name: Optional[str] = None
176
+ age: Optional[str] = None
177
+ description: Optional[str] = None
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # App
182
+ # ---------------------------------------------------------------------------
183
+
184
+ app = FastAPI(title="Yappy API", version="2.0.0")
185
+
186
+ app.add_middleware(
187
+ CORSMiddleware,
188
+ allow_origins=["*"],
189
+ allow_credentials=True,
190
+ allow_methods=["*"],
191
+ allow_headers=["*"],
192
+ )
193
+
194
+
195
+ def check_auth(request: Request):
196
+ if not API_KEY:
197
+ return
198
+ sent = request.headers.get("x-api-key", "")
199
+ if sent != API_KEY:
200
+ raise HTTPException(status_code=401, detail="Invalid or missing API key")
201
+
202
+
203
+ @app.get("/")
204
+ def root():
205
+ return {"status": "ok", "service": "Yappy", "model": MODEL_FILE, "mode": "stateless"}
206
+
207
+
208
+ @app.get("/health")
209
+ def health():
210
+ return {"status": "healthy"}
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Thinking/answer stream splitter
215
+ # ---------------------------------------------------------------------------
216
+
217
+ THINK_OPEN = "<thinking>"
218
+ THINK_CLOSE = "</thinking>"
219
+
220
+
221
+ class ThinkingSplitter:
222
+ """
223
+ Consumes raw model tokens one chunk at a time and yields SSE events,
224
+ routing text to 'thinking' or 'token' (answer) depending on whether
225
+ we're currently inside a <thinking>...</thinking> block. Handles tag
226
+ boundaries that land awkwardly across separate token chunks by
227
+ buffering a small tail of text whenever a partial tag might be forming.
228
+ """
229
+
230
+ def __init__(self):
231
+ self.buffer = ""
232
+ self.in_thinking = False
233
+ self.seen_thinking_open = False
234
+ self.full_raw = ""
235
+ self.answer_only = ""
236
+
237
+ def _maybe_partial_tag_tail(self, text: str) -> str:
238
+ max_tag_len = max(len(THINK_OPEN), len(THINK_CLOSE))
239
+ for i in range(min(max_tag_len, len(text)), 0, -1):
240
+ tail = text[-i:]
241
+ if THINK_OPEN.startswith(tail) or THINK_CLOSE.startswith(tail):
242
+ return tail
243
+ return ""
244
+
245
+ def feed(self, token: str):
246
+ events = []
247
+ self.full_raw += token
248
+ self.buffer += token
249
+
250
+ while True:
251
+ if not self.in_thinking and not self.seen_thinking_open:
252
+ idx = self.buffer.find(THINK_OPEN)
253
+ if idx == -1:
254
+ tail = self._maybe_partial_tag_tail(self.buffer)
255
+ emit = self.buffer[: len(self.buffer) - len(tail)] if tail else self.buffer
256
+ if emit:
257
+ self.answer_only += emit
258
+ events.append({"event": "token", "data": emit})
259
+ self.buffer = tail
260
+ break
261
+ else:
262
+ pre = self.buffer[:idx]
263
+ if pre:
264
+ self.answer_only += pre
265
+ events.append({"event": "token", "data": pre})
266
+ self.buffer = self.buffer[idx + len(THINK_OPEN):]
267
+ self.in_thinking = True
268
+ self.seen_thinking_open = True
269
+ continue
270
+
271
+ elif self.in_thinking:
272
+ idx = self.buffer.find(THINK_CLOSE)
273
+ if idx == -1:
274
+ tail = self._maybe_partial_tag_tail(self.buffer)
275
+ emit = self.buffer[: len(self.buffer) - len(tail)] if tail else self.buffer
276
+ if emit:
277
+ events.append({"event": "thinking", "data": emit})
278
+ self.buffer = tail
279
+ break
280
+ else:
281
+ pre = self.buffer[:idx]
282
+ if pre:
283
+ events.append({"event": "thinking", "data": pre})
284
+ self.buffer = self.buffer[idx + len(THINK_CLOSE):]
285
+ self.in_thinking = False
286
+ continue
287
+
288
+ else:
289
+ if self.buffer:
290
+ self.answer_only += self.buffer
291
+ events.append({"event": "token", "data": self.buffer})
292
+ self.buffer = ""
293
+ break
294
+
295
+ return events
296
+
297
+ def flush(self):
298
+ events = []
299
+ if self.buffer:
300
+ if self.in_thinking:
301
+ events.append({"event": "thinking", "data": self.buffer})
302
+ else:
303
+ self.answer_only += self.buffer
304
+ events.append({"event": "token", "data": self.buffer})
305
+ self.buffer = ""
306
+ return events
307
+
308
+
309
+ def _build_messages(mode: str, history: List[HistoryTurn], user_message: str, profile: Dict) -> List[Dict]:
310
+ system_prompt = build_system_prompt(mode, profile)
311
+ trimmed = history[-MAX_HISTORY_MESSAGES:] if len(history) > MAX_HISTORY_MESSAGES else history
312
+
313
+ messages = [{"role": "system", "content": system_prompt}]
314
+ for turn in trimmed:
315
+ messages.append({"role": turn.role, "content": turn.content})
316
+ messages.append({"role": "user", "content": user_message})
317
+ return messages
318
+
319
+
320
+ def _generate_stream(mode: str, history: List[HistoryTurn], user_message: str, profile: Dict):
321
+ messages = _build_messages(mode, history, user_message, profile)
322
+
323
+ splitter = ThinkingSplitter()
324
+ finish_reason = None
325
+
326
+ with model_lock:
327
+ stream = llm.create_chat_completion(
328
+ messages=messages,
329
+ max_tokens=MAX_NEW_TOKENS,
330
+ temperature=0.85,
331
+ top_p=0.95,
332
+ stream=True,
333
+ )
334
+
335
+ for chunk in stream:
336
+ choice = chunk["choices"][0]
337
+ delta = choice.get("delta", {})
338
+ token = delta.get("content", "")
339
+ if token:
340
+ for event in splitter.feed(token):
341
+ yield event
342
+ if choice.get("finish_reason"):
343
+ finish_reason = choice["finish_reason"]
344
+
345
+ continue_budget = MAX_NEW_TOKENS
346
+ while finish_reason == "length" and continue_budget > 0 and not splitter.in_thinking:
347
+ follow_messages = messages + [
348
+ {"role": "assistant", "content": splitter.full_raw},
349
+ {"role": "user", "content": "Continue exactly where you left off, no repetition. Do not repeat the <thinking> block."},
350
+ ]
351
+ continue_budget -= 300
352
+ finish_reason = None
353
+ cont_stream = llm.create_chat_completion(
354
+ messages=follow_messages,
355
+ max_tokens=300,
356
+ temperature=0.85,
357
+ top_p=0.95,
358
+ stream=True,
359
+ )
360
+ for chunk in cont_stream:
361
+ choice = chunk["choices"][0]
362
+ delta = choice.get("delta", {})
363
+ token = delta.get("content", "")
364
+ if token:
365
+ for event in splitter.feed(token):
366
+ yield event
367
+ if choice.get("finish_reason"):
368
+ finish_reason = choice["finish_reason"]
369
+
370
+ for event in splitter.flush():
371
+ yield event
372
+
373
+ yield {"event": "done", "data": ""}
374
+
375
+
376
+ def _profile_from_payload(payload: ChatRequest) -> Dict:
377
+ return {k: v for k, v in {
378
+ "name": payload.name,
379
+ "age": payload.age,
380
+ "description": payload.description,
381
+ }.items() if v}
382
+
383
+
384
+ @app.post("/chat/stream")
385
+ async def chat_stream(payload: ChatRequest, request: Request):
386
+ check_auth(request)
387
+
388
+ if not payload.message or not payload.message.strip():
389
+ raise HTTPException(status_code=400, detail="message is required")
390
+
391
+ mode = payload.mode if payload.mode in MODE_PROMPTS else DEFAULT_MODE
392
+ profile = _profile_from_payload(payload)
393
+
394
+ async def event_wrapper():
395
+ for event in _generate_stream(mode, payload.history, payload.message.strip(), profile):
396
+ yield event
397
+
398
+ return EventSourceResponse(event_wrapper())
399
+
400
+
401
+ @app.post("/chat")
402
+ def chat_once(payload: ChatRequest, request: Request):
403
+ """Non-streaming fallback endpoint."""
404
+ check_auth(request)
405
+
406
+ if not payload.message or not payload.message.strip():
407
+ raise HTTPException(status_code=400, detail="message is required")
408
+
409
+ mode = payload.mode if payload.mode in MODE_PROMPTS else DEFAULT_MODE
410
+ profile = _profile_from_payload(payload)
411
+
412
+ full_reply = ""
413
+ thinking_text = ""
414
+ for event in _generate_stream(mode, payload.history, payload.message.strip(), profile):
415
+ if event["event"] == "token":
416
+ full_reply += event["data"]
417
+ elif event["event"] == "thinking":
418
+ thinking_text += event["data"]
419
+
420
+ return {"reply": full_reply, "thinking": thinking_text}