""" 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") @app.api(name="chat") 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} @app.api(name="transcribe") 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) @app.api(name="speak") def speak(text: str) -> FileData: """Texto -> audio (voz española). Devuelve un wav.""" out_path = tts.synthesize(text) return FileData(path=out_path) @app.get("/", response_class=HTMLResponse) async def homepage(): with open(os.path.join(HERE, "frontend", "index.html"), encoding="utf-8") as f: return f.read() @app.get("/api/parental/log") async def parental_log(limit: int = 50): """Panel de padres: últimas interacciones.""" return JSONResponse(store.recent(limit)) @app.get("/api/parental/memory") 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)