Spaces:
Running on Zero
Running on Zero
| """ | |
| Lumi (Sofía) — compañera educativa local-first para niños pequeños. | |
| Arquitectura clave: | |
| - gradio.Server (FastAPI + motor Gradio) => mérito Off-Brand + hosting en Spaces | |
| - Qwen2.5-7B-Instruct (fine-tuneado, QLoRA) vía transformers + GPU dinámica del | |
| Space (ZeroGPU, @spaces.GPU en llm/engine.py) => méritos local-first + fine-tuned | |
| - El LLM es SOLO el pegamento conversacional. Los HECHOS/contenido | |
| (cuentos, actividades) salen de `content/` curado, NO los inventa el modelo. | |
| - Capa de seguridad de entrada/salida + log parental. | |
| Endpoints (llamables desde el frontend con @gradio/client, y vía gradio_client): | |
| - chat : turno conversacional (texto -> texto + actividad opcional) | |
| - transcribe : audio del niño -> texto (faster-whisper) | |
| - speak : texto -> audio (Kokoro TTS) | |
| Rutas FastAPI normales: | |
| - GET / : sirve el frontend custom | |
| - GET /api/parental/log : últimas interacciones (panel de padres) | |
| - GET /api/parental/memory : memoria estructurada (panel de padres) | |
| """ | |
| import os | |
| # `spaces` debe importarse antes que `torch` (en este módulo o cualquiera de | |
| # sus dependencias): parchea la inicialización de CUDA para el IPC de | |
| # ZeroGPU. Importarlo tarde causa "RuntimeError: No CUDA GPUs are available". | |
| # En local con LUMI_LLM_BACKEND=ollama no se usa ZeroGPU y el paquete `spaces` | |
| # puede no estar instalado, así que se omite directamente (ver llm/engine.py). | |
| if os.environ.get("LUMI_LLM_BACKEND", "zerogpu") == "zerogpu": | |
| import spaces # noqa: F401 | |
| from gradio import Server | |
| from gradio.data_classes import FileData | |
| from fastapi.responses import HTMLResponse, JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from llm.engine import LumiEngine | |
| from content.loader import ContentLibrary | |
| from safety.guard import SafetyGuard | |
| from parental.store import ParentalStore | |
| from voice import stt, tts | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| # --- componentes --- | |
| content = ContentLibrary(os.path.join(HERE, "content")) | |
| guard = SafetyGuard(blocklist_path=os.path.join(HERE, "safety", "blocklist.txt")) | |
| store = ParentalStore(os.path.join(HERE, "parental", "lumi.db")) | |
| engine = LumiEngine(content=content, store=store) | |
| engine.warmup() | |
| tts.warmup() | |
| tts.precache_stories(content.items.get("story", [])) | |
| app = Server() | |
| # Assets estáticos del frontend custom (CSS/JS) y contenido curado (formas, | |
| # canciones y sus audios) servidos directo, sin pasar por el LLM. | |
| app.mount("/static", StaticFiles(directory=os.path.join(HERE, "frontend", "static")), name="static") | |
| app.mount("/content", StaticFiles(directory=os.path.join(HERE, "content")), name="content") | |
| def chat(message: str, child_age: int = 3) -> dict: | |
| """Un turno de conversación. Devuelve respuesta + (opcional) una actividad.""" | |
| # 1) seguridad de entrada (temas prohibidos definidos por los padres) | |
| if guard.blocks_input(message): | |
| reply = guard.gentle_redirect() | |
| store.record_turn(child_age, message, reply, blocked=True) | |
| return {"reply": reply, "activity": None} | |
| # 2) el motor decide: charla libre, o disparar una actividad curada | |
| reply, activity = engine.respond(message, child_age=child_age) | |
| # 3) seguridad de salida (defensa en profundidad) | |
| if guard.blocks_output(reply): | |
| reply = guard.safe_fallback() | |
| activity = None | |
| store.record_turn(child_age, message, reply, blocked=False, activity=activity) | |
| return {"reply": reply, "activity": activity} | |
| def transcribe(audio: FileData, duration_ms: int | None = None) -> dict: | |
| """Audio del niño -> texto. Push-to-talk, no escucha continua.""" | |
| return stt.transcribe(audio["path"], duration_ms=duration_ms) | |
| def speak(text: str) -> FileData: | |
| """Texto -> audio (voz española). Devuelve un wav.""" | |
| out_path = tts.synthesize(text) | |
| return FileData(path=out_path) | |
| async def homepage(): | |
| with open(os.path.join(HERE, "frontend", "index.html"), encoding="utf-8") as f: | |
| return f.read() | |
| async def parental_log(limit: int = 50): | |
| """Panel de padres: últimas interacciones.""" | |
| return JSONResponse(store.recent(limit)) | |
| async def parental_memory(child_age: int = 3): | |
| """Memoria estructurada para seguimiento y debug.""" | |
| return JSONResponse(store.memory_snapshot(child_age)) | |
| if __name__ == "__main__": | |
| # LUMI_SHARE=1 levanta un link público temporal (*.gradio.live) además del | |
| # local, útil para abrir la app desde el celu (grabar demo, etc.). No hace | |
| # falta en el Space (ya es público). | |
| share = os.environ.get("LUMI_SHARE") == "1" | |
| app.launch(show_error=True, share=share) | |