Sheikh Mohammad Rakib commited on
Commit
4bf2b01
·
1 Parent(s): 4a51acd

feat: initialize modal_app.py for memory bridge application framework

Browse files
Files changed (1) hide show
  1. modal_app.py +549 -0
modal_app.py ADDED
@@ -0,0 +1,549 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import modal
2
+ import json
3
+ import os
4
+ import base64
5
+ from pathlib import Path
6
+
7
+ app = modal.App("memory-keeper")
8
+
9
+ # ── Volumes ───────────────────────────────────────────────────────────────────
10
+ volume = modal.Volume.from_name("memory-keeper-personas", create_if_missing=True)
11
+ hf_cache_vol = modal.Volume.from_name("memory-keeper-hf-cache", create_if_missing=True)
12
+
13
+ # ── MiniCPM Hosted API ────────────────────────────────────────────────────────
14
+ MINICPM_LLM_URL = "http://35.203.155.71:8001/v1" # MiniCPM4.1-8B
15
+ MINICPM_VISION_URL = "http://35.203.155.71:8003/v1" # MiniCPM-V-4.6
16
+ MINICPM_AUTH = "Bearer sk-minicpm-V8bcD-YTAMxECagaKOnbwTCN69IlN2LhSezGOgq2Ues"
17
+ MINICPM_LLM_MODEL = "MiniCPM4.1-8B"
18
+ MINICPM_VIS_MODEL = "MiniCPM-V-4.6"
19
+
20
+ # ── Model IDs (Modal-hosted) ──────────────────────────────────────────────────
21
+ ASR_MODEL = "CohereLabs/cohere-transcribe-03-2026"
22
+ OCR_MODEL = "nvidia/NVIDIA-Nemotron-Parse-v1.2"
23
+ TTS_MODEL = "openbmb/VoxCPM2"
24
+ MULTILINGUAL_LM_FIRE = "CohereLabs/tiny-aya-fire" # South Asian (Bengali, Hindi, Urdu)
25
+ MULTILINGUAL_LM_WATER = "CohereLabs/tiny-aya-water" # Asia Pacific
26
+
27
+ MINUTES = 60
28
+
29
+ # ── Images ────────────────────────────────────────────────────────────────────
30
+
31
+ base_image = (
32
+ modal.Image.debian_slim(python_version="3.11")
33
+ .pip_install("openai", "requests", "fastapi[standard]", "huggingface_hub")
34
+ )
35
+
36
+ asr_image = (
37
+ modal.Image.debian_slim(python_version="3.11")
38
+ .pip_install(
39
+ "torch", "transformers>=4.50.0", "torchaudio",
40
+ "huggingface_hub", "soundfile", "fastapi[standard]",
41
+ )
42
+ )
43
+
44
+ ocr_image = (
45
+ modal.Image.debian_slim(python_version="3.11")
46
+ .pip_install(
47
+ "torch", "torchvision", "transformers>=4.50.0",
48
+ "Pillow", "huggingface_hub", "fastapi[standard]",
49
+ )
50
+ )
51
+
52
+ tts_image = (
53
+ modal.Image.debian_slim(python_version="3.11")
54
+ .pip_install(
55
+ "torch", "soundfile", "huggingface_hub", "fastapi[standard]",
56
+ "voxcpm",
57
+ )
58
+ )
59
+
60
+ multilingual_image = (
61
+ modal.Image.debian_slim(python_version="3.11")
62
+ .pip_install(
63
+ "torch", "transformers>=4.50.0",
64
+ "huggingface_hub", "fastapi[standard]",
65
+ )
66
+ )
67
+
68
+
69
+ # ── Helper: MiniCPM client ────────────────────────────────────────────────────
70
+
71
+ def get_llm_client():
72
+ from openai import OpenAI
73
+ return OpenAI(
74
+ base_url=MINICPM_LLM_URL,
75
+ api_key=MINICPM_AUTH.replace("Bearer ", ""),
76
+ )
77
+
78
+ def get_vision_client():
79
+ from openai import OpenAI
80
+ return OpenAI(
81
+ base_url=MINICPM_VISION_URL,
82
+ api_key=MINICPM_AUTH.replace("Bearer ", ""),
83
+ )
84
+
85
+
86
+ # ─────────────────────────────────────────────────────────────────────────────
87
+ # 1. ASR — Cohere Transcribe 2B
88
+ # ─────────────────────────────────────────────────────────────────────────────
89
+
90
+ @app.function(gpu="T4", image=asr_image, timeout=5 * MINUTES,
91
+ volumes={"/root/.cache/huggingface": hf_cache_vol})
92
+ def transcribe_audio(audio_bytes: bytes, filename: str = "audio.wav") -> str:
93
+ import tempfile, torch, soundfile as sf
94
+ from transformers import pipeline
95
+
96
+ pipe = pipeline(
97
+ "automatic-speech-recognition",
98
+ model=ASR_MODEL,
99
+ trust_remote_code=True,
100
+ device="cuda",
101
+ torch_dtype=torch.float16,
102
+ )
103
+
104
+ with tempfile.NamedTemporaryFile(suffix=Path(filename).suffix, delete=False) as f:
105
+ f.write(audio_bytes)
106
+ tmp_path = f.name
107
+
108
+ result = pipe(tmp_path, return_timestamps=False)
109
+ os.unlink(tmp_path)
110
+ return result["text"] if isinstance(result, dict) else str(result)
111
+
112
+
113
+ # ─────────────────────────────────────────────────────────────────────────────
114
+ # 2. OCR — Nemotron Parse v1.2
115
+ # ─────────────────────────────────────────────────────────────────────────────
116
+
117
+ @app.function(gpu="T4", image=ocr_image, timeout=5 * MINUTES,
118
+ volumes={"/root/.cache/huggingface": hf_cache_vol})
119
+ def ocr_document(image_bytes: bytes) -> str:
120
+ import tempfile, torch
121
+ from PIL import Image
122
+ from transformers import AutoModelForImageTextToText, AutoProcessor
123
+
124
+ processor = AutoProcessor.from_pretrained(OCR_MODEL, trust_remote_code=True)
125
+ model = AutoModelForImageTextToText.from_pretrained(
126
+ OCR_MODEL, trust_remote_code=True,
127
+ torch_dtype=torch.float16, device_map="auto",
128
+ )
129
+ model.eval()
130
+
131
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
132
+ f.write(image_bytes)
133
+ tmp_path = f.name
134
+
135
+ image = Image.open(tmp_path).convert("RGB")
136
+ os.unlink(tmp_path)
137
+
138
+ messages = [{"role": "user", "content": [
139
+ {"type": "image"},
140
+ {"type": "text", "text": "Extract all text from this document. Preserve structure and formatting."},
141
+ ]}]
142
+ inputs = processor.apply_chat_template(
143
+ messages, tokenize=True, add_generation_prompt=True,
144
+ return_dict=True, return_tensors="pt",
145
+ ).to(model.device)
146
+
147
+ generated_ids = model.generate(**inputs, max_new_tokens=2048)
148
+ trimmed = [out[len(inp):] for inp, out in zip(inputs.input_ids, generated_ids)]
149
+ return processor.batch_decode(trimmed, skip_special_tokens=True)[0]
150
+
151
+
152
+ # ─────────────────────────────────────────────────────────────────────────────
153
+ # 3. TTS — VoxCPM2
154
+ # ─────────────────────────────────────────────────────────────────────────────
155
+
156
+ @app.function(gpu="T4", image=tts_image, timeout=5 * MINUTES,
157
+ volumes={"/root/.cache/huggingface": hf_cache_vol})
158
+ def text_to_speech(text: str, voice_description: str = None) -> bytes:
159
+ import soundfile as sf, io
160
+ from voxcpm import VoxCPM
161
+
162
+ model = VoxCPM.from_pretrained(TTS_MODEL, load_denoiser=False)
163
+ if voice_description:
164
+ text = f"({voice_description}){text}"
165
+
166
+ wav = model.generate(text=text, cfg_value=2.0, inference_timesteps=10)
167
+ buf = io.BytesIO()
168
+ sf.write(buf, wav, model.tts_model.sample_rate, format="WAV")
169
+ return buf.getvalue()
170
+
171
+
172
+ # ─────────────────────────────────────────────────────────────────────────────
173
+ # 4. Multilingual — Tiny Aya Fire (Bengali/South Asian fallback)
174
+ # ─────────────────────────────────────────────────────────────────────────────
175
+
176
+ @app.function(gpu="T4", image=multilingual_image, timeout=5 * MINUTES,
177
+ volumes={"/root/.cache/huggingface": hf_cache_vol})
178
+ def chat_multilingual(system_prompt: str, history: list, user_message: str, model_id: str = None) -> str:
179
+ import torch
180
+ from transformers import AutoTokenizer, AutoModelForCausalLM
181
+
182
+ if model_id is None:
183
+ model_id = MULTILINGUAL_LM_FIRE
184
+ tokenizer = AutoTokenizer.from_pretrained(model_id)
185
+ model = AutoModelForCausalLM.from_pretrained(
186
+ model_id, torch_dtype=torch.float16, device_map="auto",
187
+ )
188
+ model.eval()
189
+
190
+ messages = [{"role": "system", "content": system_prompt}]
191
+ for msg in history[-8:]:
192
+ messages.append({"role": msg["role"], "content": msg["content"]})
193
+ messages.append({"role": "user", "content": user_message})
194
+
195
+ input_ids = tokenizer.apply_chat_template(
196
+ messages, tokenize=True, add_generation_prompt=True, return_tensors="pt",
197
+ ).to(model.device)
198
+
199
+ gen_tokens = model.generate(
200
+ input_ids, max_new_tokens=512, do_sample=True,
201
+ temperature=0.8, top_p=0.95,
202
+ )
203
+ output = gen_tokens[0][input_ids.shape[-1]:]
204
+ return tokenizer.decode(output, skip_special_tokens=True)
205
+
206
+
207
+ # ─────────────────────────────────────────────────────────────────────────────
208
+ # 5. Photo Description — MiniCPM-V 4.6 (hosted API)
209
+ # ─────────────────────────────────────────────────────────────────────────────
210
+
211
+ @app.function(image=base_image, timeout=5 * MINUTES)
212
+ def describe_photo(image_bytes: bytes, prompt: str = None) -> str:
213
+ import base64
214
+ client = get_vision_client()
215
+
216
+ img_b64 = base64.b64encode(image_bytes).decode()
217
+ if prompt is None:
218
+ prompt = (
219
+ "Describe this person in detail: their appearance, expression, what they are doing, "
220
+ "the setting, and any emotional tone you sense. "
221
+ "This will help reconstruct their memory and personality."
222
+ )
223
+
224
+ response = client.chat.completions.create(
225
+ model=MINICPM_VIS_MODEL,
226
+ messages=[{"role": "user", "content": [
227
+ {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}},
228
+ {"type": "text", "text": prompt},
229
+ ]}],
230
+ max_tokens=512,
231
+ temperature=0.3,
232
+ )
233
+ return response.choices[0].message.content.strip()
234
+
235
+
236
+ # ─────────────────────────────────────────────────────────────────────────────
237
+ # 6. Persona Builder — MiniCPM4.1-8B (hosted API)
238
+ # ─────────────────────────────────────────────────────────────────────────────
239
+
240
+ @app.function(image=base_image, timeout=20 * MINUTES, volumes={"/personas": volume})
241
+ def build_persona(
242
+ persona_id: str, name: str, relationship: str,
243
+ texts: list, photo_captions: list, voice_transcripts: list,
244
+ ) -> dict:
245
+ client = get_llm_client()
246
+
247
+ all_content = []
248
+ if texts:
249
+ all_content.append("=== LETTERS / DIARY ENTRIES ===\n" + "\n---\n".join(texts))
250
+ if photo_captions:
251
+ all_content.append("=== PHOTO DESCRIPTIONS ===\n" + "\n---\n".join(photo_captions))
252
+ if voice_transcripts:
253
+ all_content.append("=== VOICE TRANSCRIPTS ===\n" + "\n---\n".join(voice_transcripts))
254
+
255
+ combined = "\n\n".join(all_content)
256
+
257
+ prompt = f"""You are helping preserve the memory of {name} ({relationship}).
258
+
259
+ Below is everything we have from them:
260
+
261
+ {combined}
262
+
263
+ Extract a rich persona profile. Return ONLY valid JSON, no markdown, no backticks:
264
+ {{
265
+ "name": "{name}",
266
+ "relationship": "{relationship}",
267
+ "personality_traits": ["5-8 traits"],
268
+ "speech_style": "how they speak, tone, vocabulary, sentence length",
269
+ "common_phrases": ["phrases or expressions they often used"],
270
+ "key_memories": ["10-15 specific memories or life events"],
271
+ "values": ["what they cared about most"],
272
+ "language": "primary language (English/Bengali/Hindi/mixed)",
273
+ "emotional_tone": "overall emotional quality",
274
+ "topics_they_loved": ["subjects they talked about often"],
275
+ "advice_they_gave": ["wisdom or advice they shared"],
276
+ "voice_description": "describe their voice: age, gender, tone, accent e.g. elderly Bengali man, warm gentle voice",
277
+ "system_prompt": "A 300-word system prompt starting with: You are {name}..."
278
+ }}"""
279
+
280
+ response = client.chat.completions.create(
281
+ model=MINICPM_LLM_MODEL,
282
+ messages=[{"role": "user", "content": prompt}],
283
+ temperature=0.3,
284
+ max_tokens=2048,
285
+ )
286
+
287
+ import re
288
+ raw = response.choices[0].message.content.strip()
289
+ print(f"RAW RESPONSE (first 500 chars): {raw[:500]}")
290
+
291
+ # Strip <think>...</think> tags
292
+ raw = re.sub(r'<think>.*?</think>', '', raw, flags=re.DOTALL).strip()
293
+ print(f"AFTER THINK STRIP (first 500 chars): {raw[:500]}")
294
+
295
+ # Strip markdown code blocks
296
+ if "```" in raw:
297
+ parts = raw.split("```")
298
+ for part in parts:
299
+ if part.startswith("json"):
300
+ part = part[4:]
301
+ part = part.strip()
302
+ if part.startswith("{"):
303
+ raw = part
304
+ break
305
+
306
+ # Extract JSON object
307
+ json_match = re.search(r'[{].*[}]', raw, re.DOTALL)
308
+ if json_match:
309
+ raw = json_match.group(0)
310
+
311
+ print(f"FINAL RAW FOR PARSING (first 300 chars): {raw[:300]}")
312
+ persona = json.loads(raw)
313
+ os.makedirs("/personas", exist_ok=True)
314
+ with open(f"/personas/{persona_id}.json", "w", encoding="utf-8") as f:
315
+ json.dump(persona, f, ensure_ascii=False, indent=2)
316
+ volume.commit()
317
+ return persona
318
+
319
+
320
+ # ─────────────────────────────────────────────────────────────────────────────
321
+ # 7. Chat — MiniCPM4.1-8B (hosted API)
322
+ # ─────────────────────────────────────────────────────────────────────────────
323
+
324
+ BENGALI_CHARS = set("অআইঈউঊঋএঐওঔকখগঘঙচছজঝঞটঠডঢণতথদধনপফবভমযরলশষসহড়ঢ়য়ংঃ")
325
+ # Asia Pacific: Chinese, Japanese, Korean, Thai, Vietnamese tones etc.
326
+ ASIA_PACIFIC_RANGES = [(0x4E00, 0x9FFF), (0x3040, 0x30FF), (0xAC00, 0xD7AF), (0x0E00, 0x0E7F)]
327
+
328
+ def is_asia_pacific(text):
329
+ for ch in text:
330
+ cp = ord(ch)
331
+ if any(lo <= cp <= hi for lo, hi in ASIA_PACIFIC_RANGES):
332
+ return True
333
+ return False
334
+
335
+ @app.function(image=base_image, timeout=5 * MINUTES, volumes={"/personas": volume})
336
+ def chat_with_persona(
337
+ persona_id: str, history: list, user_message: str, language: str = "auto",
338
+ ) -> dict:
339
+ persona_path = f"/personas/{persona_id}.json"
340
+ if not os.path.exists(persona_path):
341
+ raise FileNotFoundError(f"Persona '{persona_id}' not found.")
342
+
343
+ with open(persona_path, "r", encoding="utf-8") as f:
344
+ persona = json.load(f)
345
+
346
+ system_prompt = persona.get("system_prompt", f"You are {persona['name']}.")
347
+ voice_desc = persona.get("voice_description", "warm elderly voice")
348
+
349
+ full_system = f"""{system_prompt}
350
+
351
+ IMPORTANT:
352
+ - You ARE {persona['name']}. Never break character.
353
+ - Use their speech style, phrases, and memories naturally.
354
+ - Be warm and personal, not like an AI.
355
+ - Detect the user's language and respond in the same language.
356
+ - Keep responses 2-4 sentences unless sharing a story."""
357
+
358
+ import re
359
+
360
+ is_bengali = any(c in BENGALI_CHARS for c in user_message)
361
+ is_ap = is_asia_pacific(user_message)
362
+
363
+ # All languages through MiniCPM4.1-8B (handles Bengali/Hindi/Chinese natively)
364
+ # Tiny Aya Fire/Water kept for dedicated multilingual endpoint (future use)
365
+ client = get_llm_client()
366
+
367
+ # Add language instruction to system prompt
368
+ if is_bengali or language == "Bengali":
369
+ full_system = full_system + "\n\nIMPORTANT: The user is writing in Bengali. You MUST respond in Bengali only."
370
+ elif is_ap or language in ("Chinese", "Japanese", "Korean", "Thai"):
371
+ full_system = full_system + "\n\nIMPORTANT: Respond in " + str(language) + " only."
372
+
373
+
374
+ messages = [{"role": "system", "content": full_system}]
375
+ for msg in history[-10:]:
376
+ messages.append({"role": msg["role"], "content": msg["content"]})
377
+ messages.append({"role": "user", "content": user_message})
378
+ response = client.chat.completions.create(
379
+ model=MINICPM_LLM_MODEL,
380
+ messages=messages,
381
+ temperature=0.8,
382
+ max_tokens=1024,
383
+ stop=None,
384
+ )
385
+ response_text = response.choices[0].message.content.strip()
386
+ response_text = re.sub(r'<think>.*?</think>', '', response_text, flags=re.DOTALL).strip()
387
+ # Remove any truncated incomplete sentence at end
388
+ if response_text and not response_text[-1] in '.!?।':
389
+ last_punct = max(
390
+ response_text.rfind('.'),
391
+ response_text.rfind('!'),
392
+ response_text.rfind('?'),
393
+ response_text.rfind('।'),
394
+ )
395
+ if last_punct > len(response_text) // 2:
396
+ response_text = response_text[:last_punct+1]
397
+
398
+ return {"text": response_text, "voice_description": voice_desc}
399
+
400
+
401
+ # ─────────────────────────────────────────────────────────────────────────────
402
+ # 8. Web Endpoints
403
+ # ─────────────────────────────────────────────────────────────────────────────
404
+
405
+ from fastapi import FastAPI, Request
406
+ from fastapi.middleware.cors import CORSMiddleware
407
+ from fastapi.responses import Response
408
+
409
+
410
+ def make_app():
411
+ web_app = FastAPI()
412
+ web_app.add_middleware(
413
+ CORSMiddleware, allow_origins=["*"],
414
+ allow_methods=["*"], allow_headers=["*"],
415
+ )
416
+ return web_app
417
+
418
+
419
+ # Health check — tests MiniCPM API reachability
420
+ health_web = make_app()
421
+ @app.function(image=base_image, timeout=30)
422
+ @modal.asgi_app(label="health")
423
+ def health_endpoint():
424
+ @health_web.get("/")
425
+ async def handler():
426
+ try:
427
+ client = get_llm_client()
428
+ r = client.chat.completions.create(
429
+ model=MINICPM_LLM_MODEL,
430
+ messages=[{"role": "user", "content": "Hi"}],
431
+ max_tokens=5,
432
+ )
433
+ return {"status": "ok", "minicpm": "reachable", "reply": r.choices[0].message.content}
434
+ except Exception as e:
435
+ return {"status": "error", "minicpm": "unreachable", "error": str(e)}
436
+ return health_web
437
+
438
+
439
+ # Build persona
440
+ build_web = make_app()
441
+ @app.function(image=base_image, volumes={"/personas": volume}, timeout=20 * MINUTES)
442
+ @modal.asgi_app(label="build-persona")
443
+ def build_persona_endpoint():
444
+ @build_web.post("/")
445
+ async def handler(request: Request):
446
+ try:
447
+ data = await request.json()
448
+ persona = build_persona.remote(
449
+ persona_id=data["persona_id"], name=data["name"],
450
+ relationship=data["relationship"], texts=data.get("texts", []),
451
+ photo_captions=data.get("photo_captions", []),
452
+ voice_transcripts=data.get("voice_transcripts", []),
453
+ )
454
+ return {"success": True, "persona": persona}
455
+ except Exception as e:
456
+ return {"success": False, "error": str(e)}
457
+ return build_web
458
+
459
+
460
+ # Chat
461
+ chat_web = make_app()
462
+ @app.function(image=base_image, volumes={"/personas": volume}, timeout=5 * MINUTES)
463
+ @modal.asgi_app(label="chat")
464
+ def chat_endpoint():
465
+ @chat_web.post("/")
466
+ async def handler(request: Request):
467
+ data = await request.json()
468
+ result = chat_with_persona.remote(
469
+ persona_id=data["persona_id"], history=data.get("history", []),
470
+ user_message=data["message"], language=data.get("language", "auto"),
471
+ )
472
+ return result
473
+ return chat_web
474
+
475
+
476
+ # Transcribe
477
+ transcribe_web = make_app()
478
+ @app.function(image=asr_image, volumes={"/root/.cache/huggingface": hf_cache_vol}, timeout=5 * MINUTES)
479
+ @modal.asgi_app(label="transcribe")
480
+ def transcribe_endpoint():
481
+ @transcribe_web.post("/")
482
+ async def handler(request: Request):
483
+ data = await request.json()
484
+ audio_bytes = base64.b64decode(data["audio_b64"])
485
+ transcript = transcribe_audio.remote(audio_bytes, data.get("filename", "audio.wav"))
486
+ return {"transcript": transcript}
487
+ return transcribe_web
488
+
489
+
490
+ # Vision
491
+ vision_web = make_app()
492
+ @app.function(image=base_image, timeout=5 * MINUTES)
493
+ @modal.asgi_app(label="describe-photo")
494
+ def vision_endpoint():
495
+ @vision_web.post("/")
496
+ async def handler(request: Request):
497
+ data = await request.json()
498
+ image_bytes = base64.b64decode(data["image_b64"])
499
+ description = describe_photo.remote(image_bytes, data.get("prompt"))
500
+ return {"description": description}
501
+ return vision_web
502
+
503
+
504
+ # OCR
505
+ ocr_web = make_app()
506
+ @app.function(image=ocr_image, volumes={"/root/.cache/huggingface": hf_cache_vol}, timeout=5 * MINUTES)
507
+ @modal.asgi_app(label="ocr-document")
508
+ def ocr_endpoint():
509
+ @ocr_web.post("/")
510
+ async def handler(request: Request):
511
+ data = await request.json()
512
+ image_bytes = base64.b64decode(data["image_b64"])
513
+ text = ocr_document.remote(image_bytes)
514
+ return {"text": text}
515
+ return ocr_web
516
+
517
+
518
+ # TTS
519
+ tts_web = make_app()
520
+ @app.function(image=tts_image, volumes={"/root/.cache/huggingface": hf_cache_vol}, timeout=5 * MINUTES)
521
+ @modal.asgi_app(label="text-to-speech")
522
+ def tts_endpoint():
523
+ @tts_web.post("/")
524
+ async def handler(request: Request):
525
+ data = await request.json()
526
+ wav_bytes = text_to_speech.remote(data["text"], data.get("voice_description"))
527
+ return Response(content=wav_bytes, media_type="audio/wav")
528
+ return tts_web
529
+
530
+
531
+ # List personas
532
+ list_web = make_app()
533
+ @app.function(image=base_image, volumes={"/personas": volume}, timeout=MINUTES)
534
+ @modal.asgi_app(label="list-personas")
535
+ def list_personas_endpoint():
536
+ @list_web.get("/")
537
+ async def handler():
538
+ personas = []
539
+ if os.path.exists("/personas"):
540
+ for f in Path("/personas").glob("*.json"):
541
+ with open(f) as fp:
542
+ data = json.load(fp)
543
+ personas.append({
544
+ "id": f.stem,
545
+ "name": data.get("name"),
546
+ "relationship": data.get("relationship"),
547
+ })
548
+ return {"personas": personas}
549
+ return list_web