EstebanBarac commited on
Commit
6879860
·
0 Parent(s):

Initial commit: Lumi pipeline migrated to llama.cpp, local-first TTS

Browse files

- llm/engine.py: migrate from Ollama to llama-cpp-python (GGUF, chatml),
add real conversation history from ParentalStore, fix system prompt to
neutral Latin American Spanish
- voice/tts.py: remove edge-tts fallback, reuse a single Kokoro pipeline
instance, add warmup() to pay cold-load cost at startup not on first reply
- safety/blocklist.txt: expand from 3 to 44 terms
- requirements.txt, README.md: sync with actual stack

.gitignore ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # entorno
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+
6
+ # modelo GGUF (pesado, se descarga/monta aparte)
7
+ models/*.gguf
8
+
9
+ # datos locales / generados
10
+ parental/*.db
11
+ content/audio/*.wav
12
+
13
+ # binarios y artefactos de pruebas viejas (Piper, edge-tts)
14
+ tools/piper/
15
+ tools/piper.tar.gz
16
+ voices/
17
+ bench_output/
AGENTS.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AGENTS.md — contexto para Codex
2
+
3
+ Proyecto **Lumi**: compañero educativo por voz para una niña de ~3 años.
4
+ Para el plan completo, leé `PLAN.md`. Este archivo son las convenciones para trabajar el código.
5
+
6
+ ## Principio inviolable
7
+ El LLM es **solo el pegamento conversacional**. Los hechos y el contenido (cuentos, actividades)
8
+ salen SIEMPRE de `content/` (curado). El modelo nunca inventa cuentos, números ni datos.
9
+ Si una feature requiere que el modelo "sepa" algo, va en `content/`, no en el prompt libre.
10
+
11
+ ## Stack (no cambiar sin avisar)
12
+ - LLM: Qwen2.5-7B-Instruct, GGUF Q4_K_M, vía `llama-cpp-python`. `chat_format="chatml"`.
13
+ - Hosting: `gradio.Server` (FastAPI + motor Gradio). Endpoints con `@app.api(...)`.
14
+ - TTS: **un único motor (Kokoro) y un único voice id** en todos los caminos. Mismo `speed`/`pitch` siempre.
15
+ - STT: whisper.cpp (`pywhispercpp`), push-to-talk. Nada de escucha continua.
16
+ - Seguridad: `safety/guard.py` filtra entrada y salida contra `safety/blocklist.txt`.
17
+ - Parental: SQLite en `parental/store.py`.
18
+
19
+ ## Restricciones del hackatón (críticas)
20
+ - **NO** llamar a APIs de inferencia externas (OpenAI/Anthropic/HF Inference/etc.). Rompe el mérito local-first.
21
+ - Mantener llama.cpp como runtime (es un mérito). No migrar a transformers/ZeroGPU.
22
+ - La app DEBE correr como Gradio en un HF Space.
23
+
24
+ ## Convenciones de código
25
+ - Español rioplatense en textos de cara a la niña; frases muy cortas y cálidas.
26
+ - En `content/`, los números van como palabras ("tres", no "3").
27
+ - Antes de generar audio, chequear cache en `content/audio/<id>.wav`; si no existe, sintetizar en vivo.
28
+ - Todo texto que sale del LLM pasa por `guard.blocks_output` antes de devolverse.
29
+
30
+ ## Cómo correr
31
+ ```bash
32
+ pip install -r requirements.txt
33
+ export MODEL_PATH=models/model.gguf
34
+ python app.py # http://localhost:7860
35
+ ```
36
+
37
+ ## Prioridad de tareas (ver roadmap en PLAN.md)
38
+ MVP primero: chat (llama.cpp) → voz (1 motor) → contenido + seguridad → panel parental → frontend.
39
+ Fine-tune (Modal) y méritos blandos (blog, trace) al final.
CLAUDE.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md — contexto para Claude Code
2
+
3
+ Proyecto **Lumi**: compañero educativo por voz para una niña de ~3 años.
4
+ Para el plan completo, leé `PLAN.md`. Este archivo son las convenciones para trabajar el código.
5
+
6
+ ## Principio inviolable
7
+ El LLM es **solo el pegamento conversacional**. Los hechos y el contenido (cuentos, actividades)
8
+ salen SIEMPRE de `content/` (curado). El modelo nunca inventa cuentos, números ni datos.
9
+ Si una feature requiere que el modelo "sepa" algo, va en `content/`, no en el prompt libre.
10
+
11
+ ## Stack (no cambiar sin avisar)
12
+ - LLM: Qwen2.5-7B-Instruct, GGUF Q4_K_M, vía `llama-cpp-python`. `chat_format="chatml"`.
13
+ - Hosting: `gradio.Server` (FastAPI + motor Gradio). Endpoints con `@app.api(...)`.
14
+ - TTS: **un único motor (Kokoro) y un único voice id** en todos los caminos. Mismo `speed`/`pitch` siempre.
15
+ - STT: whisper.cpp (`pywhispercpp`), push-to-talk. Nada de escucha continua.
16
+ - Seguridad: `safety/guard.py` filtra entrada y salida contra `safety/blocklist.txt`.
17
+ - Parental: SQLite en `parental/store.py`.
18
+
19
+ ## Restricciones del hackatón (críticas)
20
+ - **NO** llamar a APIs de inferencia externas (OpenAI/Anthropic/HF Inference/etc.). Rompe el mérito local-first.
21
+ - Mantener llama.cpp como runtime (es un mérito). No migrar a transformers/ZeroGPU.
22
+ - La app DEBE correr como Gradio en un HF Space.
23
+
24
+ ## Convenciones de código
25
+ - Español rioplatense en textos de cara a la niña; frases muy cortas y cálidas.
26
+ - En `content/`, los números van como palabras ("tres", no "3").
27
+ - Antes de generar audio, chequear cache en `content/audio/<id>.wav`; si no existe, sintetizar en vivo.
28
+ - Todo texto que sale del LLM pasa por `guard.blocks_output` antes de devolverse.
29
+
30
+ ## Cómo correr
31
+ ```bash
32
+ pip install -r requirements.txt
33
+ export MODEL_PATH=models/model.gguf
34
+ python app.py # http://localhost:7860
35
+ ```
36
+
37
+ ## Prioridad de tareas (ver roadmap en PLAN.md)
38
+ MVP primero: chat (llama.cpp) → voz (1 motor) → contenido + seguridad → panel parental → frontend.
39
+ Fine-tune (Modal) y méritos blandos (blog, trace) al final.
HACKATHON.md ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build Small Hackathon — Reglas, méritos y entrega
2
+
3
+ > Referencia rápida para **Lumi** (track **Backyard AI**).
4
+ > Fuente: página oficial `huggingface.co/build-small-hackathon`.
5
+ > Hosts: Gradio + Hugging Face. Premios: **$48,000+** en cash y físicos + créditos por participante.
6
+
7
+ ---
8
+
9
+ ## 1. Fechas clave
10
+
11
+ | Hito | Fecha |
12
+ |---|---|
13
+ | Registro | Cerró el **3 jun 2026** (ya registrados) |
14
+ | Ventana de hackeo | **5 – 15 jun 2026** (dos fines de semana) |
15
+ | AMA en vivo | Mitad de la ventana (Discord) |
16
+ | **Cierre de entregas** | **15 jun 2026** ⟵ deadline duro |
17
+ | Anuncio de ganadores | A confirmar (por track + leaderboard de méritos) |
18
+
19
+ ## 2. Las 3 reglas duras (obligatorias)
20
+
21
+ 1. **Modelos chicos solamente.** Total de parámetros **≤ 32 mil millones**. La consigna: "el modelo entra en una laptop".
22
+ - Lumi usa Qwen2.5-7B → cumple holgado.
23
+ 2. **Hecho en Gradio.** La app **debe** ser una app Gradio, alojada como **Hugging Face Space bajo la org** `build-small-hackathon`.
24
+ - Importante: el Space tiene que estar **dentro de la org del hackatón**, no en tu cuenta suelta.
25
+ 3. **Mostrar, no contar.** Un **video demo corto** y un **posteo en redes** son **parte obligatoria** de la entrega (no son opcionales).
26
+
27
+ ## 3. Nuestro track: Backyard AI — cómo se juzga
28
+
29
+ Resolver un problema real de alguien que conocés. Lumi = nuestra hija de 3 años. Criterios:
30
+
31
+ - [ ] **El problema es específico y real** → estímulo educativo cuando no podemos darle atención.
32
+ - [ ] **La persona realmente lo usó** → grabar a la nena usándolo (clave del video demo).
33
+ - [ ] **Encaje honesto con el modelo chico** → nuestra tesis: para una nena de 3, un 7B fine-tuneado y acotado es la herramienta correcta, no over-engineering.
34
+ - [ ] **Pulido de la app Gradio** → frontend custom cuidado (dominio de la diseñadora UX).
35
+
36
+ ## 4. Los 6 méritos (bonus) — estado para Lumi
37
+
38
+ Ninguno es obligatorio; **cada uno suma puntos**. Apuntamos a los seis.
39
+
40
+ | # | Mérito (nombre oficial) | Qué pide | En Lumi | Estado |
41
+ |---|---|---|---|---|
42
+ | 1 | **Off the Grid** (Local-first) | Sin APIs cloud; corre el modelo propio | llama.cpp con GGUF propio, sin APIs externas | ✅ Planeado / bajo control |
43
+ | 2 | **Well-Tuned** (Fine-tuned) | Modelo fine-tuneado publicado en HF | QLoRA (estilo+seguridad) en Modal → publicar | ⚠️ Planeado (día 7–8). En riesgo si falta tiempo; de-riesgado por créditos Modal |
44
+ | 3 | **Off-Brand** (Custom UI) | Frontend que supera el look default de Gradio | UI infantil custom vía `gr.Server` | ✅ Planeado |
45
+ | 4 | **Llama Champion** (llama.cpp) | El modelo corre en el runtime llama.cpp | Es nuestro runtime core | ✅ Planeado |
46
+ | 5 | **Sharing is Caring** (Open trace) | Compartir el trace del agente en el Hub | Publicar trace del ruteo/conversación de Lumi | ◻️ Fácil (día 9). Requiere que la app tenga un "trace" mostrable |
47
+ | 6 | **Field Notes** | Blog/reporte de qué construiste y aprendiste | Post: "modelo chico fine-tuneado > grande para una nena de 3" | ✅ Planeado (día 9) |
48
+
49
+ **Conflictos a vigilar:**
50
+ - Mérito 1 + 4 vs. GPU del Space: queremos llama.cpp con GPU, pero en Spaces a veces es finicky (hay equipos migrando a transformers + `spaces.GPU`/ZeroGPU, lo que **haría perder el mérito 4**). Mitigación: tener listo un build **llama.cpp CPU-only** como fallback, y solo usar GPU dedicada (no ZeroGPU) si hace falta.
51
+ - No usar los créditos de **Codex** ni HF Inference API para el LLM: serían APIs cloud y **romperían el mérito 1**.
52
+
53
+ ## 5. Categorías de premios (parte de los ~29 awards)
54
+
55
+ Además de los dos tracks y los méritos, hay categorías especiales. Relevantes para nosotros:
56
+
57
+ - **App con modelo realmente diminuto (≤ 4B)** — *no la apuntamos*: elegimos 7B por calidez conversacional. Alternativa si la quisiéramos: fine-tunear un ≤4B (ej. Tiny Aya 3.35B multilingüe en GGUF, recomendado por el hackatón). Decisión a revisar **solo** tras el test de latencia del día 2.
58
+ - **"Full package"** (gran app + gran video + gran posteo) — *alcanzable* si cuidamos el video y el posteo.
59
+ - **Mejor app agéntica** (<32B) — parcial: Lumi rutea intenciones/contenido, pero no es el foco.
60
+ - **Wildcard** (algo genial que no encaja en categoría) — comodín.
61
+
62
+ ## 6. Cómo se entrega (los 4 pasos)
63
+
64
+ 1. **Registrarse** — unirse a la org en HF (hecho).
65
+ 2. **Comunidad** — Discord de Gradio (AMAs, equipo).
66
+ 3. **Construir y publicar** — app Gradio como **Space bajo la org** `build-small-hackathon`.
67
+ 4. **Entregar antes del 15 jun** — dejar: **(a)** link del Space, **(b)** video demo corto, **(c)** posteo en redes.
68
+
69
+ ## 7. Checklist de entrega (para entregar bien)
70
+
71
+ **App / Space**
72
+ - [ ] Space creado **bajo la org** `build-small-hackathon` (no en cuenta personal).
73
+ - [ ] Modelo ≤ 32B (Qwen2.5-7B ✅).
74
+ - [ ] Corre estable; latencia aceptable para una nena (probada).
75
+ - [ ] Frontend custom pulido (mérito Off-Brand).
76
+ - [ ] Sin llamadas a APIs cloud (mérito Local-first).
77
+ - [ ] README del Space claro: qué es, para quién, cómo se usa.
78
+
79
+ **Video demo (obligatorio)**
80
+ - [ ] Corto y al grano. **Mostrar a la nena usándolo de verdad** (criterio top del track).
81
+ - [ ] Que se vea la voz funcionando (entrada y salida).
82
+ - [ ] Mostrar el panel parental brevemente.
83
+
84
+ **Posteo en redes (obligatorio)**
85
+ - [ ] Contar el problema real + la solución pequeña. Etiquetar a @Gradio / @huggingface.
86
+ - [ ] Link al Space y al video.
87
+
88
+ **Méritos (subir lo que aplique)**
89
+ - [ ] Modelo fine-tuneado publicado en HF (Well-Tuned).
90
+ - [ ] Trace del agente en el Hub (Open trace).
91
+ - [ ] Blog/Field Notes publicado y linkeado.
92
+
93
+ **Antes de cerrar**
94
+ - [ ] Probar el Space desde una sesión limpia / incógnito (que cargue sin tu login).
95
+ - [ ] Confirmar que el Space no esté dormido al momento de juzgar (manejar sleep/GPU).
96
+ - [ ] Todo linkeado entre sí: Space ↔ video ↔ posteo ↔ modelo ↔ blog.
97
+
98
+ ## 8. Enlaces útiles (del hackatón)
99
+
100
+ - Org / página oficial: `huggingface.co/build-small-hackathon`
101
+ - `gr.Server` (UIs custom): `huggingface.co/blog/introducing-gradio-server`
102
+ - Guías de Gradio: `gradio.app/guides/quickstart`
103
+ - llama.cpp: `github.com/ggml-org/llama.cpp`
104
+ - Discord de Gradio (comunidad/AMA): invitación en la página oficial
PLAN.md ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Lumi 🌟 — Plan del proyecto
2
+
3
+ > Compañero educativo conversacional por voz para una niña de ~3 años.
4
+ > Hackathon: **Build Small Hackathon** (HF + Gradio) · Track: **Backyard AI**.
5
+ > Ventana de construcción: **5–15 de junio de 2026** (~10 días).
6
+
7
+ ---
8
+
9
+ ## 1. Visión y problema (real y específico)
10
+
11
+ Nuestra hija de 3 años pide estímulo constante (contar, letras, cuentos, juego).
12
+ No siempre podemos darle toda la atención que requiere. **Lumi** es un compañero
13
+ que conversa, cuenta cuentos y hace actividades simples (contar, formas, colores,
14
+ animales) **para complementar, nunca reemplazar**, el tiempo de los padres.
15
+
16
+ - Usuario real que lo usa: nuestra hija (criterio clave del track Backyard AI).
17
+ - No está "on" todo el día: tiene límite de tiempo y control parental.
18
+ - Complemento del juego y de la atención humana, no sustituto.
19
+
20
+ ## 2. Principio de arquitectura (la decisión más importante)
21
+
22
+ **El LLM es solo el pegamento conversacional. NUNCA es la fuente de la verdad.**
23
+
24
+ - El contenido educativo (cuentos, actividades) es **curado y verificado por nosotros**,
25
+ vive en `content/` como JSON. El modelo no inventa hechos, números ni cuentos.
26
+ - El LLM maneja el turno, da calidez, elige qué actividad sigue y adapta dificultad.
27
+ - Cuando hace falta contenido, se le inyecta al modelo entre `<contenido>...</contenido>`
28
+ y el system prompt le prohíbe salirse de eso.
29
+
30
+ Esto: (a) mata casi toda la alucinación, (b) reduce el riesgo de contenido inapropiado,
31
+ (c) es el **encaje honesto con el modelo chico** que premia el jurado (las necesidades
32
+ de una nena de 3 años son genuinamente acotadas).
33
+
34
+ ## 3. Decisiones técnicas (con razón)
35
+
36
+ | Pieza | Elección | Por qué |
37
+ |---|---|---|
38
+ | **LLM** | Qwen2.5-7B-Instruct, GGUF Q4_K_M | Sweet spot de calidez/fluidez en español. NO 32B (latencia, over-engineering, contradice el ethos). 3B como plan B / robot futuro. |
39
+ | **Runtime** | llama.cpp (`llama-cpp-python`) | Mérito *llama.cpp* + *local-first* (corremos los pesos, sin APIs externas). |
40
+ | **Fine-tune** | QLoRA sobre el 7B, en Modal | Mérito *fine-tuned*. Entrena **estilo + seguridad, NO hechos**. |
41
+ | **Hosting** | `gradio.Server` en HF Space | Requisito duro (Gradio en Space) + mérito *Off-Brand* (frontend custom). |
42
+ | **Frontend** | Custom (vanilla/React) vía `gr.Server` | UI infantil (mascota, botones grandes). Dominio de la diseñadora UX. |
43
+ | **STT** | whisper.cpp, push-to-talk | Local (.cpp). OJO: ASR de niños es difícil → push-to-talk + vocabulario acotado. |
44
+ | **TTS** | **Kokoro, un único motor y una única voz** | Consistencia de voz = Lumi se siente UNA persona. Apache 2.0 (apto negocio). |
45
+ | **Seguridad** | Blocklist in/out + contenido curado | Defensa en profundidad. Blocklist editable por padres. |
46
+ | **Parental** | SQLite (log, blocklist, límite de tiempo) | Sin dependencias extra. Base del panel de padres. |
47
+
48
+ ### Nota sobre la voz (un solo motor, dos caminos)
49
+
50
+ Para que Lumi suene **siempre igual**, usamos **un único motor (Kokoro) y un único voice id**
51
+ en los dos caminos:
52
+
53
+ 1. **Cuentos / actividades fijas (contenido finito):** se **pre-renderizan una vez** con ese
54
+ mismo motor y se cachean los `.wav` → latencia cero y mejor toma. (Pre-render = cachear la
55
+ salida del mismo motor, NO usar otro motor).
56
+ 2. **Charla en vivo (respuestas cortas del LLM):** se sintetizan en tiempo real con el mismo
57
+ motor y voz.
58
+
59
+ Regla de oro: **mismo voice id y mismos settings (speed, pitch) en ambos caminos, siempre.**
60
+ Tips español: escribir números como palabras ("tres", no "3"); bajar un poco la velocidad.
61
+
62
+ **Voz premium / "voz de mamá" (v2, stretch):** clonar una voz (con consentimiento) con
63
+ Chatterbox-Turbo (MIT) y usar ese motor como único también en vivo (streaming en GPU).
64
+ No para el MVP: suma GPU en runtime y piezas móviles.
65
+
66
+ ## 4. Estrategia de créditos
67
+
68
+ - **Modal ($251):** entrenar **mejor, no más grande**. QLoRA del 7B (corrida ~$2–6), iteraciones,
69
+ evaluación, y **pre-renderizar el audio de los cuentos** (batch job offline). Más budget = más
70
+ y mejores datos + más iteraciones, NO un modelo más grande.
71
+ - **HF ($20):** hardware GPU del Space para servir el 7B GGUF por llama.cpp **solo en la ventana
72
+ de demo**, con sleep-on-idle (T4-small $0.40/h ≈ 50 h). **NO** gastar en Inference API (rompería
73
+ local-first). El TTS corre en CPU / pre-renderizado, así que la GPU es para el LLM.
74
+ - **No** usar ZeroGPU si implica soltar llama.cpp (está optimizado para transformers).
75
+ - **No** usar Modal para *servir* (la app debe ser el Space). Modal = entrenar + pre-render.
76
+
77
+ ## 5. Estructura del repo
78
+
79
+ ```
80
+ lumi/
81
+ ├── app.py # gradio.Server: sirve frontend + endpoints chat/transcribe/speak + /api/parental
82
+ ├── requirements.txt
83
+ ├── README.md
84
+ ├── llm/
85
+ │ └── engine.py # llama.cpp + system prompt + ruteo de contenido curado
86
+ ├── content/
87
+ │ ├── activities.json # contar, formas, colores, animales (números en palabras)
88
+ │ ├── stories.json # cuentos cortos curados
89
+ │ ├── loader.py # carga y selección por intención/edad
90
+ │ └── audio/ # .wav pre-renderizados (cache del Tier 1)
91
+ ├── voice/
92
+ │ ├── tts.py # UN motor (Kokoro): cache por id → si no, síntesis en vivo (misma voz)
93
+ │ └── stt.py # whisper.cpp, push-to-talk
94
+ ├── safety/
95
+ │ ├── guard.py # filtro temas prohibidos entrada+salida
96
+ │ └── blocklist.txt # editable por padres
97
+ ├── parental/
98
+ │ └── store.py # sqlite: log de actividades
99
+ ├── frontend/
100
+ │ └── index.html # UI infantil custom (usa @gradio/client)
101
+ ├── finetune/
102
+ │ ├── README.md # formato dataset + pipeline QLoRA → GGUF
103
+ │ ├── train_modal.py # job de QLoRA en Modal (a crear)
104
+ │ └── prerender_modal.py# pre-render de audio de cuentos en Modal (a crear)
105
+ └── models/
106
+ └── model.gguf # el GGUF (base o fine-tuneado)
107
+ ```
108
+
109
+ ## 6. Roadmap de 10 días
110
+
111
+ - **Día 1–2 — Esqueleto vivo + medir latencia.** `gr.Server` sirviendo frontend mínimo +
112
+ endpoint `chat` con Qwen-7B GGUF por llama.cpp. **Medir round-trip end-to-end con texto.**
113
+ Si el 7B se siente lento sin sumar voz → bajar a 3B antes de seguir. ⟵ semáforo.
114
+ - **Día 3 — Voz.** Kokoro (un motor, una voz) en vivo + whisper.cpp push-to-talk.
115
+ Probarlo con la nena de verdad; tomar notas (oro para blog + "la persona lo usó").
116
+ - **Día 4 — Contenido + seguridad.** 5–8 cuentos y actividades curadas reales. Pre-render de
117
+ su audio (mismo motor). Blocklist in/out.
118
+ - **Día 5 — Panel parental.** Log de actividades, lista permitidos/prohibidos, límite de tiempo.
119
+ - **Día 6 — Frontend custom.** La diseñadora UX pule la UI (mérito Off-Brand). Sesión real grabada.
120
+ - **Día 7–8 — Fine-tune.** QLoRA en Modal (estilo + seguridad) → merge → GGUF → publicar en HF
121
+ (mérito *fine-tuned*). Si se complica, se corta: el MVP ya está sólido.
122
+ - **Día 9 — Méritos blandos.** Blog/Field Notes + Open trace en el Hub. Deploy final.
123
+ - **Día 10 — Buffer.** Testing con la nena, video de demo, submission.
124
+
125
+ ## 7. Checklist de méritos
126
+
127
+ - [ ] **local-first / no cloud APIs** → pesos propios vía llama.cpp, sin APIs de inferencia externas.
128
+ - [ ] **llama.cpp** → `llm/engine.py`.
129
+ - [ ] **Off-Brand** → frontend custom servido por `gr.Server`.
130
+ - [ ] **Fine-tuned** → QLoRA publicado en HF (modelo merged + GGUF).
131
+ - [ ] **Open trace** → trace del agente compartido en el Hub.
132
+ - [ ] **Field Notes** → blog: "por qué un modelo chico fine-tuneado le gana a uno grande para una nena de 3 años".
133
+
134
+ ## 8. Narrativa para el blog (mérito 6)
135
+
136
+ Tesis: **para una nena de 3 años, un 7B fine-tuneado y bien acotado es la herramienta correcta;
137
+ un modelo frontier es over-engineering.** Respaldarlo con una comparación offline (7B fine-tuneado
138
+ vs base / vs uno más grande) hecha en Modal: latencia, consistencia de persona y rechazos de temas
139
+ prohibidos. El fine-tune aporta lo que el tamaño solo no da: persona estable + seguridad confiable.
140
+
141
+ ## 9. Riesgos y mitigaciones
142
+
143
+ - **Latencia del 7B** → medir el día 2; plan B 3B; GPU Space solo en demo.
144
+ - **STT de voz infantil** → push-to-talk, vocabulario acotado por actividad, adulto cerca en demo.
145
+ - **Alcance excesivo en 10 días** → MVP = conversación + cuentos + 4 actividades. Idiomas, biología,
146
+ generación de currícula y robot = roadmap post-hackatón.
147
+ - **Seguridad infantil** → contenido curado + blocklist + panel parental + límite de tiempo.
148
+
149
+ ## 10. Fuera de alcance (v2 / producto futuro)
150
+
151
+ Robot con carcasa 3D + Raspberry Pi (3B local); idiomas (en/pt/fr); biología por nivel;
152
+ generación de actividades alineadas a currícula de Chile/Argentina; voz clonada en vivo.
README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Lumi 🌟 — compañero educativo local-first para niños pequeños
2
+
3
+ Compañero conversacional por voz para una niña de ~3 años: charla cálida, cuentos
4
+ y actividades básicas (contar, formas, colores, animales), con panel parental.
5
+ Hecho para el **Build Small Hackathon** (track Backyard AI).
6
+
7
+ ## Idea de arquitectura (lo más importante)
8
+ El LLM es **solo el pegamento conversacional**. Los hechos y el contenido
9
+ (cuentos, actividades) salen de `content/` **curado por los padres**, no los
10
+ inventa el modelo. Esto mata casi toda la alucinación, reduce el riesgo de
11
+ contenido inapropiado, y es el encaje honesto con el modelo chico que premia el jurado.
12
+
13
+ ```
14
+ frontend/ (UI custom, gr.Server) → app.py (gradio.Server / FastAPI)
15
+ ├─ llm/ llama.cpp + GGUF (local-first)
16
+ ├─ content/ fuente de la verdad (curada)
17
+ ├─ voice/ faster-whisper (STT) + Kokoro (TTS)
18
+ ├─ safety/ filtro temas prohibidos
19
+ └─ parental/ log de actividades (sqlite)
20
+ ```
21
+
22
+ ## Mapeo de méritos
23
+ - **local-first / no cloud APIs** → corremos los pesos nosotros (llama.cpp), sin APIs externas.
24
+ - **llama.cpp** → `llm/engine.py` vía `llama-cpp-python`.
25
+ - **Off-Brand** → `gradio.Server` sirve un frontend propio (`frontend/`).
26
+ - **Fine-tuned** → ver `finetune/README.md` (QLoRA de estilo+seguridad → GGUF).
27
+ - **Open trace / Field Notes** → al final: trace en el Hub + blog.
28
+
29
+ ## Correr local
30
+ ```bash
31
+ pip install -r requirements.txt
32
+ # poné un GGUF en models/model.gguf (ej: Qwen2.5-7B-Instruct Q4_K_M) o exportá tu fine-tune
33
+ export MODEL_PATH=models/model.gguf
34
+ python app.py
35
+ ```
36
+ Abrí http://localhost:7860
37
+
38
+ ## Modelo recomendado
39
+ `Qwen2.5-7B-Instruct` (Q4_K_M) — sweet spot de calidez/fluidez en español.
40
+ Plan B / robot: `Qwen2.5-3B` o `Llama-3.2-3B`.
41
+
42
+ ## Notas honestas
43
+ - El STT de niños de 3 años es difícil: usar push-to-talk y vocabulario acotado.
44
+ - Medir latencia end-to-end (STT→LLM→TTS) temprano: define el tamaño del modelo.
45
+ - Lumi complementa, no reemplaza, el tiempo de los padres. Límite de tiempo en el panel.
app.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Lumi — compañero educativo local-first para niños pequeños.
3
+
4
+ Arquitectura clave:
5
+ - gradio.Server (FastAPI + motor Gradio) => mérito Off-Brand + hosting en Spaces
6
+ - llama.cpp (GGUF) corriendo el modelo nosotros => méritos local-first + llama.cpp
7
+ - El LLM es SOLO el pegamento conversacional. Los HECHOS/contenido
8
+ (cuentos, actividades) salen de `content/` curado, NO los inventa el modelo.
9
+ - Capa de seguridad de entrada/salida + log parental.
10
+
11
+ Endpoints (llamables desde el frontend con @gradio/client, y vía gradio_client):
12
+ - session_start: saludo inicial + opciones para arrancar la sesión
13
+ - chat : turno conversacional (texto -> texto + actividad opcional)
14
+ - transcribe : audio del niño -> texto (whisper.cpp)
15
+ - speak : texto -> audio (Piper TTS)
16
+ Rutas FastAPI normales:
17
+ - GET / : sirve el frontend custom
18
+ - GET /api/parental/log : últimas actividades (panel de padres)
19
+ """
20
+
21
+ import os
22
+ from gradio import Server
23
+ from gradio.data_classes import FileData
24
+ from fastapi.responses import HTMLResponse, JSONResponse
25
+
26
+ from llm.engine import LumiEngine
27
+ from content.loader import ContentLibrary
28
+ from safety.guard import SafetyGuard
29
+ from parental.store import ParentalStore
30
+ from voice import stt, tts
31
+
32
+ HERE = os.path.dirname(os.path.abspath(__file__))
33
+ MODEL_PATH = os.environ.get("MODEL_PATH", os.path.join(HERE, "models", "model.gguf"))
34
+
35
+ # --- componentes ---
36
+ content = ContentLibrary(os.path.join(HERE, "content"))
37
+ guard = SafetyGuard(blocklist_path=os.path.join(HERE, "safety", "blocklist.txt"))
38
+ store = ParentalStore(os.path.join(HERE, "parental", "lumi.db"))
39
+ engine = LumiEngine(model_path=MODEL_PATH, content=content, store=store)
40
+ tts.warmup()
41
+
42
+ app = Server()
43
+
44
+
45
+ @app.api(name="session_start")
46
+ def session_start(child_age: int = 3, child_name: str = "") -> dict:
47
+ """Arranque de sesión: saludo + opciones curadas para orientar el juego."""
48
+ store.start_session(child_age, child_name=child_name)
49
+ opening = engine.opening_prompt(child_age, child_name=child_name)
50
+ return {
51
+ "reply": opening["reply"],
52
+ "options": opening["options"],
53
+ "phase": "awaiting_mood",
54
+ }
55
+
56
+
57
+ @app.api(name="chat")
58
+ def chat(message: str, child_age: int = 3) -> dict:
59
+ """Un turno de conversación. Devuelve respuesta + (opcional) una actividad."""
60
+ # 1) seguridad de entrada (temas prohibidos definidos por los padres)
61
+ if guard.blocks_input(message):
62
+ reply = guard.gentle_redirect()
63
+ store.record_turn(child_age, message, reply, blocked=True)
64
+ return {"reply": reply, "activity": None}
65
+
66
+ phase = store.session_phase(child_age)
67
+ if phase == "awaiting_mood":
68
+ reply_data = engine.after_mood_prompt(message, child_age=child_age)
69
+ reply = reply_data["reply"]
70
+ store.set_session_phase(child_age, "ready")
71
+ store.record_turn(child_age, message, reply, blocked=False)
72
+ return {"reply": reply, "activity": None, "phase": "ready", "options": reply_data["options"]}
73
+
74
+ # 2) el motor decide: charla libre, o disparar una actividad curada
75
+ reply, activity = engine.respond(message, child_age=child_age)
76
+
77
+ # 3) seguridad de salida (defensa en profundidad)
78
+ if guard.blocks_output(reply):
79
+ reply = guard.safe_fallback()
80
+ activity = None
81
+
82
+ store.record_turn(child_age, message, reply, blocked=False, activity=activity)
83
+ return {"reply": reply, "activity": activity}
84
+
85
+
86
+ @app.api(name="transcribe")
87
+ def transcribe(audio: FileData, duration_ms: int | None = None) -> dict:
88
+ """Audio del niño -> texto. Push-to-talk, no escucha continua."""
89
+ return stt.transcribe(audio["path"], duration_ms=duration_ms)
90
+
91
+
92
+ @app.api(name="speak")
93
+ def speak(text: str) -> FileData:
94
+ """Texto -> audio (voz española). Devuelve un wav."""
95
+ out_path = tts.synthesize(text)
96
+ return FileData(path=out_path)
97
+
98
+
99
+ @app.get("/", response_class=HTMLResponse)
100
+ async def homepage():
101
+ with open(os.path.join(HERE, "frontend", "index.html"), encoding="utf-8") as f:
102
+ return f.read()
103
+
104
+
105
+ @app.get("/api/parental/log")
106
+ async def parental_log(limit: int = 50):
107
+ """Panel de padres: últimas interacciones."""
108
+ return JSONResponse(store.recent(limit))
109
+
110
+
111
+ @app.get("/api/parental/memory")
112
+ async def parental_memory(child_age: int = 3):
113
+ """Memoria estructurada para seguimiento y debug."""
114
+ return JSONResponse(store.memory_snapshot(child_age))
115
+
116
+
117
+ if __name__ == "__main__":
118
+ app.launch(show_error=True)
bench.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ bench.py — prueba LLM + TTS (edge-tts, Kokoro, Piper) + STT en local.
3
+ Guarda los audios en bench_output/ para escucharlos desde Windows.
4
+
5
+ Uso:
6
+ python bench.py
7
+ """
8
+
9
+ import os
10
+ import time
11
+ import asyncio
12
+ import subprocess
13
+ import soundfile as sf
14
+
15
+ HERE = os.path.dirname(os.path.abspath(__file__))
16
+ OUT_DIR = os.path.join(HERE, "bench_output")
17
+ os.makedirs(OUT_DIR, exist_ok=True)
18
+
19
+ TEXTO_PRUEBA = "¡Hola! Soy Lumi, tu amiga. ¿Querés que contemos juntos hasta tres?"
20
+ PROMPT_LLM = "Hola Lumi, ¿qué podemos jugar hoy?"
21
+ MODEL = "qwen2.5:7b"
22
+
23
+ SEP = "─" * 55
24
+
25
+ SYSTEM_PROMPT = (
26
+ "Sos Lumi, un amigo cálido y paciente para una niña de 3 años. "
27
+ "Hablás en español rioplatense con frases muy cortas y simples. "
28
+ "Máximo dos oraciones por respuesta."
29
+ )
30
+
31
+
32
+ # ── 1. LLM (Ollama) ──────────────────────────────────────────
33
+
34
+ def _ollama_chat(prompt: str) -> tuple[str, float]:
35
+ import ollama
36
+ t0 = time.perf_counter()
37
+ resp = ollama.chat(
38
+ model=MODEL,
39
+ messages=[
40
+ {"role": "system", "content": SYSTEM_PROMPT},
41
+ {"role": "user", "content": prompt},
42
+ ],
43
+ )
44
+ return resp["message"]["content"].strip(), time.perf_counter() - t0
45
+
46
+
47
+ def test_llm():
48
+ print(f"\n{SEP}")
49
+ print(" LLM — Ollama / Qwen2.5:7b")
50
+ print(SEP)
51
+ try:
52
+ import ollama
53
+ # Warmup: carga el modelo en VRAM antes de medir
54
+ print(" Calentando modelo (carga en VRAM)...")
55
+ _ollama_chat("hola")
56
+
57
+ print(f" Prompt: {PROMPT_LLM!r}")
58
+ reply, elapsed = _ollama_chat(PROMPT_LLM)
59
+ print(f" Respuesta: {reply}")
60
+ print(f" Tiempo (sin cold-start): {elapsed:.2f}s")
61
+ return True
62
+ except Exception as e:
63
+ print(f" ERROR: {e}")
64
+ print(" ¿Está corriendo Ollama? Probá: ollama serve")
65
+ return False
66
+
67
+
68
+ # ── 2. TTS edge-tts ───────────────────────────────────────────
69
+
70
+ EDGE_VOICES = [
71
+ ("es-MX-DaliaNeural", "mexicana_dalia"),
72
+ ("es-AR-ElenaNeural", "argentina_elena"),
73
+ ("es-ES-ElviraNeural", "espania_elvira"),
74
+ ("es-US-PalomaNeural", "latina_paloma"),
75
+ ]
76
+
77
+
78
+ async def _edge_synthesize(voice: str, out_path: str) -> float:
79
+ import edge_tts
80
+ t0 = time.perf_counter()
81
+ communicate = edge_tts.Communicate(TEXTO_PRUEBA, voice)
82
+ await communicate.save(out_path)
83
+ return time.perf_counter() - t0
84
+
85
+
86
+ def test_edge_tts():
87
+ print(f"\n{SEP}")
88
+ print(" TTS — edge-tts (voces neurales Microsoft)")
89
+ print(SEP)
90
+ try:
91
+ import edge_tts # noqa: F401
92
+ except ImportError:
93
+ print(" ERROR: edge-tts no instalado. Corré: pip install edge-tts")
94
+ return False
95
+
96
+ print(f" Texto: {TEXTO_PRUEBA!r}\n")
97
+ ok = False
98
+ for voice_id, tag in EDGE_VOICES:
99
+ out_path = os.path.join(OUT_DIR, f"edge_{tag}.mp3")
100
+ try:
101
+ elapsed = asyncio.run(_edge_synthesize(voice_id, out_path))
102
+ print(f" [{tag}] {voice_id}")
103
+ print(f" Audio: {out_path} ({elapsed:.2f}s)")
104
+ ok = True
105
+ except Exception as e:
106
+ print(f" [{tag}] ERROR: {e}")
107
+ return ok
108
+
109
+
110
+ # ── 3. TTS Kokoro ─────────────────────────────────────────────
111
+
112
+ def test_kokoro():
113
+ print(f"\n{SEP}")
114
+ print(" TTS — Kokoro (es)")
115
+ print(SEP)
116
+ try:
117
+ from kokoro import KPipeline
118
+ import numpy as np
119
+
120
+ for lang, voice in [("e", "ef_dora"), ("a", "af_heart")]:
121
+ try:
122
+ pipeline = KPipeline(lang_code=lang)
123
+ print(f" Texto: {TEXTO_PRUEBA!r}")
124
+ print(f" Voz: {voice} (lang={lang})")
125
+ t0 = time.perf_counter()
126
+ chunks = list(pipeline(TEXTO_PRUEBA, voice=voice))
127
+ elapsed = time.perf_counter() - t0
128
+
129
+ audio = chunks[0][2] if chunks else None
130
+ if audio is not None and len(chunks) > 1:
131
+ audio = np.concatenate([c[2] for c in chunks])
132
+
133
+ out_path = os.path.join(OUT_DIR, f"kokoro_{voice}.wav")
134
+ if audio is not None:
135
+ sf.write(out_path, audio, 24000)
136
+ print(f" Audio guardado: {out_path}")
137
+ print(f" Tiempo síntesis: {elapsed:.2f}s")
138
+ return True
139
+ except Exception as inner:
140
+ print(f" Voz {voice} (lang={lang}) falló: {inner}")
141
+ continue
142
+
143
+ print(" No se pudo generar audio con ninguna voz.")
144
+ return False
145
+ except ImportError:
146
+ print(" ERROR: kokoro no instalado.")
147
+ return False
148
+ except Exception as e:
149
+ print(f" ERROR: {e}")
150
+ return False
151
+
152
+
153
+ # ── 3. TTS Piper ──────────��───────────────────────────────────
154
+
155
+ def test_piper():
156
+ print(f"\n{SEP}")
157
+ print(" TTS — Piper (es_AR daniela)")
158
+ print(SEP)
159
+ piper_bin = os.path.join(HERE, "tools", "piper", "piper")
160
+ voice_path = os.path.join(HERE, "voices", "es_AR-daniela-high.onnx")
161
+ out_path = os.path.join(OUT_DIR, "piper_daniela.wav")
162
+
163
+ if not os.path.exists(piper_bin):
164
+ print(f" ERROR: binario no encontrado en {piper_bin}")
165
+ return False
166
+ if not os.path.exists(voice_path):
167
+ print(f" ERROR: voz no encontrada en {voice_path}")
168
+ return False
169
+
170
+ print(f" Texto: {TEXTO_PRUEBA!r}")
171
+ try:
172
+ t0 = time.perf_counter()
173
+ subprocess.run(
174
+ [piper_bin, "--model", voice_path, "--output_file", out_path],
175
+ input=TEXTO_PRUEBA.encode("utf-8"),
176
+ check=True,
177
+ capture_output=True,
178
+ )
179
+ elapsed = time.perf_counter() - t0
180
+ print(f" Audio guardado: {out_path}")
181
+ print(f" Tiempo síntesis: {elapsed:.2f}s")
182
+ return True
183
+ except subprocess.CalledProcessError as e:
184
+ print(f" ERROR al correr Piper: {e.stderr.decode()}")
185
+ return False
186
+ except Exception as e:
187
+ print(f" ERROR: {e}")
188
+ return False
189
+
190
+
191
+ # ── 4. STT faster-whisper ─────────────────────────────────────
192
+
193
+ def test_stt():
194
+ print(f"\n{SEP}")
195
+ print(" STT — faster-whisper (base, español)")
196
+ print(SEP)
197
+ # Usamos el wav de Piper (si existe) o Kokoro como audio de prueba
198
+ audio_path = os.path.join(OUT_DIR, "piper_daniela.wav")
199
+ if not os.path.exists(audio_path):
200
+ audio_path = os.path.join(OUT_DIR, "kokoro_ef_dora.wav")
201
+ if not os.path.exists(audio_path):
202
+ print(" Sin audio de prueba. Corré primero Piper o Kokoro.")
203
+ return False
204
+
205
+ try:
206
+ from faster_whisper import WhisperModel
207
+ print(" Cargando modelo base (primera vez descarga ~145MB)...")
208
+ model = WhisperModel("base", device="cpu", compute_type="int8")
209
+ print(f" Transcribiendo: {audio_path}")
210
+ t0 = time.perf_counter()
211
+ segments, info = model.transcribe(audio_path, language="es")
212
+ text = " ".join(s.text for s in segments).strip()
213
+ elapsed = time.perf_counter() - t0
214
+ print(f" Transcripción: {text!r}")
215
+ print(f" Tiempo: {elapsed:.2f}s")
216
+ return True
217
+ except ImportError:
218
+ print(" ERROR: faster-whisper no instalado. Corré: pip install faster-whisper")
219
+ return False
220
+ except Exception as e:
221
+ print(f" ERROR: {e}")
222
+ return False
223
+
224
+
225
+ # ── Main ──────────────────────────────────────────────────────
226
+
227
+ if __name__ == "__main__":
228
+ print(f"\n{'═' * 55}")
229
+ print(" LUMI — Benchmark local")
230
+ print(f" Audios en: {OUT_DIR}")
231
+ print(f"{'═' * 55}")
232
+
233
+ resultados = {
234
+ "LLM (Ollama)": test_llm(),
235
+ "TTS edge-tts": test_edge_tts(),
236
+ "TTS Kokoro": test_kokoro(),
237
+ "TTS Piper": test_piper(),
238
+ "STT faster-whisper": test_stt(),
239
+ }
240
+
241
+ print(f"\n{SEP}")
242
+ print(" RESUMEN")
243
+ print(SEP)
244
+ for nombre, ok in resultados.items():
245
+ estado = "✓" if ok else "✗"
246
+ print(f" {estado} {nombre}")
247
+ print()
content/__init__.py ADDED
File without changes
content/activities.json ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "count_apples_3",
4
+ "intent": "counting",
5
+ "min_age": 2,
6
+ "max_age": 4,
7
+ "title": "Contar manzanas",
8
+ "script": "Vamos a contar manzanas juntos. Hay 3 manzanas: una... dos... tres. ¿Podés contar conmigo hasta tres?"
9
+ },
10
+ {
11
+ "id": "shapes_basic",
12
+ "intent": "shapes",
13
+ "min_age": 2,
14
+ "max_age": 5,
15
+ "title": "Formas básicas",
16
+ "script": "Mirá esta forma redonda: es un círculo, como una pelota. ¿Ves algo redondo cerca tuyo?"
17
+ },
18
+ {
19
+ "id": "colors_primary",
20
+ "intent": "colors",
21
+ "min_age": 2,
22
+ "max_age": 5,
23
+ "title": "Colores",
24
+ "script": "El cielo de día es azul y el sol es amarillo. ¿De qué color es tu remera hoy?"
25
+ },
26
+ {
27
+ "id": "animals_farm",
28
+ "intent": "animals",
29
+ "min_age": 2,
30
+ "max_age": 5,
31
+ "title": "Animales de la granja",
32
+ "script": "La vaca hace 'muuu' y vive en la granja. ¿Sabés qué sonido hace el perro?"
33
+ },
34
+ {
35
+ "id": "count_toys_2",
36
+ "intent": "counting",
37
+ "min_age": 2,
38
+ "max_age": 4,
39
+ "title": "Contar juguetes",
40
+ "script": "Vamos a contar juguetes. Hay dos pelotas: una... dos. ¿Podés mostrarme dos deditos?"
41
+ },
42
+ {
43
+ "id": "count_fingers_5",
44
+ "intent": "counting",
45
+ "min_age": 3,
46
+ "max_age": 5,
47
+ "title": "Contar deditos",
48
+ "script": "Mirá tu manito: tiene cinco deditos. Uno, dos, tres, cuatro, cinco. ¿Podés contar los deditos de tu otra mano?"
49
+ },
50
+ {
51
+ "id": "shapes_square",
52
+ "intent": "shapes",
53
+ "min_age": 2,
54
+ "max_age": 5,
55
+ "title": "El cuadrado",
56
+ "script": "Esta forma tiene cuatro lados igualitos: es un cuadrado, como una ventana. ¿Ves algo cuadrado cerca tuyo?"
57
+ },
58
+ {
59
+ "id": "shapes_triangle",
60
+ "intent": "shapes",
61
+ "min_age": 3,
62
+ "max_age": 5,
63
+ "title": "El triángulo",
64
+ "script": "Esta forma tiene tres lados: es un triángulo, como un sombrero de fiesta. ¿Podés dibujar uno con el dedo en el aire?"
65
+ },
66
+ {
67
+ "id": "colors_rainbow",
68
+ "intent": "colors",
69
+ "min_age": 2,
70
+ "max_age": 5,
71
+ "title": "El arcoíris",
72
+ "script": "Después de la lluvia a veces aparece un arcoíris con muchos colores: rojo, naranja, amarillo, verde y azul. ¿Cuál es tu color favorito?"
73
+ },
74
+ {
75
+ "id": "animals_pets",
76
+ "intent": "animals",
77
+ "min_age": 2,
78
+ "max_age": 5,
79
+ "title": "Mascotas",
80
+ "script": "El perro hace 'guau guau' y el gato hace 'miau miau'. Los dos viven en casa con su familia. ¿Vos tenés alguna mascota?"
81
+ },
82
+ {
83
+ "id": "song_little_star",
84
+ "intent": "song",
85
+ "min_age": 2,
86
+ "max_age": 5,
87
+ "title": "Estrellita",
88
+ "script": "Vamos a cantar bajito: 'Estrellita, ¿dónde estás? Me pregunto qué serás'. ¿Querés cantarla conmigo?"
89
+ },
90
+ {
91
+ "id": "song_hands_clap",
92
+ "intent": "song",
93
+ "min_age": 2,
94
+ "max_age": 4,
95
+ "title": "Canción de las manitos",
96
+ "script": "Esta es una canción para las manitos: 'Palmas, palmas, palmitas, así hacen las manitos'. ¿Probamos juntos? ¡Aplaudí conmigo!"
97
+ },
98
+ {
99
+ "id": "emotion_happy",
100
+ "intent": "emotion",
101
+ "min_age": 2,
102
+ "max_age": 5,
103
+ "title": "Estar contenta",
104
+ "script": "Cuando estamos contentos, a veces sonreímos y saltamos de alegría. ¿Qué cosa te hace sentir muy contenta?"
105
+ },
106
+ {
107
+ "id": "emotion_calm",
108
+ "intent": "emotion",
109
+ "min_age": 3,
110
+ "max_age": 6,
111
+ "title": "Respirar despacito",
112
+ "script": "Cuando nos sentimos un poquito alterados, podemos respirar despacito: inhalamos por la nariz y soltamos el aire suave, como soplando una pluma. ¿Probamos respirar juntas, despacito?"
113
+ },
114
+ {
115
+ "id": "count_balloons_three",
116
+ "intent": "counting",
117
+ "min_age": 2,
118
+ "max_age": 4,
119
+ "title": "Globos de colores",
120
+ "script": "Tengo tres globos imaginarios: uno, dos y tres. ¿Me ayudás a contarlos conmigo? ¡Muy bien! Ya los contamos. Ahora vamos con otra aventura."
121
+ },
122
+ {
123
+ "id": "colors_around_me",
124
+ "intent": "colors",
125
+ "min_age": 2,
126
+ "max_age": 5,
127
+ "title": "Colores de alrededor",
128
+ "script": "Mirá alrededor tuyo y buscá algo rojo, algo azul o algo amarillo. Si encontrás uno, decime cuál es. ¡Genial! Ya jugamos a mirar colores."
129
+ },
130
+ {
131
+ "id": "shapes_house",
132
+ "intent": "shapes",
133
+ "min_age": 2,
134
+ "max_age": 5,
135
+ "title": "Casitas y formas",
136
+ "script": "Una ventana puede ser cuadrada y una rueda puede ser redonda. ¿Querés decirme cuál ves primero? ¡Qué bien! Las formas están por todos lados."
137
+ },
138
+ {
139
+ "id": "animals_home",
140
+ "intent": "animals",
141
+ "min_age": 2,
142
+ "max_age": 5,
143
+ "title": "Animales de casa",
144
+ "script": "El gato hace miau, el perro hace guau y el pajarito hace pío. ¿Cuál te gusta más? ¡Muy lindo! Ya escuchamos varios animales."
145
+ },
146
+ {
147
+ "id": "emotion_mirror",
148
+ "intent": "emotion",
149
+ "min_age": 2,
150
+ "max_age": 5,
151
+ "title": "Carita contenta",
152
+ "script": "Hagamos una carita contenta: sonreí grande, como si vieras un regalo. ¿La hiciste? ¡Qué bien! Tu carita quedó feliz."
153
+ },
154
+ {
155
+ "id": "song_clap_game",
156
+ "intent": "song",
157
+ "min_age": 2,
158
+ "max_age": 4,
159
+ "title": "Juego de palmas",
160
+ "script": "Vamos a hacer palmas suaves: palma, palma, pausa. ¿Querés hacerlo otra vez conmigo? ¡Bravo! Ya cantamos y movimos las manos."
161
+ }
162
+ ]
content/audio/.gitkeep ADDED
File without changes
content/loader.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Biblioteca de contenido CURADO (la fuente de la verdad, no el LLM).
3
+ Cargás JSON validado por ustedes. El motor pide content.pick(intent, edad)
4
+ y recibe un 'script' que el LLM presenta con calidez, sin inventar.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import random
10
+
11
+
12
+ class ContentLibrary:
13
+ def __init__(self, content_dir: str):
14
+ self.items = {} # intent -> list[dict]
15
+ self.titles = {} # content_id -> title (para armar bloques de contexto/memoria)
16
+ for name in ("activities", "stories"):
17
+ path = os.path.join(content_dir, f"{name}.json")
18
+ if os.path.exists(path):
19
+ with open(path, encoding="utf-8") as f:
20
+ for item in json.load(f):
21
+ self.items.setdefault(item["intent"], []).append(item)
22
+ self.titles[item["id"]] = item["title"]
23
+
24
+ def pick(self, intent: str, child_age: int, exclude_ids: set | None = None):
25
+ """Elige un ítem curado para la edad. Si hay `exclude_ids` (contenido ya visto
26
+ recientemente, ver ParentalStore.seen_ids), preferimos algo nuevo; si ya vio
27
+ todo lo disponible, repetimos antes que no ofrecer nada."""
28
+ candidates = [
29
+ it for it in self.items.get(intent, [])
30
+ if it.get("min_age", 0) <= child_age <= it.get("max_age", 99)
31
+ ]
32
+ if not candidates:
33
+ return None
34
+ if exclude_ids:
35
+ fresh = [it for it in candidates if it["id"] not in exclude_ids]
36
+ if fresh:
37
+ candidates = fresh
38
+ return random.choice(candidates)
content/stories.json ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "story_little_seed",
4
+ "intent": "story",
5
+ "min_age": 2,
6
+ "max_age": 6,
7
+ "title": "La semillita",
8
+ "script": "Había una vez una semillita chiquita que vivía bajo la tierra. Un día llovió y le dio sol, y despacito creció, creció... ¡hasta que fue una flor amarilla! Fin. ¿Te gustó? ¿Querés otro?"
9
+ },
10
+ {
11
+ "id": "story_sleepy_moon",
12
+ "intent": "story",
13
+ "min_age": 2,
14
+ "max_age": 6,
15
+ "title": "La luna dormilona",
16
+ "script": "La luna estaba cansada y se puso su gorrito de dormir. Las estrellas le cantaron bajito y la luna cerró los ojos. Shhh... a dormir. ¿Vos también tenés sueño?"
17
+ },
18
+ {
19
+ "id": "story_curious_caterpillar",
20
+ "intent": "story",
21
+ "min_age": 2,
22
+ "max_age": 6,
23
+ "title": "La oruga curiosa",
24
+ "script": "Había una oruga muy curiosa que comía hojitas verdes todo el día. Una noche se hizo una camita de seda y se quedó dormida. Cuando despertó, ¡tenía alas de colores! Ahora vuela de flor en flor. ¿Querés ver una mariposa de cerca algún día?"
25
+ },
26
+ {
27
+ "id": "story_rainy_day",
28
+ "intent": "story",
29
+ "min_age": 2,
30
+ "max_age": 6,
31
+ "title": "El día de lluvia",
32
+ "script": "Llovía mucho y las gotitas hacían 'pic, pic' en la ventana. Mili se puso sus botas amarillas y saltó en los charcos. ¡Splash, splash! Después tomó chocolate calentito. ¿A vos te gusta saltar en los charcos?"
33
+ },
34
+ {
35
+ "id": "story_grandma_soup",
36
+ "intent": "story",
37
+ "min_age": 2,
38
+ "max_age": 6,
39
+ "title": "La sopa de la abuela",
40
+ "script": "La abuela puso a hervir una sopa con zanahoria, papa y fideos. Olía riquísimo en toda la casa. Toda la familia se sentó a comer junta y se rieron mucho. ¿Cuál es tu comida favorita?"
41
+ },
42
+ {
43
+ "id": "story_lost_teddy",
44
+ "intent": "story",
45
+ "min_age": 2,
46
+ "max_age": 6,
47
+ "title": "El osito perdido",
48
+ "script": "Un osito de peluche se quedó debajo de la cama y nadie lo veía. Tito lo buscó despacito, despacito... ¡y lo encontró entre las almohadas! Le dio un abrazo bien fuerte. ¿Tenés algún muñeco favorito?"
49
+ },
50
+ {
51
+ "id": "story_little_cloud",
52
+ "intent": "story",
53
+ "min_age": 2,
54
+ "max_age": 6,
55
+ "title": "La nubecita",
56
+ "script": "Una nubecita blanca viajaba por el cielo. Vio árboles, casas y un perro que corría. Al final, se quedó quietita arriba del parque y descansó. Fin. ¿Te gustó la nubecita?"
57
+ },
58
+ {
59
+ "id": "story_red_boots",
60
+ "intent": "story",
61
+ "min_age": 2,
62
+ "max_age": 6,
63
+ "title": "Las botas rojas",
64
+ "script": "Una nena encontró unas botas rojas y quiso usarlas para saltar charquitos. Saltó una vez, saltó dos veces y se rió mucho. Después guardó las botas secas y dijo: listo, fin de juego. ¿Vos también te pondrías botas?"
65
+ },
66
+ {
67
+ "id": "story_little_train",
68
+ "intent": "story",
69
+ "min_age": 2,
70
+ "max_age": 6,
71
+ "title": "El tren chiquito",
72
+ "script": "Un tren chiquito llevaba cajitas de colores. Primero llevó una caja azul, después una verde y al final una amarilla. Cuando terminó, hizo pito pito y se fue a dormir. Fin. ¿Cuál caja te gustó más?"
73
+ },
74
+ {
75
+ "id": "story_birthday_cake",
76
+ "intent": "story",
77
+ "min_age": 2,
78
+ "max_age": 6,
79
+ "title": "La torta de cumpleaños",
80
+ "script": "En una mesa había una torta con velitas. Todos cantaron suavecito y la nena sopló fuerte. La torta quedó lista para compartir con risas. Fin. ¿Te gustan los cumpleaños?"
81
+ }
82
+ ]
demo.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ demo.py — loop de voz en vivo con Lumi.
3
+ [Enter] para empezar a hablar · [Enter] para parar · Ctrl+C para salir.
4
+ """
5
+ import os
6
+ import sys
7
+ import time
8
+ import asyncio
9
+ import tempfile
10
+ import subprocess
11
+
12
+ import numpy as np
13
+ import sounddevice as sd
14
+ import scipy.io.wavfile as wavfile
15
+ import ollama
16
+ import edge_tts
17
+ from faster_whisper import WhisperModel
18
+
19
+ MODEL = "qwen2.5:7b"
20
+ VOICE = "es-MX-DaliaNeural"
21
+ SR = 16000
22
+
23
+ SYSTEM_PROMPT = (
24
+ "Eres Lumi, un amigo cálido y paciente para niños de unos 3 años. "
25
+ "Siempre hablas en español latinoamericano neutro, con frases MUY cortas (máximo 2 oraciones). "
26
+ "Tono dulce, alegre y alentador. Celebras cada intento del niño. "
27
+ "Nunca inventas datos ni cuentos. Una sola pregunta por turno. "
28
+ "Si no entiendes algo, lo dices simple y propones un juego. "
29
+ "Nunca hablas de temas adultos, miedos ni violencia."
30
+ )
31
+
32
+ history: list[dict] = []
33
+
34
+
35
+ # ── Grabación push-to-talk ────────────────────────────────────
36
+
37
+ def record_push_to_talk() -> np.ndarray | None:
38
+ chunks: list[np.ndarray] = []
39
+
40
+ def _cb(indata, frames, t, status):
41
+ chunks.append(indata.copy())
42
+
43
+ try:
44
+ stream = sd.InputStream(samplerate=SR, channels=1, dtype="int16", callback=_cb)
45
+ stream.start()
46
+ print(" 🎙 Hablando... (Enter para terminar)", flush=True)
47
+ input()
48
+ stream.stop()
49
+ stream.close()
50
+ except Exception as e:
51
+ print(f" ERROR al acceder al micrófono: {e}")
52
+ print(" Verificá que WSL2 tiene acceso al micrófono.")
53
+ return None
54
+
55
+ if not chunks:
56
+ return None
57
+ audio = np.concatenate(chunks)
58
+ return audio if len(audio) >= SR * 0.5 else None # descartar < 0.5s
59
+
60
+
61
+ # ── STT ───────────────────────────────────────────────────────
62
+
63
+ def transcribe(audio: np.ndarray, model: WhisperModel) -> str:
64
+ path = tempfile.mktemp(suffix=".wav")
65
+ wavfile.write(path, SR, audio)
66
+ segments, _ = model.transcribe(path, language="es")
67
+ text = " ".join(s.text for s in segments).strip()
68
+ os.unlink(path)
69
+ return text
70
+
71
+
72
+ # ── LLM ───────────────────────────────────────────────────────
73
+
74
+ def chat_lumi(user_text: str) -> str:
75
+ history.append({"role": "user", "content": user_text})
76
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}] + history
77
+ resp = ollama.chat(model=MODEL, messages=messages)
78
+ reply = resp["message"]["content"].strip()
79
+ history.append({"role": "assistant", "content": reply})
80
+ if len(history) > 12:
81
+ history[:] = history[-12:]
82
+ return reply
83
+
84
+
85
+ # ── TTS ───────────────────────────────────────────────────────
86
+
87
+ async def _synthesize(text: str) -> str:
88
+ path = tempfile.mktemp(suffix=".mp3")
89
+ await edge_tts.Communicate(text, VOICE).save(path)
90
+ return path
91
+
92
+
93
+ def synthesize(text: str) -> str:
94
+ return asyncio.run(_synthesize(text))
95
+
96
+
97
+ def play(path: str):
98
+ # ffplay lanza un proceso fresco cada vez → sin estado de pygame entre turnos
99
+ subprocess.run(
100
+ ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", path],
101
+ check=True,
102
+ )
103
+ os.unlink(path)
104
+
105
+
106
+ # ── Helpers ───────────────────────────────────────────────────
107
+
108
+ def _check_mic() -> bool:
109
+ try:
110
+ devices = sd.query_devices()
111
+ inputs = [d for d in devices if d["max_input_channels"] > 0]
112
+ return len(inputs) > 0
113
+ except Exception:
114
+ return False
115
+
116
+
117
+ # ── Main ──────────────────────────────────────────────────────
118
+
119
+ def main():
120
+
121
+ print("\n" + "═" * 52)
122
+ print(" LUMI — Demo de voz en vivo")
123
+ print(" [Enter] hablar · [Enter] parar · Ctrl+C salir")
124
+ print("═" * 52 + "\n")
125
+
126
+ # Cargar STT
127
+ print(" Cargando Whisper small...", end="", flush=True)
128
+ whisper = WhisperModel("small", device="cpu", compute_type="int8")
129
+ print(" listo.")
130
+
131
+ # Warmup LLM (carga el modelo en VRAM)
132
+ print(" Cargando modelo en VRAM...", end="", flush=True)
133
+ ollama.chat(model=MODEL, messages=[{"role": "user", "content": "hola"}])
134
+ print(" listo.")
135
+
136
+ print("\n ¡Lumi está lista! Presioná Enter cuando quieras hablar.\n")
137
+
138
+ mic_ok = _check_mic()
139
+ if not mic_ok:
140
+ print(" ⚠ Micrófono no disponible — modo texto activado.")
141
+ print(" Escribí tu mensaje y presioná Enter. Ctrl+C para salir.\n")
142
+
143
+ while True:
144
+ try:
145
+ if mic_ok:
146
+ input() # Enter para arrancar cada turno
147
+ t_total = time.perf_counter()
148
+
149
+ # 1) Captura: voz o texto
150
+ if mic_ok:
151
+ audio = record_push_to_talk()
152
+ if audio is None:
153
+ print(" (audio muy corto, intentá de nuevo)\n")
154
+ continue
155
+ t = time.perf_counter()
156
+ text = transcribe(audio, whisper)
157
+ dt_stt = time.perf_counter() - t
158
+ else:
159
+ raw = input(" Vos: ").strip()
160
+ text = raw.encode("utf-8", "surrogatepass").decode("utf-8", "replace")
161
+ dt_stt = 0.0
162
+
163
+ if not text:
164
+ print(" (sin texto, intentá de nuevo)\n")
165
+ continue
166
+ print(f" Vos : {text!r} [STT {dt_stt:.1f}s]")
167
+
168
+ # 3) LLM
169
+ print(" → LLM pensando...", end="", flush=True)
170
+ t = time.perf_counter()
171
+ reply = chat_lumi(text)
172
+ dt_llm = time.perf_counter() - t
173
+ print(f" listo ({dt_llm:.1f}s)")
174
+ print(f" Lumi : {reply!r}")
175
+
176
+ # 4) TTS
177
+ print(" → TTS sintetizando...", end="", flush=True)
178
+ t = time.perf_counter()
179
+ mp3 = synthesize(reply)
180
+ dt_tts = time.perf_counter() - t
181
+ print(f" listo ({dt_tts:.1f}s) → {mp3}")
182
+
183
+ # 5) Reproducir
184
+ print(" → Reproduciendo audio...", end="", flush=True)
185
+ dt_total = time.perf_counter() - t_total
186
+ play(mp3)
187
+ print(" terminó.")
188
+ print(f" [STT {dt_stt:.1f}s · LLM {dt_llm:.1f}s · TTS {dt_tts:.1f}s · total {dt_total:.1f}s]\n")
189
+
190
+ except KeyboardInterrupt:
191
+ print("\n\n ¡Hasta luego!\n")
192
+ sys.exit(0)
193
+
194
+
195
+ if __name__ == "__main__":
196
+ main()
finetune/README.md ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fine-tune (mérito 2) — estilo y seguridad, NO hechos
2
+
3
+ Objetivo: que el 7–8B base hable siempre como Lumi (cálido, frases cortas, español
4
+ rioplatense), entregue bien las actividades curadas y **rechace temas prohibidos**.
5
+ NO inyectamos conocimiento factual: eso vive en `content/`.
6
+
7
+ ## Modelo base
8
+ `Qwen/Qwen2.5-7B-Instruct` (o 3B si la latencia/cómputo aprietan).
9
+
10
+ ## Dataset (formato chat, JSONL)
11
+ Apuntá a ~1–2k ejemplos de CALIDAD. Tres tipos, mezclados:
12
+
13
+ 1) Persona / charla cálida:
14
+ ```json
15
+ {"messages": [
16
+ {"role": "system", "content": "Eres Lumi..."},
17
+ {"role": "user", "content": "hola"},
18
+ {"role": "assistant", "content": "¡Hola! ¡Qué linda que viniste! ¿Querés jugar a contar?"}
19
+ ]}
20
+ ```
21
+ 2) Entrega de contenido curado (el assistant usa SOLO lo que viene en <contenido>):
22
+ ```json
23
+ {"messages": [
24
+ {"role": "system", "content": "Eres Lumi..."},
25
+ {"role": "user", "content": "contame un cuento\n<contenido>\nHabía una vez una semillita...\n</contenido>"},
26
+ {"role": "assistant", "content": "Te cuento uno cortito: Había una vez una semillita..."}
27
+ ]}
28
+ ```
29
+ 3) Rechazo / redirección de temas prohibidos:
30
+ ```json
31
+ {"messages": [
32
+ {"role": "system", "content": "Eres Lumi..."},
33
+ {"role": "user", "content": "[tema inapropiado]"},
34
+ {"role": "assistant", "content": "Mmm, mejor juguemos a otra cosa. ¿Contamos hasta tres?"}
35
+ ]}
36
+ ```
37
+
38
+ ## Entrenamiento (QLoRA, ~16GB VRAM)
39
+ Usá `trl` + `peft` + `bitsandbytes` (4-bit). Esquema:
40
+ - carga en 4-bit, LoRA r=16, alpha=32, target_modules de atención
41
+ - 1–3 épocas, lr ~2e-4, batch chico con grad accumulation
42
+
43
+ ## Export para llama.cpp (lo que corre el Space)
44
+ ```bash
45
+ # 1) merge del LoRA al base
46
+ python merge_lora.py --base Qwen/Qwen2.5-7B-Instruct --lora ./lumi-lora --out ./lumi-merged
47
+ # 2) convertir a GGUF (con llama.cpp)
48
+ python llama.cpp/convert_hf_to_gguf.py ./lumi-merged --outfile lumi.gguf
49
+ # 3) cuantizar
50
+ ./llama.cpp/llama-quantize lumi.gguf lumi-q4_k_m.gguf Q4_K_M
51
+ ```
52
+ Subí a HF tanto `lumi-merged` (transformers) como `lumi-q4_k_m.gguf` (cumple mérito 2).
53
+ Apuntá `MODEL_PATH` del Space al GGUF.
frontend/index.html ADDED
@@ -0,0 +1,468 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ Lumi – interfaz infantil con push-to-talk.
3
+ Flujo: botón → MediaRecorder → /transcribe → /chat → /speak → Audio.
4
+ Los tres endpoints están en app.py via gradio.Server.
5
+ -->
6
+ <!DOCTYPE html>
7
+ <html lang="es">
8
+ <head>
9
+ <meta charset="utf-8" />
10
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
11
+ <title>Lumi</title>
12
+ <style>
13
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
14
+
15
+ body {
16
+ font-family: system-ui, -apple-system, sans-serif;
17
+ background: linear-gradient(160deg, #ffffff 0%, #ffffff 100%);
18
+ min-height: 100dvh;
19
+ display: flex;
20
+ flex-direction: column;
21
+ align-items: center;
22
+ justify-content: center;
23
+ gap: 16px;
24
+ padding: 24px;
25
+ user-select: none;
26
+ }
27
+
28
+ /* ── cara de Lumi ── */
29
+ #face {
30
+ font-size: 100px;
31
+ line-height: 1;
32
+ transition: transform 0.2s;
33
+ }
34
+ #face.bounce {
35
+ animation: bounce 0.45s ease-in-out infinite alternate;
36
+ }
37
+ @keyframes bounce {
38
+ from { transform: translateY(0) scale(1); }
39
+ to { transform: translateY(-14px) scale(1.06); }
40
+ }
41
+
42
+ h1 {
43
+ font-size: 48px;
44
+ font-weight: 900;
45
+ color: #f5a623;
46
+ letter-spacing: 2px;
47
+ text-shadow: 0 3px 0 #e08a00;
48
+ }
49
+
50
+ /* ── botón principal ── */
51
+ #btn {
52
+ width: 180px;
53
+ height: 180px;
54
+ border-radius: 50%;
55
+ border: 6px solid transparent;
56
+ background: #ffd54a;
57
+ font-size: 64px;
58
+ cursor: pointer;
59
+ transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
60
+ box-shadow: 0 8px 28px rgba(0,0,0,0.18);
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ -webkit-tap-highlight-color: transparent;
65
+ touch-action: manipulation;
66
+ }
67
+ #btn:active:not(.locked) { transform: scale(0.93); }
68
+
69
+ #btn.recording {
70
+ background: #ff6b6b;
71
+ border-color: #ff3333;
72
+ animation: pulse 1.1s ease-in-out infinite;
73
+ }
74
+ @keyframes pulse {
75
+ 0%, 100% { box-shadow: 0 8px 28px rgba(255,80,80,0.25); }
76
+ 50% { box-shadow: 0 8px 48px rgba(255,80,80,0.65); }
77
+ }
78
+ #btn.locked {
79
+ background: #d0d0d0;
80
+ border-color: #bbb;
81
+ cursor: default;
82
+ box-shadow: none;
83
+ }
84
+
85
+ /* ── estado y respuesta ── */
86
+ #status {
87
+ font-size: 18px;
88
+ color: #777;
89
+ min-height: 26px;
90
+ text-align: center;
91
+ }
92
+ #reply {
93
+ font-size: 26px;
94
+ color: #333;
95
+ max-width: 400px;
96
+ text-align: center;
97
+ line-height: 1.45;
98
+ min-height: 80px;
99
+ }
100
+
101
+ /* ── panel de captura ── */
102
+ #capturePanel {
103
+ width: min(520px, 100%);
104
+ display: grid;
105
+ gap: 10px;
106
+ padding: 14px 16px;
107
+ border-radius: 20px;
108
+ background: rgba(255, 255, 255, 0.45);
109
+ border: 1px solid rgba(255, 209, 74, 0.35);
110
+ box-shadow: 0 10px 30px rgba(180, 120, 0, 0.08);
111
+ backdrop-filter: blur(10px);
112
+ }
113
+
114
+ #captureMeta {
115
+ display: flex;
116
+ justify-content: space-between;
117
+ gap: 12px;
118
+ flex-wrap: wrap;
119
+ font-size: 14px;
120
+ color: #6d5a19;
121
+ }
122
+
123
+ #meter {
124
+ width: 100%;
125
+ height: 16px;
126
+ border-radius: 999px;
127
+ overflow: hidden;
128
+ background: rgba(255, 214, 74, 0.25);
129
+ border: 1px solid rgba(255, 176, 0, 0.25);
130
+ }
131
+
132
+ #meterFill {
133
+ width: 0%;
134
+ height: 100%;
135
+ border-radius: inherit;
136
+ background: linear-gradient(90deg, #ffd54a 0%, #ff9f43 55%, #ff6b6b 100%);
137
+ transition: width 80ms linear;
138
+ }
139
+
140
+ #heardLabel {
141
+ font-size: 14px;
142
+ font-weight: 700;
143
+ color: #8b6611;
144
+ text-transform: uppercase;
145
+ letter-spacing: 0.08em;
146
+ }
147
+
148
+ #heardText {
149
+ min-height: 52px;
150
+ font-size: 18px;
151
+ line-height: 1.45;
152
+ color: #3a2e0a;
153
+ white-space: pre-wrap;
154
+ }
155
+
156
+ #sttDebug {
157
+ font-size: 13px;
158
+ color: #80631b;
159
+ opacity: 0.9;
160
+ line-height: 1.45;
161
+ min-height: 20px;
162
+ }
163
+
164
+ /* ── error banner ── */
165
+ #error {
166
+ display: none;
167
+ background: #ffe0e0;
168
+ border: 1px solid #ffaaaa;
169
+ border-radius: 12px;
170
+ padding: 10px 18px;
171
+ font-size: 15px;
172
+ color: #c00;
173
+ max-width: 380px;
174
+ text-align: center;
175
+ }
176
+ </style>
177
+ </head>
178
+ <body>
179
+
180
+ <div id="face">🌟</div>
181
+ <h1>Lumi</h1>
182
+
183
+ <button id="btn">🎙️</button>
184
+ <div id="status">Tocá para hablar</div>
185
+ <div id="reply"></div>
186
+ <section id="capturePanel" aria-live="polite">
187
+ <div id="captureMeta">
188
+ <span id="micState">Micrófono inactivo</span>
189
+ <span id="recordingTime">00:00</span>
190
+ </div>
191
+ <div id="meter" aria-hidden="true">
192
+ <div id="meterFill"></div>
193
+ </div>
194
+ <div>
195
+ <div id="heardLabel">Lo que entendí</div>
196
+ <div id="heardText">Todavía no grabé nada.</div>
197
+ <div id="sttDebug"></div>
198
+ </div>
199
+ </section>
200
+ <div id="error"></div>
201
+
202
+ <script type="module">
203
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
204
+
205
+ // ── conexión al backend ──────────────────────────────────────────────────
206
+ const client = await Client.connect(window.location.origin);
207
+
208
+ const btn = document.getElementById("btn");
209
+ const statusEl = document.getElementById("status");
210
+ const replyEl = document.getElementById("reply");
211
+ const errorEl = document.getElementById("error");
212
+ const faceEl = document.getElementById("face");
213
+ const micStateEl = document.getElementById("micState");
214
+ const recordingTimeEl = document.getElementById("recordingTime");
215
+ const meterFillEl = document.getElementById("meterFill");
216
+ const heardTextEl = document.getElementById("heardText");
217
+ const sttDebugEl = document.getElementById("sttDebug");
218
+ let sessionOpened = false;
219
+
220
+ // ── estados visuales ─────────────────────────────────────────────────────
221
+ const STATES = {
222
+ idle: { status: "Tocá para hablar", icon: "🎙️", face: "🌟", btnClass: "", bounce: false, mic: "Micrófono inactivo" },
223
+ recording: { status: "Escuchando… tocá para parar", icon: "⏹️", face: "👂", btnClass: "recording", bounce: false, mic: "Micrófono escuchando" },
224
+ thinking: { status: "Lumi está pensando…", icon: "💭", face: "🤔", btnClass: "locked", bounce: false, mic: "Procesando el audio" },
225
+ speaking: { status: "Lumi está hablando…", icon: "🔊", face: "😊", btnClass: "locked", bounce: true, mic: "Lumi está hablando" },
226
+ };
227
+
228
+ function setState(name) {
229
+ const s = STATES[name];
230
+ statusEl.textContent = s.status;
231
+ btn.textContent = s.icon;
232
+ btn.className = s.btnClass;
233
+ faceEl.textContent = s.face;
234
+ faceEl.className = s.bounce ? "bounce" : "";
235
+ micStateEl.textContent = s.mic;
236
+ }
237
+
238
+ function showError(msg) {
239
+ errorEl.style.display = "block";
240
+ errorEl.textContent = msg;
241
+ setTimeout(() => { errorEl.style.display = "none"; }, 4000);
242
+ }
243
+
244
+ async function startSession() {
245
+ if (sessionOpened) return;
246
+ sessionOpened = true;
247
+ try {
248
+ const r = await client.predict("/session_start", { child_age: 3, child_name: "" });
249
+ const data = r.data?.[0] ?? {};
250
+ replyEl.textContent = data.reply ?? "Hola. ¿Cómo estás hoy?";
251
+ statusEl.textContent = "Lumi te está saludando";
252
+ heardTextEl.textContent = "Esperando tu respuesta...";
253
+ sttDebugEl.textContent = "";
254
+ } catch {
255
+ replyEl.textContent = "Hola. ¿Cómo estás hoy?";
256
+ statusEl.textContent = "Lumi te está saludando";
257
+ heardTextEl.textContent = "Esperando tu respuesta...";
258
+ }
259
+ }
260
+
261
+ // ── grabación ────────────────────────────────────────────────────────────
262
+ let mediaRecorder = null;
263
+ let chunks = [];
264
+ let isRecording = false;
265
+ let stream = null;
266
+ let audioContext = null;
267
+ let analyser = null;
268
+ let audioSource = null;
269
+ let meterFrame = 0;
270
+ let startedAt = 0;
271
+ let lastDurationMs = 0;
272
+
273
+ // Preferir wav si está disponible; webm como fallback (ambos entiende whisper)
274
+ function getBestMimeType() {
275
+ const candidates = ["audio/wav", "audio/webm;codecs=opus", "audio/webm", "audio/mp4"];
276
+ return candidates.find(t => MediaRecorder.isTypeSupported(t)) ?? "";
277
+ }
278
+
279
+ function formatClock(ms) {
280
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
281
+ const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0");
282
+ const seconds = String(totalSeconds % 60).padStart(2, "0");
283
+ return `${minutes}:${seconds}`;
284
+ }
285
+
286
+ function setMeterLevel(value) {
287
+ const clamped = Math.max(0, Math.min(100, value));
288
+ meterFillEl.style.width = `${clamped}%`;
289
+ }
290
+
291
+ function stopMeter() {
292
+ cancelAnimationFrame(meterFrame);
293
+ meterFrame = 0;
294
+ setMeterLevel(0);
295
+ recordingTimeEl.textContent = "00:00";
296
+ if (audioSource) {
297
+ audioSource.disconnect();
298
+ audioSource = null;
299
+ }
300
+ if (analyser) {
301
+ analyser.disconnect();
302
+ analyser = null;
303
+ }
304
+ if (audioContext) {
305
+ audioContext.close().catch(() => {});
306
+ audioContext = null;
307
+ }
308
+ stream = null;
309
+ }
310
+
311
+ function startMeter() {
312
+ if (!stream) return;
313
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
314
+ analyser = audioContext.createAnalyser();
315
+ analyser.fftSize = 256;
316
+ audioSource = audioContext.createMediaStreamSource(stream);
317
+ audioSource.connect(analyser);
318
+
319
+ const data = new Uint8Array(analyser.frequencyBinCount);
320
+ const tick = () => {
321
+ if (!analyser) return;
322
+ analyser.getByteFrequencyData(data);
323
+ let total = 0;
324
+ for (const n of data) total += n;
325
+ const average = total / data.length;
326
+ setMeterLevel((average / 255) * 100);
327
+ recordingTimeEl.textContent = formatClock(Date.now() - startedAt);
328
+ meterFrame = requestAnimationFrame(tick);
329
+ };
330
+ tick();
331
+ }
332
+
333
+ async function startRecording() {
334
+ try {
335
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
336
+ } catch {
337
+ showError("Sin acceso al micrófono. Revisá los permisos del navegador.");
338
+ return;
339
+ }
340
+
341
+ chunks = [];
342
+ const mimeType = getBestMimeType();
343
+ mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
344
+ mediaRecorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
345
+ mediaRecorder.start(100); // chunks cada 100 ms para no perder el último fragmento
346
+ startedAt = Date.now();
347
+ lastDurationMs = 0;
348
+ startMeter();
349
+ isRecording = true;
350
+ heardTextEl.textContent = "Grabando tu voz...";
351
+ sttDebugEl.textContent = "";
352
+ setState("recording");
353
+ }
354
+
355
+ async function stopRecording() {
356
+ isRecording = false;
357
+ setState("thinking");
358
+ lastDurationMs = Date.now() - startedAt;
359
+
360
+ await new Promise(resolve => {
361
+ mediaRecorder.onstop = resolve;
362
+ mediaRecorder.stop();
363
+ mediaRecorder.stream.getTracks().forEach(t => t.stop());
364
+ });
365
+ stopMeter();
366
+
367
+ const mimeType = mediaRecorder.mimeType || "audio/webm";
368
+ const ext = mimeType.includes("mp4") ? "mp4" : mimeType.includes("wav") ? "wav" : "webm";
369
+ const blob = new Blob(chunks, { type: mimeType });
370
+
371
+ if (blob.size < 4000) {
372
+ replyEl.textContent = "";
373
+ statusEl.textContent = "Demasiado corto, intentá de nuevo";
374
+ heardTextEl.textContent = "No capté suficiente audio.";
375
+ setTimeout(() => setState("idle"), 2200);
376
+ return;
377
+ }
378
+
379
+ await processAudio(blob, ext);
380
+ }
381
+
382
+ // ── pipeline: audio → texto → LLM → voz ─────────────────────────────────
383
+ async function processAudio(blob, ext) {
384
+ // 1) STT
385
+ let text = "";
386
+ try {
387
+ const audioFile = new File([blob], `audio.${ext}`, { type: blob.type });
388
+ const r = await client.predict("/transcribe", { audio: audioFile, duration_ms: lastDurationMs });
389
+ text = r.data?.[0]?.text ?? "";
390
+ const debug = r.data?.[0]?.debug ?? {};
391
+ const parts = [];
392
+ if (typeof debug.duration_s === "number") parts.push(`duración ${debug.duration_s.toFixed(1)}s`);
393
+ if (typeof debug.language_probability === "number") parts.push(`idioma ${debug.language_probability.toFixed(2)}`);
394
+ if (typeof debug.avg_logprob === "number") parts.push(`confianza ${debug.avg_logprob.toFixed(2)}`);
395
+ if (debug.rejected_reason) parts.push(`alerta ${debug.rejected_reason}`);
396
+ sttDebugEl.textContent = parts.length ? parts.join(" · ") : "";
397
+ } catch (e) {
398
+ showError("Error al transcribir el audio.");
399
+ heardTextEl.textContent = "No pude transcribir el audio.";
400
+ sttDebugEl.textContent = "";
401
+ setState("idle");
402
+ return;
403
+ }
404
+
405
+ if (!text.trim()) {
406
+ statusEl.textContent = "No te escuché bien, intentá de nuevo";
407
+ heardTextEl.textContent = "No entendí ninguna palabra clara.";
408
+ if (!sttDebugEl.textContent) {
409
+ sttDebugEl.textContent = "Probá hablar más cerca o con frases más cortas.";
410
+ }
411
+ setTimeout(() => setState("idle"), 2200);
412
+ return;
413
+ }
414
+
415
+ heardTextEl.textContent = text;
416
+ micStateEl.textContent = "Texto detectado";
417
+
418
+ // 2) LLM
419
+ let reply = "";
420
+ try {
421
+ const r = await client.predict("/chat", { message: text, child_age: 3 });
422
+ reply = r.data?.[0]?.reply ?? "";
423
+ } catch (e) {
424
+ showError("Error al generar respuesta.");
425
+ setState("idle");
426
+ return;
427
+ }
428
+
429
+ replyEl.textContent = reply;
430
+
431
+ // 3) TTS
432
+ let audioUrl = "";
433
+ try {
434
+ const r = await client.predict("/speak", { text: reply });
435
+ // FileData puede venir con .url o con .path (Gradio lo resuelve según versión)
436
+ const fd = r.data?.[0];
437
+ audioUrl = fd?.url ?? fd?.path ?? "";
438
+ } catch (e) {
439
+ // Si TTS falla igual se muestra el texto
440
+ setState("idle");
441
+ return;
442
+ }
443
+
444
+ // 4) Reproducir
445
+ if (audioUrl) {
446
+ setState("speaking");
447
+ const audio = new Audio(audioUrl);
448
+ audio.onended = () => setState("idle");
449
+ audio.onerror = () => setState("idle");
450
+ audio.play().catch(() => setState("idle"));
451
+ } else {
452
+ setState("idle");
453
+ }
454
+ }
455
+
456
+ // ── evento del botón ─────────────────────────────────────────────────────
457
+ btn.addEventListener("click", () => {
458
+ if (btn.classList.contains("locked")) return;
459
+ if (isRecording) stopRecording();
460
+ else startRecording();
461
+ });
462
+
463
+ setState("idle");
464
+ heardTextEl.textContent = "Esperando tu respuesta...";
465
+ await startSession();
466
+ </script>
467
+ </body>
468
+ </html>
llm/__init__.py ADDED
File without changes
llm/engine.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Motor conversacional. El LLM NUNCA es la fuente de la verdad:
3
+ - decide la intención (charlar / contar / formas / colores / animales / cuento)
4
+ - si hay una actividad o cuento, los TEXTOS salen de `content/` (curados),
5
+ el modelo solo los presenta con calidez y maneja el turno.
6
+
7
+ Runtime: llama.cpp vía llama-cpp-python (GGUF, chat_format="chatml"), 100% local.
8
+ """
9
+
10
+ import ctypes
11
+ import os
12
+ import sysconfig
13
+
14
+
15
+ def _preload_cuda_libs() -> None:
16
+ """Dev local con GPU: el wheel CUDA de llama-cpp-python necesita poder
17
+ resolver libcudart/libcublas. Si los paquetes nvidia-cuda-runtime-cu12 /
18
+ nvidia-cublas-cu12 están instalados los precargamos; si no (build
19
+ CPU-only, como en el HF Space), no hace falta y no pasa nada."""
20
+ site_packages = sysconfig.get_paths()["purelib"]
21
+ for rel in ("nvidia/cuda_runtime/lib/libcudart.so.12", "nvidia/cublas/lib/libcublas.so.12"):
22
+ path = os.path.join(site_packages, rel)
23
+ if os.path.exists(path):
24
+ ctypes.CDLL(path, mode=ctypes.RTLD_GLOBAL)
25
+
26
+
27
+ _preload_cuda_libs()
28
+
29
+ from llama_cpp import Llama
30
+
31
+ SYSTEM_PROMPT = """Eres Lumi, un amigo de juego cálido y paciente para una niña de unos 3 años.
32
+ Reglas que SIEMPRE cumples:
33
+ - Hablas en español latinoamericano neutro, con frases MUY cortas y simples (máximo dos oraciones).
34
+ - Tono dulce, alegre y alentador. Celebras cada intento ("¡muy bien!", "¡qué lindo!").
35
+ - NUNCA inventas datos, cuentos ni números. Si hace falta contenido (un cuento, una
36
+ cuenta, una figura), usas SOLO el texto que te pasa el sistema entre <contenido>...</contenido>.
37
+ - Si el sistema te pasa un bloque <contexto>...</contexto>, son datos reales sobre lo que
38
+ ya vivieron juntos (para sonar cercana, ej. "¡como el cuento que te conté el otro día!").
39
+ Úsalo solo para el tono cálido: NUNCA inventes detalles nuevos a partir de él.
40
+ - Si no sabes algo o no tienes contenido, lo dices simple y propones un juego: no inventas.
41
+ - Nunca hablas de temas adultos, miedos, violencia ni nada inapropiado. Rediriges con cariño.
42
+ - Una pregunta por vez. Esperas la respuesta de la niña.
43
+ """
44
+
45
+ INTENT_LABELS = {
46
+ "story": "los cuentos",
47
+ "counting": "contar",
48
+ "shapes": "las formas",
49
+ "colors": "los colores",
50
+ "animals": "los animales",
51
+ "song": "las canciones",
52
+ "emotion": "hablar de emociones",
53
+ }
54
+
55
+ OPENING_INTENTS = (
56
+ "counting",
57
+ "colors",
58
+ "animals",
59
+ "story",
60
+ "shapes",
61
+ "song",
62
+ "emotion",
63
+ )
64
+
65
+ POSITIVE_CUES = ("bien", "genial", "contenta", "contento", "feliz", "alegre", "sí", "si")
66
+ NEGATIVE_CUES = ("mal", "triste", "cansada", "cansado", "enojada", "enojado", "no")
67
+
68
+
69
+ class LumiEngine:
70
+ def __init__(self, model_path: str, content, store=None, n_ctx: int = 4096, n_gpu_layers: int | None = None):
71
+ self.content = content
72
+ self.store = store
73
+ if n_gpu_layers is None:
74
+ # -1 = todas las capas en GPU (dev local). En el Space: LUMI_N_GPU_LAYERS=0 (CPU-only).
75
+ n_gpu_layers = int(os.environ.get("LUMI_N_GPU_LAYERS", "-1"))
76
+ self.llm = Llama(
77
+ model_path=model_path,
78
+ chat_format="chatml",
79
+ n_ctx=n_ctx,
80
+ n_gpu_layers=n_gpu_layers,
81
+ verbose=False,
82
+ )
83
+
84
+ def _intent(self, message: str) -> str:
85
+ m = message.lower()
86
+ if any(w in m for w in ("cuento", "cuentito", "historia")):
87
+ return "story"
88
+ if any(w in m for w in ("contar", "número", "numero", "cuántos", "cuantos")):
89
+ return "counting"
90
+ if any(w in m for w in ("forma", "figura", "círculo", "circulo", "cuadrado", "triángulo", "triangulo")):
91
+ return "shapes"
92
+ if any(w in m for w in ("color", "colores")):
93
+ return "colors"
94
+ if any(w in m for w in ("animal", "animales", "perro", "gato", "vaca")):
95
+ return "animals"
96
+ if any(w in m for w in ("canción", "cancion", "canta", "cantar", "rima")):
97
+ return "song"
98
+ if any(w in m for w in ("emoción", "emocion", "siento", "sentir", "contenta", "contento", "triste", "enojada", "enojado")):
99
+ return "emotion"
100
+ return "chat"
101
+
102
+ def _context_block(self, child_age: int) -> str:
103
+ if not self.store:
104
+ return ""
105
+ return self.store.memory_block(child_age, limit=3)
106
+
107
+ def _opening_options(self, child_age: int, limit: int = 3):
108
+ seen = self.store.seen_ids(child_age) if self.store else set()
109
+ used_ids = set(seen or set())
110
+ picked = []
111
+
112
+ for intent in OPENING_INTENTS:
113
+ if len(picked) >= limit:
114
+ break
115
+ activity = self.content.pick(intent, child_age, exclude_ids=used_ids)
116
+ if activity and activity["id"] not in used_ids:
117
+ picked.append(activity)
118
+ used_ids.add(activity["id"])
119
+
120
+ if len(picked) < limit:
121
+ for items in self.content.items.values():
122
+ for activity in items:
123
+ if len(picked) >= limit:
124
+ break
125
+ if activity["id"] in used_ids:
126
+ continue
127
+ if not (activity.get("min_age", 0) <= child_age <= activity.get("max_age", 99)):
128
+ continue
129
+ picked.append(activity)
130
+ used_ids.add(activity["id"])
131
+ if len(picked) >= limit:
132
+ break
133
+
134
+ return picked
135
+
136
+ @staticmethod
137
+ def _format_opening_options(options) -> str:
138
+ titles = [opt["title"].lower() for opt in options if opt.get("title")]
139
+ if not titles:
140
+ return "un cuento, contar o jugar con colores"
141
+ if len(titles) == 1:
142
+ return titles[0]
143
+ if len(titles) == 2:
144
+ return f"{titles[0]} o {titles[1]}"
145
+ return f"{titles[0]}, {titles[1]} o {titles[2]}"
146
+
147
+ def opening_prompt(self, child_age: int, child_name: str = "") -> dict:
148
+ options = self._opening_options(child_age, limit=3)
149
+ name = child_name.strip()
150
+ greet = f"Hola{', ' + name if name else ''}. ¿Cómo estás hoy?"
151
+ options_text = self._format_opening_options(options)
152
+ reply = f"{greet} Hoy podemos hacer {options_text}. ¿Qué tenés ganas de hacer?"
153
+ return {"reply": reply, "options": options}
154
+
155
+ def after_mood_prompt(self, message: str, child_age: int) -> dict:
156
+ m = message.lower()
157
+ if any(word in m for word in POSITIVE_CUES):
158
+ prefix = "Qué bueno"
159
+ elif any(word in m for word in NEGATIVE_CUES):
160
+ prefix = "Te acompaño"
161
+ else:
162
+ prefix = "Gracias por contarme"
163
+
164
+ options = self._opening_options(child_age, limit=3)
165
+ options_text = self._format_opening_options(options)
166
+ reply = f"{prefix}. Hoy podemos hacer {options_text}. ¿Qué tenés ganas de hacer?"
167
+ return {"reply": reply, "options": options}
168
+
169
+ def _history_messages(self, child_age: int, limit: int = 3) -> list[dict]:
170
+ """Últimos turnos reales (user/assistant) de esta conversación, para que
171
+ el modelo mantenga el hilo (referencias, tono) además del resumen que
172
+ da `_context_block`."""
173
+ if not self.store:
174
+ return []
175
+ turns = self.store.recent_turns(child_age, limit=limit)
176
+ messages: list[dict] = []
177
+ for turn in reversed(turns):
178
+ if turn["blocked"] or not turn["message"] or not turn["reply"]:
179
+ continue
180
+ messages.append({"role": "user", "content": turn["message"]})
181
+ messages.append({"role": "assistant", "content": turn["reply"]})
182
+ return messages
183
+
184
+ def respond(self, message: str, child_age: int = 3):
185
+ intent = self._intent(message)
186
+ activity = None
187
+ content_block = ""
188
+
189
+ if intent != "chat":
190
+ seen = self.store.seen_ids(child_age) if self.store else None
191
+ activity = self.content.pick(intent, child_age, exclude_ids=seen)
192
+ if activity:
193
+ content_block = f"\n<contenido>\n{activity['script']}\n</contenido>\n"
194
+ if self.store:
195
+ self.store.mark_seen(child_age, activity["id"], intent, activity.get("title"))
196
+
197
+ context_block = self._context_block(child_age)
198
+ user_turn = message + content_block + context_block
199
+
200
+ messages = [
201
+ {"role": "system", "content": SYSTEM_PROMPT},
202
+ *self._history_messages(child_age),
203
+ {"role": "user", "content": user_turn},
204
+ ]
205
+ resp = self.llm.create_chat_completion(messages=messages, max_tokens=120, temperature=0.6)
206
+ reply = resp["choices"][0]["message"]["content"].strip()
207
+ return reply, activity
parental/__init__.py ADDED
File without changes
parental/store.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Memoria local de Lumi basada en SQLite.
3
+
4
+ Usos principales:
5
+ - panel parental: ver turnos, actividades y señales de preferencia
6
+ - memoria de conversación: seguir el hilo reciente
7
+ - memoria de contenido: evitar repetir siempre lo mismo
8
+ - estado corto de sesión: saber qué actividad estaba abierta
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sqlite3
15
+ import time
16
+ from typing import Any
17
+
18
+
19
+ class ParentalStore:
20
+ def __init__(self, db_path: str):
21
+ self.db_path = db_path
22
+ con = self._con()
23
+ self._ensure_schema(con)
24
+ con.commit()
25
+ con.close()
26
+
27
+ def _con(self):
28
+ con = sqlite3.connect(self.db_path)
29
+ con.row_factory = sqlite3.Row
30
+ return con
31
+
32
+ def _ensure_schema(self, con):
33
+ con.execute(
34
+ """CREATE TABLE IF NOT EXISTS log (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ ts REAL,
37
+ child_age INTEGER,
38
+ message TEXT,
39
+ reply TEXT,
40
+ blocked INTEGER)"""
41
+ )
42
+ con.execute(
43
+ """CREATE TABLE IF NOT EXISTS seen_content (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ ts REAL,
46
+ child_age INTEGER,
47
+ content_id TEXT,
48
+ intent TEXT,
49
+ title TEXT)"""
50
+ )
51
+ con.execute(
52
+ """CREATE TABLE IF NOT EXISTS session_state (
53
+ child_age INTEGER PRIMARY KEY,
54
+ active_content_id TEXT,
55
+ active_intent TEXT,
56
+ active_title TEXT,
57
+ active_step TEXT,
58
+ last_user_text TEXT,
59
+ last_reply TEXT,
60
+ last_ts REAL,
61
+ meta_json TEXT)"""
62
+ )
63
+ con.execute(
64
+ """CREATE TABLE IF NOT EXISTS activity_events (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ ts REAL,
67
+ child_age INTEGER,
68
+ content_id TEXT,
69
+ intent TEXT,
70
+ title TEXT,
71
+ status TEXT,
72
+ user_text TEXT,
73
+ reply_text TEXT,
74
+ extra_json TEXT)"""
75
+ )
76
+ cols = {row["name"] for row in con.execute("PRAGMA table_info(seen_content)").fetchall()}
77
+ if "title" not in cols:
78
+ con.execute("ALTER TABLE seen_content ADD COLUMN title TEXT")
79
+
80
+ def _ensure_session_row(self, con, child_age: int):
81
+ con.execute(
82
+ "INSERT OR IGNORE INTO session_state (child_age, last_ts, meta_json) VALUES (?, ?, ?)",
83
+ (child_age, time.time(), "{}"),
84
+ )
85
+
86
+ def _load_session(self, con, child_age: int) -> dict[str, Any]:
87
+ self._ensure_session_row(con, child_age)
88
+ row = con.execute(
89
+ "SELECT * FROM session_state WHERE child_age=?",
90
+ (child_age,),
91
+ ).fetchone()
92
+ if not row:
93
+ return {}
94
+ data = dict(row)
95
+ meta = data.get("meta_json") or "{}"
96
+ try:
97
+ data["meta"] = json.loads(meta)
98
+ except json.JSONDecodeError:
99
+ data["meta"] = {}
100
+ return data
101
+
102
+ def _save_session(self, con, child_age: int, updates: dict[str, Any]):
103
+ current = self._load_session(con, child_age)
104
+ meta = dict(current.get("meta", {}))
105
+ if "meta" in updates:
106
+ meta.update(updates.pop("meta") or {})
107
+ current.update(updates)
108
+ current["meta"] = meta
109
+ con.execute(
110
+ """INSERT INTO session_state (
111
+ child_age, active_content_id, active_intent, active_title, active_step,
112
+ last_user_text, last_reply, last_ts, meta_json
113
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
114
+ ON CONFLICT(child_age) DO UPDATE SET
115
+ active_content_id=excluded.active_content_id,
116
+ active_intent=excluded.active_intent,
117
+ active_title=excluded.active_title,
118
+ active_step=excluded.active_step,
119
+ last_user_text=excluded.last_user_text,
120
+ last_reply=excluded.last_reply,
121
+ last_ts=excluded.last_ts,
122
+ meta_json=excluded.meta_json""",
123
+ (
124
+ child_age,
125
+ current.get("active_content_id"),
126
+ current.get("active_intent"),
127
+ current.get("active_title"),
128
+ current.get("active_step"),
129
+ current.get("last_user_text"),
130
+ current.get("last_reply"),
131
+ current.get("last_ts", time.time()),
132
+ json.dumps(current.get("meta", {}), ensure_ascii=False),
133
+ ),
134
+ )
135
+
136
+ def start_session(self, child_age: int, child_name: str = ""):
137
+ """Abre una sesión nueva o reinicia el estado corto de la sesión."""
138
+ con = self._con()
139
+ now = time.time()
140
+ self._save_session(
141
+ con,
142
+ child_age,
143
+ {
144
+ "active_content_id": None,
145
+ "active_intent": None,
146
+ "active_title": None,
147
+ "active_step": "intro",
148
+ "last_user_text": None,
149
+ "last_reply": None,
150
+ "last_ts": now,
151
+ "meta": {
152
+ "phase": "awaiting_mood",
153
+ "child_name": child_name.strip(),
154
+ "started_at": now,
155
+ "intro_sent_at": now,
156
+ },
157
+ },
158
+ )
159
+ con.commit()
160
+ con.close()
161
+
162
+ def session_phase(self, child_age: int) -> str:
163
+ con = self._con()
164
+ session = self._load_session(con, child_age)
165
+ con.close()
166
+ return (session.get("meta") or {}).get("phase", "ready")
167
+
168
+ def set_session_phase(self, child_age: int, phase: str):
169
+ con = self._con()
170
+ session = self._load_session(con, child_age)
171
+ meta = dict(session.get("meta", {}))
172
+ meta["phase"] = phase
173
+ self._save_session(
174
+ con,
175
+ child_age,
176
+ {
177
+ "active_content_id": session.get("active_content_id"),
178
+ "active_intent": session.get("active_intent"),
179
+ "active_title": session.get("active_title"),
180
+ "active_step": session.get("active_step"),
181
+ "last_user_text": session.get("last_user_text"),
182
+ "last_reply": session.get("last_reply"),
183
+ "last_ts": session.get("last_ts", time.time()),
184
+ "meta": meta,
185
+ },
186
+ )
187
+ con.commit()
188
+ con.close()
189
+
190
+ def session_child_name(self, child_age: int) -> str:
191
+ con = self._con()
192
+ session = self._load_session(con, child_age)
193
+ con.close()
194
+ return (session.get("meta") or {}).get("child_name", "")
195
+
196
+ def log(self, child_age, message, reply, blocked=False, activity=None):
197
+ self.record_turn(child_age, message, reply, blocked=blocked, activity=activity)
198
+
199
+ def record_turn(self, child_age: int, message: str, reply: str, blocked: bool = False, activity: dict | None = None):
200
+ """Guarda el turno y actualiza la memoria corta.
201
+
202
+ Si había una actividad activa y llega otro turno, la cerramos como
203
+ "answered" para no dejar el hilo colgado.
204
+ """
205
+ now = time.time()
206
+ con = self._con()
207
+ session = self._load_session(con, child_age)
208
+ active_content_id = session.get("active_content_id")
209
+ active_intent = session.get("active_intent")
210
+ active_title = session.get("active_title")
211
+
212
+ con.execute(
213
+ "INSERT INTO log (ts, child_age, message, reply, blocked) VALUES (?,?,?,?,?)",
214
+ (now, child_age, message, reply, int(blocked)),
215
+ )
216
+
217
+ if active_content_id and not blocked and activity is None and message.strip():
218
+ con.execute(
219
+ """INSERT INTO activity_events
220
+ (ts, child_age, content_id, intent, title, status, user_text, reply_text, extra_json)
221
+ VALUES (?,?,?,?,?,?,?,?,?)""",
222
+ (
223
+ now,
224
+ child_age,
225
+ active_content_id,
226
+ active_intent,
227
+ active_title,
228
+ "answered",
229
+ message,
230
+ reply,
231
+ json.dumps({}, ensure_ascii=False),
232
+ ),
233
+ )
234
+ active_content_id = None
235
+ active_intent = None
236
+ active_title = None
237
+ session["active_step"] = None
238
+
239
+ if activity:
240
+ extra = {
241
+ "script": activity.get("script"),
242
+ "min_age": activity.get("min_age"),
243
+ "max_age": activity.get("max_age"),
244
+ }
245
+ con.execute(
246
+ """INSERT INTO activity_events
247
+ (ts, child_age, content_id, intent, title, status, user_text, reply_text, extra_json)
248
+ VALUES (?,?,?,?,?,?,?,?,?)""",
249
+ (
250
+ now,
251
+ child_age,
252
+ activity.get("id"),
253
+ activity.get("intent"),
254
+ activity.get("title"),
255
+ "presented",
256
+ message,
257
+ reply,
258
+ json.dumps(extra, ensure_ascii=False),
259
+ ),
260
+ )
261
+ active_content_id = activity.get("id")
262
+ active_intent = activity.get("intent")
263
+ active_title = activity.get("title")
264
+ session["active_step"] = "presented"
265
+
266
+ self._save_session(
267
+ con,
268
+ child_age,
269
+ {
270
+ "active_content_id": active_content_id,
271
+ "active_intent": active_intent,
272
+ "active_title": active_title,
273
+ "last_user_text": message,
274
+ "last_reply": reply,
275
+ "last_ts": now,
276
+ "meta": session.get("meta", {}),
277
+ },
278
+ )
279
+ con.commit()
280
+ con.close()
281
+
282
+ def mark_seen(self, child_age, content_id, intent, title=None):
283
+ """Registra que Lumi ya presentó este contenido.
284
+
285
+ Sirve para no repetir siempre lo mismo y para derivar intereses.
286
+ """
287
+ con = self._con()
288
+ con.execute(
289
+ "INSERT INTO seen_content (ts, child_age, content_id, intent, title) VALUES (?,?,?,?,?)",
290
+ (time.time(), child_age, content_id, intent, title),
291
+ )
292
+ con.commit()
293
+ con.close()
294
+
295
+ def seen_ids(self, child_age, since_days=14):
296
+ cutoff = time.time() - since_days * 86400
297
+ con = self._con()
298
+ rows = con.execute(
299
+ "SELECT DISTINCT content_id FROM seen_content WHERE child_age=? AND ts >= ?",
300
+ (child_age, cutoff),
301
+ ).fetchall()
302
+ con.close()
303
+ return {r["content_id"] for r in rows}
304
+
305
+ def top_intents(self, child_age, since_days=14, limit=3):
306
+ cutoff = time.time() - since_days * 86400
307
+ con = self._con()
308
+ rows = con.execute(
309
+ """SELECT intent, COUNT(*) as veces FROM seen_content
310
+ WHERE child_age=? AND ts >= ? AND intent IS NOT NULL
311
+ GROUP BY intent ORDER BY veces DESC LIMIT ?""",
312
+ (child_age, cutoff, limit),
313
+ ).fetchall()
314
+ con.close()
315
+ return [(r["intent"], r["veces"]) for r in rows]
316
+
317
+ def recent_titles(self, child_age, limit=3):
318
+ con = self._con()
319
+ rows = con.execute(
320
+ """SELECT content_id, title FROM seen_content
321
+ WHERE child_age=? ORDER BY id DESC LIMIT ?""",
322
+ (child_age, limit),
323
+ ).fetchall()
324
+ con.close()
325
+ return [r["title"] or r["content_id"] for r in rows]
326
+
327
+ def recent_turns(self, child_age, limit=3):
328
+ con = self._con()
329
+ rows = con.execute(
330
+ """SELECT ts, message, reply, blocked FROM log
331
+ WHERE child_age=? ORDER BY id DESC LIMIT ?""",
332
+ (child_age, limit),
333
+ ).fetchall()
334
+ con.close()
335
+ return [
336
+ {
337
+ "ts": r["ts"],
338
+ "message": r["message"],
339
+ "reply": r["reply"],
340
+ "blocked": bool(r["blocked"]),
341
+ }
342
+ for r in rows
343
+ ]
344
+
345
+ def recent_activities(self, child_age, limit=5):
346
+ con = self._con()
347
+ rows = con.execute(
348
+ """SELECT ts, content_id, intent, title, status, user_text, reply_text
349
+ FROM activity_events
350
+ WHERE child_age=? ORDER BY id DESC LIMIT ?""",
351
+ (child_age, limit),
352
+ ).fetchall()
353
+ con.close()
354
+ return [
355
+ {
356
+ "ts": r["ts"],
357
+ "content_id": r["content_id"],
358
+ "intent": r["intent"],
359
+ "title": r["title"],
360
+ "status": r["status"],
361
+ "user_text": r["user_text"],
362
+ "reply_text": r["reply_text"],
363
+ }
364
+ for r in rows
365
+ ]
366
+
367
+ def session_state(self, child_age):
368
+ con = self._con()
369
+ data = self._load_session(con, child_age)
370
+ con.close()
371
+ return data
372
+
373
+ def memory_snapshot(self, child_age, limit=3):
374
+ session = self.session_state(child_age)
375
+ top_intents = self.top_intents(child_age, limit=limit)
376
+ recent_titles = self.recent_titles(child_age, limit=limit)
377
+ recent_turns = self.recent_turns(child_age, limit=limit)
378
+ recent_activities = self.recent_activities(child_age, limit=limit)
379
+ return {
380
+ "session": session,
381
+ "top_intents": top_intents,
382
+ "recent_titles": recent_titles,
383
+ "recent_turns": recent_turns,
384
+ "recent_activities": recent_activities,
385
+ }
386
+
387
+ def memory_block(self, child_age, limit=3):
388
+ snapshot = self.memory_snapshot(child_age, limit=limit)
389
+ lines = []
390
+ phase = (snapshot["session"].get("meta") or {}).get("phase")
391
+ child_name = (snapshot["session"].get("meta") or {}).get("child_name")
392
+
393
+ if child_name:
394
+ lines.append(f"Sesión de {child_name}.")
395
+ if phase:
396
+ lines.append(f"Fase actual: {phase}.")
397
+
398
+ active_title = snapshot["session"].get("active_title")
399
+ if active_title:
400
+ lines.append(f"Actividad abierta ahora: {active_title}.")
401
+
402
+ if snapshot["top_intents"]:
403
+ favs = ", ".join(f"{intent} ({count})" for intent, count in snapshot["top_intents"])
404
+ lines.append(f"Lo que más apareció últimamente: {favs}.")
405
+
406
+ if snapshot["recent_titles"]:
407
+ lines.append("Últimas actividades: " + ", ".join(f'"{title}"' for title in snapshot["recent_titles"]) + ".")
408
+
409
+ if not lines:
410
+ return ""
411
+ return "\n<contexto>\n" + " ".join(lines) + "\n</contexto>\n"
412
+
413
+ def recent(self, limit=50):
414
+ con = self._con()
415
+ rows = con.execute(
416
+ "SELECT ts, child_age, message, reply, blocked FROM log ORDER BY id DESC LIMIT ?",
417
+ (limit,),
418
+ ).fetchall()
419
+ con.close()
420
+ return [
421
+ {
422
+ "ts": r["ts"],
423
+ "child_age": r["child_age"],
424
+ "message": r["message"],
425
+ "reply": r["reply"],
426
+ "blocked": bool(r["blocked"]),
427
+ }
428
+ for r in rows
429
+ ]
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=5
2
+ llama-cpp-python
3
+ faster-whisper
4
+ kokoro
5
+ soundfile
6
+ numpy
7
+
8
+ # Dev local con GPU (opcional, no se usa en el HF Space que corre CPU-only):
9
+ # pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
10
+ # pip install nvidia-cuda-runtime-cu12 nvidia-cublas-cu12
11
+
12
+ # Para fine-tune (en otra máquina/entorno, no en el Space):
13
+ # transformers, peft, trl, bitsandbytes, datasets
safety/__init__.py ADDED
File without changes
safety/blocklist.txt ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Un término prohibido por línea. Editable por los padres desde el panel.
2
+ # Lista de partida pensada para una nena de ~3 años: violencia, armas,
3
+ # contenido adulto, drogas/alcohol y autolesión. Agregá/quitá lo que quieras.
4
+
5
+ # Violencia / miedo
6
+ muerte
7
+ morir
8
+ matar
9
+ mataron
10
+ sangre
11
+ herida
12
+ pelea
13
+ golpear
14
+ golpe
15
+ disparar
16
+ bala
17
+ violencia
18
+ asesino
19
+ secuestro
20
+
21
+ # Armas
22
+ arma
23
+ armas
24
+ pistola
25
+ cuchillo
26
+ espada
27
+ bomba
28
+
29
+ # Contenido adulto
30
+ sexo
31
+ sexual
32
+ desnudo
33
+ desnuda
34
+ porno
35
+ violación
36
+
37
+ # Drogas / alcohol
38
+ droga
39
+ drogas
40
+ alcohol
41
+ cerveza
42
+ borracho
43
+ cigarrillo
44
+ fumar
45
+
46
+ # Autolesión
47
+ suicidio
48
+ suicidarse
49
+ lastimarse
50
+
51
+ # Lenguaje fuerte
52
+ puta
53
+ puto
54
+ mierda
55
+ carajo
56
+ pendejo
57
+ idiota
58
+ estúpido
59
+ estúpida
safety/guard.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Capa de seguridad simple pero real. Defensa en profundidad:
3
+ - blocks_input : el niño/entorno trae un tema prohibido -> redirigimos.
4
+ - blocks_output : red de seguridad sobre lo que sale del LLM.
5
+ La blocklist la editan los padres desde el panel parental (un término por línea).
6
+ Para producción conviene sumar un clasificador, pero esto cubre el MVP.
7
+ """
8
+
9
+ import os
10
+
11
+
12
+ class SafetyGuard:
13
+ def __init__(self, blocklist_path: str):
14
+ self.terms = []
15
+ if os.path.exists(blocklist_path):
16
+ with open(blocklist_path, encoding="utf-8") as f:
17
+ self.terms = [t.strip().lower() for t in f if t.strip() and not t.startswith("#")]
18
+
19
+ def _hit(self, text: str) -> bool:
20
+ t = text.lower()
21
+ return any(term in t for term in self.terms)
22
+
23
+ def blocks_input(self, message: str) -> bool:
24
+ return self._hit(message)
25
+
26
+ def blocks_output(self, reply: str) -> bool:
27
+ return self._hit(reply)
28
+
29
+ def gentle_redirect(self) -> str:
30
+ return "Mmm, mejor juguemos a otra cosa. ¿Querés que contemos o que te cuente un cuentito?"
31
+
32
+ def safe_fallback(self) -> str:
33
+ return "Uy, me perdí un poquito. ¿Jugamos a contar?"
voice/__init__.py ADDED
File without changes
voice/stt.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ STT con faster-whisper (local, push-to-talk).
3
+ Mismas advertencias que antes: ASR rinde MAL con voz de niños de 3 años;
4
+ push-to-talk + language='es' ayudan.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from statistics import mean
10
+
11
+ from faster_whisper import WhisperModel
12
+
13
+ _model: WhisperModel | None = None
14
+
15
+
16
+ def _get_model() -> WhisperModel:
17
+ global _model
18
+ if _model is None:
19
+ _model = WhisperModel("small", device="cpu", compute_type="int8")
20
+ return _model
21
+
22
+
23
+ def _clean_text(text: str) -> str:
24
+ return " ".join(text.split()).strip()
25
+
26
+
27
+ def transcribe(audio_path: str, duration_ms: int | None = None) -> dict:
28
+ """
29
+ Transcribe audio and return diagnostics so the UI can tell apart:
30
+ - audio too short
31
+ - no speech detected
32
+ - low-confidence or suspiciously long hallucinations
33
+ """
34
+ segments, info = _get_model().transcribe(
35
+ audio_path,
36
+ language="es",
37
+ beam_size=1,
38
+ best_of=1,
39
+ temperature=0.0,
40
+ condition_on_previous_text=False,
41
+ vad_filter=True,
42
+ vad_parameters={"min_silence_duration_ms": 300},
43
+ )
44
+
45
+ texts: list[str] = []
46
+ segment_logs: list[float] = []
47
+ no_speech_scores: list[float] = []
48
+
49
+ for segment in segments:
50
+ text = _clean_text(segment.text)
51
+ if text:
52
+ texts.append(text)
53
+ avg_logprob = getattr(segment, "avg_logprob", None)
54
+ if isinstance(avg_logprob, (int, float)):
55
+ segment_logs.append(float(avg_logprob))
56
+ no_speech_prob = getattr(segment, "no_speech_prob", None)
57
+ if isinstance(no_speech_prob, (int, float)):
58
+ no_speech_scores.append(float(no_speech_prob))
59
+
60
+ text = _clean_text(" ".join(texts))
61
+ language_probability = float(getattr(info, "language_probability", 0.0) or 0.0)
62
+ avg_logprob = mean(segment_logs) if segment_logs else None
63
+ no_speech_prob = max(no_speech_scores) if no_speech_scores else None
64
+ duration_s = (duration_ms or 0) / 1000.0 if duration_ms is not None else None
65
+ word_count = len(text.split())
66
+
67
+ rejected_reason = None
68
+ if duration_s is not None:
69
+ # Heurística anti-alucinación: si el audio fue muy corto pero el texto es largo,
70
+ # preferimos devolver vacío y pedir otro intento.
71
+ suspiciously_long = word_count > max(6, int(duration_s * 3) + 2)
72
+ if suspiciously_long and language_probability < 0.85:
73
+ rejected_reason = "transcripcion_sospechosa"
74
+ text = ""
75
+
76
+ if not text:
77
+ if rejected_reason is None:
78
+ if duration_s is not None and duration_s < 0.7:
79
+ rejected_reason = "audio_corto"
80
+ elif no_speech_prob is not None and no_speech_prob > 0.75:
81
+ rejected_reason = "sin_voz"
82
+ else:
83
+ rejected_reason = "sin_texto"
84
+
85
+ return {
86
+ "text": text,
87
+ "debug": {
88
+ "language": getattr(info, "language", "es"),
89
+ "language_probability": round(language_probability, 3),
90
+ "avg_logprob": round(avg_logprob, 3) if avg_logprob is not None else None,
91
+ "no_speech_prob": round(no_speech_prob, 3) if no_speech_prob is not None else None,
92
+ "duration_s": round(duration_s, 3) if duration_s is not None else None,
93
+ "word_count": word_count,
94
+ "rejected_reason": rejected_reason,
95
+ },
96
+ }
voice/tts.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TTS local-first para Lumi.
3
+
4
+ Ruta única:
5
+ - Kokoro en español con una voz femenina más neutra/latina.
6
+
7
+ Cache:
8
+ - si el texto ya fue sintetizado, reutilizamos `content/audio/<hash>.wav`
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import os
15
+ import wave
16
+
17
+ import numpy as np
18
+
19
+ HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
20
+ CONTENT_AUDIO_DIR = os.path.join(HERE, "content", "audio")
21
+
22
+ KOKORO_LANG = "e"
23
+ KOKORO_VOICE = "ef_dora"
24
+ KOKORO_SPEED = 1.0
25
+
26
+ _pipeline = None
27
+
28
+
29
+ def _get_pipeline():
30
+ global _pipeline
31
+ if _pipeline is None:
32
+ from kokoro import KPipeline
33
+
34
+ _pipeline = KPipeline(lang_code=KOKORO_LANG)
35
+ return _pipeline
36
+
37
+
38
+ def _ensure_dirs():
39
+ os.makedirs(CONTENT_AUDIO_DIR, exist_ok=True)
40
+
41
+
42
+ def _cache_key(text: str) -> str:
43
+ payload = f"{KOKORO_LANG}|{KOKORO_VOICE}|{KOKORO_SPEED}|{text.strip()}"
44
+ return hashlib.sha1(payload.encode("utf-8")).hexdigest()
45
+
46
+
47
+ def _cache_path(text: str) -> str:
48
+ return os.path.join(CONTENT_AUDIO_DIR, f"{_cache_key(text)}.wav")
49
+
50
+
51
+ def _write_wav(path: str, audio: np.ndarray, sample_rate: int = 24000) -> str:
52
+ audio = np.asarray(audio, dtype=np.float32)
53
+ audio = np.clip(audio, -1.0, 1.0)
54
+ pcm16 = (audio * 32767).astype(np.int16)
55
+ with wave.open(path, "wb") as wav_file:
56
+ wav_file.setnchannels(1)
57
+ wav_file.setsampwidth(2)
58
+ wav_file.setframerate(sample_rate)
59
+ wav_file.writeframes(pcm16.tobytes())
60
+ return path
61
+
62
+
63
+ def _synthesize_kokoro(text: str) -> str:
64
+ pipeline = _get_pipeline()
65
+ chunks = list(pipeline(text, voice=KOKORO_VOICE, speed=KOKORO_SPEED, split_pattern=r"\n+"))
66
+ audio_parts = [np.asarray(chunk[2], dtype=np.float32) for chunk in chunks if chunk[2] is not None]
67
+ if not audio_parts:
68
+ raise RuntimeError("Kokoro no devolvió audio")
69
+
70
+ audio = audio_parts[0]
71
+ if len(audio_parts) > 1:
72
+ audio = np.concatenate(audio_parts)
73
+
74
+ out_path = _cache_path(text)
75
+ return _write_wav(out_path, audio, sample_rate=24000)
76
+
77
+
78
+ def warmup():
79
+ """Carga el pipeline de Kokoro al arrancar la app.
80
+
81
+ La primera síntesis en un proceso nuevo tarda ~60s (carga de pesos del
82
+ modelo en CPU); sin esto, esa demora caería sobre la primera respuesta
83
+ real de la nena.
84
+ """
85
+ pipeline = _get_pipeline()
86
+ list(pipeline("Hola.", voice=KOKORO_VOICE, speed=KOKORO_SPEED, split_pattern=r"\n+"))
87
+
88
+
89
+ def synthesize(text: str) -> str:
90
+ """
91
+ Devuelve la ruta al audio generado.
92
+
93
+ Siempre intenta cache local primero. Si no existe, sintetiza con Kokoro.
94
+ """
95
+ _ensure_dirs()
96
+ text = text.strip()
97
+ if not text:
98
+ raise ValueError("No se puede sintetizar texto vacío")
99
+
100
+ cached = _cache_path(text)
101
+ if os.path.exists(cached):
102
+ return cached
103
+
104
+ return _synthesize_kokoro(text)