EstebanBarac's picture
Reproducción inline de canciones y cuentos, mic como botón de terminar
bf47841
Raw
History Blame Contribute Delete
4.82 kB
"""
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)