| """ |
| 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 |
|
|
| |
| |
| |
| |
| |
| if os.environ.get("LUMI_LLM_BACKEND", "zerogpu") == "zerogpu": |
| import spaces |
|
|
| 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__)) |
|
|
| |
| 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() |
|
|
| |
| |
| 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.""" |
| |
| if guard.blocks_input(message): |
| reply = guard.gentle_redirect() |
| store.record_turn(child_age, message, reply, blocked=True) |
| return {"reply": reply, "activity": None} |
|
|
| |
| reply, activity = engine.respond(message, child_age=child_age) |
|
|
| |
| 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__": |
| |
| |
| |
| share = os.environ.get("LUMI_SHARE") == "1" |
| app.launch(show_error=True, share=share) |
|
|