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 +17 -0
- AGENTS.md +39 -0
- CLAUDE.md +39 -0
- HACKATHON.md +104 -0
- PLAN.md +152 -0
- README.md +45 -0
- app.py +118 -0
- bench.py +247 -0
- content/__init__.py +0 -0
- content/activities.json +162 -0
- content/audio/.gitkeep +0 -0
- content/loader.py +38 -0
- content/stories.json +82 -0
- demo.py +196 -0
- finetune/README.md +53 -0
- frontend/index.html +468 -0
- llm/__init__.py +0 -0
- llm/engine.py +207 -0
- parental/__init__.py +0 -0
- parental/store.py +429 -0
- requirements.txt +13 -0
- safety/__init__.py +0 -0
- safety/blocklist.txt +59 -0
- safety/guard.py +33 -0
- voice/__init__.py +0 -0
- voice/stt.py +96 -0
- voice/tts.py +104 -0
.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)
|