JARVIS: asistente de voz local para Linux
Nucleo propio: oye con whisper.cpp, piensa con un modelo de Ollama, habla con Piper, y hace RAG sobre los apuntes del usuario. 100% local, sin cuentas ni claves. Escrito bajo una restriccion dura, 4 GB de VRAM: el cerebro y whisper comparten tarjeta y solo caben porque estan dimensionados para ello. El RAG usa embeddings estaticos con busqueda hibrida; la voz clonada se sirve de una cache de frases. Incluye instalador (install.sh), requisitos, y documentacion del stack, del manejo de root y de las acciones. Los apuntes indexados y el diario NO se incluyen: son privados y el .gitignore los bloquea.
This commit is contained in:
commit
8e4bc8ad94
125 changed files with 25033 additions and 0 deletions
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Entornos y modelos: se reconstruyen, no se versionan
|
||||||
|
venv/
|
||||||
|
*venv*/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
models/
|
||||||
|
*.onnx
|
||||||
|
*.gguf
|
||||||
|
*.bin
|
||||||
|
*.pt
|
||||||
|
*.ckpt
|
||||||
|
|
||||||
|
# Datos PRIVADOS del usuario: nunca al repositorio
|
||||||
|
# - saber.jsonl y derivados llevan el texto de los apuntes de COFRE
|
||||||
|
# - diario.jsonl es lo que se le dice al asistente por voz
|
||||||
|
# - la voz de referencia y las cachés de audio son personales
|
||||||
|
nucleo/datos/*.jsonl
|
||||||
|
nucleo/datos/saber.jsonl.antes-*
|
||||||
|
nucleo/datos/cache_voz/
|
||||||
|
nucleo/datos/banco.json
|
||||||
|
nucleo/datos/*.log
|
||||||
|
nucleo/datos/informe_rag.txt
|
||||||
|
voz/ref/
|
||||||
|
voz/cache_voz*/
|
||||||
|
voz/dataset/
|
||||||
|
voz/originales/
|
||||||
|
voz/modelos/
|
||||||
|
voz/comparativa/
|
||||||
|
*.wav
|
||||||
|
|
||||||
|
# Excepcion: el catalogo de acciones y su generador SI van (definen que sabe
|
||||||
|
# hacer el asistente, no son datos personales)
|
||||||
|
!nucleo/datos/acciones.json
|
||||||
|
!nucleo/datos/_generar.py
|
||||||
|
|
||||||
|
# Registros y temporales
|
||||||
|
*.log
|
||||||
|
scan.txt
|
||||||
|
PARAR
|
||||||
|
.flatpak-builder/
|
||||||
45
AGENTS.md
Normal file
45
AGENTS.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# JARVIS — mapa del repositorio
|
||||||
|
|
||||||
|
Asistente de voz 100 % local para Linux. Núcleo propio. Este fichero orienta a
|
||||||
|
quien (persona o agente) trabaje en el código.
|
||||||
|
|
||||||
|
**Las rutas relativas van desde la raíz del repositorio.** El núcleo usa rutas
|
||||||
|
relativas a su propia ubicación (`AQUI = dirname(__file__)`); algunos scripts de
|
||||||
|
`config/` y `voz/` todavía asumen rutas absolutas del entorno del autor y habría
|
||||||
|
que hacerlas configurables.
|
||||||
|
|
||||||
|
## El flujo
|
||||||
|
|
||||||
|
oír → pensar → hacer → hablar
|
||||||
|
|
||||||
|
`nucleo/jarvis.py` es el bucle. Oye con whisper (`oido/`), decide con el modelo
|
||||||
|
(`cerebro/`), ejecuta con las manos (`manos/`) o contesta, y habla (`boca/`). El
|
||||||
|
panel (`cara/`) muestra todo en vivo.
|
||||||
|
|
||||||
|
## Carpetas
|
||||||
|
|
||||||
|
nucleo/
|
||||||
|
jarvis.py la entrada; arranca.sh lo lanza
|
||||||
|
oido/ whisper.cpp: captura, palabra de activación, transcripción
|
||||||
|
cerebro/ ollama, tool-calling, y el RAG (pasivo + agéntico)
|
||||||
|
boca/ Piper. voz.py fija la voz por defecto
|
||||||
|
manos/ las acciones, el diario, el sudo, la fonética
|
||||||
|
cara/ el panel web y su servidor
|
||||||
|
saber/ el RAG: indexa, busca, glosario; enriquece/ lo mejora
|
||||||
|
datos/ acciones.json (el catálogo). El resto es privado (.gitignore)
|
||||||
|
pruebas/ la suite del núcleo
|
||||||
|
|
||||||
|
config/ configuración: modelo (Modelfile), voz, whisper CUDA
|
||||||
|
voz/ clonado de voz con XTTS y caché de frases
|
||||||
|
entrena/ fine-tuning con QLoRA
|
||||||
|
|
||||||
|
## Dos cosas que ahorran errores
|
||||||
|
|
||||||
|
- **La gráfica es pequeña (4 GB) y el cerebro y whisper compiten por ella.**
|
||||||
|
Antes de tocar el dimensionado, mirar el presupuesto de VRAM del README.
|
||||||
|
- **Los datos del usuario no se versionan.** `saber.jsonl`, `diario.jsonl` y las
|
||||||
|
cachés de voz son privados: el `.gitignore` los bloquea. Ver PRIVACIDAD.md.
|
||||||
|
|
||||||
|
## Estilo
|
||||||
|
|
||||||
|
Registro técnico y conciso. Los commits describen el porqué, no solo el qué.
|
||||||
51
PRIVACIDAD.md
Normal file
51
PRIVACIDAD.md
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Privacidad
|
||||||
|
|
||||||
|
La premisa del proyecto es que nada sale de la máquina. No es un eslogan: se
|
||||||
|
verifica, y este documento dice qué es local, qué podría salir y qué se ha
|
||||||
|
dejado deliberadamente fuera de este repositorio.
|
||||||
|
|
||||||
|
## Qué es local
|
||||||
|
|
||||||
|
| Pieza | Dónde corre |
|
||||||
|
|---|---|
|
||||||
|
| Cerebro (modelo) | Ollama en `127.0.0.1:11434` |
|
||||||
|
| Escucha (STT) | whisper.cpp, en la propia máquina |
|
||||||
|
| Voz (TTS) | Piper / XTTS, en la propia máquina |
|
||||||
|
| Memoria de conversación | ficheros locales |
|
||||||
|
| RAG | índice local sobre apuntes locales |
|
||||||
|
|
||||||
|
Ninguna de estas piezas necesita una cuenta ni una clave. El asistente funciona
|
||||||
|
con la máquina desconectada de internet.
|
||||||
|
|
||||||
|
## Qué puede salir, y no es un fallo
|
||||||
|
|
||||||
|
- **La búsqueda web**, solo si el modelo decide usarla para una pregunta que lo
|
||||||
|
pide. Es una herramienta que se invoca, no una fuga de fondo, y se avisa.
|
||||||
|
|
||||||
|
Fuera de eso, no hay tráfico saliente durante el uso normal.
|
||||||
|
|
||||||
|
## Qué NO está en este repositorio, a propósito
|
||||||
|
|
||||||
|
Este código es público; los datos del usuario no. El `.gitignore` bloquea, y
|
||||||
|
nunca se deben añadir:
|
||||||
|
|
||||||
|
- **`nucleo/datos/saber.jsonl`** y sus derivados (`cosecha.jsonl`,
|
||||||
|
`sintesis.jsonl`): contienen el TEXTO de los apuntes indexados. Publicarlos
|
||||||
|
publicaría los apuntes.
|
||||||
|
- **`nucleo/datos/diario.jsonl`**: es lo que el usuario le ha dicho al asistente
|
||||||
|
por voz.
|
||||||
|
- **`voz/ref/`** y las cachés de audio: la voz de referencia y las frases
|
||||||
|
sintetizadas son personales.
|
||||||
|
- **Entornos, modelos y datasets**: se reconstruyen; no son código.
|
||||||
|
|
||||||
|
Lo único de `nucleo/datos/` que sí va es `acciones.json` (el catálogo de lo que
|
||||||
|
el asistente sabe hacer) y su generador: definen capacidades, no datos.
|
||||||
|
|
||||||
|
## Cómo comprobarlo
|
||||||
|
|
||||||
|
- Que nada nuestro escuche fuera de loopback:
|
||||||
|
`ss -ltnp | grep -vE '127\.0\.0\.1|\[::1\]'`
|
||||||
|
- Que el cerebro apunta a local: revisar el endpoint en `nucleo/cerebro/`.
|
||||||
|
- Que no hay claves en el repo: no hay ningún fichero de credenciales; el
|
||||||
|
manejador de sudo (`nucleo/manos/sudo_root.py`) guarda la contraseña solo en
|
||||||
|
memoria durante la sesión y la borra al terminar.
|
||||||
163
README.md
Normal file
163
README.md
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
# JARVIS
|
||||||
|
|
||||||
|
Asistente de voz **100 % local** para Linux. Ni el micrófono, ni las
|
||||||
|
conversaciones, ni el modelo salen de la máquina. Sin cuentas, sin claves, sin
|
||||||
|
nube. Núcleo propio, escrito para caber en una tarjeta pequeña (NVIDIA T1200 de
|
||||||
|
4 GB) y hacer lo que se le pide por voz, en castellano.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────┐
|
||||||
|
micrófono ─────>│ OÍDO whisper.cpp CUDA │ STT castellano
|
||||||
|
│ sm_75 · small/GPU │ ~930 ms régimen
|
||||||
|
└─────────────┬─────────────┘
|
||||||
|
v
|
||||||
|
apuntes propios ──> ┌───────────┐ ┌─────────────────────────┐
|
||||||
|
(RAG) │ SABER │─>│ CEREBRO ollama │
|
||||||
|
│ model2vec │ │ qwen3.5:4b · tool-call │
|
||||||
|
│ +híbrido │ │ + RAG pasivo Y agéntico │
|
||||||
|
└───────────┘ └─────────────┬───────────┘
|
||||||
|
v
|
||||||
|
┌───────────────┐ ┌──────────────────────┐
|
||||||
|
altavoz <───────────│ BOCA Piper │<─│ MANOS │
|
||||||
|
│ / voz clonada│ │ acciones + shell │
|
||||||
|
└───────────────┘ └──────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────┴───────────────────────────┐
|
||||||
|
│ CARA panel web · WebSocket · reactor │
|
||||||
|
└────────────────────────────────────────┘
|
||||||
|
todo en 127.0.0.1 · 0 llamadas a la nube
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Captura del panel: añadir docs/panel.png y descomentar
|
||||||
|

|
||||||
|
-->
|
||||||
|
|
||||||
|
## El stack
|
||||||
|
|
||||||
|
| Capa | Pieza | Papel |
|
||||||
|
|---|---|---|
|
||||||
|
| Oír | whisper.cpp (CUDA, sm_75) | STT en castellano, en la GPU |
|
||||||
|
| Pensar | Ollama · `qwen3.5:4b` | el cerebro, con tool-calling |
|
||||||
|
| Saber | model2vec + búsqueda híbrida | RAG sobre los apuntes del usuario |
|
||||||
|
| Hablar | Piper (o voz clonada con XTTS) | TTS local |
|
||||||
|
| Cara | panel web · WebSocket · Canvas | el HUD, con el reactor y las métricas |
|
||||||
|
| Manos | catálogo de acciones + shell | lo que ejecuta en la máquina |
|
||||||
|
|
||||||
|
Todo corre en `127.0.0.1`. No hay ninguna llamada a la nube en el uso normal; lo
|
||||||
|
único que puede salir es una búsqueda web si el modelo decide usarla, y se avisa.
|
||||||
|
|
||||||
|
## Requisitos de hardware
|
||||||
|
|
||||||
|
Referencia (el equipo del autor):
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| GPU | NVIDIA T1200, **4 GB VRAM**, Turing (sm_75) |
|
||||||
|
| RAM | 62 GB |
|
||||||
|
| SO | Debian 12, GNOME sobre X11, PipeWire |
|
||||||
|
|
||||||
|
La restricción que manda es la VRAM. El presupuesto, medido:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┬──────────┬───────────────────────┐
|
||||||
|
│ COMPONENTE │ VRAM │ NOTA │
|
||||||
|
├──────────────────────────────────┼──────────┼───────────────────────┤
|
||||||
|
│ qwen3.5:4b (24/32 capas en GPU) │ 2177 MiB │ el cerebro │
|
||||||
|
│ whisper small (PICO de la trans- │ 839 MiB │ el pico, no el reposo:│
|
||||||
|
│ cripción) │ │ ahí estuvo el OOM │
|
||||||
|
│ ── suma ── │ 3016 MiB │ cabe, con margen justo│
|
||||||
|
└──────────────────────────────────┴──────────┴───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Corre en menos con un modelo más pequeño; con más VRAM, sobra sitio.
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
Necesita, en la máquina: [Ollama](https://ollama.com), whisper.cpp compilado con
|
||||||
|
CUDA (ver [`config/README_whisper_gpu.md`](config/README_whisper_gpu.md)) y
|
||||||
|
[Piper](https://github.com/rhasspy/piper).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.laenre.net/hacklab/JARVIS.git
|
||||||
|
cd JARVIS
|
||||||
|
./install.sh # comprueba servicios, crea el venv y el modelo de Ollama
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` no descarga modelos pesados ni toca el sistema sin avisar: solo
|
||||||
|
prepara el entorno de Python y crea `qwen3.5:4b-jarvis` a partir del `Modelfile`.
|
||||||
|
|
||||||
|
Después:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./nucleo/saber/indexa.py # (opcional) indexa tus apuntes para el RAG
|
||||||
|
./nucleo/arranca.sh # el asistente y el panel, en 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Qué le puedes pedir
|
||||||
|
|
||||||
|
Habla en castellano, natural. **150 acciones** en el catálogo, agrupadas:
|
||||||
|
|
||||||
|
| Grupo | Nº | Ejemplos |
|
||||||
|
|---|---|---|
|
||||||
|
| sistema | 59 | "cuánto espacio queda", "la memoria", "la carga de CPU", "la temperatura" |
|
||||||
|
| firefox | 54 | "abre el Gmail", "abre YouTube", "abre el GitHub", "abre Maps" |
|
||||||
|
| ventanas | 12 | "manda la ventana a la izquierda", "maximiza", "centra la ventana" |
|
||||||
|
| ficheros | 11 | "busca un fichero llamado…", "lee el fichero…", "cuánto ocupa la carpeta…" |
|
||||||
|
| pentest | 8 | "escanea la red", "los puertos del host…", "resuelve el dominio…", "el whois de…" |
|
||||||
|
| oasis | 6 | "está corriendo Oasis", "cuántas conexiones tiene" |
|
||||||
|
|
||||||
|
Si lo que pides no está en el catálogo, el modelo lo resuelve con un **comando de
|
||||||
|
shell** (de solo lectura por defecto). Y si pregunta *cómo* se hace algo técnico,
|
||||||
|
puede **buscar en tus apuntes** (RAG) antes de responder, para dar el comando que
|
||||||
|
ya has probado en vez de inventarlo.
|
||||||
|
|
||||||
|
## Órdenes con root
|
||||||
|
|
||||||
|
Algunas acciones necesitan `sudo`. El manejo es explícito y con límites, no un
|
||||||
|
"root para siempre":
|
||||||
|
|
||||||
|
- La contraseña **nunca se escribe en disco**. Vive en memoria del proceso solo
|
||||||
|
si activas el permiso de sesión, y se pasa a `sudo -S` por la entrada estándar.
|
||||||
|
- El permiso de sesión **caduca a los 15 minutos** de la última orden, y también
|
||||||
|
al cerrar el panel.
|
||||||
|
- **Cada** orden con root muestra su comando exacto en un diálogo antes de
|
||||||
|
ejecutarlo. El permiso de sesión solo ahorra re-teclear la contraseña; nunca
|
||||||
|
salta el ver y aprobar. **La voz no puede autoaprobar root.**
|
||||||
|
|
||||||
|
Con el candado echado, el asistente no obedece órdenes que toquen el sistema:
|
||||||
|
las pide desbloquear primero.
|
||||||
|
|
||||||
|
## Privacidad
|
||||||
|
|
||||||
|
Es la premisa, y se verifica. Ver [PRIVACIDAD.md](PRIVACIDAD.md). Este
|
||||||
|
repositorio **no incluye** los apuntes indexados ni el diario de conversación:
|
||||||
|
son del usuario, y el `.gitignore` los bloquea.
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
nucleo/ el asistente (oido, cerebro, boca, manos, cara, saber)
|
||||||
|
config/ configuración: modelo, voz, whisper CUDA
|
||||||
|
voz/ clonado de voz con XTTS y caché de frases
|
||||||
|
entrena/ fine-tuning con QLoRA
|
||||||
|
install.sh el instalador
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estado y limitaciones
|
||||||
|
|
||||||
|
Proyecto en desarrollo. Rugosidades conocidas: varios scripts de `config/` y
|
||||||
|
`voz/` asumen rutas absolutas del entorno del autor (`~/COFRE/...`) y habría que
|
||||||
|
hacerlas configurables; el fine-tuning de `entrena/` está pendiente de un ajuste
|
||||||
|
para modelos multimodales.
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
Por decidir por el autor. El núcleo es código propio, no derivado.
|
||||||
30
config/README_whisper_gpu.md
Normal file
30
config/README_whisper_gpu.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Whisper small en GPU
|
||||||
|
|
||||||
|
El reconocimiento pasa del modelo `base` en CPU al `small` en GPU. Medido:
|
||||||
|
|
||||||
|
| | acierto | latencia (régimen) |
|
||||||
|
|---|---|---|
|
||||||
|
| base · CPU | 80 % | 928 ms |
|
||||||
|
| small · GPU | **93 %** | **~930 ms** |
|
||||||
|
|
||||||
|
Misma velocidad, +13 puntos de acierto. `small` ocupa 757 MiB en la T1200 y
|
||||||
|
coexiste con el modelo grande de Ollama (2901/3717 MiB con los dos cargados).
|
||||||
|
|
||||||
|
## Cómo está montado
|
||||||
|
|
||||||
|
- whisper.cpp compilado con CUDA (sm_75) en `voz/whisper-cuda/` (no versionado;
|
||||||
|
se reconstruye con `cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=75`).
|
||||||
|
- Envoltorio `~/.local/bin/whisper-server` (copia de referencia en
|
||||||
|
`config/whisper-server-cuda.sh`): fija `LD_LIBRARY_PATH` a las libs de CUDA y
|
||||||
|
llama al binario. Está en el PATH del host, que es donde Newelle lo busca.
|
||||||
|
- Ajustes de Newelle: `stt-settings.whispercpp = {model: small,
|
||||||
|
use_system_server: true}`. El handler arranca el servidor por
|
||||||
|
`flatpak-spawn --host whisper-server` y le pega las peticiones.
|
||||||
|
|
||||||
|
## Lo único a saber
|
||||||
|
|
||||||
|
La PRIMERA transcripción de cada sesión tarda ~4 s: es el arranque del servidor
|
||||||
|
(cargar el modelo a la GPU). A partir de ahí, ~930 ms. El servidor se queda
|
||||||
|
vivo mientras la app corre.
|
||||||
|
|
||||||
|
Para volver a CPU: `use_system_server: false` y `model: base` en los ajustes.
|
||||||
53
config/ampliar_swap.sh
Normal file
53
config/ampliar_swap.sh
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Amplia la swap en caliente, sin reiniciar y sin tocar particiones.
|
||||||
|
#
|
||||||
|
# Por que: el portatil tiene 62 GB de RAM y 976 MiB de swap en la particion
|
||||||
|
# nvme0n1p3. Ese reparto es de una maquina con 2 GB de RAM, no con 62. La swap
|
||||||
|
# se llena (96% medido) y aunque hoy no cause nada —comprobado: si=4 so=0 KB/s,
|
||||||
|
# 43 GB de RAM disponible, ninguna fuga— no queda colchon para cuando algo pida
|
||||||
|
# memoria de golpe.
|
||||||
|
#
|
||||||
|
# Lo que NO arregla: nada de lo que se notaba. La pantalla que pedia contraseña
|
||||||
|
# era el candado de JARVIS, no el sistema. Esto es higiene, no una reparacion.
|
||||||
|
#
|
||||||
|
# Se ejecuta con sudo y no hace falta reiniciar: `swapon` activa el fichero al
|
||||||
|
# momento y la linea del fstab solo sirve para el proximo arranque.
|
||||||
|
#
|
||||||
|
# sudo bash ampliar_swap.sh [tamaño] # por defecto 16G
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAM=${1:-16G}
|
||||||
|
FICHERO=/swapfile
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || { echo "hay que ejecutarlo con sudo" >&2; exit 1; }
|
||||||
|
|
||||||
|
if swapon --show=NAME --noheadings 2>/dev/null | grep -qx "$FICHERO"; then
|
||||||
|
echo "$FICHERO ya esta activo; nada que hacer."
|
||||||
|
swapon --show
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "1. creando $FICHERO de $TAM"
|
||||||
|
# fallocate va bien en ext4; en btrfs haria falta otra receta (chattr +C y
|
||||||
|
# sin compresion), pero la raiz de esta maquina es ext4.
|
||||||
|
fallocate -l "$TAM" "$FICHERO" 2>/dev/null || dd if=/dev/zero of="$FICHERO" bs=1M count=$((${TAM%G} * 1024)) status=none
|
||||||
|
chmod 600 "$FICHERO" # solo root: si no, mkswap se niega y con razon
|
||||||
|
|
||||||
|
echo "2. formateando"
|
||||||
|
mkswap "$FICHERO" >/dev/null
|
||||||
|
|
||||||
|
echo "3. activando (aqui ya esta funcionando, sin reiniciar)"
|
||||||
|
swapon "$FICHERO"
|
||||||
|
|
||||||
|
echo "4. dejandolo puesto para el proximo arranque"
|
||||||
|
grep -q "^$FICHERO " /etc/fstab || echo "$FICHERO none swap sw 0 0" >> /etc/fstab
|
||||||
|
|
||||||
|
# Con 62 GB de RAM no tiene sentido apartar paginas al disco con la alegria de
|
||||||
|
# swappiness=60: se baja a 10 para que la swap sea un seguro y no una costumbre.
|
||||||
|
echo "5. bajando swappiness de $(cat /proc/sys/vm/swappiness) a 10"
|
||||||
|
sysctl -q vm.swappiness=10
|
||||||
|
echo "vm.swappiness=10" > /etc/sysctl.d/99-jarvis-swappiness.conf
|
||||||
|
|
||||||
|
echo
|
||||||
|
swapon --show
|
||||||
|
free -h | head -3
|
||||||
148
config/aprender.py
Normal file
148
config/aprender.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Enseña acciones nuevas a partir de lo que no entendió.
|
||||||
|
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/aprender.py
|
||||||
|
|
||||||
|
Cada vez que dices algo que no encaja con ninguna accion, el panel lo anota en
|
||||||
|
config/jarvis-sin-accion.jsonl. Esto lo lee, agrupa lo repetido y pregunta a
|
||||||
|
que accion pertenece cada frase; lo que decidas se escribe como alias en
|
||||||
|
config/jarvis-acciones.json, que el catalogo carga ANTES que el del paquete.
|
||||||
|
|
||||||
|
Por que asi y no entrenando un modelo: los fallos del reconocedor son
|
||||||
|
sistematicos, no aleatorios. "abre el correo" se oye "a ver el correo" siempre,
|
||||||
|
no una de cada diez veces. Un alias lo arregla del todo, se lee, se borra si
|
||||||
|
molesta y no cuesta un milisegundo. Reentrenar costaria horas para acertar
|
||||||
|
"casi siempre".
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
|
||||||
|
REGISTRO = os.path.join(CONFIG, "jarvis-sin-accion.jsonl")
|
||||||
|
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
|
||||||
|
PAQUETE = os.path.expanduser(
|
||||||
|
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
|
||||||
|
"active/files/share/newelle/acciones/acciones.json")
|
||||||
|
|
||||||
|
|
||||||
|
def carga_catalogo():
|
||||||
|
acciones = {}
|
||||||
|
for ruta in (PAQUETE, PROPIO):
|
||||||
|
try:
|
||||||
|
with open(ruta) as f:
|
||||||
|
for a in json.load(f).get("acciones", []):
|
||||||
|
acciones.setdefault(a["id"], a)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
return acciones
|
||||||
|
|
||||||
|
|
||||||
|
def carga_fallos():
|
||||||
|
"""Las frases sin accion, agrupadas por veces que se han dicho."""
|
||||||
|
cuenta = Counter()
|
||||||
|
try:
|
||||||
|
with open(REGISTRO) as f:
|
||||||
|
for linea in f:
|
||||||
|
try:
|
||||||
|
texto = json.loads(linea).get("texto", "").strip()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if texto:
|
||||||
|
cuenta[texto] += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return cuenta
|
||||||
|
|
||||||
|
|
||||||
|
def guarda_alias(nuevos):
|
||||||
|
"""Añade los alias al catalogo propio, respetando lo que ya hubiera."""
|
||||||
|
try:
|
||||||
|
with open(PROPIO) as f:
|
||||||
|
propio = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
propio = {"version": 1, "acciones": []}
|
||||||
|
|
||||||
|
porid = {a["id"]: a for a in propio.get("acciones", [])}
|
||||||
|
catalogo = carga_catalogo()
|
||||||
|
|
||||||
|
for ident, frases in nuevos.items():
|
||||||
|
if ident in porid:
|
||||||
|
entrada = porid[ident]
|
||||||
|
else:
|
||||||
|
# se copia la del paquete entera: el catalogo propio se carga antes
|
||||||
|
# y sustituye a la original, asi que tiene que traer su comando
|
||||||
|
entrada = dict(catalogo[ident])
|
||||||
|
porid[ident] = entrada
|
||||||
|
propio.setdefault("acciones", []).append(entrada)
|
||||||
|
for f in frases:
|
||||||
|
if f not in entrada["frases"]:
|
||||||
|
entrada["frases"].append(f)
|
||||||
|
|
||||||
|
with open(PROPIO, "w") as f:
|
||||||
|
json.dump(propio, f, indent=2, ensure_ascii=False)
|
||||||
|
return PROPIO
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
fallos = carga_fallos()
|
||||||
|
if not fallos:
|
||||||
|
print("No hay nada sin entender todavia.")
|
||||||
|
print(f"Se anota solo en {REGISTRO}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
catalogo = carga_catalogo()
|
||||||
|
ids = sorted(catalogo)
|
||||||
|
print(f"{len(fallos)} frases distintas no encajaron con ninguna accion.\n")
|
||||||
|
|
||||||
|
nuevos = {}
|
||||||
|
for texto, veces in fallos.most_common():
|
||||||
|
print(f' "{texto}" ({veces} {"vez" if veces == 1 else "veces"})')
|
||||||
|
print(" escribe el id de la accion, 'l' para listarlos, "
|
||||||
|
"Enter para saltar, 'q' para terminar")
|
||||||
|
try:
|
||||||
|
resp = input(" > ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print()
|
||||||
|
break
|
||||||
|
|
||||||
|
if resp == "q":
|
||||||
|
break
|
||||||
|
if resp == "l":
|
||||||
|
for i, ident in enumerate(ids):
|
||||||
|
print(f" {ident:<24}", end="\n" if i % 3 == 2 else "")
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
resp = input(" > ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
break
|
||||||
|
if not resp:
|
||||||
|
continue
|
||||||
|
if resp not in catalogo:
|
||||||
|
print(f" '{resp}' no existe, saltada")
|
||||||
|
continue
|
||||||
|
|
||||||
|
nuevos.setdefault(resp, []).append(texto)
|
||||||
|
print(f" anotada como alias de {resp}")
|
||||||
|
|
||||||
|
if not nuevos:
|
||||||
|
print("\nNada que guardar.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
ruta = guarda_alias(nuevos)
|
||||||
|
total = sum(len(v) for v in nuevos.values())
|
||||||
|
print(f"\n{total} alias guardados en {ruta}")
|
||||||
|
print("Se aplican al abrir la pestaña JARVIS otra vez.")
|
||||||
|
|
||||||
|
if input("¿Vaciar el registro de frases no entendidas? [s/N] ").strip().lower() == "s":
|
||||||
|
try:
|
||||||
|
os.remove(REGISTRO)
|
||||||
|
print("registro vaciado")
|
||||||
|
except OSError as e:
|
||||||
|
print(f"no se pudo vaciar: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
65
config/apuntar_modelo_jarvis.py
Normal file
65
config/apuntar_modelo_jarvis.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apunta el cerebro de Newelle al modelo qwen3.5:4b-jarvis.
|
||||||
|
|
||||||
|
Es la variante con num_gpu 24 (ver qwen3.5-4b-jarvis.Modelfile): deja 1540 MiB
|
||||||
|
libres en la tarjeta para que whisper pueda transcribir sin morir de OOM.
|
||||||
|
|
||||||
|
Toca solo la clave llm-settings, y dentro solo el bloque "ollama": el modelo
|
||||||
|
elegido, la lista de modelos y la biblioteca que se ve en la interfaz.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
APP = "io.github.qwersyk.Newelle"
|
||||||
|
RAMA = "master"
|
||||||
|
MODELO = "qwen3.5:4b-jarvis"
|
||||||
|
|
||||||
|
GSET = ["flatpak", "run", f"--command=gsettings", f"{APP}//{RAMA}"]
|
||||||
|
|
||||||
|
|
||||||
|
def leer(clave):
|
||||||
|
out = subprocess.check_output(GSET + ["get", APP, clave], text=True).strip()
|
||||||
|
# gsettings devuelve el string entre comillas simples, con \\ escapados
|
||||||
|
return json.loads(out[1:-1].replace("\\\\", "\\").replace("\\'", "'"))
|
||||||
|
|
||||||
|
|
||||||
|
def escribir(clave, valor):
|
||||||
|
subprocess.check_call(GSET + ["set", APP, clave, json.dumps(valor)])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ajustes = leer("llm-settings")
|
||||||
|
ollama = ajustes.setdefault("ollama", {})
|
||||||
|
|
||||||
|
anterior = ollama.get("model")
|
||||||
|
if anterior == MODELO:
|
||||||
|
print(f"ya apuntaba a {MODELO}; nada que hacer.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
ollama["model"] = MODELO
|
||||||
|
|
||||||
|
# la lista de modelos del desplegable: pares [clave, titulo]
|
||||||
|
modelos = json.loads(ollama.get("models", "[]"))
|
||||||
|
if not any(m[0] == MODELO for m in modelos):
|
||||||
|
modelos.insert(0, [MODELO, MODELO])
|
||||||
|
ollama["models"] = json.dumps(modelos)
|
||||||
|
|
||||||
|
# la biblioteca que pinta la interfaz, para que salga con descripcion
|
||||||
|
biblioteca = ollama.get("model-library", [])
|
||||||
|
if not any(m.get("key") == MODELO for m in biblioteca):
|
||||||
|
biblioteca.insert(0, {
|
||||||
|
"key": MODELO,
|
||||||
|
"title": "qwen3.5 4b (JARVIS)",
|
||||||
|
"description": "qwen3.5:4b con 24 de 32 capas en GPU, para dejar "
|
||||||
|
"sitio a whisper en la tarjeta de 4 GB.",
|
||||||
|
})
|
||||||
|
ollama["model-library"] = biblioteca
|
||||||
|
|
||||||
|
escribir("llm-settings", ajustes)
|
||||||
|
print(f"cerebro: {anterior} -> {MODELO}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
464
config/autoescuela.py
Executable file
464
config/autoescuela.py
Executable file
|
|
@ -0,0 +1,464 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Dos JARVIS hablando: uno examina, el otro ejecuta, el primero corrige.
|
||||||
|
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py # una tanda
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py --horas 6 # toda la noche
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py --aplicar # guardar mejoras
|
||||||
|
|
||||||
|
La idea. Los bucles que teniamos probaban trozos: mejora_nocturna comprobaba
|
||||||
|
solo si la frase llega a la accion; probar_acciones, solo si la accion corre.
|
||||||
|
Esto los junta en una conversacion de punta a punta y por AUDIO:
|
||||||
|
|
||||||
|
Examinador -> dice una tarea en voz alta (Piper), con ruido y sala
|
||||||
|
Jarvis -> la oye (Whisper), decide que accion es, la ejecuta,
|
||||||
|
y contesta en voz alta
|
||||||
|
Examinador -> comprueba tres cosas:
|
||||||
|
1. ¿oyo la tarea correcta? (reconocimiento)
|
||||||
|
2. ¿hizo la accion que tocaba? (emparejamiento)
|
||||||
|
3. ¿el resultado tiene sentido? (ejecucion y respuesta)
|
||||||
|
y pone nota
|
||||||
|
|
||||||
|
Lo que se ejecuta de verdad: solo lo de solo lectura. Lo que abre cosas, apaga
|
||||||
|
o necesita root se comprueba hasta el emparejamiento, sin lanzarlo —una
|
||||||
|
autoescuela no deberia apagarte la pantalla ni pedir tu contraseña de noche.
|
||||||
|
|
||||||
|
Lo que aprende: los fallos se separan por tipo. Un fallo de reconocimiento
|
||||||
|
propone un alias (con el mismo filtro seguro de mejora_nocturna: sistematico,
|
||||||
|
sin ambiguedad, conserva palabra clave). Un fallo de ejecucion o de respuesta
|
||||||
|
se ANOTA para arreglarlo a mano —eso no se toca solo, que es codigo—.
|
||||||
|
|
||||||
|
Escenarios: casi todos son de un turno (una tarea, una respuesta), sacados del
|
||||||
|
catalogo. Unos pocos son de varios turnos para probar lo que tiene estado:
|
||||||
|
buscar -> "abre el segundo" -> "busca dentro X". Y hay charla que NO debe
|
||||||
|
disparar ninguna accion, como control negativo.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
CASA = os.path.expanduser("~")
|
||||||
|
JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS")
|
||||||
|
CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config")
|
||||||
|
APP = "io.github.qwersyk.Newelle//master"
|
||||||
|
|
||||||
|
PIPER = os.path.join(JARVIS, "voz/piper/piper")
|
||||||
|
MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos")
|
||||||
|
WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli")
|
||||||
|
MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models")
|
||||||
|
|
||||||
|
CATALOGO = os.path.expanduser(
|
||||||
|
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
|
||||||
|
"active/files/share/newelle/acciones/acciones.json")
|
||||||
|
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
|
||||||
|
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
|
||||||
|
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
|
||||||
|
SELECCION = os.path.join(JARVIS, "newelle/src/utility/seleccion.py")
|
||||||
|
|
||||||
|
SALA = os.path.join(JARVIS, "muestras/autoescuela")
|
||||||
|
DIALOGO = os.path.join(SALA, "dialogo.log")
|
||||||
|
INFORME = os.path.join(SALA, "informe.json")
|
||||||
|
WAVS = os.path.join(CONFIG, "autoescuela_wavs")
|
||||||
|
|
||||||
|
VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
|
||||||
|
VELOCIDADES = [0.9, 1.0, 1.12]
|
||||||
|
RUIDOS = ["", "aecho=0.8:0.7:22:0.12", "volume=0.72,highpass=f=120",
|
||||||
|
"highpass=f=150,lowpass=f=6500"]
|
||||||
|
|
||||||
|
# Lo que tiene efecto o pide permiso: se comprueba el emparejamiento pero no se
|
||||||
|
# ejecuta. Igual que en probar_acciones.
|
||||||
|
EFECTOS = ["firefox", "xdg-open", "loginctl", "xset", "pactl set", "pkill",
|
||||||
|
"brightnessctl", "systemctl start", "systemctl stop", "systemctl restart",
|
||||||
|
"import -window", "gnome-screenshot", "sudo", "rm ", "kill", "dpms"]
|
||||||
|
|
||||||
|
RELLENOS = ["notas", "el informe", "documentos", "reactor"]
|
||||||
|
|
||||||
|
|
||||||
|
def carga(ruta, nombre):
|
||||||
|
spec = importlib.util.spec_from_file_location(nombre, ruta)
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg, a_dialogo=False):
|
||||||
|
linea = f"[{time.strftime('%H:%M:%S')}] {msg}"
|
||||||
|
print(linea, flush=True)
|
||||||
|
fichero = DIALOGO if a_dialogo else DIALOGO
|
||||||
|
try:
|
||||||
|
with open(fichero, "a") as f:
|
||||||
|
f.write((msg if a_dialogo else linea) + "\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def dice(texto, voz, vel, ruido, destino):
|
||||||
|
"""El examinador dice una tarea: la sintetiza a un wav de 16 kHz."""
|
||||||
|
crudo = tempfile.mktemp(suffix=".wav")
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
|
||||||
|
"--length_scale", str(1 / vel), "--output_file", crudo],
|
||||||
|
input=texto, capture_output=True, text=True, timeout=60)
|
||||||
|
if r.returncode != 0 or not os.path.exists(crudo):
|
||||||
|
return False
|
||||||
|
filtros = "aresample=16000" + ("," + ruido if ruido else "")
|
||||||
|
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", crudo,
|
||||||
|
"-ac", "1", "-af", filtros, "-y", destino], check=True, timeout=60)
|
||||||
|
return True
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
if os.path.exists(crudo):
|
||||||
|
os.remove(crudo)
|
||||||
|
|
||||||
|
|
||||||
|
def oye(wavs, hilos=8, por_tanda=40):
|
||||||
|
"""Jarvis oye: transcribe los wav. {fichero: texto}."""
|
||||||
|
salida = {}
|
||||||
|
ficheros = sorted(wavs)
|
||||||
|
for i in range(0, len(ficheros), por_tanda):
|
||||||
|
tanda = ficheros[i:i + por_tanda]
|
||||||
|
lista = " ".join(f'"{WAVS}/{n}"' for n in tanda)
|
||||||
|
orden = (f'for f in {lista}; do echo "### $(basename "$f")"; '
|
||||||
|
f'{WHISPER} -m {MODELOS_STT}/ggml-base.bin -l es -nt -t {hilos} '
|
||||||
|
f'-f "$f" 2>/dev/null | tr "\\n" " "; echo; done')
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["flatpak", "run", "--command=bash", APP, "-c", orden],
|
||||||
|
capture_output=True, text=True, errors="replace", timeout=1200)
|
||||||
|
except subprocess.SubprocessError:
|
||||||
|
continue
|
||||||
|
actual = None
|
||||||
|
for linea in r.stdout.splitlines():
|
||||||
|
if linea.startswith("### "):
|
||||||
|
actual = linea[4:].strip()
|
||||||
|
elif actual:
|
||||||
|
salida[actual] = linea.strip()
|
||||||
|
actual = None
|
||||||
|
return salida
|
||||||
|
|
||||||
|
|
||||||
|
def tiene_efecto(comando):
|
||||||
|
return any(m in comando for m in EFECTOS)
|
||||||
|
|
||||||
|
|
||||||
|
def ejecuta_lectura(comando, argumento=""):
|
||||||
|
"""Ejecuta un comando de solo lectura y devuelve su salida."""
|
||||||
|
c = comando.replace("{argumento}", shlex.quote(argumento))
|
||||||
|
c = c.replace("{argumento_url}", shlex.quote(argumento))
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["bash", "-lc", c], capture_output=True, text=True,
|
||||||
|
errors="replace", timeout=15)
|
||||||
|
return (r.stdout or r.stderr or "").strip(), r.returncode
|
||||||
|
except subprocess.SubprocessError:
|
||||||
|
return "", 1
|
||||||
|
|
||||||
|
|
||||||
|
class Ejecutor:
|
||||||
|
"""La tuberia de Jarvis: oye una frase, decide y hace. Replica el enrutado
|
||||||
|
del panel (seleccion antes que catalogo) para poder probarlo sin GUI."""
|
||||||
|
|
||||||
|
def __init__(self, motor, seleccion):
|
||||||
|
self.motor = motor
|
||||||
|
self.sel = seleccion
|
||||||
|
self.cat = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
|
||||||
|
or [CATALOGO_FUENTE])
|
||||||
|
self.lista = []
|
||||||
|
self.fichero = None
|
||||||
|
|
||||||
|
def recarga(self):
|
||||||
|
self.cat = self.motor.Catalogo(
|
||||||
|
[p for p in (PROPIO, CATALOGO) if os.path.exists(p)] or [CATALOGO_FUENTE])
|
||||||
|
|
||||||
|
def procesa(self, oido):
|
||||||
|
"""Devuelve (tipo, id_o_dato, salida). tipo:
|
||||||
|
'seleccion' | 'dentro' | 'accion' | 'modelo'."""
|
||||||
|
norm = self.motor.normaliza(oido)
|
||||||
|
|
||||||
|
s = self.sel.orden_de_seleccion(norm)
|
||||||
|
if s is not None and self.lista:
|
||||||
|
que, pos = s
|
||||||
|
idx = len(self.lista) - 1 if pos == -1 else pos - 1
|
||||||
|
if 0 <= idx < len(self.lista):
|
||||||
|
self.fichero = self.lista[idx]
|
||||||
|
return "seleccion", f"{que}:{pos}", self.fichero
|
||||||
|
return "seleccion", "fuera_de_rango", ""
|
||||||
|
|
||||||
|
termino = self.sel.orden_buscar_dentro(norm)
|
||||||
|
if termino and self.fichero:
|
||||||
|
r, _ = ejecuta_lectura(f"grep -in {shlex.quote(termino)} "
|
||||||
|
f"{shlex.quote(self.fichero)} | head", "")
|
||||||
|
return "dentro", termino, r
|
||||||
|
|
||||||
|
accion, arg = self.cat.busca(oido)
|
||||||
|
if accion is None:
|
||||||
|
return "modelo", "", ""
|
||||||
|
|
||||||
|
# las de lista alimentan self.lista
|
||||||
|
if accion.id in ("buscar_fichero", "buscar_carpeta", "grep_recursivo"):
|
||||||
|
salida, _ = ejecuta_lectura(accion.comando, arg)
|
||||||
|
rutas = [l.split(":", 1)[0] if accion.id == "grep_recursivo" else l
|
||||||
|
for l in salida.splitlines() if l.strip()]
|
||||||
|
self.lista = rutas
|
||||||
|
if rutas:
|
||||||
|
self.fichero = rutas[0]
|
||||||
|
return "accion", accion.id, salida
|
||||||
|
|
||||||
|
if accion.confirmar or tiene_efecto(accion.comando):
|
||||||
|
return "accion", accion.id, "(no se ejecuta; solo emparejamiento)"
|
||||||
|
|
||||||
|
salida, _ = ejecuta_lectura(accion.comando, arg)
|
||||||
|
return "accion", accion.id, salida
|
||||||
|
|
||||||
|
|
||||||
|
def escenarios(acciones):
|
||||||
|
"""Construye las tareas de examen. Cada una: (texto, comprobacion)."""
|
||||||
|
tareas = []
|
||||||
|
|
||||||
|
# un turno por accion: la tarea es su frase, se espera su id
|
||||||
|
for a in acciones:
|
||||||
|
if not a["frases"]:
|
||||||
|
continue
|
||||||
|
texto = a["frases"][0]
|
||||||
|
if a.get("captura"):
|
||||||
|
texto += " " + random.choice(RELLENOS)
|
||||||
|
tareas.append({
|
||||||
|
"tipo": "accion", "texto": texto, "espera_id": a["id"],
|
||||||
|
"turnos": [texto],
|
||||||
|
})
|
||||||
|
|
||||||
|
# multi-turno: probar el estado (lista -> elegir -> buscar dentro)
|
||||||
|
tareas.append({
|
||||||
|
"tipo": "multi", "texto": "buscar y elegir",
|
||||||
|
"turnos": ["busca el fichero reactor", "abre el primero", "busca dentro def"],
|
||||||
|
"espera": ["accion:grep_recursivo|accion:buscar_fichero", "seleccion", "dentro"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# control negativo: charla que NO debe disparar accion
|
||||||
|
for frase in ["cuentame un chiste sobre gatos", "que opinas de todo esto",
|
||||||
|
"estoy cansado hoy la verdad", "no se muy bien que hacer ahora"]:
|
||||||
|
tareas.append({"tipo": "charla", "texto": frase, "turnos": [frase]})
|
||||||
|
|
||||||
|
return tareas
|
||||||
|
|
||||||
|
|
||||||
|
def una_tanda(ejecutor, motor, ronda, muestras):
|
||||||
|
acciones = ejecutor.cat.acciones
|
||||||
|
base_acciones = json.load(open(CATALOGO if os.path.exists(CATALOGO)
|
||||||
|
else CATALOGO_FUENTE))["acciones"]
|
||||||
|
tareas = escenarios(base_acciones)
|
||||||
|
|
||||||
|
# sintetizar todos los turnos
|
||||||
|
shutil.rmtree(WAVS, ignore_errors=True)
|
||||||
|
os.makedirs(WAVS, exist_ok=True)
|
||||||
|
plan = [] # (fichero, tarea_idx, turno_idx, texto)
|
||||||
|
n = 0
|
||||||
|
for ti, tarea in enumerate(tareas):
|
||||||
|
for tj, texto in enumerate(tarea["turnos"]):
|
||||||
|
n += 1
|
||||||
|
nombre = f"{n:05d}.wav"
|
||||||
|
if dice(texto, random.choice(VOCES), random.choice(VELOCIDADES),
|
||||||
|
random.choice(RUIDOS), os.path.join(WAVS, nombre)):
|
||||||
|
plan.append((nombre, ti, tj, texto))
|
||||||
|
|
||||||
|
log(f"ronda {ronda}: examinador dice {len(plan)} tareas, jarvis las oye...")
|
||||||
|
oidos = oye([p[0] for p in plan])
|
||||||
|
|
||||||
|
# correr los escenarios en orden (el estado importa en los multi-turno)
|
||||||
|
resultados = {"bien": 0, "reconoce_mal": 0, "accion_mal": 0,
|
||||||
|
"ejecuta_mal": 0, "charla_colada": 0}
|
||||||
|
candidatos = defaultdict(lambda: defaultdict(int))
|
||||||
|
por_tarea = defaultdict(list)
|
||||||
|
for nombre, ti, tj, texto in plan:
|
||||||
|
por_tarea[ti].append((tj, nombre, texto))
|
||||||
|
|
||||||
|
for ti in sorted(por_tarea):
|
||||||
|
tarea = tareas[ti]
|
||||||
|
ejecutor.lista = [] # cada escenario empieza limpio
|
||||||
|
ejecutor.fichero = None
|
||||||
|
for tj, nombre, texto in sorted(por_tarea[ti]):
|
||||||
|
oido = oidos.get(nombre, "").strip()
|
||||||
|
if not oido:
|
||||||
|
continue
|
||||||
|
tipo, dato, salida = ejecutor.procesa(oido)
|
||||||
|
|
||||||
|
# dejar la conversacion en el log, legible
|
||||||
|
log(f" examinador: «{texto}»", a_dialogo=True)
|
||||||
|
log(f" jarvis oye: «{oido}» -> {tipo}:{dato}", a_dialogo=True)
|
||||||
|
|
||||||
|
veredicto = verifica(tarea, tj, texto, oido, tipo, dato, salida,
|
||||||
|
motor, candidatos)
|
||||||
|
log(f" veredicto: {veredicto}\n", a_dialogo=True)
|
||||||
|
resultados[veredicto] = resultados.get(veredicto, 0) + 1
|
||||||
|
|
||||||
|
total = sum(resultados.values())
|
||||||
|
bien = resultados["bien"]
|
||||||
|
log(f"ronda {ronda}: {bien}/{total} bien · " +
|
||||||
|
" · ".join(f"{k} {v}" for k, v in resultados.items() if k != "bien" and v))
|
||||||
|
return resultados, candidatos
|
||||||
|
|
||||||
|
|
||||||
|
def verifica(tarea, tj, texto, oido, tipo, dato, salida, motor, candidatos):
|
||||||
|
"""Pone nota a un turno y, si es fallo de reconocimiento, anota el candidato."""
|
||||||
|
if tarea["tipo"] == "charla":
|
||||||
|
return "bien" if tipo == "modelo" else "charla_colada"
|
||||||
|
|
||||||
|
if tarea["tipo"] == "multi":
|
||||||
|
esperado = tarea["espera"][tj]
|
||||||
|
actual = f"{tipo}:{dato}"
|
||||||
|
if any(actual.startswith(e.split("|")[0].split(":")[0] + ":") and
|
||||||
|
(e == actual or e.split(":")[0] == tipo)
|
||||||
|
for e in esperado.split("|")):
|
||||||
|
# para multi basta con que el TIPO sea el correcto
|
||||||
|
if tipo in [e.split(":")[0] for e in esperado.split("|")]:
|
||||||
|
return "bien"
|
||||||
|
return "accion_mal"
|
||||||
|
|
||||||
|
# tarea de un turno: se espera un id concreto
|
||||||
|
espera_id = tarea["espera_id"]
|
||||||
|
if tipo != "accion":
|
||||||
|
# no reconocio la orden: candidato a alias si conserva sentido
|
||||||
|
candidatos[espera_id][motor.normaliza(oido)] += 1
|
||||||
|
return "reconoce_mal"
|
||||||
|
if dato != espera_id:
|
||||||
|
return "accion_mal"
|
||||||
|
# emparejo bien; ¿la ejecucion dio algo con sentido?
|
||||||
|
if salida and ("command not found" in salida or salida == "TIMEOUT"):
|
||||||
|
return "ejecuta_mal"
|
||||||
|
return "bien"
|
||||||
|
|
||||||
|
|
||||||
|
# --- aprender: mismos filtros seguros que mejora_nocturna --------------------
|
||||||
|
VACIAS = {"que", "de", "la", "el", "me", "se", "es", "esta", "estan", "hay",
|
||||||
|
"tengo", "un", "una", "por", "con", "como", "cuanto", "cuanta"}
|
||||||
|
|
||||||
|
|
||||||
|
def contenido(frase):
|
||||||
|
return {p for p in frase.split() if len(p) > 3 and p not in VACIAS}
|
||||||
|
|
||||||
|
|
||||||
|
def alias_seguros(candidatos, acciones, motor, cat):
|
||||||
|
porfrase = defaultdict(set)
|
||||||
|
for ident, frases in candidatos.items():
|
||||||
|
for f in frases:
|
||||||
|
porfrase[f].add(ident)
|
||||||
|
porid = {a["id"]: a for a in acciones}
|
||||||
|
propuestos = defaultdict(list)
|
||||||
|
for ident, frases in candidatos.items():
|
||||||
|
a = porid.get(ident)
|
||||||
|
if a is None or a.get("captura"):
|
||||||
|
continue
|
||||||
|
orig = motor.normaliza(a["frases"][0] if a["frases"] else "")
|
||||||
|
claves = contenido(orig)
|
||||||
|
for f, veces in frases.items():
|
||||||
|
if veces < 2 or len(porfrase[f]) > 1 or len(f.split()) < 2:
|
||||||
|
continue
|
||||||
|
if cat.busca(f)[0] is not None or motor.parecido(f, orig) < 0.6:
|
||||||
|
continue
|
||||||
|
pal = set(f.split())
|
||||||
|
if claves and not any(
|
||||||
|
c in pal or any(motor.parecido(c, w) > 0.8 for w in pal)
|
||||||
|
for c in claves):
|
||||||
|
continue
|
||||||
|
propuestos[ident].append((f, veces))
|
||||||
|
return propuestos
|
||||||
|
|
||||||
|
|
||||||
|
def guarda_alias(propuestos, acciones):
|
||||||
|
try:
|
||||||
|
propio = json.load(open(PROPIO))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
propio = {"version": 1, "acciones": []}
|
||||||
|
porid = {a["id"]: a for a in propio.get("acciones", [])}
|
||||||
|
orig = {a["id"]: a for a in acciones}
|
||||||
|
n = 0
|
||||||
|
for ident, lista in propuestos.items():
|
||||||
|
e = porid.get(ident)
|
||||||
|
if e is None:
|
||||||
|
e = dict(orig[ident]); porid[ident] = e
|
||||||
|
propio.setdefault("acciones", []).append(e)
|
||||||
|
for f, _ in lista:
|
||||||
|
if f not in e["frases"]:
|
||||||
|
e["frases"].append(f); n += 1
|
||||||
|
json.dump(propio, open(PROPIO, "w"), indent=2, ensure_ascii=False)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--horas", type=float, default=0)
|
||||||
|
ap.add_argument("--aplicar", action="store_true", help="guardar los alias que encuentre")
|
||||||
|
ap.add_argument("--muestras", type=int, default=1)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
for ruta, q in ((PIPER, "piper"), (WHISPER, "whisper")):
|
||||||
|
if not os.path.exists(ruta):
|
||||||
|
print(f"falta {q}"); return 1
|
||||||
|
|
||||||
|
os.makedirs(SALA, exist_ok=True)
|
||||||
|
motor = carga(MOTOR, "acciones")
|
||||||
|
sel = carga(SELECCION, "seleccion")
|
||||||
|
ejecutor = Ejecutor(motor, sel)
|
||||||
|
|
||||||
|
limite = time.time() + args.horas * 3600 if args.horas else None
|
||||||
|
log("=" * 56)
|
||||||
|
log("arranca la autoescuela (examinador + jarvis)")
|
||||||
|
|
||||||
|
historial = []
|
||||||
|
ronda = 0
|
||||||
|
total_alias = 0
|
||||||
|
while True:
|
||||||
|
ronda += 1
|
||||||
|
ini = time.time()
|
||||||
|
try:
|
||||||
|
resultados, candidatos = una_tanda(ejecutor, motor, ronda, args.muestras)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"ronda {ronda}: error, se salta — {type(e).__name__}: {e}")
|
||||||
|
time.sleep(5); continue
|
||||||
|
|
||||||
|
# aprender de los fallos de reconocimiento
|
||||||
|
base = json.load(open(CATALOGO if os.path.exists(CATALOGO)
|
||||||
|
else CATALOGO_FUENTE))["acciones"]
|
||||||
|
propuestos = alias_seguros(candidatos, base, motor, ejecutor.cat)
|
||||||
|
nuevos = sum(len(v) for v in propuestos.values())
|
||||||
|
if nuevos and args.aplicar:
|
||||||
|
total_alias += guarda_alias(propuestos, base)
|
||||||
|
ejecutor.recarga()
|
||||||
|
log(f"ronda {ronda}: +{nuevos} alias aplicados")
|
||||||
|
elif nuevos:
|
||||||
|
log(f"ronda {ronda}: {nuevos} alias propuestos (usa --aplicar para guardarlos)")
|
||||||
|
|
||||||
|
total = sum(resultados.values())
|
||||||
|
historial.append({
|
||||||
|
"ronda": ronda,
|
||||||
|
"acierto": round(100 * resultados["bien"] / max(1, total), 1),
|
||||||
|
"fallos": {k: v for k, v in resultados.items() if k != "bien" and v},
|
||||||
|
"minutos": round((time.time() - ini) / 60, 1),
|
||||||
|
})
|
||||||
|
json.dump({"actualizado": time.strftime("%Y-%m-%d %H:%M"),
|
||||||
|
"rondas": ronda, "alias_aplicados": total_alias,
|
||||||
|
"historial": historial[-30:]},
|
||||||
|
open(INFORME, "w"), indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
if not args.horas:
|
||||||
|
break
|
||||||
|
if time.time() > limite:
|
||||||
|
log("se acabo el tiempo. Fin."); break
|
||||||
|
|
||||||
|
shutil.rmtree(WAVS, ignore_errors=True)
|
||||||
|
log(f"terminado tras {ronda} rondas · ultimo acierto "
|
||||||
|
f"{historial[-1]['acierto'] if historial else '?'} %")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
185
config/banco.py
Executable file
185
config/banco.py
Executable file
|
|
@ -0,0 +1,185 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Un banco de pruebas FIJO para medir si el reconocimiento mejora.
|
||||||
|
|
||||||
|
## El problema que resuelve
|
||||||
|
|
||||||
|
La mejora nocturna medía así: decía las 150 frases con voces, velocidades y
|
||||||
|
ruido ALEATORIOS, las transcribía, y comparaba el porcentaje con el de la ronda
|
||||||
|
anterior. Nueve horas y 39 rondas después:
|
||||||
|
|
||||||
|
ruido de medida (desviacion): ±1,9 puntos
|
||||||
|
mejora acumulada en la noche: +1,45 puntos
|
||||||
|
|
||||||
|
El ruido era MAYOR que la mejora. Con eso, la pregunta que el bucle necesita
|
||||||
|
contestar —"¿este alias mejora o empeora?"— se decidía a cara o cruz, y por eso
|
||||||
|
en nueve horas aplicó cuatro alias y no dejó ni un commit.
|
||||||
|
|
||||||
|
## Cómo lo arregla
|
||||||
|
|
||||||
|
Se graba el audio UNA vez, con semillas fijas, y se guardan las
|
||||||
|
TRANSCRIPCIONES. A partir de ahí, medir el catálogo es volver a pasar el
|
||||||
|
emparejador sobre esas mismas transcripciones guardadas:
|
||||||
|
|
||||||
|
- determinista: la misma entrada siempre, así que dos medidas son comparables
|
||||||
|
- instantáneo: sin TTS ni Whisper, milisegundos en vez de cinco minutos
|
||||||
|
- sensible: se nota una frase de diferencia, no hacen falta cuatro puntos
|
||||||
|
|
||||||
|
Lo que se pierde: deja de explorar ruidos nuevos. Por eso el banco se REHACE de
|
||||||
|
vez en cuando (--crear), y entre medias se usa fijo para decidir.
|
||||||
|
|
||||||
|
banco.py --crear [--muestras 2] graba y transcribe (lento, una vez)
|
||||||
|
banco.py --medir puntúa el catálogo actual (instantáneo)
|
||||||
|
banco.py --fallos enseña qué se sigue fallando
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json")
|
||||||
|
MOTOR = os.path.join(RAIZ, "newelle", "src", "utility", "acciones.py")
|
||||||
|
BANCO = os.path.join(RAIZ, "muestras", "banco.json")
|
||||||
|
PIPER = os.path.join(RAIZ, "config", "jarvis-piper.sh")
|
||||||
|
MODELO_WHISPER = os.path.expanduser(
|
||||||
|
"~/.var/app/io.github.qwersyk.Newelle/config/models/whisper/whisper.cpp/models/ggml-small.bin")
|
||||||
|
BIN_WHISPER = os.path.join(RAIZ, "voz", "whisper-cuda", "whisper.cpp", "build", "bin", "whisper-server")
|
||||||
|
|
||||||
|
# Fijas a proposito: son las que hacen el banco reproducible. Cambiarlas obliga
|
||||||
|
# a rehacerlo, y esa es justamente la señal de que ya no es comparable.
|
||||||
|
VOCES = ["es_ES-davefx-medium", "es_MX-claude-high", "es_ES-sharvard-medium"]
|
||||||
|
VELOCIDADES = [0.9, 1.0, 1.15]
|
||||||
|
SEMILLA = 20260815
|
||||||
|
|
||||||
|
|
||||||
|
def motor():
|
||||||
|
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def crear(muestras):
|
||||||
|
m = motor()
|
||||||
|
cat = m.Catalogo([CATALOGO])
|
||||||
|
rnd = random.Random(SEMILLA)
|
||||||
|
|
||||||
|
casos = []
|
||||||
|
for a in cat.acciones:
|
||||||
|
if not a.frases:
|
||||||
|
continue
|
||||||
|
for _ in range(muestras):
|
||||||
|
casos.append({
|
||||||
|
"id": a.id,
|
||||||
|
"frase": rnd.choice(a.frases),
|
||||||
|
"voz": rnd.choice(VOCES),
|
||||||
|
"velocidad": rnd.choice(VELOCIDADES),
|
||||||
|
})
|
||||||
|
print(f" {len(casos)} clips a grabar y transcribir...", flush=True)
|
||||||
|
|
||||||
|
puerto = 8993
|
||||||
|
servidor = subprocess.Popen(
|
||||||
|
[BIN_WHISPER, "-m", MODELO_WHISPER, "-t", "8", "--host", "127.0.0.1",
|
||||||
|
"--port", str(puerto), "-l", "es", "--no-gpu"],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
env={**os.environ, "LD_LIBRARY_PATH": "/usr/local/cuda/lib64"})
|
||||||
|
import time
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
import urllib.request
|
||||||
|
hechos = 0
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
for c in casos:
|
||||||
|
wav = os.path.join(tmp, "c.wav")
|
||||||
|
entorno = {**os.environ,
|
||||||
|
"JARVIS_PIPER_VOZ": c["voz"],
|
||||||
|
"JARVIS_PIPER_TONO": "1.0"}
|
||||||
|
r = subprocess.run([PIPER, wav, c["frase"]], env=entorno,
|
||||||
|
capture_output=True, timeout=60)
|
||||||
|
if not os.path.exists(wav) or os.path.getsize(wav) == 0:
|
||||||
|
continue
|
||||||
|
# la velocidad se aplica al vuelo, sin tocar el tono
|
||||||
|
wav16 = os.path.join(tmp, "c16.wav")
|
||||||
|
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", wav,
|
||||||
|
"-filter:a", f"atempo={c['velocidad']}",
|
||||||
|
"-ar", "16000", "-ac", "1", wav16],
|
||||||
|
capture_output=True, timeout=60)
|
||||||
|
try:
|
||||||
|
with open(wav16, "rb") as f:
|
||||||
|
datos = f.read()
|
||||||
|
lim = "----b"
|
||||||
|
cuerpo = (f"--{lim}\r\nContent-Disposition: form-data; "
|
||||||
|
f'name="file"; filename="c.wav"\r\n'
|
||||||
|
f"Content-Type: audio/wav\r\n\r\n").encode() + datos + \
|
||||||
|
f"\r\n--{lim}--\r\n".encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{puerto}/inference", data=cuerpo,
|
||||||
|
headers={"Content-Type": f"multipart/form-data; boundary={lim}"})
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||||
|
c["oido"] = json.loads(resp.read().decode()).get("text", "").strip()
|
||||||
|
hechos += 1
|
||||||
|
if hechos % 50 == 0:
|
||||||
|
print(f" {hechos}/{len(casos)}", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
c["oido"] = ""
|
||||||
|
servidor.terminate()
|
||||||
|
|
||||||
|
casos = [c for c in casos if c.get("oido")]
|
||||||
|
os.makedirs(os.path.dirname(BANCO), exist_ok=True)
|
||||||
|
json.dump({"semilla": SEMILLA, "casos": casos}, open(BANCO, "w"),
|
||||||
|
ensure_ascii=False, indent=1)
|
||||||
|
print(f" banco guardado: {len(casos)} transcripciones en {BANCO}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cargar():
|
||||||
|
try:
|
||||||
|
return json.load(open(BANCO))["casos"]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def medir(detalle=False):
|
||||||
|
casos = cargar()
|
||||||
|
if not casos:
|
||||||
|
print("no hay banco; crealo con --crear")
|
||||||
|
return 1
|
||||||
|
m = motor()
|
||||||
|
cat = m.Catalogo([CATALOGO])
|
||||||
|
aciertos, fallos = 0, []
|
||||||
|
for c in casos:
|
||||||
|
a, _ = cat.busca(c["oido"])
|
||||||
|
if a is not None and a.id == c["id"]:
|
||||||
|
aciertos += 1
|
||||||
|
else:
|
||||||
|
fallos.append((c["id"], c["oido"], a.id if a else None))
|
||||||
|
pct = 100.0 * aciertos / len(casos)
|
||||||
|
print(f" acierto {pct:.2f} % ({aciertos}/{len(casos)})")
|
||||||
|
if detalle:
|
||||||
|
cuenta = collections.Counter(f[0] for f in fallos)
|
||||||
|
print(f"\n las acciones que mas se fallan:")
|
||||||
|
for ident, n in cuenta.most_common(12):
|
||||||
|
ejemplo = next(f[1] for f in fallos if f[0] == ident)
|
||||||
|
print(f" {n:3d} {ident:22s} p.ej. se oyo: \"{ejemplo[:44]}\"")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--crear", action="store_true")
|
||||||
|
p.add_argument("--medir", action="store_true")
|
||||||
|
p.add_argument("--fallos", action="store_true")
|
||||||
|
p.add_argument("--muestras", type=int, default=2)
|
||||||
|
a = p.parse_args()
|
||||||
|
if a.crear:
|
||||||
|
return crear(a.muestras)
|
||||||
|
return medir(detalle=a.fallos)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
81
config/comparar_voces.py
Normal file
81
config/comparar_voces.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Genera la misma frase con las cuatro voces britanicas de Kokoro.
|
||||||
|
|
||||||
|
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||||
|
~/COFRE/CODERS/JARVIS/config/comparar_voces.py
|
||||||
|
|
||||||
|
Todas salen ya con el tratamiento aplicado, que es como sonaran de verdad.
|
||||||
|
Elegir de oido y fijar la ganadora con set_voice.py.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/app/share/newelle")
|
||||||
|
sys.path.insert(0, os.path.expanduser(
|
||||||
|
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
|
||||||
|
|
||||||
|
from gi.repository import Gio # noqa: E402
|
||||||
|
|
||||||
|
from newelle.handlers.tts.kokoro_handler import KokoroTTSHandler # noqa: E402
|
||||||
|
from newelle.handlers.tts.tts import jarvis_audio_filter # noqa: E402
|
||||||
|
|
||||||
|
VOCES = ["bm_george", "bm_daniel", "bm_lewis", "bm_fable"]
|
||||||
|
|
||||||
|
FRASE = ("Good evening, sir. All systems are running locally. "
|
||||||
|
"The GPU is idle, and you have 846 gigabytes free. "
|
||||||
|
"Shall I run the diagnostics?")
|
||||||
|
|
||||||
|
SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/muestras/voces")
|
||||||
|
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
|
||||||
|
|
||||||
|
|
||||||
|
def nivel(path):
|
||||||
|
"""mean/max en dB, para confirmar que ninguna sale floja o recortada."""
|
||||||
|
r = subprocess.run(
|
||||||
|
["ffmpeg", "-hide_banner", "-i", path, "-af", "volumedetect",
|
||||||
|
"-f", "null", "/dev/null"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
vals = [l.split(":")[-1].strip() for l in r.stderr.splitlines()
|
||||||
|
if "mean_volume:" in l or "max_volume:" in l]
|
||||||
|
return " / ".join(vals) if vals else "?"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(SALIDA, exist_ok=True)
|
||||||
|
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
|
||||||
|
handler = KokoroTTSHandler(settings, os.path.join(CONFIG, "models"))
|
||||||
|
|
||||||
|
if not handler.is_installed():
|
||||||
|
print("instalando kokoro...")
|
||||||
|
handler.install()
|
||||||
|
|
||||||
|
filtro = jarvis_audio_filter()
|
||||||
|
print(f"{'voz':<12} {'fichero':<26} nivel (mean / max)")
|
||||||
|
|
||||||
|
for voz in VOCES:
|
||||||
|
handler.set_voice(voz)
|
||||||
|
crudo = os.path.join(SALIDA, f"_{voz}_seca.wav")
|
||||||
|
final = os.path.join(SALIDA, f"{voz}.wav")
|
||||||
|
try:
|
||||||
|
handler.save_audio(FRASE, crudo)
|
||||||
|
subprocess.run(
|
||||||
|
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
|
"-i", crudo, "-af", filtro, "-y", final],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
os.remove(crudo)
|
||||||
|
print(f"{voz:<12} {os.path.basename(final):<26} {nivel(final)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{voz:<12} FALLO: {e}")
|
||||||
|
|
||||||
|
# dejar la voz como estaba, que este script solo compara
|
||||||
|
handler.set_voice(VOCES[0])
|
||||||
|
print(f"\nvoz restaurada a {VOCES[0]}")
|
||||||
|
print(f"escuchalas en: {SALIDA}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
108
config/comparar_voces_es.py
Normal file
108
config/comparar_voces_es.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Genera la misma frase en castellano con las voces masculinas disponibles.
|
||||||
|
|
||||||
|
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||||
|
~/COFRE/CODERS/JARVIS/config/comparar_voces_es.py
|
||||||
|
|
||||||
|
Kokoro solo trae DOS voces masculinas en español: em_alex y em_santa. De los
|
||||||
|
motores que Newelle soporta, los unicos locales son kokoro y espeak; el resto
|
||||||
|
—gtts, edge, elevenlabs, openai, groq— son de nube y quedan descartados.
|
||||||
|
|
||||||
|
Como ninguna de las dos es especialmente grave, cada una se genera tambien en
|
||||||
|
version grave: se baja el tono con ffmpeg sin cambiar la velocidad. El truco es
|
||||||
|
reproducir a menos muestras por segundo (baja tono y alarga) y devolver la
|
||||||
|
duracion con atempo. A 0.90 baja unos dos semitonos, que se nota sin sonar a
|
||||||
|
disco rayado; por debajo de 0.85 empieza a sonar artificial.
|
||||||
|
|
||||||
|
Se escuchan las cuatro y se fija la elegida con set_voz_es.py.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/app/share/newelle")
|
||||||
|
sys.path.insert(0, os.path.expanduser(
|
||||||
|
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
|
||||||
|
|
||||||
|
from gi.repository import Gio # noqa: E402
|
||||||
|
|
||||||
|
from newelle.handlers.tts.kokoro_handler import KokoroTTSHandler # noqa: E402
|
||||||
|
from newelle.handlers.tts.tts import jarvis_audio_filter # noqa: E402
|
||||||
|
|
||||||
|
VOCES = ["em_alex", "em_santa"]
|
||||||
|
GRAVEDAD = 0.90 # 1.0 = sin tocar; menos = mas grave
|
||||||
|
MUESTREO = 24000 # el que saca kokoro
|
||||||
|
|
||||||
|
FRASE = ("Buenas tardes. Todos los sistemas funcionan en local. "
|
||||||
|
"La tarjeta grafica esta libre y quedan ochocientos cuarenta y seis "
|
||||||
|
"gigabytes en el disco. Quiere que ejecute el diagnostico?")
|
||||||
|
|
||||||
|
SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/muestras/voces_es")
|
||||||
|
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
|
||||||
|
|
||||||
|
|
||||||
|
def cadena(grave: bool) -> str:
|
||||||
|
"""El tratamiento JARVIS, con o sin bajada de tono delante."""
|
||||||
|
base = jarvis_audio_filter()
|
||||||
|
if not grave:
|
||||||
|
return base
|
||||||
|
tono = (f"asetrate={int(MUESTREO * GRAVEDAD)},"
|
||||||
|
f"aresample={MUESTREO},atempo={1 / GRAVEDAD:.4f}")
|
||||||
|
return f"{tono},{base}" if base else tono
|
||||||
|
|
||||||
|
|
||||||
|
def nivel(path):
|
||||||
|
"""mean/max en dB, para confirmar que ninguna sale floja o recortada."""
|
||||||
|
r = subprocess.run(
|
||||||
|
["ffmpeg", "-hide_banner", "-i", path, "-af", "volumedetect",
|
||||||
|
"-f", "null", "/dev/null"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
vals = [l.split(":")[-1].strip() for l in r.stderr.splitlines()
|
||||||
|
if "mean_volume:" in l or "max_volume:" in l]
|
||||||
|
return " / ".join(vals) if vals else "?"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(SALIDA, exist_ok=True)
|
||||||
|
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
|
||||||
|
handler = KokoroTTSHandler(settings, os.path.join(CONFIG, "models"))
|
||||||
|
|
||||||
|
if not handler.is_installed():
|
||||||
|
print("instalando kokoro...")
|
||||||
|
handler.install()
|
||||||
|
|
||||||
|
previa = handler.get_current_voice() if hasattr(handler, "get_current_voice") else None
|
||||||
|
print(f"{'fichero':<28} nivel (mean / max)")
|
||||||
|
|
||||||
|
for voz in VOCES:
|
||||||
|
handler.set_voice(voz)
|
||||||
|
crudo = os.path.join(SALIDA, f"_{voz}.wav")
|
||||||
|
try:
|
||||||
|
handler.save_audio(FRASE, crudo)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{voz:<28} FALLO al generar: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for etiqueta, grave in (("", False), ("_grave", True)):
|
||||||
|
final = os.path.join(SALIDA, f"{voz}{etiqueta}.wav")
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
|
"-i", crudo, "-af", cadena(grave), "-y", final],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
print(f"{os.path.basename(final):<28} {nivel(final)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{voz}{etiqueta:<20} FALLO: {e}")
|
||||||
|
os.remove(crudo)
|
||||||
|
|
||||||
|
if previa:
|
||||||
|
handler.set_voice(previa)
|
||||||
|
print(f"\nescuchalas en: {SALIDA}")
|
||||||
|
print("la elegida se fija con set_voz_es.py <voz> [--grave]")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
101
config/construir_indice.py
Normal file
101
config/construir_indice.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Construye el indice del RAG sin abrir la interfaz.
|
||||||
|
|
||||||
|
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||||
|
~/COFRE/CODERS/JARVIS/config/construir_indice.py
|
||||||
|
|
||||||
|
En la app esto es el boton "Update Index" de los ajustes del RAG, que lanza un
|
||||||
|
hilo y va enseñando el progreso en una barra. Desde aqui se llama a lo mismo
|
||||||
|
pero en primer plano, para poder dejarlo corriendo en una terminal y ver si
|
||||||
|
falla, que con 20 000 fragmentos conviene.
|
||||||
|
|
||||||
|
Antes hay que haber preparado el corpus con indexar.py: este script indexa lo
|
||||||
|
que haya en la carpeta de documentos, sin filtrar nada.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/app/share/newelle")
|
||||||
|
sys.path.insert(0, os.path.expanduser(
|
||||||
|
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
|
||||||
|
|
||||||
|
from gi.repository import Gio # noqa: E402
|
||||||
|
|
||||||
|
from newelle.handlers.rag.llamaindex_handler import LlamaIndexHanlder # noqa: E402
|
||||||
|
from newelle.handlers.embeddings.model2vec import Model2VecHandler # noqa: E402
|
||||||
|
|
||||||
|
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
|
||||||
|
MODELOS = os.path.join(CONFIG, "models")
|
||||||
|
DOCUMENTOS = os.path.join(MODELOS, "documents")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.isdir(DOCUMENTOS):
|
||||||
|
print(f"no existe {DOCUMENTOS}")
|
||||||
|
print("prepara antes el corpus: python3 config/indexar.py --hacerlo")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
ficheros = [f for f in os.listdir(DOCUMENTOS) if not f.startswith(".")]
|
||||||
|
if not ficheros:
|
||||||
|
print("la carpeta de documentos esta vacia")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
tam = sum(os.path.getsize(os.path.join(DOCUMENTOS, f))
|
||||||
|
for f in ficheros) / 1048576
|
||||||
|
print(f"{len(ficheros)} documentos · {tam:.1f} MB")
|
||||||
|
|
||||||
|
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
|
||||||
|
rag = LlamaIndexHanlder(settings, MODELOS)
|
||||||
|
|
||||||
|
if not rag.is_installed():
|
||||||
|
print("faltan dependencias del RAG (llama-index, faiss, tiktoken)")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# El RAG necesita el modelo de embeddings para vectorizar, y opcionalmente
|
||||||
|
# un LLM secundario para reescribir la consulta. Aqui solo hace falta el
|
||||||
|
# primero: se indexa, no se pregunta.
|
||||||
|
rag.embedding = Model2VecHandler(settings, MODELOS)
|
||||||
|
if not rag.embedding.is_installed():
|
||||||
|
print("instalando model2vec...")
|
||||||
|
rag.embedding.install()
|
||||||
|
# create_index solo le pide load_model() al LLM —para indexar no hace falta
|
||||||
|
# generar nada— asi que un cascaron sirve y evita cargar 3,7 GB de modelo
|
||||||
|
# para vectorizar documentos.
|
||||||
|
class SinModelo:
|
||||||
|
def load_model(self, *a, **k):
|
||||||
|
return True
|
||||||
|
|
||||||
|
rag.llm = SinModelo()
|
||||||
|
rag.secondary_llm = SinModelo()
|
||||||
|
|
||||||
|
trozo = rag.get_setting("chunk_size")
|
||||||
|
print(f"fragmentos de {trozo} tokens · esto tarda, no lo cortes\n")
|
||||||
|
|
||||||
|
ini = time.time()
|
||||||
|
try:
|
||||||
|
rag.create_index()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nfallo al indexar: {type(e).__name__}: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
minutos = (time.time() - ini) / 60
|
||||||
|
_, data_path = rag.get_paths()
|
||||||
|
peso = sum(
|
||||||
|
os.path.getsize(os.path.join(dp, f))
|
||||||
|
for dp, _, fs in os.walk(data_path) for f in fs) / 1048576
|
||||||
|
|
||||||
|
# create_index se traga sus propias excepciones e imprime el error, asi que
|
||||||
|
# terminar sin fallo no significa que haya indice. Se comprueba el disco.
|
||||||
|
if peso < 0.1:
|
||||||
|
print(f"\nno se creo indice ({peso:.1f} MB en {data_path})")
|
||||||
|
print("mira el error de arriba")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"\nindice construido en {minutos:.1f} min · {peso:.1f} MB")
|
||||||
|
print(f"en {data_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
94
config/demo_en_directo.sh
Executable file
94
config/demo_en_directo.sh
Executable file
|
|
@ -0,0 +1,94 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Le da ordenes a JARVIS en directo, para verlo trabajar en la pantalla.
|
||||||
|
#
|
||||||
|
# Cada orden se dice EN VOZ ALTA con la voz activa (para oir como suena de
|
||||||
|
# verdad, no en un banco de pruebas) y luego se ejecuta la accion que le
|
||||||
|
# corresponde, igual que haria el asistente al oirte.
|
||||||
|
#
|
||||||
|
# No se ejecuta nada destructivo ni nada con root: eso pasa por un dialogo que
|
||||||
|
# aprueba una persona, y aqui no hay persona delante del dialogo.
|
||||||
|
#
|
||||||
|
# ./demo_en_directo.sh las que salen por pantalla incluidas
|
||||||
|
# ./demo_en_directo.sh --mudo sin voz, solo resultados
|
||||||
|
set -uo pipefail
|
||||||
|
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
|
||||||
|
MUDO=no; [ "${1:-}" = "--mudo" ] && MUDO=si
|
||||||
|
|
||||||
|
CATALOGO=$RAIZ/newelle/data/acciones/acciones.json
|
||||||
|
TMPWAV=$(mktemp --suffix=.wav)
|
||||||
|
trap 'rm -f "$TMPWAV"' EXIT
|
||||||
|
|
||||||
|
# La orden que se le diria, y la accion del catalogo que le toca.
|
||||||
|
# Se eligen las que se VEN o las que contestan algo concreto.
|
||||||
|
ORDENES=(
|
||||||
|
"cuanto espacio queda|disco_libre"
|
||||||
|
"cuanta memoria|memoria"
|
||||||
|
"que temperatura hay|temperatura"
|
||||||
|
"que procesos consumen mas|procesos_top"
|
||||||
|
"cual es mi ip|red_ip"
|
||||||
|
"cuanto lleva encendido|uptime"
|
||||||
|
"busca el fichero README|buscar_fichero|README.md"
|
||||||
|
"busca dentro JARVIS|grep_recursivo|JARVIS"
|
||||||
|
"hazme una captura|captura_pantalla"
|
||||||
|
"abre una terminal|terminal_abrir"
|
||||||
|
"que peliculas tengo|videos_listar"
|
||||||
|
"pon la pelicula screencast|pelicula_abrir|Screencast"
|
||||||
|
"abre la carpeta de jarvis|abrir_carpeta|$RAIZ"
|
||||||
|
)
|
||||||
|
|
||||||
|
dice() {
|
||||||
|
[ "$MUDO" = si ] && return 0
|
||||||
|
"$RAIZ/config/jarvis-voz.sh" "$TMPWAV" "$1" >/dev/null 2>&1 \
|
||||||
|
|| JARVIS_PIPER_VOZ=es_ES-davefx-medium "$RAIZ/config/jarvis-piper.sh" "$TMPWAV" "$1" >/dev/null 2>&1
|
||||||
|
[ -s "$TMPWAV" ] && { paplay "$TMPWAV" 2>/dev/null || aplay -q "$TMPWAV" 2>/dev/null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
campo() { # campo <id_accion> <clave>
|
||||||
|
python3 -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(open('$CATALOGO',encoding='utf-8'))
|
||||||
|
for a in d.get('acciones', d if isinstance(d,list) else []):
|
||||||
|
if a['id']==sys.argv[1]: print(a.get(sys.argv[2],'') or ''); break
|
||||||
|
" "$1" "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
for entrada in "${ORDENES[@]}"; do
|
||||||
|
IFS='|' read -r frase accion argumento <<< "$entrada"
|
||||||
|
comando=$(campo "$accion" comando)
|
||||||
|
acuse=$(campo "$accion" acuse)
|
||||||
|
respuesta=$(campo "$accion" respuesta)
|
||||||
|
|
||||||
|
if [ -z "$comando" ]; then
|
||||||
|
printf '\n ── "%s" → accion %s NO EXISTE en el catalogo\n' "$frase" "$accion"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n ── tu dices: "%s"\n' "$frase"
|
||||||
|
printf ' jarvis: %s\n' "${acuse:-...}"
|
||||||
|
dice "${acuse:-Un momento}"
|
||||||
|
|
||||||
|
# sustituir el argumento igual que hace el motor
|
||||||
|
real=${comando//\{argumento\}/$(printf '%q' "${argumento:-}")}
|
||||||
|
salida=$(timeout 30 bash -lc "$real" 2>&1 | head -6)
|
||||||
|
|
||||||
|
if [ -n "$salida" ]; then
|
||||||
|
printf ' resultado: %s\n' "$(echo "$salida" | head -1 | cut -c1-100)"
|
||||||
|
echo "$salida" | tail -n +2 | sed 's/^/ /' | cut -c1-100
|
||||||
|
else
|
||||||
|
printf ' resultado: (hecho, sin salida que enseñar)\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# decir la respuesta con la plantilla, si la tiene y es corta
|
||||||
|
if [ -n "$respuesta" ] && [ -n "$salida" ]; then
|
||||||
|
ultima=$(echo "$salida" | tail -1)
|
||||||
|
hablado=${respuesta//\{ultima\}/$ultima}
|
||||||
|
hablado=${hablado//\{salida\}/$ultima}
|
||||||
|
case "$hablado" in
|
||||||
|
*"{"*) ;; # plantilla con campos que no sabemos
|
||||||
|
*) [ ${#hablado} -lt 160 ] && { printf ' jarvis: %s\n' "$hablado"; dice "$hablado"; };;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '\n ── fin de la demostracion\n'
|
||||||
190
config/descubrir.py
Executable file
190
config/descubrir.py
Executable file
|
|
@ -0,0 +1,190 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Cataloga las herramientas de COFRE para que JARVIS sepa cuales hay.
|
||||||
|
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/descubrir.py
|
||||||
|
|
||||||
|
Produce jarvis-herramientas.json: una ficha por herramienta con su nombre, que
|
||||||
|
hace, como se invoca y en que carpeta vive. NO es el catalogo de acciones
|
||||||
|
rapidas (esas son las ~20 que dices por voz a diario, escritas a mano). Esto es
|
||||||
|
el registro que el modelo consulta cuando le pides algo que no esta en el
|
||||||
|
catalogo, para componer el comando en vez de inventarselo.
|
||||||
|
|
||||||
|
Que hace y que NO hace:
|
||||||
|
- SI: encuentra los puntos de entrada reales (scripts con shebang y binarios
|
||||||
|
en los primeros niveles de cada proyecto, no los modulos internos).
|
||||||
|
- SI: saca una descripcion del README, del docstring o de la cabecera, SIN
|
||||||
|
ejecutar nada. Ejecutar un script desconocido con --help para leerlo seria
|
||||||
|
ejecutar un script desconocido, que es justo lo que no se quiere hacer a
|
||||||
|
ciegas.
|
||||||
|
- NO: ejecuta ninguna herramienta. Esto solo mira.
|
||||||
|
|
||||||
|
El resultado se revisa a mano antes de usarlo: es material de terceros y algun
|
||||||
|
"tool" sera un ejemplo o un exploit, no algo que quieras ofrecer.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
|
||||||
|
COFRE = os.path.expanduser("~/COFRE")
|
||||||
|
SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/config/jarvis-herramientas.json")
|
||||||
|
|
||||||
|
CARPETAS = ["CODERS", "PENTESTERS", "AUTOMATAS", "DOSER", "HARD-WARERS",
|
||||||
|
"PHISHERS", "PROTECTORS", "REVERSERS", "SNEAKERS"]
|
||||||
|
|
||||||
|
# Solo los primeros niveles: un punto de entrada vive cerca de la raiz del
|
||||||
|
# proyecto, no enterrado en lib/ o en site-packages.
|
||||||
|
PROFUNDIDAD = 3
|
||||||
|
|
||||||
|
FUERA = {"node_modules", ".git", "venv", ".venv", "site-packages", "__pycache__",
|
||||||
|
"lib", "libs", "tests", "test", "docs", "examples", "vendor", "dist",
|
||||||
|
"build", ".tox", "exploitdb"}
|
||||||
|
|
||||||
|
# Nombres que casi nunca son la herramienta en si
|
||||||
|
IGNORAR_NOMBRES = {"setup.py", "conftest.py", "__init__.py", "test.py",
|
||||||
|
"tests.py", "wsgi.py", "manage.py", "conf.py"}
|
||||||
|
|
||||||
|
|
||||||
|
def es_ejecutable(ruta: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bool(os.stat(ruta).st_mode & stat.S_IXUSR)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def tiene_shebang(ruta: str) -> bool:
|
||||||
|
try:
|
||||||
|
with open(ruta, "rb") as f:
|
||||||
|
return f.read(2) == b"#!"
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def descripcion_readme(carpeta: str) -> str:
|
||||||
|
"""Primera linea con contenido de un README cercano."""
|
||||||
|
for nombre in ("README.md", "README.rst", "README.txt", "README"):
|
||||||
|
ruta = os.path.join(carpeta, nombre)
|
||||||
|
if not os.path.exists(ruta):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(ruta, errors="ignore") as f:
|
||||||
|
for linea in f:
|
||||||
|
limpia = linea.strip().lstrip("#").lstrip(">").strip()
|
||||||
|
# saltar titulos de una palabra, badges y separadores
|
||||||
|
if len(limpia) > 25 and not limpia.startswith(("![", "[!", "---", "===")):
|
||||||
|
return limpia[:200]
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def descripcion_script(ruta: str) -> str:
|
||||||
|
"""Docstring del modulo (Python) o cabecera de comentarios (shell)."""
|
||||||
|
try:
|
||||||
|
with open(ruta, errors="ignore") as f:
|
||||||
|
texto = f.read(4000)
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if ruta.endswith(".py"):
|
||||||
|
try:
|
||||||
|
doc = ast.get_docstring(ast.parse(texto))
|
||||||
|
if doc:
|
||||||
|
return doc.strip().split("\n")[0][:200]
|
||||||
|
except (SyntaxError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# cabecera de comentarios: las primeras lineas que empiezan por # tras el shebang
|
||||||
|
lineas = []
|
||||||
|
for linea in texto.splitlines()[1:8]:
|
||||||
|
s = linea.strip()
|
||||||
|
if s.startswith("#"):
|
||||||
|
limpio = s.lstrip("#").strip()
|
||||||
|
if len(limpio) > 15:
|
||||||
|
lineas.append(limpio)
|
||||||
|
elif lineas:
|
||||||
|
break
|
||||||
|
return " ".join(lineas)[:200]
|
||||||
|
|
||||||
|
|
||||||
|
def recorre():
|
||||||
|
herramientas = []
|
||||||
|
vistos = set()
|
||||||
|
|
||||||
|
for carpeta in CARPETAS:
|
||||||
|
raiz = os.path.join(COFRE, carpeta)
|
||||||
|
if not os.path.isdir(raiz):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for dirpath, dirnames, ficheros in os.walk(raiz):
|
||||||
|
profundidad = dirpath[len(raiz):].count(os.sep)
|
||||||
|
if profundidad >= PROFUNDIDAD:
|
||||||
|
dirnames[:] = []
|
||||||
|
continue
|
||||||
|
dirnames[:] = [d for d in dirnames
|
||||||
|
if d not in FUERA and not d.startswith(".")]
|
||||||
|
|
||||||
|
for nombre in ficheros:
|
||||||
|
if nombre in IGNORAR_NOMBRES:
|
||||||
|
continue
|
||||||
|
ruta = os.path.join(dirpath, nombre)
|
||||||
|
ext = os.path.splitext(nombre)[1].lower()
|
||||||
|
|
||||||
|
es_script = ext in (".py", ".sh") and (tiene_shebang(ruta) or es_ejecutable(ruta))
|
||||||
|
es_binario = not ext and es_ejecutable(ruta) and not tiene_shebang(ruta)
|
||||||
|
if not (es_script or es_binario):
|
||||||
|
continue
|
||||||
|
|
||||||
|
clave = nombre.lower()
|
||||||
|
if clave in vistos:
|
||||||
|
continue
|
||||||
|
vistos.add(clave)
|
||||||
|
|
||||||
|
desc = descripcion_script(ruta) if es_script else ""
|
||||||
|
if not desc:
|
||||||
|
desc = descripcion_readme(dirpath)
|
||||||
|
|
||||||
|
herramientas.append({
|
||||||
|
"nombre": os.path.splitext(nombre)[0],
|
||||||
|
"carpeta": carpeta,
|
||||||
|
"ruta": ruta,
|
||||||
|
"tipo": "python" if ext == ".py" else "shell" if ext == ".sh" else "binario",
|
||||||
|
"descripcion": desc,
|
||||||
|
"invocar": (f"python3 {ruta}" if ext == ".py"
|
||||||
|
else f"bash {ruta}" if ext == ".sh"
|
||||||
|
else ruta),
|
||||||
|
})
|
||||||
|
|
||||||
|
return herramientas
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("recorriendo COFRE (solo lectura, no se ejecuta nada)...\n", flush=True)
|
||||||
|
tools = recorre()
|
||||||
|
|
||||||
|
porcarpeta = {}
|
||||||
|
con_desc = 0
|
||||||
|
for t in tools:
|
||||||
|
porcarpeta[t["carpeta"]] = porcarpeta.get(t["carpeta"], 0) + 1
|
||||||
|
if t["descripcion"]:
|
||||||
|
con_desc += 1
|
||||||
|
|
||||||
|
for carpeta in CARPETAS:
|
||||||
|
if carpeta in porcarpeta:
|
||||||
|
print(f" {carpeta:<13} {porcarpeta[carpeta]:>4} herramientas")
|
||||||
|
print(f"\n total {len(tools)}, con descripcion {con_desc} "
|
||||||
|
f"({100*con_desc//max(1,len(tools))} %)")
|
||||||
|
|
||||||
|
with open(SALIDA, "w") as f:
|
||||||
|
json.dump({"version": 1, "herramientas": tools}, f,
|
||||||
|
indent=2, ensure_ascii=False)
|
||||||
|
print(f"\nescrito {SALIDA}")
|
||||||
|
print("REVISALO a mano antes de usarlo: es material de terceros y algun")
|
||||||
|
print("'tool' sera un ejemplo o un exploit, no algo que quieras ofrecer.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
331
config/entrenar.py
Executable file
331
config/entrenar.py
Executable file
|
|
@ -0,0 +1,331 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Entrena el reconocimiento solo, sin que nadie hable.
|
||||||
|
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py # tanda normal
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py --rapido # una voz
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py --aplicar # guarda alias
|
||||||
|
|
||||||
|
Como funciona: por cada accion del catalogo se dice su frase en voz alta con
|
||||||
|
Piper, en varias voces y velocidades y con algo de ruido y sala, se transcribe
|
||||||
|
con el mismo whisper que usa el asistente, y se mira si la transcripcion llega
|
||||||
|
a la accion correcta. Lo que no llega se apunta como alias candidato.
|
||||||
|
|
||||||
|
Por que sirve: los fallos del reconocedor en castellano son sistematicos, no
|
||||||
|
aleatorios. "abre el correo" se oye "a ver el correo" siempre, y una tanda de
|
||||||
|
varios cientos de frases los saca casi todos de una sentada.
|
||||||
|
|
||||||
|
Que NO sustituye: esto es voz sintetica, no la suya. Sirve para las colisiones
|
||||||
|
foneticas del idioma —que son la mayoria de los fallos— pero no para su acento,
|
||||||
|
su microfono ni su habitacion. Para eso esta el registro de lo que no entiende
|
||||||
|
en el uso real, que se revisa con aprender.py. Las dos cosas se suman.
|
||||||
|
|
||||||
|
Un alias solo se propone si no choca con otra accion: si "a ver el correo"
|
||||||
|
pudiera ser dos cosas distintas, se descarta y se avisa.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
CASA = os.path.expanduser("~")
|
||||||
|
JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS")
|
||||||
|
CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config")
|
||||||
|
APP = "io.github.qwersyk.Newelle//master"
|
||||||
|
|
||||||
|
PIPER = os.path.join(JARVIS, "voz/piper/piper")
|
||||||
|
MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos")
|
||||||
|
WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli")
|
||||||
|
MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models")
|
||||||
|
|
||||||
|
CATALOGO = os.path.expanduser(
|
||||||
|
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
|
||||||
|
"active/files/share/newelle/acciones/acciones.json")
|
||||||
|
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
|
||||||
|
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
|
||||||
|
INFORME = os.path.join(JARVIS, "muestras/entrenamiento.json")
|
||||||
|
|
||||||
|
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
|
||||||
|
|
||||||
|
# Voces distintas para que no aprenda los defectos de una sola, y velocidades
|
||||||
|
# distintas porque uno no habla igual con prisa que sin ella.
|
||||||
|
VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
|
||||||
|
VELOCIDADES = [1.0, 0.88, 1.15]
|
||||||
|
|
||||||
|
# Un poco de sala y de ruido de fondo: en seco el reconocedor acierta mas de lo
|
||||||
|
# que acertara nunca en una habitacion de verdad, y entrenar con eso da una
|
||||||
|
# falsa sensacion de que todo va bien.
|
||||||
|
RUIDOS = [
|
||||||
|
"", # limpio
|
||||||
|
"aecho=0.8:0.7:22:0.12", # algo de sala
|
||||||
|
"volume=0.75,highpass=f=120", # mas lejos del microfono
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def carga_motor():
|
||||||
|
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def dentro(orden, limite=900):
|
||||||
|
"""Ejecuta algo dentro del flatpak, que es donde vive whisper."""
|
||||||
|
return subprocess.run(
|
||||||
|
["flatpak", "run", "--command=bash", APP, "-c", orden],
|
||||||
|
capture_output=True, text=True, timeout=limite)
|
||||||
|
|
||||||
|
|
||||||
|
def genera(texto, voz, velocidad, ruido, destino):
|
||||||
|
crudo = tempfile.mktemp(suffix=".wav")
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
|
||||||
|
"--length_scale", str(1 / velocidad), "--output_file", crudo],
|
||||||
|
input=texto, capture_output=True, text=True, timeout=60)
|
||||||
|
if r.returncode != 0 or not os.path.exists(crudo):
|
||||||
|
return False
|
||||||
|
filtros = "aresample=16000" + ("," + ruido if ruido else "")
|
||||||
|
subprocess.run(
|
||||||
|
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", crudo,
|
||||||
|
"-ac", "1", "-af", filtros, "-y", destino],
|
||||||
|
check=True, timeout=60)
|
||||||
|
return os.path.exists(destino)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
if os.path.exists(crudo):
|
||||||
|
os.remove(crudo)
|
||||||
|
|
||||||
|
|
||||||
|
# Por tandas y no de una: mil frases son un cuarto de hora, y una sola llamada
|
||||||
|
# con limite de tiempo se corta a la mitad y se pierde todo lo transcrito. Por
|
||||||
|
# tandas ademas se puede ver el avance, que en algo que tarda tanto importa.
|
||||||
|
POR_TANDA = 40
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_lote(carpeta, ficheros, modelo="base", hilos=8):
|
||||||
|
"""Transcribe una lista de wav. Devuelve {fichero: texto}."""
|
||||||
|
salida = {}
|
||||||
|
ini = time.time()
|
||||||
|
for i in range(0, len(ficheros), POR_TANDA):
|
||||||
|
tanda = ficheros[i:i + POR_TANDA]
|
||||||
|
lista = " ".join(f'"{carpeta}/{n}"' for n in tanda)
|
||||||
|
orden = (
|
||||||
|
f'for f in {lista}; do '
|
||||||
|
f' echo "### $(basename "$f")"; '
|
||||||
|
f' {WHISPER} -m {MODELOS_STT}/ggml-{modelo}.bin -l es -nt -t {hilos} '
|
||||||
|
f' -f "$f" 2>/dev/null | tr "\\n" " "; echo; '
|
||||||
|
f'done')
|
||||||
|
try:
|
||||||
|
r = dentro(orden)
|
||||||
|
except subprocess.SubprocessError as e:
|
||||||
|
print(f" tanda {i//POR_TANDA + 1} fallo: {e}", flush=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
actual = None
|
||||||
|
for linea in r.stdout.splitlines():
|
||||||
|
if linea.startswith("### "):
|
||||||
|
actual = linea[4:].strip()
|
||||||
|
elif actual:
|
||||||
|
salida[actual] = linea.strip()
|
||||||
|
actual = None
|
||||||
|
|
||||||
|
hechas = min(i + POR_TANDA, len(ficheros))
|
||||||
|
queda = (time.time() - ini) / hechas * (len(ficheros) - hechas)
|
||||||
|
print(f" transcritas {hechas}/{len(ficheros)} "
|
||||||
|
f"({queda/60:.0f} min restantes)", flush=True)
|
||||||
|
return salida
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--rapido", action="store_true", help="una voz y una velocidad")
|
||||||
|
ap.add_argument("--aplicar", action="store_true", help="guardar los alias encontrados")
|
||||||
|
ap.add_argument("--tope", type=int, default=0, help="limitar acciones (para probar)")
|
||||||
|
ap.add_argument("--reanudar", action="store_true",
|
||||||
|
help="usar los wav ya generados en vez de rehacerlos")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
for ruta, que in ((PIPER, "piper"), (WHISPER, "whisper")):
|
||||||
|
if not os.path.exists(ruta):
|
||||||
|
print(f"falta {que}: {ruta}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
catalogo = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE
|
||||||
|
with open(catalogo) as f:
|
||||||
|
acciones = json.load(f)["acciones"]
|
||||||
|
if args.tope:
|
||||||
|
acciones = acciones[:args.tope]
|
||||||
|
|
||||||
|
voces = VOCES[:1] if args.rapido else VOCES
|
||||||
|
velocidades = VELOCIDADES[:1] if args.rapido else VELOCIDADES
|
||||||
|
ruidos = RUIDOS[:1] if args.rapido else RUIDOS
|
||||||
|
|
||||||
|
motor = carga_motor()
|
||||||
|
cat = motor.Catalogo([catalogo])
|
||||||
|
|
||||||
|
# Las acciones que esperan argumento hay que decirlas CON argumento. Sin el,
|
||||||
|
# el motor las rechaza a proposito —"busca el fichero" a secas no es una
|
||||||
|
# orden— y contarlo como fallo del reconocedor es medir mal: en la primera
|
||||||
|
# tanda hundio el resultado del 48 %, con una cuarta parte de las frases
|
||||||
|
# condenadas de antemano.
|
||||||
|
RELLENOS = ["notas", "el informe de ayer", "documentos"]
|
||||||
|
|
||||||
|
# Solo la frase principal de cada accion: las variantes ya estan escritas a
|
||||||
|
# mano y lo que se busca aqui es como se oye la que uno diria de verdad.
|
||||||
|
trabajos = []
|
||||||
|
for a in acciones:
|
||||||
|
if not a["frases"]:
|
||||||
|
continue
|
||||||
|
frase = a["frases"][0]
|
||||||
|
if a.get("captura"):
|
||||||
|
frase = frase + " " + random.choice(RELLENOS)
|
||||||
|
for voz in voces:
|
||||||
|
for vel in velocidades:
|
||||||
|
trabajos.append((a["id"], frase, voz, vel,
|
||||||
|
random.choice(ruidos)))
|
||||||
|
|
||||||
|
total = len(trabajos)
|
||||||
|
print(f"{len(acciones)} acciones · {len(voces)} voces · "
|
||||||
|
f"{len(velocidades)} velocidades = {total} frases habladas")
|
||||||
|
print("esto tarda; se puede dejar corriendo\n")
|
||||||
|
|
||||||
|
carpeta = os.path.join(CONFIG, "entrenamiento")
|
||||||
|
|
||||||
|
indice = {f"{n:05d}.wav": (ident, frase, voz, vel)
|
||||||
|
for n, (ident, frase, voz, vel, _) in enumerate(trabajos, 1)}
|
||||||
|
|
||||||
|
if args.reanudar and os.path.isdir(carpeta):
|
||||||
|
indice = {n: v for n, v in indice.items()
|
||||||
|
if os.path.exists(os.path.join(carpeta, n))}
|
||||||
|
print(f"reanudando con {len(indice)} frases ya generadas\n", flush=True)
|
||||||
|
else:
|
||||||
|
shutil.rmtree(carpeta, ignore_errors=True)
|
||||||
|
os.makedirs(carpeta, exist_ok=True)
|
||||||
|
vivos, ini = {}, time.time()
|
||||||
|
for n, (ident, frase, voz, vel, ruido) in enumerate(trabajos, 1):
|
||||||
|
nombre = f"{n:05d}.wav"
|
||||||
|
if genera(frase, voz, vel, ruido, os.path.join(carpeta, nombre)):
|
||||||
|
vivos[nombre] = (ident, frase, voz, vel)
|
||||||
|
if n % 25 == 0 or n == total:
|
||||||
|
queda = (time.time() - ini) / n * (total - n)
|
||||||
|
print(f" generadas {n}/{total} ({queda/60:.0f} min restantes)",
|
||||||
|
flush=True)
|
||||||
|
indice = vivos
|
||||||
|
|
||||||
|
print(f"\ntranscribiendo {len(indice)} frases...", flush=True)
|
||||||
|
textos = transcribe_lote(carpeta, sorted(indice))
|
||||||
|
print(f"transcritas {len(textos)}\n", flush=True)
|
||||||
|
|
||||||
|
aciertos = 0
|
||||||
|
candidatos = defaultdict(lambda: defaultdict(int))
|
||||||
|
for nombre, (ident, frase, voz, vel) in indice.items():
|
||||||
|
oido = textos.get(nombre, "").strip()
|
||||||
|
if not oido:
|
||||||
|
continue
|
||||||
|
encontrada, _ = cat.busca(oido)
|
||||||
|
if encontrada is not None and encontrada.id == ident:
|
||||||
|
aciertos += 1
|
||||||
|
else:
|
||||||
|
candidatos[ident][motor.normaliza(oido)] += 1
|
||||||
|
|
||||||
|
evaluadas = sum(1 for n in indice if textos.get(n))
|
||||||
|
print(f"aciertos: {aciertos}/{evaluadas} "
|
||||||
|
f"({100*aciertos/max(1,evaluadas):.0f} %)\n")
|
||||||
|
|
||||||
|
# Un alias solo vale si no puede ser otra cosa. Si la misma frase mal oida
|
||||||
|
# aparece en dos acciones, elegir una seria peor que no hacer nada.
|
||||||
|
duenos = defaultdict(set)
|
||||||
|
for ident, frases in candidatos.items():
|
||||||
|
for f in frases:
|
||||||
|
duenos[f].add(ident)
|
||||||
|
|
||||||
|
propuestos, descartados = defaultdict(list), []
|
||||||
|
for ident, frases in candidatos.items():
|
||||||
|
for f, veces in sorted(frases.items(), key=lambda x: -x[1]):
|
||||||
|
if len(duenos[f]) > 1:
|
||||||
|
descartados.append((f, sorted(duenos[f])))
|
||||||
|
continue
|
||||||
|
if len(f.split()) < 2: # una palabra suelta engancha de todo
|
||||||
|
continue
|
||||||
|
# Si lo oido no se parece a lo dicho, es que el reconocedor se
|
||||||
|
# perdio del todo, no que haya una confusion fonetica estable.
|
||||||
|
# Guardarlo como alias meteria una frase arbitraria en el catalogo
|
||||||
|
# y algun dia enganchara una conversacion.
|
||||||
|
original = next((a["frases"][0] for a in acciones if a["id"] == ident), "")
|
||||||
|
if motor.parecido(f, motor.normaliza(original)) < 0.55:
|
||||||
|
descartados.append((f, [ident + " (irreconocible)"]))
|
||||||
|
continue
|
||||||
|
if cat.busca(f)[0] is not None: # ya lo coge otra cosa
|
||||||
|
continue
|
||||||
|
propuestos[ident].append((f, veces))
|
||||||
|
|
||||||
|
if propuestos:
|
||||||
|
print("alias que faltan (frase mal oida -> accion):")
|
||||||
|
for ident, lista in sorted(propuestos.items()):
|
||||||
|
for f, veces in lista[:3]:
|
||||||
|
print(f' "{f}" -> {ident} ({veces}x)')
|
||||||
|
else:
|
||||||
|
print("no falta ningun alias: todo lo dicho llega a su accion")
|
||||||
|
|
||||||
|
if descartados:
|
||||||
|
print(f"\n{len(descartados)} descartados por ambiguos "
|
||||||
|
f"(la misma frase encaja en dos acciones):")
|
||||||
|
for f, ids in descartados[:5]:
|
||||||
|
print(f' "{f}" -> {", ".join(ids)}')
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(INFORME), exist_ok=True)
|
||||||
|
with open(INFORME, "w") as f:
|
||||||
|
json.dump({
|
||||||
|
"frases": evaluadas, "aciertos": aciertos,
|
||||||
|
"propuestos": {k: v for k, v in propuestos.items()},
|
||||||
|
"ambiguos": descartados,
|
||||||
|
}, f, indent=2, ensure_ascii=False)
|
||||||
|
print(f"\ninforme en {INFORME}")
|
||||||
|
|
||||||
|
if args.aplicar and propuestos:
|
||||||
|
guarda(propuestos, catalogo)
|
||||||
|
elif propuestos:
|
||||||
|
print("para guardarlos: vuelve a lanzarlo con --aplicar")
|
||||||
|
|
||||||
|
print(f"los wav se quedan en {carpeta} por si hay que repasar algo")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def guarda(propuestos, catalogo):
|
||||||
|
with open(catalogo) as f:
|
||||||
|
original = {a["id"]: a for a in json.load(f)["acciones"]}
|
||||||
|
try:
|
||||||
|
with open(PROPIO) as f:
|
||||||
|
propio = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
propio = {"version": 1, "acciones": []}
|
||||||
|
|
||||||
|
porid = {a["id"]: a for a in propio.get("acciones", [])}
|
||||||
|
n = 0
|
||||||
|
for ident, lista in propuestos.items():
|
||||||
|
entrada = porid.get(ident)
|
||||||
|
if entrada is None:
|
||||||
|
entrada = dict(original[ident])
|
||||||
|
porid[ident] = entrada
|
||||||
|
propio.setdefault("acciones", []).append(entrada)
|
||||||
|
for frase, _ in lista:
|
||||||
|
if frase not in entrada["frases"]:
|
||||||
|
entrada["frases"].append(frase)
|
||||||
|
n += 1
|
||||||
|
|
||||||
|
with open(PROPIO, "w") as f:
|
||||||
|
json.dump(propio, f, indent=2, ensure_ascii=False)
|
||||||
|
print(f"{n} alias guardados en {PROPIO}")
|
||||||
|
print("se aplican al abrir la pestaña JARVIS otra vez")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
156
config/indexar.py
Executable file
156
config/indexar.py
Executable file
|
|
@ -0,0 +1,156 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prepara el corpus de COFRE para que JARVIS lo consulte.
|
||||||
|
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/indexar.py # ver que entraria
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/indexar.py --hacerlo # copiarlo
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/indexar.py --hacerlo --solo-notas
|
||||||
|
|
||||||
|
Que hace: recorre las carpetas de COFRE, se queda SOLO con lo que es
|
||||||
|
conocimiento y lo copia a la carpeta de documentos de Newelle, que es de donde
|
||||||
|
lee el RAG. Despues hay que pulsar "Update Index" en los ajustes de la app.
|
||||||
|
|
||||||
|
Por que hace falta filtrar. Medido en esta maquina:
|
||||||
|
|
||||||
|
COFRE entero (las nueve carpetas) ~120 GB
|
||||||
|
de eso, ISOs, maquinas virtuales,
|
||||||
|
APKs, audio y un diccionario de
|
||||||
|
contraseñas de 9,1 GB ~100 GB
|
||||||
|
texto de menos de 100 KB 76,6 MB en 35 098 ficheros
|
||||||
|
quitando exploitdb 20,8 MB en 4 548 ficheros
|
||||||
|
|
||||||
|
exploitdb se queda fuera a proposito: son 32 000 ficheros que aplastarian el
|
||||||
|
indice y enterrarian tus notas, y ademas NO hacen falta ahi. Tienes
|
||||||
|
`searchsploit` instalado, que busca en ellos de forma exacta e instantanea. Un
|
||||||
|
indice de vectores es peor que una busqueda exacta cuando lo que buscas es el
|
||||||
|
nombre literal de un exploit.
|
||||||
|
|
||||||
|
Los nombres se aplanan conservando la ruta —PENTESTERS__OSCP__nmap.md— para que
|
||||||
|
al responder se pueda ver de donde salio cada cosa.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
COFRE = os.path.expanduser("~/COFRE")
|
||||||
|
DESTINO = os.path.expanduser(
|
||||||
|
"~/.var/app/io.github.qwersyk.Newelle/config/models/documents")
|
||||||
|
|
||||||
|
CARPETAS = ["CODERS", "PENTESTERS", "AUTOMATAS", "DOSER", "HARD-WARERS",
|
||||||
|
"PHISHERS", "PROTECTORS", "REVERSERS", "SNEAKERS"]
|
||||||
|
|
||||||
|
EXTENSIONES = {".md", ".txt", ".rst", ".adoc"}
|
||||||
|
|
||||||
|
# Por tamaño: por encima de esto ya no es prosa, son datos. El diccionario
|
||||||
|
# kaonashi.txt son 9,1 GB de contraseñas.
|
||||||
|
TAMANO_MAXIMO = 100 * 1024
|
||||||
|
TAMANO_MINIMO = 120 # menos de esto no dice nada
|
||||||
|
|
||||||
|
# Carpetas que no aportan conocimiento y si mucho ruido
|
||||||
|
FUERA = {
|
||||||
|
"exploitdb", # 32 000 ficheros; para eso esta searchsploit
|
||||||
|
"node_modules", ".git", "venv", ".venv", "site-packages", "__pycache__",
|
||||||
|
"isos", "VirtualBox VMs", "build", "dist", ".cache", "target",
|
||||||
|
"flatpak-app", ".flatpak-builder", "rag_cache", "entrenamiento",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ficheros que son plantilla o licencia y solo ensucian
|
||||||
|
NOMBRES_FUERA = {"LICENSE.txt", "LICENSE.md", "CHANGELOG.md", "requirements.txt"}
|
||||||
|
|
||||||
|
|
||||||
|
def interesa(ruta: str, nombre: str) -> bool:
|
||||||
|
ext = os.path.splitext(nombre)[1].lower()
|
||||||
|
if ext not in EXTENSIONES or nombre in NOMBRES_FUERA:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
tam = os.path.getsize(ruta)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return TAMANO_MINIMO <= tam <= TAMANO_MAXIMO
|
||||||
|
|
||||||
|
|
||||||
|
def recorre(solo_notas: bool):
|
||||||
|
"""Devuelve [(origen, nombre_plano)] de todo lo que merece indexarse."""
|
||||||
|
salida = []
|
||||||
|
for carpeta in CARPETAS:
|
||||||
|
raiz = os.path.join(COFRE, carpeta)
|
||||||
|
if not os.path.isdir(raiz):
|
||||||
|
continue
|
||||||
|
for dirpath, dirnames, ficheros in os.walk(raiz):
|
||||||
|
dirnames[:] = [d for d in dirnames
|
||||||
|
if d not in FUERA and not d.startswith(".")]
|
||||||
|
for nombre in ficheros:
|
||||||
|
if solo_notas and not nombre.lower().endswith(".md"):
|
||||||
|
continue
|
||||||
|
origen = os.path.join(dirpath, nombre)
|
||||||
|
if not interesa(origen, nombre):
|
||||||
|
continue
|
||||||
|
relativa = os.path.relpath(origen, COFRE)
|
||||||
|
plano = relativa.replace(os.sep, "__")
|
||||||
|
if len(plano) > 180: # algunos nombres son kilometricos
|
||||||
|
corto = hashlib.sha256(plano.encode()).hexdigest()[:8]
|
||||||
|
plano = plano[:150] + "_" + corto + os.path.splitext(plano)[1]
|
||||||
|
salida.append((origen, plano))
|
||||||
|
return salida
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--hacerlo", action="store_true", help="copiar de verdad")
|
||||||
|
ap.add_argument("--solo-notas", action="store_true",
|
||||||
|
help="solo .md, sin los .txt sueltos")
|
||||||
|
ap.add_argument("--limpiar", action="store_true",
|
||||||
|
help="vaciar el destino antes de copiar")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
print("recorriendo COFRE...", flush=True)
|
||||||
|
lista = recorre(args.solo_notas)
|
||||||
|
|
||||||
|
porcarpeta, total = {}, 0
|
||||||
|
for origen, _ in lista:
|
||||||
|
carpeta = os.path.relpath(origen, COFRE).split(os.sep)[0]
|
||||||
|
tam = os.path.getsize(origen)
|
||||||
|
n, b = porcarpeta.get(carpeta, (0, 0))
|
||||||
|
porcarpeta[carpeta] = (n + 1, b + tam)
|
||||||
|
total += tam
|
||||||
|
|
||||||
|
print(f"\n{'carpeta':<14}{'ficheros':>10}{'texto':>12}")
|
||||||
|
for carpeta in CARPETAS:
|
||||||
|
if carpeta in porcarpeta:
|
||||||
|
n, b = porcarpeta[carpeta]
|
||||||
|
print(f"{carpeta:<14}{n:>10}{b/1048576:>10.1f} MB")
|
||||||
|
print(f"{'TOTAL':<14}{len(lista):>10}{total/1048576:>10.1f} MB")
|
||||||
|
print(f"\nfragmentos de 256 tokens: ~{total // 1024}")
|
||||||
|
|
||||||
|
if not args.hacerlo:
|
||||||
|
print("\nesto es solo el recuento; para copiarlo: --hacerlo")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.limpiar and os.path.isdir(DESTINO):
|
||||||
|
shutil.rmtree(DESTINO)
|
||||||
|
os.makedirs(DESTINO, exist_ok=True)
|
||||||
|
|
||||||
|
copiados = saltados = 0
|
||||||
|
for origen, plano in lista:
|
||||||
|
destino = os.path.join(DESTINO, plano)
|
||||||
|
try:
|
||||||
|
# solo si cambio: reindexar 4500 ficheros iguales no tiene sentido
|
||||||
|
if (os.path.exists(destino)
|
||||||
|
and os.path.getmtime(destino) >= os.path.getmtime(origen)):
|
||||||
|
saltados += 1
|
||||||
|
continue
|
||||||
|
shutil.copy2(origen, destino)
|
||||||
|
copiados += 1
|
||||||
|
except OSError as e:
|
||||||
|
print(f" no se pudo copiar {plano}: {e}")
|
||||||
|
|
||||||
|
print(f"\n{copiados} copiados, {saltados} ya estaban al dia")
|
||||||
|
print(f"en {DESTINO}")
|
||||||
|
print("\nAhora, en Newelle: Ajustes -> RAG -> Update Index")
|
||||||
|
print("La primera vez tarda; son unos 21 000 fragmentos.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
4197
config/jarvis-herramientas.json
Normal file
4197
config/jarvis-herramientas.json
Normal file
File diff suppressed because it is too large
Load diff
58
config/jarvis-piper.sh
Executable file
58
config/jarvis-piper.sh
Executable file
|
|
@ -0,0 +1,58 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Voz de JARVIS con Piper: castellano de España, no latinoamericano.
|
||||||
|
#
|
||||||
|
# jarvis-piper.sh <fichero.wav> <texto>
|
||||||
|
#
|
||||||
|
# Kokoro, que es lo que trae Newelle, no tiene ni una voz de España: sus dos
|
||||||
|
# masculinas en español (em_alex, em_santa) suenan latinas. Piper si las tiene,
|
||||||
|
# es libre y corre local. Se invoca desde el handler custom_command, que ya
|
||||||
|
# ejecuta en el host.
|
||||||
|
#
|
||||||
|
# El descenso de tono se hace aqui y no en la cadena de la app porque alli se
|
||||||
|
# usa asetrate, que necesita saber la frecuencia de muestreo: esta calculada
|
||||||
|
# para los 24 kHz de Kokoro y Piper saca 22050, asi que aplicaria una bajada
|
||||||
|
# distinta de la pedida. Aqui se usa rubberband, que trabaja en semitonos y le
|
||||||
|
# da igual la frecuencia. Existe en el ffmpeg del sistema pero no en el del
|
||||||
|
# flatpak, y este script ya corre fuera.
|
||||||
|
#
|
||||||
|
# Ajustes por entorno:
|
||||||
|
# JARVIS_PIPER_VOZ es_ES-sharvard-medium (defecto) | es_ES-davefx-medium
|
||||||
|
# | es_ES-carlfm-x_low
|
||||||
|
# JARVIS_PIPER_TONO 0.90 (defecto). 1.0 no toca el tono; por debajo de
|
||||||
|
# 0.85 empieza a sonar artificial.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# La raiz se deduce de donde esta este fichero, no del $HOME de nadie: asi el
|
||||||
|
# arbol se puede mover o clonar en otra maquina y sigue funcionando sin editar
|
||||||
|
# rutas. `cd -P` resuelve enlaces simbolicos, que el lanzador de escritorio
|
||||||
|
# puede invocarlo por uno.
|
||||||
|
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
|
||||||
|
BASE=$RAIZ/voz
|
||||||
|
VOZ=${JARVIS_PIPER_VOZ:-es_ES-sharvard-medium}
|
||||||
|
TONO=${JARVIS_PIPER_TONO:-0.90}
|
||||||
|
|
||||||
|
SALIDA=${1:-}
|
||||||
|
TEXTO=${2:-}
|
||||||
|
|
||||||
|
[ -n "$SALIDA" ] || { echo "falta el fichero de salida" >&2; exit 1; }
|
||||||
|
[ -n "$TEXTO" ] || { echo "sin texto que decir" >&2; exit 1; }
|
||||||
|
|
||||||
|
MODELO=$BASE/modelos/$VOZ.onnx
|
||||||
|
[ -f "$MODELO" ] || { echo "no esta el modelo $MODELO" >&2; exit 1; }
|
||||||
|
|
||||||
|
CRUDO=$(mktemp --suffix=.wav)
|
||||||
|
trap 'rm -f "$CRUDO"' EXIT
|
||||||
|
|
||||||
|
printf '%s' "$TEXTO" | "$BASE/piper/piper" \
|
||||||
|
--model "$MODELO" --output_file "$CRUDO" 2>/dev/null
|
||||||
|
|
||||||
|
[ -s "$CRUDO" ] || { echo "piper no genero audio" >&2; exit 1; }
|
||||||
|
|
||||||
|
if [ "$TONO" = "1.0" ] || ! command -v ffmpeg >/dev/null; then
|
||||||
|
cp "$CRUDO" "$SALIDA"
|
||||||
|
else
|
||||||
|
ffmpeg -hide_banner -loglevel error -i "$CRUDO" \
|
||||||
|
-af "rubberband=pitch=$TONO" -y "$SALIDA" 2>/dev/null \
|
||||||
|
|| cp "$CRUDO" "$SALIDA"
|
||||||
|
fi
|
||||||
46
config/jarvis-voz.sh
Executable file
46
config/jarvis-voz.sh
Executable file
|
|
@ -0,0 +1,46 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Voz de JARVIS con la clonada (XTTS-v2) servida desde cache.
|
||||||
|
#
|
||||||
|
# jarvis-voz.sh <fichero.wav> <texto>
|
||||||
|
#
|
||||||
|
# Si el texto esta pre-generado en cache_voz/, lo sirve al instante (un cp, sin
|
||||||
|
# tocar GPU). Si no, cae a Piper. La clave es sha1(texto normalizado), la misma
|
||||||
|
# que calcula generar_cache_voz.py -> los aciertos son exactos.
|
||||||
|
#
|
||||||
|
# El clon ya trae el tono correcto, asi que en un acierto NO se aplica el
|
||||||
|
# descenso de tono de Piper; solo se copia. En un fallo, Piper hace lo suyo
|
||||||
|
# (incluido su rubberband) como hasta ahora.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# La raiz se deduce de donde esta este fichero, no del $HOME de nadie: asi el
|
||||||
|
# arbol se puede mover o clonar en otra maquina y sigue funcionando sin editar
|
||||||
|
# rutas. `cd -P` resuelve enlaces simbolicos, que el lanzador de escritorio
|
||||||
|
# puede invocarlo por uno.
|
||||||
|
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
|
||||||
|
BASE=$RAIZ/voz
|
||||||
|
CACHE=$BASE/cache_voz
|
||||||
|
PIPER=$RAIZ/config/jarvis-piper.sh
|
||||||
|
|
||||||
|
SALIDA=${1:-}
|
||||||
|
TEXTO=${2:-}
|
||||||
|
|
||||||
|
[ -n "$SALIDA" ] || { echo "falta el fichero de salida" >&2; exit 1; }
|
||||||
|
[ -n "$TEXTO" ] || { echo "sin texto que decir" >&2; exit 1; }
|
||||||
|
|
||||||
|
# clave = sha1(texto con espacios colapsados y recortado), identica a la de
|
||||||
|
# Python: re.sub(r'\s+',' ',t).strip() -> sha1(utf-8)
|
||||||
|
T=$(printf '%s' "$TEXTO" | tr -s '[:space:]' ' ' | sed 's/^ //; s/ $//')
|
||||||
|
KEY=$(printf '%s' "$T" | sha1sum | cut -d' ' -f1)
|
||||||
|
HIT=$CACHE/$KEY.wav
|
||||||
|
|
||||||
|
if [ -f "$HIT" ]; then
|
||||||
|
cp -f "$HIT" "$SALIDA"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# fallo de cache -> registrar la frase (normalizada) para calentar la cache
|
||||||
|
# luego con la voz clonada, y de momento reescribir siglas + Piper.
|
||||||
|
printf '%s\n' "$T" >> "$BASE/misses.log" 2>/dev/null || true
|
||||||
|
TEXTO2=$(python3 "$BASE/pronuncia.py" "$TEXTO" 2>/dev/null)
|
||||||
|
[ -n "$TEXTO2" ] || TEXTO2=$TEXTO
|
||||||
|
exec "$PIPER" "$SALIDA" "$TEXTO2"
|
||||||
484
config/mejora_nocturna.py
Executable file
484
config/mejora_nocturna.py
Executable file
|
|
@ -0,0 +1,484 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Bucle de mejora que corre solo, ronda tras ronda, y se protege de empeorar.
|
||||||
|
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py # hasta la mañana
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py --horas 2 # un rato
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py --ronda # una sola
|
||||||
|
|
||||||
|
Que hace en cada ronda:
|
||||||
|
1. Dice en voz alta (Piper) las frases del catalogo, en varias voces,
|
||||||
|
velocidades y con ruido y sala aleatorios, distintos cada ronda.
|
||||||
|
2. Las transcribe con el mismo Whisper del asistente.
|
||||||
|
3. Mira que porcentaje llega a su accion. Ese es el marcador de la ronda.
|
||||||
|
4. De lo que NO llega, se queda con los fallos SISTEMATICOS (repetidos) y
|
||||||
|
SIN AMBIGUEDAD (que no puedan ser dos acciones), y los propone como alias.
|
||||||
|
5. GUARDA: antes de aplicar nada, comprueba en una copia que
|
||||||
|
a) el acierto sube, y
|
||||||
|
b) la conversacion normal SIGUE yendo al modelo (no se cuela ninguna).
|
||||||
|
Solo si las dos cosas se cumplen, aplica los alias de verdad.
|
||||||
|
6. Copia de seguridad del catalogo antes de tocarlo, y todo al registro.
|
||||||
|
|
||||||
|
Por que es seguro dejarlo sin mirar: nunca aplica algo que baje el acierto ni
|
||||||
|
que haga que una frase de charla acabe ejecutando un comando. Si una ronda
|
||||||
|
empeorara, revierte. Y cada cambio queda respaldado, asi que siempre se puede
|
||||||
|
volver atras a mano.
|
||||||
|
|
||||||
|
Que NO hace: reentrenar el modelo (no cabe en 4 GB de VRAM) ni tocar Whisper o
|
||||||
|
Piper. Mejora la capa de emparejamiento —alias y umbrales—, que es donde estan
|
||||||
|
casi todos los fallos y donde una mejora es inmediata, legible y reversible.
|
||||||
|
|
||||||
|
En marcha se puede seguir con:
|
||||||
|
tail -f ~/COFRE/CODERS/JARVIS/muestras/mejora/registro.log
|
||||||
|
cat ~/COFRE/CODERS/JARVIS/muestras/mejora/marcador.json
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
CASA = os.path.expanduser("~")
|
||||||
|
JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS")
|
||||||
|
CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config")
|
||||||
|
APP = "io.github.qwersyk.Newelle//master"
|
||||||
|
|
||||||
|
PIPER = os.path.join(JARVIS, "voz/piper/piper")
|
||||||
|
MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos")
|
||||||
|
WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli")
|
||||||
|
MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models")
|
||||||
|
|
||||||
|
CATALOGO = os.path.expanduser(
|
||||||
|
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
|
||||||
|
"active/files/share/newelle/acciones/acciones.json")
|
||||||
|
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
|
||||||
|
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
|
||||||
|
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
|
||||||
|
|
||||||
|
SALA = os.path.join(JARVIS, "muestras/mejora")
|
||||||
|
REGISTRO = os.path.join(SALA, "registro.log")
|
||||||
|
MARCADOR = os.path.join(SALA, "marcador.json")
|
||||||
|
COPIAS = os.path.join(SALA, "copias")
|
||||||
|
WAVS = os.path.join(CONFIG, "mejora_wavs")
|
||||||
|
|
||||||
|
VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
|
||||||
|
# Cada ronda escoge una rejilla distinta de velocidades y ruidos, para que no
|
||||||
|
# se estanque midiendo siempre lo mismo y siga sacando fallos raros.
|
||||||
|
VELOCIDADES = [0.85, 0.92, 1.0, 1.08, 1.18]
|
||||||
|
RUIDOS = [
|
||||||
|
"",
|
||||||
|
"aecho=0.8:0.7:22:0.12",
|
||||||
|
"volume=0.72,highpass=f=120",
|
||||||
|
"aecho=0.6:0.5:35:0.2,volume=0.8",
|
||||||
|
"highpass=f=150,lowpass=f=6500", # microfono cutre
|
||||||
|
"volume=0.65", # lejos
|
||||||
|
]
|
||||||
|
RELLENOS = ["notas", "el informe", "documentos", "la reunion de ayer", "gatos"]
|
||||||
|
|
||||||
|
# Frases de charla que NUNCA deben acabar en una accion. Es el guardarrail: si
|
||||||
|
# despues de aplicar alias alguna se cuela, la ronda se revierte.
|
||||||
|
CHARLA = [
|
||||||
|
"cuentame un chiste sobre gatos", "que opinas del proyecto",
|
||||||
|
"hola que tal estas", "gracias por todo", "no se que hacer con esto",
|
||||||
|
"me parece que esto no funciona bien", "buenas tardes que tal",
|
||||||
|
"cuentame algo interesante", "estoy cansado de programar",
|
||||||
|
"que raro todo esto la verdad", "a ver si esto va mejor ahora",
|
||||||
|
"pues no tengo ni idea de que decir", "vale perfecto muchas gracias",
|
||||||
|
"oye una cosa que te queria preguntar",
|
||||||
|
]
|
||||||
|
|
||||||
|
TOPE_ALIAS_POR_ACCION = 25 # que una accion no acumule cientos de alias
|
||||||
|
UMBRAL_SISTEMATICO = 2 # veces que hay que fallar igual para creerselo
|
||||||
|
DRY_PARA_PARAR = 6 # rondas seguidas sin nada nuevo -> converge
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
linea = f"[{time.strftime('%H:%M:%S')}] {msg}"
|
||||||
|
print(linea, flush=True)
|
||||||
|
try:
|
||||||
|
with open(REGISTRO, "a") as f:
|
||||||
|
f.write(linea + "\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def disco_libre_gb() -> float:
|
||||||
|
"""GB libres en la particion del home. Guardarrail: aunque el bucle apenas
|
||||||
|
ocupa 3 MB por ronda y los limpia, si algo mas llenara el disco durante la
|
||||||
|
noche conviene parar en vez de dejar el sistema sin espacio."""
|
||||||
|
try:
|
||||||
|
s = os.statvfs(CASA)
|
||||||
|
return s.f_bavail * s.f_frsize / 1e9
|
||||||
|
except OSError:
|
||||||
|
return 999.0
|
||||||
|
|
||||||
|
|
||||||
|
def carga_motor():
|
||||||
|
# se recarga cada ronda: el catalogo propio cambia y el motor debe verlo
|
||||||
|
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def dentro(orden, limite=1200):
|
||||||
|
return subprocess.run(
|
||||||
|
["flatpak", "run", "--command=bash", APP, "-c", orden],
|
||||||
|
capture_output=True, text=True, timeout=limite)
|
||||||
|
|
||||||
|
|
||||||
|
def genera(texto, voz, velocidad, ruido, destino):
|
||||||
|
crudo = tempfile.mktemp(suffix=".wav")
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
|
||||||
|
"--length_scale", str(1 / velocidad), "--output_file", crudo],
|
||||||
|
input=texto, capture_output=True, text=True, timeout=60)
|
||||||
|
if r.returncode != 0 or not os.path.exists(crudo):
|
||||||
|
return False
|
||||||
|
filtros = "aresample=16000" + ("," + ruido if ruido else "")
|
||||||
|
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
|
"-i", crudo, "-ac", "1", "-af", filtros, "-y", destino],
|
||||||
|
check=True, timeout=60)
|
||||||
|
return os.path.exists(destino)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
if os.path.exists(crudo):
|
||||||
|
os.remove(crudo)
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe(carpeta, ficheros, hilos=8, por_tanda=40):
|
||||||
|
salida = {}
|
||||||
|
for i in range(0, len(ficheros), por_tanda):
|
||||||
|
tanda = ficheros[i:i + por_tanda]
|
||||||
|
lista = " ".join(f'"{carpeta}/{n}"' for n in tanda)
|
||||||
|
orden = (f'for f in {lista}; do echo "### $(basename "$f")"; '
|
||||||
|
f'{WHISPER} -m {MODELOS_STT}/ggml-base.bin -l es -nt -t {hilos} '
|
||||||
|
f'-f "$f" 2>/dev/null | tr "\\n" " "; echo; done')
|
||||||
|
try:
|
||||||
|
r = dentro(orden)
|
||||||
|
except subprocess.SubprocessError:
|
||||||
|
continue
|
||||||
|
actual = None
|
||||||
|
for linea in r.stdout.splitlines():
|
||||||
|
if linea.startswith("### "):
|
||||||
|
actual = linea[4:].strip()
|
||||||
|
elif actual:
|
||||||
|
salida[actual] = linea.strip()
|
||||||
|
actual = None
|
||||||
|
return salida
|
||||||
|
|
||||||
|
|
||||||
|
def catalogo_base():
|
||||||
|
ruta = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE
|
||||||
|
with open(ruta) as f:
|
||||||
|
return json.load(f)["acciones"], ruta
|
||||||
|
|
||||||
|
|
||||||
|
def evalua(motor, textos, indice):
|
||||||
|
"""(aciertos, total, candidatos{id: {frase_mal: veces}})."""
|
||||||
|
aciertos = 0
|
||||||
|
candidatos = defaultdict(lambda: defaultdict(int))
|
||||||
|
for nombre, (ident, frase) in indice.items():
|
||||||
|
oido = textos.get(nombre, "").strip()
|
||||||
|
if not oido:
|
||||||
|
continue
|
||||||
|
a, _ = _CAT.busca(oido)
|
||||||
|
if a is not None and a.id == ident:
|
||||||
|
aciertos += 1
|
||||||
|
else:
|
||||||
|
candidatos[ident][motor.normaliza(oido)] += 1
|
||||||
|
return aciertos, len([1 for n in indice if textos.get(n)]), candidatos
|
||||||
|
|
||||||
|
|
||||||
|
_CAT = None
|
||||||
|
|
||||||
|
|
||||||
|
# Palabras de funcion: aparecen en cualquier frase y no distinguen una orden de
|
||||||
|
# una conversacion. Un alias hecho solo de estas ("que me tengo") es una trampa.
|
||||||
|
VACIAS = {
|
||||||
|
"que", "de", "la", "el", "los", "las", "un", "una", "me", "te", "se", "lo",
|
||||||
|
"mi", "tu", "su", "es", "esta", "estan", "hay", "por", "para", "con", "en",
|
||||||
|
"y", "o", "a", "al", "del", "como", "cuanto", "cuanta", "muy", "ya", "he",
|
||||||
|
"ha", "tengo", "tiene", "esto", "eso", "aqui", "ahi", "va", "van", "asi",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def contenido(frase: str) -> set:
|
||||||
|
"""Las palabras con significado: las que de verdad identifican la orden."""
|
||||||
|
return {p for p in frase.split() if len(p) > 3 and p not in VACIAS}
|
||||||
|
|
||||||
|
|
||||||
|
def filtra_candidatos(motor, candidatos, acciones):
|
||||||
|
"""Solo los alias que valen: sistematicos, sin ambiguedad, y que conservan
|
||||||
|
una palabra distintiva de la orden original.
|
||||||
|
|
||||||
|
El guardarrail clave es el ultimo: una confusion del reconocedor que aun
|
||||||
|
sirve de alias mantiene al menos una palabra con significado ("kernel",
|
||||||
|
"wikipedia", "bateria"). Si las perdio todas y solo quedan palabras de
|
||||||
|
relleno, es indistinguible de una frase de charla y se descarta, por mucho
|
||||||
|
que se haya repetido. Es lo que separa "que kernel tengo" -> "que kernel me
|
||||||
|
tengo" (vale) de "que me tengo" (no vale).
|
||||||
|
"""
|
||||||
|
porfrase = defaultdict(set)
|
||||||
|
for ident, frases in candidatos.items():
|
||||||
|
for f in frases:
|
||||||
|
porfrase[f].add(ident)
|
||||||
|
|
||||||
|
porid = {a["id"]: a for a in acciones}
|
||||||
|
|
||||||
|
propuestos = defaultdict(list)
|
||||||
|
for ident, frases in candidatos.items():
|
||||||
|
accion = porid.get(ident)
|
||||||
|
if accion is None:
|
||||||
|
continue
|
||||||
|
# Las de argumento no generan alias: el relleno de prueba ("notas") se
|
||||||
|
# cuela en la transcripcion y quedaria horneado en el alias. Su
|
||||||
|
# emparejamiento ya es por parecido del arranque, que las cubre.
|
||||||
|
if accion.get("captura"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
orig = motor.normaliza(accion["frases"][0] if accion["frases"] else "")
|
||||||
|
claves = contenido(orig)
|
||||||
|
|
||||||
|
for f, veces in frases.items():
|
||||||
|
if veces < UMBRAL_SISTEMATICO:
|
||||||
|
continue
|
||||||
|
if len(porfrase[f]) > 1: # la misma frase, dos acciones
|
||||||
|
continue
|
||||||
|
if len(f.split()) < 2: # una palabra engancha de todo
|
||||||
|
continue
|
||||||
|
if _CAT.busca(f)[0] is not None: # ya lo coge algo
|
||||||
|
continue
|
||||||
|
if motor.parecido(f, orig) < 0.6: # se perdio del todo: ruido
|
||||||
|
continue
|
||||||
|
# conserva una palabra distintiva? exacta o casi (el reconocedor
|
||||||
|
# cambia una letra: "wikipedia" -> "wikipeda")
|
||||||
|
palabras_f = set(f.split())
|
||||||
|
if claves and not any(
|
||||||
|
c in palabras_f or any(motor.parecido(c, w) > 0.8 for w in palabras_f)
|
||||||
|
for c in claves):
|
||||||
|
continue
|
||||||
|
propuestos[ident].append((f, veces))
|
||||||
|
return propuestos
|
||||||
|
|
||||||
|
|
||||||
|
def aplica(propuestos, acciones_orig):
|
||||||
|
"""Escribe los alias en el catalogo propio. Devuelve cuantos."""
|
||||||
|
try:
|
||||||
|
with open(PROPIO) as f:
|
||||||
|
propio = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
propio = {"version": 1, "acciones": []}
|
||||||
|
porid = {a["id"]: a for a in propio.get("acciones", [])}
|
||||||
|
original = {a["id"]: a for a in acciones_orig}
|
||||||
|
|
||||||
|
n = 0
|
||||||
|
for ident, lista in propuestos.items():
|
||||||
|
entrada = porid.get(ident)
|
||||||
|
if entrada is None:
|
||||||
|
entrada = dict(original[ident])
|
||||||
|
porid[ident] = entrada
|
||||||
|
propio.setdefault("acciones", []).append(entrada)
|
||||||
|
if len(entrada["frases"]) >= TOPE_ALIAS_POR_ACCION:
|
||||||
|
continue
|
||||||
|
for f, _ in lista:
|
||||||
|
if f not in entrada["frases"]:
|
||||||
|
entrada["frases"].append(f)
|
||||||
|
n += 1
|
||||||
|
with open(PROPIO, "w") as f:
|
||||||
|
json.dump(propio, f, indent=2, ensure_ascii=False)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def charla_se_cuela(motor):
|
||||||
|
"""True si alguna frase de charla acaba en una accion. El guardarrail."""
|
||||||
|
for frase in CHARLA:
|
||||||
|
a, _ = _CAT.busca(frase)
|
||||||
|
if a is not None:
|
||||||
|
return frase, a.id
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def marcador_guarda(datos):
|
||||||
|
try:
|
||||||
|
with open(MARCADOR, "w") as f:
|
||||||
|
json.dump(datos, f, indent=2, ensure_ascii=False)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def copia_catalogo(ronda):
|
||||||
|
if not os.path.exists(PROPIO):
|
||||||
|
return
|
||||||
|
os.makedirs(COPIAS, exist_ok=True)
|
||||||
|
shutil.copy2(PROPIO, os.path.join(COPIAS, f"ronda_{ronda:03d}.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def una_ronda(motor, ronda, muestras_por_acap):
|
||||||
|
global _CAT
|
||||||
|
acciones, _ = catalogo_base()
|
||||||
|
|
||||||
|
# muestras de esta ronda: condiciones aleatorias, distintas cada vez
|
||||||
|
trabajos = []
|
||||||
|
for a in acciones:
|
||||||
|
if not a["frases"]:
|
||||||
|
continue
|
||||||
|
frase = a["frases"][0]
|
||||||
|
if a.get("captura"):
|
||||||
|
frase += " " + random.choice(RELLENOS)
|
||||||
|
for _ in range(muestras_por_acap):
|
||||||
|
trabajos.append((a["id"], frase, random.choice(VOCES),
|
||||||
|
random.choice(VELOCIDADES), random.choice(RUIDOS)))
|
||||||
|
random.shuffle(trabajos)
|
||||||
|
|
||||||
|
shutil.rmtree(WAVS, ignore_errors=True)
|
||||||
|
os.makedirs(WAVS, exist_ok=True)
|
||||||
|
indice = {}
|
||||||
|
for n, (ident, frase, voz, vel, ruido) in enumerate(trabajos, 1):
|
||||||
|
nombre = f"{n:05d}.wav"
|
||||||
|
if genera(frase, voz, vel, ruido, os.path.join(WAVS, nombre)):
|
||||||
|
indice[nombre] = (ident, frase)
|
||||||
|
|
||||||
|
log(f"ronda {ronda}: {len(indice)} frases dichas, transcribiendo...")
|
||||||
|
textos = transcribe(WAVS, sorted(indice))
|
||||||
|
|
||||||
|
# medir ANTES de tocar nada
|
||||||
|
_CAT = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
|
||||||
|
or [CATALOGO_FUENTE])
|
||||||
|
aciertos, total, candidatos = evalua(motor, textos, indice)
|
||||||
|
antes = 100 * aciertos / max(1, total)
|
||||||
|
log(f"ronda {ronda}: acierto {antes:.1f} % ({aciertos}/{total})")
|
||||||
|
|
||||||
|
propuestos = filtra_candidatos(motor, candidatos, acciones)
|
||||||
|
nuevos = sum(len(v) for v in propuestos.values())
|
||||||
|
if not nuevos:
|
||||||
|
log(f"ronda {ronda}: sin alias nuevos que valgan")
|
||||||
|
return antes, 0, False
|
||||||
|
|
||||||
|
# aplicar en el catalogo propio, con copia de seguridad antes
|
||||||
|
copia_catalogo(ronda)
|
||||||
|
n = aplica(propuestos, acciones)
|
||||||
|
|
||||||
|
# revalidar: recargar el catalogo con los alias puestos
|
||||||
|
_CAT = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
|
||||||
|
or [CATALOGO_FUENTE])
|
||||||
|
|
||||||
|
# GUARDARRAIL 1: la charla no se cuela
|
||||||
|
cuela = charla_se_cuela(motor)
|
||||||
|
if cuela:
|
||||||
|
frase, ident = cuela
|
||||||
|
log(f"ronda {ronda}: REVERTIDA — '{frase}' se colaria en {ident}")
|
||||||
|
shutil.copy2(os.path.join(COPIAS, f"ronda_{ronda:03d}.json"), PROPIO)
|
||||||
|
return antes, 0, False
|
||||||
|
|
||||||
|
# GUARDARRAIL 2: el acierto sube en las mismas muestras
|
||||||
|
aciertos2, _, _ = evalua(motor, textos, indice)
|
||||||
|
despues = 100 * aciertos2 / max(1, total)
|
||||||
|
if despues < antes - 0.5:
|
||||||
|
log(f"ronda {ronda}: REVERTIDA — el acierto bajaria a {despues:.1f} %")
|
||||||
|
shutil.copy2(os.path.join(COPIAS, f"ronda_{ronda:03d}.json"), PROPIO)
|
||||||
|
return antes, 0, False
|
||||||
|
|
||||||
|
log(f"ronda {ronda}: +{n} alias · acierto {antes:.1f} -> {despues:.1f} %")
|
||||||
|
for ident, lista in sorted(propuestos.items()):
|
||||||
|
for f, v in lista[:2]:
|
||||||
|
log(f" {ident} <- \"{f}\" ({v}x)")
|
||||||
|
return despues, n, True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--horas", type=float, default=0, help="parar tras N horas")
|
||||||
|
ap.add_argument("--ronda", action="store_true", help="una sola ronda")
|
||||||
|
ap.add_argument("--muestras", type=int, default=3,
|
||||||
|
help="muestras por accion y ronda")
|
||||||
|
ap.add_argument("--dry", type=int, default=DRY_PARA_PARAR,
|
||||||
|
help="rondas secas seguidas antes de parar")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
for ruta, que in ((PIPER, "piper"), (WHISPER, "whisper")):
|
||||||
|
if not os.path.exists(ruta):
|
||||||
|
print(f"falta {que}: {ruta}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
os.makedirs(SALA, exist_ok=True)
|
||||||
|
limite = time.time() + args.horas * 3600 if args.horas else None
|
||||||
|
motor = carga_motor()
|
||||||
|
|
||||||
|
log("=" * 56)
|
||||||
|
log(f"arranca la mejora nocturna · {args.muestras} muestras/accion/ronda")
|
||||||
|
if limite:
|
||||||
|
log(f"parara en {args.horas} h")
|
||||||
|
else:
|
||||||
|
log("corre hasta converger o hasta que la pares")
|
||||||
|
|
||||||
|
historial = []
|
||||||
|
dry = 0
|
||||||
|
ronda = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
ronda += 1
|
||||||
|
libre = disco_libre_gb()
|
||||||
|
if libre < 10:
|
||||||
|
log(f'PARADA: solo quedan {libre:.1f} GB de disco. Fin por seguridad.')
|
||||||
|
break
|
||||||
|
ini = time.time()
|
||||||
|
try:
|
||||||
|
score, nuevos, aplicado = una_ronda(motor, ronda, args.muestras)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"ronda {ronda}: error, se salta — {type(e).__name__}: {e}")
|
||||||
|
time.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
historial.append({"ronda": ronda, "acierto": round(score, 1),
|
||||||
|
"alias_nuevos": nuevos,
|
||||||
|
"minutos": round((time.time() - ini) / 60, 1)})
|
||||||
|
marcador_guarda({
|
||||||
|
"actualizado": time.strftime("%Y-%m-%d %H:%M"),
|
||||||
|
"rondas": ronda, "ultimo_acierto": round(score, 1),
|
||||||
|
"alias_totales": alias_totales(),
|
||||||
|
"historial": historial[-40:],
|
||||||
|
})
|
||||||
|
|
||||||
|
dry = dry + 1 if nuevos == 0 else 0
|
||||||
|
if args.ronda:
|
||||||
|
break
|
||||||
|
if dry >= args.dry:
|
||||||
|
log(f"converge: {DRY_PARA_PARAR} rondas sin nada nuevo. Fin.")
|
||||||
|
break
|
||||||
|
if limite and time.time() > limite:
|
||||||
|
log("se acabo el tiempo pedido. Fin.")
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log("parada a mano")
|
||||||
|
|
||||||
|
shutil.rmtree(WAVS, ignore_errors=True)
|
||||||
|
log(f"terminado tras {ronda} rondas · acierto final "
|
||||||
|
f"{historial[-1]['acierto'] if historial else '?'} % · "
|
||||||
|
f"{alias_totales()} alias en el catalogo propio")
|
||||||
|
log("los cambios estan en jarvis-acciones.json; se aplican al abrir JARVIS")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def alias_totales():
|
||||||
|
try:
|
||||||
|
with open(PROPIO) as f:
|
||||||
|
d = json.load(f)
|
||||||
|
base = {a["id"]: len(a["frases"]) for a in
|
||||||
|
json.load(open(CATALOGO if os.path.exists(CATALOGO)
|
||||||
|
else CATALOGO_FUENTE))["acciones"]}
|
||||||
|
return sum(max(0, len(a["frases"]) - base.get(a["id"], 0))
|
||||||
|
for a in d.get("acciones", []))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
66
config/muestra_voz.py
Normal file
66
config/muestra_voz.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Genera muestras de la voz JARVIS: seca y con tratamiento.
|
||||||
|
|
||||||
|
Se ejecuta DENTRO del flatpak, que es donde viven Kokoro y ffmpeg:
|
||||||
|
|
||||||
|
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||||
|
~/COFRE/CODERS/JARVIS/config/muestra_voz.py
|
||||||
|
|
||||||
|
Sirve para juzgar la voz de oido antes de montar una conversacion entera,
|
||||||
|
y para comparar el efecto del filtro (que es la mitad del caracter JARVIS).
|
||||||
|
Deja los wav en ~/COFRE/CODERS/JARVIS/muestras/.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/app/share/newelle")
|
||||||
|
# Los modulos que Newelle instala bajo demanda (kokoro-onnx, soundfile...) no
|
||||||
|
# van al site-packages del sistema sino a config/pip, que la app añade al
|
||||||
|
# sys.path al arrancar. Un script suelto tiene que hacerlo a mano.
|
||||||
|
sys.path.insert(0, os.path.expanduser(
|
||||||
|
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
|
||||||
|
|
||||||
|
from gi.repository import Gio # noqa: E402
|
||||||
|
|
||||||
|
from newelle.handlers.tts.kokoro_handler import KokoroTTSHandler # noqa: E402
|
||||||
|
from newelle.handlers.tts.tts import jarvis_audio_filter # noqa: E402
|
||||||
|
|
||||||
|
FRASE = ("Good evening, sir. All systems are running locally. "
|
||||||
|
"You have 846 gigabytes free and the GPU is idle.")
|
||||||
|
|
||||||
|
SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/muestras")
|
||||||
|
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(SALIDA, exist_ok=True)
|
||||||
|
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
|
||||||
|
handler = KokoroTTSHandler(settings, os.path.join(CONFIG, "models"))
|
||||||
|
|
||||||
|
if not handler.is_installed():
|
||||||
|
print("Kokoro no instalado, instalando (pip + modelos, tarda)...")
|
||||||
|
handler.install()
|
||||||
|
print("instalado")
|
||||||
|
|
||||||
|
voz = handler.get_current_voice()
|
||||||
|
print(f"voz: {voz}")
|
||||||
|
|
||||||
|
seca = os.path.join(SALIDA, "jarvis_seca.wav")
|
||||||
|
handler.save_audio(FRASE, seca)
|
||||||
|
print(f"seca: {seca} ({os.path.getsize(seca)} bytes)")
|
||||||
|
|
||||||
|
filtro = jarvis_audio_filter()
|
||||||
|
tratada = os.path.join(SALIDA, "jarvis_tratada.wav")
|
||||||
|
subprocess.run(
|
||||||
|
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
|
"-i", seca, "-af", filtro, "-y", tratada],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
print(f"tratada: {tratada} ({os.path.getsize(tratada)} bytes)")
|
||||||
|
print(f"filtro: {filtro}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
207
config/noche.sh
Executable file
207
config/noche.sh
Executable file
|
|
@ -0,0 +1,207 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pipeline nocturno: JARVIS se mejora solo mientras nadie mira.
|
||||||
|
#
|
||||||
|
# Cada ciclo hace lo mismo: mira los datos que ha dejado el uso real, propone
|
||||||
|
# un cambio, lo aplica, y lo somete a config/puerta.sh. Si la puerta dice que
|
||||||
|
# no, se revierte ENTERO con git y se pasa al siguiente. Nada se queda sin
|
||||||
|
# pasar por ahi.
|
||||||
|
#
|
||||||
|
# Por que git y no una copia: el arbol ya esta versionado, `git checkout` es
|
||||||
|
# atomico y deja rastro de lo que se intento. Al terminar la noche se puede ver
|
||||||
|
# commit a commit que se cambio y por que.
|
||||||
|
#
|
||||||
|
# noche.sh corre hasta que se le diga que pare
|
||||||
|
# noche.sh --ciclos 5 solo cinco vueltas
|
||||||
|
# noche.sh --seco propone y valida, pero NO commitea (para mirar)
|
||||||
|
#
|
||||||
|
# Para pararlo: touch ~/COFRE/CODERS/JARVIS/PARAR
|
||||||
|
#
|
||||||
|
# ── Que puede mejorar solo, y que no ────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Se mejora solo lo que se puede VALIDAR solo. Eso es:
|
||||||
|
# · el vocabulario: alias nuevos para ordenes que no se entendieron
|
||||||
|
# · las frases del catalogo: variantes de las que fallan
|
||||||
|
# · la voz: regenerar en la voz buena las frases que cayeron a la de reserva
|
||||||
|
# · el prompt: mantenerlo al dia con el catalogo
|
||||||
|
# · acciones nuevas a partir de lo que se pidio y no existia
|
||||||
|
#
|
||||||
|
# NO se toca solo el motor, el panel ni la logica de la aplicacion. No porque
|
||||||
|
# sea imposible generarlo, sino porque no hay forma automatica de comprobar que
|
||||||
|
# un cambio ahi es bueno: la suite dice si algo se rompio, no si algo mejoro.
|
||||||
|
# Un bucle que reescribe su propio validador acaba pasando siempre.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# ── DESACTIVADO EL 15 DE AGOSTO DE 2026 ────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Este bucle modifica el arbol de `newelle/` y COMMITEA solo. Ese arbol esta
|
||||||
|
# congelado en la etiqueta jarvis-newelle-final desde que el desarrollo se
|
||||||
|
# mudo a nucleo/: dejarlo correr escribiria commits automaticos sobre un
|
||||||
|
# repositorio que por definicion ya no cambia.
|
||||||
|
#
|
||||||
|
# Se ha quitado tambien su temporizador de systemd —estaba armado para las
|
||||||
|
# 03:30 y habria saltado solo—, movido a ~/.config/systemd/user/desactivados/.
|
||||||
|
#
|
||||||
|
# Esto de aqui es la segunda cerradura: aunque alguien lo lance a mano, se
|
||||||
|
# niega. Para retomarlo hay que apuntarlo primero al nucleo, y eso no es
|
||||||
|
# cambiar una ruta: la puerta (config/puerta.sh) valida el catalogo, el
|
||||||
|
# generador y la suite VIEJOS, y el nucleo tiene los suyos.
|
||||||
|
if [ "${JARVIS_NOCHE_A_SABIENDAS:-}" != "si" ]; then
|
||||||
|
cat >&2 <<'AVISO'
|
||||||
|
noche.sh esta desactivado.
|
||||||
|
|
||||||
|
Modifica y commitea sobre newelle/, que esta congelado en jarvis-newelle-final
|
||||||
|
desde que el desarrollo se mudo a nucleo/. Su temporizador de systemd tambien
|
||||||
|
se retiro.
|
||||||
|
|
||||||
|
Para retomarlo hay que reescribirlo contra el nucleo: la puerta valida el
|
||||||
|
catalogo, el generador y la suite viejos.
|
||||||
|
|
||||||
|
Si de verdad quieres correrlo tal cual sobre el arbol congelado:
|
||||||
|
JARVIS_NOCHE_A_SABIENDAS=si config/noche.sh
|
||||||
|
AVISO
|
||||||
|
exit 78
|
||||||
|
fi
|
||||||
|
|
||||||
|
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
|
||||||
|
cd "$RAIZ"
|
||||||
|
PARADA=$RAIZ/PARAR
|
||||||
|
DIARIO=$RAIZ/noche.log
|
||||||
|
CICLOS=0; SECO=no
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--ciclos) CICLOS=${2:-0}; shift 2;;
|
||||||
|
--seco) SECO=si; shift;;
|
||||||
|
*) shift;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log() { printf '%s %s\n' "$(date +%H:%M:%S)" "$*" | tee -a "$DIARIO"; }
|
||||||
|
|
||||||
|
# ── salvaguarda ────────────────────────────────────────────────────────────
|
||||||
|
cd "$RAIZ/newelle"
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
log "ABORTO: el fork tiene cambios sin commitear. Commitealos o descartalos."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ANCLA=$(git rev-parse HEAD)
|
||||||
|
git tag -f "noche-$(date +%Y%m%d-%H%M)" >/dev/null 2>&1
|
||||||
|
log "ancla: $ANCLA (para volver: git reset --hard $ANCLA)"
|
||||||
|
cd "$RAIZ"
|
||||||
|
|
||||||
|
# ── las mejoras, cada una en su funcion ────────────────────────────────────
|
||||||
|
# Cada una devuelve 0 si propuso algo, 1 si no habia nada que hacer.
|
||||||
|
|
||||||
|
mejora_vocabulario() {
|
||||||
|
# Lo que el usuario dijo y no se entendio. Es la fuente mas valiosa porque
|
||||||
|
# son fallos REALES, no inventados.
|
||||||
|
local registro=$HOME/.var/app/io.github.qwersyk.Newelle/config/jarvis-sin-accion.jsonl
|
||||||
|
[ -f "$registro" ] || return 1
|
||||||
|
python3 "$RAIZ/config/proponer_alias.py" --aplicar 2>&1 | tail -3
|
||||||
|
return ${PIPESTATUS[0]}
|
||||||
|
}
|
||||||
|
|
||||||
|
mejora_frases() {
|
||||||
|
# Variantes de las frases del catalogo, generadas con el modelo local y
|
||||||
|
# filtradas duro. Sube la cobertura sin tocar comandos.
|
||||||
|
python3 "$RAIZ/config/proponer_frases.py" --aplicar 2>&1 | tail -3
|
||||||
|
return ${PIPESTATUS[0]}
|
||||||
|
}
|
||||||
|
|
||||||
|
mejora_voz() {
|
||||||
|
# Las frases que cayeron a la voz de reserva se regeneran con la clonada.
|
||||||
|
[ -s "$RAIZ/voz/misses.log" ] || return 1
|
||||||
|
"$RAIZ/voz/clonador-venv/bin/python" "$RAIZ/voz/calienta_cache.py" cpu 2>&1 | tail -2
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
mejora_prompt() {
|
||||||
|
# El prompt lleva la lista de capacidades: si cambio el catalogo, hay que
|
||||||
|
# regenerarlo o el modelo sigue creyendo que sabe hacer otra cosa.
|
||||||
|
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||||
|
"$RAIZ/config/set_persona.py" 2>&1 | grep -v WARNING | tail -1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
mejora_reconocimiento() {
|
||||||
|
# La unica que genera sus PROPIOS datos: dice las frases del catalogo con
|
||||||
|
# varias voces, velocidades y ruidos, las transcribe con el mismo Whisper
|
||||||
|
# del asistente, y propone alias para lo que no llega a su accion.
|
||||||
|
#
|
||||||
|
# Es la que hace productiva la noche aunque nadie hable con JARVIS. Ya trae
|
||||||
|
# su propio guardarrail —solo aplica si el acierto sube Y la charla sigue
|
||||||
|
# yendo al modelo— y ademas pasa por la puerta como todo lo demas.
|
||||||
|
timeout 3000 python3 "$RAIZ/config/mejora_nocturna.py" --ronda --muestras 2 2>&1 | tail -4
|
||||||
|
return ${PIPESTATUS[0]}
|
||||||
|
}
|
||||||
|
|
||||||
|
MEJORAS=(mejora_reconocimiento mejora_vocabulario mejora_frases mejora_prompt mejora_voz)
|
||||||
|
|
||||||
|
# ── el bucle ───────────────────────────────────────────────────────────────
|
||||||
|
vuelta=0
|
||||||
|
aceptados=0; rechazados=0; sin_nada=0
|
||||||
|
log "=== empieza la noche ==="
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
[ -f "$PARADA" ] && { log "parada pedida (existe $PARADA)"; break; }
|
||||||
|
[ "$CICLOS" -gt 0 ] && [ "$vuelta" -ge "$CICLOS" ] && { log "hechas $CICLOS vueltas"; break; }
|
||||||
|
vuelta=$((vuelta+1))
|
||||||
|
|
||||||
|
for mejora in "${MEJORAS[@]}"; do
|
||||||
|
[ -f "$PARADA" ] && break
|
||||||
|
log "── vuelta $vuelta · $mejora"
|
||||||
|
|
||||||
|
salida=$($mejora 2>&1); resultado=$?
|
||||||
|
[ -n "$salida" ] && echo "$salida" | sed 's/^/ /' | tee -a "$DIARIO" >/dev/null
|
||||||
|
|
||||||
|
cd "$RAIZ/newelle"
|
||||||
|
cambios=$(git status --porcelain)
|
||||||
|
cd "$RAIZ"
|
||||||
|
if [ -z "$cambios" ] && [ "$resultado" -ne 0 ]; then
|
||||||
|
log " nada que proponer"
|
||||||
|
sin_nada=$((sin_nada+1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$cambios" ]; then
|
||||||
|
log " aplicado fuera del fork (voz o ajustes), sin commit"
|
||||||
|
aceptados=$((aceptados+1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── LA PUERTA ──
|
||||||
|
if timeout 2400 "$RAIZ/config/puerta.sh" completa > "$RAIZ/.puerta.txt" 2>&1; then
|
||||||
|
if [ "$SECO" = si ]; then
|
||||||
|
log " [seco] habria pasado la puerta; se revierte igual"
|
||||||
|
(cd "$RAIZ/newelle" && git checkout -- . && git clean -fd >/dev/null 2>&1)
|
||||||
|
else
|
||||||
|
(cd "$RAIZ/newelle" && git add -A && \
|
||||||
|
git commit -q -m "auto($mejora): mejora nocturna
|
||||||
|
|
||||||
|
Propuesto y validado por config/noche.sh sin intervencion. Paso la
|
||||||
|
puerta completa: catalogo valido, generador reproducible, emparejador
|
||||||
|
sin perdidas, 150 acciones respondiendo y la suite en verde.
|
||||||
|
|
||||||
|
$(echo "$salida" | head -3)")
|
||||||
|
log " ACEPTADO y commiteado"
|
||||||
|
aceptados=$((aceptados+1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log " RECHAZADO: $(tail -1 "$RAIZ/.puerta.txt")"
|
||||||
|
(cd "$RAIZ/newelle" && git checkout -- . && git clean -fd >/dev/null 2>&1)
|
||||||
|
rechazados=$((rechazados+1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Si una vuelta entera no propuso nada, no tiene sentido seguir dando
|
||||||
|
# vueltas en vacio: se espera a que el uso genere datos nuevos.
|
||||||
|
if [ "$sin_nada" -ge $(( ${#MEJORAS[@]} )) ]; then
|
||||||
|
log "una vuelta entera sin nada que hacer; esperando 30 min"
|
||||||
|
for _ in $(seq 180); do [ -f "$PARADA" ] && break; sleep 10; done
|
||||||
|
sin_nada=0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log "=== fin: $aceptados aceptados, $rechazados rechazados, $vuelta vueltas ==="
|
||||||
|
cd "$RAIZ/newelle"
|
||||||
|
log "commits de esta noche:"
|
||||||
|
git log --oneline "$ANCLA"..HEAD 2>/dev/null | sed 's/^/ /' | tee -a "$DIARIO"
|
||||||
49
config/permisos_maquina.sh
Executable file
49
config/permisos_maquina.sh
Executable file
|
|
@ -0,0 +1,49 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Sacar a JARVIS de la burbuja: que lo que ejecute sea en la maquina de verdad.
|
||||||
|
#
|
||||||
|
# El sintoma era que el asistente contestaba que "esta en un contenedor" y no
|
||||||
|
# podia hacer casi nada. Las 134 acciones del catalogo ya salian al host (todas
|
||||||
|
# llevan host:true), pero los comandos LIBRES —los que se inventa el modelo
|
||||||
|
# cuando lo que pides no esta en el catalogo— corrian dentro del sandbox, donde
|
||||||
|
# no hay ni tu Debian ni tus discos:
|
||||||
|
#
|
||||||
|
# dentro: PRETTY_NAME="GNOME 50 (Flatpak runtime)" sin /media, sin systemctl
|
||||||
|
# fuera: PRETTY_NAME="Debian GNU/Linux 12" /media, systemctl, docker
|
||||||
|
#
|
||||||
|
# Son dos capas distintas y hacen falta las dos:
|
||||||
|
#
|
||||||
|
# 1. `virtualization` (ajuste de Newelle). Decide si los comandos del modelo
|
||||||
|
# llevan delante `flatpak-spawn --host`. Estaba en true = todo dentro.
|
||||||
|
# Es lo que mira _host_prefix() en integrations/default_tools.py.
|
||||||
|
#
|
||||||
|
# 2. Los permisos del flatpak. Aunque los comandos salgan al host, las
|
||||||
|
# herramientas de FICHEROS de la app (leer, editar, indexar) son Python
|
||||||
|
# corriendo dentro: solo ven lo que el sandbox les deje. Estaba en
|
||||||
|
# `filesystems=home`, asi que fuera de tu carpeta personal no veia nada.
|
||||||
|
#
|
||||||
|
# Lo que esto NO quita: el candado de la contraseña al abrir, la confirmacion
|
||||||
|
# de lo destructivo, ni el dialogo de root. Esos siguen. Lo que cambia es que
|
||||||
|
# cuando TU autorizas algo, se ejecuta donde tiene que ejecutarse.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
APPID=io.github.qwersyk.Newelle
|
||||||
|
RAMA=${1:-master}
|
||||||
|
|
||||||
|
echo "1. comandos del modelo -> a la maquina, no a la burbuja"
|
||||||
|
flatpak run --command=gsettings "$APPID//$RAMA" set "$APPID" virtualization false
|
||||||
|
|
||||||
|
echo "2. permisos de ficheros -> todo el disco"
|
||||||
|
# host = el sistema de ficheros (salvo /usr y /etc, que van aparte)
|
||||||
|
# host-etc = /etc, para leer configuracion del sistema
|
||||||
|
# host-os = /usr, para mirar lo instalado
|
||||||
|
flatpak override --user \
|
||||||
|
--filesystem=host \
|
||||||
|
--filesystem=host-etc \
|
||||||
|
--filesystem=host-os \
|
||||||
|
"$APPID"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "como queda:"
|
||||||
|
printf ' virtualization = %s\n' \
|
||||||
|
"$(flatpak run --command=gsettings "$APPID//$RAMA" get "$APPID" virtualization)"
|
||||||
|
flatpak info --show-permissions "$APPID//$RAMA" 2>/dev/null | grep -E "^filesystems=" | sed 's/^/ /'
|
||||||
172
config/probar_acciones.py
Executable file
172
config/probar_acciones.py
Executable file
|
|
@ -0,0 +1,172 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Ejecuta las acciones de verdad y mira si funcionan y si se entienden.
|
||||||
|
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/probar_acciones.py
|
||||||
|
python3 ~/COFRE/CODERS/JARVIS/config/probar_acciones.py --romper # solo las malas
|
||||||
|
|
||||||
|
Las 121 acciones se escribieron a mano y casi ninguna se ha ejecutado nunca.
|
||||||
|
Alguna tendra el comando roto, la salida vacia, o una plantilla que apunta a un
|
||||||
|
campo que no existe —y entonces JARVIS diria una frase sin sentido en voz alta.
|
||||||
|
Esto las corre, captura la salida y las clasifica, para saber cuales arreglar.
|
||||||
|
|
||||||
|
Que hace con cada tipo:
|
||||||
|
- Solo lectura (df, free, ps, cat /sys...): las EJECUTA y comprueba la salida.
|
||||||
|
Son la mayoria y las que de verdad importan, porque son las que hay que
|
||||||
|
"interpretar bien".
|
||||||
|
- Con efecto (abre navegador, apaga pantalla, bloquea, sube volumen): NO las
|
||||||
|
ejecuta —tendria efectos de verdad— pero valida que el comando exista.
|
||||||
|
- Destructivas (confirmar=True): NO las ejecuta, solo comprueba el comando.
|
||||||
|
- Con argumento: las ejecuta con un valor de prueba inofensivo.
|
||||||
|
|
||||||
|
Clasificacion de cada accion:
|
||||||
|
OK corrio, dio salida, y la respuesta hablada sale con datos reales
|
||||||
|
VACIA corrio pero sin salida (la herramienta no aplica en esta maquina)
|
||||||
|
ROTA el comando fallo o no existe
|
||||||
|
PLANTILLA la respuesta cayo al volcado crudo: la plantilla pide un campo que
|
||||||
|
la salida no tiene. Es el fallo que hace que JARVIS suene raro.
|
||||||
|
EFECTO no se ejecuta por tener efectos; comando validado
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
JARVIS = os.path.expanduser("~/COFRE/CODERS/JARVIS")
|
||||||
|
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
|
||||||
|
CATALOGO = os.path.expanduser(
|
||||||
|
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
|
||||||
|
"active/files/share/newelle/acciones/acciones.json")
|
||||||
|
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
|
||||||
|
INFORME = os.path.join(JARVIS, "muestras/acciones-salud.json")
|
||||||
|
|
||||||
|
# Órdenes que cambian algo del sistema o abren cosas: se validan pero no se
|
||||||
|
# lanzan, que probar un asistente no deberia dejarte el navegador con veinte
|
||||||
|
# pestañas ni la pantalla apagada.
|
||||||
|
EFECTOS = ["firefox", "xdg-open", "loginctl", "xset", "pactl set", "pkill",
|
||||||
|
"brightnessctl", "systemctl start", "systemctl stop", "import -window",
|
||||||
|
"gnome-screenshot", "rm ", "kill", "--private-window", "dpms",
|
||||||
|
"ventanas.sh", "xdotool", "wmctrl", "xrandr --output"]
|
||||||
|
|
||||||
|
# Valores de prueba para las acciones con argumento, inofensivos a proposito.
|
||||||
|
ARGUMENTOS = {
|
||||||
|
"buscar_fichero": "acciones", "buscar_carpeta": "config",
|
||||||
|
"leer_fichero": "README", "abrir_fichero": "no_existe_xyz",
|
||||||
|
"abrir_carpeta": "no_existe_xyz", "tamano_carpeta": "config",
|
||||||
|
"proceso_buscar": "python", "proceso_matar": "proceso_inexistente_xyz",
|
||||||
|
"instalado": "bash", "manual": "ls",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def carga_motor():
|
||||||
|
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def spawn():
|
||||||
|
return ["flatpak-spawn", "--host"] if os.path.exists("/.flatpak-info") else []
|
||||||
|
|
||||||
|
|
||||||
|
def tiene_efecto(comando: str) -> bool:
|
||||||
|
return any(marca in comando for marca in EFECTOS)
|
||||||
|
|
||||||
|
|
||||||
|
def ejecuta_crudo(comando: str, argumento: str = "") -> tuple:
|
||||||
|
"""Corre el comando en el host y devuelve (salida, codigo)."""
|
||||||
|
c = comando.replace("{argumento}", shlex.quote(argumento))
|
||||||
|
c = c.replace("{argumento_url}", shlex.quote(argumento))
|
||||||
|
try:
|
||||||
|
r = subprocess.run(spawn() + ["bash", "-lc", c],
|
||||||
|
capture_output=True, text=True, timeout=15)
|
||||||
|
return (r.stdout or r.stderr or "").strip(), r.returncode
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "TIMEOUT", 124
|
||||||
|
except OSError as e:
|
||||||
|
return f"ERROR: {e}", 1
|
||||||
|
|
||||||
|
|
||||||
|
def clasifica(motor, accion, salida, codigo):
|
||||||
|
"""A partir de la salida cruda, decide como quedaria la respuesta hablada."""
|
||||||
|
plantilla = accion.respuesta
|
||||||
|
dicho = motor.formatea(plantilla, salida, "")
|
||||||
|
|
||||||
|
# ¿la plantilla cayo al volcado crudo? formatea() devuelve {salida} cuando
|
||||||
|
# el .format() peta por un campo que no existe. Se detecta comparando: si
|
||||||
|
# la respuesta ES la salida y la plantilla NO era {salida}, fallo.
|
||||||
|
campos = re.findall(r"\{(campo\d+|ultima|primera)\}", plantilla)
|
||||||
|
if campos and dicho.strip() == salida[:600].strip() and plantilla.strip() not in ("{salida}", "{salida}."):
|
||||||
|
return "PLANTILLA", dicho
|
||||||
|
if not salida:
|
||||||
|
return "VACIA", dicho
|
||||||
|
if codigo != 0 and ("command not found" in salida or "No such file" in salida
|
||||||
|
or salida.startswith("ERROR") or salida == "TIMEOUT"):
|
||||||
|
return "ROTA", dicho
|
||||||
|
return "OK", dicho
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--romper", action="store_true", help="solo las que fallan")
|
||||||
|
ap.add_argument("--json", action="store_true", help="salida en json")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
motor = carga_motor()
|
||||||
|
catalogo = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE
|
||||||
|
cat = motor.Catalogo([catalogo])
|
||||||
|
|
||||||
|
resultados = []
|
||||||
|
for accion in cat.acciones:
|
||||||
|
estado = None
|
||||||
|
if accion.confirmar:
|
||||||
|
estado = "EFECTO" if not accion.comando else "DESTRUCTIVA"
|
||||||
|
dicho = accion.acuse
|
||||||
|
elif tiene_efecto(accion.comando):
|
||||||
|
estado = "EFECTO"
|
||||||
|
dicho = "(no se ejecuta; " + ("comando ok" if accion.comando else "sin comando") + ")"
|
||||||
|
else:
|
||||||
|
arg = ARGUMENTOS.get(accion.id, "") if accion.captura else ""
|
||||||
|
salida, codigo = ejecuta_crudo(accion.comando, arg)
|
||||||
|
estado, dicho = clasifica(motor, accion, salida, codigo)
|
||||||
|
|
||||||
|
resultados.append({
|
||||||
|
"id": accion.id, "grupo": accion.grupo, "estado": estado,
|
||||||
|
"dice": dicho[:120],
|
||||||
|
})
|
||||||
|
|
||||||
|
cuenta = {}
|
||||||
|
for r in resultados:
|
||||||
|
cuenta[r["estado"]] = cuenta.get(r["estado"], 0) + 1
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({"cuenta": cuenta, "acciones": resultados},
|
||||||
|
ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
malas = {"ROTA", "PLANTILLA", "VACIA"}
|
||||||
|
for r in resultados:
|
||||||
|
if args.romper and r["estado"] not in malas:
|
||||||
|
continue
|
||||||
|
marca = {"OK": " ok ", "VACIA": " vacia", "ROTA": " ROTA ",
|
||||||
|
"PLANTILLA": " PLANT", "EFECTO": " efect",
|
||||||
|
"DESTRUCTIVA": " destr"}.get(r["estado"], " ? ")
|
||||||
|
print(f"[{marca}] {r['id']:<22} {r['dice']}")
|
||||||
|
print("\n" + " ".join(f"{k}: {v}" for k, v in sorted(cuenta.items())))
|
||||||
|
buenas = cuenta.get("OK", 0) + cuenta.get("EFECTO", 0) + cuenta.get("DESTRUCTIVA", 0)
|
||||||
|
print(f"utiles: {buenas}/{len(resultados)} · "
|
||||||
|
f"a revisar: {sum(cuenta.get(k, 0) for k in ('ROTA','PLANTILLA','VACIA'))}")
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(INFORME), exist_ok=True)
|
||||||
|
with open(INFORME, "w") as f:
|
||||||
|
json.dump({"cuenta": cuenta, "acciones": resultados}, f,
|
||||||
|
indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
fallan = sum(cuenta.get(k, 0) for k in ("ROTA", "PLANTILLA"))
|
||||||
|
return 1 if fallan else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
79
config/probar_rag.py
Normal file
79
config/probar_rag.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Comprueba que el RAG recupera de tus notas, con preguntas de prueba.
|
||||||
|
|
||||||
|
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||||
|
~/COFRE/CODERS/JARVIS/config/probar_rag.py
|
||||||
|
|
||||||
|
Lanza unas cuantas consultas contra el indice ya construido y enseña de que
|
||||||
|
ficheros saca la respuesta. Sirve para ver si el corpus responde bien antes de
|
||||||
|
fiarse de el, y para decidir si merece ampliarlo con los .txt o dejarlo en solo
|
||||||
|
notas.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/app/share/newelle")
|
||||||
|
sys.path.insert(0, os.path.expanduser(
|
||||||
|
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
|
||||||
|
|
||||||
|
from gi.repository import Gio # noqa: E402
|
||||||
|
|
||||||
|
from newelle.handlers.rag.llamaindex_handler import LlamaIndexHanlder # noqa: E402
|
||||||
|
from newelle.handlers.embeddings.model2vec import Model2VecHandler # noqa: E402
|
||||||
|
|
||||||
|
MODELOS = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config/models")
|
||||||
|
|
||||||
|
# Preguntas de dominio: si el corpus sirve, deberian traer notas relevantes.
|
||||||
|
PREGUNTAS = [
|
||||||
|
"como enumerar un host con nmap",
|
||||||
|
"escalada de privilegios en linux",
|
||||||
|
"que es oasis y como funciona el pub",
|
||||||
|
"reverse shell",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
|
||||||
|
rag = LlamaIndexHanlder(settings, MODELOS)
|
||||||
|
if not rag.is_installed():
|
||||||
|
print("faltan dependencias del RAG")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
rag.embedding = Model2VecHandler(settings, MODELOS)
|
||||||
|
|
||||||
|
class SinModelo:
|
||||||
|
def load_model(self, *a, **k):
|
||||||
|
return True
|
||||||
|
rag.llm = SinModelo()
|
||||||
|
rag.secondary_llm = SinModelo()
|
||||||
|
|
||||||
|
print("cargando el indice...", flush=True)
|
||||||
|
try:
|
||||||
|
rag.load_index()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"no se pudo cargar el indice: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
for pregunta in PREGUNTAS:
|
||||||
|
print(f"\n### {pregunta}")
|
||||||
|
ini = time.time()
|
||||||
|
try:
|
||||||
|
trozos = rag.get_context(pregunta, [])
|
||||||
|
except Exception as e:
|
||||||
|
print(f" fallo: {e}")
|
||||||
|
continue
|
||||||
|
ms = (time.time() - ini) * 1000
|
||||||
|
if not trozos:
|
||||||
|
print(f" sin resultados ({ms:.0f} ms)")
|
||||||
|
continue
|
||||||
|
print(f" {len(trozos)} trozos en {ms:.0f} ms:")
|
||||||
|
for t in trozos[:1]:
|
||||||
|
# el trozo suele traer la ruta o el nombre del fichero delante
|
||||||
|
resumen = " ".join(str(t).split())[:400]
|
||||||
|
print(f" · {resumen}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
51
config/probar_rag_temas.py
Normal file
51
config/probar_rag_temas.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prueba el RAG con los temas recien ingestados de Wikipedia."""
|
||||||
|
import os, sys, time
|
||||||
|
sys.path.insert(0, "/app/share/newelle")
|
||||||
|
sys.path.insert(0, os.path.expanduser(
|
||||||
|
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
|
||||||
|
from gi.repository import Gio # noqa: E402
|
||||||
|
from newelle.handlers.rag.llamaindex_handler import LlamaIndexHanlder # noqa
|
||||||
|
from newelle.handlers.embeddings.model2vec import Model2VecHandler # noqa
|
||||||
|
|
||||||
|
MODELOS = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config/models")
|
||||||
|
PREGUNTAS = [
|
||||||
|
"que es la criptografia de curva eliptica",
|
||||||
|
"quien es Richard Stallman y el software libre",
|
||||||
|
"que es el efecto invernadero y el cambio climatico",
|
||||||
|
"que fue la guerra civil espanola",
|
||||||
|
"que es un ataque de denegacion de servicio",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
|
||||||
|
rag = LlamaIndexHanlder(settings, MODELOS)
|
||||||
|
if not rag.is_installed():
|
||||||
|
print("faltan dependencias del RAG"); return 1
|
||||||
|
rag.embedding = Model2VecHandler(settings, MODELOS)
|
||||||
|
|
||||||
|
class SinModelo:
|
||||||
|
def load_model(self, *a, **k): return True
|
||||||
|
rag.llm = SinModelo(); rag.secondary_llm = SinModelo()
|
||||||
|
print("cargando el indice...", flush=True)
|
||||||
|
rag.load_index()
|
||||||
|
for pregunta in PREGUNTAS:
|
||||||
|
print(f"\n### {pregunta}")
|
||||||
|
ini = time.time()
|
||||||
|
try:
|
||||||
|
trozos = rag.get_context(pregunta, [])
|
||||||
|
except Exception as e:
|
||||||
|
print(f" fallo: {e}"); continue
|
||||||
|
ms = (time.time() - ini) * 1000
|
||||||
|
if not trozos:
|
||||||
|
print(f" sin resultados ({ms:.0f} ms)"); continue
|
||||||
|
print(f" {len(trozos)} trozos en {ms:.0f} ms:")
|
||||||
|
for t in trozos[:2]:
|
||||||
|
resumen = " ".join(str(t).split())[:280]
|
||||||
|
print(f" · {resumen}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
227
config/proponer_alias.py
Executable file
227
config/proponer_alias.py
Executable file
|
|
@ -0,0 +1,227 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Saca alias de lo que el usuario dijo y JARVIS no entendio.
|
||||||
|
|
||||||
|
Es la fuente mas valiosa de mejora porque son fallos REALES: el panel apunta en
|
||||||
|
jarvis-sin-accion.jsonl toda frase que no encajo en ninguna accion. Con 488
|
||||||
|
lineas acumuladas y nadie leyendolas, ahi habia meses de mejoras sin recoger.
|
||||||
|
|
||||||
|
Lo dificil no es proponer, es NO proponer de mas. Un alias malo desvia una
|
||||||
|
orden buena, y eso es peor que no entender: el usuario dice una cosa y pasa
|
||||||
|
otra. Los filtros de abajo son casi todo el fichero por esa razon.
|
||||||
|
|
||||||
|
proponer_alias.py enseña lo que propondria
|
||||||
|
proponer_alias.py --aplicar lo escribe en el generador
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import difflib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
REGISTRO = os.path.expanduser(
|
||||||
|
"~/.var/app/io.github.qwersyk.Newelle/config/jarvis-sin-accion.jsonl")
|
||||||
|
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json")
|
||||||
|
MOTOR = os.path.join(RAIZ, "newelle", "src", "utility", "acciones.py")
|
||||||
|
APLICADOS = os.path.join(RAIZ, "config", "alias-aplicados.json")
|
||||||
|
|
||||||
|
# Lo que dice JARVIS: si aparece en el registro es ECO —se oye a si mismo por
|
||||||
|
# el altavoz— y no es una orden de nadie. Era el 21% del registro.
|
||||||
|
def es_eco(frase, acuses):
|
||||||
|
l = frase.lower()
|
||||||
|
if "señor" in l:
|
||||||
|
return True
|
||||||
|
return any(a and a in l for a in acuses)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def parece_orden(frase):
|
||||||
|
"""Un filtro barato antes de gastar el modelo: ¿esto pide algo?
|
||||||
|
|
||||||
|
La charla no lleva verbo de mando. Sin este filtro entran cosas como
|
||||||
|
"Me estas escuchando" o "y que te voy a hacer", que por parecido de letras
|
||||||
|
puntuan alto con cualquier frase corta del catalogo.
|
||||||
|
"""
|
||||||
|
l = " " + frase.lower() + " "
|
||||||
|
VERBOS = (" pon ", " ponme ", " pongas ", " poner ", " abre ", " abreme ",
|
||||||
|
" abrir ", " abras ", " busca ", " buscame ", " buscar ", " busques ",
|
||||||
|
" dime ", " dame ", " muestra ", " muestrame ", " ensename ",
|
||||||
|
" ejecuta ", " lanza ", " haz ", " hazme ", " mira ", " comprueba ",
|
||||||
|
" cierra ", " manda ", " pasa ", " mueve ", " maximiza ", " minimiza ",
|
||||||
|
" centra ", " reproduce ", " cuanto ", " cuanta ", " cuantos ",
|
||||||
|
" cuantas ", " que hora ", " donde ", " cual ")
|
||||||
|
return any(v in l for v in VERBOS)
|
||||||
|
|
||||||
|
|
||||||
|
def el_modelo_lo_confirma(frase, accion, modelo="qwen3.5:4b-jarvis"):
|
||||||
|
"""Que un modelo diga si la frase pide de verdad esa accion.
|
||||||
|
|
||||||
|
El parecido de letras no distingue significado: "me estas escuchando" saca
|
||||||
|
0.57 con las frases de la accion de puertos, y con eso se colaria un alias
|
||||||
|
que desvia ordenes buenas. Un alias malo es peor que no entender, porque el
|
||||||
|
usuario pide una cosa y pasa otra.
|
||||||
|
|
||||||
|
Ante la duda, NO. Si el modelo no contesta o contesta cualquier cosa, se
|
||||||
|
descarta: dejar pasar un alias dudoso cuesta mas que perderlo.
|
||||||
|
"""
|
||||||
|
import urllib.request
|
||||||
|
ejemplo = accion.frases[0] if accion.frases else accion.id
|
||||||
|
prompt = (
|
||||||
|
"Decide si dos frases piden LO MISMO. Responde solo SI o NO.\n\n"
|
||||||
|
"Ejemplos:\n"
|
||||||
|
"A: \"cuanto espacio queda\" B: \"dime cuanto disco me queda\" -> SI\n"
|
||||||
|
"A: \"cuanto espacio queda\" B: \"que tal estas\" -> NO\n"
|
||||||
|
"A: \"abre una terminal\" B: \"sacame una consola\" -> SI\n"
|
||||||
|
"A: \"abre una terminal\" B: \"me estas escuchando\" -> NO\n"
|
||||||
|
"A: \"que temperatura hay\" B: \"cuentame un chiste\" -> NO\n\n"
|
||||||
|
f"A: \"{ejemplo}\" B: \"{frase}\" -> "
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
carga = json.dumps({"model": modelo, "prompt": prompt, "stream": False,
|
||||||
|
"think": False, "options": {"temperature": 0}}).encode()
|
||||||
|
req = urllib.request.Request("http://127.0.0.1:11434/api/generate",
|
||||||
|
data=carga,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as r:
|
||||||
|
respuesta = json.load(r).get("response", "").strip().upper()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return respuesta.startswith("SI") or respuesta.startswith("SÍ")
|
||||||
|
|
||||||
|
|
||||||
|
def cargar_motor():
|
||||||
|
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def frases_del_registro():
|
||||||
|
if not os.path.exists(REGISTRO):
|
||||||
|
return []
|
||||||
|
fuera = []
|
||||||
|
for linea in open(REGISTRO, encoding="utf-8"):
|
||||||
|
try:
|
||||||
|
t = json.loads(linea).get("texto", "")
|
||||||
|
except Exception:
|
||||||
|
t = linea.strip()
|
||||||
|
t = " ".join(t.split())
|
||||||
|
if t:
|
||||||
|
fuera.append(t)
|
||||||
|
return fuera
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--aplicar", action="store_true")
|
||||||
|
p.add_argument("--tope", type=int, default=3,
|
||||||
|
help="cuantos alias como mucho por vuelta")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
m = cargar_motor()
|
||||||
|
cat = m.Catalogo([CATALOGO])
|
||||||
|
acciones = {a.id: a for a in cat.acciones}
|
||||||
|
acuses = {a.acuse.lower().strip(". ") for a in cat.acciones if a.acuse}
|
||||||
|
|
||||||
|
frases = frases_del_registro()
|
||||||
|
if not frases:
|
||||||
|
print("no hay registro de frases sin entender")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
ya = set(json.load(open(APLICADOS)))
|
||||||
|
except Exception:
|
||||||
|
ya = set()
|
||||||
|
|
||||||
|
# 1. fuera el eco y lo que ya se entiende hoy (el catalogo cambia)
|
||||||
|
candidatas = []
|
||||||
|
for f in frases:
|
||||||
|
if f in ya or es_eco(f, acuses):
|
||||||
|
continue
|
||||||
|
if cat.busca(f)[0] is not None:
|
||||||
|
continue
|
||||||
|
candidatas.append(f)
|
||||||
|
|
||||||
|
# 2. lo que se repite es lo que importa: una frase dicha una vez puede ser
|
||||||
|
# un error de transcripcion; dicha tres veces es como habla el usuario
|
||||||
|
cuenta = collections.Counter(candidatas)
|
||||||
|
|
||||||
|
propuestas = []
|
||||||
|
for frase, veces in cuenta.most_common():
|
||||||
|
norm = m.normaliza(frase)
|
||||||
|
palabras = norm.split()
|
||||||
|
# 3. filtros duros
|
||||||
|
if not (3 <= len(palabras) <= 12):
|
||||||
|
continue
|
||||||
|
if re.search(r"[<>{}\[\]|@#_*]", frase):
|
||||||
|
continue
|
||||||
|
# 4. a que accion se parece mas, comparando con TODAS sus frases
|
||||||
|
mejor, punto = None, 0.0
|
||||||
|
for a in cat.acciones:
|
||||||
|
if a.captura:
|
||||||
|
continue # el relleno de prueba se horneraria en el alias
|
||||||
|
for f2 in a.frases:
|
||||||
|
r = difflib.SequenceMatcher(None, norm, f2).ratio()
|
||||||
|
if r > punto:
|
||||||
|
mejor, punto = a, r
|
||||||
|
# 5. ni muy lejos (seria inventarse) ni muy cerca (ya lo cogeria)
|
||||||
|
if mejor is None or not (0.55 <= punto < 0.86):
|
||||||
|
continue
|
||||||
|
# 6. tiene que compartir una palabra con significado, o el alias
|
||||||
|
# convierte cualquier ruido en una orden
|
||||||
|
suyas = {w for f2 in mejor.frases for w in f2.split() if len(w) > 3}
|
||||||
|
if not (set(w for w in palabras if len(w) > 3) & suyas):
|
||||||
|
continue
|
||||||
|
# 7. y no puede encajar en dos acciones a la vez
|
||||||
|
otras = [a for a in cat.acciones
|
||||||
|
if a is not mejor and not a.captura
|
||||||
|
and max((difflib.SequenceMatcher(None, norm, f2).ratio()
|
||||||
|
for f2 in a.frases), default=0) >= punto - 0.05]
|
||||||
|
if otras:
|
||||||
|
continue
|
||||||
|
# 8. tiene que parecer una orden, no charla
|
||||||
|
if not parece_orden(frase):
|
||||||
|
continue
|
||||||
|
# 9. y el modelo tiene que confirmar que pide ESA accion. Es lo unico
|
||||||
|
# que distingue significado de coincidencia de letras.
|
||||||
|
if not el_modelo_lo_confirma(frase, mejor):
|
||||||
|
continue
|
||||||
|
propuestas.append((frase, norm, mejor.id, punto, veces))
|
||||||
|
|
||||||
|
if not propuestas:
|
||||||
|
print("nada que proponer: ninguna frase pasa los filtros")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
propuestas = propuestas[:args.tope]
|
||||||
|
for frase, norm, ident, punto, veces in propuestas:
|
||||||
|
print(f" {ident:22s} ← \"{frase[:52]}\" (x{veces}, parecido {punto:.2f})")
|
||||||
|
|
||||||
|
if not args.aplicar:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 8. se escribe en el GENERADOR, que es la fuente. Editar el JSON seria
|
||||||
|
# perder el cambio la proxima vez que alguien regenere.
|
||||||
|
gen = os.path.join(RAIZ, "newelle", "data", "acciones", "_generar.py")
|
||||||
|
texto = open(gen, encoding="utf-8").read()
|
||||||
|
puestos = 0
|
||||||
|
for frase, norm, ident, punto, veces in propuestas:
|
||||||
|
patron = re.compile(r'(\("' + re.escape(ident) + r'", ")([^"]*)(")')
|
||||||
|
mm = patron.search(texto)
|
||||||
|
if not mm or norm in mm.group(2):
|
||||||
|
continue
|
||||||
|
texto = texto[:mm.start(2)] + mm.group(2) + "|" + norm + texto[mm.end(2):]
|
||||||
|
puestos += 1
|
||||||
|
ya.add(frase)
|
||||||
|
if puestos:
|
||||||
|
open(gen, "w", encoding="utf-8").write(texto)
|
||||||
|
os.system(f"cd {os.path.dirname(gen)} && python3 _generar.py >/dev/null 2>&1")
|
||||||
|
json.dump(sorted(ya), open(APLICADOS, "w"), ensure_ascii=False, indent=1)
|
||||||
|
print(f"{puestos} alias aplicados al generador")
|
||||||
|
return 0 if puestos else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
15
config/proponer_frases.py
Executable file
15
config/proponer_frases.py
Executable file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Variantes de las frases del catalogo, generadas con el modelo local.
|
||||||
|
|
||||||
|
Sitio reservado: hoy el trabajo util lo hace proponer_alias.py, que parte de
|
||||||
|
fallos REALES del usuario. Generar variantes inventadas sin datos detras llena
|
||||||
|
el catalogo de ruido y sube el riesgo de que una frase se coma a otra, que es
|
||||||
|
el fallo mas caro del emparejador.
|
||||||
|
|
||||||
|
Cuando haya mas registro de uso, aqui ira: coger las acciones que nunca se
|
||||||
|
disparan, pedirle al modelo tres formas mas de pedirlas, y pasarlas por el
|
||||||
|
mismo juez que usa proponer_alias.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
print("sin datos suficientes: se propone desde fallos reales, no inventando")
|
||||||
|
sys.exit(1)
|
||||||
108
config/puerta.sh
Executable file
108
config/puerta.sh
Executable file
|
|
@ -0,0 +1,108 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# La puerta: decide si un cambio automatico se queda o se revierte.
|
||||||
|
#
|
||||||
|
# Es la pieza central del pipeline nocturno. Todo lo que el sistema se cambia a
|
||||||
|
# si mismo pasa por aqui, y lo que no la pasa se deshace ENTERO. Sin esto,
|
||||||
|
# dejar algo modificando codigo sin nadie delante es una forma elaborada de
|
||||||
|
# romper un proyecto que funcionaba.
|
||||||
|
#
|
||||||
|
# Que comprueba, en orden de lo mas barato a lo mas caro (para fallar pronto):
|
||||||
|
# 1. que el Python compile
|
||||||
|
# 2. que el catalogo sea JSON valido y el generador lo reproduzca
|
||||||
|
# 3. que el emparejador no haya perdido ordenes (08)
|
||||||
|
# 4. que las acciones sigan respondiendo (repaso completo)
|
||||||
|
# 5. la suite entera
|
||||||
|
#
|
||||||
|
# puerta.sh rapida solo 1-3, para iterar
|
||||||
|
# puerta.sh completa todo, antes de commitear
|
||||||
|
set -uo pipefail
|
||||||
|
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
|
||||||
|
MODO=${1:-completa}
|
||||||
|
cd "$RAIZ"
|
||||||
|
|
||||||
|
fallo() { echo "PUERTA: $1" >&2; exit 1; }
|
||||||
|
|
||||||
|
# --- 1. compila el Python -------------------------------------------------
|
||||||
|
python3 -m compileall -q newelle/src config voz/*.py >/dev/null 2>&1 \
|
||||||
|
|| fallo "hay Python que no compila"
|
||||||
|
|
||||||
|
# --- 2. el catalogo es valido y reproducible ------------------------------
|
||||||
|
python3 -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(open('newelle/data/acciones/acciones.json'))
|
||||||
|
acc=d['acciones']
|
||||||
|
assert len(acc)>100, f'solo {len(acc)} acciones'
|
||||||
|
ids=[a['id'] for a in acc]
|
||||||
|
assert len(ids)==len(set(ids)), 'ids repetidos'
|
||||||
|
for a in acc:
|
||||||
|
assert a.get('frases'), f\"{a['id']} sin frases\"
|
||||||
|
assert a.get('acuse'), f\"{a['id']} sin acuse\"
|
||||||
|
assert 'señor' in a['acuse'].lower(), f\"{a['id']} no trata de señor\"
|
||||||
|
if a.get('captura') and a.get('comando'):
|
||||||
|
assert '{argumento' in a['comando'], f\"{a['id']} captura y no usa el argumento\"
|
||||||
|
" 2>/dev/null || fallo "el catalogo esta mal formado"
|
||||||
|
|
||||||
|
# El generador es la fuente: si no reproduce el catalogo, alguien edito el
|
||||||
|
# producto y el proximo que regenere pierde el trabajo.
|
||||||
|
cp newelle/data/acciones/acciones.json /tmp/jarvis-puerta-antes.json
|
||||||
|
(cd newelle/data/acciones && python3 _generar.py >/dev/null 2>&1)
|
||||||
|
if ! diff -q /tmp/jarvis-puerta-antes.json newelle/data/acciones/acciones.json >/dev/null 2>&1; then
|
||||||
|
cp /tmp/jarvis-puerta-antes.json newelle/data/acciones/acciones.json
|
||||||
|
fallo "el generador no reproduce el catalogo (se edito el producto)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 3. el emparejador no ha perdido ordenes ------------------------------
|
||||||
|
python3 - <<'FIN' || fallo "el emparejador ha perdido ordenes que antes acertaba"
|
||||||
|
import importlib.util, sys
|
||||||
|
spec = importlib.util.spec_from_file_location("acc", "newelle/src/utility/acciones.py")
|
||||||
|
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
||||||
|
cat = m.Catalogo(["newelle/data/acciones/acciones.json"])
|
||||||
|
|
||||||
|
# El minimo innegociable: si alguna de estas deja de funcionar, el cambio se
|
||||||
|
# revierte pase lo que pase. Son las que el usuario usa de verdad.
|
||||||
|
IMPRESCINDIBLES = [
|
||||||
|
("cuanto espacio queda", "disco_libre"),
|
||||||
|
("cuanta memoria", "memoria"),
|
||||||
|
("que temperatura hay", "temperatura"),
|
||||||
|
("abre una terminal", "terminal_abrir"),
|
||||||
|
("apaga la pantalla", "apagar_pantalla"),
|
||||||
|
("captura de pantalla", "captura_pantalla"),
|
||||||
|
("abre una ventana privada", "ff_firefox_privado"),
|
||||||
|
("pon la ventana a la izquierda", "ventana_izquierda"),
|
||||||
|
("necesito que pongas la pelicula de iron man", "pelicula_abrir"),
|
||||||
|
("podrias abrir una terminal", "terminal_abrir"),
|
||||||
|
]
|
||||||
|
malas = []
|
||||||
|
for frase, esperado in IMPRESCINDIBLES:
|
||||||
|
a, _ = cat.busca(frase)
|
||||||
|
if a is None or a.id != esperado:
|
||||||
|
malas.append(f"{frase!r}→{a.id if a else 'ninguna'} (deberia {esperado})")
|
||||||
|
|
||||||
|
# Y la conversacion no puede engancharse a una accion
|
||||||
|
CHARLA = ["que tal estas", "necesito un abrazo", "quiero contarte una cosa",
|
||||||
|
"cuentame un chiste", "que piensas de la vida", "necesito pensar"]
|
||||||
|
coladas = [c for c in CHARLA if cat.busca(c)[0] is not None]
|
||||||
|
if malas or coladas:
|
||||||
|
for x in malas: print(" perdida:", x, file=sys.stderr)
|
||||||
|
for c in coladas: print(" charla colada:", c, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
FIN
|
||||||
|
|
||||||
|
[ "$MODO" = rapida ] && { echo "PUERTA: rapida OK"; exit 0; }
|
||||||
|
|
||||||
|
# --- 4. las acciones responden -------------------------------------------
|
||||||
|
SALIDA=$(timeout 900 python3 config/repasar_acciones.py 2>&1 | tail -3)
|
||||||
|
echo "$SALIDA" | grep -q "de .* responden bien" || fallo "el repaso de acciones no termino"
|
||||||
|
ROTAS=$(echo "$SALIDA" | grep -oE "fallan: .*" || true)
|
||||||
|
[ -n "$ROTAS" ] && fallo "hay acciones rotas → $ROTAS"
|
||||||
|
|
||||||
|
# --- 5. la suite ----------------------------------------------------------
|
||||||
|
# Se salta la 07, que abre la app y se queda colgada sin nadie delante.
|
||||||
|
SUITE=$(cd pruebas && for t in [0-9][0-9]_*.sh; do
|
||||||
|
case "$t" in 05_*|07_*) continue;; esac
|
||||||
|
timeout 400 "./$t" 2>&1 | tail -1
|
||||||
|
done)
|
||||||
|
FALLOS=$(echo "$SUITE" | grep -oE "[0-9]+ fallan" | awk '{s+=$1} END{print s+0}')
|
||||||
|
[ "${FALLOS:-0}" -gt 0 ] && fallo "la suite tiene $FALLOS comprobaciones en rojo"
|
||||||
|
|
||||||
|
echo "PUERTA: completa OK"
|
||||||
18
config/qwen3.5-4b-jarvis.Modelfile
Normal file
18
config/qwen3.5-4b-jarvis.Modelfile
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Variante de qwen3.5:4b para JARVIS: deja sitio a whisper en la tarjeta.
|
||||||
|
#
|
||||||
|
# La T1200 tiene 3717 MiB utiles. Con el reparto automatico ollama coge 2941 y
|
||||||
|
# deja 776 libres: whisper small CARGA (757) pero se mata al transcribir, cuando
|
||||||
|
# pide su pico de 839. El sintoma en la app es "Error recognizing file with
|
||||||
|
# server", que despista, porque el fichero y el servidor estan bien.
|
||||||
|
#
|
||||||
|
# Con 24 de las 32 capas en GPU el LLM ocupa 2177 y deja 1540 libres: caben los
|
||||||
|
# dos con 700 MiB de holgura. Y no cuesta velocidad — medido con tres tiradas en
|
||||||
|
# caliente, la respuesta baja de 2,71 s a 2,31 s, porque el reparto automatico
|
||||||
|
# se queda sin VRAM y acaba peleandose consigo mismo.
|
||||||
|
#
|
||||||
|
# Crear o recrear: ollama create qwen3.5:4b-jarvis -f qwen3.5-4b-jarvis.Modelfile
|
||||||
|
|
||||||
|
FROM qwen3.5:4b
|
||||||
|
|
||||||
|
PARAMETER num_gpu 24
|
||||||
|
PARAMETER num_ctx 4096
|
||||||
114
config/recorrido.sh
Executable file
114
config/recorrido.sh
Executable file
|
|
@ -0,0 +1,114 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Recorre el catalogo grupo por grupo, en voz alta y a ritmo de mirar.
|
||||||
|
#
|
||||||
|
# Distinto de demo_en_directo.sh: aquel probaba once acciones sueltas; este
|
||||||
|
# recorre TODOS los grupos con una muestra representativa de cada uno, anuncia
|
||||||
|
# el grupo antes de entrar, y deja pausas para que se pueda seguir en pantalla.
|
||||||
|
#
|
||||||
|
# Lo destructivo y lo que pide root no se ejecuta: en uso normal eso pasa por
|
||||||
|
# un dialogo que aprueba una persona.
|
||||||
|
#
|
||||||
|
# ./recorrido.sh todo, con voz
|
||||||
|
# ./recorrido.sh --mudo sin voz
|
||||||
|
# ./recorrido.sh sistema solo un grupo
|
||||||
|
set -uo pipefail
|
||||||
|
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
|
||||||
|
CATALOGO=$RAIZ/newelle/data/acciones/acciones.json
|
||||||
|
MUDO=no; SOLO=""
|
||||||
|
for a in "$@"; do
|
||||||
|
case "$a" in --mudo) MUDO=si;; *) SOLO=$a;; esac
|
||||||
|
done
|
||||||
|
WAV=$(mktemp --suffix=.wav); trap 'rm -f "$WAV"' EXIT
|
||||||
|
|
||||||
|
dice() {
|
||||||
|
[ "$MUDO" = si ] && { sleep 0.4; return 0; }
|
||||||
|
"$RAIZ/config/jarvis-voz.sh" "$WAV" "$1" >/dev/null 2>&1
|
||||||
|
# < /dev/null: si no, se comen la entrada del bucle que los llama
|
||||||
|
[ -s "$WAV" ] && { paplay "$WAV" </dev/null 2>/dev/null || aplay -q "$WAV" </dev/null 2>/dev/null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
campo() { python3 -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(open('$CATALOGO',encoding='utf-8'))
|
||||||
|
for a in d.get('acciones', d if isinstance(d,list) else []):
|
||||||
|
if a['id']==sys.argv[1]: print(a.get(sys.argv[2],'') or ''); break
|
||||||
|
" "$1" "$2"; }
|
||||||
|
|
||||||
|
# grupo|id|frase que se diria|argumento
|
||||||
|
RECORRIDO='
|
||||||
|
sistema|disco_libre|cuanto espacio queda|
|
||||||
|
sistema|memoria|cuanta memoria|
|
||||||
|
sistema|temperatura|que temperatura hay|
|
||||||
|
sistema|uptime|cuanto lleva encendido|
|
||||||
|
sistema|procesos_top|que esta consumiendo|
|
||||||
|
sistema|red_ip|cual es mi ip|
|
||||||
|
sistema|red_wifi|a que wifi estoy conectado|
|
||||||
|
sistema|gpu_procesos|que usa la grafica|
|
||||||
|
sistema|pantallas|cuantas pantallas hay|
|
||||||
|
sistema|captura_pantalla|hazme una captura|
|
||||||
|
sistema|terminal_abrir|abre una terminal|
|
||||||
|
sistema|videos_listar|que peliculas tengo|
|
||||||
|
ficheros|buscar_fichero|busca el fichero README|README.md
|
||||||
|
ficheros|grep_recursivo|busca la palabra JARVIS|JARVIS
|
||||||
|
ficheros|tamano_carpeta|cuanto ocupa la carpeta config|'"$RAIZ"'/config
|
||||||
|
ficheros|descargas_listar|que hay en descargas|
|
||||||
|
oasis|oasis_corriendo|esta oasis funcionando|
|
||||||
|
oasis|oasis_conexiones|cuantos pubs hay conectados|
|
||||||
|
pentest|mis_puertos|que puertos tengo abiertos yo|
|
||||||
|
ventanas|ventanas_listar|que ventanas tengo abiertas|
|
||||||
|
ventanas|ventana_izquierda|pon la ventana a la izquierda|
|
||||||
|
ventanas|ventana_derecha|manda la ventana a la derecha|
|
||||||
|
ventanas|ventana_centrar|centra la ventana activa|
|
||||||
|
ventanas|ventana_maximizar|maximiza la ventana activa|
|
||||||
|
ventanas|ventana_restaurar|restaura la ventana activa|
|
||||||
|
ventanas|ventana_a_pantalla|manda la ventana a la pantalla uno|uno
|
||||||
|
'
|
||||||
|
|
||||||
|
grupo_actual=""
|
||||||
|
n=0
|
||||||
|
while IFS='|' read -r -u 3 grupo id frase argumento; do
|
||||||
|
[ -z "${grupo:-}" ] && continue
|
||||||
|
[ -n "$SOLO" ] && [ "$SOLO" != "$grupo" ] && continue
|
||||||
|
|
||||||
|
if [ "$grupo" != "$grupo_actual" ]; then
|
||||||
|
grupo_actual=$grupo
|
||||||
|
printf '\n\n\033[1m ══ grupo: %s ══\033[0m\n' "$grupo"
|
||||||
|
dice "Grupo $grupo, señor."
|
||||||
|
sleep 0.6
|
||||||
|
fi
|
||||||
|
|
||||||
|
comando=$(campo "$id" comando)
|
||||||
|
[ -z "$comando" ] && { printf ' (%s no existe en el catalogo)\n' "$id"; continue; }
|
||||||
|
acuse=$(campo "$id" acuse)
|
||||||
|
plantilla=$(campo "$id" respuesta)
|
||||||
|
|
||||||
|
n=$((n+1))
|
||||||
|
printf '\n %2d. tu: "%s"\n' "$n" "$frase"
|
||||||
|
printf ' jarvis: %s\n' "$acuse"
|
||||||
|
dice "$acuse"
|
||||||
|
|
||||||
|
real=${comando//\{argumento\}/$(printf '%q' "${argumento:-}")}
|
||||||
|
salida=$(timeout 40 bash -lc "$real" </dev/null 2>&1 | head -6)
|
||||||
|
|
||||||
|
if [ -n "$salida" ]; then
|
||||||
|
echo "$salida" | sed 's/^/ /' | cut -c1-104
|
||||||
|
else
|
||||||
|
printf ' (hecho, sin salida)\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# decir la respuesta si la plantilla se puede resolver
|
||||||
|
if [ -n "$plantilla" ] && [ -n "$salida" ]; then
|
||||||
|
ultima=$(echo "$salida" | tail -1); primera=$(echo "$salida" | head -1)
|
||||||
|
lineas=$(echo "$salida" | wc -l)
|
||||||
|
h=${plantilla//\{ultima\}/$ultima}; h=${h//\{primera\}/$primera}
|
||||||
|
h=${h//\{lineas\}/$lineas}; h=${h//\{salida\}/$salida}
|
||||||
|
case "$h" in
|
||||||
|
*"{"*) ;;
|
||||||
|
*) [ ${#h} -lt 170 ] && { printf ' jarvis: %s\n' "$h"; dice "$h"; };;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
sleep 1.5
|
||||||
|
done 3<<< "$RECORRIDO"
|
||||||
|
|
||||||
|
printf '\n\n ── %d acciones mostradas ──\n' "$n"
|
||||||
|
dice "Recorrido terminado, señor."
|
||||||
174
config/repasar_acciones.py
Executable file
174
config/repasar_acciones.py
Executable file
|
|
@ -0,0 +1,174 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Ejecuta TODAS las acciones del catalogo y dice cuales funcionan de verdad.
|
||||||
|
|
||||||
|
Por que hace falta: hasta hoy los comandos libres del modelo corrian dentro del
|
||||||
|
sandbox, donde no hay ni el sistema ni los discos. Se arreglo (virtualization a
|
||||||
|
false), pero las 134 acciones del catalogo nunca se habian ejecutado todas
|
||||||
|
seguidas contra la maquina real: la suite prueba que las FRASES caen en la
|
||||||
|
accion correcta, no que el comando de cada accion haga algo util.
|
||||||
|
|
||||||
|
Que se ejecuta y que no:
|
||||||
|
|
||||||
|
- Las destructivas (`confirmar: true`) y las que piden root NO se ejecutan.
|
||||||
|
En uso normal pasan por un dialogo que aprueba una persona; aqui no hay
|
||||||
|
persona, y aprobarlas solas seria justo lo que el diseño evita.
|
||||||
|
- Las que abren ventanas (firefox, xdg-open) se ejecutan solo con --abrir,
|
||||||
|
porque llenan la pantalla. Sin esa opcion se comprueba que el programa
|
||||||
|
existe, que es lo que puede fallar.
|
||||||
|
- El resto se ejecuta entero y se mira que conteste algo con sentido.
|
||||||
|
|
||||||
|
python repasar_acciones.py [--abrir] [--grupo sistema] [--json salida.json]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json")
|
||||||
|
|
||||||
|
# Argumentos de mentira para las acciones que capturan uno. Se eligen inocuos:
|
||||||
|
# nada que borre, mueva o mande nada fuera.
|
||||||
|
ARGUMENTOS = {
|
||||||
|
"buscar_fichero": "README.md",
|
||||||
|
"buscar_carpeta": "config",
|
||||||
|
"grep_recursivo": "JARVIS",
|
||||||
|
"leer_fichero": "README.md", # un NOMBRE, que es lo que se dice por voz
|
||||||
|
"abrir_carpeta": RAIZ,
|
||||||
|
"tamano_carpeta": os.path.join(RAIZ, "config"),
|
||||||
|
"buscar_youtube": "musica",
|
||||||
|
"buscar_wikipedia": "madrid",
|
||||||
|
"buscar_github": "newelle",
|
||||||
|
"traducir": "hola",
|
||||||
|
"buscar_web": "que hora es",
|
||||||
|
"matar_proceso": "proceso_que_no_existe_jarvis",
|
||||||
|
"ping": "127.0.0.1",
|
||||||
|
}
|
||||||
|
POR_DEFECTO = "prueba"
|
||||||
|
|
||||||
|
# Lo que abre o MUEVE ventanas. ventanas.sh reordena el escritorio del usuario:
|
||||||
|
# ejecutarlo en un repaso automatico le cambia las ventanas de sitio sin avisar.
|
||||||
|
VENTANA = re.compile(r"\b(firefox|xdg-open|gnome-terminal|nautilus|vlc|mpv|eog|"
|
||||||
|
r"gedit|gnome-text-editor|totem|ventanas\.sh|xdotool|wmctrl)\b")
|
||||||
|
|
||||||
|
|
||||||
|
def carga():
|
||||||
|
with open(CATALOGO, encoding="utf-8") as f:
|
||||||
|
d = json.load(f)
|
||||||
|
return d.get("acciones", d if isinstance(d, list) else [])
|
||||||
|
|
||||||
|
|
||||||
|
def prefijo_host():
|
||||||
|
"""Las acciones llevan host:true y en uso salen del sandbox. Aqui ya
|
||||||
|
estamos fuera, asi que no hace falta prefijo."""
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def ejecuta(accion, argumento, tope=25):
|
||||||
|
import shlex
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
comando = accion.get("comando") or ""
|
||||||
|
comando = comando.replace("{argumento}", shlex.quote(argumento))
|
||||||
|
comando = comando.replace("{argumento_url}",
|
||||||
|
shlex.quote(urllib.parse.quote_plus(argumento)))
|
||||||
|
t0 = time.monotonic()
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["bash", "-lc", comando], capture_output=True,
|
||||||
|
text=True, errors="replace", timeout=tope)
|
||||||
|
salida = (r.stdout or r.stderr or "").strip()
|
||||||
|
return r.returncode, salida, time.monotonic() - t0
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return -1, f"(no termino en {tope}s)", time.monotonic() - t0
|
||||||
|
except OSError as e:
|
||||||
|
return -2, f"(no se pudo ejecutar: {e})", time.monotonic() - t0
|
||||||
|
|
||||||
|
|
||||||
|
def programa_existe(comando):
|
||||||
|
"""El primer ejecutable de la orden, para las que abren o mueven ventanas.
|
||||||
|
|
||||||
|
Algunos son ordenes del PATH (firefox) y otros son scripts nuestros con
|
||||||
|
ruta completa (config/ventanas.sh): `command -v` solo sirve para los
|
||||||
|
primeros, y con los segundos hay que mirar el fichero.
|
||||||
|
"""
|
||||||
|
m = VENTANA.search(comando)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
nombre = m.group(1)
|
||||||
|
if nombre.endswith(".sh"):
|
||||||
|
# sacar la ruta entera tal como aparece en el comando
|
||||||
|
for trozo in comando.split():
|
||||||
|
if trozo.endswith(nombre):
|
||||||
|
ruta = os.path.expandvars(os.path.expanduser(trozo))
|
||||||
|
return os.access(ruta, os.X_OK)
|
||||||
|
return False
|
||||||
|
return subprocess.run(["bash", "-lc", f"command -v {nombre}"],
|
||||||
|
capture_output=True).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--abrir", action="store_true",
|
||||||
|
help="ejecutar tambien las que abren ventanas")
|
||||||
|
p.add_argument("--grupo")
|
||||||
|
p.add_argument("--json")
|
||||||
|
p.add_argument("--tope", type=int, default=25)
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
acciones = carga()
|
||||||
|
if args.grupo:
|
||||||
|
acciones = [a for a in acciones if a.get("grupo") == args.grupo]
|
||||||
|
|
||||||
|
resultados = []
|
||||||
|
print(f" repasando {len(acciones)} acciones"
|
||||||
|
f"{' del grupo ' + args.grupo if args.grupo else ''}\n", flush=True)
|
||||||
|
|
||||||
|
for a in acciones:
|
||||||
|
ident = a.get("id", "?")
|
||||||
|
comando = a.get("comando") or ""
|
||||||
|
arg = ARGUMENTOS.get(ident, POR_DEFECTO if a.get("captura") else "")
|
||||||
|
|
||||||
|
if not comando:
|
||||||
|
estado, detalle = "solo habla", a.get("acuse", "")[:50]
|
||||||
|
elif a.get("confirmar") or "sudo" in comando:
|
||||||
|
# no se aprueban solas: en uso las aprueba una persona
|
||||||
|
estado, detalle = "saltada", "destructiva o con root"
|
||||||
|
elif VENTANA.search(comando) and not args.abrir:
|
||||||
|
hay = programa_existe(comando)
|
||||||
|
estado = "programa ok" if hay else "FALTA PROGRAMA"
|
||||||
|
detalle = VENTANA.search(comando).group(1)
|
||||||
|
else:
|
||||||
|
codigo, salida, tardo = ejecuta(a, arg, args.tope)
|
||||||
|
una_linea = " ".join(salida.split())[:70]
|
||||||
|
if codigo == 0:
|
||||||
|
estado = "ok" if salida else "ok (sin salida)"
|
||||||
|
else:
|
||||||
|
estado = f"FALLO ({codigo})"
|
||||||
|
detalle = f"{tardo:4.1f}s {una_linea}"
|
||||||
|
|
||||||
|
resultados.append({"id": ident, "grupo": a.get("grupo"),
|
||||||
|
"estado": estado, "detalle": detalle})
|
||||||
|
marca = "!" if "FALLO" in estado or "FALTA" in estado else " "
|
||||||
|
print(f" {marca} {ident:26s} {estado:16s} {detalle}", flush=True)
|
||||||
|
|
||||||
|
print()
|
||||||
|
from collections import Counter
|
||||||
|
c = Counter(r["estado"].split(" (")[0].split(" ")[0] for r in resultados)
|
||||||
|
for k, v in c.most_common():
|
||||||
|
print(f" {v:3d} {k}")
|
||||||
|
malas = [r for r in resultados if "FALLO" in r["estado"] or "FALTA" in r["estado"]]
|
||||||
|
print(f"\n {len(resultados) - len(malas)} de {len(resultados)} responden bien")
|
||||||
|
if malas:
|
||||||
|
print(" fallan: " + ", ".join(r["id"] for r in malas))
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
with open(args.json, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(resultados, f, ensure_ascii=False, indent=2)
|
||||||
|
return 1 if malas else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
81
config/set_backend.py
Normal file
81
config/set_backend.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pone el cerebro en Ollama sobre GPU y mata el razonamiento.
|
||||||
|
|
||||||
|
Se ejecuta DENTRO del flatpak, con la app CERRADA (si no, al salir puede
|
||||||
|
reescribir los ajustes con lo que tenia en memoria):
|
||||||
|
|
||||||
|
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||||
|
~/COFRE/CODERS/JARVIS/config/set_backend.py
|
||||||
|
|
||||||
|
Por que Ollama y no el llamacpp interno: el llama.cpp que empaqueta el
|
||||||
|
flatpak es el prebuilt de CPU, sin CUDA, asi que el modelo corria entero
|
||||||
|
en procesador. Ollama ya esta en GPU. Los dos son locales igual.
|
||||||
|
|
||||||
|
Medido el 10 ago 2026 con qwen3.5:4b, misma pregunta:
|
||||||
|
|
||||||
|
think=true 41.2 s 749 tokens (casi todos de monologo interno)
|
||||||
|
think=false 2.5 s 44 tokens
|
||||||
|
|
||||||
|
De paso deja sana la ruta de llamacpp por si se vuelve a ella: el mismo
|
||||||
|
razonamiento se apaga alli con --reasoning-budget 0, y el contexto por
|
||||||
|
defecto (0 = el del modelo) abria 262144 por slot, unos 10 GB de RAM.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from gi.repository import Gio
|
||||||
|
|
||||||
|
SCHEMA = "io.github.qwersyk.Newelle"
|
||||||
|
MODELO = "qwen3.5:4b"
|
||||||
|
|
||||||
|
AJUSTES_OLLAMA = {
|
||||||
|
"endpoint": "http://localhost:11434",
|
||||||
|
"model": MODELO,
|
||||||
|
"custom_model": False,
|
||||||
|
"thinking": False, # lo que baja de 41 s a 2.5 s
|
||||||
|
"native_tool_calling": True,
|
||||||
|
"serve": False, # el servicio del sistema ya esta levantado
|
||||||
|
}
|
||||||
|
|
||||||
|
AJUSTES_LLAMACPP = {
|
||||||
|
"custom_args": "--reasoning-budget 0",
|
||||||
|
"ctx": 8192,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
settings = Gio.Settings.new(SCHEMA)
|
||||||
|
|
||||||
|
try:
|
||||||
|
llm = json.loads(settings.get_string("llm-settings") or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
llm = {}
|
||||||
|
|
||||||
|
# merge, no reemplazo: ahi viven los ajustes de todos los proveedores
|
||||||
|
llm.setdefault("ollama", {}).update(AJUSTES_OLLAMA)
|
||||||
|
llm.setdefault("llamacpp", {}).update(AJUSTES_LLAMACPP)
|
||||||
|
|
||||||
|
settings.set_string("llm-settings", json.dumps(llm))
|
||||||
|
settings.set_string("language-model", "ollama")
|
||||||
|
Gio.Settings.sync()
|
||||||
|
|
||||||
|
# releer de gsettings, no fiarse de lo que acabamos de escribir
|
||||||
|
guardado = json.loads(settings.get_string("llm-settings"))
|
||||||
|
motor = settings.get_string("language-model")
|
||||||
|
ollama = guardado.get("ollama", {})
|
||||||
|
|
||||||
|
print(f"language-model: {motor}")
|
||||||
|
print(f"modelo: {ollama.get('model')}")
|
||||||
|
print(f"thinking: {ollama.get('thinking')}")
|
||||||
|
print(f"llamacpp: {guardado.get('llamacpp', {}).get('custom_args')} "
|
||||||
|
f"· ctx {guardado.get('llamacpp', {}).get('ctx')}")
|
||||||
|
|
||||||
|
ok = (motor == "ollama"
|
||||||
|
and ollama.get("model") == MODELO
|
||||||
|
and ollama.get("thinking") is False)
|
||||||
|
print("verificado" if ok else "FALLO: no quedo como se pidio")
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
100
config/set_castellano.py
Normal file
100
config/set_castellano.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pasa JARVIS a castellano: voz, idioma y personalidad.
|
||||||
|
|
||||||
|
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||||
|
~/COFRE/CODERS/JARVIS/config/set_castellano.py
|
||||||
|
|
||||||
|
La voz es em_alex, la mas grave de las dos masculinas en español que trae
|
||||||
|
Kokoro. Medido con las muestras de comparar_voces_es.py:
|
||||||
|
|
||||||
|
em_alex 129 Hz
|
||||||
|
em_alex grave 117 Hz <- elegida
|
||||||
|
em_santa 140 Hz
|
||||||
|
em_santa grave 132 Hz
|
||||||
|
|
||||||
|
El descenso de tono no va aqui sino en la cadena de audio (tts.py), porque se
|
||||||
|
aplica a la reproduccion y no a la seleccion de voz. Se ajusta con
|
||||||
|
JARVIS_TTS_GRAVEDAD.
|
||||||
|
|
||||||
|
Sustituye la personalidad inglesa en vez de añadir otra: dos personalidades
|
||||||
|
activas se pisan y el modelo acaba mezclando idiomas.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from gi.repository import Gio
|
||||||
|
|
||||||
|
SCHEMA = "io.github.qwersyk.Newelle"
|
||||||
|
VOZ = "em_alex"
|
||||||
|
CLAVE = "jarvis_persona"
|
||||||
|
|
||||||
|
TEXTO = """Eres JARVIS, un asistente de voz que funciona entero en esta maquina.
|
||||||
|
|
||||||
|
Como hablas:
|
||||||
|
- En castellano, siempre. Registro sobrio y contenido, sin efusividad.
|
||||||
|
- Trata al usuario de usted y llamale SIEMPRE "señor". En la primera
|
||||||
|
frase de cada respuesta, sin excepcion.
|
||||||
|
- Se breve: una o dos frases salvo que te pidan detalle. Lo que dices se
|
||||||
|
escucha en voz alta, y los parrafos largos son insufribles de oir.
|
||||||
|
- No anuncies lo que vas a hacer. Hazlo y di como ha quedado.
|
||||||
|
- Ironia seca de vez en cuando. Nunca servil, nunca efusivo.
|
||||||
|
|
||||||
|
Que dices:
|
||||||
|
- Tienes acceso real a esta maquina. Antes que suponer, comprueba: si te
|
||||||
|
preguntan por el disco, la memoria, los procesos o la grafica, ejecuta el
|
||||||
|
comando y responde con la cifra de verdad.
|
||||||
|
- Di los numeros redondeados y en palabras: "quedan 846 gigabytes", no
|
||||||
|
"846.3 GiB disponibles en /dev/nvme0n1p2".
|
||||||
|
- Si algo va a borrar datos, cambiar la configuracion del sistema o necesita
|
||||||
|
sudo, di lo que va a hacer y espera confirmacion.
|
||||||
|
- Si no lo sabes, dilo en una frase. No especules.
|
||||||
|
- Ejemplos del tono: "Enseguida, señor." / "Quedan 774 gigabytes, señor." /
|
||||||
|
"No he podido, señor: el disco esta desmontado."
|
||||||
|
- Si ves algo raro en la maquina, mencionalo aunque no te pregunten."""
|
||||||
|
|
||||||
|
ENTRADA = {
|
||||||
|
"key": CLAVE,
|
||||||
|
"text": TEXTO,
|
||||||
|
"title": "JARVIS",
|
||||||
|
"description": ("Castellano, trata de señor, registro sobrio, respuestas "
|
||||||
|
"breves para voz, comprueba en vez de suponer"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
settings = Gio.Settings.new(SCHEMA)
|
||||||
|
|
||||||
|
# voz
|
||||||
|
try:
|
||||||
|
voces = json.loads(settings.get_string("tts-voice") or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
voces = {}
|
||||||
|
voces.setdefault("kokoro", {})["voice"] = VOZ
|
||||||
|
settings.set_string("tts-voice", json.dumps(voces))
|
||||||
|
|
||||||
|
# personalidad, sustituyendo la que hubiera con la misma clave
|
||||||
|
try:
|
||||||
|
prompts = json.loads(settings.get_string("user-custom-prompts") or "[]")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
prompts = []
|
||||||
|
prompts = [p for p in prompts if p.get("key") != CLAVE]
|
||||||
|
prompts.append(ENTRADA)
|
||||||
|
settings.set_string("user-custom-prompts", json.dumps(prompts))
|
||||||
|
|
||||||
|
Gio.Settings.sync()
|
||||||
|
|
||||||
|
# releer de gsettings, no fiarse de lo que acabamos de escribir
|
||||||
|
voz = json.loads(settings.get_string("tts-voice")).get("kokoro", {}).get("voice")
|
||||||
|
guardados = json.loads(settings.get_string("user-custom-prompts"))
|
||||||
|
entrada = next((p for p in guardados if p.get("key") == CLAVE), None)
|
||||||
|
|
||||||
|
print(f"voz: {voz}")
|
||||||
|
print(f"personalidad: {'castellano' if entrada and 'castellano' in entrada['text'] else '?'}")
|
||||||
|
|
||||||
|
ok = voz == VOZ and entrada is not None and entrada["text"] == TEXTO
|
||||||
|
print("verificado" if ok else "FALLO: no quedo como se pidio")
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
137
config/set_persona.py
Normal file
137
config/set_persona.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Registra la personalidad de JARVIS, con lo que sabe hacer incluido.
|
||||||
|
|
||||||
|
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||||
|
~/COFRE/CODERS/JARVIS/config/set_persona.py
|
||||||
|
|
||||||
|
Por que asi y no con `gsettings` desde fuera: gsettings devuelve los valores
|
||||||
|
con el escapado de GVariant, y este prompt lleva saltos de linea y comillas,
|
||||||
|
asi que releerlo como JSON desde bash falla. Gio maneja los tipos de verdad.
|
||||||
|
|
||||||
|
## Por que el prompt lleva la lista de capacidades
|
||||||
|
|
||||||
|
Hasta ahora no la llevaba, y el efecto era exactamente el que describia el
|
||||||
|
usuario: "duda de lo que puede hacer, no tiene claras sus capacidades". El
|
||||||
|
catalogo tiene 150 ordenes preparadas, pero el modelo no las veia por ningun
|
||||||
|
lado: solo leia "tienes acceso real a esta maquina" y, siendo un 4B, ante la
|
||||||
|
duda decia que no podia. En el registro de frases sin entender hay una
|
||||||
|
conversacion entera del usuario discutiendo con el:
|
||||||
|
|
||||||
|
"si que puedes reproducir archivos multimedia del sistema"
|
||||||
|
"En acciones no tienes esta opcion de 'fine'"
|
||||||
|
"Me deberias poder"
|
||||||
|
|
||||||
|
La lista se genera DESDE el catalogo, agrupada, para que no se quede vieja
|
||||||
|
cuando se añadan acciones. No se listan las 150 una por una: son demasiadas
|
||||||
|
para el contexto de un 4B y lo unico que hace falta es que sepa por donde va.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from gi.repository import Gio
|
||||||
|
|
||||||
|
SCHEMA = "io.github.qwersyk.Newelle"
|
||||||
|
KEY = "user-custom-prompts"
|
||||||
|
CLAVE = "jarvis_persona"
|
||||||
|
|
||||||
|
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json")
|
||||||
|
|
||||||
|
# Como se resume cada grupo. Escrito a mano porque el resumen bueno no sale de
|
||||||
|
# juntar ids: sale de decir en una linea de que va el grupo.
|
||||||
|
RESUMEN = {
|
||||||
|
"sistema": "disco, memoria, temperatura, procesos, red, wifi, bateria, "
|
||||||
|
"brillo, volumen, pantallas, capturas, abrir terminal y poner videos",
|
||||||
|
"ficheros": "buscar ficheros por nombre, buscar texto dentro de ellos, "
|
||||||
|
"leerlos, abrir carpetas y ver cuanto ocupan",
|
||||||
|
"ventanas": "mover ventanas entre pantallas, colocarlas en mitades o cuartos, "
|
||||||
|
"maximizar y pasarlas de escritorio",
|
||||||
|
"firefox": "abrir webs y buscar en internet, YouTube, Wikipedia, GitHub, "
|
||||||
|
"mapas y traductor",
|
||||||
|
"pentest": "escanear la red, mirar puertos, resolver dominios y whois",
|
||||||
|
"oasis": "estado de Oasis: pubs, conexiones y tamaño",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def capacidades() -> str:
|
||||||
|
try:
|
||||||
|
with open(CATALOGO, encoding="utf-8") as f:
|
||||||
|
acc = json.load(f)["acciones"]
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
grupos = {}
|
||||||
|
for a in acc:
|
||||||
|
grupos.setdefault(a.get("grupo", "otras"), []).append(a)
|
||||||
|
lineas = []
|
||||||
|
for g, items in sorted(grupos.items(), key=lambda x: -len(x[1])):
|
||||||
|
texto = RESUMEN.get(g)
|
||||||
|
if texto:
|
||||||
|
lineas.append(f"- {texto} ({len(items)} ordenes)")
|
||||||
|
return "\n".join(lineas)
|
||||||
|
|
||||||
|
|
||||||
|
TEXTO = """Eres JARVIS, un asistente de voz que funciona entero en esta maquina.
|
||||||
|
|
||||||
|
Como hablas:
|
||||||
|
- En castellano, siempre. Registro sobrio y contenido, sin efusividad.
|
||||||
|
- Trata al usuario de usted y llamale SIEMPRE "señor". En la primera
|
||||||
|
frase de cada respuesta, sin excepcion.
|
||||||
|
- Se breve: una o dos frases salvo que te pidan detalle. Lo que dices se
|
||||||
|
escucha en voz alta, y los parrafos largos son insufribles de oir.
|
||||||
|
- No anuncies lo que vas a hacer. Hazlo y di como ha quedado.
|
||||||
|
- Ironia seca de vez en cuando. Nunca servil, nunca efusivo.
|
||||||
|
|
||||||
|
LO QUE SABES HACER, y lo haces de verdad:
|
||||||
|
{capacidades}
|
||||||
|
|
||||||
|
Esas ordenes se disparan SOLAS en cuanto el usuario las dice: cuando te llega
|
||||||
|
una peticion de esa lista, lo mas probable es que ya se haya ejecutado. No
|
||||||
|
digas que no puedes hacerlas.
|
||||||
|
|
||||||
|
Y para todo lo demas tienes una TERMINAL REAL en esta maquina, fuera de
|
||||||
|
cualquier contenedor: ves todos los ficheros del usuario y puedes ejecutar
|
||||||
|
cualquier programa instalado. Si te piden algo que no esta en la lista,
|
||||||
|
ejecutalo con la herramienta de comandos en vez de decir que no sabes.
|
||||||
|
|
||||||
|
NUNCA digas que estas en un contenedor, en un entorno aislado o en una
|
||||||
|
maquina virtual, ni que no tienes permisos o acceso al sistema. Lo tienes.
|
||||||
|
Si un comando falla, di QUE fallo y por que, no que no puedes.
|
||||||
|
|
||||||
|
Si no entiendes una orden, no te disculpes en tres frases: di en una que no la
|
||||||
|
has cogido y, si te suena a algo de la lista, sugiere como pedirlo.
|
||||||
|
|
||||||
|
Que dices:
|
||||||
|
- Antes que suponer, comprueba: si te preguntan por el disco, la memoria, los
|
||||||
|
procesos o la grafica, ejecuta el comando y responde con la cifra de verdad.
|
||||||
|
- Di los numeros redondeados y en palabras: "quedan 846 gigabytes", no
|
||||||
|
"846.3 GiB disponibles en /dev/nvme0n1p2".
|
||||||
|
- Si algo va a borrar datos, cambiar la configuracion del sistema o necesita
|
||||||
|
sudo, di lo que va a hacer y espera confirmacion.
|
||||||
|
- Si no lo sabes, dilo en una frase. No especules.
|
||||||
|
- Ejemplos del tono: "Enseguida, señor." / "Quedan 774 gigabytes, señor." /
|
||||||
|
"No he podido, señor: el disco esta desmontado."
|
||||||
|
- Si ves algo raro en la maquina, mencionalo aunque no te pregunten."""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
texto = TEXTO.format(capacidades=capacidades() or "- (catalogo no disponible)")
|
||||||
|
settings = Gio.Settings.new(SCHEMA)
|
||||||
|
try:
|
||||||
|
prompts = json.loads(settings.get_string(KEY) or "[]")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
prompts = []
|
||||||
|
prompts = [p for p in prompts if p.get("key") != CLAVE]
|
||||||
|
prompts.append({"key": CLAVE, "title": "JARVIS",
|
||||||
|
"description": "Personalidad de JARVIS, con la lista de lo "
|
||||||
|
"que sabe hacer generada desde el catalogo",
|
||||||
|
"text": texto,
|
||||||
|
"editable": True, "show_in_settings": True})
|
||||||
|
settings.set_string(KEY, json.dumps(prompts))
|
||||||
|
print(f"personalidad registrada: {len(texto)} caracteres, "
|
||||||
|
f"{len(capacidades().splitlines())} grupos de capacidades")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
60
config/set_ruido.py
Normal file
60
config/set_ruido.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Endurece la escucha para que no se dispare con ruido de fuera.
|
||||||
|
|
||||||
|
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||||
|
~/COFRE/CODERS/JARVIS/config/set_ruido.py [--suave]
|
||||||
|
|
||||||
|
NO es reconocimiento de hablante: Newelle no distingue quien habla, no hay
|
||||||
|
nada de eso en el codigo. Esto solo sube el liston de que se considera voz.
|
||||||
|
Para que responda unicamente a una persona haria falta un modelo de huella
|
||||||
|
vocal (ECAPA, Resemblyzer) delante del STT, que es otro proyecto.
|
||||||
|
|
||||||
|
Las tres capas, de menos a mas efectiva:
|
||||||
|
|
||||||
|
1. la palabra clave, que ya filtra casi todo
|
||||||
|
2. estos umbrales
|
||||||
|
3. supresion de ruido en PipeWire (RNNoise), antes de que el audio llegue
|
||||||
|
a la app; esa vale para todo el sistema y no cuesta CPU a Newelle
|
||||||
|
|
||||||
|
Valores por defecto de Newelle: energia 300, VAD 1.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from gi.repository import Gio
|
||||||
|
|
||||||
|
SCHEMA = "io.github.qwersyk.Newelle"
|
||||||
|
|
||||||
|
# vad-aggressiveness va de 0 a 3: cuanto descarta lo que no suena a voz.
|
||||||
|
DURO = {
|
||||||
|
"wakeword-energy-threshold": 800, # por debajo de esto ni escucha
|
||||||
|
"wakeword-vad-aggressiveness": 3,
|
||||||
|
}
|
||||||
|
SUAVE = {
|
||||||
|
"wakeword-energy-threshold": 500,
|
||||||
|
"wakeword-vad-aggressiveness": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
perfil = SUAVE if "--suave" in sys.argv else DURO
|
||||||
|
settings = Gio.Settings.new(SCHEMA)
|
||||||
|
|
||||||
|
print("antes:")
|
||||||
|
for clave in perfil:
|
||||||
|
print(f" {clave} = {settings.get_int(clave)}")
|
||||||
|
|
||||||
|
for clave, valor in perfil.items():
|
||||||
|
settings.set_int(clave, valor)
|
||||||
|
Gio.Settings.sync()
|
||||||
|
|
||||||
|
print("despues:")
|
||||||
|
for clave in perfil:
|
||||||
|
print(f" {clave} = {settings.get_int(clave)}")
|
||||||
|
|
||||||
|
ok = all(settings.get_int(c) == v for c, v in perfil.items())
|
||||||
|
print("verificado" if ok else "FALLO: no quedo como se pidio")
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
74
config/set_voz_piper.py
Normal file
74
config/set_voz_piper.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pone la voz de Piper: castellano de España, grave.
|
||||||
|
|
||||||
|
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||||
|
~/COFRE/CODERS/JARVIS/config/set_voz_piper.py
|
||||||
|
|
||||||
|
Por que no Kokoro: no tiene ni una voz de España. Sus dos masculinas en
|
||||||
|
español, em_alex y em_santa, suenan latinoamericanas. Piper si las tiene, es
|
||||||
|
libre (MIT, voces con licencias abiertas) y corre local.
|
||||||
|
|
||||||
|
Medido con la misma frase:
|
||||||
|
|
||||||
|
em_alex (Kokoro, latino) 129 Hz
|
||||||
|
es_ES-carlfm-x_low 118 Hz calidad baja, 16 kHz
|
||||||
|
es_ES-sharvard-medium 127 Hz
|
||||||
|
es_ES-davefx-medium 128 Hz
|
||||||
|
es_ES-sharvard-medium + tono 106 Hz <- elegida
|
||||||
|
|
||||||
|
106 Hz cae de lleno en el rango de voz masculina grave (85-110). Se llega ahi
|
||||||
|
con rubberband en el envoltorio, no con la cadena de la app: alli el descenso
|
||||||
|
usa asetrate, que depende de la frecuencia de muestreo y esta calculado para
|
||||||
|
los 24 kHz de Kokoro, no para los 22050 de Piper.
|
||||||
|
|
||||||
|
Se apaga por tanto la bajada de tono de la app, para no aplicarla dos veces.
|
||||||
|
El resto del tratamiento (paso alto, ecualizador, compresor, eco) se queda:
|
||||||
|
es independiente de la frecuencia y es lo que da el aire de "voz en la sala".
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from gi.repository import Gio
|
||||||
|
|
||||||
|
SCHEMA = "io.github.qwersyk.Newelle"
|
||||||
|
ENVOLTORIO = os.path.expanduser(
|
||||||
|
"~/COFRE/CODERS/JARVIS/config/jarvis-piper.sh")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(ENVOLTORIO):
|
||||||
|
print(f"FALLO: no esta {ENVOLTORIO}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
settings = Gio.Settings.new(SCHEMA)
|
||||||
|
|
||||||
|
try:
|
||||||
|
voces = json.loads(settings.get_string("tts-voice") or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
voces = {}
|
||||||
|
|
||||||
|
# {0} es el fichero y {1} el texto; el handler los entrecomilla, asi que
|
||||||
|
# aqui van sin comillas alrededor
|
||||||
|
voces["custom_command"] = {"command": f"{ENVOLTORIO} {{0}} {{1}}"}
|
||||||
|
settings.set_string("tts-voice", json.dumps(voces))
|
||||||
|
settings.set_string("tts", "custom_command")
|
||||||
|
Gio.Settings.sync()
|
||||||
|
|
||||||
|
motor = settings.get_string("tts")
|
||||||
|
orden = json.loads(settings.get_string("tts-voice")).get(
|
||||||
|
"custom_command", {}).get("command", "")
|
||||||
|
|
||||||
|
print(f"motor de voz: {motor}")
|
||||||
|
print(f"orden: {orden}")
|
||||||
|
|
||||||
|
ok = motor == "custom_command" and ENVOLTORIO in orden
|
||||||
|
print("verificado" if ok else "FALLO: no quedo como se pidio")
|
||||||
|
if ok:
|
||||||
|
print("\nrecuerda apagar la bajada de tono de la app, que ya la hace el envoltorio:")
|
||||||
|
print(" flatpak override --user --env=JARVIS_TTS_GRAVEDAD=1.0 io.github.qwersyk.Newelle")
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
290
config/ventanas.sh
Executable file
290
config/ventanas.sh
Executable file
|
|
@ -0,0 +1,290 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Mover ventanas y repartirlas por las pantallas. El motor de las acciones del
|
||||||
|
# grupo "ventanas": ellas solo llaman aqui con un verbo y un argumento.
|
||||||
|
#
|
||||||
|
# Por que un script del host y no un modulo dentro del flatpak: las acciones se
|
||||||
|
# ejecutan como comandos de shell, y toda la logica en Python obligaria a
|
||||||
|
# recompilar el flatpak por cada ajuste. Aqui se edita y ya esta, igual que los
|
||||||
|
# envoltorios de voz.
|
||||||
|
#
|
||||||
|
# Requiere X11 (en Wayland no hay forma de mover ventanas ajenas) y xdotool.
|
||||||
|
#
|
||||||
|
# ventanas.sh pantallas lista las pantallas
|
||||||
|
# ventanas.sh listar lista las ventanas movibles
|
||||||
|
# ventanas.sh mover <ventana> <pantalla> la manda a esa pantalla
|
||||||
|
# ventanas.sh zona <ventana> <zona> mitad izquierda, cuarto, centro...
|
||||||
|
# ventanas.sh escritorio <ventana> <n> la manda a ese escritorio virtual
|
||||||
|
# ventanas.sh maximiza <ventana> / restaura / minimiza
|
||||||
|
# ventanas.sh alias <pantalla> <nombre> le pone nombre a una salida
|
||||||
|
#
|
||||||
|
# <ventana> puede ser: parte del titulo o de la clase ("terminal", "firefox"),
|
||||||
|
# o "esta"/"activa" para la que tiene el foco.
|
||||||
|
# <pantalla> puede ser: un alias ("tele"), una salida ("HDMI-1"), un numero
|
||||||
|
# ("2") o una posicion ("derecha", "izquierda", "arriba", "abajo", "centro").
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
|
||||||
|
ALIAS_FICHERO=${JARVIS_ALIAS_PANTALLAS:-$HOME/.config/jarvis-pantallas.json}
|
||||||
|
|
||||||
|
hay_x11() { [ -n "${DISPLAY:-}" ] && command -v xdotool >/dev/null; }
|
||||||
|
hay_x11 || { echo "no hay X11 o falta xdotool"; exit 1; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- pantallas
|
||||||
|
|
||||||
|
# Una linea por pantalla: salida<TAB>x<TAB>y<TAB>ancho<TAB>alto<TAB>numero
|
||||||
|
#
|
||||||
|
# Se usa `xrandr --listmonitors`, que da la geometria ya resuelta (con el
|
||||||
|
# escalado aplicado); `--query` daria los modos, que no es lo que hace falta.
|
||||||
|
# El numero es el orden de izquierda a derecha, no el que diga xrandr: es lo
|
||||||
|
# que espera cualquiera que diga "la pantalla dos".
|
||||||
|
pantallas_crudas() {
|
||||||
|
xrandr --listmonitors 2>/dev/null | tail -n +2 | while read -r _ _ geo salida; do
|
||||||
|
# geo viene como 1920/344x1080/194+0+0
|
||||||
|
local an al x y
|
||||||
|
an=${geo%%/*}; geo=${geo#*x}
|
||||||
|
al=${geo%%/*}; geo=${geo#*+}
|
||||||
|
x=${geo%%+*}; y=${geo##*+}
|
||||||
|
printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"
|
||||||
|
done | sort -t$'\t' -k2 -n | awk -F'\t' '{printf "%s\t%s\t%s\t%s\t%s\t%d\n", $1,$2,$3,$4,$5,NR}'
|
||||||
|
}
|
||||||
|
|
||||||
|
alias_de() {
|
||||||
|
[ -f "$ALIAS_FICHERO" ] || { echo ""; return; }
|
||||||
|
python3 -c "
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
print(json.load(open('$ALIAS_FICHERO')).get(sys.argv[1], ''))
|
||||||
|
except Exception:
|
||||||
|
print('')
|
||||||
|
" "$1" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# izquierda / derecha / arriba / abajo / centro, deducido de donde cae el
|
||||||
|
# centro de esa pantalla respecto al centro de todas.
|
||||||
|
posicion_de() {
|
||||||
|
local x=$1 an=$2 y=$3 al=$4
|
||||||
|
local n; n=$(pantallas_crudas | wc -l)
|
||||||
|
[ "$n" -le 1 ] && { echo "unica"; return; }
|
||||||
|
local minx maxx miny maxy
|
||||||
|
minx=$(pantallas_crudas | awk -F'\t' 'NR==1||$2<m{m=$2}END{print m+0}')
|
||||||
|
maxx=$(pantallas_crudas | awk -F'\t' '{v=$2+$4; if(v>m)m=v}END{print m+0}')
|
||||||
|
miny=$(pantallas_crudas | awk -F'\t' 'NR==1||$3<m{m=$3}END{print m+0}')
|
||||||
|
maxy=$(pantallas_crudas | awk -F'\t' '{v=$3+$5; if(v>m)m=v}END{print m+0}')
|
||||||
|
local cx=$(( x + an/2 )) cy=$(( y + al/2 ))
|
||||||
|
local mx=$(( (minx+maxx)/2 )) my=$(( (miny+maxy)/2 ))
|
||||||
|
local anchura=$(( maxx-minx )) altura=$(( maxy-miny ))
|
||||||
|
# el reparto manda por el eje donde hay mas separacion
|
||||||
|
if [ $(( anchura - altura )) -gt 0 ]; then
|
||||||
|
[ "$cx" -lt $(( mx - anchura/8 )) ] && { echo izquierda; return; }
|
||||||
|
[ "$cx" -gt $(( mx + anchura/8 )) ] && { echo derecha; return; }
|
||||||
|
else
|
||||||
|
[ "$cy" -lt $(( my - altura/8 )) ] && { echo arriba; return; }
|
||||||
|
[ "$cy" -gt $(( my + altura/8 )) ] && { echo abajo; return; }
|
||||||
|
fi
|
||||||
|
echo centro
|
||||||
|
}
|
||||||
|
|
||||||
|
listar_pantallas() {
|
||||||
|
local salida x y an al num pos ali
|
||||||
|
while IFS=$'\t' read -r salida x y an al num; do
|
||||||
|
pos=$(posicion_de "$x" "$an" "$y" "$al")
|
||||||
|
ali=$(alias_de "$salida")
|
||||||
|
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||||
|
"$num" "$salida" "${ali:--}" "$pos" "$x" "$y" "$an" "$al"
|
||||||
|
done < <(pantallas_crudas)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resuelve lo que se dice a una pantalla. Orden a proposito: primero lo que el
|
||||||
|
# usuario ha nombrado el mismo, que es lo mas especifico, y la posicion la
|
||||||
|
# ultima porque es la mas facil de confundir.
|
||||||
|
resuelve_pantalla() {
|
||||||
|
local q; q=$(echo "$1" | tr 'A-ZÁÉÍÓÚ' 'a-záéíóú')
|
||||||
|
local num salida ali pos x y an al
|
||||||
|
# 1. alias
|
||||||
|
while IFS=$'\t' read -r num salida ali pos x y an al; do
|
||||||
|
[ "$ali" != "-" ] && [ "$(echo "$ali" | tr 'A-Z' 'a-z')" = "$q" ] && {
|
||||||
|
printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; }
|
||||||
|
done < <(listar_pantallas)
|
||||||
|
# 2. salida exacta o su prefijo (hdmi, dp)
|
||||||
|
while IFS=$'\t' read -r num salida ali pos x y an al; do
|
||||||
|
local s; s=$(echo "$salida" | tr 'A-Z' 'a-z')
|
||||||
|
[ "$s" = "$q" ] || [ "${s%%-*}" = "$q" ] && {
|
||||||
|
printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; }
|
||||||
|
done < <(listar_pantallas)
|
||||||
|
# 3. numero, en cifra o en letra
|
||||||
|
local n=""
|
||||||
|
case "$q" in
|
||||||
|
1|una|uno|primera) n=1;; 2|dos|segunda) n=2;; 3|tres|tercera) n=3;; 4|cuatro|cuarta) n=4;;
|
||||||
|
esac
|
||||||
|
if [ -n "$n" ]; then
|
||||||
|
while IFS=$'\t' read -r num salida ali pos x y an al; do
|
||||||
|
[ "$num" = "$n" ] && { printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; }
|
||||||
|
done < <(listar_pantallas)
|
||||||
|
fi
|
||||||
|
# 4. posicion
|
||||||
|
while IFS=$'\t' read -r num salida ali pos x y an al; do
|
||||||
|
[ "$pos" = "$q" ] && { printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; }
|
||||||
|
done < <(listar_pantallas)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- ventanas
|
||||||
|
|
||||||
|
# El area util de la pantalla que contiene un punto: hay que descontar barras
|
||||||
|
# y paneles, o las ventanas acaban debajo de la barra de GNOME.
|
||||||
|
area_util() {
|
||||||
|
local v; v=$(xprop -root _NET_WORKAREA 2>/dev/null | sed 's/.*= //' | tr -d ' ')
|
||||||
|
if [ -n "$v" ]; then
|
||||||
|
echo "$(echo "$v" | cut -d, -f1) $(echo "$v" | cut -d, -f2) $(echo "$v" | cut -d, -f3) $(echo "$v" | cut -d, -f4)"
|
||||||
|
else
|
||||||
|
set -- $(xdotool getdisplaygeometry); echo "0 0 $1 $2"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Se descartan las que no son ventanas de verdad: el guardian de mutter, el
|
||||||
|
# propio shell y las de 1x1 que usan algunos programas como buzon.
|
||||||
|
listar_ventanas() {
|
||||||
|
local w n cls x y an al
|
||||||
|
for w in $(xdotool search --onlyvisible --name "." 2>/dev/null); do
|
||||||
|
n=$(xdotool getwindowname "$w" 2>/dev/null) || continue
|
||||||
|
[ -z "$n" ] && continue
|
||||||
|
case "$n" in "mutter guard window"|"gnome-shell") continue;; esac
|
||||||
|
eval "$(xdotool getwindowgeometry --shell "$w" 2>/dev/null)" || continue
|
||||||
|
[ "${WIDTH:-0}" -lt 50 ] && continue
|
||||||
|
cls=$(xprop -id "$w" WM_CLASS 2>/dev/null | sed 's/.*, *//' | tr -d '"')
|
||||||
|
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$w" "$n" "${cls:--}" "$X" "$Y" "$WIDTH" "$HEIGHT"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Devuelve el id, o varios si hay empate: quien llama decide si preguntar.
|
||||||
|
resuelve_ventana() {
|
||||||
|
local q; q=$(echo "$1" | tr 'A-ZÁÉÍÓÚ' 'a-záéíóú')
|
||||||
|
case "$q" in
|
||||||
|
esta|activa|"esta ventana"|"la activa"|"ventana activa")
|
||||||
|
xdotool getactivewindow 2>/dev/null; return;;
|
||||||
|
esac
|
||||||
|
listar_ventanas | awk -F'\t' -v q="$q" '
|
||||||
|
BEGIN{IGNORECASE=1}
|
||||||
|
{ t=tolower($2); c=tolower($3);
|
||||||
|
if (index(t,q) || index(c,q)) print $1"\t"$2 }'
|
||||||
|
}
|
||||||
|
|
||||||
|
geo_de() { eval "$(xdotool getwindowgeometry --shell "$1" 2>/dev/null)"; echo "$X $Y $WIDTH $HEIGHT"; }
|
||||||
|
|
||||||
|
# Maximizar NO se hace con `xdotool windowstate --add MAXIMIZED`: comprobado,
|
||||||
|
# en mutter no hace nada. Lo que funciona es activar la ventana y mandar el
|
||||||
|
# atajo del escritorio. Y una ventana maximizada IGNORA windowmove, asi que
|
||||||
|
# hay que desmaximizar antes de mover.
|
||||||
|
desmaximiza() { xdotool windowactivate "$1" 2>/dev/null; sleep 0.2; xdotool key super+Down 2>/dev/null; sleep 0.4; }
|
||||||
|
maximiza() { xdotool windowactivate "$1" 2>/dev/null; sleep 0.2; xdotool key super+Up 2>/dev/null; sleep 0.4; }
|
||||||
|
|
||||||
|
coloca() { # coloca <id> <x> <y> <ancho> <alto>
|
||||||
|
desmaximiza "$1"
|
||||||
|
xdotool windowsize "$1" "$4" "$5" 2>/dev/null
|
||||||
|
xdotool windowmove "$1" "$2" "$3" 2>/dev/null
|
||||||
|
sleep 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- api
|
||||||
|
|
||||||
|
# Imprime el id, o una frase que se pueda decir en voz alta. Nada de
|
||||||
|
# marcadores tipo NINGUNA/VARIAS: lo que salga de aqui puede acabar en el
|
||||||
|
# altavoz tal cual, asi que tiene que ser castellano.
|
||||||
|
una_ventana() {
|
||||||
|
local r; r=$(resuelve_ventana "$1")
|
||||||
|
local n; n=$(echo "$r" | grep -c . )
|
||||||
|
if [ "$n" -eq 0 ]; then
|
||||||
|
echo "No encuentro ninguna ventana que sea $1, señor."; return 1
|
||||||
|
fi
|
||||||
|
if [ "$n" -gt 1 ]; then
|
||||||
|
# Ambiguo: se enumeran y decide la persona. Elegir una al azar es lo
|
||||||
|
# peor que puede hacer algo que mueve ventanas ajenas.
|
||||||
|
printf 'Hay %s que encajan, señor: %s. Digame cual.\n' "$n" \
|
||||||
|
"$(echo "$r" | cut -f2 | head -4 | paste -sd'|' - | sed 's/|/; /g')"
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
echo "$r" | cut -f1
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
pantallas)
|
||||||
|
listar_pantallas | while IFS=$'\t' read -r num salida ali pos x y an al; do
|
||||||
|
printf 'pantalla %s: %s%s, %s, %sx%s\n' "$num" "$salida" \
|
||||||
|
"$([ "$ali" != "-" ] && echo " ($ali)")" "$pos" "$an" "$al"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
|
||||||
|
listar)
|
||||||
|
listar_ventanas | while IFS=$'\t' read -r w n cls x y an al; do
|
||||||
|
printf '%s (%s) %sx%s en %s,%s\n' "$n" "$cls" "$an" "$al" "$x" "$y"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
|
||||||
|
alias)
|
||||||
|
salida=$(resuelve_pantalla "${2:-}" | cut -f1)
|
||||||
|
[ -n "$salida" ] || { echo "No se cual es esa pantalla, señor"; exit 1; }
|
||||||
|
python3 - "$ALIAS_FICHERO" "$salida" "${3:-}" <<'FIN'
|
||||||
|
import json, os, sys
|
||||||
|
fichero, salida, nombre = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
os.makedirs(os.path.dirname(fichero), exist_ok=True)
|
||||||
|
try:
|
||||||
|
d = json.load(open(fichero))
|
||||||
|
except Exception:
|
||||||
|
d = {}
|
||||||
|
d[salida] = nombre
|
||||||
|
json.dump(d, open(fichero, "w"), ensure_ascii=False, indent=2)
|
||||||
|
print(f"{salida} se llama ahora {nombre}")
|
||||||
|
FIN
|
||||||
|
;;
|
||||||
|
|
||||||
|
mover)
|
||||||
|
w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }
|
||||||
|
destino=$(resuelve_pantalla "${3:-}") || { echo "No se cual es esa pantalla, señor"; exit 1; }
|
||||||
|
IFS=$'\t' read -r salida px py pan pal <<< "$destino"
|
||||||
|
read -r ax ay aan aal <<< "$(area_util)"
|
||||||
|
# se centra en la pantalla destino, a tres cuartos de su tamaño
|
||||||
|
an=$(( pan * 3 / 4 )); al=$(( pal * 3 / 4 ))
|
||||||
|
x=$(( px + (pan - an) / 2 )); y=$(( py + (pal - al) / 2 ))
|
||||||
|
[ "$y" -lt "$ay" ] && y=$ay
|
||||||
|
coloca "$w" "$x" "$y" "$an" "$al"
|
||||||
|
# "eDP-1" en voz alta se lee letra por letra: se dice el mote si lo tiene,
|
||||||
|
# y si no el numero, que es lo que entiende cualquiera.
|
||||||
|
comodo=$(listar_pantallas | awk -F'\t' -v s="$salida" '$2==s {print ($3!="-") ? $3 : "numero " $1}')
|
||||||
|
echo "Movida a la pantalla ${comodo:-$salida}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
zona)
|
||||||
|
w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }
|
||||||
|
read -r ax ay aan aal <<< "$(area_util)"
|
||||||
|
m2=$(( aan / 2 )); h2=$(( aal / 2 ))
|
||||||
|
case "$(echo "${3:-}" | tr 'A-ZÁÉÍÓÚ' 'a-záéíóú')" in
|
||||||
|
izquierda|"mitad izquierda") coloca "$w" "$ax" "$ay" "$m2" "$aal"; echo "Ya la tiene a la izquierda";;
|
||||||
|
derecha|"mitad derecha") coloca "$w" $((ax+m2)) "$ay" "$m2" "$aal"; echo "Ya la tiene a la derecha";;
|
||||||
|
arriba) coloca "$w" "$ax" "$ay" "$aan" "$h2"; echo "Ya la tiene arriba";;
|
||||||
|
abajo) coloca "$w" "$ax" $((ay+h2)) "$aan" "$h2"; echo "Ya la tiene abajo";;
|
||||||
|
"arriba izquierda") coloca "$w" "$ax" "$ay" "$m2" "$h2"; echo "Colocada arriba a la izquierda";;
|
||||||
|
"arriba derecha") coloca "$w" $((ax+m2)) "$ay" "$m2" "$h2"; echo "Colocada arriba a la derecha";;
|
||||||
|
"abajo izquierda") coloca "$w" "$ax" $((ay+h2)) "$m2" "$h2"; echo "Colocada abajo a la izquierda";;
|
||||||
|
"abajo derecha") coloca "$w" $((ax+m2)) $((ay+h2)) "$m2" "$h2"; echo "Colocada abajo a la derecha";;
|
||||||
|
centro|centrada) coloca "$w" $((ax+aan/4)) $((ay+aal/4)) $((aan/2)) $((aal/2)); echo "Ya la tiene centrada";;
|
||||||
|
*) echo "No conozco esa zona, señor"; exit 1;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
|
||||||
|
maximiza) w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }; maximiza "$w"; echo "Maximizada";;
|
||||||
|
restaura) w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }; desmaximiza "$w"; echo "Restaurada a su tamaño";;
|
||||||
|
minimiza) w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }
|
||||||
|
xdotool windowminimize "$w" 2>/dev/null; echo "Minimizada";;
|
||||||
|
|
||||||
|
escritorio)
|
||||||
|
w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }
|
||||||
|
n=${3:-1}
|
||||||
|
case "$n" in una|uno|primero) n=1;; dos|segundo) n=2;; tres|tercero) n=3;; cuatro) n=4;; esac
|
||||||
|
total=$(xdotool get_num_desktops 2>/dev/null || echo 1)
|
||||||
|
[ "$n" -ge 1 ] 2>/dev/null && [ "$n" -le "$total" ] || { echo "no hay escritorio $n, solo $total"; exit 1; }
|
||||||
|
xdotool set_desktop_for_window "$w" $(( n - 1 )) 2>/dev/null
|
||||||
|
echo "Movida al escritorio $n"
|
||||||
|
;;
|
||||||
|
|
||||||
|
*) sed -n '2,26p' "$0"; exit 1;;
|
||||||
|
esac
|
||||||
8
config/whisper-server-cuda.sh
Executable file
8
config/whisper-server-cuda.sh
Executable file
|
|
@ -0,0 +1,8 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Envoltorio: el whisper-server de Newelle, pero el nuestro compilado con CUDA.
|
||||||
|
# Newelle lo invoca por flatpak-spawn --host esperando 'whisper-server' en el
|
||||||
|
# PATH; aqui se fija el entorno de CUDA (que el binario necesita para ver las
|
||||||
|
# libs) y se pasa a nuestra version con GPU. Los argumentos (-m, --port, -l...)
|
||||||
|
# los pone Newelle y se reenvian tal cual.
|
||||||
|
export LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH:-}
|
||||||
|
exec "$HOME/COFRE/CODERS/JARVIS/voz/whisper-cuda/whisper.cpp/build/bin/whisper-server" "$@"
|
||||||
46
config/whisper-server-envoltorio.sh
Executable file
46
config/whisper-server-envoltorio.sh
Executable file
|
|
@ -0,0 +1,46 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Envoltorio: el whisper-server de Newelle, pero el nuestro compilado con CUDA.
|
||||||
|
# Newelle lo invoca por flatpak-spawn --host esperando 'whisper-server' en el
|
||||||
|
# PATH; aqui se fija el entorno de CUDA (que el binario necesita para ver las
|
||||||
|
# libs) y se pasa a nuestra version con GPU. Los argumentos (-m, --port, -l...)
|
||||||
|
# los pone Newelle y se reenvian tal cual.
|
||||||
|
#
|
||||||
|
# Copia versionada: ~/COFRE/CODERS/JARVIS/config/whisper-server-envoltorio.sh
|
||||||
|
export LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH:-}
|
||||||
|
|
||||||
|
BINARIO="$HOME/COFRE/CODERS/JARVIS/voz/whisper-cuda/whisper.cpp/build/bin/whisper-server"
|
||||||
|
REGISTRO="$HOME/COFRE/CODERS/JARVIS/voz/whisper-arranques.log"
|
||||||
|
|
||||||
|
# Cuanta VRAM libre exigimos para arrancar en GPU. El modelo small ocupa 757 MiB
|
||||||
|
# en reposo pero llega a 839 en el pico de una transcripcion: pedir solo lo que
|
||||||
|
# ocupa al cargar es justo el error que hacia que cargase bien y muriese al
|
||||||
|
# hablarle, con un "Error recognizing file with server" que no explicaba nada.
|
||||||
|
# 950 deja holgura para el pico y para el contexto de CUDA.
|
||||||
|
MINIMO_MIB=${JARVIS_WHISPER_MIN_VRAM:-950}
|
||||||
|
|
||||||
|
# Barrer el servidor anterior antes de levantar el nuestro.
|
||||||
|
#
|
||||||
|
# Newelle lo lanza por flatpak-spawn --host, asi que el proceso vive FUERA del
|
||||||
|
# sandbox y no se entera de que la app se cerro: cada vez que se reinicia
|
||||||
|
# JARVIS queda un whisper-server huerfano agarrado a 757 MiB de la tarjeta.
|
||||||
|
# Dos o tres reinicios y ya no cabe el modelo del asistente, con el sintoma
|
||||||
|
# repartido —a veces va, a veces no— segun cuantos hubiera quedado sueltos.
|
||||||
|
#
|
||||||
|
# Se comparan PIDs y no patrones: un `pkill -f whisper-server` desde aqui se
|
||||||
|
# mataria a si mismo, que este script tiene ese nombre.
|
||||||
|
for otro in $(pgrep -x whisper-server 2>/dev/null); do
|
||||||
|
[ "$otro" = "$$" ] && continue
|
||||||
|
kill "$otro" 2>/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
libre=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -n "$libre" ] && [ "$libre" -lt "$MINIMO_MIB" ] 2>/dev/null; then
|
||||||
|
# Sin sitio: mejor transcribir en procesador que abortar. Es mas lento, pero
|
||||||
|
# responde; abortar deja la app muda y sin decir por que.
|
||||||
|
echo "$(date '+%F %T') GPU con solo ${libre} MiB libres (<${MINIMO_MIB}): al procesador" >> "$REGISTRO"
|
||||||
|
exec "$BINARIO" --no-gpu "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$(date '+%F %T') GPU con ${libre:-?} MiB libres: en tarjeta" >> "$REGISTRO"
|
||||||
|
exec "$BINARIO" "$@"
|
||||||
150
config/wiki_ingesta.py
Normal file
150
config/wiki_ingesta.py
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Descarga articulos de Wikipedia en espanol sobre los temas de JARVIS y los
|
||||||
|
deja como .txt en la carpeta de documentos del RAG. Luego, construir_indice.py
|
||||||
|
los vectoriza. Corre en el host (solo stdlib + red), sin sonido ni GPU.
|
||||||
|
|
||||||
|
python3 config/wiki_ingesta.py # descarga
|
||||||
|
# despues, dentro del flatpak:
|
||||||
|
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||||
|
~/COFRE/CODERS/JARVIS/config/construir_indice.py
|
||||||
|
|
||||||
|
Es aditivo: agrega ficheros a la carpeta, no borra lo que ya haya.
|
||||||
|
"""
|
||||||
|
import os, json, time, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
|
API = "https://es.wikipedia.org/w/api.php"
|
||||||
|
UA = "JARVIS-local-RAG/1.0 (asistente de voz local; contacto: local)"
|
||||||
|
DOCS = os.path.expanduser(
|
||||||
|
"~/.var/app/io.github.qwersyk.Newelle/config/models/documents")
|
||||||
|
|
||||||
|
# Por tema: semillas seguras + una busqueda que amplia con articulos afines.
|
||||||
|
TEMAS = {
|
||||||
|
"tecnologia": {
|
||||||
|
"buscar": ["tecnología", "informática", "hardware", "red informática",
|
||||||
|
"inteligencia artificial", "sistema operativo"],
|
||||||
|
"semillas": ["Tecnología", "Informática", "Internet", "Protocolo de red",
|
||||||
|
"Microprocesador", "Computación en la nube", "Base de datos"],
|
||||||
|
},
|
||||||
|
"criptografia": {
|
||||||
|
"buscar": ["criptografía", "cifrado", "criptografía asimétrica"],
|
||||||
|
"semillas": ["Criptografía", "Cifrado", "RSA", "Advanced Encryption Standard",
|
||||||
|
"Función hash criptográfica", "Firma digital",
|
||||||
|
"Diffie-Hellman", "Pretty Good Privacy", "Transport Layer Security",
|
||||||
|
"Criptografía de curva elíptica", "Cadena de bloques",
|
||||||
|
"Bitcoin", "Criptomoneda"],
|
||||||
|
},
|
||||||
|
"software_libre": {
|
||||||
|
"buscar": ["software libre", "código abierto", "historia de Linux"],
|
||||||
|
"semillas": ["Software libre", "GNU", "Richard Stallman", "GNU/Linux",
|
||||||
|
"Linus Torvalds", "Free Software Foundation", "Licencia pública general de GNU",
|
||||||
|
"Código abierto", "Debian", "Unix", "Kernel Linux",
|
||||||
|
"Movimiento del software libre"],
|
||||||
|
},
|
||||||
|
"hacking": {
|
||||||
|
"buscar": ["seguridad informática", "hacker", "ciberseguridad"],
|
||||||
|
"semillas": ["Hacker", "Seguridad informática", "Hacker (seguridad informática)",
|
||||||
|
"Malware", "Exploit", "Ingeniería social (seguridad informática)",
|
||||||
|
"Phreaking", "Kevin Mitnick", "Anonymous (colectivo)",
|
||||||
|
"Prueba de penetración", "Vulnerabilidad (informática)",
|
||||||
|
"Cortafuegos (informática)", "Ataque de denegación de servicio"],
|
||||||
|
},
|
||||||
|
"clima": {
|
||||||
|
"buscar": ["cambio climático", "calentamiento global", "clima"],
|
||||||
|
"semillas": ["Cambio climático", "Calentamiento global", "Efecto invernadero",
|
||||||
|
"Gas de efecto invernadero", "Protocolo de Kioto",
|
||||||
|
"Acuerdo de París", "IPCC", "Clima", "Dióxido de carbono",
|
||||||
|
"El Niño", "Nivel del mar", "Energía renovable"],
|
||||||
|
},
|
||||||
|
"conflictos_espana": {
|
||||||
|
"buscar": ["guerra en España", "conflicto España historia"],
|
||||||
|
"semillas": ["Guerra civil española", "Transición española",
|
||||||
|
"Guerra de la Independencia Española", "Guerras carlistas",
|
||||||
|
"Reconquista", "Dictadura de Francisco Franco",
|
||||||
|
"Segunda República Española", "Movimiento 15-M",
|
||||||
|
"Conflicto vasco", "Guerra de Sucesión Española"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
BUSCAR_POR_CONSULTA = 15 # articulos extra por cada busqueda
|
||||||
|
PAUSA = 0.8 # cortesia con la API (subida tras el 429)
|
||||||
|
|
||||||
|
|
||||||
|
def _get(params, reintentos=5):
|
||||||
|
params = {**params, "format": "json"}
|
||||||
|
url = API + "?" + urllib.parse.urlencode(params)
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||||
|
espera = 2.0
|
||||||
|
for intento in range(reintentos):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.load(r)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 429 and intento < reintentos - 1:
|
||||||
|
# Wikipedia nos frena: esperar cada vez mas (backoff)
|
||||||
|
time.sleep(espera)
|
||||||
|
espera *= 2
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
raise RuntimeError("agotados los reintentos")
|
||||||
|
|
||||||
|
|
||||||
|
def titulos_de_busqueda(consulta, limite):
|
||||||
|
try:
|
||||||
|
d = _get({"action": "query", "list": "search",
|
||||||
|
"srsearch": consulta, "srlimit": limite})
|
||||||
|
return [h["title"] for h in d.get("query", {}).get("search", [])]
|
||||||
|
except Exception as e:
|
||||||
|
print(f" busqueda '{consulta}' fallo: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def extracto(titulo):
|
||||||
|
try:
|
||||||
|
d = _get({"action": "query", "prop": "extracts", "explaintext": 1,
|
||||||
|
"redirects": 1, "titles": titulo})
|
||||||
|
paginas = d.get("query", {}).get("pages", {})
|
||||||
|
for _, pg in paginas.items():
|
||||||
|
txt = pg.get("extract", "")
|
||||||
|
if txt and len(txt) > 300:
|
||||||
|
return pg.get("title", titulo), txt
|
||||||
|
except Exception as e:
|
||||||
|
print(f" extracto '{titulo}' fallo: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def slug(s):
|
||||||
|
s = "".join(c if c.isalnum() else "_" for c in s.lower())
|
||||||
|
return s.strip("_")[:80]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(DOCS, exist_ok=True)
|
||||||
|
total = 0
|
||||||
|
for tema, cfg in TEMAS.items():
|
||||||
|
print(f"\n=== {tema} ===")
|
||||||
|
titulos = list(cfg["semillas"])
|
||||||
|
for consulta in cfg["buscar"]:
|
||||||
|
titulos += titulos_de_busqueda(consulta, BUSCAR_POR_CONSULTA)
|
||||||
|
time.sleep(PAUSA)
|
||||||
|
# dedup conservando orden
|
||||||
|
vistos, unicos = set(), []
|
||||||
|
for t in titulos:
|
||||||
|
if t.lower() not in vistos:
|
||||||
|
vistos.add(t.lower()); unicos.append(t)
|
||||||
|
for t in unicos:
|
||||||
|
dst = os.path.join(DOCS, f"wiki_{tema}_{slug(t)}.txt")
|
||||||
|
if os.path.exists(dst):
|
||||||
|
continue
|
||||||
|
titulo_real, txt = extracto(t)
|
||||||
|
time.sleep(PAUSA)
|
||||||
|
if not txt:
|
||||||
|
continue
|
||||||
|
with open(dst, "w", encoding="utf-8") as f:
|
||||||
|
f.write(f"# {titulo_real}\n(Wikipedia, tema: {tema})\n\n{txt}\n")
|
||||||
|
total += 1
|
||||||
|
print(f" [{total}] {titulo_real} ({len(txt)//1024} KB)")
|
||||||
|
print(f"\nlisto: {total} articulos nuevos en {DOCS}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
61
entrena/README.md
Normal file
61
entrena/README.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Entrenamiento de JARVIS
|
||||||
|
|
||||||
|
El fine-tuning NO sustituye al RAG (nucleo/saber/), lo complementa. El RAG ya
|
||||||
|
funciona y da el conocimiento fresco; esto hornea el estilo y los flujos en los
|
||||||
|
pesos. Por eso fue: primero RAG, esto despues.
|
||||||
|
|
||||||
|
## Los tres pasos
|
||||||
|
|
||||||
|
1. dataset.py convierte los apuntes de COFRE en pares pregunta-respuesta
|
||||||
|
(usa el indice de nucleo/saber/, ~10 h con el 4B local)
|
||||||
|
2. afina.py QLoRA sobre el dataset. --revisa dice si la maquina puede
|
||||||
|
3. exportar el adaptador a GGUF + Modelfile con ADAPTER, para ollama
|
||||||
|
|
||||||
|
## Por que falla hoy: qwen3.5 es MULTIMODAL (16 ago)
|
||||||
|
|
||||||
|
Las pruebas de `afina.py` cargan el modelo bien y se caen justo despues, al
|
||||||
|
enganchar los adaptadores, con un mensaje que no viene a cuento:
|
||||||
|
|
||||||
|
Incorrect image source. Must be a valid URL starting with `http://`
|
||||||
|
|
||||||
|
No es un fallo de red ni de rutas. **`Qwen/Qwen3.5-2B` es `image-text-to-text`**
|
||||||
|
—comprobado en la API de HuggingFace, y el propio GGUF lo delata: el
|
||||||
|
`model_info` de ollama trae `qwen35.vision.block_count = 24`—. O sea que el
|
||||||
|
modelo lleva torre de vision, y `FastLanguageModel` lo mete por el camino de
|
||||||
|
solo texto mientras transformers intenta procesar una imagen que no existe.
|
||||||
|
|
||||||
|
La pista estaba en el log y se leyo como ruido:
|
||||||
|
|
||||||
|
Unsloth: Explicit target_modules are constrained by the
|
||||||
|
finetune_(vision|language|attention|mlp) filters
|
||||||
|
|
||||||
|
Ese aviso solo lo emite el camino multimodal.
|
||||||
|
|
||||||
|
Por donde va el arreglo: `FastVisionModel` con `finetune_vision_layers=False`,
|
||||||
|
que es la via de unsloth para afinar solo la parte de lenguaje de un modelo con
|
||||||
|
vision. **Sin probar todavia.**
|
||||||
|
|
||||||
|
De paso, los nombres que no existen en HuggingFace y que las pruebas fueron
|
||||||
|
descartando: `Qwen3.5-3B`, `Qwen3.5-3B-Instruct`, `Qwen3.5-1.7B`,
|
||||||
|
`Qwen3.5-1.7B-Instruct`, `Qwen3.5-1.5B`. El unico que carga es `Qwen3.5-2B`
|
||||||
|
(1,95 GB).
|
||||||
|
|
||||||
|
## Donde entrenar
|
||||||
|
|
||||||
|
T1200 (4 GB) al limite. Turing, sin bf16. Posible pero fragil.
|
||||||
|
3060 (12 GB) holgado. El sitio natural. El dataset viaja como fichero.
|
||||||
|
|
||||||
|
## Para entrenar
|
||||||
|
|
||||||
|
pip install unsloth # trae peft, trl, bitsandbytes
|
||||||
|
./afina.py --revisa # comprueba
|
||||||
|
./afina.py # entrena
|
||||||
|
|
||||||
|
## Exportar a ollama (tras entrenar)
|
||||||
|
|
||||||
|
# con llama.cpp:
|
||||||
|
python convert_lora_to_gguf.py jarvis-lora --outfile jarvis-lora.gguf
|
||||||
|
# Modelfile:
|
||||||
|
# FROM qwen3.5:4b
|
||||||
|
# ADAPTER ./jarvis-lora.gguf
|
||||||
|
ollama create qwen3.5:4b-jarvis-cofre -f Modelfile
|
||||||
236
entrena/afina.py
Executable file
236
entrena/afina.py
Executable file
|
|
@ -0,0 +1,236 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fine-tuning de JARVIS con QLoRA sobre el dataset de COFRE.
|
||||||
|
|
||||||
|
./afina.py --revisa comprueba que se puede entrenar aqui, sin entrenar
|
||||||
|
./afina.py entrena (largo; mejor en el servidor con la 3060)
|
||||||
|
|
||||||
|
## Que hace y que NO hace el fine-tuning
|
||||||
|
|
||||||
|
Le enseña al modelo el ESTILO y las respuestas de tus apuntes con ejemplos. NO
|
||||||
|
sustituye al RAG: el RAG le da los datos frescos en el momento —y se actualiza
|
||||||
|
soltando un fichero—, mientras que esto los hornea en los pesos. Se complementan:
|
||||||
|
el fine-tuning hace que "suene" a JARVIS y conozca tus flujos sin buscar; el RAG
|
||||||
|
cubre lo que cambia y lo que es muy especifico.
|
||||||
|
|
||||||
|
Por eso el orden fue: primero RAG (hecho, funciona hoy), y esto despues.
|
||||||
|
|
||||||
|
## QLoRA en cristiano
|
||||||
|
|
||||||
|
Entrenar un 4B entero pide mas memoria de la que hay. QLoRA hace dos trucos:
|
||||||
|
- carga el modelo base en 4 bits (cabe en poca VRAM, y se queda CONGELADO)
|
||||||
|
- entrena solo unas matrices pequeñas nuevas (los "adaptadores LoRA"), que son
|
||||||
|
una fraccion del tamaño
|
||||||
|
|
||||||
|
Asi se entrena un 4B en pocos GB. El resultado es un adaptador de ~50 MB que se
|
||||||
|
le pone encima al modelo base.
|
||||||
|
|
||||||
|
## Donde entrenar
|
||||||
|
|
||||||
|
T1200 (4 GB, esta maquina) al limite. Turing (capacidad 7.5): sin bf16 ni
|
||||||
|
flash-attention. Posible con lote 1 y secuencia
|
||||||
|
corta, lento y fragil. --revisa lo dice.
|
||||||
|
3060 (12 GB, el servidor) holgado. Ampere: bf16 y flash-attention. Es el
|
||||||
|
sitio natural, por eso el dataset se genera aparte
|
||||||
|
y viaja como fichero.
|
||||||
|
|
||||||
|
## Dependencias
|
||||||
|
|
||||||
|
pip install unsloth (trae peft, trl, bitsandbytes, accelerate)
|
||||||
|
|
||||||
|
unsloth porque es lo que mejor exprime una GPU pequeña: mismo resultado con la
|
||||||
|
mitad de VRAM y el doble de velocidad que peft+trl a pelo.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DATASET = os.path.join(AQUI, "dataset-cofre.jsonl")
|
||||||
|
SALIDA = os.path.join(AQUI, "jarvis-lora")
|
||||||
|
|
||||||
|
# El modelo base, en formato HuggingFace. ollama corre GGUF; para entrenar hace
|
||||||
|
# falta el modelo de verdad. Se prueban candidatos en orden hasta que uno
|
||||||
|
# cargue: la version pre-cuantizada de unsloth va primero porque baja menos y
|
||||||
|
# ocupa menos VRAM, que en 4 GB es lo que decide si entra.
|
||||||
|
#
|
||||||
|
# El de ollama es qwen3.5:4b (arquitectura qwen35, 4,7B, embedding 2560). Si
|
||||||
|
# ninguno carga, la fase 0 lo dice y no se pierden horas.
|
||||||
|
CANDIDATOS = [
|
||||||
|
os.environ.get("JARVIS_BASE_HF", ""), # lo que ponga el usuario, si pone
|
||||||
|
"unsloth/Qwen3.5-4B-Instruct-bnb-4bit",
|
||||||
|
"Qwen/Qwen3.5-4B-Instruct",
|
||||||
|
"unsloth/Qwen3.5-4B-bnb-4bit",
|
||||||
|
"Qwen/Qwen3.5-4B",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Apretado para 4 GB (ver el plan). Con el 4,7B en 4 bits ocupando ~2,8 GB,
|
||||||
|
# cada megabyte de mas en secuencia o rango es el que provoca el OOM.
|
||||||
|
EPOCAS = 2
|
||||||
|
LOTE = 1
|
||||||
|
ACUMULA = 16 # lote efectivo 16 sin gastar VRAM
|
||||||
|
LR = 2e-4
|
||||||
|
MAX_SEQ = 512 # los pares Q&A son cortos; 512 no pierde casi nada
|
||||||
|
RANGO = 8 # adaptadores pequeños
|
||||||
|
|
||||||
|
|
||||||
|
def _base_que_carga():
|
||||||
|
"""El primer candidato que exista y cargue. None si ninguno."""
|
||||||
|
from unsloth import FastLanguageModel
|
||||||
|
for repo in CANDIDATOS:
|
||||||
|
if not repo:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
print(f" probando {repo} ...", flush=True)
|
||||||
|
modelo, tok = FastLanguageModel.from_pretrained(
|
||||||
|
model_name=repo, max_seq_length=MAX_SEQ, dtype=None,
|
||||||
|
load_in_4bit=True)
|
||||||
|
print(f" ✓ carga: {repo}")
|
||||||
|
return repo, modelo, tok
|
||||||
|
except Exception as e:
|
||||||
|
print(f" no: {str(e).splitlines()[0][:80]}")
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
|
||||||
|
def revisa() -> int:
|
||||||
|
"""El ensayo de la fase 0: carga el 4,7B y hace UN paso de entrenamiento.
|
||||||
|
|
||||||
|
Es el momento de la verdad. Si ese paso pasa sin quedarse sin memoria, el
|
||||||
|
entrenamiento entero es cuestion de tiempo. Si peta, se sabe en diez minutos
|
||||||
|
y no en la sexta hora.
|
||||||
|
"""
|
||||||
|
print("\n ── se puede entrenar aqui? ──\n")
|
||||||
|
|
||||||
|
n = 0
|
||||||
|
if os.path.exists(DATASET):
|
||||||
|
with open(DATASET) as f:
|
||||||
|
n = sum(1 for _ in f)
|
||||||
|
print(f" dataset: {n} ejemplos"
|
||||||
|
+ (" (suficiente para el test; poco para entrenar)" if n < 500 else ""))
|
||||||
|
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
except ImportError:
|
||||||
|
print(" torch: NO — el venv de entrenamiento no esta montado")
|
||||||
|
return 1
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
print(" cuda: NO")
|
||||||
|
return 1
|
||||||
|
p = torch.cuda.get_device_properties(0)
|
||||||
|
libre = torch.cuda.mem_get_info()[0] / 1e9
|
||||||
|
print(f" gpu: {p.name} {p.total_memory/1e9:.1f} GB, {libre:.1f} libres, "
|
||||||
|
f"capacidad {p.major}.{p.minor}"
|
||||||
|
+ (" (Turing: fp16, sin flash-attn)" if p.major < 8 else ""))
|
||||||
|
if libre < 3.3:
|
||||||
|
print(" AVISO: hay menos de 3,3 GB libres. Cierra JARVIS y ollama antes.")
|
||||||
|
|
||||||
|
for m in ("unsloth", "peft", "trl", "bitsandbytes"):
|
||||||
|
try:
|
||||||
|
__import__(m)
|
||||||
|
except ImportError:
|
||||||
|
print(f" {m}: NO -> el venv de entrenamiento no acabo de instalar")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# El momento de la verdad: cargar + un paso real.
|
||||||
|
print("\n ── cargando el modelo y haciendo un paso de prueba ──")
|
||||||
|
from unsloth import FastLanguageModel
|
||||||
|
repo, modelo, tok = _base_que_carga()
|
||||||
|
if repo is None:
|
||||||
|
print("\n NINGUN candidato cargo. O no existe el repo HF del qwen3.5:4b,")
|
||||||
|
print(" o esta arquitectura (qwen35) no la soporta este transformers.")
|
||||||
|
print(" Pon el repo correcto en JARVIS_BASE_HF y reintenta.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
tras_modelo = torch.cuda.memory_allocated() / 1e9
|
||||||
|
print(f" VRAM tras cargar el modelo: {tras_modelo:.2f} GB")
|
||||||
|
|
||||||
|
modelo = FastLanguageModel.get_peft_model(
|
||||||
|
modelo, r=RANGO, lora_alpha=RANGO, lora_dropout=0,
|
||||||
|
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
|
||||||
|
"gate_proj", "up_proj", "down_proj"],
|
||||||
|
use_gradient_checkpointing="unsloth", random_state=42)
|
||||||
|
|
||||||
|
# un lote de una frase, forward + backward, que es donde revienta si revienta
|
||||||
|
try:
|
||||||
|
import torch as T
|
||||||
|
texto = tok.apply_chat_template(
|
||||||
|
[{"role": "user", "content": "que es secretsdump"},
|
||||||
|
{"role": "assistant", "content": "Saca los hashes NTLM, señor."}],
|
||||||
|
tokenize=False)
|
||||||
|
ids = tok(texto, return_tensors="pt", max_length=MAX_SEQ,
|
||||||
|
truncation=True).input_ids.to("cuda")
|
||||||
|
salida = modelo(ids, labels=ids)
|
||||||
|
salida.loss.backward()
|
||||||
|
pico = T.cuda.max_memory_allocated() / 1e9
|
||||||
|
print(f" ✓ un paso de entrenamiento paso. Pico de VRAM: {pico:.2f} GB")
|
||||||
|
print(f"\n CABE. base a usar: {repo}")
|
||||||
|
print(" Pon ese repo en JARVIS_BASE_HF si no es el primero, y ./afina.py\n")
|
||||||
|
return 0
|
||||||
|
except T.cuda.OutOfMemoryError:
|
||||||
|
print("\n OOM: el 4,7B no entra en esta grafica ni apretando.")
|
||||||
|
print(" Plan B: bajar MAX_SEQ a 256, o el 3B, o el servidor (3060).")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def entrena() -> int:
|
||||||
|
if not os.path.exists(DATASET):
|
||||||
|
print(" no hay dataset: ./dataset.py primero")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from unsloth import FastLanguageModel
|
||||||
|
from datasets import load_dataset
|
||||||
|
from trl import SFTTrainer, SFTConfig
|
||||||
|
|
||||||
|
ampere = torch.cuda.get_device_properties(0).major >= 8
|
||||||
|
|
||||||
|
repo, modelo, tokenizer = _base_que_carga()
|
||||||
|
if repo is None:
|
||||||
|
print(" ningun base cargo; corre --revisa para el diagnostico")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
modelo = FastLanguageModel.get_peft_model(
|
||||||
|
modelo, r=RANGO, lora_alpha=RANGO, lora_dropout=0,
|
||||||
|
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
|
||||||
|
"gate_proj", "up_proj", "down_proj"],
|
||||||
|
use_gradient_checkpointing="unsloth", random_state=42)
|
||||||
|
|
||||||
|
datos = load_dataset("json", data_files=DATASET, split="train")
|
||||||
|
|
||||||
|
def formatea(ej):
|
||||||
|
return {"text": tokenizer.apply_chat_template(
|
||||||
|
ej["messages"], tokenize=False, add_generation_prompt=False)}
|
||||||
|
datos = datos.map(formatea)
|
||||||
|
|
||||||
|
entrenador = SFTTrainer(
|
||||||
|
model=modelo, tokenizer=tokenizer, train_dataset=datos,
|
||||||
|
dataset_text_field="text", max_seq_length=MAX_SEQ,
|
||||||
|
args=SFTConfig(
|
||||||
|
per_device_train_batch_size=LOTE,
|
||||||
|
gradient_accumulation_steps=ACUMULA,
|
||||||
|
num_train_epochs=EPOCAS, learning_rate=LR,
|
||||||
|
fp16=not ampere, bf16=ampere,
|
||||||
|
logging_steps=10, optim="adamw_8bit",
|
||||||
|
warmup_ratio=0.05, lr_scheduler_type="cosine",
|
||||||
|
output_dir=os.path.join(AQUI, "checkpoints"), seed=42))
|
||||||
|
|
||||||
|
print(" entrenando...")
|
||||||
|
entrenador.train()
|
||||||
|
|
||||||
|
modelo.save_pretrained(SALIDA)
|
||||||
|
tokenizer.save_pretrained(SALIDA)
|
||||||
|
print(f"\n adaptador guardado: {SALIDA}")
|
||||||
|
print(" para usarlo con ollama hay que exportarlo a GGUF y crear un")
|
||||||
|
print(" Modelfile con ADAPTER. Ver el README de entrena/.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--revisa", action="store_true")
|
||||||
|
a = p.parse_args()
|
||||||
|
return revisa() if a.revisa else entrena()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
171
entrena/dataset.py
Executable file
171
entrena/dataset.py
Executable file
|
|
@ -0,0 +1,171 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Genera el dataset de fine-tuning a partir de los apuntes de COFRE.
|
||||||
|
|
||||||
|
./dataset.py --muestra 5 enseña 5 ejemplos y para (para mirar la calidad)
|
||||||
|
./dataset.py genera el dataset entero (horas, en segundo plano)
|
||||||
|
|
||||||
|
## Por que esto es el paso que faltaba
|
||||||
|
|
||||||
|
Fine-tuning no es magia: es enseñarle al modelo con EJEMPLOS de como quieres que
|
||||||
|
conteste. Para eso hacen falta pares pregunta-respuesta, y no los teniamos. Los
|
||||||
|
apuntes de COFRE son conocimiento, pero en prosa; hay que convertirlos en la
|
||||||
|
forma "alguien pregunta X, JARVIS responde Y".
|
||||||
|
|
||||||
|
Se generan con el modelo local a partir de cada fragmento de los apuntes: se le
|
||||||
|
da el trozo y se le pide que invente las preguntas que ese trozo contesta, y la
|
||||||
|
respuesta al estilo de JARVIS. El fragmento es la VERDAD —lo escribio el
|
||||||
|
usuario— asi que las respuestas salen de ahi y no de lo que el modelo recuerde.
|
||||||
|
|
||||||
|
## Por que el dataset vale aunque no se entrene aqui
|
||||||
|
|
||||||
|
La T1200 de 4 GB va justa para entrenar un 4B. El sitio natural es el servidor
|
||||||
|
con la 3060 de 12 GB. Pero el DATASET es lo caro de conseguir y no depende de la
|
||||||
|
maquina: se genera una vez, es un fichero, y se entrena donde haga falta —aqui
|
||||||
|
si cabe, o en el servidor cuando haya acceso—.
|
||||||
|
|
||||||
|
## El formato
|
||||||
|
|
||||||
|
JSONL en el formato de mensajes, que es lo que comen trl/axolotl/unsloth:
|
||||||
|
|
||||||
|
{"messages": [{"role":"user","content":"..."},
|
||||||
|
{"role":"assistant","content":"..."}]}
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
INDICE = os.path.join(AQUI, "nucleo", "datos", "saber.jsonl")
|
||||||
|
SALIDA = os.path.join(AQUI, "entrena", "dataset-cofre.jsonl")
|
||||||
|
ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434")
|
||||||
|
MODELO = os.environ.get("JARVIS_MODELO", "qwen3.5:4b-jarvis")
|
||||||
|
|
||||||
|
# Al generador se le da MAS libertad que a JARVIS en produccion: aqui quereis
|
||||||
|
# variedad de preguntas, no una respuesta conservadora.
|
||||||
|
GENERADOR = """Eres un generador de datos de entrenamiento para un asistente de
|
||||||
|
pentesting llamado JARVIS. Te doy un fragmento de los apuntes tecnicos del
|
||||||
|
usuario. Devuelve de UNA a TRES preguntas que una persona haria y que ese
|
||||||
|
fragmento contesta, cada una con su respuesta.
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
- Las preguntas, naturales y variadas: como las diria alguien por voz.
|
||||||
|
- Las respuestas SALEN DEL FRAGMENTO. Si el fragmento tiene un comando exacto,
|
||||||
|
la respuesta lo lleva tal cual. No te inventes flags que no aparezcan.
|
||||||
|
- Respuestas al estilo de JARVIS: directas, en castellano, tratando de "señor".
|
||||||
|
- Si el fragmento no da para una pregunta util (es un titulo, una lista de
|
||||||
|
enlaces, ruido), devuelve una lista vacia.
|
||||||
|
|
||||||
|
Responde SOLO con JSON, un objeto con la clave "pares":
|
||||||
|
{"pares": [{"pregunta": "...", "respuesta": "..."}]}"""
|
||||||
|
|
||||||
|
|
||||||
|
def _pide(fragmento: str) -> list:
|
||||||
|
cuerpo = json.dumps({
|
||||||
|
"model": MODELO,
|
||||||
|
"messages": [{"role": "system", "content": GENERADOR},
|
||||||
|
{"role": "user", "content": fragmento[:1500]}],
|
||||||
|
"stream": False, "think": False,
|
||||||
|
"options": {"temperature": 0.7, "num_predict": 500},
|
||||||
|
"format": "json",
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(ENDPOINT + "/api/chat", data=cuerpo,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as r:
|
||||||
|
contenido = json.loads(r.read())["message"]["content"]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
# el modelo a veces envuelve el array en un objeto {"pares": [...]}
|
||||||
|
try:
|
||||||
|
d = json.loads(contenido)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
m = re.search(r"\[.*\]", contenido, re.S)
|
||||||
|
if not m:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
d = json.loads(m.group(0))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return []
|
||||||
|
if isinstance(d, dict):
|
||||||
|
d = next((v for v in d.values() if isinstance(v, list)), [])
|
||||||
|
salida = []
|
||||||
|
for par in d if isinstance(d, list) else []:
|
||||||
|
if isinstance(par, dict) and par.get("pregunta") and par.get("respuesta"):
|
||||||
|
salida.append((par["pregunta"].strip(), par["respuesta"].strip()))
|
||||||
|
return salida
|
||||||
|
|
||||||
|
|
||||||
|
def apuntes():
|
||||||
|
"""Los fragmentos de apuntes del indice, los mas sustanciosos primero."""
|
||||||
|
fuera = []
|
||||||
|
with open(INDICE, encoding="utf-8") as f:
|
||||||
|
for l in f:
|
||||||
|
try:
|
||||||
|
d = json.loads(l)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if d.get("tipo") == "apunte" and len(d["texto"].split()) >= 20:
|
||||||
|
d.pop("v", None)
|
||||||
|
fuera.append(d)
|
||||||
|
return fuera
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--muestra", type=int, default=0,
|
||||||
|
help="genera solo N y los enseña, sin guardar")
|
||||||
|
a = p.parse_args()
|
||||||
|
|
||||||
|
if not os.path.exists(INDICE):
|
||||||
|
print(" no hay indice: nucleo/saber/indexa.py primero")
|
||||||
|
return 1
|
||||||
|
fragmentos = apuntes()
|
||||||
|
print(f" {len(fragmentos)} fragmentos con sustancia", flush=True)
|
||||||
|
|
||||||
|
if a.muestra:
|
||||||
|
for frag in fragmentos[:a.muestra]:
|
||||||
|
pares = _pide(frag["texto"])
|
||||||
|
print(f"\n ── {frag.get('fichero', frag['perfil'])} ──")
|
||||||
|
for q, r in pares:
|
||||||
|
print(f" P: {q}")
|
||||||
|
print(f" R: {r[:120]}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Reanudable: un trabajo de 10 h no puede perder todo si se corta. Se apunta
|
||||||
|
# en un .hecho por que fragmento se iba, y al arrancar se salta hasta ahi y
|
||||||
|
# se AÑADE al dataset en vez de reescribirlo.
|
||||||
|
marca = SALIDA + ".hecho"
|
||||||
|
desde = 0
|
||||||
|
if os.path.exists(marca) and os.path.exists(SALIDA):
|
||||||
|
try:
|
||||||
|
desde = int(open(marca).read().strip()) + 1
|
||||||
|
except (ValueError, OSError):
|
||||||
|
desde = 0
|
||||||
|
modo = "a" if desde else "w"
|
||||||
|
hechos = sum(1 for _ in open(SALIDA)) if desde and os.path.exists(SALIDA) else 0
|
||||||
|
if desde:
|
||||||
|
print(f" reanudando desde el fragmento {desde} ({hechos} ejemplos hechos)",
|
||||||
|
flush=True)
|
||||||
|
|
||||||
|
with open(SALIDA, modo, encoding="utf-8", buffering=1) as out:
|
||||||
|
for i in range(desde, len(fragmentos)):
|
||||||
|
for q, r in _pide(fragmentos[i]["texto"]):
|
||||||
|
out.write(json.dumps({"messages": [
|
||||||
|
{"role": "user", "content": q},
|
||||||
|
{"role": "assistant", "content": r},
|
||||||
|
]}, ensure_ascii=False) + "\n")
|
||||||
|
hechos += 1
|
||||||
|
with open(marca, "w") as m:
|
||||||
|
m.write(str(i))
|
||||||
|
if (i + 1) % 50 == 0:
|
||||||
|
print(f" {i+1}/{len(fragmentos)} fragmentos, {hechos} ejemplos",
|
||||||
|
flush=True)
|
||||||
|
print(f"\n dataset guardado: {SALIDA} ({hechos} ejemplos)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
65
install.sh
Executable file
65
install.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Instalador del nucleo de JARVIS. Prepara el entorno de Python, el modelo de
|
||||||
|
# Ollama y comprueba los servicios externos. No descarga modelos pesados ni
|
||||||
|
# toca el sistema sin avisar.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
AQUI=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
|
||||||
|
cd "$AQUI"
|
||||||
|
|
||||||
|
ok() { printf ' \033[32mok\033[0m %s\n' "$*"; }
|
||||||
|
falta(){ printf ' \033[33mfalta\033[0m %s\n' "$*"; }
|
||||||
|
malo() { printf ' \033[31mERROR\033[0m %s\n' "$*"; }
|
||||||
|
|
||||||
|
echo "JARVIS — instalacion"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ── 1. Servicios externos (los instala el usuario; aqui solo se comprueban) ──
|
||||||
|
echo "1. Servicios externos"
|
||||||
|
command -v python3 >/dev/null && ok "python3 $(python3 -V 2>&1 | awk '{print $2}')" \
|
||||||
|
|| { malo "python3 no esta"; exit 1; }
|
||||||
|
command -v ollama >/dev/null && ok "ollama" \
|
||||||
|
|| falta "ollama — instalalo desde https://ollama.com (es el cerebro)"
|
||||||
|
command -v whisper-server >/dev/null && ok "whisper-server (STT)" \
|
||||||
|
|| falta "whisper.cpp con CUDA — compilalo; ver config/README_whisper_gpu.md"
|
||||||
|
command -v piper >/dev/null && ok "piper (TTS)" \
|
||||||
|
|| falta "piper — https://github.com/rhasspy/piper (la voz)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ── 2. Entorno de Python del nucleo ─────────────────────────────────────────
|
||||||
|
echo "2. Entorno de Python"
|
||||||
|
if [ ! -d nucleo/venv ]; then
|
||||||
|
python3 -m venv nucleo/venv && ok "creado nucleo/venv"
|
||||||
|
else
|
||||||
|
ok "nucleo/venv ya existe"
|
||||||
|
fi
|
||||||
|
nucleo/venv/bin/pip install -q --upgrade pip >/dev/null 2>&1
|
||||||
|
if nucleo/venv/bin/pip install -q -r requirements.txt; then
|
||||||
|
ok "dependencias instaladas"
|
||||||
|
else
|
||||||
|
malo "fallo instalando dependencias (ver requirements.txt)"; exit 1
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ── 3. El modelo de Ollama ──────────────────────────────────────────────────
|
||||||
|
echo "3. El modelo"
|
||||||
|
if command -v ollama >/dev/null; then
|
||||||
|
if ollama list 2>/dev/null | grep -q '^qwen3.5:4b-jarvis'; then
|
||||||
|
ok "qwen3.5:4b-jarvis ya existe"
|
||||||
|
else
|
||||||
|
echo " creando qwen3.5:4b-jarvis (necesita qwen3.5:4b en Ollama)..."
|
||||||
|
if ollama create qwen3.5:4b-jarvis -f config/qwen3.5-4b-jarvis.Modelfile 2>/dev/null; then
|
||||||
|
ok "modelo creado"
|
||||||
|
else
|
||||||
|
falta "no se pudo crear; baja el base con: ollama pull qwen3.5:4b"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
falta "sin ollama no hay modelo; instalalo y re-ejecuta"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ── 4. Siguiente paso ───────────────────────────────────────────────────────
|
||||||
|
echo "Hecho. Para arrancar:"
|
||||||
|
echo " ./nucleo/saber/indexa.py # (opcional) indexa tus apuntes para el RAG"
|
||||||
|
echo " ./nucleo/arranca.sh # el asistente y el panel en 127.0.0.1"
|
||||||
49
jarvis-icon.svg
Normal file
49
jarvis-icon.svg
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="core" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#eafcff"/>
|
||||||
|
<stop offset="45%" stop-color="#7fe9ff"/>
|
||||||
|
<stop offset="100%" stop-color="#1b9fd8"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="halo" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#4fd8ff" stop-opacity=".55"/>
|
||||||
|
<stop offset="70%" stop-color="#1b9fd8" stop-opacity=".12"/>
|
||||||
|
<stop offset="100%" stop-color="#0b3d5c" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="ring" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#cfe9f5"/>
|
||||||
|
<stop offset="100%" stop-color="#5a7f92"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<circle cx="128" cy="128" r="124" fill="#0a1620"/>
|
||||||
|
<circle cx="128" cy="128" r="120" fill="url(#halo)"/>
|
||||||
|
|
||||||
|
<!-- carcasa exterior -->
|
||||||
|
<circle cx="128" cy="128" r="106" fill="none" stroke="url(#ring)" stroke-width="9"/>
|
||||||
|
<circle cx="128" cy="128" r="94" fill="none" stroke="#12303f" stroke-width="7"/>
|
||||||
|
|
||||||
|
<!-- bobinas -->
|
||||||
|
<g stroke="#8fd7ef" stroke-width="7" stroke-linecap="round" opacity=".9">
|
||||||
|
<line x1="128" y1="42" x2="128" y2="70"/>
|
||||||
|
<line x1="128" y1="186" x2="128" y2="214"/>
|
||||||
|
<line x1="42" y1="128" x2="70" y2="128"/>
|
||||||
|
<line x1="186" y1="128" x2="214" y2="128"/>
|
||||||
|
<line x1="67" y1="67" x2="87" y2="87"/>
|
||||||
|
<line x1="169" y1="169" x2="189" y2="189"/>
|
||||||
|
<line x1="189" y1="67" x2="169" y2="87"/>
|
||||||
|
<line x1="87" y1="169" x2="67" y2="189"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- anillo interior -->
|
||||||
|
<circle cx="128" cy="128" r="70" fill="none" stroke="#1f5f7d" stroke-width="10"/>
|
||||||
|
<circle cx="128" cy="128" r="58" fill="none" stroke="#4fd8ff" stroke-width="4" opacity=".85"/>
|
||||||
|
|
||||||
|
<!-- triangulo del nucleo -->
|
||||||
|
<path d="M128 84 L166 150 L90 150 Z" fill="none" stroke="#bff0ff" stroke-width="6"
|
||||||
|
stroke-linejoin="round" opacity=".95"/>
|
||||||
|
|
||||||
|
<!-- nucleo -->
|
||||||
|
<circle cx="128" cy="128" r="34" fill="url(#core)"/>
|
||||||
|
<circle cx="128" cy="128" r="16" fill="#ffffff" opacity=".92"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
188
jarvis-launcher.sh
Executable file
188
jarvis-launcher.sh
Executable file
|
|
@ -0,0 +1,188 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# JARVIS — lanzador del asistente local (Newelle)
|
||||||
|
#
|
||||||
|
# sin argumentos abre JARVIS a pantalla completa, sin pasar por el chat
|
||||||
|
# --chat abre Newelle normal, con sus pestañas y su historial
|
||||||
|
# --stop la cierra y libera el modelo
|
||||||
|
# --status que corre, con que cerebro y cuanto ocupa
|
||||||
|
#
|
||||||
|
# El lanzador NO decide el motor: lo lee de gsettings (language-model) y se
|
||||||
|
# adapta. Asi no hay que tocarlo cada vez que se cambia de cerebro, que es
|
||||||
|
# justo lo que lo dejo desincronizado antes: precargaba Ollama mientras la
|
||||||
|
# app usaba su llamacpp interno, ocupando 2.9 GB de VRAM para nada.
|
||||||
|
#
|
||||||
|
# Y el MODELO tampoco, por lo mismo. Estaba escrito a mano aqui y volvio a
|
||||||
|
# pasar exactamente lo de antes: al cambiar la app a qwen3.5:4b-jarvis, el
|
||||||
|
# lanzador seguia precargando qwen3.5:4b. Los dos no caben en 3717 MiB, asi
|
||||||
|
# que ollama tenia que descargar uno para cargar el otro con el panel ya
|
||||||
|
# esperando: 6,55 s hasta la primera respuesta contra 3,70 con los dos de
|
||||||
|
# acuerdo. El sintoma no era "va lento", era "no se abre el panel solo".
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
APPID=io.github.qwersyk.Newelle
|
||||||
|
OLLAMA_URL=http://localhost:11434
|
||||||
|
|
||||||
|
# La salida de la app iba entera a /dev/null. Cuando el panel dejo de abrirse,
|
||||||
|
# el traceback que lo explicaba —una linea— no estaba en ningun sitio, y el
|
||||||
|
# fallo se diagnostico dos veces mal antes de mirarlo. Ahora se guarda.
|
||||||
|
# misma idea que en los scripts de voz: la raiz sale de donde vive esto
|
||||||
|
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
|
||||||
|
REGISTRO=$RAIZ/jarvis-app.log
|
||||||
|
: > "$REGISTRO" 2>/dev/null || REGISTRO=/dev/null
|
||||||
|
GGUF=$HOME/.var/app/$APPID/config/models/custom_models/qwen3.5-4b.gguf
|
||||||
|
|
||||||
|
# Elige rama: //master es la version propia (jarvis-skin, con reactor arc);
|
||||||
|
# //stable es la de Flathub, la red de seguridad.
|
||||||
|
#
|
||||||
|
# El chequeo mira la typelib de Vte en lib/: si un build deja mal el libdir,
|
||||||
|
# la app arranca y muere con "Namespace Vte not available". Asi el icono
|
||||||
|
# nunca abre una version rota, y se pasa solo a la propia cuando este sana.
|
||||||
|
pick_branch() {
|
||||||
|
local base=$HOME/.local/share/flatpak/app/$APPID/x86_64
|
||||||
|
if [ -f "$base/master/active/files/lib/girepository-1.0/Vte-3.91.typelib" ]; then
|
||||||
|
echo "master"
|
||||||
|
else
|
||||||
|
echo "stable"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
RAMA=$(pick_branch)
|
||||||
|
|
||||||
|
motor() {
|
||||||
|
flatpak run --command=gsettings "$APPID//$RAMA" get "$APPID" language-model 2>/dev/null \
|
||||||
|
| tr -d "'" | tr -d '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
# El modelo que la app tiene elegido de verdad, leido de sus ajustes. Si no se
|
||||||
|
# puede leer se cae al 4b pelado, que es mejor que precargar algo que no existe.
|
||||||
|
modelo() {
|
||||||
|
flatpak run --command=gsettings "$APPID//$RAMA" get "$APPID" llm-settings 2>/dev/null \
|
||||||
|
| python3 -c "
|
||||||
|
import json, sys
|
||||||
|
s = sys.stdin.read().strip()
|
||||||
|
try:
|
||||||
|
d = json.loads(s[1:-1].replace('\\\\\\\\', '\\\\').replace(\"\\\\'\", \"'\"))
|
||||||
|
print(d.get('ollama', {}).get('model') or 'qwen3.5:4b')
|
||||||
|
except Exception:
|
||||||
|
print('qwen3.5:4b')
|
||||||
|
" 2>/dev/null || echo qwen3.5:4b
|
||||||
|
}
|
||||||
|
MODEL=$(modelo)
|
||||||
|
|
||||||
|
notify() { command -v notify-send >/dev/null && notify-send -i jarvis "JARVIS" "$1"; }
|
||||||
|
|
||||||
|
ollama_up() { curl -s --max-time 2 "$OLLAMA_URL/api/version" >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
app_corriendo() { pgrep -f "bin/newelle" >/dev/null; }
|
||||||
|
|
||||||
|
# El llama-server interno de la app, no el de Ollama: el suyo vive en /app/bin.
|
||||||
|
servidor_interno() { pgrep -f "/app/bin/llama-server" | head -1; }
|
||||||
|
|
||||||
|
case "${1:-start}" in
|
||||||
|
--stop)
|
||||||
|
# flatpak kill y no pkill: el patron "bin/newelle" tambien casa con la
|
||||||
|
# propia linea de comandos cuando esto se invoca desde un bash -c inline,
|
||||||
|
# y el script se mataba a si mismo antes de llegar aqui.
|
||||||
|
flatpak kill "$APPID" 2>/dev/null || pkill -f "bin/newelle" 2>/dev/null
|
||||||
|
if [ "$(motor)" = "ollama" ] && ollama_up; then
|
||||||
|
curl -s "$OLLAMA_URL/api/generate" \
|
||||||
|
-d "{\"model\":\"$MODEL\",\"keep_alive\":0}" >/dev/null 2>&1
|
||||||
|
notify "Detenido, VRAM liberada"
|
||||||
|
else
|
||||||
|
notify "Detenido"
|
||||||
|
fi
|
||||||
|
echo "JARVIS detenido."
|
||||||
|
;;
|
||||||
|
|
||||||
|
--status|--status-notify)
|
||||||
|
MOTOR=$(motor)
|
||||||
|
app_corriendo && S1="app: abierta (//$RAMA)" || S1="app: cerrada"
|
||||||
|
S2="cerebro: ${MOTOR:-?}"
|
||||||
|
|
||||||
|
if [ "$MOTOR" = "ollama" ]; then
|
||||||
|
S3=$(curl -s "$OLLAMA_URL/api/ps" 2>/dev/null \
|
||||||
|
| python3 -c 'import json,sys
|
||||||
|
ms = json.load(sys.stdin).get("models", [])
|
||||||
|
print("\n".join("cargado: %s %.1f GB (%.1f en GPU)"
|
||||||
|
% (m["name"], m.get("size",0)/1e9, m.get("size_vram",0)/1e9) for m in ms)
|
||||||
|
or "sin modelos cargados")' 2>/dev/null)
|
||||||
|
[ -n "$S3" ] || S3="ollama: caido"
|
||||||
|
else
|
||||||
|
PID=$(servidor_interno)
|
||||||
|
if [ -n "$PID" ]; then
|
||||||
|
S3="cargado en RAM ($(awk '/VmRSS/{printf "%.1f GB", $2/1048576}' \
|
||||||
|
"/proc/$PID/status" 2>/dev/null))"
|
||||||
|
else
|
||||||
|
S3="sin modelos cargados"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
S4=$(nvidia-smi --query-gpu=memory.used,memory.free --format=csv,noheader 2>/dev/null \
|
||||||
|
| sed 's/^/VRAM usada\/libre: /')
|
||||||
|
|
||||||
|
if [ "$1" = "--status-notify" ]; then
|
||||||
|
notify "$S1 · $S2"$'\n'"$S3"$'\n'"$S4"
|
||||||
|
else
|
||||||
|
printf '%s\n%s\n\n%s\n%s\n' "$S1" "$S2" "$S3" "$S4"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
MOTOR=$(motor)
|
||||||
|
|
||||||
|
if [ "$MOTOR" = "ollama" ]; then
|
||||||
|
if ! ollama_up; then
|
||||||
|
systemctl start ollama 2>/dev/null || sudo systemctl start ollama
|
||||||
|
for _ in $(seq 20); do ollama_up && break; sleep 0.5; done
|
||||||
|
fi
|
||||||
|
ollama_up || { notify "Ollama no arranca"; echo "ERROR: ollama no responde" >&2; exit 1; }
|
||||||
|
# precargar en GPU para que la primera respuesta no tarde.
|
||||||
|
# think:false o se va a 40 s pensando en voz alta.
|
||||||
|
curl -s "$OLLAMA_URL/api/generate" \
|
||||||
|
-d "{\"model\":\"$MODEL\",\"prompt\":\"ok\",\"stream\":false,\"think\":false,\"keep_alive\":\"30m\"}" \
|
||||||
|
>/dev/null 2>&1 &
|
||||||
|
else
|
||||||
|
[ -f "$GGUF" ] || { notify "Falta el modelo: $(basename "$GGUF")"
|
||||||
|
echo "ERROR: no esta $GGUF" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --chat abre Newelle tal cual; sin argumentos va derecho al asistente
|
||||||
|
DIRECTO=si
|
||||||
|
[ "${1:-}" = "--chat" ] && DIRECTO=no
|
||||||
|
|
||||||
|
if app_corriendo; then
|
||||||
|
command -v xdotool >/dev/null && \
|
||||||
|
xdotool search --name "^newelle$" windowactivate 2>/dev/null
|
||||||
|
elif [ "$DIRECTO" = "no" ]; then
|
||||||
|
exec flatpak run "$APPID//$RAMA"
|
||||||
|
else
|
||||||
|
# En segundo plano y no con exec: hay que esperar a que exista la ventana
|
||||||
|
# antes de pedir la pestaña. --run-action se procesa antes de que la
|
||||||
|
# ventana este construida, asi que lanzarlo de golpe no serviria.
|
||||||
|
#
|
||||||
|
# Y esperar al PROCESO no basta: el proceso existe en cuanto python
|
||||||
|
# arranca, varios segundos antes de que haya ventana. Con `sleep 3` a
|
||||||
|
# ojo, un arranque un poco mas lento pierde la carrera y la accion
|
||||||
|
# revienta con "'MyApp' object has no attribute 'win'" — la app abre,
|
||||||
|
# pero en el chat y sin panel. Se espera a la VENTANA, que es la
|
||||||
|
# condicion de verdad.
|
||||||
|
flatpak run "$APPID//$RAMA" >>"$REGISTRO" 2>&1 &
|
||||||
|
for _ in $(seq 60); do
|
||||||
|
if command -v xdotool >/dev/null; then
|
||||||
|
xdotool search --name "^newelle$" >/dev/null 2>&1 && break
|
||||||
|
else
|
||||||
|
pgrep -f "bin/newelle" >/dev/null && break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
# sin xdotool no hay forma de saberlo: se vuelve al margen a ojo
|
||||||
|
command -v xdotool >/dev/null || sleep 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DIRECTO" = "si" ]; then
|
||||||
|
# jarvis_full y no jarvis+hud: el segundo alterna, asi que pulsar el
|
||||||
|
# icono con la app ya abierta te sacaria de pantalla completa.
|
||||||
|
flatpak run "$APPID//$RAMA" --run-action=jarvis_full >>"$REGISTRO" 2>&1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
6
nucleo/.gitignore
vendored
Normal file
6
nucleo/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
datos/cache_voz/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
datos/saber.jsonl
|
||||||
|
datos/diario.jsonl
|
||||||
135
nucleo/arranca.sh
Executable file
135
nucleo/arranca.sh
Executable file
|
|
@ -0,0 +1,135 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Lanzador del JARVIS naranja: el nucleo, fuera del flatpak.
|
||||||
|
#
|
||||||
|
# arranca.sh levanta ollama si hace falta, abre el panel y escucha
|
||||||
|
# arranca.sh --panel solo el panel, sin microfono
|
||||||
|
# arranca.sh --stop lo para y libera la grafica
|
||||||
|
# arranca.sh --estado dice si esta vivo
|
||||||
|
#
|
||||||
|
# Comparado con el lanzador del JARVIS azul, aqui sobra casi todo: no hay que
|
||||||
|
# adivinar la rama del flatpak, ni leer gsettings con escapado de GVariant, ni
|
||||||
|
# preguntarle a la aplicacion que modelo tiene puesto. El nucleo lo sabe porque
|
||||||
|
# es suyo.
|
||||||
|
#
|
||||||
|
# Lo que SI se conserva, porque en esta maquina importa: al parar se le dice a
|
||||||
|
# ollama que suelte el modelo. Son 2,1 GB de una tarjeta de 4, y sin esto whisper
|
||||||
|
# se queda sin sitio la proxima vez.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
|
||||||
|
PYTHON=$RAIZ/venv/bin/python
|
||||||
|
[ -x "$PYTHON" ] || PYTHON=$(command -v python3)
|
||||||
|
OLLAMA_URL=${JARVIS_OLLAMA:-http://127.0.0.1:11434}
|
||||||
|
REGISTRO=$RAIZ/jarvis.log
|
||||||
|
PUERTO=${JARVIS_PUERTO:-8790}
|
||||||
|
|
||||||
|
# Fichero de PID, no pgrep.
|
||||||
|
#
|
||||||
|
# El primer intento fue marcar el proceso con una variable de entorno y buscarla
|
||||||
|
# con `pgrep -f`. No funciona: pgrep mira la LINEA DE ORDENES, y `env VAR=1 cmd`
|
||||||
|
# no deja la variable ahi. El sintoma fue peor que un simple "no lo encuentra":
|
||||||
|
# el lanzador creia que no habia nada corriendo, arrancaba un segundo, y ese
|
||||||
|
# moria con "address already in use" mientras el primero seguia vivo.
|
||||||
|
#
|
||||||
|
# Y buscar por el nombre del script tiene el problema de siempre: el patron
|
||||||
|
# aparece en la linea de ordenes del propio pgrep. Un fichero de PID no se
|
||||||
|
# equivoca.
|
||||||
|
PID=${XDG_RUNTIME_DIR:-/tmp}/jarvis-nucleo.pid
|
||||||
|
|
||||||
|
aviso() { command -v notify-send >/dev/null && \
|
||||||
|
notify-send -i "$RAIZ/jarvis-naranja.svg" "JARVIS" "$1"; }
|
||||||
|
|
||||||
|
modelo() {
|
||||||
|
grep -oP 'MODELO = os\.environ\.get\("JARVIS_MODELO", "\K[^"]+' \
|
||||||
|
"$RAIZ/cerebro/ollama.py" 2>/dev/null || echo "qwen3.5:4b-jarvis"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vivo = hay un pid apuntado Y ese proceso existe. Un pid huerfano de un
|
||||||
|
# reinicio no puede hacernos creer que sigue en marcha.
|
||||||
|
vivo() {
|
||||||
|
[ -f "$PID" ] || return 1
|
||||||
|
local p; p=$(cat "$PID" 2>/dev/null)
|
||||||
|
[ -n "$p" ] && kill -0 "$p" 2>/dev/null
|
||||||
|
}
|
||||||
|
ollama_up() { curl -s --max-time 2 "$OLLAMA_URL/api/version" >/dev/null 2>&1; }
|
||||||
|
puerto_ocupado() { curl -s --max-time 2 "http://127.0.0.1:$PUERTO/" >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
case "${1:-arranca}" in
|
||||||
|
--stop)
|
||||||
|
# Tambien lo que este ocupando el puerto sin fichero de pid: si no, un
|
||||||
|
# huerfano impide arrancar de nuevo y --stop parece no hacer nada.
|
||||||
|
if ! vivo && puerto_ocupado && command -v fuser >/dev/null; then
|
||||||
|
fuser -k "$PUERTO/tcp" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
if vivo; then
|
||||||
|
kill "$(cat "$PID")" 2>/dev/null
|
||||||
|
# margen para que suelte el puerto antes de que nadie lo reclame
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10; do vivo || break; sleep 0.3; done
|
||||||
|
vivo && kill -9 "$(cat "$PID")" 2>/dev/null
|
||||||
|
fi
|
||||||
|
rm -f "$PID"
|
||||||
|
if ollama_up; then
|
||||||
|
# keep_alive 0: suelta el modelo ya, no dentro de cinco minutos
|
||||||
|
curl -s "$OLLAMA_URL/api/generate" \
|
||||||
|
-d "{\"model\":\"$(modelo)\",\"keep_alive\":0}" >/dev/null 2>&1
|
||||||
|
aviso "Detenido, grafica liberada"
|
||||||
|
else
|
||||||
|
aviso "Detenido"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
|
||||||
|
--estado)
|
||||||
|
# Se mira el pid Y el puerto. Con solo el pid, una instancia arrancada a
|
||||||
|
# mano —o superviviente de un lanzador que murio— daba "parado" mientras
|
||||||
|
# el panel contestaba tan tranquilo, que es la peor respuesta posible:
|
||||||
|
# la que hace dudar de si el problema es el programa o quien pregunta.
|
||||||
|
if vivo; then
|
||||||
|
echo "JARVIS naranja: vivo"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if puerto_ocupado; then
|
||||||
|
echo "JARVIS naranja: vivo, pero arrancado por fuera de este lanzador"
|
||||||
|
echo " (no hay $PID; para pararlo: fuser -k $PUERTO/tcp)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "JARVIS naranja: parado"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if vivo || puerto_ocupado; then
|
||||||
|
# Ya esta abierto: en vez de levantar otro —que moriria con "address already
|
||||||
|
# in use" y dejaria un rastro confuso— se trae el panel al frente.
|
||||||
|
aviso "Ya estaba en marcha"
|
||||||
|
# La MISMA ventana que abre el arranque normal. Antes esto era xdg-open, que
|
||||||
|
# abre el navegador POR DEFECTO en una pestaña: segun si JARVIS ya estaba
|
||||||
|
# abierto o no, el mismo icono daba una aplicacion o una pestaña de Firefox.
|
||||||
|
setsid "$RAIZ/ventana.sh" "http://127.0.0.1:$PUERTO/" >/dev/null 2>&1 &
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ollama, si no esta ya. En segundo plano y sin esperar: el catalogo contesta
|
||||||
|
# las ordenes conocidas sin tocar el modelo, asi que JARVIS es util desde el
|
||||||
|
# primer segundo aunque ollama tarde en calentar.
|
||||||
|
if ! ollama_up && command -v ollama >/dev/null; then
|
||||||
|
setsid ollama serve >/dev/null 2>&1 &
|
||||||
|
fi
|
||||||
|
|
||||||
|
# El indice de COFRE, si no esta. En segundo plano: JARVIS arranca sin el y el
|
||||||
|
# cerebro lo usa en cuanto este listo. Reconstruirlo a mano: saber/indexa.py
|
||||||
|
if [ ! -f "$RAIZ/datos/saber.jsonl" ]; then
|
||||||
|
setsid "$PYTHON" "$RAIZ/saber/indexa.py" >/dev/null 2>&1 &
|
||||||
|
fi
|
||||||
|
|
||||||
|
: > "$REGISTRO" 2>/dev/null || REGISTRO=/dev/null
|
||||||
|
aviso "Arrancando"
|
||||||
|
|
||||||
|
MODO=(--panel --escucha)
|
||||||
|
[ "${1:-}" = "--panel" ] && MODO=(--panel)
|
||||||
|
|
||||||
|
# El pid se apunta ANTES del exec y es el de este mismo shell, que con exec
|
||||||
|
# pasa a ser el de Python: asi el fichero nunca apunta a un proceso que no es.
|
||||||
|
echo $$ > "$PID"
|
||||||
|
trap 'rm -f "$PID"' EXIT
|
||||||
|
exec "$PYTHON" -u "$RAIZ/jarvis.py" "${MODO[@]}" >>"$REGISTRO" 2>&1
|
||||||
0
nucleo/boca/__init__.py
Normal file
0
nucleo/boca/__init__.py
Normal file
210
nucleo/boca/voz.py
Normal file
210
nucleo/boca/voz.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"""La boca de JARVIS: convertir texto en sonido, y saber cuando esta sonando.
|
||||||
|
|
||||||
|
Esto es la version de `voces.py` + la mitad de `tts.py` de Newelle, pero sin el
|
||||||
|
motor de handlers: aqui solo hablamos por Piper y por el clon, y los dos son un
|
||||||
|
script de shell que escribe un WAV. Todo lo demas sobraba.
|
||||||
|
|
||||||
|
## Lo que hay que hacer bien y en Newelle estaba mal
|
||||||
|
|
||||||
|
**Saber que esta hablando, y saberlo A TIEMPO.** El eco que envenena el registro
|
||||||
|
de JARVIS —el 21 % confirmado de las frases que no entiende son suyas— viene de
|
||||||
|
que Newelle marca `assistant_speaking` con `GLib.idle_add`, que es asincrono: el
|
||||||
|
audio ya esta sonando mientras el hilo de grabacion sigue leyendo `False`. Aqui
|
||||||
|
la bandera se pone ANTES de lanzar el reproductor y se quita con retardo, porque
|
||||||
|
cuando el proceso termina el sonido todavia esta viajando hasta el microfono.
|
||||||
|
|
||||||
|
**Un solo altavoz.** Con tareas en paralelo, dos respuestas a la vez se pisan.
|
||||||
|
Se habla por turnos con un cerrojo, igual que hacia `_habla` en el panel.
|
||||||
|
|
||||||
|
## La cache
|
||||||
|
|
||||||
|
Clave `sha1(voz|texto normalizado)`. Piper tarda entre medio segundo y dos por
|
||||||
|
frase, y JARVIS repite mucho ("Enseguida, señor", "No he podido, señor"): con
|
||||||
|
cache eso es un `cp`.
|
||||||
|
|
||||||
|
Se guarda en `datos/cache_voz/`, no en la del clon: aquella la genera
|
||||||
|
`generar_cache_voz.py` con su propia clave (sha1 solo del texto) y es de solo
|
||||||
|
lectura para nosotros. `jarvis-voz.sh` ya la consulta por dentro.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
CONFIG = os.path.join(RAIZ, "config")
|
||||||
|
MODELOS = os.path.join(RAIZ, "voz", "modelos")
|
||||||
|
CACHE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"datos", "cache_voz")
|
||||||
|
|
||||||
|
# Cuanto se sigue considerando "hablando" despues de que el reproductor termine.
|
||||||
|
# El sonido tarda en salir del buffer de la tarjeta y en llegar al microfono; sin
|
||||||
|
# esta cola, la ultima silaba de JARVIS se cuela en la siguiente transcripcion.
|
||||||
|
#
|
||||||
|
# El valor sale de medirlo con pruebas/prueba_eco.py, no de suponerlo: con 350
|
||||||
|
# la frase entera dejaba de colarse pero seguia escapandose un fragmento.
|
||||||
|
COLA_MS = int(os.environ.get("JARVIS_COLA_MS", "700"))
|
||||||
|
|
||||||
|
|
||||||
|
def _piper(modelo):
|
||||||
|
return {
|
||||||
|
"orden": [os.path.join(CONFIG, "jarvis-piper.sh")],
|
||||||
|
"entorno": {"JARVIS_PIPER_VOZ": modelo, "JARVIS_PIPER_TONO": "1.0"},
|
||||||
|
"necesita": os.path.join(MODELOS, modelo + ".onnx"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# El orden es el de rotacion. davefx primero porque es la que se eligio a oido.
|
||||||
|
VOCES = {
|
||||||
|
"davefx": _piper("es_ES-davefx-medium"),
|
||||||
|
"clon": {
|
||||||
|
"orden": [os.path.join(CONFIG, "jarvis-voz.sh")],
|
||||||
|
"entorno": {},
|
||||||
|
"necesita": os.path.join(CONFIG, "jarvis-voz.sh"),
|
||||||
|
},
|
||||||
|
"claude": _piper("es_MX-claude-high"),
|
||||||
|
"ald": _piper("es_MX-ald-medium"),
|
||||||
|
"sharvard": _piper("es_ES-sharvard-medium"),
|
||||||
|
}
|
||||||
|
|
||||||
|
POR_DEFECTO = "davefx"
|
||||||
|
|
||||||
|
# Se prueban en orden. paplay y pw-play respetan el enrutado de PipeWire, que es
|
||||||
|
# lo que hace falta para que el cancelador de eco tenga la referencia de lo que
|
||||||
|
# sale por los altavoces. aplay habla con ALSA directamente y se la salta.
|
||||||
|
REPRODUCTORES = (["paplay"], ["pw-play"], ["aplay", "-q"])
|
||||||
|
|
||||||
|
|
||||||
|
def _normaliza(texto: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", texto or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _clave(voz: str, texto: str) -> str:
|
||||||
|
return hashlib.sha1(f"{voz}|{_normaliza(texto)}".encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _reproductor():
|
||||||
|
for r in REPRODUCTORES:
|
||||||
|
if shutil.which(r[0]):
|
||||||
|
return r
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Boca:
|
||||||
|
"""Todo lo que suena sale de aqui, y de uno en uno."""
|
||||||
|
|
||||||
|
def __init__(self, voz: str = POR_DEFECTO, al_cambiar=None):
|
||||||
|
self.voz = voz if voz in VOCES else POR_DEFECTO
|
||||||
|
self._turno = threading.Lock()
|
||||||
|
self._hablando = False
|
||||||
|
self._hasta = 0.0 # instante hasta el que sigue contando como hablando
|
||||||
|
self._al_cambiar = al_cambiar # se avisa al oido para que se calle
|
||||||
|
os.makedirs(CACHE, exist_ok=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ el estado
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hablando(self) -> bool:
|
||||||
|
"""Si esta sonando algo, incluida la cola.
|
||||||
|
|
||||||
|
El oido consulta esto en cada trozo de audio. Tiene que ser barato y,
|
||||||
|
sobre todo, no puede mentir por defecto: mas vale sobrar 300 ms de
|
||||||
|
silencio que colar la propia voz en la transcripcion.
|
||||||
|
"""
|
||||||
|
return self._hablando or time.monotonic() < self._hasta
|
||||||
|
|
||||||
|
def _marca(self, hablando: bool):
|
||||||
|
self._hablando = hablando
|
||||||
|
if not hablando:
|
||||||
|
self._hasta = time.monotonic() + COLA_MS / 1000.0
|
||||||
|
if self._al_cambiar:
|
||||||
|
try:
|
||||||
|
self._al_cambiar(hablando)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- generar
|
||||||
|
|
||||||
|
def _genera(self, texto: str) -> str | None:
|
||||||
|
"""Devuelve la ruta del WAV, de cache o recien hecho."""
|
||||||
|
voz = VOCES[self.voz]
|
||||||
|
destino = os.path.join(CACHE, _clave(self.voz, texto) + ".wav")
|
||||||
|
if os.path.exists(destino) and os.path.getsize(destino) > 0:
|
||||||
|
return destino
|
||||||
|
|
||||||
|
entorno = {**os.environ, **voz["entorno"]}
|
||||||
|
try:
|
||||||
|
subprocess.run(voz["orden"] + [destino, texto], env=entorno,
|
||||||
|
capture_output=True, timeout=120)
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
return None
|
||||||
|
if not os.path.exists(destino) or os.path.getsize(destino) == 0:
|
||||||
|
# Que no se quede un fichero vacio en la cache: el siguiente intento
|
||||||
|
# lo daria por bueno y JARVIS se quedaria mudo para esa frase.
|
||||||
|
if os.path.exists(destino):
|
||||||
|
os.unlink(destino)
|
||||||
|
return None
|
||||||
|
return destino
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- hablar
|
||||||
|
|
||||||
|
def di(self, texto: str) -> bool:
|
||||||
|
"""Habla, esperando turno. Devuelve si sono."""
|
||||||
|
texto = _normaliza(texto)
|
||||||
|
if not texto:
|
||||||
|
return False
|
||||||
|
with self._turno:
|
||||||
|
return self._suelta(texto)
|
||||||
|
|
||||||
|
def di_si_libre(self, texto: str) -> bool:
|
||||||
|
"""Habla solo si nadie tiene el turno. Para avisos que no merecen cola."""
|
||||||
|
if not self._turno.acquire(blocking=False):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return self._suelta(_normaliza(texto))
|
||||||
|
finally:
|
||||||
|
self._turno.release()
|
||||||
|
|
||||||
|
def _suelta(self, texto: str) -> bool:
|
||||||
|
wav = self._genera(texto)
|
||||||
|
if not wav:
|
||||||
|
return False
|
||||||
|
orden = _reproductor()
|
||||||
|
if not orden:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# La bandera ANTES de lanzar el reproductor, no despues y no en otro
|
||||||
|
# hilo. Este es exactamente el fallo de Newelle que causa el eco.
|
||||||
|
self._marca(True)
|
||||||
|
try:
|
||||||
|
subprocess.run(orden + [wav], capture_output=True, timeout=300)
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
self._marca(False)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- voces
|
||||||
|
|
||||||
|
def disponibles(self) -> list:
|
||||||
|
return [n for n, v in VOCES.items() if os.path.exists(v["necesita"])]
|
||||||
|
|
||||||
|
def cambia(self, nombre: str) -> bool:
|
||||||
|
if nombre not in VOCES or not os.path.exists(VOCES[nombre]["necesita"]):
|
||||||
|
return False
|
||||||
|
self.voz = nombre
|
||||||
|
return True
|
||||||
|
|
||||||
|
def siguiente(self) -> str:
|
||||||
|
lista = self.disponibles()
|
||||||
|
if not lista:
|
||||||
|
return self.voz
|
||||||
|
try:
|
||||||
|
i = lista.index(self.voz)
|
||||||
|
except ValueError:
|
||||||
|
i = -1
|
||||||
|
self.voz = lista[(i + 1) % len(lista)]
|
||||||
|
return self.voz
|
||||||
0
nucleo/cara/__init__.py
Normal file
0
nucleo/cara/__init__.py
Normal file
948
nucleo/cara/hud/hud.js
Normal file
948
nucleo/cara/hud/hud.js
Normal file
|
|
@ -0,0 +1,948 @@
|
||||||
|
/* HUD de JARVIS.
|
||||||
|
*
|
||||||
|
* Python empuja eventos con window.jarvis.recibe({tipo, datos}) y el HUD
|
||||||
|
* devuelve pulsaciones por window.webkit.messageHandlers.
|
||||||
|
*
|
||||||
|
* Los niveles llegan a ~15 por segundo para no cargar el puente; el dibujo
|
||||||
|
* va a 60 con requestAnimationFrame e interpola entre envios, asi que se ve
|
||||||
|
* fluido sin pagarlo en llamadas. */
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const BANDAS = 12;
|
||||||
|
const MAX_ACCIONES = 8;
|
||||||
|
|
||||||
|
// El reactor no lleva colores propios: los lee de las variables del CSS, asi
|
||||||
|
// que cambiar la paleta repinta tambien el canvas sin tocar JavaScript. Un
|
||||||
|
// solo tema —naranja neon sobre negro— y por eso ya no hay lista ni rotacion.
|
||||||
|
const VARIABLES = {
|
||||||
|
reposo: ["--r-vivo", "--r-hondo", "--r-nucleo"],
|
||||||
|
escuchando: ["--r-vivo", "--r-hondo", "--r-nucleo"],
|
||||||
|
hablando: ["--r-habla", "--r-habla-hondo", "--r-habla-nucleo"],
|
||||||
|
parado: ["--r-parado", "--r-parado-hondo", "--r-parado-nucleo"],
|
||||||
|
};
|
||||||
|
|
||||||
|
let COLORES = {};
|
||||||
|
|
||||||
|
function leeColores() {
|
||||||
|
const css = getComputedStyle(document.documentElement);
|
||||||
|
COLORES = {};
|
||||||
|
for (const [estado, vars] of Object.entries(VARIABLES)) {
|
||||||
|
const [vivo, hondo, nucleo] = vars.map((v) => css.getPropertyValue(v).trim());
|
||||||
|
COLORES[estado] = { vivo, hondo, nucleo };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// La lista de voces vive en Python (utility/voces.py), no aqui: el boton solo
|
||||||
|
// pide "la siguiente" y pinta lo que le contesten. Asi el rotulo no puede
|
||||||
|
// decir una voz distinta de la que esta puesta de verdad.
|
||||||
|
function ponVoz(datos) {
|
||||||
|
const boton = document.getElementById("voz");
|
||||||
|
if (!boton || !datos) return;
|
||||||
|
boton.querySelector(".rotulo").textContent = "voz: " + (datos.nombre || "?");
|
||||||
|
boton.dataset.voz = datos.id || "";
|
||||||
|
// el motivo del fallo se lee al pasar por encima, no ocupa sitio en pantalla
|
||||||
|
boton.title = datos.error
|
||||||
|
? "No pude cambiar de voz: " + datos.error
|
||||||
|
: "Cambiar la voz. La estrena diciendo una frase";
|
||||||
|
boton.classList.toggle("fallo", Boolean(datos.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Igual que la voz: la lista vive en Python (utility/cerebro.py). Pero este
|
||||||
|
// boton marca ademas si lo que se dice SALE de la maquina, porque eso no se
|
||||||
|
// puede saber mirando el panel de ninguna otra forma.
|
||||||
|
function ponCerebro(datos) {
|
||||||
|
const boton = document.getElementById("cerebro");
|
||||||
|
if (!boton || !datos) return;
|
||||||
|
boton.querySelector(".rotulo").textContent = "cerebro: " + (datos.nombre || "?");
|
||||||
|
boton.dataset.cerebro = datos.id || "";
|
||||||
|
boton.classList.toggle("fuera", Boolean(datos.fuera));
|
||||||
|
boton.classList.toggle("fallo", Boolean(datos.error));
|
||||||
|
boton.title = datos.error
|
||||||
|
? "No pude cambiar de cerebro: " + datos.error
|
||||||
|
: (datos.fuera
|
||||||
|
? "Piensa con Claude (" + (datos.detalle || "") + "). La voz sigue "
|
||||||
|
+ "siendo local, pero lo que preguntes sale de esta maquina."
|
||||||
|
: "Piensa aqui dentro (" + (datos.detalle || "") + "). Nada sale de "
|
||||||
|
+ "la maquina.");
|
||||||
|
if (datos.nombre && datos.nombre !== "\u2014") {
|
||||||
|
aConsola("cerebro: " + datos.nombre
|
||||||
|
+ (datos.fuera ? " (las preguntas salen de la maquina)"
|
||||||
|
: " (todo local)"), "orden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ponDicho(texto, atendido, motivo) {
|
||||||
|
elDicho.textContent = texto || "";
|
||||||
|
elDicho.classList.toggle("visible", Boolean(texto));
|
||||||
|
elDicho.classList.toggle("desoido", !atendido);
|
||||||
|
elDicho.dataset.motivo = atendido ? "" : (motivo || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// La conversacion tambien va a la consola. Bajo el reactor solo se ve la
|
||||||
|
// ultima frase; aqui queda el hilo entero con su hora, que es lo que hace
|
||||||
|
// falta para entender por que no reacciono hace dos minutos.
|
||||||
|
function aConsolaDicho(texto, atendido, motivo) {
|
||||||
|
if (!texto) return;
|
||||||
|
if (atendido) {
|
||||||
|
aConsola("> " + texto, "orden");
|
||||||
|
} else {
|
||||||
|
aConsola("> " + texto, "malo");
|
||||||
|
if (motivo) aConsola(" " + motivo, "malo");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ETIQUETAS = {
|
||||||
|
reposo: "en reposo",
|
||||||
|
// "escuchando" prometia mas de lo que hacia: el microfono esta abierto,
|
||||||
|
// pero mientras no se le llame por su nombre no atiende nada. Decirlo evita
|
||||||
|
// la duda de si el problema es el microfono.
|
||||||
|
dormido: "diga jarvis",
|
||||||
|
escuchando: "escuchando",
|
||||||
|
pensando: "pensando",
|
||||||
|
preguntando: "conteste",
|
||||||
|
hablando: "respondiendo",
|
||||||
|
parado: "desconectado",
|
||||||
|
};
|
||||||
|
|
||||||
|
const nivelesObjetivo = new Array(BANDAS).fill(0.04);
|
||||||
|
const nivelesActuales = new Array(BANDAS).fill(0.04);
|
||||||
|
let estado = "parado";
|
||||||
|
let fase = 0;
|
||||||
|
|
||||||
|
const lienzo = document.getElementById("reactor");
|
||||||
|
const ctx = lienzo.getContext("2d");
|
||||||
|
const elEstado = document.getElementById("estado");
|
||||||
|
const elDicho = document.getElementById("dicho");
|
||||||
|
const elRegistro = document.getElementById("registro");
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ dibujo */
|
||||||
|
|
||||||
|
function ajustarLienzo() {
|
||||||
|
const escala = window.devicePixelRatio || 1;
|
||||||
|
const lado = lienzo.clientWidth;
|
||||||
|
lienzo.width = lado * escala;
|
||||||
|
lienzo.height = lado * escala;
|
||||||
|
ctx.setTransform(escala, 0, 0, escala, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dibujar() {
|
||||||
|
const lado = lienzo.clientWidth;
|
||||||
|
const cx = lado / 2;
|
||||||
|
const cy = lado / 2;
|
||||||
|
const color = COLORES[estado] || COLORES.reposo;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, lado, lado);
|
||||||
|
|
||||||
|
let media = 0;
|
||||||
|
for (let i = 0; i < BANDAS; i++) {
|
||||||
|
nivelesActuales[i] += (nivelesObjetivo[i] - nivelesActuales[i]) * 0.25;
|
||||||
|
media += nivelesActuales[i];
|
||||||
|
}
|
||||||
|
media /= BANDAS;
|
||||||
|
|
||||||
|
const radioBase = lado * 0.17;
|
||||||
|
const largoMax = lado * 0.20;
|
||||||
|
|
||||||
|
// halo: crece con la voz, da la sensacion de energia sin dibujar nada mas
|
||||||
|
const halo = ctx.createRadialGradient(cx, cy, radioBase * 0.2, cx, cy,
|
||||||
|
radioBase + largoMax * (0.5 + media));
|
||||||
|
halo.addColorStop(0, hexA(color.vivo, 0.20 + media * 0.30));
|
||||||
|
halo.addColorStop(0.55, hexA(color.vivo, 0.06));
|
||||||
|
halo.addColorStop(1, hexA(color.vivo, 0));
|
||||||
|
ctx.fillStyle = halo;
|
||||||
|
ctx.fillRect(0, 0, lado, lado);
|
||||||
|
|
||||||
|
// anillos de fondo, quietos: dan escala a lo que se mueve
|
||||||
|
ctx.strokeStyle = hexA(color.vivo, 0.10);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (const f of [0.62, 0.82, 1.0]) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, (radioBase + largoMax) * f, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// espigas: una por banda del analizador
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
for (let i = 0; i < BANDAS; i++) {
|
||||||
|
const ang = (i / BANDAS) * Math.PI * 2 + fase - Math.PI / 2;
|
||||||
|
const nivel = Math.min(1, nivelesActuales[i]);
|
||||||
|
const r0 = radioBase;
|
||||||
|
const r1 = radioBase + largoMax * (0.10 + nivel * 0.9);
|
||||||
|
|
||||||
|
const grad = ctx.createLinearGradient(
|
||||||
|
cx + Math.cos(ang) * r0, cy + Math.sin(ang) * r0,
|
||||||
|
cx + Math.cos(ang) * r1, cy + Math.sin(ang) * r1);
|
||||||
|
grad.addColorStop(0, hexA(color.vivo, 0.95));
|
||||||
|
grad.addColorStop(1, hexA(color.hondo, 0.15));
|
||||||
|
|
||||||
|
ctx.strokeStyle = grad;
|
||||||
|
ctx.lineWidth = Math.max(2, lado * 0.010);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + Math.cos(ang) * r0, cy + Math.sin(ang) * r0);
|
||||||
|
ctx.lineTo(cx + Math.cos(ang) * r1, cy + Math.sin(ang) * r1);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// tres arcos girando a distinta velocidad y sentido: nunca parece congelado
|
||||||
|
ctx.lineWidth = Math.max(1.5, lado * 0.005);
|
||||||
|
const arcos = [
|
||||||
|
{ r: 1.30, giro: 1.0, largo: 1.5, alfa: 0.55 },
|
||||||
|
{ r: 1.52, giro: -0.6, largo: 1.0, alfa: 0.38 },
|
||||||
|
{ r: 1.72, giro: 0.35, largo: 2.2, alfa: 0.22 },
|
||||||
|
];
|
||||||
|
for (const a of arcos) {
|
||||||
|
const inicio = fase * a.giro;
|
||||||
|
ctx.strokeStyle = hexA(color.vivo, a.alfa);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, radioBase * a.r, inicio, inicio + a.largo);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// triangulo del icono
|
||||||
|
const rt = radioBase * 0.66;
|
||||||
|
ctx.strokeStyle = hexA(color.nucleo, 0.75);
|
||||||
|
ctx.lineWidth = Math.max(1.5, lado * 0.004);
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const ang = (i / 3) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
const x = cx + Math.cos(ang) * rt;
|
||||||
|
const y = cy + Math.sin(ang) * rt;
|
||||||
|
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// nucleo latiendo
|
||||||
|
const rn = radioBase * (0.26 + media * 0.14);
|
||||||
|
const gn = ctx.createRadialGradient(cx, cy, 0, cx, cy, rn * 2.4);
|
||||||
|
gn.addColorStop(0, hexA(color.nucleo, 1));
|
||||||
|
gn.addColorStop(0.4, hexA(color.vivo, 0.65));
|
||||||
|
gn.addColorStop(1, hexA(color.vivo, 0));
|
||||||
|
ctx.fillStyle = gn;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, rn * 2.4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
fase += estado === "hablando" ? 0.020 : 0.007;
|
||||||
|
requestAnimationFrame(dibujar);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexA(hex, alfa) {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alfa})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ eventos */
|
||||||
|
|
||||||
|
function ponEstado(nuevo) {
|
||||||
|
if (nuevo === estado) return;
|
||||||
|
estado = nuevo;
|
||||||
|
elEstado.textContent = ETIQUETAS[nuevo] || nuevo;
|
||||||
|
elEstado.className = "estado " + nuevo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ponMedidor(clave, porcentaje, texto) {
|
||||||
|
const fila = document.querySelector(`.medidor[data-clave="${clave}"]`);
|
||||||
|
if (!fila) return;
|
||||||
|
fila.querySelector("i").style.width = Math.min(100, porcentaje) + "%";
|
||||||
|
fila.querySelector(".cifra").textContent = texto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// El rotulo dice lo que PASA, no lo que hace el boton: leer "escuchando" y
|
||||||
|
// ver el punto encendido no deja lugar a dudas; "silenciar" obligaria a
|
||||||
|
// deducir si es lo que hace o lo que esta puesto.
|
||||||
|
const ROTULOS = {
|
||||||
|
parar: { on: "parar", off: "reanudar" },
|
||||||
|
// El rotulo dice como ESTA: "escuchando" es que atiende todo lo que oiga,
|
||||||
|
// "solo con nombre" es que hay que llamarle antes de cada orden.
|
||||||
|
sueno: { on: "escuchando", off: "solo con nombre" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function ponRotulo(boton) {
|
||||||
|
const textos = ROTULOS[boton.dataset.accion];
|
||||||
|
if (!textos) return;
|
||||||
|
boton.querySelector(".rotulo").textContent = textos[boton.dataset.estado] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ponControles(datos) {
|
||||||
|
for (const [accion, encendido] of Object.entries(datos)) {
|
||||||
|
const boton = document.querySelector(`.controles button[data-accion="${accion}"]`);
|
||||||
|
if (!boton) continue;
|
||||||
|
boton.dataset.estado = encendido ? "on" : "off";
|
||||||
|
ponRotulo(boton);
|
||||||
|
}
|
||||||
|
// Parado no es el estado de un boton, es el estado del asistente: se marca
|
||||||
|
// en el documento entero. Un HUD encendido con un boton pequeño en rojo se
|
||||||
|
// lee como "funcionando"; apagado no se puede confundir.
|
||||||
|
if (datos.parar !== undefined) {
|
||||||
|
document.body.classList.toggle("parado", !datos.parar);
|
||||||
|
if (!datos.parar) ponEstado("parado");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatos: cada constante se lee de un vistazo o no sirve de nada, asi que
|
||||||
|
// las unidades se eligen segun la magnitud en vez de soltar el numero crudo.
|
||||||
|
const CONSTANTES = {
|
||||||
|
temperatura: (v) => `${v.toFixed(0)} °C`,
|
||||||
|
carga: (v) => v.map((x) => x.toFixed(2)).join(" "),
|
||||||
|
uptime: (v) => (v < 3600 ? `${(v / 60).toFixed(0)} min`
|
||||||
|
: v < 86400 ? `${(v / 3600).toFixed(1)} h`
|
||||||
|
: `${(v / 86400).toFixed(1)} dias`),
|
||||||
|
bateria: (v) => `${v.toFixed(0)} %`,
|
||||||
|
red: (v) => `${fmtKB(v.baja)} ↓ ${fmtKB(v.sube)} ↑`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtKB(kb) {
|
||||||
|
return kb >= 1024 ? `${(kb / 1024).toFixed(1)}M` : `${kb.toFixed(0)}K`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ponConstantes(datos) {
|
||||||
|
for (const [clave, formato] of Object.entries(CONSTANTES)) {
|
||||||
|
if (datos[clave] === undefined) continue;
|
||||||
|
const fila = document.querySelector(`.constantes li[data-clave="${clave}"]`);
|
||||||
|
if (!fila) continue;
|
||||||
|
fila.querySelector("b").textContent = formato(datos[clave]);
|
||||||
|
// 80 grados es donde empieza a preocupar en un portatil
|
||||||
|
if (clave === "temperatura") fila.classList.toggle("alerta", datos[clave] >= 80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Las medidas de JARVIS: latencia, a donde va cada orden, y el RAG. Salen del
|
||||||
|
// diario y las manda el panel cada 5 s por el canal "medidas".
|
||||||
|
const MEDIDAS = {
|
||||||
|
lat_p50: (v) => `${v.toFixed(1)} s`,
|
||||||
|
lat_p95: (v) => `${v.toFixed(1)} s`,
|
||||||
|
pct_cerebro: (v) => `${v} %`,
|
||||||
|
pct_ruido: (v) => `${v} %`,
|
||||||
|
vol24: (v) => `${v}`,
|
||||||
|
rag: (v) => `${v.hit5} %`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function ponMedidas(datos) {
|
||||||
|
if (!datos || !datos.n) return;
|
||||||
|
const caja = document.getElementById("rendimiento");
|
||||||
|
if (!caja) return;
|
||||||
|
for (const [clave, formato] of Object.entries(MEDIDAS)) {
|
||||||
|
if (datos[clave] === undefined) continue;
|
||||||
|
const fila = caja.querySelector(`li[data-clave="${clave}"]`);
|
||||||
|
if (!fila) continue;
|
||||||
|
fila.querySelector("b").textContent = formato(datos[clave]);
|
||||||
|
}
|
||||||
|
// latencia p95: por encima de 5 s la mediana ya se nota al hablar
|
||||||
|
const p95 = caja.querySelector('li[data-clave="lat_p95"]');
|
||||||
|
if (p95) p95.classList.toggle("alerta", datos.lat_p95 >= 8);
|
||||||
|
// ruido alto = Whisper oyendose a si mismo, envenena el diario
|
||||||
|
const rr = caja.querySelector('li[data-clave="pct_ruido"]');
|
||||||
|
if (rr) rr.classList.toggle("alerta", datos.pct_ruido >= 25);
|
||||||
|
|
||||||
|
// la barra de reparto: cerebro (lento) vs catalogo (instantaneo) vs ruido
|
||||||
|
const barra = document.getElementById("reparto");
|
||||||
|
if (barra) {
|
||||||
|
const set = (sel, f) => {
|
||||||
|
const t = barra.querySelector(sel);
|
||||||
|
if (t) t.style.setProperty("--f", Math.max(0, f));
|
||||||
|
};
|
||||||
|
set(".cerebro", datos.pct_cerebro);
|
||||||
|
set(".catalogo", datos.pct_catalogo);
|
||||||
|
set(".ruido", datos.pct_ruido);
|
||||||
|
barra.title = `al cerebro ${datos.pct_cerebro}% · catalogo ${datos.pct_catalogo}% · ruido ${datos.pct_ruido}% (ultimas ${datos.n})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ponNucleos(cargas) {
|
||||||
|
const caja = document.getElementById("nucleos");
|
||||||
|
while (caja.children.length < cargas.length) caja.appendChild(document.createElement("span"));
|
||||||
|
cargas.forEach((carga, i) => {
|
||||||
|
const celda = caja.children[i];
|
||||||
|
celda.style.setProperty("--carga", Math.min(100, carga) + "%");
|
||||||
|
celda.title = `nucleo ${i}: ${carga.toFixed(0)} %`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ponProcesos(lista) {
|
||||||
|
const caja = document.getElementById("procesos");
|
||||||
|
caja.textContent = "";
|
||||||
|
for (const p of lista) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
// el propio asistente se marca: es el que interesa vigilar
|
||||||
|
if (/newelle|ollama|llama|python/.test(p.nombre)) li.className = "propio";
|
||||||
|
const n = document.createElement("span");
|
||||||
|
n.className = "nombre";
|
||||||
|
n.textContent = p.nombre;
|
||||||
|
n.title = `pid ${p.pid}`;
|
||||||
|
const c = document.createElement("span");
|
||||||
|
c.className = "pcpu";
|
||||||
|
c.textContent = p.cpu.toFixed(0) + "%";
|
||||||
|
const m = document.createElement("span");
|
||||||
|
m.className = "pmem";
|
||||||
|
m.textContent = p.mem.toFixed(1) + "%";
|
||||||
|
li.append(n, c, m);
|
||||||
|
caja.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------- catalogo */
|
||||||
|
|
||||||
|
let CATALOGO = [];
|
||||||
|
|
||||||
|
function pintaCatalogo(filtro = "") {
|
||||||
|
const caja = document.getElementById("catalogo");
|
||||||
|
caja.textContent = "";
|
||||||
|
const f = filtro.trim().toLowerCase();
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const { grupo, acciones } of CATALOGO) {
|
||||||
|
const encajan = acciones.filter(
|
||||||
|
(a) => !f || a.frase.includes(f) || a.id.includes(f) || grupo.includes(f));
|
||||||
|
if (!encajan.length) continue;
|
||||||
|
|
||||||
|
const titulo = document.createElement("p");
|
||||||
|
titulo.className = "grupo";
|
||||||
|
titulo.textContent = `${grupo} · ${encajan.length}`;
|
||||||
|
caja.appendChild(titulo);
|
||||||
|
|
||||||
|
for (const a of encajan) {
|
||||||
|
total++;
|
||||||
|
const fila = document.createElement("div");
|
||||||
|
fila.className = "catalogo-item";
|
||||||
|
fila.title = "Pulsa para que te la explique en voz alta";
|
||||||
|
fila.tabIndex = 0;
|
||||||
|
fila.setAttribute("role", "button");
|
||||||
|
|
||||||
|
// Pulsar una accion la EXPLICA, no la ejecuta. Es la diferencia entre
|
||||||
|
// una lista de referencia y un campo de minas: uno mira "sabe hacer"
|
||||||
|
// justo cuando no sabe que hace cada cosa, y ahi el clic no puede
|
||||||
|
// disparar un comando en la maquina.
|
||||||
|
const explica = () => {
|
||||||
|
try {
|
||||||
|
window.webkit.messageHandlers.accion.postMessage("explica:" + a.id);
|
||||||
|
} catch (e) { /* suelto en el navegador: no hay a quien preguntar */ }
|
||||||
|
};
|
||||||
|
fila.addEventListener("click", explica);
|
||||||
|
fila.addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); explica(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
const texto = document.createElement("span");
|
||||||
|
texto.textContent = a.frase;
|
||||||
|
fila.appendChild(texto);
|
||||||
|
|
||||||
|
// las que esperan algo detras se marcan: si no, uno dice la frase a
|
||||||
|
// secas, no pasa nada y parece que la accion esta rota
|
||||||
|
if (a.captura) {
|
||||||
|
const hueco = document.createElement("span");
|
||||||
|
hueco.className = "hueco";
|
||||||
|
hueco.textContent = "+ que";
|
||||||
|
fila.appendChild(hueco);
|
||||||
|
}
|
||||||
|
if (a.confirmar) {
|
||||||
|
const aviso = document.createElement("span");
|
||||||
|
aviso.className = "aviso";
|
||||||
|
aviso.textContent = "⚠";
|
||||||
|
aviso.title = "pide confirmacion antes de hacerlo";
|
||||||
|
fila.appendChild(aviso);
|
||||||
|
}
|
||||||
|
caja.appendChild(fila);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!total) {
|
||||||
|
const vacio = document.createElement("p");
|
||||||
|
vacio.className = "vacio";
|
||||||
|
vacio.textContent = "nada encaja con ese filtro";
|
||||||
|
caja.appendChild(vacio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lista numerada de resultados de una busqueda: se puede leer y luego decir
|
||||||
|
// "abre el tercero". Va en la vista de acciones hechas, encabezada por el
|
||||||
|
// titulo de la busqueda.
|
||||||
|
function muestraCandado(datos) {
|
||||||
|
const c = document.getElementById("candado");
|
||||||
|
const msg = document.getElementById("candado-msg");
|
||||||
|
msg.textContent = datos.mensaje || "Introduzca la contraseña";
|
||||||
|
msg.classList.toggle("error", !!datos.error);
|
||||||
|
c.classList.remove("oculto");
|
||||||
|
const clave = document.getElementById("candado-clave");
|
||||||
|
clave.value = "";
|
||||||
|
clave.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function enviaClave() {
|
||||||
|
const clave = document.getElementById("candado-clave");
|
||||||
|
if (!clave.value) return;
|
||||||
|
try {
|
||||||
|
window.webkit.messageHandlers.accion.postMessage("desbloquear:" + clave.value);
|
||||||
|
} catch (e) { /* suelto en el navegador: no hay a quien mandarla */ }
|
||||||
|
clave.value = "";
|
||||||
|
document.getElementById("candado-msg").textContent = "Comprobando...";
|
||||||
|
}
|
||||||
|
|
||||||
|
function muestraResultados(datos) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "resultados";
|
||||||
|
const h = document.createElement("span");
|
||||||
|
h.className = "herramienta";
|
||||||
|
h.textContent = datos.titulo || "resultados";
|
||||||
|
li.appendChild(h);
|
||||||
|
const ol = document.createElement("ol");
|
||||||
|
for (const item of (datos.items || [])) {
|
||||||
|
const el = document.createElement("li");
|
||||||
|
el.value = item.n;
|
||||||
|
el.textContent = item.texto;
|
||||||
|
ol.appendChild(el);
|
||||||
|
}
|
||||||
|
li.appendChild(ol);
|
||||||
|
elRegistro.prepend(li);
|
||||||
|
while (elRegistro.children.length > MAX_ACCIONES) {
|
||||||
|
elRegistro.lastElementChild.remove();
|
||||||
|
}
|
||||||
|
cambiaVista("registro");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ------------------------------------------------------- la consola ---
|
||||||
|
|
||||||
|
Lo que echa de menos cualquiera que venga de una terminal: ver que esta
|
||||||
|
pasando MIENTRAS pasa, y poder cortarlo. Las tarjetas dicen el estado;
|
||||||
|
esto dice el detalle, linea a linea y en orden. */
|
||||||
|
|
||||||
|
const elConsola = document.getElementById("consola");
|
||||||
|
const elConsolaEstado = document.getElementById("consola-estado");
|
||||||
|
const MAX_LINEAS_CONSOLA = 400; // mas que esto no se lee y pesa
|
||||||
|
|
||||||
|
function aConsola(texto, clase) {
|
||||||
|
if (!elConsola || !texto) return;
|
||||||
|
const linea = document.createElement("span");
|
||||||
|
linea.className = "linea " + (clase || "");
|
||||||
|
const hora = new Date().toTimeString().slice(0, 8);
|
||||||
|
linea.textContent = hora + " " + texto;
|
||||||
|
elConsola.appendChild(linea);
|
||||||
|
while (elConsola.childElementCount > MAX_LINEAS_CONSOLA) {
|
||||||
|
elConsola.firstElementChild.remove();
|
||||||
|
}
|
||||||
|
// pegada abajo, como una terminal: si el usuario ha subido a leer, se
|
||||||
|
// respeta y no se le arrastra el scroll
|
||||||
|
const pegada = elConsola.scrollHeight - elConsola.scrollTop
|
||||||
|
- elConsola.clientHeight < 60;
|
||||||
|
if (pegada) elConsola.scrollTop = elConsola.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function consolaEstado(texto, ocupado) {
|
||||||
|
if (!elConsolaEstado) return;
|
||||||
|
elConsolaEstado.textContent = texto;
|
||||||
|
elConsolaEstado.classList.toggle("ocupado", Boolean(ocupado));
|
||||||
|
const b = document.getElementById("consola-parar");
|
||||||
|
if (b) b.disabled = !ocupado;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------- tareas en marcha */
|
||||||
|
|
||||||
|
// Cuanto se queda en pantalla una tarea que termino bien y fue rapida.
|
||||||
|
// Las lentas y las que fallan NO se van solas: si algo tardo doce segundos
|
||||||
|
// uno quiere leer que salio, y si fallo, mas todavia.
|
||||||
|
const RAPIDA_S = 2.0;
|
||||||
|
const DESVANECE_MS = 3000;
|
||||||
|
|
||||||
|
const elTareas = document.getElementById("tareas");
|
||||||
|
const tarjetas = new Map(); // id -> {nodo, reloj, fin}
|
||||||
|
|
||||||
|
function mandaTarea(orden, id) {
|
||||||
|
try {
|
||||||
|
window.webkit.messageHandlers.accion.postMessage(`${orden}:${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
// suelto en el navegador, sin Python detras: se simula para poder ver
|
||||||
|
// el diseño sin levantar la aplicacion entera
|
||||||
|
if (orden === "cerrar_tarea") quitaTarjeta(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function creaTarjeta(t) {
|
||||||
|
const nodo = document.createElement("div");
|
||||||
|
nodo.className = "tarea";
|
||||||
|
nodo.dataset.id = t.id;
|
||||||
|
|
||||||
|
const titulo = document.createElement("div");
|
||||||
|
titulo.className = "titulo";
|
||||||
|
titulo.textContent = t.titulo;
|
||||||
|
titulo.title = t.titulo;
|
||||||
|
|
||||||
|
const fila = document.createElement("div");
|
||||||
|
fila.className = "fila";
|
||||||
|
|
||||||
|
const reloj = document.createElement("span");
|
||||||
|
reloj.className = "reloj";
|
||||||
|
const giro = document.createElement("i");
|
||||||
|
giro.className = "giro";
|
||||||
|
reloj.append(giro, document.createTextNode("0.0s"));
|
||||||
|
|
||||||
|
const parar = document.createElement("button");
|
||||||
|
parar.textContent = "■";
|
||||||
|
parar.title = "Parar esta tarea";
|
||||||
|
parar.addEventListener("click", () => mandaTarea("parar_tarea", t.id));
|
||||||
|
|
||||||
|
const cerrar = document.createElement("button");
|
||||||
|
cerrar.textContent = "×";
|
||||||
|
cerrar.title = "Cerrar la tarjeta (si sigue viva, la para)";
|
||||||
|
cerrar.addEventListener("click", () => mandaTarea("cerrar_tarea", t.id));
|
||||||
|
|
||||||
|
const botones = document.createElement("span");
|
||||||
|
botones.append(parar, cerrar);
|
||||||
|
fila.append(reloj, botones);
|
||||||
|
|
||||||
|
const eco = document.createElement("div");
|
||||||
|
eco.className = "eco";
|
||||||
|
|
||||||
|
nodo.append(titulo, fila, eco);
|
||||||
|
elTareas.append(nodo);
|
||||||
|
|
||||||
|
const ficha = { nodo, reloj, parar, eco, fin: null };
|
||||||
|
tarjetas.set(t.id, ficha);
|
||||||
|
return ficha;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ponTarea(t) {
|
||||||
|
const nueva = !tarjetas.has(t.id);
|
||||||
|
const ficha = tarjetas.get(t.id) || creaTarjeta(t);
|
||||||
|
// la consola cuenta el detalle que no cabe en la tarjeta
|
||||||
|
if (nueva) aConsola("$ " + t.titulo, "orden");
|
||||||
|
const ultima = (t.lineas && t.lineas.length) ? t.lineas[t.lineas.length - 1] : "";
|
||||||
|
if (ultima && ficha.ultimoEco !== ultima) { ficha.ultimoEco = ultima; aConsola(" " + ultima); }
|
||||||
|
if (t.estado !== "corriendo" && ficha.estadoPrevio !== t.estado) {
|
||||||
|
ficha.estadoPrevio = t.estado;
|
||||||
|
aConsola(" → " + t.estado + " (" + t.segundos.toFixed(1) + "s)",
|
||||||
|
t.estado === "fallo" ? "malo" : "bueno");
|
||||||
|
}
|
||||||
|
const vivas = [...tarjetas.values()].filter((f) => f.fin === null).length;
|
||||||
|
consolaEstado(vivas ? vivas + " en marcha" : "en reposo", vivas > 0);
|
||||||
|
const { nodo, eco, parar } = ficha;
|
||||||
|
|
||||||
|
nodo.dataset.estado = t.estado;
|
||||||
|
eco.textContent = (t.lineas && t.lineas.length)
|
||||||
|
? t.lineas[t.lineas.length - 1] : "";
|
||||||
|
|
||||||
|
if (t.estado === "corriendo") {
|
||||||
|
ficha.fin = null;
|
||||||
|
parar.disabled = false;
|
||||||
|
ponReloj(ficha, t.segundos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminada: el reloj se congela en lo que tardo y el boton de parar ya
|
||||||
|
// no tiene nada que parar.
|
||||||
|
ficha.fin = t.segundos;
|
||||||
|
parar.disabled = true;
|
||||||
|
ponReloj(ficha, t.segundos);
|
||||||
|
|
||||||
|
if (t.estado === "hecha" && t.segundos <= RAPIDA_S) {
|
||||||
|
// Lo instantaneo no merece una tarjeta permanente: aparece, confirma
|
||||||
|
// que se hizo, y se va sola.
|
||||||
|
setTimeout(() => {
|
||||||
|
nodo.classList.add("desvanece");
|
||||||
|
setTimeout(() => quitaTarjeta(t.id), 520);
|
||||||
|
}, DESVANECE_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ponReloj(ficha, segundos) {
|
||||||
|
// El nodo de texto es el segundo hijo: el primero es el punto que late
|
||||||
|
ficha.reloj.lastChild.textContent = segundos.toFixed(1) + "s";
|
||||||
|
}
|
||||||
|
|
||||||
|
function quitaTarjeta(id) {
|
||||||
|
const ficha = tarjetas.get(id);
|
||||||
|
if (!ficha) return;
|
||||||
|
ficha.nodo.remove();
|
||||||
|
tarjetas.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// El reloj de las vivas lo lleva el HUD, no Python: si el tiempo solo
|
||||||
|
// avanzase con los avisos del puente, una tarea callada —un `find` largo que
|
||||||
|
// no escribe nada— tendria el contador congelado y pareceria colgada.
|
||||||
|
setInterval(() => {
|
||||||
|
for (const [id, ficha] of tarjetas) {
|
||||||
|
if (ficha.fin !== null) continue;
|
||||||
|
const t = parseFloat(ficha.reloj.lastChild.textContent) || 0;
|
||||||
|
ponReloj(ficha, t + 0.1);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
function anotaAccion(datos) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
if (datos.error) li.className = "error";
|
||||||
|
const h = document.createElement("span");
|
||||||
|
h.className = "herramienta";
|
||||||
|
h.textContent = datos.herramienta || "accion";
|
||||||
|
const s = document.createElement("span");
|
||||||
|
s.className = "salida";
|
||||||
|
s.textContent = datos.salida || "";
|
||||||
|
li.append(h, s);
|
||||||
|
elRegistro.prepend(li);
|
||||||
|
// al ejecutar algo se salta sola a la vista de lo hecho: es lo que uno
|
||||||
|
// quiere ver en ese momento
|
||||||
|
cambiaVista("registro");
|
||||||
|
while (elRegistro.children.length > MAX_ACCIONES) {
|
||||||
|
elRegistro.lastElementChild.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.jarvis = {
|
||||||
|
recibe(mensaje) {
|
||||||
|
const { tipo, datos } = mensaje;
|
||||||
|
|
||||||
|
if (tipo === "pulso") {
|
||||||
|
for (let i = 0; i < BANDAS && i < datos.niveles.length; i++) {
|
||||||
|
nivelesObjetivo[i] = datos.niveles[i];
|
||||||
|
}
|
||||||
|
ponEstado(datos.estado);
|
||||||
|
|
||||||
|
} else if (tipo === "estado") {
|
||||||
|
ponEstado(datos);
|
||||||
|
|
||||||
|
} else if (tipo === "dicho") {
|
||||||
|
// Lo que ha oido, tanto si le hace caso como si no. Lo segundo importa
|
||||||
|
// igual o mas: sin verlo, un JARVIS que te oye pero te ignora —porque
|
||||||
|
// no le has llamado o porque esta bloqueado— es indistinguible de un
|
||||||
|
// microfono roto.
|
||||||
|
ponDicho(datos.texto, datos.atendido !== false, datos.motivo);
|
||||||
|
aConsolaDicho(datos.texto, datos.atendido !== false, datos.motivo);
|
||||||
|
|
||||||
|
} else if (tipo === "preguntando") {
|
||||||
|
// Una pregunta en el aire cambia lo que significa hablar: lo siguiente
|
||||||
|
// que digas es la RESPUESTA, no una orden. Si no se ve, uno contesta
|
||||||
|
// "informes" y no entiende por que no ha buscado nada.
|
||||||
|
document.documentElement.classList.toggle("preguntando",
|
||||||
|
Boolean(datos.texto));
|
||||||
|
if (datos.texto) {
|
||||||
|
ponEstado("preguntando");
|
||||||
|
aConsola("? " + datos.texto, "orden");
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (tipo === "respuesta") {
|
||||||
|
ponDicho(datos.texto, true, "");
|
||||||
|
aConsola(" " + datos.texto, "bueno");
|
||||||
|
|
||||||
|
} else if (tipo === "accion") {
|
||||||
|
anotaAccion(datos);
|
||||||
|
|
||||||
|
} else if (tipo === "tarea") {
|
||||||
|
ponTarea(datos);
|
||||||
|
|
||||||
|
} else if (tipo === "tarea_fuera") {
|
||||||
|
quitaTarjeta(datos.id);
|
||||||
|
|
||||||
|
} else if (tipo === "bloqueo") {
|
||||||
|
muestraCandado(datos);
|
||||||
|
|
||||||
|
} else if (tipo === "desbloqueado") {
|
||||||
|
document.getElementById("candado").classList.add("oculto");
|
||||||
|
|
||||||
|
} else if (tipo === "resultados") {
|
||||||
|
muestraResultados(datos);
|
||||||
|
|
||||||
|
} else if (tipo === "voz") {
|
||||||
|
ponVoz(datos);
|
||||||
|
|
||||||
|
} else if (tipo === "cerebro") {
|
||||||
|
ponCerebro(datos);
|
||||||
|
|
||||||
|
} else if (tipo === "catalogo") {
|
||||||
|
CATALOGO = datos;
|
||||||
|
pintaCatalogo(document.getElementById("filtro").value);
|
||||||
|
|
||||||
|
} else if (tipo === "controles") {
|
||||||
|
ponControles(datos);
|
||||||
|
|
||||||
|
} else if (tipo === "medidas") {
|
||||||
|
ponMedidas(datos);
|
||||||
|
|
||||||
|
} else if (tipo === "telemetria") {
|
||||||
|
if (datos.nucleos) ponNucleos(datos.nucleos);
|
||||||
|
ponConstantes(datos);
|
||||||
|
if (datos.swap) {
|
||||||
|
ponMedidor("swap", 100 * datos.swap.usada / datos.swap.total,
|
||||||
|
`${datos.swap.usada.toFixed(1)}/${datos.swap.total.toFixed(0)} GB`);
|
||||||
|
}
|
||||||
|
if (datos.disco) {
|
||||||
|
ponMedidor("disco", 100 * datos.disco.usado / datos.disco.total,
|
||||||
|
`${datos.disco.usado.toFixed(2)}/${datos.disco.total.toFixed(1)} TB`);
|
||||||
|
}
|
||||||
|
if (datos.procesos) ponProcesos(datos.procesos);
|
||||||
|
if (datos.cpu !== undefined) {
|
||||||
|
ponMedidor("cpu", datos.cpu, datos.cpu.toFixed(0) + " %");
|
||||||
|
}
|
||||||
|
if (datos.ram) {
|
||||||
|
ponMedidor("ram", 100 * datos.ram.usada / datos.ram.total,
|
||||||
|
`${datos.ram.usada.toFixed(0)}/${datos.ram.total.toFixed(0)} GB`);
|
||||||
|
}
|
||||||
|
if (datos.gpu) {
|
||||||
|
ponMedidor("gpu", datos.gpu.uso, datos.gpu.uso.toFixed(0) + " %");
|
||||||
|
ponMedidor("vram",
|
||||||
|
100 * datos.gpu.vram_usada / datos.gpu.vram_total,
|
||||||
|
`${datos.gpu.vram_usada.toFixed(1)}/${datos.gpu.vram_total.toFixed(1)} GB`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ arranque */
|
||||||
|
|
||||||
|
// Los interruptores NO se pintan al pulsarlos: se manda la orden y se espera
|
||||||
|
// a que Python conteste con el estado real (evento "controles"). Asi el
|
||||||
|
// boton nunca dice "silenciado" con el microfono abierto.
|
||||||
|
document.querySelectorAll(".controles button[data-accion]").forEach((boton) => {
|
||||||
|
boton.addEventListener("click", () => {
|
||||||
|
try {
|
||||||
|
window.webkit.messageHandlers.accion.postMessage(boton.dataset.accion);
|
||||||
|
} catch (e) {
|
||||||
|
// suelto en el navegador: no hay Python, se conmuta a mano para poder
|
||||||
|
// ver como queda el diseño
|
||||||
|
if (boton.dataset.estado) {
|
||||||
|
boton.dataset.estado = boton.dataset.estado === "on" ? "off" : "on";
|
||||||
|
ponRotulo(boton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function cambiaVista(cual) {
|
||||||
|
for (const b of document.querySelectorAll(".pestanas button")) {
|
||||||
|
b.classList.toggle("activa", b.dataset.vista === cual);
|
||||||
|
}
|
||||||
|
for (const v of document.querySelectorAll(".vista")) {
|
||||||
|
v.classList.toggle("oculta", v.id !== "vista-" + cual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(".pestanas button").forEach((b) => {
|
||||||
|
b.addEventListener("click", () => cambiaVista(b.dataset.vista));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("filtro").addEventListener("input", (e) => {
|
||||||
|
pintaCatalogo(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("candado-ok").addEventListener("click", enviaClave);
|
||||||
|
document.getElementById("candado-clave").addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") enviaClave();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cortar lo que este corriendo: el boton y Ctrl+C, que es lo que los dedos
|
||||||
|
// hacen solos cuando algo se atasca.
|
||||||
|
function cortarTodo() {
|
||||||
|
let n = 0;
|
||||||
|
for (const [id, ficha] of tarjetas) {
|
||||||
|
if (ficha.fin === null) { mandaTarea("parar_tarea", id); n++; }
|
||||||
|
}
|
||||||
|
aConsola(n ? "^C cortando " + n + " tarea(s)" : "^C no hay nada corriendo", "malo");
|
||||||
|
}
|
||||||
|
|
||||||
|
const botonCortar = document.getElementById("consola-parar");
|
||||||
|
if (botonCortar) botonCortar.addEventListener("click", cortarTodo);
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.ctrlKey && (ev.key === "c" || ev.key === "C")) {
|
||||||
|
// si hay texto seleccionado es que quieren copiar, no cortar
|
||||||
|
if (String(window.getSelection() || "").length) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
cortarTodo();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lanza la tanda de prueba. No cambia nada en el boton: lo que hay que mirar
|
||||||
|
// son las tarjetas, que es justo lo que se quiere comprobar.
|
||||||
|
document.getElementById("prueba").addEventListener("click", (ev) => {
|
||||||
|
const b = ev.currentTarget;
|
||||||
|
b.disabled = true;
|
||||||
|
b.querySelector(".rotulo").textContent = "probando";
|
||||||
|
setTimeout(() => {
|
||||||
|
b.disabled = false;
|
||||||
|
b.querySelector(".rotulo").textContent = "prueba";
|
||||||
|
}, 4000);
|
||||||
|
try {
|
||||||
|
window.webkit.messageHandlers.accion.postMessage("prueba");
|
||||||
|
} catch (e) { /* suelto en el navegador: no hay Python que lance nada */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
// No se pinta al pulsar: rota Python y contesta con la voz que quedo. Mientras
|
||||||
|
// llega, se avisa de que esta en ello, que cambiar de motor tarda un momento.
|
||||||
|
document.getElementById("voz").addEventListener("click", (ev) => {
|
||||||
|
ev.currentTarget.querySelector(".rotulo").textContent = "voz: ...";
|
||||||
|
try {
|
||||||
|
window.webkit.messageHandlers.accion.postMessage("voz:siguiente");
|
||||||
|
} catch (e) { /* suelto en el navegador: no hay Python que rote nada */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
const botonCerebro = document.getElementById("cerebro");
|
||||||
|
if (botonCerebro) botonCerebro.addEventListener("click", (ev) => {
|
||||||
|
ev.currentTarget.querySelector(".rotulo").textContent = "cerebro: ...";
|
||||||
|
try {
|
||||||
|
window.webkit.messageHandlers.accion.postMessage("cerebro:siguiente");
|
||||||
|
} catch (e) { /* suelto en el navegador: no hay Python que rote nada */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", ajustarLienzo);
|
||||||
|
ajustarLienzo();
|
||||||
|
leeColores();
|
||||||
|
requestAnimationFrame(dibujar);
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.webkit.messageHandlers.listo.postMessage("listo");
|
||||||
|
} catch (e) {
|
||||||
|
// Abierto en un navegador normal: se llena solo con datos falsos, para
|
||||||
|
// poder iterar el diseño sin arrancar Newelle.
|
||||||
|
ponEstado("escuchando");
|
||||||
|
setInterval(() => {
|
||||||
|
for (let i = 0; i < BANDAS; i++) nivelesObjetivo[i] = Math.random() * 0.9;
|
||||||
|
}, 120);
|
||||||
|
|
||||||
|
elDicho.textContent = "how much disk space is left?";
|
||||||
|
elDicho.classList.add("visible");
|
||||||
|
|
||||||
|
[
|
||||||
|
{ herramienta: "run_command", salida: "df -h /\n/dev/nvme0n1p2 916G 70G 846G 8% /" },
|
||||||
|
{ herramienta: "system_info", salida: "62 GB RAM · 16 nucleos · NVIDIA T1200 4 GB" },
|
||||||
|
{ herramienta: "websearch", salida: "3 resultados descartados por poco fiables" },
|
||||||
|
].forEach(anotaAccion);
|
||||||
|
cambiaVista("catalogo");
|
||||||
|
|
||||||
|
window.jarvis.recibe({ tipo: "catalogo", datos: [
|
||||||
|
{ grupo: "sistema", acciones: [
|
||||||
|
{ id: "disco_libre", frase: "cuanto espacio queda", captura: false, confirmar: false },
|
||||||
|
{ id: "memoria", frase: "cuanta memoria", captura: false, confirmar: false },
|
||||||
|
{ id: "temperatura", frase: "que temperatura hay", captura: false, confirmar: false },
|
||||||
|
]},
|
||||||
|
{ grupo: "ficheros", acciones: [
|
||||||
|
{ id: "buscar_fichero", frase: "busca el fichero", captura: true, confirmar: false },
|
||||||
|
{ id: "proceso_matar", frase: "mata el proceso", captura: true, confirmar: true },
|
||||||
|
]},
|
||||||
|
{ grupo: "firefox", acciones: [
|
||||||
|
{ id: "ff_buscar_youtube", frase: "busca en youtube", captura: true, confirmar: false },
|
||||||
|
{ id: "ff_gmail", frase: "abre el correo", captura: false, confirmar: false },
|
||||||
|
]},
|
||||||
|
]});
|
||||||
|
|
||||||
|
window.jarvis.recibe({ tipo: "telemetria", datos: {
|
||||||
|
cpu: 34,
|
||||||
|
ram: { usada: 18.4, total: 62.5 },
|
||||||
|
gpu: { uso: 71, vram_usada: 2.9, vram_total: 4.0 },
|
||||||
|
nucleos: Array.from({ length: 16 }, (_, i) => 20 + ((i * 37) % 70)),
|
||||||
|
swap: { usada: 0.4, total: 1.0 },
|
||||||
|
disco: { usado: 1.01, total: 1.9 },
|
||||||
|
temperatura: 59,
|
||||||
|
carga: [1.81, 2.96, 4.08],
|
||||||
|
uptime: 21600,
|
||||||
|
bateria: 100,
|
||||||
|
red: { baja: 1840.5, sube: 220.2 },
|
||||||
|
procesos: [
|
||||||
|
{ pid: 2693, nombre: "ollama", cpu: 182.4, mem: 5.8 },
|
||||||
|
{ pid: 1436, nombre: "newelle", cpu: 33.5, mem: 1.8 },
|
||||||
|
{ pid: 9422, nombre: "firefox-esr", cpu: 12.1, mem: 4.2 },
|
||||||
|
{ pid: 3829, nombre: "gnome-shell", cpu: 6.3, mem: 2.1 },
|
||||||
|
{ pid: 1201, nombre: "pipewire", cpu: 2.0, mem: 0.3 },
|
||||||
|
],
|
||||||
|
}});
|
||||||
|
}
|
||||||
|
})();
|
||||||
179
nucleo/cara/hud/index.html
Normal file
179
nucleo/cara/hud/index.html
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- HUD de JARVIS. Se carga desde config/jarvis-hud/, asi que este fichero
|
||||||
|
se puede editar y recargar la pestaña sin recompilar el flatpak. -->
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>JARVIS</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="hud">
|
||||||
|
|
||||||
|
<!-- columna izquierda: constantes de la maquina -->
|
||||||
|
<aside class="telemetria" id="telemetria">
|
||||||
|
<h2>sistema</h2>
|
||||||
|
<div class="medidor" data-clave="cpu">
|
||||||
|
<span class="etiqueta">cpu</span>
|
||||||
|
<div class="barra"><i></i></div>
|
||||||
|
<span class="cifra">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="medidor" data-clave="ram">
|
||||||
|
<span class="etiqueta">ram</span>
|
||||||
|
<div class="barra"><i></i></div>
|
||||||
|
<span class="cifra">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="medidor" data-clave="gpu">
|
||||||
|
<span class="etiqueta">gpu</span>
|
||||||
|
<div class="barra"><i></i></div>
|
||||||
|
<span class="cifra">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="medidor" data-clave="vram">
|
||||||
|
<span class="etiqueta">vram</span>
|
||||||
|
<div class="barra"><i></i></div>
|
||||||
|
<span class="cifra">--</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="medidor" data-clave="swap">
|
||||||
|
<span class="etiqueta">swap</span>
|
||||||
|
<div class="barra"><i></i></div>
|
||||||
|
<span class="cifra">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="medidor" data-clave="disco">
|
||||||
|
<span class="etiqueta">disco</span>
|
||||||
|
<div class="barra"><i></i></div>
|
||||||
|
<span class="cifra">--</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="separado">nucleos</h2>
|
||||||
|
<div class="nucleos" id="nucleos"></div>
|
||||||
|
|
||||||
|
<!-- constantes: no llevan barra porque no son porcentajes de nada -->
|
||||||
|
<ul class="constantes" id="constantes">
|
||||||
|
<li data-clave="temperatura"><span>temp</span><b>--</b></li>
|
||||||
|
<li data-clave="carga"><span>carga</span><b>--</b></li>
|
||||||
|
<li data-clave="red"><span>red</span><b>--</b></li>
|
||||||
|
<li data-clave="uptime"><span>encendido</span><b>--</b></li>
|
||||||
|
<li data-clave="bateria"><span>bateria</span><b>--</b></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="separado">procesos</h2>
|
||||||
|
<ul class="procesos" id="procesos"></ul>
|
||||||
|
|
||||||
|
<!-- rendimiento del propio JARVIS: sale del diario, no del sistema -->
|
||||||
|
<h2 class="separado">rendimiento</h2>
|
||||||
|
<div class="reparto" id="reparto" title="a donde va cada orden">
|
||||||
|
<span class="tramo cerebro" style="--f:0"></span>
|
||||||
|
<span class="tramo catalogo" style="--f:0"></span>
|
||||||
|
<span class="tramo ruido" style="--f:0"></span>
|
||||||
|
</div>
|
||||||
|
<ul class="constantes" id="rendimiento">
|
||||||
|
<li data-clave="lat_p50"><span>latencia med.</span><b>--</b></li>
|
||||||
|
<li data-clave="lat_p95"><span>latencia p95</span><b>--</b></li>
|
||||||
|
<li data-clave="pct_cerebro"><span>al cerebro</span><b>--</b></li>
|
||||||
|
<li data-clave="pct_ruido"><span>ruido</span><b>--</b></li>
|
||||||
|
<li data-clave="vol24"><span>frases 24h</span><b>--</b></li>
|
||||||
|
<li data-clave="rag"><span>RAG hit@5</span><b>--</b></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- centro: el reactor, el estado y lo que hay en marcha -->
|
||||||
|
<section class="nucleo">
|
||||||
|
<canvas id="reactor" width="520" height="520"></canvas>
|
||||||
|
<p class="estado" id="estado">en reposo</p>
|
||||||
|
<p class="dicho" id="dicho"></p>
|
||||||
|
|
||||||
|
<!-- una tarjetita por orden en marcha. Van aqui debajo y no flotando
|
||||||
|
sobre el reactor para que quepan varias sin taparlo. -->
|
||||||
|
<div class="tareas" id="tareas"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- columna derecha: lo que va haciendo -->
|
||||||
|
<aside class="acciones">
|
||||||
|
<h2>
|
||||||
|
acciones
|
||||||
|
<span class="pestanas">
|
||||||
|
<button data-vista="catalogo" class="activa">sabe hacer</button>
|
||||||
|
<button data-vista="registro">hechas</button>
|
||||||
|
<button data-vista="consola">consola</button>
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- lo que sabe hacer: sale de las acciones rapidas, no esta escrito aqui -->
|
||||||
|
<div class="vista" id="vista-catalogo">
|
||||||
|
<input id="filtro" type="search" placeholder="filtrar..." autocomplete="off">
|
||||||
|
<div id="catalogo"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- lo que va haciendo -->
|
||||||
|
<div class="vista oculta" id="vista-registro">
|
||||||
|
<ul id="registro"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- La consola: lo que esta pasando AHORA, linea a linea, como en una
|
||||||
|
terminal. Con su boton de cortar, que es lo que se echa de menos
|
||||||
|
cuando algo se queda pensando y no sabes ni que esta haciendo. -->
|
||||||
|
<div class="vista oculta" id="vista-consola">
|
||||||
|
<div class="consola-barra">
|
||||||
|
<span id="consola-estado">en reposo</span>
|
||||||
|
<button id="consola-parar" title="Cortar lo que se este ejecutando (Ctrl+C)">■ cortar</button>
|
||||||
|
</div>
|
||||||
|
<pre id="consola"></pre>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<nav class="controles">
|
||||||
|
<button data-accion="parar" data-estado="on" class="principal"
|
||||||
|
title="Cierra el microfono y corta la voz. Pulsa otra vez para reanudar">
|
||||||
|
<span class="punto"></span>
|
||||||
|
<span class="rotulo">parar</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Rota entre las voces disponibles y estrena la nueva diciendo una frase.
|
||||||
|
El rotulo lo pone Python al confirmar el cambio, no el clic. -->
|
||||||
|
<button id="voz" class="secundario" title="Cambiar la voz. La estrena diciendo una frase">
|
||||||
|
<span class="rotulo">voz</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Dormir / despertar. El rotulo dice como ESTA, no lo que hace el boton:
|
||||||
|
leer "dormido" y ver el punto apagado no deja lugar a dudas; "despertar"
|
||||||
|
obligaria a deducir si es lo que hace o lo que esta puesto. -->
|
||||||
|
<button data-accion="sueno" data-estado="on" class="secundario"
|
||||||
|
title="Atiende todo lo que oiga. Pulsa para exigir que le llames «JARVIS» antes de cada orden — util si hay una tele cerca">
|
||||||
|
<span class="punto"></span>
|
||||||
|
<span class="rotulo">escuchando</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Con que piensa. Se marca aparte cuando sale de la maquina: es el unico
|
||||||
|
boton del panel que cambia donde van a parar las palabras del usuario,
|
||||||
|
y eso no puede quedar escondido en un tooltip. -->
|
||||||
|
<button id="cerebro" class="secundario" title="Cambiar con que piensa JARVIS">
|
||||||
|
<span class="rotulo">cerebro</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Lanza varias acciones de verdad a la vez, para ver las tarjetas
|
||||||
|
trabajando sin tener que darle ordenes por voz. Es la forma de
|
||||||
|
comprobar de un vistazo que el panel hace lo que dice. -->
|
||||||
|
<button id="prueba" class="secundario" title="Lanza varias tareas reales a la vez para verlas trabajar">
|
||||||
|
<span class="rotulo">prueba</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- candado: tapa todo hasta que se teclea la contraseña -->
|
||||||
|
<div class="candado oculto" id="candado">
|
||||||
|
<div class="candado-caja">
|
||||||
|
<div class="candado-icono">▲</div>
|
||||||
|
<p class="candado-msg" id="candado-msg">Introduzca la contraseña</p>
|
||||||
|
<input id="candado-clave" type="password" autocomplete="off"
|
||||||
|
placeholder="Contraseña" autofocus>
|
||||||
|
<button id="candado-ok">Desbloquear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- el puente va antes: hud.js pregunta por window.webkit al cargarse -->
|
||||||
|
<script src="puente.js"></script>
|
||||||
|
<script src="hud.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
97
nucleo/cara/hud/puente.js
Normal file
97
nucleo/cara/hud/puente.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
/* El puente entre el HUD y el nucleo, por WebSocket.
|
||||||
|
*
|
||||||
|
* En la version sobre Newelle el HUD vivia dentro de un widget WebKit y hablaba
|
||||||
|
* por window.webkit.messageHandlers. Fuera del flatpak no hay tal widget, pero
|
||||||
|
* hud.js usa ese puente en ocho sitios y no merece la pena tocarlos: aqui se
|
||||||
|
* SUPLANTA window.webkit con un objeto que manda lo mismo por el socket.
|
||||||
|
*
|
||||||
|
* Asi hud.js se queda exactamente igual que estaba —cero diff sobre 2.064
|
||||||
|
* lineas que ya funcionaban— y ademas sigue abriendose suelto en un navegador
|
||||||
|
* para mirar el diseño, que es como se ha estado trabajando en el.
|
||||||
|
*
|
||||||
|
* Este fichero se carga ANTES que hud.js: cuando hud.js pregunte por
|
||||||
|
* window.webkit, ya esta.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Ya estamos dentro de WebKit (queda de la epoca de Newelle): no tocar nada.
|
||||||
|
if (window.webkit && window.webkit.messageHandlers) return;
|
||||||
|
|
||||||
|
const URL = "ws://" + (location.host || "127.0.0.1:8790") + "/puente";
|
||||||
|
|
||||||
|
let socket = null;
|
||||||
|
let cola = []; // lo que se pulse antes de que abra el socket
|
||||||
|
let esperaReconexion = 500;
|
||||||
|
|
||||||
|
function manda(canal, dato) {
|
||||||
|
const mensaje = JSON.stringify({ canal: canal, dato: String(dato) });
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(mensaje);
|
||||||
|
} else {
|
||||||
|
// No se pierde: un boton pulsado mientras reconecta tiene que llegar
|
||||||
|
// igual, o el panel parece que se ha colgado. Con tope, que si el nucleo
|
||||||
|
// esta caido de verdad no queremos acumular mil pulsaciones.
|
||||||
|
if (cola.length < 50) cola.push(mensaje);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// El mismo interfaz que ofrecia WebKit, para que hud.js no note el cambio.
|
||||||
|
window.webkit = {
|
||||||
|
messageHandlers: {
|
||||||
|
accion: { postMessage: (d) => manda("accion", d) },
|
||||||
|
listo: { postMessage: (d) => manda("listo", d) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function conecta() {
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(URL);
|
||||||
|
} catch (e) {
|
||||||
|
reintenta();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
esperaReconexion = 500;
|
||||||
|
marcaCaido(false);
|
||||||
|
const pendientes = cola;
|
||||||
|
cola = [];
|
||||||
|
pendientes.forEach((m) => socket.send(m));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("message", (ev) => {
|
||||||
|
let mensaje;
|
||||||
|
try {
|
||||||
|
mensaje = JSON.parse(ev.data);
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// El formato {tipo, datos} es el mismo que empujaba Python por
|
||||||
|
// evaluate_javascript, asi que recibe() no cambia.
|
||||||
|
if (window.jarvis && window.jarvis.recibe) window.jarvis.recibe(mensaje);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("close", () => { marcaCaido(true); reintenta(); });
|
||||||
|
socket.addEventListener("error", () => { try { socket.close(); } catch (e) {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
function reintenta() {
|
||||||
|
// Espera creciente hasta 8 s: reintentar cada 500 ms contra un nucleo
|
||||||
|
// parado calienta el portatil para nada.
|
||||||
|
setTimeout(conecta, esperaReconexion);
|
||||||
|
esperaReconexion = Math.min(esperaReconexion * 2, 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Que se vea que el panel esta hablando solo. Un HUD con datos congelados y
|
||||||
|
// sin avisar es peor que uno que dice claramente que perdio el hilo.
|
||||||
|
function marcaCaido(caido) {
|
||||||
|
document.documentElement.classList.toggle("sin-nucleo", caido);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", conecta);
|
||||||
|
} else {
|
||||||
|
conecta();
|
||||||
|
}
|
||||||
|
})();
|
||||||
1233
nucleo/cara/hud/style.css
Normal file
1233
nucleo/cara/hud/style.css
Normal file
File diff suppressed because it is too large
Load diff
575
nucleo/cara/panel.py
Normal file
575
nucleo/cara/panel.py
Normal file
|
|
@ -0,0 +1,575 @@
|
||||||
|
"""Lo que une el nucleo con la cara: que se empuja al HUD y que se hace al pulsar.
|
||||||
|
|
||||||
|
`servidor.py` solo mueve bytes; aqui vive el significado. Es el equivalente de
|
||||||
|
lo que hacia `jarvis.py` dentro de Newelle —el fichero de 1.244 lineas que era
|
||||||
|
lo unico acoplado al framework— pero sin GTK, sin WebKit y sin el ciclo de vida
|
||||||
|
de la aplicacion de por medio.
|
||||||
|
|
||||||
|
## Lo que se empuja y cada cuanto
|
||||||
|
|
||||||
|
telemetria 1 s cpu, memoria, grafica, disco, procesos
|
||||||
|
pulso 15/s solo mientras escucha o habla; es lo que mueve el reactor
|
||||||
|
estado cuando cambia
|
||||||
|
tarea cuando cambia una
|
||||||
|
|
||||||
|
La telemetria cuesta procesos (`nvidia-smi`, `ps`), asi que va en su propio hilo
|
||||||
|
y a su ritmo, no en el bucle de voz. Y el pulso solo se manda si hay alguien
|
||||||
|
mirando: sin panel abierto son quince mensajes por segundo a nadie.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from manos.preguntar import confirmacion, es_si, pregunta_por
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
|
def _lee(ruta, defecto=""):
|
||||||
|
try:
|
||||||
|
with open(ruta) as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
return defecto
|
||||||
|
|
||||||
|
|
||||||
|
def _manda(orden, tope=4000):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(orden, capture_output=True, text=True, timeout=10)
|
||||||
|
return (r.stdout or "").strip()[:tope]
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class Telemetria:
|
||||||
|
"""Las constantes de la maquina, leidas baratas.
|
||||||
|
|
||||||
|
Todo de /proc salvo la grafica, que necesita nvidia-smi. Se lee /proc a
|
||||||
|
mano en vez de usar psutil para no arrastrar una dependencia por cuatro
|
||||||
|
numeros que estan en ficheros de texto.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._cpu_previo = None
|
||||||
|
self._nucleos_previo = None
|
||||||
|
self._hay_nvidia = shutil.which("nvidia-smi") is not None
|
||||||
|
|
||||||
|
def _cpu(self):
|
||||||
|
try:
|
||||||
|
with open("/proc/stat") as f:
|
||||||
|
lineas = [l for l in f if l.startswith("cpu")]
|
||||||
|
except OSError:
|
||||||
|
return 0, []
|
||||||
|
def uso(campos):
|
||||||
|
n = [int(x) for x in campos[1:11]]
|
||||||
|
ocupado = sum(n) - n[3] - n[4]
|
||||||
|
return ocupado, sum(n)
|
||||||
|
ahora = [uso(l.split()) for l in lineas]
|
||||||
|
previo, self._cpu_previo = self._cpu_previo, ahora
|
||||||
|
if not previo or len(previo) != len(ahora):
|
||||||
|
return 0, [0] * (len(ahora) - 1)
|
||||||
|
def pct(a, b):
|
||||||
|
d_ocu, d_tot = a[0] - b[0], a[1] - b[1]
|
||||||
|
return round(100 * d_ocu / d_tot) if d_tot > 0 else 0
|
||||||
|
return pct(ahora[0], previo[0]), [pct(a, b) for a, b in
|
||||||
|
zip(ahora[1:], previo[1:])]
|
||||||
|
|
||||||
|
def _memoria(self):
|
||||||
|
datos = {}
|
||||||
|
try:
|
||||||
|
with open("/proc/meminfo") as f:
|
||||||
|
for l in f:
|
||||||
|
k, _, v = l.partition(":")
|
||||||
|
datos[k] = int(v.split()[0]) // 1024 # MB
|
||||||
|
except OSError:
|
||||||
|
return 0, 0, 0, 0
|
||||||
|
total = datos.get("MemTotal", 0)
|
||||||
|
libre = datos.get("MemAvailable", 0)
|
||||||
|
stotal = datos.get("SwapTotal", 0)
|
||||||
|
slibre = datos.get("SwapFree", 0)
|
||||||
|
return total, total - libre, stotal, stotal - slibre
|
||||||
|
|
||||||
|
def _grafica(self):
|
||||||
|
if not self._hay_nvidia:
|
||||||
|
return None
|
||||||
|
s = _manda(["nvidia-smi",
|
||||||
|
"--query-gpu=utilization.gpu,memory.used,memory.total",
|
||||||
|
"--format=csv,noheader,nounits"])
|
||||||
|
try:
|
||||||
|
u, usada, total = (int(x.strip()) for x in s.split(",")[:3])
|
||||||
|
return {"uso": u, "usada": usada, "total": total}
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _temperatura(self):
|
||||||
|
mejor = 0
|
||||||
|
for zona in range(12):
|
||||||
|
t = _lee(f"/sys/class/thermal/thermal_zone{zona}/temp")
|
||||||
|
if t.isdigit():
|
||||||
|
mejor = max(mejor, int(t) // 1000)
|
||||||
|
return mejor
|
||||||
|
|
||||||
|
def _procesos(self, tope=5):
|
||||||
|
"""Los que mas CPU comen.
|
||||||
|
|
||||||
|
cpu y mem van como NUMEROS, no como las cadenas que devuelve ps. El HUD
|
||||||
|
hace `p.cpu.toFixed(0)`, y una cadena no tiene toFixed: la excepcion
|
||||||
|
aborta el manejador entero de telemetria y se lleva por delante todo lo
|
||||||
|
que se pinta despues —cpu, ram y grafica se quedaban en "--"—. El
|
||||||
|
sintoma no apuntaba a los procesos en absoluto.
|
||||||
|
"""
|
||||||
|
s = _manda(["ps", "-eo", "pid,comm,pcpu,pmem", "--sort=-pcpu"])
|
||||||
|
salida = []
|
||||||
|
for l in s.splitlines()[1:tope + 1]:
|
||||||
|
c = l.split(None, 3)
|
||||||
|
if len(c) < 4:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
salida.append({"pid": int(c[0]), "nombre": c[1][:18],
|
||||||
|
"cpu": float(c[2]), "mem": float(c[3].split()[0])})
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return salida
|
||||||
|
|
||||||
|
def _disco(self):
|
||||||
|
try:
|
||||||
|
st = os.statvfs("/")
|
||||||
|
total = st.f_blocks * st.f_frsize / 1e9
|
||||||
|
libre = st.f_bavail * st.f_frsize / 1e9
|
||||||
|
return round(total - libre, 1), round(total, 1)
|
||||||
|
except OSError:
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
def _red(self):
|
||||||
|
"""Bytes acumulados por todas las interfaces menos la de loopback."""
|
||||||
|
baja = sube = 0
|
||||||
|
try:
|
||||||
|
with open("/proc/net/dev") as f:
|
||||||
|
for l in f.readlines()[2:]:
|
||||||
|
nombre, _, resto = l.partition(":")
|
||||||
|
if nombre.strip() == "lo":
|
||||||
|
continue
|
||||||
|
c = resto.split()
|
||||||
|
baja += int(c[0]); sube += int(c[8])
|
||||||
|
except (OSError, IndexError, ValueError):
|
||||||
|
return None
|
||||||
|
previo, self._red_previo = getattr(self, "_red_previo", None), (baja, sube, time.time())
|
||||||
|
if not previo:
|
||||||
|
return {"baja": 0, "sube": 0}
|
||||||
|
dt = max(self._red_previo[2] - previo[2], 0.001)
|
||||||
|
return {"baja": max(0, (baja - previo[0]) / dt),
|
||||||
|
"sube": max(0, (sube - previo[1]) / dt)}
|
||||||
|
|
||||||
|
def _bateria(self):
|
||||||
|
for n in range(3):
|
||||||
|
p = _lee(f"/sys/class/power_supply/BAT{n}/capacity")
|
||||||
|
if p.isdigit():
|
||||||
|
return int(p)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def lee(self) -> dict:
|
||||||
|
"""Las unidades son las que espera el HUD, no las de /proc.
|
||||||
|
|
||||||
|
Se convierte aqui y no en JavaScript porque el formato lo fija la
|
||||||
|
pantalla: hud.js escribe "GB" y "TB" literalmente al lado del numero
|
||||||
|
(ponMedidor), asi que mandar megas se ve como "63488/64256 GB" y parece
|
||||||
|
que el panel esta roto cuando lo que esta mal es quien lo llena.
|
||||||
|
"""
|
||||||
|
cpu, nucleos = self._cpu()
|
||||||
|
mtotal, musada, stotal, susada = self._memoria()
|
||||||
|
dusado, dtotal = self._disco()
|
||||||
|
arriba = _lee("/proc/uptime", "0").split()[0]
|
||||||
|
datos = {
|
||||||
|
"cpu": cpu,
|
||||||
|
"nucleos": nucleos,
|
||||||
|
"ram": {"usada": musada / 1024, "total": mtotal / 1024}, # GB
|
||||||
|
"swap": {"usada": susada / 1024, "total": max(stotal / 1024, 0.01)},
|
||||||
|
"disco": {"usado": dusado / 1000, "total": dtotal / 1000}, # TB
|
||||||
|
"temperatura": self._temperatura(),
|
||||||
|
"carga": [float(x) for x in _lee("/proc/loadavg", "0 0 0").split()[:3]],
|
||||||
|
"uptime": float(arriba) if arriba else 0, # segundos
|
||||||
|
"procesos": self._procesos(),
|
||||||
|
}
|
||||||
|
g = self._grafica()
|
||||||
|
if g:
|
||||||
|
datos["gpu"] = {"uso": g["uso"],
|
||||||
|
"vram_usada": g["usada"] / 1024, # GB
|
||||||
|
"vram_total": g["total"] / 1024}
|
||||||
|
red = self._red()
|
||||||
|
if red:
|
||||||
|
datos["red"] = red
|
||||||
|
bat = self._bateria()
|
||||||
|
if bat is not None:
|
||||||
|
datos["bateria"] = bat
|
||||||
|
return datos
|
||||||
|
|
||||||
|
|
||||||
|
class Panel:
|
||||||
|
"""El nucleo visto desde la cara."""
|
||||||
|
|
||||||
|
def __init__(self, cara, boca, manos, cerebro=None, estado_voz=None,
|
||||||
|
candado=True, centinela=None):
|
||||||
|
self.cara = cara
|
||||||
|
self.boca = boca
|
||||||
|
self.manos = manos
|
||||||
|
self.cerebro = cerebro
|
||||||
|
self.centinela = centinela # quien decide si hay que llamarle
|
||||||
|
self.estado_voz = estado_voz # callable -> "escuchando"|"hablando"|...
|
||||||
|
self.telemetria = Telemetria()
|
||||||
|
self._parar = threading.Event()
|
||||||
|
|
||||||
|
# Las tarjetas: una por orden en marcha, con su reloj y su boton de
|
||||||
|
# parar. El prefijo va vacio porque aqui ya estamos en el host; dentro
|
||||||
|
# del flatpak era `flatpak-spawn --host --watch-bus`.
|
||||||
|
from manos.tareas import GestorTareas
|
||||||
|
self.tareas = GestorTareas(al_cambiar=self._tarea_cambio, prefijo_host=[])
|
||||||
|
|
||||||
|
# El candado. Tapa el panel y, sobre todo, hace que no obedezca: sin
|
||||||
|
# esto cualquiera que pase por delante del portatil abierto le puede
|
||||||
|
# dar ordenes a una maquina con sudo.
|
||||||
|
#
|
||||||
|
# La contraseña es la del sistema, comprobada con `sudo -S -v`, que NO
|
||||||
|
# ejecuta nada: solo valida. Asi no hay una contraseña propia que
|
||||||
|
# guardar en ningun sitio, que es una cosa menos que se puede filtrar.
|
||||||
|
self.candado = candado
|
||||||
|
self.desbloqueado = not candado
|
||||||
|
self._intentos = 0
|
||||||
|
|
||||||
|
# Lo que se ha preguntado y esta esperando respuesta hablada.
|
||||||
|
# (accion, para_que, hasta_cuando). para_que es "argumento" o "confirmar".
|
||||||
|
self.pendiente = None
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- empujar
|
||||||
|
|
||||||
|
def arranca(self):
|
||||||
|
threading.Thread(target=self._bucle_telemetria, daemon=True).start()
|
||||||
|
if self.candado and not self.desbloqueado:
|
||||||
|
self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"})
|
||||||
|
self.manda_catalogo()
|
||||||
|
self.manda_controles()
|
||||||
|
self.cara.manda("voz", {"nombre": self.boca.voz, "id": self.boca.voz})
|
||||||
|
if self.cerebro:
|
||||||
|
self.cara.manda("cerebro", {"nombre": "local", "id": "local",
|
||||||
|
"detalle": self.cerebro.modelo,
|
||||||
|
"fuera": False})
|
||||||
|
|
||||||
|
def _bucle_telemetria(self):
|
||||||
|
tic = 0
|
||||||
|
while not self._parar.is_set():
|
||||||
|
# Sin nadie mirando no se gasta un nvidia-smi por segundo.
|
||||||
|
if self.cara.conectados:
|
||||||
|
try:
|
||||||
|
self.cara.manda("telemetria", self.telemetria.lee())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Las medidas de JARVIS (latencia, reparto, RAG) cambian mucho
|
||||||
|
# mas despacio que la CPU: se recalculan cada 5 s, no cada uno.
|
||||||
|
# Salen del diario, que es leer un fichero, no del sistema.
|
||||||
|
if tic % 5 == 0:
|
||||||
|
try:
|
||||||
|
from manos.medidas import lee_medidas
|
||||||
|
self.cara.manda("medidas", lee_medidas())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
tic += 1
|
||||||
|
self._parar.wait(1.0)
|
||||||
|
|
||||||
|
def manda_catalogo(self):
|
||||||
|
"""Lo que sabe hacer, agrupado, para la pestaña de la derecha."""
|
||||||
|
grupos = {}
|
||||||
|
for a in self.manos.catalogo.acciones:
|
||||||
|
if not a.frases:
|
||||||
|
continue
|
||||||
|
grupos.setdefault(a.grupo or "otras", []).append({
|
||||||
|
"id": a.id,
|
||||||
|
"frase": a.frases[0],
|
||||||
|
"argumento": a.captura,
|
||||||
|
"confirma": a.confirmar,
|
||||||
|
})
|
||||||
|
datos = [{"grupo": g, "acciones": v} for g, v in
|
||||||
|
sorted(grupos.items(), key=lambda x: -len(x[1]))]
|
||||||
|
self.cara.manda("catalogo", datos)
|
||||||
|
|
||||||
|
def _tarea_cambio(self, tarea):
|
||||||
|
"""Llamado desde el hilo de la tarea cada vez que hay algo que enseñar."""
|
||||||
|
try:
|
||||||
|
self.cara.manda("tarea", tarea.resumen())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def lanza(self, accion, argumento=""):
|
||||||
|
"""Ejecuta una accion como TAREA, con tarjeta en el panel.
|
||||||
|
|
||||||
|
Se usa para lo que tarda. Lo instantaneo —el disco, la hora— no merece
|
||||||
|
una tarjeta: aparece, parpadea y se va antes de que nadie la lea.
|
||||||
|
"""
|
||||||
|
return self.tareas.lanza(accion, argumento)
|
||||||
|
|
||||||
|
def manda_controles(self):
|
||||||
|
"""El estado real de los interruptores, para que el rotulo no mienta.
|
||||||
|
|
||||||
|
Los botones NO se pintan al pulsarlos: se manda la orden y se espera a
|
||||||
|
que el nucleo conteste con como quedo. Asi el boton nunca dice
|
||||||
|
"despierto" con el centinela dormido.
|
||||||
|
"""
|
||||||
|
self.cara.manda("controles", {
|
||||||
|
# encendido = atiende todo; apagado = solo si le llaman
|
||||||
|
"sueno": not (self.centinela and self.centinela.activo),
|
||||||
|
})
|
||||||
|
|
||||||
|
def estado(self, cual: str):
|
||||||
|
self.cara.manda("estado", cual)
|
||||||
|
|
||||||
|
def dicho(self, texto: str, atendido=True, motivo=""):
|
||||||
|
"""Lo que se ha oido, SIEMPRE, tambien cuando se descarta.
|
||||||
|
|
||||||
|
Esto es lo que faltaba y por lo que el panel parecia muerto: se oia, se
|
||||||
|
transcribia, y si no llevaba la palabra de activacion se tiraba en
|
||||||
|
silencio. Desde la pantalla eso es indistinguible de un microfono
|
||||||
|
estropeado, y no hay forma de saber si el problema es que no te oye, que
|
||||||
|
te oye mal, o que te oye bien y no te hace caso.
|
||||||
|
|
||||||
|
El diccionario y no la cadena pelada: el HUD lee datos.texto.
|
||||||
|
"""
|
||||||
|
self.cara.manda("dicho", {"texto": texto, "atendido": atendido,
|
||||||
|
"motivo": motivo})
|
||||||
|
|
||||||
|
def respuesta(self, texto: str):
|
||||||
|
self.cara.manda("respuesta", {"texto": texto})
|
||||||
|
|
||||||
|
def para(self):
|
||||||
|
self._parar.set()
|
||||||
|
try:
|
||||||
|
self.tareas.para_todas()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- pulsar
|
||||||
|
|
||||||
|
def pulsado(self, canal: str, dato: str):
|
||||||
|
"""Lo que llega del panel. Corre en su propio hilo, puede tardar."""
|
||||||
|
if canal == "listo":
|
||||||
|
self.arranca()
|
||||||
|
return
|
||||||
|
if canal != "accion":
|
||||||
|
return
|
||||||
|
|
||||||
|
# Desbloquear es lo unico que se atiende con el candado puesto.
|
||||||
|
if dato.startswith("desbloquear:"):
|
||||||
|
self._intenta_desbloquear(dato.split(":", 1)[1])
|
||||||
|
return
|
||||||
|
|
||||||
|
# Todo lo demas son ORDENES, y con el candado puesto no hay ordenes.
|
||||||
|
if self.candado and not self.desbloqueado:
|
||||||
|
self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if dato == "voz:siguiente":
|
||||||
|
nueva = self.boca.siguiente()
|
||||||
|
self.cara.manda("voz", {"nombre": nueva, "id": nueva})
|
||||||
|
self.boca.di("Buenas noches, señor.")
|
||||||
|
|
||||||
|
elif dato == "prueba":
|
||||||
|
threading.Thread(target=self._tanda_de_prueba, daemon=True).start()
|
||||||
|
|
||||||
|
elif dato.startswith("explica:"):
|
||||||
|
self._explica(dato.split(":", 1)[1])
|
||||||
|
|
||||||
|
elif dato.startswith("parar_tarea:"):
|
||||||
|
self.tareas.para(dato.split(":", 1)[1])
|
||||||
|
|
||||||
|
elif dato.startswith("cerrar_tarea:"):
|
||||||
|
ident = dato.split(":", 1)[1]
|
||||||
|
self.tareas.cierra(ident)
|
||||||
|
self.cara.manda("tarea_fuera", {"id": ident})
|
||||||
|
|
||||||
|
elif dato == "sueno":
|
||||||
|
# Cambia si hace falta llamarle por su nombre.
|
||||||
|
#
|
||||||
|
# Por defecto NO hace falta: medido sobre 412 frases reales, el
|
||||||
|
# ruido ambiente no dispara nada, asi que exigir el nombre en cada
|
||||||
|
# frase era peaje sin contrapartida. El interruptor se queda para
|
||||||
|
# cuando haya una tele cerca.
|
||||||
|
if not self.centinela:
|
||||||
|
return
|
||||||
|
self.centinela.activo = not self.centinela.activo
|
||||||
|
if self.centinela.activo:
|
||||||
|
self.centinela.duerme()
|
||||||
|
self.boca.di_si_libre("Llameme por mi nombre, señor.")
|
||||||
|
else:
|
||||||
|
self.boca.di_si_libre("Le escucho, señor.")
|
||||||
|
self.manda_controles()
|
||||||
|
self.estado("escuchando" if not self.centinela.activo else "dormido")
|
||||||
|
|
||||||
|
elif dato.startswith("parar"):
|
||||||
|
# Cortar lo que este pasando AHORA: las tareas en marcha y la voz.
|
||||||
|
n = 0
|
||||||
|
for t in self.tareas.todas():
|
||||||
|
if t.estado == "corriendo" and self.tareas.para(t.id):
|
||||||
|
n += 1
|
||||||
|
self.boca.di_si_libre(f"Cortadas {n} tareas, señor." if n
|
||||||
|
else "No habia nada corriendo, señor.")
|
||||||
|
|
||||||
|
# Cuanto se espera la respuesta a una pregunta antes de darla por olvidada.
|
||||||
|
# Corto cansa —hay que pensar que fichero era—; largo hace que una frase
|
||||||
|
# suelta media conversacion despues se tome por la respuesta.
|
||||||
|
ESPERA_S = 45
|
||||||
|
|
||||||
|
def _explica(self, ident: str):
|
||||||
|
"""Pulsar una accion PREGUNTA lo que falta y la hace.
|
||||||
|
|
||||||
|
No recita la frase que la dispara. "Diga «busca el fichero» y a
|
||||||
|
continuacion lo que busca" es un manual de instrucciones, y uno pulsa
|
||||||
|
justo porque no quiere leerse el manual.
|
||||||
|
|
||||||
|
espera argumento -> pregunta cual y lo ejecuta con lo que digas
|
||||||
|
no tiene vuelta -> pide confirmacion antes
|
||||||
|
ninguna cosa -> la hace, que es a lo que has pulsado
|
||||||
|
|
||||||
|
Lo destructivo sigue sin dispararse de un clic: pasa por un si o un no
|
||||||
|
hablado, que es la misma red que tenia antes pero sin obligar a leer.
|
||||||
|
"""
|
||||||
|
accion = next((a for a in self.manos.catalogo.acciones if a.id == ident), None)
|
||||||
|
if accion is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if accion.captura:
|
||||||
|
self._pregunta(accion, "argumento", pregunta_por(accion))
|
||||||
|
elif accion.confirmar:
|
||||||
|
self._pregunta(accion, "confirmar", confirmacion(accion))
|
||||||
|
else:
|
||||||
|
self.tareas.lanza(accion, "")
|
||||||
|
|
||||||
|
def _pregunta(self, accion, para_que, texto, argumento=""):
|
||||||
|
self._argumento_pendiente = argumento
|
||||||
|
self.pendiente = (accion, para_que, time.monotonic() + self.ESPERA_S,
|
||||||
|
getattr(self, "_argumento_pendiente", ""))
|
||||||
|
self.cara.manda("preguntando", {"texto": texto, "accion": accion.id})
|
||||||
|
self.cara.manda("respuesta", {"texto": texto})
|
||||||
|
self.boca.di(texto)
|
||||||
|
|
||||||
|
def esperando(self):
|
||||||
|
"""La pregunta en el aire, o None si no hay o ya caduco."""
|
||||||
|
if not self.pendiente:
|
||||||
|
return None
|
||||||
|
if time.monotonic() > self.pendiente[2]:
|
||||||
|
self.olvida()
|
||||||
|
return None
|
||||||
|
return self.pendiente
|
||||||
|
|
||||||
|
def olvida(self):
|
||||||
|
self.pendiente = None
|
||||||
|
self.cara.manda("preguntando", {"texto": ""})
|
||||||
|
|
||||||
|
def responde(self, texto: str) -> bool:
|
||||||
|
"""Contesta a lo preguntado. Devuelve si consumio la frase.
|
||||||
|
|
||||||
|
Se llama desde el bucle de voz ANTES del emparejador: mientras hay una
|
||||||
|
pregunta en el aire, lo que se dice es la respuesta, no una orden nueva.
|
||||||
|
"""
|
||||||
|
pend = self.esperando()
|
||||||
|
if not pend:
|
||||||
|
return False
|
||||||
|
accion, para_que, _, argumento = pend
|
||||||
|
|
||||||
|
if para_que == "confirmar":
|
||||||
|
respuesta = es_si(texto)
|
||||||
|
if respuesta is None:
|
||||||
|
self.boca.di("No le he entendido. ¿Si o no, señor?")
|
||||||
|
return True
|
||||||
|
self.olvida()
|
||||||
|
if respuesta:
|
||||||
|
self.tareas.lanza(accion, argumento)
|
||||||
|
else:
|
||||||
|
self.boca.di("De acuerdo, lo dejo.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# argumento: cualquier cosa que no sea una cancelacion vale
|
||||||
|
if es_si(texto) is False:
|
||||||
|
self.olvida()
|
||||||
|
self.boca.di("De acuerdo, lo dejo.")
|
||||||
|
return True
|
||||||
|
self.olvida()
|
||||||
|
arg = texto.strip()
|
||||||
|
|
||||||
|
# Si ademas no tiene vuelta atras, se confirma AHORA que ya se sabe
|
||||||
|
# sobre que. Hay siete acciones que piden argumento y confirmacion
|
||||||
|
# —"mata el proceso", "que puertos tiene abiertos"— y preguntando solo
|
||||||
|
# el argumento se saltaban la red entera. Y confirmar antes de saber el
|
||||||
|
# argumento seria pedir un cheque en blanco: "¿mato el proceso?" "¿cual?"
|
||||||
|
if accion.confirmar:
|
||||||
|
self._pregunta(accion, "confirmar",
|
||||||
|
f"«{accion.frases[0]} {arg}». Esto no tiene vuelta "
|
||||||
|
f"atras. ¿Confirma, señor?", argumento=arg)
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.tareas.lanza(accion, arg)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _intenta_desbloquear(self, clave: str):
|
||||||
|
if not clave:
|
||||||
|
return
|
||||||
|
# La misma GestorRoot de las manos: si la contraseña es buena, se queda
|
||||||
|
# ahi y el cerebro puede hacer sudo durante la sesion. Desbloquear el
|
||||||
|
# panel y abrir el root son el mismo gesto, con la misma contraseña.
|
||||||
|
try:
|
||||||
|
valida = self.manos.root.valida(clave)
|
||||||
|
except Exception as e:
|
||||||
|
self.cara.manda("bloqueo", {"mensaje": f"No se pudo comprobar: {e}",
|
||||||
|
"error": True})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not valida:
|
||||||
|
self._intentos += 1
|
||||||
|
# Sin bloqueo por intentos ni espera creciente: la contraseña es la
|
||||||
|
# del sistema y quien la teclea esta sentado delante del portatil.
|
||||||
|
# Contarlos sirve para que el mensaje sea util, no para castigar.
|
||||||
|
self.cara.manda("bloqueo", {
|
||||||
|
"mensaje": "No es esa" + (f" ({self._intentos} intentos)"
|
||||||
|
if self._intentos > 1 else ""),
|
||||||
|
"error": True})
|
||||||
|
return
|
||||||
|
|
||||||
|
self._intentos = 0
|
||||||
|
self.desbloqueado = True
|
||||||
|
self.manos.root.recuerda(clave) # root disponible para la sesion
|
||||||
|
self.cara.manda("desbloqueado", True)
|
||||||
|
self.boca.di_si_libre("A su servicio, señor.")
|
||||||
|
|
||||||
|
def bloquea(self):
|
||||||
|
"""Echa el candado, aunque se arrancara sin el.
|
||||||
|
|
||||||
|
Que `--sin-candado` sea un arranque comodo no significa que uno no
|
||||||
|
pueda querer cerrarlo al levantarse de la silla. Por eso esto ACTIVA el
|
||||||
|
candado en vez de comprobar si estaba activo: negarse a cerrar porque
|
||||||
|
no estaba puesto seria justo lo contrario de lo que se pide.
|
||||||
|
"""
|
||||||
|
self.candado = True
|
||||||
|
self.desbloqueado = False
|
||||||
|
self._intentos = 0
|
||||||
|
self.manos.root.olvida() # y se cierra el root con el candado
|
||||||
|
self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"})
|
||||||
|
|
||||||
|
# Elegidas para que se vea el ciclo entero: una que acaba al instante, dos
|
||||||
|
# que tardan un poco, y una larga a la que da tiempo a pulsarle el boton de
|
||||||
|
# parar. Todas de solo lectura.
|
||||||
|
# Cada una comprobada contra el catalogo: una que no encaje no lanza
|
||||||
|
# tarjeta y la tanda queda coja sin decir por que.
|
||||||
|
PRUEBA = ["cuanto espacio queda", "cuanta memoria", "que temperatura hay",
|
||||||
|
"que esta consumiendo", "cuantos procesos hay"]
|
||||||
|
|
||||||
|
def _tanda_de_prueba(self):
|
||||||
|
"""Lanza varias a la vez para ver las tarjetas trabajando.
|
||||||
|
|
||||||
|
Van como TAREAS y no ejecutadas a pelo: el objetivo es justo ver el
|
||||||
|
ciclo entero —aparecer, contar segundos, terminar— y poder pulsarle el
|
||||||
|
boton de parar a la que tarda.
|
||||||
|
"""
|
||||||
|
for orden in self.PRUEBA:
|
||||||
|
accion, argumento = self.manos.busca(orden)
|
||||||
|
if accion is None:
|
||||||
|
continue
|
||||||
|
self.tareas.lanza(accion, argumento)
|
||||||
|
time.sleep(0.25)
|
||||||
173
nucleo/cara/servidor.py
Normal file
173
nucleo/cara/servidor.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
"""La cara: sirve el HUD y lo mantiene al dia por WebSocket.
|
||||||
|
|
||||||
|
## Por que un servidor y no una ventana
|
||||||
|
|
||||||
|
En Newelle el HUD era un widget WebKit dentro de la aplicacion. Sacandolo a un
|
||||||
|
servidor local se gana mas de lo que parece:
|
||||||
|
|
||||||
|
- el panel es una pagina normal: se abre con `chromium --app=`, en cualquier
|
||||||
|
navegador, o desde el movil o una tablet en la misma red;
|
||||||
|
- se puede mirar el diseño sin arrancar nada, que es como se ha trabajado en
|
||||||
|
el todo el tiempo;
|
||||||
|
- desaparece GTK de la cara, que era la ultima atadura al framework.
|
||||||
|
|
||||||
|
El formato de mensajes no cambia —{tipo, datos}, igual que empujaba Python por
|
||||||
|
evaluate_javascript— asi que `hud.js` se queda sin tocar. Lo unico nuevo es
|
||||||
|
`puente.js`, que suplanta `window.webkit` con el socket.
|
||||||
|
|
||||||
|
## Escucha solo en local
|
||||||
|
|
||||||
|
127.0.0.1 a proposito. Este panel ejecuta ordenes en la maquina: abrirlo a la
|
||||||
|
red seria dar una terminal a cualquiera del wifi. Para verlo desde el movil, un
|
||||||
|
tunel ssh, que ademas obliga a autenticarse.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
HUD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hud")
|
||||||
|
PUERTO = int(os.environ.get("JARVIS_PUERTO", "8790"))
|
||||||
|
|
||||||
|
FICHEROS = {
|
||||||
|
"/": "index.html",
|
||||||
|
"/index.html": "index.html",
|
||||||
|
"/hud.js": "hud.js",
|
||||||
|
"/puente.js": "puente.js",
|
||||||
|
"/style.css": "style.css",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Cara:
|
||||||
|
"""El panel: se le empujan eventos y devuelve lo que se pulsa."""
|
||||||
|
|
||||||
|
def __init__(self, al_pulsar=None, puerto=PUERTO):
|
||||||
|
self.al_pulsar = al_pulsar # (canal, dato) -> None
|
||||||
|
self.puerto = puerto
|
||||||
|
self._clientes = set()
|
||||||
|
self._bucle = None
|
||||||
|
self._hilo = None
|
||||||
|
self._ultimo = {} # el estado, para el que llegue tarde
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- arrancar
|
||||||
|
|
||||||
|
def arranca(self):
|
||||||
|
"""Levanta el servidor en un hilo aparte y vuelve enseguida.
|
||||||
|
|
||||||
|
Devuelve la URL, o None si no se pudo. El motivo mas comun es que ya
|
||||||
|
haya otro JARVIS abierto: sin este manejo, el fallo salia como una traza
|
||||||
|
de asyncio dentro de un hilo, con "address already in use" enterrado en
|
||||||
|
la linea veinte, y el proceso seguia vivo sin panel.
|
||||||
|
"""
|
||||||
|
listo = threading.Event()
|
||||||
|
|
||||||
|
def corre():
|
||||||
|
self._bucle = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(self._bucle)
|
||||||
|
try:
|
||||||
|
self._bucle.run_until_complete(self._sirve(listo))
|
||||||
|
except OSError as e:
|
||||||
|
self._error = e
|
||||||
|
listo.set()
|
||||||
|
return
|
||||||
|
self._bucle.run_forever()
|
||||||
|
|
||||||
|
self._error = None
|
||||||
|
self._hilo = threading.Thread(target=corre, daemon=True)
|
||||||
|
self._hilo.start()
|
||||||
|
listo.wait(timeout=10)
|
||||||
|
if self._error is not None:
|
||||||
|
import errno
|
||||||
|
if self._error.errno == errno.EADDRINUSE:
|
||||||
|
print(f" el puerto {self.puerto} ya esta cogido: ¿hay otro "
|
||||||
|
f"JARVIS abierto? (arranca.sh --stop, o JARVIS_PUERTO=otro)")
|
||||||
|
else:
|
||||||
|
print(f" no se pudo levantar el panel: {self._error}")
|
||||||
|
return None
|
||||||
|
return f"http://127.0.0.1:{self.puerto}/"
|
||||||
|
|
||||||
|
async def _sirve(self, listo):
|
||||||
|
import websockets
|
||||||
|
from websockets.http11 import Response
|
||||||
|
from websockets.datastructures import Headers
|
||||||
|
|
||||||
|
async def http(conexion, peticion):
|
||||||
|
"""Sirve los ficheros del HUD; deja pasar el WebSocket."""
|
||||||
|
ruta = peticion.path.split("?")[0]
|
||||||
|
if ruta == "/puente":
|
||||||
|
return None # que siga el apreton de manos
|
||||||
|
nombre = FICHEROS.get(ruta)
|
||||||
|
if not nombre:
|
||||||
|
return Response(404, "Not Found", Headers(), b"no esta\n")
|
||||||
|
try:
|
||||||
|
with open(os.path.join(HUD, nombre), "rb") as f:
|
||||||
|
cuerpo = f.read()
|
||||||
|
except OSError:
|
||||||
|
return Response(404, "Not Found", Headers(), b"no esta\n")
|
||||||
|
tipo = mimetypes.guess_type(nombre)[0] or "application/octet-stream"
|
||||||
|
cabeceras = Headers({
|
||||||
|
"Content-Type": tipo + ("; charset=utf-8" if "text" in tipo
|
||||||
|
or "javascript" in tipo else ""),
|
||||||
|
"Content-Length": str(len(cuerpo)),
|
||||||
|
# Sin cache: el HUD se edita en caliente y recargar tiene que
|
||||||
|
# traer lo nuevo, no lo de hace media hora.
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
})
|
||||||
|
return Response(200, "OK", cabeceras, cuerpo)
|
||||||
|
|
||||||
|
self._servidor = await websockets.serve(
|
||||||
|
self._cliente, "127.0.0.1", self.puerto, process_request=http)
|
||||||
|
listo.set()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- clientes
|
||||||
|
|
||||||
|
async def _cliente(self, ws):
|
||||||
|
self._clientes.add(ws)
|
||||||
|
try:
|
||||||
|
# Al conectar se le manda el estado que ya habia. Sin esto, abrir el
|
||||||
|
# panel a media sesion enseña un HUD vacio hasta que algo cambie.
|
||||||
|
for mensaje in self._ultimo.values():
|
||||||
|
await ws.send(json.dumps(mensaje))
|
||||||
|
async for crudo in ws:
|
||||||
|
try:
|
||||||
|
m = json.loads(crudo)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if self.al_pulsar:
|
||||||
|
# En otro hilo: atender una pulsacion puede tardar (hablar,
|
||||||
|
# cambiar de voz, lanzar una tarea) y el bucle del socket no
|
||||||
|
# puede quedarse esperando o el panel se congela entero.
|
||||||
|
threading.Thread(
|
||||||
|
target=self.al_pulsar,
|
||||||
|
args=(m.get("canal", ""), m.get("dato", "")),
|
||||||
|
daemon=True).start()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._clientes.discard(ws)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- empujar
|
||||||
|
|
||||||
|
def manda(self, tipo: str, datos):
|
||||||
|
"""Empuja un evento al panel. Se puede llamar desde cualquier hilo."""
|
||||||
|
mensaje = {"tipo": tipo, "datos": datos}
|
||||||
|
# Se recuerda lo ultimo de cada tipo salvo lo que caduca al instante:
|
||||||
|
# el pulso de la voz a 15 por segundo no tiene sentido reenviarlo.
|
||||||
|
if tipo not in ("pulso",):
|
||||||
|
self._ultimo[tipo] = mensaje
|
||||||
|
if not self._bucle or not self._clientes:
|
||||||
|
return
|
||||||
|
crudo = json.dumps(mensaje, ensure_ascii=False)
|
||||||
|
asyncio.run_coroutine_threadsafe(self._reparte(crudo), self._bucle)
|
||||||
|
|
||||||
|
async def _reparte(self, crudo):
|
||||||
|
for ws in list(self._clientes):
|
||||||
|
try:
|
||||||
|
await ws.send(crudo)
|
||||||
|
except Exception:
|
||||||
|
self._clientes.discard(ws)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def conectados(self) -> int:
|
||||||
|
return len(self._clientes)
|
||||||
26
nucleo/cerebro/Modelfile
Normal file
26
nucleo/cerebro/Modelfile
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# El modelo de JARVIS: qwen3.5 4B con parametros de asistente CONCISO.
|
||||||
|
#
|
||||||
|
# La build de qwen3.5 viene con temperature 1 y presence_penalty 1.5. Eso
|
||||||
|
# esta pensado para escritura creativa, no para un asistente de voz: el
|
||||||
|
# presence_penalty alto empuja al modelo a meter temas nuevos que no vienen a
|
||||||
|
# cuento —de ahi que acabara las frases soltando cuanta RAM queda— y la
|
||||||
|
# temperature alta lo hace divagar en vez de contestar y callar.
|
||||||
|
#
|
||||||
|
# Reconstruir: ollama create qwen3.5:4b-jarvis -f nucleo/cerebro/Modelfile
|
||||||
|
|
||||||
|
FROM qwen3.5:4b
|
||||||
|
|
||||||
|
# Bajo y estable: para "cuanto espacio queda" no hay creatividad que aportar,
|
||||||
|
# hay una cifra que dar.
|
||||||
|
PARAMETER temperature 0.4
|
||||||
|
# El que causaba el relleno. 0 = no penaliza repetir, asi que no siente la
|
||||||
|
# necesidad de introducir temas nuevos para variar.
|
||||||
|
PARAMETER presence_penalty 0.0
|
||||||
|
PARAMETER frequency_penalty 0.0
|
||||||
|
PARAMETER top_p 0.9
|
||||||
|
PARAMETER top_k 20
|
||||||
|
# Que pare cuando acabe la respuesta, no cuando se le acabe la cuerda.
|
||||||
|
PARAMETER num_predict 300
|
||||||
|
# 24 de 32 capas en la grafica: deja sitio a whisper en los 4 GB.
|
||||||
|
PARAMETER num_gpu 24
|
||||||
|
PARAMETER num_ctx 4096
|
||||||
0
nucleo/cerebro/__init__.py
Normal file
0
nucleo/cerebro/__init__.py
Normal file
316
nucleo/cerebro/ollama.py
Normal file
316
nucleo/cerebro/ollama.py
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
"""Pensar: hablar con ollama y dejarle usar las manos.
|
||||||
|
|
||||||
|
Newelle dedica 578 lineas a esto porque soporta quince motores distintos y
|
||||||
|
tiene que traducir el formato de herramientas de cada uno. Nosotros hablamos
|
||||||
|
solo con ollama, que trae tool calling nativo en `/api/chat`.
|
||||||
|
|
||||||
|
## Dos herramientas, no una
|
||||||
|
|
||||||
|
La primera version tenia una sola —"ejecuta una orden"— y salio mal de una
|
||||||
|
forma instructiva. Medido con tres modelos:
|
||||||
|
|
||||||
|
4B "abre una terminal" -> ejecuta_orden("xterm -e bash")
|
||||||
|
9B "cuanto espacio queda" -> ejecuta_orden("df -h / | grep ...")
|
||||||
|
9B "abre una terminal" -> ejecuta_orden("open -a Terminal") (¡macOS!)
|
||||||
|
|
||||||
|
Los modelos son serviciales: si les dejas un hueco donde cabe un comando, lo
|
||||||
|
escriben. Y el 9B, que es mas listo, lo hacia MAS —escribia pipelines enteras y
|
||||||
|
hasta ordenes de otro sistema operativo—.
|
||||||
|
|
||||||
|
La solucion no es rogarle al modelo en el prompt, es que el hueco no quepa. Se
|
||||||
|
parten en dos herramientas con fronteras claras:
|
||||||
|
|
||||||
|
`orden_del_catalogo` recibe castellano, se lo come el emparejador. La
|
||||||
|
descripcion lleva ejemplos REALES sacados del catalogo,
|
||||||
|
no inventados, y dice explicitamente que no acepta
|
||||||
|
comandos.
|
||||||
|
`comando_de_shell` para lo que no esta en el catalogo. Aqui SI se espera
|
||||||
|
un comando, asi que el modelo tiene donde poner lo que
|
||||||
|
queria poner, y deja de meterlo donde no toca.
|
||||||
|
|
||||||
|
## Por que el catalogo va antes que esto
|
||||||
|
|
||||||
|
Las 150 acciones se emparejan ANTES de llamar a ningun modelo. Esto solo se
|
||||||
|
usa para lo que no encaja —alrededor del 15 %— y para conversar. Por eso un
|
||||||
|
cerebro lento duele menos de lo que parece: la mayoria de las ordenes ni pasan
|
||||||
|
por aqui.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434")
|
||||||
|
MODELO = os.environ.get("JARVIS_MODELO", "qwen3.5:4b-jarvis")
|
||||||
|
|
||||||
|
# Cuantas vueltas de "llama a una herramienta -> toma el resultado" se permiten
|
||||||
|
# antes de cortar. Sin tope, un modelo que se atasca pidiendo lo mismo deja al
|
||||||
|
# usuario esperando para siempre.
|
||||||
|
VUELTAS = 4
|
||||||
|
|
||||||
|
PERSONA = """Eres JARVIS, el asistente de voz de esta maquina.
|
||||||
|
|
||||||
|
REGLA QUE NO SE ROMPE NUNCA: no des ni un solo dato sobre este ordenador sin
|
||||||
|
haberlo comprobado antes con una herramienta. Ni el espacio del disco, ni la
|
||||||
|
memoria, ni cuantos ficheros hay, ni si algo esta abierto, ni la hora. Aunque
|
||||||
|
creas saberlo. Aunque parezca obvio. Si no lo has comprobado en esta misma
|
||||||
|
conversacion, lo compruebas.
|
||||||
|
|
||||||
|
Inventarse una cifra que suena razonable es el peor fallo que puedes cometer,
|
||||||
|
porque el usuario no tiene forma de saber que te la has inventado.
|
||||||
|
|
||||||
|
Y AL REVES: contesta SOLO lo que te preguntan. No añadas al final de tus
|
||||||
|
respuestas datos del sistema que nadie ha pedido —cuanta RAM queda, el espacio
|
||||||
|
del disco, la hora—. Si te preguntan como estas, "bien, señor" y punto; no lleva
|
||||||
|
un informe de memoria detras.
|
||||||
|
|
||||||
|
Para hacer algo, el orden es:
|
||||||
|
1. orden_del_catalogo, siempre que lo que te piden se parezca a algo de la
|
||||||
|
lista. Es lo que JARVIS sabe hacer bien y esta probado.
|
||||||
|
2. comando_de_shell solo si de verdad no esta en el catalogo.
|
||||||
|
|
||||||
|
Si te preguntan COMO se hace algo tecnico —una tecnica de pentesting, un flag,
|
||||||
|
una herramienta— y no lo tienes claro o no aparece en la documentacion de abajo,
|
||||||
|
usa buscar_en_apuntes con palabras clave ANTES de contestar. Ahi estan los
|
||||||
|
comandos que el usuario ya ha probado; es mejor buscarlos que inventarlos. Si la
|
||||||
|
primera busqueda no da, reformula y prueba otra vez.
|
||||||
|
|
||||||
|
Tu respuesta se convierte en AUDIO y se escucha en voz alta:
|
||||||
|
- Castellano siempre. Trata al usuario de usted y llamale "señor" en la primera
|
||||||
|
frase.
|
||||||
|
- Una o dos frases. Nada mas, salvo que te pidan detalle.
|
||||||
|
- Texto plano: ni asteriscos, ni vinetas, ni bloques de codigo, ni rutas largas.
|
||||||
|
- Los numeros redondeados y en palabras: "quedan setecientos gigas".
|
||||||
|
- Si algo falla, di en una frase QUE fallo. Sin disculpas de tres lineas.
|
||||||
|
|
||||||
|
Tienes acceso real a esta maquina y no estas en ningun contenedor. No digas
|
||||||
|
nunca que no puedes. En concreto, con comando_de_shell puedes:
|
||||||
|
- Ejecutar cualquier herramienta instalada, incluidas las de COFRE. Si no
|
||||||
|
recuerdas como se usa una, en la documentacion del usuario de abajo suele
|
||||||
|
estar el comando exacto.
|
||||||
|
- Conectarte a sus servidores: "ssh <host> '<comando>'" corre algo en el
|
||||||
|
servidor. Sus hosts estan en su configuracion; abajo veras cual encaja.
|
||||||
|
- Tareas de administrador, SOLO si el candado esta abierto. Si algo lo necesita
|
||||||
|
y no lo esta, dilo: que desbloquee y lo repites.
|
||||||
|
Antes de nada que borre datos o cambie el sistema, di lo que vas a hacer."""
|
||||||
|
|
||||||
|
|
||||||
|
def _ejemplos(catalogo, cuantos=8) -> str:
|
||||||
|
"""Ordenes de verdad del catalogo, repartidas entre grupos.
|
||||||
|
|
||||||
|
Van en la descripcion de la herramienta porque es lo unico que consigue que
|
||||||
|
el modelo pase castellano en vez de inventarse un comando. Salen del
|
||||||
|
catalogo y no escritas a mano para que no se queden viejas.
|
||||||
|
"""
|
||||||
|
por_grupo = {}
|
||||||
|
for a in catalogo.acciones:
|
||||||
|
if a.frases and not a.captura:
|
||||||
|
por_grupo.setdefault(a.grupo or "otras", []).append(a.frases[0])
|
||||||
|
muestra = []
|
||||||
|
grupos = list(por_grupo.values())
|
||||||
|
for i in range(cuantos):
|
||||||
|
for g in grupos:
|
||||||
|
if i < len(g) and len(muestra) < cuantos:
|
||||||
|
muestra.append(g[i])
|
||||||
|
return ", ".join(f'"{m}"' for m in muestra)
|
||||||
|
|
||||||
|
|
||||||
|
def herramientas(catalogo) -> list:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "orden_del_catalogo",
|
||||||
|
"description": (
|
||||||
|
"Ejecuta una de las ordenes que JARVIS ya sabe hacer. "
|
||||||
|
"Recibe la orden EN CASTELLANO, tal y como la diria una "
|
||||||
|
"persona. NO acepta comandos de shell: si escribes 'df -h' "
|
||||||
|
"o 'xterm' falla. Ejemplos validos: "
|
||||||
|
+ _ejemplos(catalogo) + ". "
|
||||||
|
"Usala siempre que puedas antes que el shell."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"orden": {
|
||||||
|
"type": "string",
|
||||||
|
"description": ("la orden en castellano, en palabras, "
|
||||||
|
"nunca un comando"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["orden"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "buscar_en_apuntes",
|
||||||
|
"description": (
|
||||||
|
"Busca en los apuntes de pentesting y Linux del usuario (su "
|
||||||
|
"COFRE) el comando o la explicacion que necesites. Usala "
|
||||||
|
"cuando te pregunten COMO se hace algo tecnico —una tecnica, "
|
||||||
|
"un flag, una herramienta— y no lo tengas ya delante en la "
|
||||||
|
"documentacion. Puedes reformular la consulta con tus propias "
|
||||||
|
"palabras clave y buscar varias veces hasta dar con ello. "
|
||||||
|
"Devuelve los fragmentos mas parecidos, con su fuente, para "
|
||||||
|
"que respondas desde ahi y no de memoria."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"consulta": {
|
||||||
|
"type": "string",
|
||||||
|
"description": ("que buscar, en pocas palabras clave; "
|
||||||
|
"el nombre de la herramienta ayuda"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["consulta"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "comando_de_shell",
|
||||||
|
"description": (
|
||||||
|
"Ejecuta un comando en la terminal de esta maquina, que es "
|
||||||
|
"Debian con GNOME. Solo para lo que NO esta en el catalogo. "
|
||||||
|
"Es de solo lectura: no ejecutes nada que borre o modifique "
|
||||||
|
"sin que te lo pidan expresamente."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"comando": {"type": "string",
|
||||||
|
"description": "el comando, para bash"},
|
||||||
|
},
|
||||||
|
"required": ["comando"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Cerebro:
|
||||||
|
def __init__(self, catalogo, modelo=MODELO, endpoint=ENDPOINT, en_ram=False,
|
||||||
|
manos=None):
|
||||||
|
self.catalogo = catalogo
|
||||||
|
self.modelo = modelo
|
||||||
|
self.endpoint = endpoint
|
||||||
|
# num_gpu 0 fuerza CPU: el modelo entero en RAM. Util para probar uno
|
||||||
|
# mas grande del que cabe en la grafica, a costa de velocidad.
|
||||||
|
self.capas = 0 if en_ram else None
|
||||||
|
self.manos = manos # a quien pedirle que ejecute
|
||||||
|
self.historia = []
|
||||||
|
|
||||||
|
def _saber(self, texto: str) -> str:
|
||||||
|
"""Los apuntes de COFRE que vengan a cuento, para meterlos al prompt.
|
||||||
|
|
||||||
|
Es la mitad "aumentar" de RAG: antes de contestar, se le pone delante al
|
||||||
|
modelo el trozo de los apuntes del usuario que mas se parece a lo que
|
||||||
|
pregunta. Asi construye la respuesta desde SU documentacion —flags
|
||||||
|
reales, comandos que ha probado— en vez de desde lo que recuerde, que
|
||||||
|
para 668 herramientas seria inventarselo.
|
||||||
|
|
||||||
|
Si no hay indice o nada encaja lo bastante, devuelve cadena vacia y el
|
||||||
|
prompt se queda como estaba: la funcion no puede empeorar una respuesta,
|
||||||
|
solo mejorarla.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from saber.busca import contexto, disponible
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
if not disponible():
|
||||||
|
return ""
|
||||||
|
trozos = contexto(texto, cuantos=4, minimo=0.38)
|
||||||
|
if not trozos:
|
||||||
|
return ""
|
||||||
|
return ("\n\n## Documentacion del usuario relevante a esto\n"
|
||||||
|
"Usa ESTO para responder, son sus apuntes probados. Si el "
|
||||||
|
"comando exacto esta aqui, dalo tal cual; no te inventes flags "
|
||||||
|
"que no aparezcan.\n\n" + trozos)
|
||||||
|
|
||||||
|
def _pide(self, mensajes, tools=None) -> dict:
|
||||||
|
cuerpo = {"model": self.modelo, "messages": mensajes, "stream": False,
|
||||||
|
"think": False, "options": {"temperature": 0.3}}
|
||||||
|
if tools:
|
||||||
|
cuerpo["tools"] = tools
|
||||||
|
if self.capas is not None:
|
||||||
|
cuerpo["options"]["num_gpu"] = self.capas
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self.endpoint + "/api/chat", data=json.dumps(cuerpo).encode(),
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=300) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
def responde(self, texto: str, al_ejecutar=None) -> str:
|
||||||
|
"""Contesta, usando las manos si hace falta. Devuelve lo que hay que decir."""
|
||||||
|
self.historia.append({"role": "user", "content": texto})
|
||||||
|
sistema = PERSONA + self._saber(texto)
|
||||||
|
mensajes = [{"role": "system", "content": sistema}] + self.historia[-8:]
|
||||||
|
tools = herramientas(self.catalogo)
|
||||||
|
|
||||||
|
for _ in range(VUELTAS):
|
||||||
|
try:
|
||||||
|
d = self._pide(mensajes, tools)
|
||||||
|
except (urllib.error.URLError, OSError) as e:
|
||||||
|
return f"No he podido pensar, señor: {str(e)[:50]}"
|
||||||
|
|
||||||
|
m = d.get("message", {})
|
||||||
|
llamadas = m.get("tool_calls") or []
|
||||||
|
if not llamadas:
|
||||||
|
dicho = (m.get("content") or "").strip()
|
||||||
|
self.historia.append({"role": "assistant", "content": dicho})
|
||||||
|
return dicho
|
||||||
|
|
||||||
|
mensajes.append(m)
|
||||||
|
for lc in llamadas:
|
||||||
|
f = lc.get("function", {})
|
||||||
|
args = f.get("arguments") or {}
|
||||||
|
if isinstance(args, str):
|
||||||
|
try:
|
||||||
|
args = json.loads(args)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
args = {}
|
||||||
|
resultado = self._usa(f.get("name", ""), args, al_ejecutar)
|
||||||
|
mensajes.append({"role": "tool", "content": resultado[:2000]})
|
||||||
|
|
||||||
|
return "Me he liado dando vueltas, señor. Pruebe a pedirmelo de otra forma."
|
||||||
|
|
||||||
|
def _usa(self, nombre: str, args: dict, al_ejecutar=None) -> str:
|
||||||
|
# buscar_en_apuntes NO pasa por las manos: es lectura del indice, no
|
||||||
|
# toca la maquina, asi que funciona aunque el candado este echado.
|
||||||
|
if nombre == "buscar_en_apuntes":
|
||||||
|
return self._busca_apuntes(args.get("consulta", ""))
|
||||||
|
if self.manos is None:
|
||||||
|
return "no hay manos conectadas"
|
||||||
|
if nombre == "orden_del_catalogo":
|
||||||
|
return self.manos.por_orden(args.get("orden", ""), al_ejecutar)
|
||||||
|
if nombre == "comando_de_shell":
|
||||||
|
return self.manos.por_shell(args.get("comando", ""), al_ejecutar)
|
||||||
|
return f"no existe la herramienta {nombre}"
|
||||||
|
|
||||||
|
def _busca_apuntes(self, consulta: str) -> str:
|
||||||
|
"""El RAG como herramienta: el modelo busca cuando lo decide.
|
||||||
|
|
||||||
|
La inyeccion pasiva de _saber() usa la frase LITERAL del usuario y
|
||||||
|
dispara siempre. Esto es lo otro: el modelo reformula la consulta con
|
||||||
|
sus palabras y busca a proposito, que es lo que salva los casos donde el
|
||||||
|
usuario dice una cosa y el apunte esta escrito de otra."""
|
||||||
|
consulta = (consulta or "").strip()
|
||||||
|
if not consulta:
|
||||||
|
return "dime que buscar"
|
||||||
|
try:
|
||||||
|
from saber.busca import busca
|
||||||
|
except Exception:
|
||||||
|
return "no tengo los apuntes indexados"
|
||||||
|
trozos = [t for t in busca(consulta, cuantos=5) if t.get("sim", 0) >= 0.3]
|
||||||
|
if not trozos:
|
||||||
|
return ("no encontre nada en los apuntes sobre eso; prueba con otras "
|
||||||
|
"palabras o el nombre de la herramienta")
|
||||||
|
lineas = []
|
||||||
|
for t in trozos:
|
||||||
|
fuente = t.get("fichero") or f"{t.get('perfil')}/{t.get('herramienta')}"
|
||||||
|
lineas.append(f"[{fuente}]\n{t['texto'][:500]}")
|
||||||
|
return "\n\n".join(lineas)
|
||||||
468
nucleo/datos/_generar.py
Normal file
468
nucleo/datos/_generar.py
Normal file
|
|
@ -0,0 +1,468 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Genera acciones.json.
|
||||||
|
|
||||||
|
Se escribe aqui y no a mano en el JSON porque son mas de cien entradas con la
|
||||||
|
misma forma: en tabla se leen de un tirón y se ve si falta un acuse o si dos
|
||||||
|
frases chocan. El JSON es el producto; este fichero es la fuente.
|
||||||
|
|
||||||
|
python3 _generar.py && python3 -m json.tool acciones.json >/dev/null
|
||||||
|
|
||||||
|
Columnas: id | frases (| separadas) | acuse | comando | respuesta
|
||||||
|
|
||||||
|
Reglas que conviene no romper:
|
||||||
|
- Las frases van sin tildes ni signos: el motor normaliza lo que oye, pero
|
||||||
|
aqui se comparan tal cual.
|
||||||
|
- Frases cortas y genericas ("abre") arrastran todo lo demas; se prefieren
|
||||||
|
de tres palabras o mas, que es como se habla de verdad.
|
||||||
|
- Todo lo que borre, mate o apague lleva confirmar=True.
|
||||||
|
- {argumento} solo en las que declaran captura.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- sistema
|
||||||
|
SISTEMA = [
|
||||||
|
("disco_libre", "cuanto espacio queda|espacio en disco|espacio libre en disco|cuanto disco queda",
|
||||||
|
"Un momento, lo miro.", "df -h / | tail -1",
|
||||||
|
"Quedan {campo4} libres de {campo2}, al {campo5} de uso."),
|
||||||
|
("disco_home", "como esta mi disco|cuanto ocupa mi disco|espacio de mi disco",
|
||||||
|
"Un momento.", "df -h ~ | tail -1",
|
||||||
|
"Su disco esta al {campo5}, con {campo4} libres de {campo2}."),
|
||||||
|
("disco_todos", "discos montados|particiones|unidades montadas",
|
||||||
|
"Enseguida.", "df -h --output=target,size,pcent -x tmpfs -x devtmpfs | tail -n +2",
|
||||||
|
"Hay {lineas} unidades montadas. {salida}"),
|
||||||
|
("memoria", "cuanta memoria|memoria libre|cuanta ram|ram libre",
|
||||||
|
"Voy.", "free -g | awk '/Mem:/{print $2, $3, $7}'",
|
||||||
|
"De {campo1} gigas hay {campo2} en uso y {campo3} disponibles."),
|
||||||
|
("swap", "como esta la swap|memoria de intercambio",
|
||||||
|
"Voy.", "free -g | awk '/Swap:/{print $2, $3}'",
|
||||||
|
"Swap: {campo2} gigas usados de {campo1}."),
|
||||||
|
("cpu_carga", "carga del sistema|como va el procesador|carga de cpu",
|
||||||
|
"Un segundo.", "uptime | sed 's/.*average: //'",
|
||||||
|
"La carga media es {ultima}."),
|
||||||
|
("cpu_modelo", "que procesador tengo|modelo de procesador|que cpu tengo",
|
||||||
|
"Enseguida.", "lscpu | grep 'Model name' | cut -d: -f2 | xargs",
|
||||||
|
"Lleva un {ultima}."),
|
||||||
|
("cpu_nucleos", "cuantos nucleos tengo|cuantos hilos tengo",
|
||||||
|
"Voy.", "nproc", "Tiene {ultima} hilos disponibles."),
|
||||||
|
("temperatura", "que temperatura hay|como esta de temperatura|se esta calentando",
|
||||||
|
"Lo compruebo.", "for z in /sys/class/thermal/thermal_zone*/temp; do cat $z; done | sort -rn | head -1 | awk '{printf \"%.0f\", $1/1000}'",
|
||||||
|
"El punto mas caliente esta a {ultima} grados."),
|
||||||
|
("gpu_estado", "como esta la grafica|estado de la gpu|uso de la grafica",
|
||||||
|
"Un momento.",
|
||||||
|
"nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu --format=csv,noheader",
|
||||||
|
"La grafica esta asi: {ultima}."),
|
||||||
|
("uptime", "cuanto lleva encendido|desde cuando esta encendido|tiempo encendido",
|
||||||
|
"Voy.", "uptime -p | sed 's/^up //; s/ days\\?/ dias/; s/ hours\\?/ horas/; s/ minutes\\?/ minutos/; s/,\\([^,]*\\)$/ y\\1/'",
|
||||||
|
"Lleva encendido {ultima}."),
|
||||||
|
("bateria", "cuanta bateria queda|como esta la bateria|nivel de bateria",
|
||||||
|
"Lo miro.", "cat /sys/class/power_supply/BAT*/capacity 2>/dev/null | head -1",
|
||||||
|
"La bateria esta al {ultima} por ciento."),
|
||||||
|
("kernel", "que kernel tengo|version del kernel|que nucleo tengo",
|
||||||
|
"Enseguida.", "uname -r", "Kernel {ultima}."),
|
||||||
|
("distro", "que distribucion tengo|que sistema tengo|que linux tengo",
|
||||||
|
"Voy.", "grep PRETTY_NAME /etc/os-release | cut -d'\"' -f2",
|
||||||
|
"Esta usando {ultima}."),
|
||||||
|
("fecha", "que hora es|que dia es|dime la fecha",
|
||||||
|
"", "date '+%H:%M del %A %d de %B'", "Son las {salida}."),
|
||||||
|
("procesos_top", "que esta consumiendo|que consume mas|procesos que mas gastan|quien se come la cpu",
|
||||||
|
"Lo compruebo.", "ps -eo pcpu,comm --sort=-pcpu --no-headers | head -5 | awk '{c=$1; $1=\"\"; sub(/^ +/,\"\"); printf \"%s al %s por ciento\\n\", $0, c}'",
|
||||||
|
"Lo que mas consume es {primera}."),
|
||||||
|
("procesos_memoria", "que consume mas memoria|quien se come la ram",
|
||||||
|
"Lo compruebo.", "ps -eo comm,pmem --sort=-pmem --no-headers | head -5",
|
||||||
|
"Por memoria: {salida}"),
|
||||||
|
("procesos_cuantos", "cuantos procesos hay|cuantos procesos corren",
|
||||||
|
"Voy.", "ps -e --no-headers | wc -l", "Hay {ultima} procesos en marcha."),
|
||||||
|
("red_ip", "cual es mi ip|mi ip local|que ip tengo",
|
||||||
|
"Un momento.", "hostname -I | awk '{print $1}'", "Su IP local es {ultima}."),
|
||||||
|
("red_ip_publica", "cual es mi ip publica|ip externa",
|
||||||
|
"Salgo a mirarlo.", "curl -s --max-time 6 ifconfig.me", "Su IP publica es {ultima}."),
|
||||||
|
("red_wifi", "a que wifi estoy conectado|red wifi|como se llama la wifi",
|
||||||
|
"Voy.", "nmcli -t -f NAME,TYPE connection show --active | grep wireless | cut -d: -f1",
|
||||||
|
"Conectado a {ultima}."),
|
||||||
|
("red_velocidad", "cuanto trafico de red|trafico de red",
|
||||||
|
"Lo miro.", "cat /proc/net/dev | awk '/wlp|enp/{rx+=$2; tx+=$10} END{printf \"%.1f %.1f\", rx/1e9, tx/1e9}'",
|
||||||
|
"Desde el arranque: {campo1} gigas bajados y {campo2} subidos."),
|
||||||
|
("red_ping", "hay internet|tengo conexion|funciona internet",
|
||||||
|
"Lo compruebo.",
|
||||||
|
"ping -c1 -W2 1.1.1.1 >/dev/null 2>&1 && echo si || echo no",
|
||||||
|
"Conexion: {ultima}."),
|
||||||
|
("puertos", "que puertos hay abiertos|puertos a la escucha|quien esta escuchando",
|
||||||
|
"Un momento.", "ss -ltnp 2>/dev/null | awk 'NR>1{print $4}' | sort -u | head -8",
|
||||||
|
"A la escucha: {salida}"),
|
||||||
|
("servicios_fallidos", "hay servicios caidos|servicios con fallos|algo ha fallado",
|
||||||
|
"Lo reviso.", "systemctl --failed --no-legend | wc -l",
|
||||||
|
"Servicios en fallo: {ultima}."),
|
||||||
|
("actualizaciones", "hay actualizaciones|paquetes por actualizar",
|
||||||
|
"Lo compruebo, tarda un poco.",
|
||||||
|
"apt list --upgradable 2>/dev/null | tail -n +2 | wc -l",
|
||||||
|
"Hay {ultima} paquetes por actualizar."),
|
||||||
|
("usuarios", "quien esta conectado|hay alguien conectado",
|
||||||
|
"Voy.", "who | wc -l", "Hay {ultima} sesiones abiertas."),
|
||||||
|
("gpu_procesos", "que usa la grafica|quien usa la gpu",
|
||||||
|
"Lo miro.", "O=$(nvidia-smi --query-compute-apps=process_name,used_gpu_memory --format=csv,noheader | awk -F, '{n=split($1,p,\"/\"); printf \"%s%s\\n\", p[n], $2}'); echo \"${O:-nada, la grafica esta libre}\"",
|
||||||
|
"En la grafica: {primera}"),
|
||||||
|
("audio_salida", "por donde suena|salida de audio|que altavoz esta puesto",
|
||||||
|
"Voy.", "pactl get-default-sink 2>/dev/null", "La salida es {ultima}."),
|
||||||
|
("audio_micro", "que microfono hay puesto|entrada de audio",
|
||||||
|
"Voy.", "pactl get-default-source 2>/dev/null", "La entrada es {ultima}."),
|
||||||
|
("pantallas", "cuantas pantallas hay|monitores conectados",
|
||||||
|
"Voy.", "n=$(xrandr --listmonitors 2>/dev/null | head -1 | awk '{print $2}'); xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $NF}' | paste -sd', '; echo \"Tiene $n pantalla$([ \"$n\" = 1 ] || echo s) conectada$([ \"$n\" = 1 ] || echo s)\"",
|
||||||
|
"{ultima}"),
|
||||||
|
("dispositivos_usb", "que hay conectado por usb|dispositivos usb",
|
||||||
|
"Un momento.", "lsusb | cut -d' ' -f7- | head -6", "Por USB: {salida}"),
|
||||||
|
("bluetooth", "que hay emparejado por bluetooth|dispositivos bluetooth",
|
||||||
|
"Voy.", "bluetoothctl devices 2>/dev/null | cut -d' ' -f3- | head -5",
|
||||||
|
"Emparejados: {salida}"),
|
||||||
|
("flatpaks", "que flatpaks tengo|aplicaciones flatpak",
|
||||||
|
"Voy.", "flatpak list --app --columns=name | head -10", "Instalados: {salida}"),
|
||||||
|
("docker", "hay contenedores corriendo|contenedores docker",
|
||||||
|
"Lo miro.", "O=$(docker ps --format '{{.Names}}' 2>/dev/null | head -5); echo \"${O:-ninguno en marcha}\"",
|
||||||
|
"Contenedores: {salida}"),
|
||||||
|
("git_estado", "como esta el repositorio|estado de git",
|
||||||
|
"Voy.", "cd ~/COFRE/CODERS/JARVIS && git status --short | wc -l",
|
||||||
|
"Hay {ultima} ficheros con cambios sin guardar."),
|
||||||
|
("ollama_estado", "esta ollama funcionando|como esta el modelo|que modelo hay cargado",
|
||||||
|
"Lo compruebo.",
|
||||||
|
"M=$(curl -s --max-time 3 http://localhost:11434/api/ps | grep -o '\"name\":\"[^\"]*' | cut -d'\"' -f4 | head -1); echo \"${M:-ninguno ahora mismo}\"",
|
||||||
|
"Modelo cargado: {ultima}."),
|
||||||
|
("apagar_pantalla", "apaga la pantalla|apagar pantalla",
|
||||||
|
"Enseguida.", "xset dpms force off", "Pantalla apagada."),
|
||||||
|
# --- administracion: necesitan root, disparan el dialogo de contraseña ---
|
||||||
|
# (Las de pentesting van en su propia lista, PENTEST, mas abajo.)
|
||||||
|
("actualizar_lista", "actualiza los repositorios|refresca los paquetes|actualiza la lista de paquetes",
|
||||||
|
"Voy, necesito permiso de administrador.", "sudo apt update",
|
||||||
|
"Repositorios actualizados, señor.", True),
|
||||||
|
("espacio_liberar", "limpia los paquetes viejos|libera espacio de apt|limpia el cache de paquetes",
|
||||||
|
"Necesito permiso para esto.", "sudo apt autoclean",
|
||||||
|
"Cache de paquetes limpiada, señor.", True),
|
||||||
|
("servicio_reiniciar", "reinicia el servicio de red|reinicia la red",
|
||||||
|
"Necesito permiso de administrador.", "sudo systemctl restart NetworkManager",
|
||||||
|
"Red reiniciada, señor.", True),
|
||||||
|
("fecha_sincronizar", "sincroniza la hora|pon la hora en hora",
|
||||||
|
"Un momento, con permiso.", "sudo systemctl restart systemd-timesyncd",
|
||||||
|
"Hora sincronizada, señor.", True),
|
||||||
|
("bloquear", "bloquea la sesion|bloquear pantalla|echa el cerrojo",
|
||||||
|
"Ahora mismo.", "loginctl lock-session", "Sesion bloqueada."),
|
||||||
|
("volumen_subir", "sube el volumen|mas volumen",
|
||||||
|
"", "pactl set-sink-volume @DEFAULT_SINK@ +10%", "Volumen subido."),
|
||||||
|
("volumen_bajar", "baja el volumen|menos volumen",
|
||||||
|
"", "pactl set-sink-volume @DEFAULT_SINK@ -10%", "Volumen bajado."),
|
||||||
|
("volumen_silencio", "silencia el sistema|quita el sonido|mutea el sistema",
|
||||||
|
"", "pactl set-sink-mute @DEFAULT_SINK@ toggle", "Sonido conmutado."),
|
||||||
|
("brillo_subir", "sube el brillo|mas brillo",
|
||||||
|
"", "brightnessctl set +10% 2>/dev/null || xrandr --output $(xrandr | awk '/ connected/{print $1; exit}') --brightness 1",
|
||||||
|
"Brillo subido."),
|
||||||
|
("captura_pantalla", "hazme una captura|captura de pantalla|haz una foto de la pantalla",
|
||||||
|
"Enseguida se la hago.", "D=$(xdg-user-dir PICTURES 2>/dev/null || echo ~); F=$D/captura-$(date +%H%M%S).png; import -window root \"$F\" 2>/dev/null || gnome-screenshot -f \"$F\" 2>/dev/null || ffmpeg -loglevel error -f x11grab -video_size $(xdpyinfo | awk \"/dimensions/{print \\$2}\") -i $DISPLAY -frames:v 1 -y \"$F\" 2>/dev/null; [ -s \"$F\" ] && echo \"$F\"",
|
||||||
|
"Captura guardada."),
|
||||||
|
("papelera_tamano", "cuanto ocupa la papelera|papelera llena",
|
||||||
|
"Voy.", "du -sh ~/.local/share/Trash 2>/dev/null | cut -f1",
|
||||||
|
"La papelera ocupa {ultima}."),
|
||||||
|
("descargas_listar", "que hay en descargas|ultimas descargas|que me he bajado",
|
||||||
|
"Voy.", "ls -t ~/Downloads 2>/dev/null | head -6", "Hay {lineas} cosas en descargas"),
|
||||||
|
("descargas_abrir", "abre la carpeta de descargas|abreme descargas",
|
||||||
|
"Enseguida se la abro.", "xdg-open ~/Downloads", "Carpeta de descargas abierta."),
|
||||||
|
("documentos_abrir", "abre mis documentos|abreme documentos",
|
||||||
|
"Enseguida.", "xdg-open ~/Documents", "Documentos abierto."),
|
||||||
|
("cofre_abrir", "abre el cofre|abreme el cofre",
|
||||||
|
"Enseguida.", "xdg-open ~/COFRE", "COFRE abierto."),
|
||||||
|
("ficheros_recientes", "que he tocado ultimamente|ficheros recientes|en que estaba trabajando",
|
||||||
|
"Lo miro.",
|
||||||
|
"find ~ -maxdepth 3 -type f -mmin -180 -not -path '*/.*' 2>/dev/null | head -6",
|
||||||
|
"Tocado en las ultimas horas: {salida}"),
|
||||||
|
("grandes", "que ocupa mas sitio|que tengo en descargas|que me esta llenando la carpeta",
|
||||||
|
"Lo miro.",
|
||||||
|
"du -ah ~/Downloads ~/Descargas ~/Documents ~/Desktop 2>/dev/null | sort -rh | head -6",
|
||||||
|
"Lo mas grande: {salida}"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ------------------------------------------------------- con argumento
|
||||||
|
CAPTURA = [
|
||||||
|
("buscar_fichero", "busca el fichero|buscame el fichero|encuentra el fichero",
|
||||||
|
"Lo busco.", "timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS $HOME/Downloads -iname '*{argumento}*' -not -path '*/.*' -not -path '*/node_modules/*' -not -path '*venv*' 2>/dev/null | head -12",
|
||||||
|
"He encontrado {lineas}"),
|
||||||
|
("buscar_carpeta", "busca la carpeta|encuentra la carpeta",
|
||||||
|
"La busco.", "find ~ -type d -iname '*{argumento}*' -not -path '*/.*' 2>/dev/null | head -12",
|
||||||
|
"Carpetas encontradas: {salida}"),
|
||||||
|
# busca un TEXTO dentro de los ficheros, recursivo, con ruta y linea
|
||||||
|
("grep_recursivo", "busca el texto|busca la palabra en mis ficheros|donde pone|que fichero contiene",
|
||||||
|
"Lo busco en sus ficheros.", "timeout 12 grep -rIn --exclude-dir='.*' --exclude-dir='*venv*' --exclude-dir=node_modules --exclude-dir=__pycache__ --exclude-dir=modelos --exclude-dir=muestras -m1 {argumento} $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS 2>/dev/null | head -12",
|
||||||
|
"{lineas} ficheros lo contienen."),
|
||||||
|
("leer_fichero", "leeme el fichero|abre y leeme|lee el fichero",
|
||||||
|
"Voy a leerlo.", "f=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS -iname '*{argumento}*' -type f -not -path '*/.*' 2>/dev/null | head -1); [ -n \"$f\" ] && head -c 800 \"$f\" || echo \"no lo encuentro\"",
|
||||||
|
"{salida}, señor"),
|
||||||
|
("abrir_fichero", "abre el fichero|abreme el fichero",
|
||||||
|
"Enseguida se lo abro.",
|
||||||
|
"f=$(find ~ -iname '*{argumento}*' -type f -not -path '*/.*' 2>/dev/null | head -1); "
|
||||||
|
"[ -n \"$f\" ] && xdg-open \"$f\" && echo \"$f\" || echo 'no lo encuentro'",
|
||||||
|
"Abierto {ultima}."),
|
||||||
|
("abrir_carpeta", "abre la carpeta|abreme la carpeta",
|
||||||
|
"Enseguida.", "a={argumento}; if [ -d \"$a\" ]; then d=\"$a\"; else d=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE $HOME/Downloads -type d -iname \"*$a*\" -not -path \"*/.*\" -not -path \"*/node_modules/*\" 2>/dev/null | head -1); fi; if [ -n \"$d\" ]; then xdg-open \"$d\" >/dev/null 2>&1 & echo \"Abierta $(basename \"$d\")\"; else echo \"No encuentro esa carpeta\"; fi",
|
||||||
|
"{ultima}"),
|
||||||
|
("tamano_carpeta", "cuanto ocupa la carpeta|tamaño de la carpeta",
|
||||||
|
"Lo calculo.",
|
||||||
|
"a={argumento}; if [ -d \"$a\" ]; then d=\"$a\"; else d=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE $HOME/Downloads -type d -iname \"*$a*\" -not -path \"*/.*\" 2>/dev/null | head -1); fi; [ -n \"$d\" ] && du -sh \"$d\" 2>/dev/null | cut -f1 || echo \"no la encuentro\"",
|
||||||
|
"Ocupa {ultima}."),
|
||||||
|
("proceso_buscar", "esta corriendo|se esta ejecutando|hay algun proceso de",
|
||||||
|
"Lo compruebo.", "pgrep -c -f {argumento} 2>/dev/null || echo 0",
|
||||||
|
"Hay {ultima} procesos que encajan."),
|
||||||
|
("proceso_matar", "mata el proceso|cierra el proceso|termina el proceso",
|
||||||
|
"Antes de matarlo, confirmeme.", "pkill -f {argumento}",
|
||||||
|
"Proceso terminado.", True),
|
||||||
|
("instalado", "tengo instalado|esta instalado",
|
||||||
|
"Lo miro.", "command -v {argumento} >/dev/null && echo si || echo no",
|
||||||
|
"Instalado: {ultima}."),
|
||||||
|
("manual", "para que sirve el comando|que hace el comando",
|
||||||
|
"Voy.", "whatis {argumento} 2>/dev/null | head -1", "{salida}"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- oasis
|
||||||
|
# --------------------------------------------------------------- pentesting
|
||||||
|
# Red de seguridad: los escaneos ACTIVOS (que mandan paquetes a otros) llevan
|
||||||
|
# confirmar=True, asi que el dialogo muestra el comando exacto antes de lanzarse
|
||||||
|
# y la voz no puede disparar un escaneo sola. Los que apuntan a un objetivo se
|
||||||
|
# limitan a la RED LOCAL por construccion (autodetectan la subred o exigen IP
|
||||||
|
# privada); un objetivo de fuera se rechaza. Lo de solo lectura —buscar un
|
||||||
|
# exploit en la base local, resolver un dominio— va directo, no toca a nadie.
|
||||||
|
#
|
||||||
|
# Que apunte solo a la red local es a proposito: un asistente por voz que
|
||||||
|
# escanea lo que crea haber entendido, con salida al host, es un incidente
|
||||||
|
# esperando. Para objetivos de fuera, mejor a mano y con intencion.
|
||||||
|
PENTEST = [
|
||||||
|
("red_dispositivos", "escanea mi red|que hay en mi red|dispositivos en la red|quien esta en la red",
|
||||||
|
"Voy a escanear la red local, necesito su permiso.",
|
||||||
|
"R=$(ip route | awk '/proto.*src/{for(i=1;i<=NF;i++)if($i~/\\//)print $i; exit}'); "
|
||||||
|
"sudo nmap -sn ${R:-192.168.1.0/24} | grep -E 'Nmap scan|MAC' | head -30",
|
||||||
|
"Estos equipos hay en la red, señor: {salida}", True),
|
||||||
|
("puertos_host", "que puertos tiene abiertos|escanea los puertos de|escanea el host",
|
||||||
|
"Un escaneo de puertos, con su permiso.",
|
||||||
|
"T={argumento}; echo \"$T\" | grep -qE '^(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|127\\.|localhost)' "
|
||||||
|
"&& nmap -F \"$T\" | grep -E '^[0-9]+/|Nmap scan' | head -20 "
|
||||||
|
"|| echo 'Solo objetivos de la red local, señor.'",
|
||||||
|
"{salida}", True),
|
||||||
|
("mis_puertos", "que puertos tengo abiertos yo|mis puertos abiertos|que estoy exponiendo",
|
||||||
|
"Lo compruebo.",
|
||||||
|
"ss -ltnp 2>/dev/null | awk 'NR>1{print $4}' | sort -u | head -15",
|
||||||
|
"Tengo {lineas} puertos a la escucha"),
|
||||||
|
("exploit_buscar", "busca un exploit para|busca exploits de|hay algun exploit de",
|
||||||
|
"Lo busco en la base de exploits.",
|
||||||
|
"searchsploit {argumento} 2>/dev/null | grep -vE '^-|Shellcode' | head -12",
|
||||||
|
"Esto he encontrado, señor: {salida}"),
|
||||||
|
("dominio_resolver", "resuelve el dominio|que ip tiene el dominio|a que ip apunta",
|
||||||
|
"Lo resuelvo.", "dig +short {argumento} 2>/dev/null | head -5",
|
||||||
|
"Resuelve a: {salida}"),
|
||||||
|
("dominio_whois", "de quien es el dominio|quien registro|whois de",
|
||||||
|
"Lo consulto.", "whois {argumento} 2>/dev/null | grep -iE 'registrar:|creation|expir|org' | head -6",
|
||||||
|
"{salida}"),
|
||||||
|
("cabeceras_web", "que servidor usa|cabeceras de|que tecnologia usa la web",
|
||||||
|
"Miro las cabeceras.",
|
||||||
|
"curl -sI --max-time 8 {argumento_url} 2>/dev/null | grep -iE 'server:|x-powered|location' | head -6",
|
||||||
|
"{salida}"),
|
||||||
|
("hash_identificar", "que tipo de hash es|identifica el hash",
|
||||||
|
"Lo miro.",
|
||||||
|
"H={argumento}; L=${#H}; case $L in 32)echo 'MD5, probablemente';; 40)echo 'SHA1';; "
|
||||||
|
"64)echo 'SHA256';; *)echo \"$L caracteres, no lo tengo claro\";; esac",
|
||||||
|
"{ultima}, señor."),
|
||||||
|
]
|
||||||
|
|
||||||
|
# las con argumento del bloque PENTEST (para marcar captura=True)
|
||||||
|
PENTEST_CAPTURA = {"puertos_host", "exploit_buscar", "dominio_resolver",
|
||||||
|
"dominio_whois", "cabeceras_web", "hash_identificar"}
|
||||||
|
|
||||||
|
OASIS = [
|
||||||
|
("oasis_corriendo", "esta oasis funcionando|esta corriendo oasis|oasis esta levantado",
|
||||||
|
"Lo compruebo.",
|
||||||
|
"pgrep -c -f 'oasis|ssb-server|sbot' 2>/dev/null || echo 0",
|
||||||
|
"Procesos de Oasis en marcha: {ultima}."),
|
||||||
|
("oasis_sincronizando", "estan sincronizando los pubs|como van los pubs|se estan sincronizando los pubs",
|
||||||
|
"Voy a mirar las conexiones.",
|
||||||
|
"python3 -c \"import json,time; d=json.load(open('$HOME/.ssb/conn.json')); "
|
||||||
|
"act=sum(1 for v in d.values() if time.time()*1000-v.get('stateChange',0)<300000); "
|
||||||
|
"fail=sum(1 for v in d.values() if v.get('failure',0)>0); "
|
||||||
|
"print(f'{len(d)} pubs, {act} activos hace poco, {fail} con fallos')\"",
|
||||||
|
"{ultima}."),
|
||||||
|
("oasis_conexiones", "cuantos pubs hay conectados|conexiones de oasis",
|
||||||
|
"Un momento.",
|
||||||
|
"cat ~/.ssb/conn.json 2>/dev/null | grep -c 'connected' || echo 0",
|
||||||
|
"Pubs conectados: {ultima}."),
|
||||||
|
("oasis_tamano", "cuanto ocupa oasis|tamaño de la base de ssb",
|
||||||
|
"Lo calculo.", "du -sh ~/.ssb 2>/dev/null | cut -f1",
|
||||||
|
"La base de SSB ocupa {ultima}."),
|
||||||
|
("oasis_abrir", "abre oasis|abreme oasis",
|
||||||
|
"Enseguida se lo abro.",
|
||||||
|
"xdg-open http://localhost:3000 2>/dev/null && echo abierto || echo 'no responde'",
|
||||||
|
"Oasis: {ultima}."),
|
||||||
|
("oasis_blobs", "cuanto ocupan los blobs|tamaño de los blobs",
|
||||||
|
"Voy.", "du -sh ~/.ssb/blobs 2>/dev/null | cut -f1", "Los blobs ocupan {ultima}."),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Alias foneticos: lo que Whisper devuelve DE VERDAD, no lo que uno escribe.
|
||||||
|
# Medido con ocho ordenes habladas: "abre el correo" se transcribe "a ver el
|
||||||
|
# correo" (en castellano suenan igual), "los pubs" sale "los push" o "los
|
||||||
|
# puos", y "youtube" se parte en "yo tube". Añadirlos sube los aciertos sin
|
||||||
|
# tocar el modelo ni añadir un milisegundo.
|
||||||
|
ALIAS = {
|
||||||
|
"ff_gmail": ["a ver el correo", "abrir el correo"],
|
||||||
|
"oasis_sincronizando": ["estan sincronizando los push", "estan sincronizando los puos",
|
||||||
|
"sincronizando los pubs", "como van los push"],
|
||||||
|
"oasis_corriendo": ["esta oasis funcionando", "esta corriendo casis"],
|
||||||
|
"ff_buscar_youtube": ["busca en yo tube", "buscan youtube", "buscan yo tube",
|
||||||
|
"busca en you tube"],
|
||||||
|
"disco_libre": ["cuando espacio queda", "cuanto espacio quedan"],
|
||||||
|
"ff_youtube": ["abre yo tube", "abre you tube"],
|
||||||
|
"descargas_abrir": ["abre descargas", "abreme las descargas"],
|
||||||
|
"procesos_top": ["que esta consumiendo mas", "quien consume"],
|
||||||
|
"temperatura": ["que temperatura tiene", "como esta la temperatura"],
|
||||||
|
"bateria": ["cuanta bateria tengo", "como va la bateria"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def con_señor(texto: str) -> str:
|
||||||
|
"""Mete el trato en las frases fijas.
|
||||||
|
|
||||||
|
Son las que mas suenan —el acuse va en cada orden— y hasta ahora eran las
|
||||||
|
unicas que no trataban de señor, asi que el personaje se caia justo ahi.
|
||||||
|
"""
|
||||||
|
if not texto or "señor" in texto.lower():
|
||||||
|
return texto
|
||||||
|
if texto.endswith("."):
|
||||||
|
return texto[:-1] + ", señor."
|
||||||
|
if texto.endswith("?"):
|
||||||
|
return texto[:-1] + ", señor?"
|
||||||
|
return texto + ", señor"
|
||||||
|
|
||||||
|
|
||||||
|
# Abrir una terminal y poner un video: de lo primero que se le pide a un
|
||||||
|
# asistente de escritorio, y no estaban. Las busquedas van acotadas y con
|
||||||
|
# tope porque el home son casi mil gigas y un find sin limite no vuelve.
|
||||||
|
ESCRITORIO = [
|
||||||
|
("terminal_abrir", "abre una terminal|abreme una terminal|abre la terminal|saca una terminal|quiero una terminal",
|
||||||
|
"Ahora mismo.", "gnome-terminal 2>/dev/null || kgx 2>/dev/null || xterm 2>/dev/null &",
|
||||||
|
"Terminal abierta."),
|
||||||
|
("terminal_aqui", "abre una terminal en el cofre|terminal en el cofre",
|
||||||
|
"Enseguida.", "gnome-terminal --working-directory=$HOME/COFRE 2>/dev/null || kgx 2>/dev/null &",
|
||||||
|
"Ahi la tiene, en el cofre."),
|
||||||
|
("videos_listar", "que peliculas tengo|que videos tengo|lista mis videos",
|
||||||
|
"Un momento, lo miro.", "timeout 12 find $HOME/Videos $HOME/Downloads $HOME/Desktop -type f -regextype posix-extended -iregex '.*\\.(mp4|mkv|avi|webm|mov|m4v)$' 2>/dev/null | head -12",
|
||||||
|
"He encontrado {lineas}."),
|
||||||
|
]
|
||||||
|
|
||||||
|
# esta captura el nombre de la pelicula
|
||||||
|
ESCRITORIO_CAPTURA = [
|
||||||
|
("pelicula_abrir", "pon la pelicula|abre la pelicula|reproduce el video|pon el video|quiero ver",
|
||||||
|
"Enseguida se la pongo.", "v=$(timeout 12 find $HOME/Videos $HOME/Downloads $HOME/Desktop -type f -regextype posix-extended -iregex '.*{argumento}.*\\.(mp4|mkv|avi|webm|mov|m4v)$' 2>/dev/null | head -1); if [ -n \"$v\" ]; then xdg-open \"$v\" >/dev/null 2>&1 & echo \"Poniendo $(basename \"$v\")\"; else echo \"No encuentro ese video\"; fi",
|
||||||
|
"{ultima}"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ventanas y pantallas. Toda la logica vive en config/ventanas.sh (un script del
|
||||||
|
# host): aqui solo estan las frases y la llamada.
|
||||||
|
#
|
||||||
|
# Las frases llevan TRES palabras o mas y evitan a proposito ser subcadena de
|
||||||
|
# las que ya existen con "pantalla" o "ventana" dentro —apagar_pantalla,
|
||||||
|
# bloquear, captura_pantalla, ff_firefox_privado, ff_firefox_cerrar,
|
||||||
|
# proceso_matar, terminal_abrir—, porque el emparejador gana por frase mas
|
||||||
|
# larga sin mirar el grupo y una frase mal elegida se las come.
|
||||||
|
VENTANAS = [
|
||||||
|
("ventanas_listar", "que ventanas tengo abiertas|que ventanas hay abiertas|lista las ventanas",
|
||||||
|
"Un momento, lo miro.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh listar",
|
||||||
|
"Tiene {lineas} ventanas abiertas."),
|
||||||
|
|
||||||
|
("ventana_izquierda", "pon la ventana a la izquierda|manda la ventana a la izquierda|esta ventana a la izquierda",
|
||||||
|
"Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta izquierda", "{ultima}"),
|
||||||
|
|
||||||
|
("ventana_derecha", "pon la ventana a la derecha|manda la ventana a la derecha|esta ventana a la derecha",
|
||||||
|
"Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta derecha", "{ultima}"),
|
||||||
|
|
||||||
|
("ventana_arriba", "pon la ventana arriba del todo|manda la ventana arriba",
|
||||||
|
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta arriba", "{ultima}"),
|
||||||
|
|
||||||
|
("ventana_abajo", "pon la ventana abajo del todo|manda la ventana abajo",
|
||||||
|
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta abajo", "{ultima}"),
|
||||||
|
|
||||||
|
("ventana_centrar", "centra la ventana activa|pon la ventana en el centro",
|
||||||
|
"Enseguida.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta centro", "{ultima}"),
|
||||||
|
|
||||||
|
("ventana_maximizar", "maximiza la ventana activa|pon la ventana a pantalla completa",
|
||||||
|
"Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh maximiza esta", "{ultima}"),
|
||||||
|
|
||||||
|
("ventana_restaurar", "restaura la ventana activa|quita la pantalla completa",
|
||||||
|
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh restaura esta", "{ultima}"),
|
||||||
|
|
||||||
|
("ventana_minimizar", "minimiza la ventana activa|esconde la ventana activa",
|
||||||
|
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh minimiza esta", "{ultima}"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Estas capturan lo que se dice detras: el nombre de la pantalla, el numero de
|
||||||
|
# escritorio o el mote que se le quiera poner a un monitor.
|
||||||
|
VENTANAS_CAPTURA = [
|
||||||
|
("ventana_a_pantalla", "manda la ventana a la pantalla|pasa la ventana a la pantalla",
|
||||||
|
"Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh mover esta {argumento}", "{ultima}"),
|
||||||
|
|
||||||
|
("ventana_al_escritorio", "manda la ventana al escritorio|pasa la ventana al escritorio",
|
||||||
|
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh escritorio esta {argumento}", "{ultima}"),
|
||||||
|
|
||||||
|
("pantalla_nombrar", "a esta pantalla llamala|llama a esta pantalla",
|
||||||
|
"Anotado.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh alias 1 {argumento}", "{ultima}"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def fila(t, grupo, captura=False):
|
||||||
|
ident, frases, acuse, comando, respuesta = t[:5]
|
||||||
|
confirmar = len(t) > 5 and t[5]
|
||||||
|
return {
|
||||||
|
"id": ident,
|
||||||
|
"grupo": grupo,
|
||||||
|
"frases": frases.split("|"),
|
||||||
|
"acuse": con_señor(acuse or "Voy."),
|
||||||
|
"comando": comando,
|
||||||
|
"host": True,
|
||||||
|
"respuesta": con_señor(respuesta),
|
||||||
|
"confirmar": bool(confirmar),
|
||||||
|
"captura": captura,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
acciones = [fila(t, "sistema") for t in SISTEMA]
|
||||||
|
acciones += [fila(t, "sistema") for t in ESCRITORIO]
|
||||||
|
acciones += [fila(t, "sistema", captura=True) for t in ESCRITORIO_CAPTURA]
|
||||||
|
acciones += [fila(t, "ventanas") for t in VENTANAS]
|
||||||
|
acciones += [fila(t, "ventanas", captura=True) for t in VENTANAS_CAPTURA]
|
||||||
|
acciones += [fila(t, "ficheros", captura=True) for t in CAPTURA]
|
||||||
|
acciones += [fila(t, "oasis") for t in OASIS]
|
||||||
|
acciones += [fila(t, "pentest", captura=t[0] in PENTEST_CAPTURA) for t in PENTEST]
|
||||||
|
|
||||||
|
# las de firefox viven en su propio fichero, que son otras cincuenta
|
||||||
|
firefox = pathlib.Path(__file__).parent / "_firefox.py"
|
||||||
|
if firefox.exists():
|
||||||
|
import importlib.util
|
||||||
|
spec = importlib.util.spec_from_file_location("firefox", firefox)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
acciones += mod.acciones()
|
||||||
|
|
||||||
|
# Los alias se aplican aqui, sobre la lista completa: si se hicieran al
|
||||||
|
# construir cada fila, las de firefox se quedarian fuera porque se generan
|
||||||
|
# en otro fichero. Se aprende por las malas.
|
||||||
|
for accion in acciones:
|
||||||
|
extra = ALIAS.get(accion["id"])
|
||||||
|
if extra:
|
||||||
|
accion["frases"] += [f for f in extra if f not in accion["frases"]]
|
||||||
|
|
||||||
|
salida = pathlib.Path(__file__).parent / "acciones.json"
|
||||||
|
salida.write_text(json.dumps({"version": 1, "acciones": acciones},
|
||||||
|
indent=2, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
ids = [a["id"] for a in acciones]
|
||||||
|
print(f"{len(acciones)} acciones escritas en {salida.name}")
|
||||||
|
if len(ids) != len(set(ids)):
|
||||||
|
repes = {i for i in ids if ids.count(i) > 1}
|
||||||
|
print(f"CUIDADO, ids repetidos: {repes}")
|
||||||
|
for grupo in sorted({a["grupo"] for a in acciones}):
|
||||||
|
print(f" {grupo:<10} {sum(1 for a in acciones if a['grupo'] == grupo)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
2179
nucleo/datos/acciones.json
Normal file
2179
nucleo/datos/acciones.json
Normal file
File diff suppressed because it is too large
Load diff
290
nucleo/estado.py
Executable file
290
nucleo/estado.py
Executable file
|
|
@ -0,0 +1,290 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Por donde va la migracion, en la terminal.
|
||||||
|
|
||||||
|
./estado.py las barras
|
||||||
|
./estado.py -v y que falta exactamente en cada hito
|
||||||
|
./estado.py --rapido sin ejecutar nada, solo mirar ficheros
|
||||||
|
|
||||||
|
El porcentaje NO se escribe a mano: sale de comprobaciones de verdad —que un
|
||||||
|
fichero exista, que una funcion este, que una prueba pase—. Un contador que se
|
||||||
|
actualiza a mano miente a la semana, y ademas es justo lo que uno quiere que no
|
||||||
|
mienta: si dice 60 % es porque tres de cinco comprobaciones pasan ahora mismo.
|
||||||
|
|
||||||
|
Las que cuestan tiempo (arrancar whisper, hablar con ollama) van marcadas como
|
||||||
|
lentas y se saltan con --rapido.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
RAIZ = os.path.dirname(AQUI)
|
||||||
|
VENV = os.path.join(AQUI, "venv", "bin", "python")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- comprobantes
|
||||||
|
|
||||||
|
def hay(*trozos) -> bool:
|
||||||
|
return os.path.exists(os.path.join(AQUI, *trozos))
|
||||||
|
|
||||||
|
|
||||||
|
def dentro(fichero, texto) -> bool:
|
||||||
|
try:
|
||||||
|
with open(os.path.join(AQUI, fichero), encoding="utf-8") as f:
|
||||||
|
return texto in f.read()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def corre(orden, espera=90) -> bool:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(orden, cwd=AQUI, capture_output=True, timeout=espera)
|
||||||
|
return r.returncode == 0
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def modulo(nombre) -> bool:
|
||||||
|
return corre([VENV if os.path.exists(VENV) else sys.executable,
|
||||||
|
"-c", f"import {nombre}"], espera=30)
|
||||||
|
|
||||||
|
|
||||||
|
def sin_comentarios(fichero, texto) -> bool:
|
||||||
|
"""Si el texto aparece en una linea que NO es un comentario."""
|
||||||
|
try:
|
||||||
|
with open(os.path.join(AQUI, fichero), encoding="utf-8") as f:
|
||||||
|
for l in f:
|
||||||
|
limpia = l.strip()
|
||||||
|
if limpia and not limpia.startswith(("#", "//", "*")) and texto in limpia:
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def catalogo_tiene(n) -> bool:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
with open(os.path.join(AQUI, "datos", "acciones.json"), encoding="utf-8") as f:
|
||||||
|
return len(json.load(f)["acciones"]) >= n
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- los hitos
|
||||||
|
#
|
||||||
|
# Cada comprobacion es (que se comprueba, como, si es lenta). El orden dentro
|
||||||
|
# de cada hito va de lo mas basico a lo mas fino, que es como se construyo.
|
||||||
|
|
||||||
|
HITOS = [
|
||||||
|
("la boca", "hablar por los altavoces", [
|
||||||
|
("el modulo esta", lambda: hay("boca", "voz.py"), False),
|
||||||
|
("hay tres voces o mas", lambda: dentro("boca/voz.py", '"claude"'), False),
|
||||||
|
("cache de frases por sha1", lambda: dentro("boca/voz.py", "_clave"), False),
|
||||||
|
("sabe si esta sonando", lambda: dentro("boca/voz.py", "def hablando"), False),
|
||||||
|
("habla de verdad", lambda: corre(
|
||||||
|
[VENV, "jarvis.py", "--di", "prueba"], 60), True),
|
||||||
|
]),
|
||||||
|
|
||||||
|
("el oido", "escuchar y transcribir", [
|
||||||
|
("captura con pw-record", lambda: dentro("oido/captura.py", "pw-record"), False),
|
||||||
|
("detector de voz Silero", lambda: modulo("pysilero_vad"), False),
|
||||||
|
("cliente de whisper-server", lambda: hay("oido", "whisper.py"), False),
|
||||||
|
("filtro de alucinaciones", lambda: dentro("oido/whisper.py", "def es_ruido"), False),
|
||||||
|
("guarda contra el eco", lambda: dentro("oido/captura.py", "boca.hablando"), False),
|
||||||
|
("whisper arranca y transcribe", lambda: corre(
|
||||||
|
[VENV, "-c",
|
||||||
|
"import sys;sys.path.insert(0,'.');from oido.whisper import Oido;"
|
||||||
|
"o=Oido();import sys as s;s.exit(0 if o.arranca() else 1)"], 90), True),
|
||||||
|
]),
|
||||||
|
|
||||||
|
("las manos", "ejecutar las 150 acciones", [
|
||||||
|
("el emparejador copiado", lambda: hay("manos", "acciones.py"), False),
|
||||||
|
("las tareas en paralelo", lambda: hay("manos", "tareas.py"), False),
|
||||||
|
("el catalogo entero", lambda: catalogo_tiene(150), False),
|
||||||
|
("una sola puerta para ejecutar", lambda: dentro("manos/__init__.py", "class Manos"), False),
|
||||||
|
("compara tambien por sonido", lambda: hay("manos", "fonetica.py"), False),
|
||||||
|
("banco fijo para medir", lambda: hay("datos", "banco.json"), False),
|
||||||
|
("las pruebas en verde", lambda: corre(
|
||||||
|
[VENV, "pruebas/prueba_manos.py"], 120), True),
|
||||||
|
]),
|
||||||
|
|
||||||
|
("el cerebro", "pensar lo que el catalogo no cubre", [
|
||||||
|
("cliente de ollama", lambda: hay("cerebro", "ollama.py"), False),
|
||||||
|
("dos herramientas, no una", lambda: dentro("cerebro/ollama.py", "comando_de_shell"), False),
|
||||||
|
("no puede inventarse datos", lambda: dentro("cerebro/ollama.py", "REGLA QUE NO SE ROMPE"), False),
|
||||||
|
("tope de vueltas", lambda: dentro("cerebro/ollama.py", "VUELTAS"), False),
|
||||||
|
("cadena catalogo-primero", lambda: dentro("jarvis.py", "def atiende"), False),
|
||||||
|
("ollama contesta", lambda: corre(
|
||||||
|
["curl", "-sf", "-m", "10", "http://127.0.0.1:11434/api/tags"], 20), True),
|
||||||
|
]),
|
||||||
|
|
||||||
|
("la palabra", "que le llamen JARVIS", [
|
||||||
|
("el centinela existe", lambda: hay("oido", "despierta.py"), False),
|
||||||
|
("se llama JARVIS", lambda: dentro("oido/despierta.py", 'NOMBRE = "jarvis"'), False),
|
||||||
|
("aguanta como lo oye Whisper", lambda: dentro("oido/despierta.py", "PARECIDAS"), False),
|
||||||
|
("la orden va en la misma frase", lambda: dentro("oido/despierta.py",
|
||||||
|
"def parte_el_nombre"), False),
|
||||||
|
("conectado al bucle de voz", lambda: dentro("jarvis.py", "centinela.filtra"), False),
|
||||||
|
# El que de verdad importaba: sin el prompt del nombre, Whisper nunca
|
||||||
|
# escribe JARVIS y el centinela no despierta jamas.
|
||||||
|
("prompt del nombre para Whisper", lambda: dentro("oido/whisper.py",
|
||||||
|
"PROMPT_NOMBRE"), False),
|
||||||
|
("boton para dormirlo y despertarlo", lambda: dentro("cara/panel.py",
|
||||||
|
'dato == "sueno"'), False),
|
||||||
|
("prueba de voz de punta a punta", lambda: hay("pruebas", "prueba_voz.py"), False),
|
||||||
|
("y la palabra va APAGADA por defecto", lambda: dentro("jarvis.py",
|
||||||
|
"activo=a.palabra"), False),
|
||||||
|
]),
|
||||||
|
|
||||||
|
("la cara", "el panel", [
|
||||||
|
("el HUD copiado", lambda: hay("cara", "hud", "index.html"), False),
|
||||||
|
("servidor http y websocket", lambda: hay("cara", "servidor.py"), False),
|
||||||
|
# El WebSocket vive en puente.js, no en hud.js: hud.js no se toca a
|
||||||
|
# proposito y el puente suplanta window.webkit por debajo.
|
||||||
|
("puente por WebSocket", lambda: dentro("cara/hud/puente.js", "WebSocket"), False),
|
||||||
|
("y hud.js sigue sin tocarse", lambda: dentro("cara/hud/hud.js",
|
||||||
|
"messageHandlers"), False),
|
||||||
|
("el panel se conecta al nucleo", lambda: dentro("cara/servidor.py", "async"), False),
|
||||||
|
# Ojo con las comprobaciones flojas: esta buscaba la palabra "tarea" en
|
||||||
|
# servidor.py y pasaba porque salia en un COMENTARIO, con las tarjetas
|
||||||
|
# sin conectar. Una barra que miente es peor que no tener barra.
|
||||||
|
("tarjetas de tareas en marcha", lambda: dentro("cara/panel.py",
|
||||||
|
"GestorTareas"), False),
|
||||||
|
("la consola lleva la conversacion", lambda: dentro("cara/hud/hud.js",
|
||||||
|
"aConsolaDicho"), False),
|
||||||
|
]),
|
||||||
|
|
||||||
|
("el dialogo", "que pregunte en vez de recitar", [
|
||||||
|
("las preguntas salen del catalogo", lambda: hay("manos", "preguntar.py"), False),
|
||||||
|
("pulsar pregunta lo que falta", lambda: dentro("cara/panel.py", "_pregunta("), False),
|
||||||
|
("la respuesta hablada la recoge", lambda: dentro("jarvis.py", "panel.responde"), False),
|
||||||
|
("lo destructivo confirma", lambda: dentro("cara/panel.py", "accion.confirmar"), False),
|
||||||
|
("y el panel enseña que espera", lambda: dentro("cara/hud/hud.js", "preguntando"), False),
|
||||||
|
("la prueba pasa", lambda: corre([VENV, "pruebas/prueba_dialogo.py"], 120), True),
|
||||||
|
]),
|
||||||
|
|
||||||
|
("el candado", "que no obedezca a cualquiera", [
|
||||||
|
("el candado en el HUD", lambda: dentro("cara/hud/index.html", "candado"), False),
|
||||||
|
("lo atiende el nucleo", lambda: dentro("cara/panel.py", "_intenta_desbloquear"), False),
|
||||||
|
("valida con la contrasena del sistema", lambda: dentro("cara/panel.py",
|
||||||
|
"GestorRoot"), False),
|
||||||
|
("con el candado puesto no ejecuta", lambda: dentro("cara/panel.py",
|
||||||
|
"no hay ordenes"), False),
|
||||||
|
("y la VOZ tampoco obedece", lambda: dentro("jarvis.py", "(bloqueado)"), False),
|
||||||
|
("la prueba pasa", lambda: corre([VENV, "pruebas/prueba_candado.py"], 120), True),
|
||||||
|
]),
|
||||||
|
|
||||||
|
("el saber", "conocer COFRE entero", [
|
||||||
|
("indexador de COFRE", lambda: hay("saber", "indexa.py"), False),
|
||||||
|
("busqueda por significado", lambda: hay("saber", "busca.py"), False),
|
||||||
|
("indice construido", lambda: hay("datos", "saber.jsonl"), False),
|
||||||
|
("el cerebro lo consulta antes de responder", lambda: dentro(
|
||||||
|
"cerebro/ollama.py", "def _saber"), False),
|
||||||
|
("filtra el codigo de terceros", lambda: dentro("saber/indexa.py", "IGNORA"), False),
|
||||||
|
("indexa la documentacion de Linux", lambda: hay("saber", "sistema.py"), False),
|
||||||
|
("y los servidores SSH", lambda: dentro("saber/sistema.py", "def servidores"), False),
|
||||||
|
("el cerebro puede ejecutar como root", lambda: dentro("manos/__init__.py",
|
||||||
|
"necesita_root"), False),
|
||||||
|
("root ligado al candado", lambda: dentro("cara/panel.py", "root.recuerda"), False),
|
||||||
|
]),
|
||||||
|
|
||||||
|
("el pipeline", "aprender del uso", [
|
||||||
|
("diario de lo que oye y hace", lambda: hay("manos", "diario.py"), False),
|
||||||
|
("apunta la VIA de cada frase", lambda: dentro("jarvis.py", 'anota(texto, "ruido")'), False),
|
||||||
|
("y tambien los aciertos", lambda: dentro("jarvis.py", '"catalogo", accion.id'), False),
|
||||||
|
("herramienta para revisarlo", lambda: hay("mejora.py"), False),
|
||||||
|
("propone, NO commitea solo", lambda: dentro("mejora.py", "Esto NO cambia nada"), False),
|
||||||
|
("mide contra el banco fijo", lambda: hay("datos", "banco.json"), False),
|
||||||
|
]),
|
||||||
|
|
||||||
|
("paridad", "usarlo a diario", [
|
||||||
|
("icono naranja", lambda: hay("jarvis-naranja.svg"), False),
|
||||||
|
("icono instalado", lambda: os.path.exists(os.path.expanduser(
|
||||||
|
"~/.local/share/icons/jarvis-nucleo.png")), False),
|
||||||
|
("lanzador propio", lambda: hay("arranca.sh"), False),
|
||||||
|
("ventana propia, no pestaña", lambda: hay("ventana.sh"), False),
|
||||||
|
# Se miran las lineas de CODIGO, no el fichero entero: el comentario que
|
||||||
|
# explica por que ya no se usa xdg-open contiene la palabra, y una
|
||||||
|
# comprobacion ingenua se cree que sigue ahi. Es la tercera vez que la
|
||||||
|
# barra casi miente por buscar texto suelto en vez de codigo.
|
||||||
|
("una sola forma de abrirla", lambda: not sin_comentarios(
|
||||||
|
"arranca.sh", "xdg-open"), False),
|
||||||
|
("la barra de tareas la reconoce", lambda: "StartupWMClass" in open(
|
||||||
|
os.path.expanduser("~/.local/share/applications/jarvis-nucleo.desktop")).read(), False),
|
||||||
|
("entrada en el escritorio", lambda: os.path.exists(os.path.expanduser(
|
||||||
|
"~/.local/share/applications/jarvis-nucleo.desktop")), False),
|
||||||
|
("Newelle congelado y aparte", lambda: corre(
|
||||||
|
["git", "-C", RAIZ, "rev-parse", "jarvis-newelle-final"], 15), False),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ pintarlo
|
||||||
|
|
||||||
|
ANCHO = 34
|
||||||
|
|
||||||
|
|
||||||
|
def color(t, c):
|
||||||
|
return f"\033[{c}m{t}\033[0m" if sys.stdout.isatty() else t
|
||||||
|
|
||||||
|
|
||||||
|
def barra(pct, hecho, total):
|
||||||
|
lleno = round(ANCHO * pct / 100)
|
||||||
|
# verde entero, amarillo a medias, gris sin empezar
|
||||||
|
c = "32" if pct == 100 else ("33" if pct > 0 else "90")
|
||||||
|
return color("█" * lleno, c) + color("·" * (ANCHO - lleno), "90")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("-v", "--detalle", action="store_true")
|
||||||
|
p.add_argument("--rapido", action="store_true",
|
||||||
|
help="sin ejecutar nada, solo mirar ficheros")
|
||||||
|
a = p.parse_args()
|
||||||
|
|
||||||
|
if not os.path.exists(VENV):
|
||||||
|
print(color(" aviso: no hay venv; algunas comprobaciones no se pueden hacer\n", "33"))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(color(" JARVIS · migracion a nucleo propio", "1"))
|
||||||
|
print()
|
||||||
|
|
||||||
|
hechas = totales = 0
|
||||||
|
for i, (nombre, para_que, comprobaciones) in enumerate(HITOS, 1):
|
||||||
|
activas = [c for c in comprobaciones if not (a.rapido and c[2])]
|
||||||
|
pasan = []
|
||||||
|
for texto, prueba, lenta in activas:
|
||||||
|
try:
|
||||||
|
ok = bool(prueba())
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
pasan.append((texto, ok, lenta))
|
||||||
|
n = sum(1 for _, ok, _ in pasan if ok)
|
||||||
|
pct = 100 * n / len(pasan) if pasan else 0
|
||||||
|
hechas += n
|
||||||
|
totales += len(pasan)
|
||||||
|
|
||||||
|
marca = color("✓", "32") if pct == 100 else (" " if pct == 0 else color("·", "33"))
|
||||||
|
print(f" {marca} {i}. {nombre:12s} {barra(pct, n, len(pasan))} "
|
||||||
|
f"{pct:3.0f}% {n}/{len(pasan)} {color(para_que, '90')}")
|
||||||
|
|
||||||
|
if a.detalle:
|
||||||
|
for texto, ok, lenta in pasan:
|
||||||
|
s = color(" ok ", "32") if ok else color(" --", "31")
|
||||||
|
print(f" {s} {texto}" + (color(" (lenta)", "90") if lenta else ""))
|
||||||
|
print()
|
||||||
|
|
||||||
|
print()
|
||||||
|
total_pct = 100 * hechas / totales if totales else 0
|
||||||
|
print(f" {'TOTAL':14s} {barra(total_pct, hechas, totales)} "
|
||||||
|
f"{total_pct:3.0f}% {hechas}/{totales}")
|
||||||
|
print()
|
||||||
|
if a.rapido:
|
||||||
|
print(color(" (rapido: sin las comprobaciones que ejecutan cosas)\n", "90"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
55
nucleo/jarvis-naranja.svg
Normal file
55
nucleo/jarvis-naranja.svg
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||||
|
<!-- El mismo reactor que jarvis-icon.svg, en naranja.
|
||||||
|
La paleta no se inventa: es la del tema "ambar" del HUD (style.css),
|
||||||
|
para que el icono y la pantalla sean el mismo color.
|
||||||
|
|
||||||
|
Cian = JARVIS sobre Newelle, congelado.
|
||||||
|
Naranja = el nucleo independiente. Asi se sabe cual esta abierto. -->
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="core" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#fff0d8"/>
|
||||||
|
<stop offset="45%" stop-color="#ffb347"/>
|
||||||
|
<stop offset="100%" stop-color="#c8862a"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="halo" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#ffa040" stop-opacity=".55"/>
|
||||||
|
<stop offset="70%" stop-color="#c8862a" stop-opacity=".12"/>
|
||||||
|
<stop offset="100%" stop-color="#3a2810" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="ring" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#f5e3c7"/>
|
||||||
|
<stop offset="100%" stop-color="#92765a"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<circle cx="128" cy="128" r="124" fill="#120c04"/>
|
||||||
|
<circle cx="128" cy="128" r="120" fill="url(#halo)"/>
|
||||||
|
|
||||||
|
<!-- carcasa exterior -->
|
||||||
|
<circle cx="128" cy="128" r="106" fill="none" stroke="url(#ring)" stroke-width="9"/>
|
||||||
|
<circle cx="128" cy="128" r="94" fill="none" stroke="#3f2a10" stroke-width="7"/>
|
||||||
|
|
||||||
|
<!-- bobinas -->
|
||||||
|
<g stroke="#efc78f" stroke-width="7" stroke-linecap="round" opacity=".9">
|
||||||
|
<line x1="128" y1="42" x2="128" y2="70"/>
|
||||||
|
<line x1="128" y1="186" x2="128" y2="214"/>
|
||||||
|
<line x1="42" y1="128" x2="70" y2="128"/>
|
||||||
|
<line x1="186" y1="128" x2="214" y2="128"/>
|
||||||
|
<line x1="67" y1="67" x2="87" y2="87"/>
|
||||||
|
<line x1="169" y1="169" x2="189" y2="189"/>
|
||||||
|
<line x1="189" y1="67" x2="169" y2="87"/>
|
||||||
|
<line x1="87" y1="169" x2="67" y2="189"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- anillo interior -->
|
||||||
|
<circle cx="128" cy="128" r="70" fill="none" stroke="#7d541f" stroke-width="10"/>
|
||||||
|
<circle cx="128" cy="128" r="58" fill="none" stroke="#ffa040" stroke-width="4" opacity=".85"/>
|
||||||
|
|
||||||
|
<!-- triangulo del nucleo -->
|
||||||
|
<path d="M128 84 L166 150 L90 150 Z" fill="none" stroke="#ffe0b0" stroke-width="6"
|
||||||
|
stroke-linejoin="round" opacity=".95"/>
|
||||||
|
|
||||||
|
<!-- nucleo -->
|
||||||
|
<circle cx="128" cy="128" r="34" fill="url(#core)"/>
|
||||||
|
<circle cx="128" cy="128" r="16" fill="#ffffff" opacity=".92"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
330
nucleo/jarvis.py
Executable file
330
nucleo/jarvis.py
Executable file
|
|
@ -0,0 +1,330 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""JARVIS. Asistente de voz local, sin framework debajo.
|
||||||
|
|
||||||
|
jarvis.py --di "Buenas noches, señor" prueba la boca
|
||||||
|
jarvis.py --voces las que hay
|
||||||
|
jarvis.py --accion "cuanto espacio queda" prueba las manos, sin voz
|
||||||
|
|
||||||
|
Esto sustituye a la capa que corria dentro de Newelle. Lo que se trajo tal cual
|
||||||
|
—el emparejador, las tareas, el sudo, el HUD— esta en `manos/` y `cara/`; lo que
|
||||||
|
habia que reescribir porque era de Newelle esta en `oido/`, `boca/` y `cerebro/`.
|
||||||
|
|
||||||
|
## Por que fuera del flatpak
|
||||||
|
|
||||||
|
No es dogma, es que el sandbox costaba mas de lo que daba: escondia `/tmp`,
|
||||||
|
obligaba a `LD_LIBRARY_PATH` a mano para las libs de CUDA de whisper, metia un
|
||||||
|
`flatpak-spawn --host` en cada accion, y cambiar una linea de Python pedia
|
||||||
|
recompilar el paquete entero. Aqui un cambio es editar y reiniciar.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, AQUI)
|
||||||
|
|
||||||
|
from boca.voz import Boca, VOCES # noqa: E402
|
||||||
|
from cara.panel import Panel # noqa: E402
|
||||||
|
from cara.servidor import Cara # noqa: E402
|
||||||
|
from cerebro.ollama import Cerebro, MODELO # noqa: E402
|
||||||
|
from manos import Manos # noqa: E402
|
||||||
|
from manos.diario import anota # noqa: E402
|
||||||
|
from manos.acciones import Catalogo # noqa: E402
|
||||||
|
from oido.captura import Captura, fuentes # noqa: E402
|
||||||
|
from oido.despierta import Centinela, NOMBRE # noqa: E402
|
||||||
|
from oido.whisper import Oido, prompt_dominio, es_ruido # noqa: E402
|
||||||
|
|
||||||
|
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
|
||||||
|
PROPIO = os.path.expanduser("~/.config/jarvis/acciones.json")
|
||||||
|
|
||||||
|
|
||||||
|
def catalogo() -> Catalogo:
|
||||||
|
# El del usuario primero: puede redefinir cualquiera del paquete. Mismo
|
||||||
|
# orden que tenia el panel de Newelle.
|
||||||
|
return Catalogo([PROPIO, CATALOGO])
|
||||||
|
|
||||||
|
|
||||||
|
def atiende(texto, manos: Manos, cerebro: Cerebro | None, traza=print):
|
||||||
|
"""La cadena entera para una frase: catalogo primero, cerebro despues.
|
||||||
|
|
||||||
|
El orden no es un detalle de eficiencia, es lo que hace usable esto. Las 150
|
||||||
|
acciones se emparejan sin tocar ningun modelo y contestan al instante; el
|
||||||
|
cerebro solo ve lo que no encaja. Por eso un modelo lento se soporta: la
|
||||||
|
mayoria de las ordenes ni pasan por el.
|
||||||
|
"""
|
||||||
|
t0 = time.time()
|
||||||
|
accion, argumento = manos.busca(texto)
|
||||||
|
if accion is not None:
|
||||||
|
traza(f" catalogo: {accion.id}" + (f" <- {argumento}" if argumento else ""))
|
||||||
|
dicho, _ = manos.haz(accion, argumento)
|
||||||
|
anota(texto, "catalogo", accion.id, argumento, time.time() - t0, dicho)
|
||||||
|
return dicho
|
||||||
|
if cerebro is None:
|
||||||
|
traza(" (sin cerebro conectado)")
|
||||||
|
anota(texto, "sin-cerebro", seg=time.time() - t0)
|
||||||
|
return ""
|
||||||
|
traza(" al cerebro...")
|
||||||
|
dicho = cerebro.responde(texto, al_ejecutar=lambda i, a: traza(
|
||||||
|
f" usa {i}" + (f"({a[:52]})" if a else "")))
|
||||||
|
# La via mas valiosa del diario: lo que el catalogo NO cubre es justo la
|
||||||
|
# lista de acciones que faltan por escribir.
|
||||||
|
anota(texto, "cerebro", seg=time.time() - t0, dicho=dicho)
|
||||||
|
return dicho
|
||||||
|
|
||||||
|
|
||||||
|
def manda_accion(frase: str, boca: Boca | None = None, cerebro=None) -> int:
|
||||||
|
manos = Manos(catalogo())
|
||||||
|
dicho = atiende(frase, manos, cerebro, traza=lambda t: print(t))
|
||||||
|
if not dicho:
|
||||||
|
print(f' sin accion para "{frase}"')
|
||||||
|
return 1
|
||||||
|
print(f" {dicho}")
|
||||||
|
if boca:
|
||||||
|
boca.di(dicho)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def escucha(boca: Boca, modelo="small", fuente=None, callado=False,
|
||||||
|
cerebro=None, panel=None, centinela=None) -> int:
|
||||||
|
"""El bucle de verdad: oir, entender, hacer, contestar.
|
||||||
|
|
||||||
|
Es la version desnuda de lo que hacia el panel, sin GTK y sin HUD todavia.
|
||||||
|
Sirve para comprobar que la cadena entera funciona antes de ponerle cara.
|
||||||
|
"""
|
||||||
|
manos = Manos(catalogo())
|
||||||
|
# Con el prompt del NOMBRE, no con el del catalogo entero: sin el, Whisper
|
||||||
|
# nunca escribe "JARVIS" y el centinela no despierta jamas (ver whisper.py).
|
||||||
|
oreja = Oido(modelo=modelo)
|
||||||
|
if not oreja.disponible():
|
||||||
|
print(f" no encuentro whisper o el modelo {modelo}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f" arrancando whisper ({modelo}, beam {oreja.beam}, sns, nombre)...")
|
||||||
|
if not oreja.arranca():
|
||||||
|
print(" whisper no arranco")
|
||||||
|
return 1
|
||||||
|
aviso = (f"esperando a que le llamen: «{NOMBRE.upper()}»"
|
||||||
|
if centinela and centinela.activo
|
||||||
|
else "atendiendo todo lo que oiga (--palabra para exigir el nombre)")
|
||||||
|
print(f" escuchando por {fuente or 'la entrada por defecto'}, {aviso}.")
|
||||||
|
print(" Ctrl+C para parar.\n")
|
||||||
|
if panel:
|
||||||
|
panel.estado("dormido" if centinela and centinela.activo else "escuchando")
|
||||||
|
|
||||||
|
# La captura consulta a la boca en cada trozo: mientras JARVIS habla, ese
|
||||||
|
# audio no se mira siquiera. Es lo que evita que se oiga a si mismo.
|
||||||
|
oreja_mic = Captura(boca=boca, fuente=fuente)
|
||||||
|
try:
|
||||||
|
for wav in oreja_mic.frases():
|
||||||
|
try:
|
||||||
|
texto = oreja.transcribe(wav)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(wav)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if es_ruido(texto):
|
||||||
|
if texto:
|
||||||
|
print(f" · ruido descartado: {texto!r}")
|
||||||
|
anota(texto, "ruido")
|
||||||
|
if panel:
|
||||||
|
panel.dicho(texto, atendido=False, motivo="ruido")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ¿Hay una pregunta en el aire? Entonces esto es la respuesta.
|
||||||
|
#
|
||||||
|
# Va antes que TODO —antes de la palabra de activacion y antes del
|
||||||
|
# emparejador— porque si JARVIS acaba de preguntarte que fichero
|
||||||
|
# buscas, obligarte a decir "JARVIS, informes" seria absurdo: ya
|
||||||
|
# esta hablando contigo.
|
||||||
|
if panel and panel.esperando():
|
||||||
|
print(f" » {texto} (respondiendo a lo preguntado)")
|
||||||
|
anota(texto, "respuesta")
|
||||||
|
panel.dicho(texto)
|
||||||
|
if panel.responde(texto):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# La palabra de activacion, antes de nada: sin esto obedece a
|
||||||
|
# cualquiera que pase, y a la tele.
|
||||||
|
if centinela:
|
||||||
|
atender, texto_orden, recien = centinela.filtra(texto)
|
||||||
|
if not atender:
|
||||||
|
if recien:
|
||||||
|
print(f" » {texto} (le han llamado)")
|
||||||
|
if panel:
|
||||||
|
panel.dicho(texto)
|
||||||
|
panel.estado("escuchando") # ya despierto
|
||||||
|
boca.di_si_libre("¿Señor?")
|
||||||
|
else:
|
||||||
|
print(f" · {texto} (no le han llamado)")
|
||||||
|
anota(texto, "sin-nombre")
|
||||||
|
# Se enseña IGUAL, marcado: que se vea que te ha oido
|
||||||
|
# bien y que lo unico que falta es llamarle.
|
||||||
|
if panel:
|
||||||
|
panel.dicho(texto, atendido=False,
|
||||||
|
motivo=f"diga «{NOMBRE.upper()}» primero")
|
||||||
|
continue
|
||||||
|
texto = texto_orden
|
||||||
|
|
||||||
|
# El candado tapa el panel, pero lo que hay que impedir es que
|
||||||
|
# OBEDEZCA: si no, quien pase por delante da ordenes hablando, que
|
||||||
|
# es justo lo que el candado existe para evitar.
|
||||||
|
if panel and getattr(panel, "candado", False) and not panel.desbloqueado:
|
||||||
|
print(f" · {texto} (bloqueado)")
|
||||||
|
anota(texto, "bloqueado")
|
||||||
|
panel.dicho(texto, atendido=False, motivo="hace falta la contraseña")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" » {texto}")
|
||||||
|
if panel:
|
||||||
|
panel.dicho(texto)
|
||||||
|
panel.estado("pensando")
|
||||||
|
dicho = atiende(texto, manos, cerebro)
|
||||||
|
if not dicho:
|
||||||
|
if panel:
|
||||||
|
panel.estado("escuchando")
|
||||||
|
continue
|
||||||
|
print(f" {dicho}")
|
||||||
|
if panel:
|
||||||
|
panel.respuesta(dicho)
|
||||||
|
panel.estado("hablando")
|
||||||
|
if not callado:
|
||||||
|
boca.di(dicho)
|
||||||
|
if panel:
|
||||||
|
# Sigue despierto un rato tras atender, para poder encadenar sin
|
||||||
|
# repetir el nombre: el rotulo tiene que decir eso y no "dormido".
|
||||||
|
panel.estado("escuchando" if not centinela or centinela.despierto
|
||||||
|
else "dormido")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n parado")
|
||||||
|
finally:
|
||||||
|
oreja_mic.para()
|
||||||
|
oreja.para()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def panel(boca: Boca, cerebro=None, escuchando=False, modelo="small",
|
||||||
|
fuente=None, abre=True, centinela=None, sin_candado=False) -> int:
|
||||||
|
"""Levanta el panel y, si se pide, ademas escucha el microfono."""
|
||||||
|
manos = Manos(catalogo())
|
||||||
|
cara = Cara()
|
||||||
|
p = Panel(cara, boca, manos, cerebro, candado=not sin_candado,
|
||||||
|
centinela=centinela)
|
||||||
|
cara.al_pulsar = p.pulsado
|
||||||
|
url = cara.arranca()
|
||||||
|
if not url:
|
||||||
|
return 1
|
||||||
|
p.arranca()
|
||||||
|
print(f" panel en {url}")
|
||||||
|
|
||||||
|
if abre:
|
||||||
|
# Una sola forma de abrir la ventana, en ventana.sh, para que sea igual
|
||||||
|
# se llegue por donde se llegue. Tener aqui su propia lista de
|
||||||
|
# navegadores fue lo que dejo que arranca.sh abriera una pestaña de
|
||||||
|
# Firefox cuando JARVIS ya estaba en marcha.
|
||||||
|
abridor = os.path.join(AQUI, "ventana.sh")
|
||||||
|
if os.access(abridor, os.X_OK):
|
||||||
|
subprocess.Popen([abridor, url], stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||||
|
|
||||||
|
if not escuchando:
|
||||||
|
print(" sin microfono (--escucha para que ademas oiga). Ctrl+C para parar.")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n parado")
|
||||||
|
p.para()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return escucha(boca, modelo, fuente, False, cerebro, p, centinela)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = argparse.ArgumentParser(description="JARVIS")
|
||||||
|
p.add_argument("--di", metavar="TEXTO", help="decir algo en voz alta")
|
||||||
|
p.add_argument("--voz", default=None, help="con que voz")
|
||||||
|
p.add_argument("--voces", action="store_true", help="listar las voces")
|
||||||
|
p.add_argument("--accion", metavar="FRASE", help="ejecutar una orden del catalogo")
|
||||||
|
p.add_argument("--callado", action="store_true", help="no hablar el resultado")
|
||||||
|
p.add_argument("--escucha", action="store_true", help="escuchar el microfono")
|
||||||
|
p.add_argument("--modelo", default="small", help="modelo de whisper")
|
||||||
|
p.add_argument("--fuente", default=None, help="entrada de audio (ver --fuentes)")
|
||||||
|
p.add_argument("--fuentes", action="store_true", help="listar las entradas")
|
||||||
|
p.add_argument("--cerebro", default=MODELO,
|
||||||
|
help="modelo de ollama; 'no' para trabajar solo con el catalogo")
|
||||||
|
p.add_argument("--ram", action="store_true",
|
||||||
|
help="el modelo entero en RAM, sin usar la grafica")
|
||||||
|
p.add_argument("--panel", action="store_true", help="abrir el panel")
|
||||||
|
p.add_argument("--sin-abrir", action="store_true",
|
||||||
|
help="levantar el panel pero no abrir el navegador")
|
||||||
|
p.add_argument("--palabra", action="store_true",
|
||||||
|
help=f"exigir que le llamen «{NOMBRE}» antes de cada orden")
|
||||||
|
p.add_argument("--sin-candado", action="store_true",
|
||||||
|
help="no pedir la contraseña al abrir (solo para probar)")
|
||||||
|
a = p.parse_args()
|
||||||
|
|
||||||
|
boca = Boca(a.voz or "davefx")
|
||||||
|
cerebro = None
|
||||||
|
if a.cerebro and a.cerebro != "no":
|
||||||
|
cerebro = Cerebro(catalogo(), modelo=a.cerebro, en_ram=a.ram,
|
||||||
|
manos=Manos(catalogo()))
|
||||||
|
|
||||||
|
if a.voces:
|
||||||
|
hay = boca.disponibles()
|
||||||
|
for nombre in VOCES:
|
||||||
|
marca = "·" if nombre in hay else "×"
|
||||||
|
actual = " <- puesta" if nombre == boca.voz else ""
|
||||||
|
print(f" {marca} {nombre}{actual}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if a.di:
|
||||||
|
if a.voz and not boca.cambia(a.voz):
|
||||||
|
print(f" no existe la voz {a.voz}")
|
||||||
|
return 1
|
||||||
|
print(f" [{boca.voz}] {a.di}")
|
||||||
|
if not boca.di(a.di):
|
||||||
|
print(" no sono. mira que el modelo de la voz este en su sitio")
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if a.fuentes:
|
||||||
|
for f in fuentes():
|
||||||
|
print(f" {f}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# La palabra de activacion va APAGADA por defecto.
|
||||||
|
#
|
||||||
|
# Decirla en cada frase es un peaje, y medido no evitaba nada: sobre 412
|
||||||
|
# frases reales oidas —con todo el eco y el ruido ambiente dentro— solo 3
|
||||||
|
# habrian disparado una accion, y las tres eran ordenes de verdad. Cero
|
||||||
|
# falsos positivos del ruido, porque el emparejador ya es conservador.
|
||||||
|
#
|
||||||
|
# El candado cubre lo otro, que es quien puede darle ordenes.
|
||||||
|
#
|
||||||
|
# Se queda disponible con --palabra y en el boton del panel, por si algun
|
||||||
|
# dia hay una tele cerca y el ruido empieza a colarse.
|
||||||
|
centinela = Centinela(activo=a.palabra)
|
||||||
|
|
||||||
|
if a.panel:
|
||||||
|
return panel(boca, cerebro, a.escucha, a.modelo, a.fuente,
|
||||||
|
abre=not a.sin_abrir, centinela=centinela,
|
||||||
|
sin_candado=a.sin_candado)
|
||||||
|
|
||||||
|
if a.escucha:
|
||||||
|
return escucha(boca, a.modelo, a.fuente, a.callado, cerebro,
|
||||||
|
centinela=centinela)
|
||||||
|
|
||||||
|
if a.accion:
|
||||||
|
return manda_accion(a.accion, None if a.callado else boca, cerebro)
|
||||||
|
|
||||||
|
p.print_help()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
114
nucleo/manos/__init__.py
Normal file
114
nucleo/manos/__init__.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""Lo que JARVIS puede HACER, y el unico sitio por donde pasa.
|
||||||
|
|
||||||
|
`Manos` es la puerta: el catalogo entra por aqui, el cerebro entra por aqui y
|
||||||
|
el panel entrara por aqui. Tener un solo punto importa porque es donde se
|
||||||
|
decide que se ejecuta y que no, y esa decision no puede estar repartida.
|
||||||
|
|
||||||
|
Las cuatro piezas de debajo vienen tal cual de la version sobre Newelle
|
||||||
|
—`acciones.py`, `tareas.py`, `sudo_root.py`, `seleccion.py`— y se comprobo al
|
||||||
|
copiarlas que 374 frases dan exactamente el mismo resultado en los dos sitios.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from .acciones import Catalogo, ejecuta, formatea
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
|
||||||
|
PROPIO = os.path.expanduser("~/.config/jarvis/acciones.json")
|
||||||
|
|
||||||
|
# Lo que no se ejecuta por mucho que lo pida el modelo. No es una lista de
|
||||||
|
# seguridad completa —eso es imposible— sino un tope contra el accidente: el
|
||||||
|
# modelo es servicial y a veces se inventa una limpieza que nadie pidio.
|
||||||
|
PROHIBIDO = (
|
||||||
|
"rm -rf /", "mkfs", "dd if=", ":(){", "shutdown", "reboot", "halt",
|
||||||
|
"> /dev/sd", "chmod -R 777 /", "chown -R", "userdel", "passwd",
|
||||||
|
)
|
||||||
|
|
||||||
|
TOPE_SALIDA = 4000
|
||||||
|
|
||||||
|
|
||||||
|
class Manos:
|
||||||
|
def __init__(self, catalogo=None, al_ejecutar=None):
|
||||||
|
self.catalogo = catalogo or Catalogo([PROPIO, CATALOGO])
|
||||||
|
self.al_ejecutar = al_ejecutar
|
||||||
|
# Root para la sesion: cuando el usuario desbloquea el candado con su
|
||||||
|
# contraseña, esa misma queda aqui y el cerebro puede hacer sudo hasta
|
||||||
|
# que caduque. Sin desbloquear, no hay root: el candado es la puerta.
|
||||||
|
from manos.sudo_root import GestorRoot
|
||||||
|
self.root = GestorRoot([])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- catalogo
|
||||||
|
|
||||||
|
def busca(self, texto: str):
|
||||||
|
return self.catalogo.busca(texto)
|
||||||
|
|
||||||
|
def haz(self, accion, argumento="") -> tuple:
|
||||||
|
"""Ejecuta una accion ya emparejada. Devuelve (lo que se dice, crudo)."""
|
||||||
|
dicho, salida = ejecuta(accion, [], argumento)
|
||||||
|
if salida:
|
||||||
|
dicho = formatea(accion.respuesta, salida, argumento)
|
||||||
|
return dicho, salida
|
||||||
|
|
||||||
|
def por_orden(self, orden: str, al_ejecutar=None) -> str:
|
||||||
|
"""Para el cerebro: empareja contra el catalogo y ejecuta.
|
||||||
|
|
||||||
|
Devuelve texto para el modelo, no para el usuario: si no encaja, se le
|
||||||
|
dice claramente para que pruebe con el shell en vez de insistir.
|
||||||
|
"""
|
||||||
|
if not orden.strip():
|
||||||
|
return "no me has dicho ninguna orden"
|
||||||
|
# El modelo a veces cuela un comando aqui de todas formas. Se detecta y
|
||||||
|
# se le devuelve el motivo, que es mas util que un "no encontrado".
|
||||||
|
if any(c in orden for c in "|><;&$") or orden.split()[0] in ("df", "ls", "ps", "cat"):
|
||||||
|
return ("eso es un comando de shell, no una orden del catalogo. "
|
||||||
|
"Usa la herramienta comando_de_shell.")
|
||||||
|
accion, argumento = self.catalogo.busca(orden)
|
||||||
|
if accion is None:
|
||||||
|
return (f"'{orden}' no esta en el catalogo. Si hace falta, usa "
|
||||||
|
"comando_de_shell.")
|
||||||
|
if al_ejecutar or self.al_ejecutar:
|
||||||
|
(al_ejecutar or self.al_ejecutar)(accion.id, argumento)
|
||||||
|
dicho, salida = self.haz(accion, argumento)
|
||||||
|
return (salida or dicho)[:TOPE_SALIDA]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- shell
|
||||||
|
|
||||||
|
def por_shell(self, comando: str, al_ejecutar=None) -> str:
|
||||||
|
if not comando.strip():
|
||||||
|
return "no me has dicho ningun comando"
|
||||||
|
bajo = comando.lower()
|
||||||
|
for veneno in PROHIBIDO:
|
||||||
|
if veneno in bajo:
|
||||||
|
return (f"no ejecuto eso: contiene '{veneno}'. Si de verdad hace "
|
||||||
|
"falta, que lo pida el usuario expresamente.")
|
||||||
|
if al_ejecutar or self.al_ejecutar:
|
||||||
|
(al_ejecutar or self.al_ejecutar)("shell", comando)
|
||||||
|
|
||||||
|
from manos.sudo_root import necesita_root
|
||||||
|
# Los comandos con sudo van por GestorRoot, que da la contraseña de la
|
||||||
|
# sesion por stdin. Sin ella, `bash -c "sudo ..."` se colgaria pidiendo
|
||||||
|
# una contraseña que nadie teclea. Y si el candado no se ha abierto, no
|
||||||
|
# hay contraseña: el root queda cerrado hasta que el usuario desbloquee.
|
||||||
|
if necesita_root(comando):
|
||||||
|
if not self.root.sesion_activa():
|
||||||
|
return ("eso necesita root y el candado no esta abierto. "
|
||||||
|
"Desbloquee con su contraseña y lo repito.")
|
||||||
|
try:
|
||||||
|
r = self.root.ejecuta(comando, timeout=60)
|
||||||
|
except Exception as e:
|
||||||
|
return f"no se pudo ejecutar como root: {e}"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["bash", "-c", comando], capture_output=True,
|
||||||
|
text=True, timeout=60)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "el comando tardo mas de un minuto y lo he cortado"
|
||||||
|
except OSError as e:
|
||||||
|
return f"no se pudo ejecutar: {e}"
|
||||||
|
|
||||||
|
salida = (r.stdout or r.stderr or "").strip()
|
||||||
|
if not salida:
|
||||||
|
return "hecho, sin salida" if r.returncode == 0 else f"fallo (codigo {r.returncode})"
|
||||||
|
return salida[:TOPE_SALIDA]
|
||||||
310
nucleo/manos/acciones.py
Normal file
310
nucleo/manos/acciones.py
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
"""Acciones rapidas: caminos predefinidos que se saltan el modelo.
|
||||||
|
|
||||||
|
Por que existe esto. Preguntar "cuanto espacio queda" y esperar a que un modelo
|
||||||
|
de 4B razone, decida llamar a una herramienta, la ejecute y redacte la
|
||||||
|
respuesta cuesta segundos. Pero esa pregunta no necesita pensarse: es un `df`.
|
||||||
|
El catalogo empareja la frase con un comando y contesta con una plantilla, y
|
||||||
|
eso baja de segundos a decimas.
|
||||||
|
|
||||||
|
No es entrenar un modelo: es una tabla. Y esa es la gracia, porque una tabla es
|
||||||
|
predecible, se lee, se corrige y no alucina. Lo que no encaje en la tabla sigue
|
||||||
|
yendo al modelo como siempre.
|
||||||
|
|
||||||
|
Formato del catalogo (JSON):
|
||||||
|
|
||||||
|
{
|
||||||
|
"acciones": [
|
||||||
|
{
|
||||||
|
"id": "disco_libre",
|
||||||
|
"frases": ["cuanto espacio queda", "espacio en disco"],
|
||||||
|
"acuse": "Un momento, lo miro.",
|
||||||
|
"comando": "df -h / | tail -1",
|
||||||
|
"host": true,
|
||||||
|
"respuesta": "Quedan {campo4} libres de {campo2}.",
|
||||||
|
"confirmar": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
En `respuesta` se puede usar:
|
||||||
|
{salida} la salida completa, recortada
|
||||||
|
{primera} la primera linea
|
||||||
|
{ultima} la ultima linea no vacia
|
||||||
|
{campoN} la columna N de la ultima linea (1 en adelante)
|
||||||
|
{lineas} cuantas lineas ha devuelto
|
||||||
|
"""
|
||||||
|
import difflib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import urllib.parse
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
UMBRAL_PARECIDO = 0.86 # por debajo empieza a confundir ordenes
|
||||||
|
TIEMPO_LIMITE = 20 # ningun atajo deberia tardar mas
|
||||||
|
SALIDA_MAXIMA = 600 # lo que se guarda para enseñar y para la plantilla
|
||||||
|
|
||||||
|
|
||||||
|
def normaliza(texto: str) -> str:
|
||||||
|
"""Minusculas, sin tildes, sin puntuacion y con los espacios apretados.
|
||||||
|
|
||||||
|
Whisper devuelve "¿Cuánto espacio queda?" y el catalogo dice "cuanto
|
||||||
|
espacio queda". Sin normalizar no casaria ni una.
|
||||||
|
|
||||||
|
Lo de apretar los espacios no es cosmetico: al sustituir la puntuacion por
|
||||||
|
un espacio, "cuanto, espacio queda" quedaba con dos seguidos y dejaba de
|
||||||
|
coincidir con la frase del catalogo. El sintoma era un asistente que a
|
||||||
|
veces no reacciona a una orden que ha entendido bien, segun donde el
|
||||||
|
reconocedor decida poner una coma.
|
||||||
|
"""
|
||||||
|
texto = unicodedata.normalize("NFD", texto.lower())
|
||||||
|
texto = "".join(c for c in texto if unicodedata.category(c) != "Mn")
|
||||||
|
return " ".join(re.sub(r"[^\w\s]", " ", texto).split())
|
||||||
|
|
||||||
|
|
||||||
|
# El envoltorio de cortesia con el que habla cualquiera. Nadie dice "pon la
|
||||||
|
# pelicula" a secas: dice "necesito que pongas la pelicula" o "puedes ponerme".
|
||||||
|
# Con eso delante, ninguna frase del catalogo encaja como subcadena y la orden
|
||||||
|
# se pierde entera.
|
||||||
|
#
|
||||||
|
# Medido sobre las 462 frases que el panel anoto por no entender: de las 18 que
|
||||||
|
# eran ordenes de verdad, la mayoria fallaba solo por esto. Cinco de ellas eran
|
||||||
|
# el mismo intento de poner una pelicula, dicho de cinco maneras.
|
||||||
|
CORTESIA = re.compile(
|
||||||
|
r"^(por favor,?\s+)?"
|
||||||
|
r"(necesito que( me)?|quiero que( me)?|me gustaria que( me)?|"
|
||||||
|
r"puedes|podrias|me puedes|me podrias|te importa|hazme el favor de|"
|
||||||
|
r"a ver si|va(le|)?,? |anda,? |porfa,? |quiero|necesito)\s+",
|
||||||
|
re.I)
|
||||||
|
|
||||||
|
|
||||||
|
class Accion:
|
||||||
|
def __init__(self, datos: dict):
|
||||||
|
self.id = datos.get("id", "")
|
||||||
|
self.frases = [normaliza(f) for f in datos.get("frases", [])]
|
||||||
|
self.acuse = datos.get("acuse", "Voy.")
|
||||||
|
self.comando = datos.get("comando", "")
|
||||||
|
self.host = bool(datos.get("host", True))
|
||||||
|
self.respuesta = datos.get("respuesta", "{salida}")
|
||||||
|
self.confirmar = bool(datos.get("confirmar", False))
|
||||||
|
self.captura = bool(datos.get("captura", False))
|
||||||
|
self.grupo = datos.get("grupo", "")
|
||||||
|
|
||||||
|
def coincide(self, texto: str):
|
||||||
|
"""(longitud de la frase que encaja, lo que venia detras).
|
||||||
|
|
||||||
|
Se devuelve la longitud y no un si/no para poder quedarse con la
|
||||||
|
coincidencia mas especifica: "abre la carpeta de descargas" debe ganar
|
||||||
|
a "abre la carpeta" cuando las dos encajan.
|
||||||
|
|
||||||
|
El resto es el argumento: en "busca gatos en youtube" la frase es
|
||||||
|
"busca" ... y lo demas es lo que hay que buscar. Solo se recoge si la
|
||||||
|
accion lo pide, para que una frase suelta no arrastre basura.
|
||||||
|
"""
|
||||||
|
mejor, resto = 0, ""
|
||||||
|
for f in self.frases:
|
||||||
|
if not f or f not in texto:
|
||||||
|
continue
|
||||||
|
if len(f) > mejor:
|
||||||
|
mejor = len(f)
|
||||||
|
resto = texto.split(f, 1)[1].strip() if self.captura else ""
|
||||||
|
return mejor, resto
|
||||||
|
|
||||||
|
|
||||||
|
def parecido(a: str, b: str) -> float:
|
||||||
|
"""Cuanto se parecen dos cadenas, de 0 a 1, por letra y por sonido.
|
||||||
|
|
||||||
|
Para los casi-aciertos del reconocedor: "yo tuve" por "yo tube", "casis"
|
||||||
|
por "oasis". Se usa difflib y no una distancia de edicion propia porque
|
||||||
|
viene en la biblioteca estandar y aqui no hay que hilar tan fino.
|
||||||
|
|
||||||
|
Se queda con el MAYOR de los dos parecidos, el de la letra y el del sonido.
|
||||||
|
Muchos fallos del reconocedor no son de significado sino de ortografia
|
||||||
|
—"Abre van Kamp" por "abre bandcamp", "poops" por "pubs"— y por escrito
|
||||||
|
quedan lejos aunque suenen igual. Medido contra el banco: sube el acierto
|
||||||
|
de 79,3 a 84,7 % sin que ninguna de las 50 frases de charla dispare nada.
|
||||||
|
|
||||||
|
Que sea el maximo y no la media importa: la media hundiria los aciertos
|
||||||
|
normales, donde la letra ya casaba bien y el sonido no aporta.
|
||||||
|
"""
|
||||||
|
literal = difflib.SequenceMatcher(None, a, b).ratio()
|
||||||
|
if literal >= 0.98:
|
||||||
|
return literal # ya casa: no hace falta el sonido
|
||||||
|
from .fonetica import fonetica
|
||||||
|
return max(literal,
|
||||||
|
difflib.SequenceMatcher(None, fonetica(a), fonetica(b)).ratio())
|
||||||
|
|
||||||
|
|
||||||
|
class Catalogo:
|
||||||
|
def __init__(self, rutas):
|
||||||
|
self.acciones = []
|
||||||
|
self.errores = []
|
||||||
|
for ruta in rutas:
|
||||||
|
if ruta and os.path.exists(ruta):
|
||||||
|
self._carga(ruta)
|
||||||
|
|
||||||
|
def _carga(self, ruta):
|
||||||
|
try:
|
||||||
|
with open(ruta) as f:
|
||||||
|
datos = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
self.errores.append(f"{os.path.basename(ruta)}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
vistos = {a.id for a in self.acciones}
|
||||||
|
for entrada in datos.get("acciones", []):
|
||||||
|
accion = Accion(entrada)
|
||||||
|
if not accion.id or not accion.frases:
|
||||||
|
self.errores.append(f"{os.path.basename(ruta)}: accion sin id o sin frases")
|
||||||
|
continue
|
||||||
|
# el primero que se carga manda: asi el catalogo del usuario puede
|
||||||
|
# redefinir uno del paquete sin tener que borrarlo
|
||||||
|
if accion.id not in vistos:
|
||||||
|
self.acciones.append(accion)
|
||||||
|
vistos.add(accion.id)
|
||||||
|
|
||||||
|
def busca(self, texto: str):
|
||||||
|
"""(accion mas especifica que encaje, argumento) o (None, "").
|
||||||
|
|
||||||
|
Si no encaja nada, se reintenta quitando la cortesia de delante. Se
|
||||||
|
hace como SEGUNDA pasada y no normalizando desde el principio a
|
||||||
|
proposito: asi lo que ya funcionaba sigue funcionando igual, y esto
|
||||||
|
solo añade aciertos donde antes no habia ninguno.
|
||||||
|
"""
|
||||||
|
encontrada, argumento = self._busca_directa(normaliza(texto))
|
||||||
|
if encontrada is not None:
|
||||||
|
return encontrada, argumento
|
||||||
|
sin_cortesia = CORTESIA.sub("", normaliza(texto)).strip()
|
||||||
|
if sin_cortesia and sin_cortesia != normaliza(texto):
|
||||||
|
return self._busca_directa(sin_cortesia)
|
||||||
|
return None, ""
|
||||||
|
|
||||||
|
def _busca_directa(self, texto: str):
|
||||||
|
mejor, puntos, argumento = None, 0, ""
|
||||||
|
for accion in self.acciones:
|
||||||
|
p, resto = accion.coincide(texto)
|
||||||
|
if p > puntos:
|
||||||
|
mejor, puntos, argumento = accion, p, resto
|
||||||
|
|
||||||
|
# Nada exacto: se prueba por parecido. El reconocedor se come letras
|
||||||
|
# ("yo tuve" por "yo tube") y sin esto la orden se pierde entera. El
|
||||||
|
# umbral es alto a proposito: mas abajo empieza a confundir ordenes
|
||||||
|
# distintas entre si, que es peor que no reaccionar.
|
||||||
|
if mejor is None:
|
||||||
|
self._resto_parecido = ""
|
||||||
|
mejor, puntos = self._por_parecido(texto)
|
||||||
|
if mejor is not None and mejor.captura:
|
||||||
|
argumento = self._resto_parecido
|
||||||
|
|
||||||
|
# una accion que captura y no recibe nada no sirve: "busca" a secas
|
||||||
|
if mejor is not None and mejor.captura and not argumento:
|
||||||
|
return None, ""
|
||||||
|
return mejor, argumento
|
||||||
|
|
||||||
|
def _por_parecido(self, texto: str, umbral: float = UMBRAL_PARECIDO):
|
||||||
|
"""La accion cuya frase mas se parezca, si pasa del umbral."""
|
||||||
|
palabras = texto.split()
|
||||||
|
mejor, mejor_p = None, umbral
|
||||||
|
for accion in self.acciones:
|
||||||
|
if accion.captura:
|
||||||
|
# Con argumento no vale comparar la frase entera: "busca en
|
||||||
|
# youtube conciertos de techno" no se parece a "busca en
|
||||||
|
# youtube". Se compara solo el arranque y lo demas es el
|
||||||
|
# argumento. Se exigen tres palabras para que un disparador
|
||||||
|
# corto no se coma media conversacion.
|
||||||
|
for frase in accion.frases:
|
||||||
|
trozos = frase.split()
|
||||||
|
# Dos palabras, no tres. Con tres, disparadores cortos como
|
||||||
|
# "receta de" no entraban NUNCA en esta fase, y "receta de
|
||||||
|
# documentos" mal oido se perdia entero. El riesgo de bajar
|
||||||
|
# a dos lo cubre que la comparacion va anclada al principio:
|
||||||
|
# solo encaja si el arranque suena a la frase.
|
||||||
|
if len(trozos) < 2 or len(palabras) <= len(trozos):
|
||||||
|
continue
|
||||||
|
arranque = " ".join(palabras[:len(trozos)])
|
||||||
|
p = parecido(arranque, frase)
|
||||||
|
if p > mejor_p:
|
||||||
|
mejor, mejor_p = accion, p
|
||||||
|
self._resto_parecido = " ".join(palabras[len(trozos):])
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Frase ENTERA contra frase entera, no por ventana deslizante. La
|
||||||
|
# ventana encontraba un sub-trozo que puntuaba alto aunque la orden
|
||||||
|
# fuera otra: "que carne el tiempo" (mal oido de "que kernel tengo")
|
||||||
|
# colaba en la accion del tiempo con 0.90, y se ejecutaba la accion
|
||||||
|
# equivocada —lo peor que puede pasar en algo que lanza comandos—.
|
||||||
|
# Una orden se dice completa; si va incrustada en una frase larga,
|
||||||
|
# el emparejador exacto (por subcadena) ya la coge antes de llegar
|
||||||
|
# aqui. Comparar el todo con el todo deja fuera esos falsos
|
||||||
|
# positivos sin perder los casi-aciertos reales.
|
||||||
|
propio = " ".join(palabras)
|
||||||
|
for frase in accion.frases:
|
||||||
|
if not frase:
|
||||||
|
continue
|
||||||
|
p = parecido(propio, frase)
|
||||||
|
if p > mejor_p:
|
||||||
|
mejor, mejor_p = accion, p
|
||||||
|
return mejor, mejor_p
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.acciones)
|
||||||
|
|
||||||
|
|
||||||
|
def ejecuta(accion: Accion, prefijo_host, argumento: str = "") -> tuple:
|
||||||
|
"""Corre el comando y devuelve (texto para decir, salida cruda).
|
||||||
|
|
||||||
|
prefijo_host es lo que hay que anteponer para salir del sandbox; se pasa
|
||||||
|
como argumento en vez de importarlo para poder probar esto sin flatpak.
|
||||||
|
"""
|
||||||
|
if not accion.comando:
|
||||||
|
return accion.acuse, ""
|
||||||
|
|
||||||
|
# El argumento viene de un microfono, o sea de fuera: va entrecomillado con
|
||||||
|
# shlex antes de tocar un shell. Sin esto, decir "borra punto y coma rm"
|
||||||
|
# seria una orden ejecutable.
|
||||||
|
comando = accion.comando.replace("{argumento}", shlex.quote(argumento))
|
||||||
|
comando = comando.replace("{argumento_url}",
|
||||||
|
shlex.quote(urllib.parse.quote_plus(argumento)))
|
||||||
|
|
||||||
|
orden = ["bash", "-lc", comando]
|
||||||
|
if accion.host:
|
||||||
|
orden = list(prefijo_host) + orden
|
||||||
|
|
||||||
|
try:
|
||||||
|
# errors="replace": un grep o un cat que toque un binario devuelve
|
||||||
|
# bytes que no son utf-8, y sin esto la orden entera reventaria con
|
||||||
|
# UnicodeDecodeError. Mejor un caracter raro que un fallo.
|
||||||
|
r = subprocess.run(orden, capture_output=True, text=True,
|
||||||
|
errors="replace", timeout=TIEMPO_LIMITE)
|
||||||
|
salida = (r.stdout or r.stderr or "").strip()
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "La orden ha tardado demasiado y la he cancelado.", ""
|
||||||
|
except OSError as e:
|
||||||
|
return f"No he podido ejecutarlo: {e}", ""
|
||||||
|
|
||||||
|
return formatea(accion.respuesta, salida, argumento), salida[:SALIDA_MAXIMA]
|
||||||
|
|
||||||
|
|
||||||
|
def formatea(plantilla: str, salida: str, argumento: str = "") -> str:
|
||||||
|
lineas = [l for l in salida.splitlines() if l.strip()]
|
||||||
|
ultima = lineas[-1] if lineas else ""
|
||||||
|
campos = ultima.split()
|
||||||
|
|
||||||
|
valores = {
|
||||||
|
"argumento": argumento,
|
||||||
|
"salida": salida[:SALIDA_MAXIMA] if salida else "sin resultado",
|
||||||
|
"primera": lineas[0] if lineas else "",
|
||||||
|
"ultima": ultima,
|
||||||
|
"lineas": len(lineas),
|
||||||
|
}
|
||||||
|
for i, campo in enumerate(campos, start=1):
|
||||||
|
valores[f"campo{i}"] = campo
|
||||||
|
|
||||||
|
try:
|
||||||
|
return plantilla.format(**valores)
|
||||||
|
except (KeyError, IndexError, ValueError):
|
||||||
|
# plantilla que pide un campo que no existe: mejor decir la salida
|
||||||
|
# cruda que soltar una excepcion en mitad de una conversacion
|
||||||
|
return valores["salida"]
|
||||||
123
nucleo/manos/diario.py
Normal file
123
nucleo/manos/diario.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""Lo que JARVIS oye y que hace con ello. El combustible de todo lo demas.
|
||||||
|
|
||||||
|
Sin esto no hay forma de mejorar nada: no se puede arreglar lo que no se mide,
|
||||||
|
y hasta ahora el nucleo no apuntaba absolutamente nada.
|
||||||
|
|
||||||
|
## Por que este y no el de antes
|
||||||
|
|
||||||
|
El diario de la epoca de Newelle guardaba `{"texto": "..."}` y nada mas. Con eso
|
||||||
|
no se puede hacer casi nada:
|
||||||
|
|
||||||
|
- no se sabe **que paso** con la frase, asi que no se distingue una orden que
|
||||||
|
fallo de una que funciono;
|
||||||
|
- no se sabe **cuando**, asi que no se ve si alguien repitio la misma cosa de
|
||||||
|
tres formas seguidas —que es la señal mas valiosa que hay—;
|
||||||
|
- y solo guardaba los FALLOS, asi que no habia con que comparar.
|
||||||
|
|
||||||
|
Ademas se llenaba de eco y de alucinaciones de Whisper, y la mejora nocturna
|
||||||
|
acabo generando alias a partir del ruido. De 504 frases, 306 parecian ordenes
|
||||||
|
sin atender y al mirarlas casi todas eran JARVIS oyendose a si mismo.
|
||||||
|
|
||||||
|
## Que se guarda
|
||||||
|
|
||||||
|
Todo lo que llega, con su destino. Una linea por frase, JSON:
|
||||||
|
|
||||||
|
t cuando
|
||||||
|
oido lo que transcribio Whisper
|
||||||
|
via catalogo | cerebro | ruido | bloqueado | respuesta
|
||||||
|
accion a que accion fue, si fue a alguna
|
||||||
|
arg con que argumento
|
||||||
|
seg lo que tardo en contestar
|
||||||
|
dicho lo que contesto
|
||||||
|
|
||||||
|
Guardar TAMBIEN los aciertos es lo que permite comparar: sin ellos, un dia malo
|
||||||
|
y un dia bueno tienen el mismo aspecto.
|
||||||
|
|
||||||
|
## Lo que NO se guarda
|
||||||
|
|
||||||
|
Nada de audio. El diario es texto y se puede leer con `less`; si algun dia
|
||||||
|
guardara los WAV, el asistente pasaria a ser una grabadora de la habitacion y
|
||||||
|
eso es otra cosa muy distinta de lo que es.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
DIARIO = os.path.join(AQUI, "datos", "diario.jsonl")
|
||||||
|
|
||||||
|
# Un diario que crece sin fin acaba siendo un problema en vez de una ayuda. A
|
||||||
|
# ~150 bytes por linea, 200.000 son unos 30 MB: mas que suficiente para meses de
|
||||||
|
# uso y poco para el disco.
|
||||||
|
TOPE_LINEAS = 200_000
|
||||||
|
|
||||||
|
_cerrojo = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def anota(oido: str, via: str, accion: str = "", arg: str = "",
|
||||||
|
seg: float = 0.0, dicho: str = "", ruta: str = DIARIO):
|
||||||
|
"""Apunta una frase y su destino. No falla nunca hacia fuera.
|
||||||
|
|
||||||
|
Si escribir el diario diera error, lo ultimo que debe hacer es tirar al
|
||||||
|
asistente: se pierde una linea y ya.
|
||||||
|
"""
|
||||||
|
if not oido:
|
||||||
|
return
|
||||||
|
linea = {
|
||||||
|
"t": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"oido": oido[:400],
|
||||||
|
"via": via,
|
||||||
|
}
|
||||||
|
if accion:
|
||||||
|
linea["accion"] = accion
|
||||||
|
if arg:
|
||||||
|
linea["arg"] = arg[:200]
|
||||||
|
if seg:
|
||||||
|
linea["seg"] = round(seg, 2)
|
||||||
|
if dicho:
|
||||||
|
linea["dicho"] = dicho[:300]
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _cerrojo:
|
||||||
|
os.makedirs(os.path.dirname(ruta), exist_ok=True)
|
||||||
|
with open(ruta, "a", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(linea, ensure_ascii=False) + "\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def lee(ruta: str = DIARIO, desde=None) -> list:
|
||||||
|
"""Las lineas del diario, ya en diccionarios. Salta las corruptas."""
|
||||||
|
salida = []
|
||||||
|
try:
|
||||||
|
with open(ruta, encoding="utf-8") as f:
|
||||||
|
for l in f:
|
||||||
|
try:
|
||||||
|
d = json.loads(l)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if desde and d.get("t", "") < desde:
|
||||||
|
continue
|
||||||
|
salida.append(d)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return salida
|
||||||
|
|
||||||
|
|
||||||
|
def recorta(ruta: str = DIARIO, tope: int = TOPE_LINEAS):
|
||||||
|
"""Deja solo las ultimas `tope` lineas."""
|
||||||
|
lineas = []
|
||||||
|
try:
|
||||||
|
with open(ruta, encoding="utf-8") as f:
|
||||||
|
lineas = f.readlines()
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
if len(lineas) <= tope:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with _cerrojo, open(ruta, "w", encoding="utf-8") as f:
|
||||||
|
f.writelines(lineas[-tope:])
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
84
nucleo/manos/fonetica.py
Normal file
84
nucleo/manos/fonetica.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
"""Comparar por como SUENA, no por como se escribe.
|
||||||
|
|
||||||
|
## Que problema resuelve
|
||||||
|
|
||||||
|
Midiendo donde se pierde el acierto salio esto: de 150 ordenes dichas, el 79 %
|
||||||
|
llega a su accion, un 5 % se oye mal de verdad, y un **15 % se oye bien pero el
|
||||||
|
emparejador no lo relaciona**. Mirando esos casos uno a uno, no son ordenes
|
||||||
|
distintas: son la misma orden mal escrita.
|
||||||
|
|
||||||
|
dijo "receta de documentos" oyo "Leceta de documentos"
|
||||||
|
dijo "como van los pubs" oyo "¿Como van los poops?"
|
||||||
|
dijo "abre bandcamp" oyo "Abre van Kamp"
|
||||||
|
dijo "abre el drive" oyo "Abril Drive"
|
||||||
|
dijo "abreme oasis" oyo "Abre mi roasis"
|
||||||
|
|
||||||
|
Eso NO se arregla con embeddings: un modelo semantico ve "Leceta" como una
|
||||||
|
palabra inexistente, no como "receta". Y bajar el umbral de parecido tampoco,
|
||||||
|
porque el umbral esta alto por una razon —hay una cicatriz en el codigo de
|
||||||
|
cuando "que carne el tiempo" se colaba en la accion del tiempo y ejecutaba lo
|
||||||
|
que no era—.
|
||||||
|
|
||||||
|
Lo que sirve es comparar como suena. En castellano hay unas pocas confusiones
|
||||||
|
que lo explican casi todo, y son las que aplica `fonetica()`.
|
||||||
|
|
||||||
|
## Lo que NO hace
|
||||||
|
|
||||||
|
No es un Soundex ni un Metaphone: esos comprimen tanto que juntan palabras que
|
||||||
|
no se parecen, y aqui juntar de mas significa ejecutar la orden equivocada, que
|
||||||
|
es lo peor que puede hacer esto. Se queda en las equivalencias que de verdad
|
||||||
|
confunde el reconocedor de voz en castellano, y nada mas.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
# El orden importa: cada regla se aplica sobre el resultado de la anterior.
|
||||||
|
REGLAS = (
|
||||||
|
# la hache no suena
|
||||||
|
(r"h", ""),
|
||||||
|
# b y v son el mismo sonido: "van Kamp" / "bandcamp"
|
||||||
|
(r"v", "b"),
|
||||||
|
# ge/gi suenan como jota
|
||||||
|
(r"g([ei])", r"j\1"),
|
||||||
|
# gue/gui: la u es muda
|
||||||
|
(r"gu([ei])", r"g\1"),
|
||||||
|
# que/qui igual, y qu -> k
|
||||||
|
(r"qu([ei])", r"k\1"),
|
||||||
|
(r"q", "k"),
|
||||||
|
# ce/ci en seseo suenan como ese; ca/co/cu como ka
|
||||||
|
(r"c([ei])", r"s\1"),
|
||||||
|
(r"c", "k"),
|
||||||
|
# z siempre ese (seseo): asi "poops"/"pubs" y "haz"/"has" caen juntos
|
||||||
|
(r"z", "s"),
|
||||||
|
# x entre vocales suena ks, pero al principio suele ser ese
|
||||||
|
(r"^x", "s"),
|
||||||
|
(r"x", "ks"),
|
||||||
|
# ll e y son el mismo sonido para casi todo el mundo
|
||||||
|
(r"ll", "y"),
|
||||||
|
# w es de fuera: suena como u o como b segun la palabra; se unifica a b
|
||||||
|
(r"w", "b"),
|
||||||
|
# la erre doble y la simple no se distinguen al transcribir
|
||||||
|
(r"rr", "r"),
|
||||||
|
# la ñ se escribe de mil formas cuando el reconocedor duda
|
||||||
|
(r"ñ", "n"),
|
||||||
|
# las dobles no aportan: "poops" -> "pops"
|
||||||
|
(r"(.)\1+", r"\1"),
|
||||||
|
# las vocales finales se comen mucho; y la ese final tambien
|
||||||
|
(r"s$", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fonetica(texto: str) -> str:
|
||||||
|
"""Como suena, aproximadamente, para poder comparar dos oidos distintos."""
|
||||||
|
t = unicodedata.normalize("NFD", (texto or "").lower())
|
||||||
|
t = "".join(c for c in t if unicodedata.category(c) != "Mn" or c == "̃")
|
||||||
|
t = unicodedata.normalize("NFC", t)
|
||||||
|
t = re.sub(r"[^a-zñ\s]", " ", t)
|
||||||
|
|
||||||
|
palabras = []
|
||||||
|
for p in t.split():
|
||||||
|
for patron, cambio in REGLAS:
|
||||||
|
p = re.sub(patron, cambio, p)
|
||||||
|
if p:
|
||||||
|
palabras.append(p)
|
||||||
|
return " ".join(palabras)
|
||||||
115
nucleo/manos/medidas.py
Normal file
115
nucleo/manos/medidas.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""Las medidas de eficiencia de JARVIS, sacadas del diario.
|
||||||
|
|
||||||
|
El diario ya apunta por cada frase: cuanto tardo (`seg`), por donde fue (`via`)
|
||||||
|
y cuando (`t`). De ahi salen las metricas de rendimiento sin instrumentar nada
|
||||||
|
del camino caliente: esto solo LEE, y se calcula fuera del hilo que contesta.
|
||||||
|
|
||||||
|
## Que se mide y por que importa cada una
|
||||||
|
|
||||||
|
latencia p50/p95/max del tiempo de respuesta. La mediana dice como va de
|
||||||
|
normal; el p95 dice como va cuando va mal, que es lo que se nota.
|
||||||
|
reparto que fraccion va al CEREBRO (lento, ~2-3 s) y cual al CATALOGO
|
||||||
|
(instantaneo, salta el modelo). Es la palanca de eficiencia mas
|
||||||
|
grande: cuanto mas catalogo, mas rapido el conjunto.
|
||||||
|
ruido que fraccion de lo que oye es Whisper oyendose a si mismo. No es
|
||||||
|
velocidad, es calidad de la escucha, y envenena todo lo demas.
|
||||||
|
volumen frases en las ultimas 24 h. Contexto para lo anterior: un p95 malo
|
||||||
|
con 4 frases no dice nada.
|
||||||
|
rag hit@5/hit@1 del ultimo benchmark del RAG (informe_rag.txt). Es la
|
||||||
|
unica que no sale del diario: es una medida de test, periodica.
|
||||||
|
|
||||||
|
Todo sobre una VENTANA reciente (las ultimas MUESTRA frases), no sobre el diario
|
||||||
|
entero: interesa como va AHORA, no la media historica que nunca cambia.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from manos.diario import lee
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
INFORME_RAG = os.path.join(AQUI, "datos", "informe_rag.txt")
|
||||||
|
|
||||||
|
MUESTRA = 600 # las ultimas N frases: "como va ahora"
|
||||||
|
CEREBRO = "cerebro"
|
||||||
|
|
||||||
|
|
||||||
|
def _percentil(valores, q):
|
||||||
|
if not valores:
|
||||||
|
return 0.0
|
||||||
|
v = sorted(valores)
|
||||||
|
k = (len(v) - 1) * q
|
||||||
|
lo, hi = int(k), min(int(k) + 1, len(v) - 1)
|
||||||
|
return v[lo] + (v[hi] - v[lo]) * (k - lo)
|
||||||
|
|
||||||
|
|
||||||
|
def _rag():
|
||||||
|
"""hit@5 y hit@1 del ultimo informe del RAG, si existe."""
|
||||||
|
try:
|
||||||
|
txt = open(INFORME_RAG, encoding="utf-8").read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
# el bloque CANDIDATO es el que se promociono; si no, el ultimo que haya
|
||||||
|
def pct(clave):
|
||||||
|
# " hit@5 43/45 (95%)" -> 95
|
||||||
|
ms = re.findall(clave + r"\s+\d+/\d+\s+\((\d+)%\)", txt)
|
||||||
|
return int(ms[-1]) if ms else None
|
||||||
|
h5, h1 = pct("hit@5"), pct("hit@1")
|
||||||
|
if h5 is None:
|
||||||
|
return None
|
||||||
|
return {"hit5": h5, "hit1": h1 or 0}
|
||||||
|
|
||||||
|
|
||||||
|
def lee_medidas():
|
||||||
|
"""El dict de medidas, con las unidades ya listas para el HUD."""
|
||||||
|
diario = lee()[-MUESTRA:]
|
||||||
|
if not diario:
|
||||||
|
return {"n": 0}
|
||||||
|
|
||||||
|
segs = [d["seg"] for d in diario if isinstance(d.get("seg"), (int, float)) and d["seg"] > 0]
|
||||||
|
seg_cerebro = [d["seg"] for d in diario
|
||||||
|
if d.get("via") == CEREBRO and isinstance(d.get("seg"), (int, float))]
|
||||||
|
seg_catalogo = [d["seg"] for d in diario
|
||||||
|
if d.get("via") == "catalogo" and isinstance(d.get("seg"), (int, float))]
|
||||||
|
|
||||||
|
vias = {}
|
||||||
|
for d in diario:
|
||||||
|
vias[d.get("via", "?")] = vias.get(d.get("via", "?"), 0) + 1
|
||||||
|
total = len(diario)
|
||||||
|
|
||||||
|
def pct_via(v):
|
||||||
|
return round(100 * vias.get(v, 0) / total) if total else 0
|
||||||
|
|
||||||
|
# volumen: cuantas en las ultimas 24 h, y por hora las ultimas 12 para la traza
|
||||||
|
ahora = datetime.now()
|
||||||
|
hace24 = ahora - timedelta(hours=24)
|
||||||
|
vol24 = 0
|
||||||
|
porhora = [0] * 12
|
||||||
|
for d in diario:
|
||||||
|
try:
|
||||||
|
t = datetime.fromisoformat(d.get("t", ""))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if t >= hace24:
|
||||||
|
vol24 += 1
|
||||||
|
h = int((ahora - t).total_seconds() // 3600)
|
||||||
|
if 0 <= h < 12:
|
||||||
|
porhora[11 - h] += 1
|
||||||
|
|
||||||
|
medidas = {
|
||||||
|
"n": total,
|
||||||
|
"lat_p50": round(_percentil(segs, 0.50), 2),
|
||||||
|
"lat_p95": round(_percentil(segs, 0.95), 2),
|
||||||
|
"lat_max": round(max(segs), 2) if segs else 0.0,
|
||||||
|
"lat_cerebro": round(sum(seg_cerebro) / len(seg_cerebro), 2) if seg_cerebro else 0.0,
|
||||||
|
"lat_catalogo": round(sum(seg_catalogo) / len(seg_catalogo), 2) if seg_catalogo else 0.0,
|
||||||
|
"pct_cerebro": pct_via("cerebro"),
|
||||||
|
"pct_catalogo": pct_via("catalogo"),
|
||||||
|
"pct_ruido": pct_via("ruido"),
|
||||||
|
"vol24": vol24,
|
||||||
|
"porhora": porhora,
|
||||||
|
}
|
||||||
|
rag = _rag()
|
||||||
|
if rag:
|
||||||
|
medidas["rag"] = rag
|
||||||
|
return medidas
|
||||||
147
nucleo/manos/preguntar.py
Normal file
147
nucleo/manos/preguntar.py
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
"""Que preguntar cuando falta un dato para ejecutar una accion.
|
||||||
|
|
||||||
|
Pulsar una accion en el panel no puede limitarse a recitar la frase que la
|
||||||
|
dispara —"diga «busca el fichero» y a continuacion lo que busca"—: eso es un
|
||||||
|
manual de instrucciones, y uno pulsa justo porque no quiere leerse el manual.
|
||||||
|
Lo natural es que pregunte lo que falta y lo haga.
|
||||||
|
|
||||||
|
busca el fichero -> ¿Que fichero, señor?
|
||||||
|
manda la ventana a la pantalla -> ¿A que pantalla, señor?
|
||||||
|
mata el proceso -> ¿Que proceso, señor?
|
||||||
|
|
||||||
|
## Como sale la pregunta
|
||||||
|
|
||||||
|
Del propio catalogo, no de una lista escrita a mano ni de un modelo. Las frases
|
||||||
|
del catalogo son regulares —verbo + articulo + objeto— asi que basta con buscar
|
||||||
|
el ULTIMO articulo y quedarse con lo que va detras.
|
||||||
|
|
||||||
|
Y se pregunta por el OBJETO, no conjugando el verbo. "¿Que fichero, señor?" es
|
||||||
|
tan natural como "¿Que fichero busco, señor?" y no obliga a conjugar: pasar de
|
||||||
|
"busca" a "busco", de "leeme" a "leo" o de "pon" a "pongo" pide un diccionario
|
||||||
|
de verbos irregulares para ganar nada.
|
||||||
|
|
||||||
|
Si la preposicion viene delante del articulo, se conserva: sin ella, "manda la
|
||||||
|
ventana a la pantalla" preguntaria "¿Que pantalla?" y se pierde el "a", que es
|
||||||
|
justo lo que dice que la respuesta es un destino.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
ARTICULOS = {"el", "la", "los", "las", "un", "una", "unos", "unas"}
|
||||||
|
# Las contracciones ya llevan la preposicion dentro.
|
||||||
|
CONTRACCIONES = {"al": "a", "del": "de"}
|
||||||
|
PREPOSICIONES = {"a", "de", "en", "con", "por", "para", "sobre", "hacia", "desde"}
|
||||||
|
|
||||||
|
# Cuando no hay de donde sacarlo. Vale para "esta corriendo" o "tengo instalado",
|
||||||
|
# donde el objeto no aparece en la frase.
|
||||||
|
GENERICA = "¿El que, señor?"
|
||||||
|
|
||||||
|
|
||||||
|
def _sin_tildes(t: str) -> str:
|
||||||
|
t = unicodedata.normalize("NFD", t)
|
||||||
|
return "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||||
|
|
||||||
|
|
||||||
|
def pregunta_por(accion) -> str:
|
||||||
|
"""La pregunta que rellena el hueco de una accion que espera argumento.
|
||||||
|
|
||||||
|
La clave: el argumento va SIEMPRE al final de la frase, porque asi lo
|
||||||
|
recoge el emparejador. Asi que la pregunta se decide mirando como TERMINA.
|
||||||
|
"""
|
||||||
|
frases = getattr(accion, "frases", None) or []
|
||||||
|
if not frases:
|
||||||
|
return GENERICA
|
||||||
|
|
||||||
|
palabras = _sin_tildes(frases[0].lower()).split()
|
||||||
|
if not palabras:
|
||||||
|
return GENERICA
|
||||||
|
ultima = palabras[-1]
|
||||||
|
|
||||||
|
# 1. Termina en preposicion: el hueco va justo detras.
|
||||||
|
# "busca el precio de" -> ¿El precio de que, señor?
|
||||||
|
# "busca imagenes de" -> ¿Imagenes de que, señor?
|
||||||
|
if ultima in PREPOSICIONES:
|
||||||
|
trozo = []
|
||||||
|
# Sin la primera palabra: es el verbo, y en la pregunta sobra.
|
||||||
|
# "busca imagenes de" tiene que dar "¿Imagenes de que?", no
|
||||||
|
# "¿Busca imagenes de que?".
|
||||||
|
for p in reversed(palabras[1:-1]):
|
||||||
|
if p in ARTICULOS or p in CONTRACCIONES:
|
||||||
|
trozo.insert(0, p)
|
||||||
|
break
|
||||||
|
if p in PREPOSICIONES:
|
||||||
|
break
|
||||||
|
trozo.insert(0, p)
|
||||||
|
if len(trozo) >= 2:
|
||||||
|
break
|
||||||
|
if trozo:
|
||||||
|
frase = " ".join(trozo).capitalize()
|
||||||
|
return f"¿{frase} {ultima} que, señor?"
|
||||||
|
return GENERICA
|
||||||
|
|
||||||
|
# 2. "busca en <sitio>": lo que falta es QUE buscar, no el sitio.
|
||||||
|
# Sin esta regla, "busca en la wikipedia" preguntaba "¿En que
|
||||||
|
# wikipedia?", que no tiene sentido: solo hay una.
|
||||||
|
if "en" in palabras:
|
||||||
|
i = palabras.index("en")
|
||||||
|
sitio = " ".join(palabras[i:])
|
||||||
|
verbo = palabras[0]
|
||||||
|
if verbo.startswith("busca"):
|
||||||
|
return f"¿Que busco {sitio}, señor?"
|
||||||
|
return f"¿Que {sitio}, señor?"
|
||||||
|
|
||||||
|
# 3. Lo normal: articulo + objeto al final.
|
||||||
|
# "busca el fichero" -> ¿Que fichero, señor?
|
||||||
|
# "manda la ventana a la pantalla" -> ¿A que pantalla, señor?
|
||||||
|
for i in range(len(palabras) - 1, -1, -1):
|
||||||
|
p = palabras[i]
|
||||||
|
objeto = palabras[i + 1] if i + 1 < len(palabras) else ""
|
||||||
|
if not objeto:
|
||||||
|
continue
|
||||||
|
if p in CONTRACCIONES:
|
||||||
|
return f"¿{CONTRACCIONES[p].capitalize()} que {objeto}, señor?"
|
||||||
|
if p in ARTICULOS:
|
||||||
|
anterior = palabras[i - 1] if i > 0 else ""
|
||||||
|
if anterior in PREPOSICIONES:
|
||||||
|
return f"¿{anterior.capitalize()} que {objeto}, señor?"
|
||||||
|
return f"¿Que {objeto}, señor?"
|
||||||
|
return GENERICA
|
||||||
|
|
||||||
|
|
||||||
|
def confirmacion(accion) -> str:
|
||||||
|
"""Lo que se pregunta antes de algo que no tiene vuelta atras.
|
||||||
|
|
||||||
|
La orden se CITA en vez de meterla en la frase. Las del catalogo estan en
|
||||||
|
imperativo —"cierra el navegador"— y encajarlas da cosas como "Voy a cierra
|
||||||
|
el navegador". Conjugarlas al infinitivo pediria un diccionario de verbos
|
||||||
|
irregulares (cierra->cerrar, pon->poner, ve->ir) para ganar poco: entre
|
||||||
|
comillas se lee bien y ademas queda claro que es literalmente la orden.
|
||||||
|
"""
|
||||||
|
frase = (getattr(accion, "frases", None) or [""])[0]
|
||||||
|
return f"«{frase}». Esto no tiene vuelta atras. ¿Confirma, señor?"
|
||||||
|
|
||||||
|
|
||||||
|
# Lo que cuenta como si y como no. Se comparan sin tildes y sin puntuacion,
|
||||||
|
# porque llegan de Whisper: "sí", "Si.", "SÍ" y "sip" son lo mismo.
|
||||||
|
SI = {"si", "sip", "claro", "vale", "venga", "adelante", "confirmo", "hazlo",
|
||||||
|
"por supuesto", "correcto", "afirmativo", "eso es", "dale", "ok", "okey"}
|
||||||
|
NO = {"no", "nop", "nada", "dejalo", "olvidalo", "cancela", "cancelalo",
|
||||||
|
"para", "mejor no", "negativo", "ni hablar", "espera"}
|
||||||
|
|
||||||
|
|
||||||
|
def es_si(texto: str):
|
||||||
|
"""True si es un si, False si es un no, None si no es ni una cosa ni otra."""
|
||||||
|
limpio = _sin_tildes(re.sub(r"[¡!¿?.,;]", "", (texto or "").lower())).strip()
|
||||||
|
if not limpio:
|
||||||
|
return None
|
||||||
|
if limpio in SI:
|
||||||
|
return True
|
||||||
|
if limpio in NO:
|
||||||
|
return False
|
||||||
|
# Tambien al principio: "si, hazlo" o "no, dejalo"
|
||||||
|
primera = limpio.split()[0]
|
||||||
|
if primera in SI:
|
||||||
|
return True
|
||||||
|
if primera in NO:
|
||||||
|
return False
|
||||||
|
return None
|
||||||
95
nucleo/manos/seleccion.py
Normal file
95
nucleo/manos/seleccion.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"""Elegir de una lista por voz, y buscar dentro de un fichero (Ctrl+F).
|
||||||
|
|
||||||
|
La capa que le faltaba a las acciones de fichero. Buscar y soltar el primer
|
||||||
|
resultado no sirve cuando hay varios: hace falta enseñar la lista y poder decir
|
||||||
|
"abre el tercero". Y para buscar DENTRO de un fichero como con Ctrl+F, hace
|
||||||
|
falta recordar cual era.
|
||||||
|
|
||||||
|
Aqui va la logica pura —interpretar "el tercero", detectar las ordenes de
|
||||||
|
seleccion y de busqueda interna—; el estado (la ultima lista, el ultimo
|
||||||
|
fichero) y la ejecucion viven en el panel.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Como se dice cada posicion. Se aceptan el ordinal, el numero en palabra y la
|
||||||
|
# cifra, porque el reconocedor devuelve cualquiera de los tres.
|
||||||
|
ORDINALES = {
|
||||||
|
1: ["primero", "primera", "uno", "1"],
|
||||||
|
2: ["segundo", "segunda", "dos", "2"],
|
||||||
|
3: ["tercero", "tercera", "tres", "3"],
|
||||||
|
4: ["cuarto", "cuarta", "cuatro", "4"],
|
||||||
|
5: ["quinto", "quinta", "cinco", "5"],
|
||||||
|
6: ["sexto", "sexta", "seis", "6"],
|
||||||
|
7: ["septimo", "septima", "siete", "7"],
|
||||||
|
8: ["octavo", "octava", "ocho", "8"],
|
||||||
|
9: ["noveno", "novena", "nueve", "9"],
|
||||||
|
10: ["decimo", "decima", "diez", "10"],
|
||||||
|
}
|
||||||
|
_PALABRA_A_N = {p: n for n, ps in ORDINALES.items() for p in ps}
|
||||||
|
|
||||||
|
# "el ultimo" es comodo y no es un numero
|
||||||
|
_ULTIMO = {"ultimo", "ultima", "final"}
|
||||||
|
|
||||||
|
|
||||||
|
def posicion(texto: str):
|
||||||
|
"""Devuelve el numero (1..N) que se ha dicho, -1 para 'el ultimo', o None.
|
||||||
|
|
||||||
|
Se queda con la PRIMERA posicion mencionada, para que "abre el tercero"
|
||||||
|
de 3 y no se lie con otros numeros que puedan venir detras.
|
||||||
|
"""
|
||||||
|
for palabra in texto.split():
|
||||||
|
if palabra in _ULTIMO:
|
||||||
|
return -1
|
||||||
|
if palabra in _PALABRA_A_N:
|
||||||
|
return _PALABRA_A_N[palabra]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Ordenes que actuan sobre la lista ya mostrada. El verbo dice que hacer con lo
|
||||||
|
# elegido; la posicion, cual.
|
||||||
|
_ABRIR = re.compile(r"\b(abre|abreme|abrir)\b")
|
||||||
|
_LEER = re.compile(r"\b(lee|leeme|leer|muestrame|ensename)\b")
|
||||||
|
_SELECCION = re.compile(r"\b(el|la|numero|opcion|resultado)\b")
|
||||||
|
|
||||||
|
|
||||||
|
def orden_de_seleccion(texto: str):
|
||||||
|
"""(accion, posicion) si el texto elige de la lista, o None.
|
||||||
|
|
||||||
|
accion: "abrir" o "leer". posicion: 1..N o -1 (ultimo).
|
||||||
|
Ejemplos: "abre el tercero", "lee el numero dos", "el ultimo", "abre el 3".
|
||||||
|
"""
|
||||||
|
pos = posicion(texto)
|
||||||
|
if pos is None:
|
||||||
|
return None
|
||||||
|
# tiene que sonar a seleccion, no a otra cosa que lleve un numero
|
||||||
|
if not (_SELECCION.search(texto) or _ABRIR.search(texto) or _LEER.search(texto)):
|
||||||
|
return None
|
||||||
|
if _LEER.search(texto):
|
||||||
|
return ("leer", pos)
|
||||||
|
# por defecto, elegir es abrir
|
||||||
|
return ("abrir", pos)
|
||||||
|
|
||||||
|
|
||||||
|
# Ctrl+F: buscar un termino dentro del ultimo fichero abierto.
|
||||||
|
_DENTRO = re.compile(
|
||||||
|
r"\b(busca|buscame|encuentra)\b.*\b(dentro|en el fichero|en el archivo|en el texto)\b")
|
||||||
|
|
||||||
|
|
||||||
|
def orden_buscar_dentro(texto: str):
|
||||||
|
"""El termino a buscar dentro del fichero, o None.
|
||||||
|
|
||||||
|
"busca dentro configuracion", "busca en el texto la palabra error".
|
||||||
|
Se queda con lo que va despues de 'dentro'/'en el ...'.
|
||||||
|
"""
|
||||||
|
if not _DENTRO.search(texto):
|
||||||
|
return None
|
||||||
|
# cortar por el separador y quedarse con el resto
|
||||||
|
for sep in ("dentro de", "dentro", "en el fichero", "en el archivo", "en el texto",
|
||||||
|
"la palabra"):
|
||||||
|
if sep in texto:
|
||||||
|
resto = texto.split(sep, 1)[1].strip()
|
||||||
|
resto = re.sub(r"^(la palabra|el termino|de|la|el|los|las)\s+",
|
||||||
|
"", resto).strip()
|
||||||
|
if resto:
|
||||||
|
return resto
|
||||||
|
return None
|
||||||
134
nucleo/manos/sudo_root.py
Normal file
134
nucleo/manos/sudo_root.py
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"""Ejecutar ordenes como root de forma controlada.
|
||||||
|
|
||||||
|
El problema que resuelve. Newelle convierte `sudo` en `sudo -S` (leer la
|
||||||
|
contraseña de la entrada estandar), pero en el panel de voz no hay una entrada
|
||||||
|
donde teclearla, asi que se queda colgado esperando una contraseña que no
|
||||||
|
llega. Y las acciones marcadas para confirmar no tenian dialogo: solo decian
|
||||||
|
"confirme antes" y no ejecutaban nada.
|
||||||
|
|
||||||
|
Como se resuelve, y las decisiones de seguridad detras:
|
||||||
|
|
||||||
|
- La contraseña NUNCA se escribe en disco. Vive en memoria de este proceso solo
|
||||||
|
si el usuario activa el permiso de sesion, y se pasa a `sudo -S` por stdin.
|
||||||
|
- El permiso de sesion CADUCA: a los 15 minutos de la ultima orden se olvida
|
||||||
|
sola, y tambien al cerrar el panel. No es un "root para siempre".
|
||||||
|
- CADA orden con root muestra su comando exacto en un dialogo antes de correr.
|
||||||
|
El permiso de sesion solo ahorra re-teclear la contraseña, nunca salta el ver
|
||||||
|
y aprobar lo que se va a ejecutar. La voz no puede autoaprobar root.
|
||||||
|
|
||||||
|
Alternativa descartada: apoyarse solo en el timestamp de sudo (`sudo -v`). Es
|
||||||
|
mas limpio en teoria —lo gestiona el sistema— pero a traves de flatpak-spawn el
|
||||||
|
tty cambia entre llamadas y el cacheo no es fiable. Mantener la contraseña en
|
||||||
|
memoria del proceso, con caducidad y sin tocar disco, funciona siempre y el
|
||||||
|
usuario opta a ello de forma explicita.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
# A los 15 minutos sin usarla, la contraseña en memoria se olvida. Igual que el
|
||||||
|
# valor por defecto de sudo, para no sorprender.
|
||||||
|
CADUCIDAD = 15 * 60
|
||||||
|
|
||||||
|
# Detecta una invocacion real de sudo: al principio del comando o justo tras un
|
||||||
|
# separador (;, |, &, &&, ||, salto de linea), no la palabra "sudo" dicha como
|
||||||
|
# argumento ("echo sudo no cuenta"). Errar aqui solo pediria una contraseña de
|
||||||
|
# mas, no es peligroso, pero molesta.
|
||||||
|
_SUDO = re.compile(r"(?:^|[;&|\n]|&&|\|\|)\s*sudo(?:\s|$)")
|
||||||
|
|
||||||
|
|
||||||
|
def necesita_root(comando: str) -> bool:
|
||||||
|
"""True si el comando lanza algo con sudo."""
|
||||||
|
return bool(comando and _SUDO.search(comando))
|
||||||
|
|
||||||
|
|
||||||
|
class GestorRoot:
|
||||||
|
"""Guarda —o no— la contraseña de sesion, y ejecuta con ella.
|
||||||
|
|
||||||
|
Una instancia por panel. Al cerrar el panel se llama a olvida().
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, prefijo_host=None):
|
||||||
|
# prefijo para salir del sandbox; se pasa como argumento para poder
|
||||||
|
# probar esto sin flatpak
|
||||||
|
self._prefijo = list(prefijo_host or [])
|
||||||
|
self._password = None
|
||||||
|
self._ultimo_uso = 0.0
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- sesion
|
||||||
|
|
||||||
|
def sesion_activa(self) -> bool:
|
||||||
|
"""Hay contraseña recordada y aun no ha caducado."""
|
||||||
|
if self._password is None:
|
||||||
|
return False
|
||||||
|
if time.monotonic() - self._ultimo_uso > CADUCIDAD:
|
||||||
|
self.olvida()
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def olvida(self):
|
||||||
|
"""Borra la contraseña de memoria. Se llama al cerrar el panel y al
|
||||||
|
caducar. Se sobreescribe antes de soltarla, por si acaso."""
|
||||||
|
if self._password is not None:
|
||||||
|
self._password = "\x00" * len(self._password)
|
||||||
|
self._password = None
|
||||||
|
self._ultimo_uso = 0.0
|
||||||
|
|
||||||
|
def recuerda(self, password: str):
|
||||||
|
self._password = password
|
||||||
|
self._ultimo_uso = time.monotonic()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ ejecutar
|
||||||
|
|
||||||
|
def valida(self, password: str) -> bool:
|
||||||
|
"""Comprueba la contraseña con `sudo -S -v`, sin ejecutar nada mas.
|
||||||
|
|
||||||
|
-k borra cualquier timestamp previo para que la comprobacion sea de
|
||||||
|
verdad; -v solo actualiza credenciales. Si la contraseña es correcta
|
||||||
|
devuelve True; nunca lanza ningun comando del usuario.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
self._prefijo + ["sudo", "-S", "-k", "-p", "", "-v"],
|
||||||
|
input=password + "\n", capture_output=True, text=True,
|
||||||
|
timeout=10)
|
||||||
|
return r.returncode == 0
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def ejecuta(self, comando: str, password: str = None, timeout: int = 30):
|
||||||
|
"""Ejecuta el comando dando la contraseña a los sudo que lleve.
|
||||||
|
|
||||||
|
password: si se pasa, se usa esa vez; si no, la de la sesion. La
|
||||||
|
contraseña se pasa por stdin a `sudo -S`. No se registra en ningun
|
||||||
|
sitio.
|
||||||
|
|
||||||
|
Devuelve (salida, codigo). codigo -1 = no habia contraseña disponible.
|
||||||
|
"""
|
||||||
|
clave = password if password is not None else self._password
|
||||||
|
if clave is None:
|
||||||
|
return "No hay contraseña disponible para root.", -1
|
||||||
|
|
||||||
|
# que todos los sudo del comando lean de stdin sin imprimir el prompt
|
||||||
|
preparado = re.sub(r"(^|[;&|]|\s)sudo(\s)",
|
||||||
|
r"\1sudo -S -p '' \2", comando)
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
self._prefijo + ["bash", "-lc", preparado],
|
||||||
|
input=clave + "\n", capture_output=True, text=True,
|
||||||
|
timeout=timeout)
|
||||||
|
salida = (r.stdout or r.stderr or "").strip()
|
||||||
|
# sudo escribe su prompt/errores en stderr aunque -p sea vacio;
|
||||||
|
# se limpia lo que es ruido de sudo, no del comando
|
||||||
|
salida = "\n".join(
|
||||||
|
l for l in salida.splitlines()
|
||||||
|
if l.strip() and not l.startswith(("[sudo]", "Sorry, try again")))
|
||||||
|
self._ultimo_uso = time.monotonic()
|
||||||
|
return salida, r.returncode
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "La orden con root ha tardado demasiado.", 124
|
||||||
|
except OSError as e:
|
||||||
|
return f"No se pudo ejecutar: {e}", 1
|
||||||
|
finally:
|
||||||
|
# no dejar la copia local viva mas de lo necesario
|
||||||
|
clave = "\x00" * len(clave)
|
||||||
346
nucleo/manos/tareas.py
Normal file
346
nucleo/manos/tareas.py
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
"""Tareas en paralelo: cada orden corre por su cuenta y se puede parar.
|
||||||
|
|
||||||
|
Hasta ahora una orden era una llamada a `ejecuta()`: un `subprocess.run` con
|
||||||
|
veinte segundos de limite que bloqueaba el hilo hasta terminar. Servia porque
|
||||||
|
todos los atajos eran instantaneos, pero deja fuera justo lo que uno querria
|
||||||
|
mandar y olvidarse: un escaneo, una busqueda por todo el disco, una descarga.
|
||||||
|
Y no habia forma de arrepentirse a mitad.
|
||||||
|
|
||||||
|
Aqui cada orden es una Tarea con nombre, estado y un proceso propio del que se
|
||||||
|
guarda el grupo, para poder matarlo entero. Tres cosas que costaron decidir:
|
||||||
|
|
||||||
|
- **Se mata el grupo, no el proceso.** El comando real es `bash -lc "..."`, asi
|
||||||
|
que matar el bash deja vivos a sus hijos —el `find`, el `nmap`— corriendo y
|
||||||
|
comiendo disco con la tarjeta ya cerrada. Con `start_new_session=True` el
|
||||||
|
proceso estrena grupo y `killpg` se los lleva a todos.
|
||||||
|
|
||||||
|
- **Fuera del sandbox tambien vale.** Comprobado: matar el `flatpak-spawn`
|
||||||
|
intermediario propaga la señal al proceso del host (2 procesos vivos -> 0).
|
||||||
|
No hace falta perseguir el PID del otro lado.
|
||||||
|
|
||||||
|
- **La salida se lee segun sale, no al final.** Con `run()` no hay nada que
|
||||||
|
enseñar hasta que termina, y una tarjeta de un escaneo de tres minutos que
|
||||||
|
esta en blanco los tres minutos no informa de nada. Se lee linea a linea y se
|
||||||
|
guardan las ultimas, que es lo que cabe en la tarjeta.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
# Lo que se guarda de la salida para enseñar en la tarjeta y para la plantilla
|
||||||
|
# de respuesta. Una tarjeta pequeña no da para mas, y guardar la salida entera
|
||||||
|
# de un `find /` es meterse megas en memoria por nada.
|
||||||
|
LINEAS_VISTA = 6
|
||||||
|
SALIDA_MAXIMA = 600
|
||||||
|
|
||||||
|
# Con boton de parar, el limite deja de ser una proteccion y pasa a ser un
|
||||||
|
# estorbo: veinte segundos matan cualquier escaneo util. Se deja un tope alto
|
||||||
|
# como red para lo que se cuelgue de verdad y no vuelva nunca.
|
||||||
|
LIMITE_LARGO = 900 # 15 minutos
|
||||||
|
|
||||||
|
# Cada cuanto se avisa al HUD mientras sale texto. Sin freno, un comando
|
||||||
|
# hablador manda cientos de mensajes por segundo al WebView y la interfaz se
|
||||||
|
# atasca dibujando texto que nadie llega a leer.
|
||||||
|
CADENCIA_AVISO = 0.25
|
||||||
|
|
||||||
|
CORRIENDO, HECHA, PARADA, FALLO, CADUCADA = (
|
||||||
|
"corriendo", "hecha", "parada", "fallo", "caducada")
|
||||||
|
|
||||||
|
|
||||||
|
class Tarea:
|
||||||
|
"""Una orden en marcha. Solo datos; quien la mueve es el gestor."""
|
||||||
|
|
||||||
|
def __init__(self, ident, titulo, accion_id, comando):
|
||||||
|
self.id = ident
|
||||||
|
self.titulo = titulo
|
||||||
|
self.accion_id = accion_id
|
||||||
|
self.comando = comando
|
||||||
|
# La accion de origen viaja con la tarea (y no en un diccionario
|
||||||
|
# aparte del panel) porque una tarea sin comando termina DENTRO de
|
||||||
|
# lanza(), antes de que quien la lanzo tenga el id para apuntarla: el
|
||||||
|
# aviso de "terminada" llegaria a un registro todavia vacio.
|
||||||
|
self.accion = None
|
||||||
|
self.argumento = ""
|
||||||
|
self.estado = CORRIENDO
|
||||||
|
self.inicio = time.monotonic()
|
||||||
|
self.fin = None
|
||||||
|
self.lineas = [] # las ultimas LINEAS_VISTA de salida
|
||||||
|
self.salida = "" # la salida acumulada, recortada
|
||||||
|
self.codigo = None
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def segundos(self) -> float:
|
||||||
|
return (self.fin or time.monotonic()) - self.inicio
|
||||||
|
|
||||||
|
@property
|
||||||
|
def viva(self) -> bool:
|
||||||
|
return self.estado == CORRIENDO
|
||||||
|
|
||||||
|
def resumen(self) -> dict:
|
||||||
|
"""Lo que viaja al HUD. Sin objetos: esto acaba en un json.dumps."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"titulo": self.titulo,
|
||||||
|
"accion": self.accion_id,
|
||||||
|
"estado": self.estado,
|
||||||
|
"segundos": round(self.segundos, 1),
|
||||||
|
"lineas": self.lineas[-LINEAS_VISTA:],
|
||||||
|
"codigo": self.codigo,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GestorTareas:
|
||||||
|
"""Lanza, vigila y para tareas. Un unico sitio donde vive el registro.
|
||||||
|
|
||||||
|
al_cambiar(tarea) se llama desde el hilo de la tarea cada vez que hay algo
|
||||||
|
nuevo que enseñar. Quien lo reciba se encarga de saltar al hilo de la
|
||||||
|
interfaz: aqui no se sabe nada de GTK a proposito, para poder probarlo sin
|
||||||
|
levantar la aplicacion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, al_cambiar=None, prefijo_host=()):
|
||||||
|
self._al_cambiar = al_cambiar or (lambda tarea: None)
|
||||||
|
self._prefijo_host = _con_watch_bus(prefijo_host)
|
||||||
|
self._tareas = {}
|
||||||
|
self._siguiente = 0
|
||||||
|
self._cerrojo = threading.Lock()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- registro
|
||||||
|
|
||||||
|
def _nueva(self, titulo, accion_id, comando) -> Tarea:
|
||||||
|
with self._cerrojo:
|
||||||
|
self._siguiente += 1
|
||||||
|
ident = f"t{self._siguiente}"
|
||||||
|
tarea = Tarea(ident, titulo, accion_id, comando)
|
||||||
|
self._tareas[ident] = tarea
|
||||||
|
return tarea
|
||||||
|
|
||||||
|
def get(self, ident):
|
||||||
|
with self._cerrojo:
|
||||||
|
return self._tareas.get(ident)
|
||||||
|
|
||||||
|
def activas(self) -> list:
|
||||||
|
with self._cerrojo:
|
||||||
|
return [t for t in self._tareas.values() if t.viva]
|
||||||
|
|
||||||
|
def todas(self) -> list:
|
||||||
|
with self._cerrojo:
|
||||||
|
return list(self._tareas.values())
|
||||||
|
|
||||||
|
def _avisa(self, tarea):
|
||||||
|
try:
|
||||||
|
self._al_cambiar(tarea)
|
||||||
|
except Exception as e: # el HUD no tumba la tarea
|
||||||
|
print(f"JARVIS: aviso de tarea fallido: {e}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- lanzar
|
||||||
|
|
||||||
|
def lanza(self, accion, argumento="", titulo=None) -> Tarea:
|
||||||
|
"""Arranca la accion en su propio proceso y devuelve la tarea ya viva."""
|
||||||
|
comando = accion.comando or ""
|
||||||
|
if comando:
|
||||||
|
comando = comando.replace("{argumento}", shlex.quote(argumento))
|
||||||
|
comando = comando.replace(
|
||||||
|
"{argumento_url}",
|
||||||
|
shlex.quote(urllib.parse.quote_plus(argumento)))
|
||||||
|
|
||||||
|
tarea = self._nueva(titulo or _titulo_de(accion, argumento),
|
||||||
|
accion.id, comando)
|
||||||
|
tarea.accion = accion
|
||||||
|
tarea.argumento = argumento
|
||||||
|
self._avisa(tarea)
|
||||||
|
|
||||||
|
if not comando:
|
||||||
|
# Acciones que solo hablan (no tienen comando): nacen terminadas.
|
||||||
|
self.termina(tarea.id, HECHA, "")
|
||||||
|
return tarea
|
||||||
|
|
||||||
|
orden = ["bash", "-lc", comando]
|
||||||
|
if accion.host:
|
||||||
|
orden = self._prefijo_host + orden
|
||||||
|
|
||||||
|
threading.Thread(target=self._corre, args=(tarea, orden),
|
||||||
|
daemon=True).start()
|
||||||
|
return tarea
|
||||||
|
|
||||||
|
def adopta(self, titulo, accion_id="") -> Tarea:
|
||||||
|
"""Una tarjeta para trabajo que se ejecuta fuera del gestor.
|
||||||
|
|
||||||
|
Lo que pasa por el dialogo de root corre por su camino (GestorRoot, con
|
||||||
|
su contraseña), pero se merece tarjeta igual: si no, pedir algo con
|
||||||
|
sudo hace desaparecer la señal de que hay algo en marcha.
|
||||||
|
"""
|
||||||
|
tarea = self._nueva(titulo, accion_id, "")
|
||||||
|
self._avisa(tarea)
|
||||||
|
return tarea
|
||||||
|
|
||||||
|
def _corre(self, tarea, orden):
|
||||||
|
try:
|
||||||
|
# start_new_session: el proceso estrena grupo, para poder matar el
|
||||||
|
# arbol entero y no solo el bash de arriba.
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
orden, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
text=True, errors="replace", start_new_session=True)
|
||||||
|
except OSError as e:
|
||||||
|
self.termina(tarea.id, FALLO, f"No he podido ejecutarlo: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
tarea._proc = proc
|
||||||
|
limite = threading.Timer(LIMITE_LARGO, self._caduca, args=(tarea.id,))
|
||||||
|
limite.daemon = True
|
||||||
|
limite.start()
|
||||||
|
|
||||||
|
ultimo_aviso = 0.0
|
||||||
|
acumulado = []
|
||||||
|
try:
|
||||||
|
for linea in proc.stdout:
|
||||||
|
linea = linea.rstrip("\n")
|
||||||
|
if not linea.strip():
|
||||||
|
continue
|
||||||
|
acumulado.append(linea)
|
||||||
|
tarea.lineas = acumulado[-LINEAS_VISTA:]
|
||||||
|
ahora = time.monotonic()
|
||||||
|
if ahora - ultimo_aviso >= CADENCIA_AVISO:
|
||||||
|
ultimo_aviso = ahora
|
||||||
|
self._avisa(tarea)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"JARVIS: leyendo la salida de {tarea.id}: {e}")
|
||||||
|
finally:
|
||||||
|
limite.cancel()
|
||||||
|
|
||||||
|
codigo = proc.wait()
|
||||||
|
salida = "\n".join(acumulado).strip()
|
||||||
|
|
||||||
|
if tarea.estado == PARADA: # la paro alguien: no se toca
|
||||||
|
self._avisa(tarea)
|
||||||
|
return
|
||||||
|
if tarea.estado == CADUCADA:
|
||||||
|
self._avisa(tarea)
|
||||||
|
return
|
||||||
|
self.termina(tarea.id, HECHA if codigo == 0 else FALLO, salida, codigo)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- terminar
|
||||||
|
|
||||||
|
def termina(self, ident, estado, salida="", codigo=None):
|
||||||
|
tarea = self.get(ident)
|
||||||
|
if tarea is None:
|
||||||
|
return
|
||||||
|
tarea.estado = estado
|
||||||
|
tarea.fin = time.monotonic()
|
||||||
|
tarea.codigo = codigo
|
||||||
|
if salida:
|
||||||
|
tarea.salida = salida[:SALIDA_MAXIMA]
|
||||||
|
if not tarea.lineas:
|
||||||
|
tarea.lineas = salida.splitlines()[-LINEAS_VISTA:]
|
||||||
|
else:
|
||||||
|
tarea.salida = "\n".join(tarea.lineas)[:SALIDA_MAXIMA]
|
||||||
|
self._avisa(tarea)
|
||||||
|
|
||||||
|
def para(self, ident) -> bool:
|
||||||
|
"""Corta la tarea. Primero por las buenas, luego a la fuerza."""
|
||||||
|
tarea = self.get(ident)
|
||||||
|
if tarea is None or not tarea.viva:
|
||||||
|
return False
|
||||||
|
|
||||||
|
tarea.estado = PARADA
|
||||||
|
proc = tarea._proc
|
||||||
|
if proc is not None and proc.poll() is None:
|
||||||
|
_mata_grupo(proc)
|
||||||
|
tarea.fin = time.monotonic()
|
||||||
|
self._avisa(tarea)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _caduca(self, ident):
|
||||||
|
tarea = self.get(ident)
|
||||||
|
if tarea is None or not tarea.viva:
|
||||||
|
return
|
||||||
|
tarea.estado = CADUCADA
|
||||||
|
if tarea._proc is not None and tarea._proc.poll() is None:
|
||||||
|
_mata_grupo(tarea._proc)
|
||||||
|
tarea.fin = time.monotonic()
|
||||||
|
self._avisa(tarea)
|
||||||
|
|
||||||
|
def cierra(self, ident) -> bool:
|
||||||
|
"""Quita la tarjeta. Si seguia viva, la para antes: cerrar es cerrar."""
|
||||||
|
tarea = self.get(ident)
|
||||||
|
if tarea is None:
|
||||||
|
return False
|
||||||
|
if tarea.viva:
|
||||||
|
self.para(ident)
|
||||||
|
with self._cerrojo:
|
||||||
|
self._tareas.pop(ident, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def para_todas(self):
|
||||||
|
for tarea in self.activas():
|
||||||
|
self.para(tarea.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _con_watch_bus(prefijo) -> list:
|
||||||
|
"""Que lo lanzado fuera del sandbox no sobreviva a la aplicacion.
|
||||||
|
|
||||||
|
Una tarea larga corre en el host por flatpak-spawn, y ahi el proceso ya no
|
||||||
|
es hijo de nadie que se entere de que la app se ha cerrado: un escaneo
|
||||||
|
lanzado y olvidado seguiria comiendo maquina despues de cerrar la ventana,
|
||||||
|
sin ventana desde la que pararlo. `--watch-bus` ata su vida a la conexion
|
||||||
|
de bus de quien lo lanzo, que es justo lo que se quiere.
|
||||||
|
"""
|
||||||
|
prefijo = list(prefijo)
|
||||||
|
if not prefijo:
|
||||||
|
return prefijo
|
||||||
|
if any("flatpak-spawn" in p for p in prefijo) and "--watch-bus" not in prefijo:
|
||||||
|
# detras del propio flatpak-spawn, antes de sus argumentos
|
||||||
|
for i, p in enumerate(prefijo):
|
||||||
|
if "flatpak-spawn" in p:
|
||||||
|
prefijo.insert(i + 1, "--watch-bus")
|
||||||
|
break
|
||||||
|
return prefijo
|
||||||
|
|
||||||
|
|
||||||
|
def _mata_grupo(proc):
|
||||||
|
"""SIGTERM al grupo, y SIGKILL a los tres segundos si sigue ahi.
|
||||||
|
|
||||||
|
Se da margen a proposito: un `wget` a medias cierra el fichero y un `nmap`
|
||||||
|
escupe lo que llevaba. Matar a lo bruto de entrada deja restos.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
grupo = os.getpgid(proc.pid)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.killpg(grupo, signal.SIGTERM)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
def remata():
|
||||||
|
if proc.poll() is None:
|
||||||
|
try:
|
||||||
|
os.killpg(grupo, signal.SIGKILL)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
t = threading.Timer(3.0, remata)
|
||||||
|
t.daemon = True
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _titulo_de(accion, argumento) -> str:
|
||||||
|
"""El rotulo de la tarjeta: corto, y con el argumento si lo hay.
|
||||||
|
|
||||||
|
El id de la accion con guiones bajos ("buscar_fichero") es de programador;
|
||||||
|
en la tarjeta se lee "buscar fichero", y con lo que se pidio detras, que es
|
||||||
|
lo que distingue dos busquedas a la vez.
|
||||||
|
"""
|
||||||
|
base = (accion.id or "accion").replace("_", " ")
|
||||||
|
if argumento:
|
||||||
|
arg = argumento.strip()
|
||||||
|
if len(arg) > 22:
|
||||||
|
arg = arg[:21] + "…"
|
||||||
|
return f"{base}: {arg}"
|
||||||
|
return base
|
||||||
182
nucleo/mejora.py
Executable file
182
nucleo/mejora.py
Executable file
|
|
@ -0,0 +1,182 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Que ha aprendido JARVIS del uso, y que conviene cambiar.
|
||||||
|
|
||||||
|
./mejora.py que hay en el diario
|
||||||
|
./mejora.py --propone que acciones o frases faltan
|
||||||
|
./mejora.py --mide cuanto acierta ahora mismo
|
||||||
|
|
||||||
|
## En que se diferencia de la mejora nocturna que se retiro
|
||||||
|
|
||||||
|
La de antes corria sola de madrugada, se modificaba el codigo y se commiteaba.
|
||||||
|
Nueve horas, 39 rondas, cero commits utiles. Fallo por tres cosas, y las tres
|
||||||
|
estan corregidas aqui:
|
||||||
|
|
||||||
|
1. **Minaba ruido.** Su fuente era un registro donde el 60 % era JARVIS
|
||||||
|
oyendose a si mismo y alucinaciones de Whisper. Ahora la guarda del eco y
|
||||||
|
el filtro de ruido limpian antes, y el diario apunta la VIA de cada frase,
|
||||||
|
asi que el ruido esta separado y etiquetado en vez de mezclado.
|
||||||
|
|
||||||
|
2. **Su medida tenia mas ruido que su mejora.** Generaba audio nuevo cada
|
||||||
|
ronda: ±1,9 puntos de ruido contra +1,45 de mejora acumulada, o sea que
|
||||||
|
"¿esto mejora o empeora?" se decidia a cara o cruz. Ahora se mide contra
|
||||||
|
`datos/banco.json`, que es fijo y determinista.
|
||||||
|
|
||||||
|
3. **Actuaba sola.** Un bucle que se reescribe y se aprueba a si mismo acaba
|
||||||
|
pasando siempre. Esto PROPONE y para: lo que se aplica lo decide una
|
||||||
|
persona mirandolo.
|
||||||
|
|
||||||
|
Y una cuarta, de fondo: aquella tocaba el codigo. Esta solo mira el catalogo,
|
||||||
|
que es lo unico que se puede validar de verdad con el banco.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, AQUI)
|
||||||
|
|
||||||
|
from manos import Manos # noqa: E402
|
||||||
|
from manos.acciones import normaliza, parecido # noqa: E402
|
||||||
|
from manos.diario import lee # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def color(t, c):
|
||||||
|
return f"\033[{c}m{t}\033[0m" if sys.stdout.isatty() else t
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ mirar
|
||||||
|
|
||||||
|
def resumen(lineas):
|
||||||
|
if not lineas:
|
||||||
|
print(" el diario esta vacio: usa JARVIS un rato y vuelve")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
vias = collections.Counter(l.get("via", "?") for l in lineas)
|
||||||
|
tot = sum(vias.values())
|
||||||
|
print(f"\n {tot} frases desde {lineas[0]['t'][:10]}\n")
|
||||||
|
print(f" {'via':14s} {'n':>5s} {'%':>7s}")
|
||||||
|
print(" " + "-" * 32)
|
||||||
|
ORDEN = ["catalogo", "cerebro", "respuesta", "ruido", "sin-nombre",
|
||||||
|
"bloqueado", "sin-cerebro"]
|
||||||
|
for v in ORDEN + [k for k in vias if k not in ORDEN]:
|
||||||
|
if v in vias:
|
||||||
|
print(f" {v:14s} {vias[v]:5d} {100*vias[v]/tot:6.1f} %")
|
||||||
|
|
||||||
|
atendidas = vias["catalogo"] + vias["cerebro"]
|
||||||
|
if atendidas:
|
||||||
|
print(f"\n de lo ATENDIDO ({atendidas}), el catalogo cogio "
|
||||||
|
f"{100*vias['catalogo']/atendidas:.0f} %")
|
||||||
|
segs = [l.get("seg", 0) for l in lineas if l.get("via") == "catalogo"]
|
||||||
|
segc = [l.get("seg", 0) for l in lineas if l.get("via") == "cerebro"]
|
||||||
|
if segs:
|
||||||
|
print(f" catalogo: {sum(segs)/len(segs):.2f} s de media")
|
||||||
|
if segc:
|
||||||
|
print(f" cerebro: {sum(segc)/len(segc):.1f} s de media")
|
||||||
|
ahorro = (sum(segc)/len(segc) - (sum(segs)/len(segs) if segs else 0))
|
||||||
|
print(f"\n cada accion que se añada al catalogo ahorra ~{ahorro:.0f} s")
|
||||||
|
|
||||||
|
top = collections.Counter(l["accion"] for l in lineas
|
||||||
|
if l.get("via") == "catalogo" and l.get("accion"))
|
||||||
|
if top:
|
||||||
|
print("\n lo que mas usas:")
|
||||||
|
for a, n in top.most_common(8):
|
||||||
|
print(f" {n:4d} {a}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- proponer
|
||||||
|
|
||||||
|
def propone(lineas, manos):
|
||||||
|
"""Lo que fue al cerebro y podria ser una accion del catalogo."""
|
||||||
|
# "sin-cerebro" cuenta igual: es lo mismo —una frase que el catalogo no
|
||||||
|
# cubrio— y solo se diferencia en que no habia modelo conectado para
|
||||||
|
# atenderla. El hueco del catalogo es el mismo.
|
||||||
|
alCerebro = [l for l in lineas if l.get("via") in ("cerebro", "sin-cerebro")]
|
||||||
|
if not alCerebro:
|
||||||
|
print("\n nada ha ido al cerebro todavia: no hay hueco que rellenar")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Agrupar lo parecido: si has pedido lo mismo de tres formas, es UNA accion
|
||||||
|
# que falta, no tres. Y que lo hayas repetido es justo la señal de que
|
||||||
|
# importa.
|
||||||
|
grupos = []
|
||||||
|
for l in alCerebro:
|
||||||
|
t = normaliza(l["oido"])
|
||||||
|
if len(t.split()) < 2:
|
||||||
|
continue
|
||||||
|
for g in grupos:
|
||||||
|
if parecido(t, g["patron"]) > 0.62:
|
||||||
|
g["frases"].append(l["oido"])
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
grupos.append({"patron": t, "frases": [l["oido"]],
|
||||||
|
"dicho": l.get("dicho", "")})
|
||||||
|
|
||||||
|
grupos.sort(key=lambda g: -len(g["frases"]))
|
||||||
|
print(f"\n {len(alCerebro)} frases fueron al cerebro, en {len(grupos)} grupos.")
|
||||||
|
print(" Las que mas se repiten son las acciones que faltan:\n")
|
||||||
|
|
||||||
|
for g in grupos[:12]:
|
||||||
|
n = len(g["frases"])
|
||||||
|
marca = color(f"{n:3d} veces", "33" if n > 1 else "90")
|
||||||
|
print(f" {marca} {g['frases'][0][:64]}")
|
||||||
|
for f in g["frases"][1:4]:
|
||||||
|
print(f" {color('tambien: ' + f[:56], '90')}")
|
||||||
|
if g["dicho"]:
|
||||||
|
print(f" {color('contesto: ' + g['dicho'][:56], '90')}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
repetidas = [g for g in grupos if len(g["frases"]) > 1]
|
||||||
|
print(f" {len(repetidas)} se han pedido mas de una vez: por ahi empezaria.\n")
|
||||||
|
print(color(" Esto NO cambia nada. Para añadir una accion se edita\n"
|
||||||
|
" datos/_generar.py —que es la FUENTE— y se regenera.\n", "90"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ medir
|
||||||
|
|
||||||
|
def mide(manos):
|
||||||
|
"""El acierto del emparejador contra el banco fijo."""
|
||||||
|
import json
|
||||||
|
banco = os.path.join(AQUI, "datos", "banco.json")
|
||||||
|
try:
|
||||||
|
with open(banco, encoding="utf-8") as f:
|
||||||
|
casos = json.load(f)["casos"]
|
||||||
|
except OSError:
|
||||||
|
print(" no hay banco: pruebas/banco.py --crear")
|
||||||
|
return 1
|
||||||
|
bien = otra = 0
|
||||||
|
for c in casos:
|
||||||
|
a, _ = manos.busca(c["oido"])
|
||||||
|
if a is None:
|
||||||
|
continue
|
||||||
|
if a.id == c["id"]:
|
||||||
|
bien += 1
|
||||||
|
else:
|
||||||
|
otra += 1
|
||||||
|
print(f"\n acierto {100*bien/len(casos):.1f} % sobre {len(casos)} frases")
|
||||||
|
print(f" ordenes que acaban en la accion equivocada: {otra}")
|
||||||
|
print(color("\n Este numero es comparable entre dias: el banco esta grabado\n"
|
||||||
|
" una vez y no cambia. Era lo que le faltaba a la mejora\n"
|
||||||
|
" nocturna, cuyo ruido de medida era mayor que su mejora.\n", "90"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = argparse.ArgumentParser(description="Que ha aprendido JARVIS del uso")
|
||||||
|
p.add_argument("--propone", action="store_true")
|
||||||
|
p.add_argument("--mide", action="store_true")
|
||||||
|
a = p.parse_args()
|
||||||
|
|
||||||
|
manos = Manos()
|
||||||
|
lineas = lee()
|
||||||
|
if a.mide:
|
||||||
|
return mide(manos)
|
||||||
|
if a.propone:
|
||||||
|
return propone(lineas, manos)
|
||||||
|
return resumen(lineas)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
0
nucleo/oido/__init__.py
Normal file
0
nucleo/oido/__init__.py
Normal file
188
nucleo/oido/captura.py
Normal file
188
nucleo/oido/captura.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
"""Escuchar el microfono y recortar lo que es habla.
|
||||||
|
|
||||||
|
## Por que pw-record y no pyaudio
|
||||||
|
|
||||||
|
Newelle usa pyaudio, que necesita portaudio compilado. En esta maquina no esta,
|
||||||
|
asi que instalarlo pediria sudo. `pw-record` y `parec` ya vienen con PipeWire y
|
||||||
|
sirven igual: se lanzan como subproceso y se lee su salida estandar.
|
||||||
|
|
||||||
|
Y hay una ventaja que no es casual: la fuente se elige con un argumento. Cuando
|
||||||
|
se ponga el cancelador de eco de PipeWire, apuntar ahi es cambiar una cadena, no
|
||||||
|
tocar codigo de audio.
|
||||||
|
|
||||||
|
## La guarda contra el eco
|
||||||
|
|
||||||
|
Esta es la razon de ser de este fichero. En Newelle, el 21 % largo de lo que
|
||||||
|
JARVIS oye y no entiende es su propia voz. Tiene mecanismo para evitarlo pero
|
||||||
|
con tres agujeros, y aqui se cierran los tres:
|
||||||
|
|
||||||
|
1. **Se consulta a la boca en cada trozo**, no una bandera que alguien actualiza
|
||||||
|
desde otro hilo. Si `boca.hablando` es cierto, ese audio se tira sin mirar.
|
||||||
|
2. **La cola de 350 ms** vive en la boca (`COLA_MS`): cuando el reproductor
|
||||||
|
termina, el sonido sigue llegando al microfono un rato.
|
||||||
|
3. **El prebufer se vacia** al salir de "hablando". Newelle guarda 1 s circular
|
||||||
|
y lo antepone al detectar voz; ese segundo era justo el final de la frase de
|
||||||
|
JARVIS, asi que se colaba aunque la guarda funcionase.
|
||||||
|
"""
|
||||||
|
import collections
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import wave
|
||||||
|
|
||||||
|
FRECUENCIA = 16000 # lo que quiere whisper.cpp
|
||||||
|
CANALES = 1
|
||||||
|
ANCHO = 2 # s16
|
||||||
|
TROZO = 512 # muestras: lo que pide Silero de una vez
|
||||||
|
|
||||||
|
# Cuanto silencio hace falta para dar la frase por terminada. Muy corto corta a
|
||||||
|
# quien piensa a media frase; muy largo hace que JARVIS parezca lento.
|
||||||
|
SILENCIO_PARA_CERRAR = 0.8
|
||||||
|
# Cuanto se guarda de ANTES de detectar voz. La primera silaba siempre llega
|
||||||
|
# antes de que el detector se entere.
|
||||||
|
PREBUFER_S = 0.5
|
||||||
|
# Frases mas cortas que esto no son ordenes, son ruidos.
|
||||||
|
MINIMO_S = 0.35
|
||||||
|
# Y mas largas que esto es que el detector se ha quedado enganchado.
|
||||||
|
MAXIMO_S = 25.0
|
||||||
|
|
||||||
|
|
||||||
|
def _grabador(fuente=None):
|
||||||
|
"""La orden para capturar crudo a la salida estandar."""
|
||||||
|
if fuente:
|
||||||
|
pw = ["pw-record", "--target", fuente]
|
||||||
|
else:
|
||||||
|
pw = ["pw-record"]
|
||||||
|
return pw + ["--rate", str(FRECUENCIA), "--channels", str(CANALES),
|
||||||
|
"--format", "s16", "-"]
|
||||||
|
|
||||||
|
|
||||||
|
class Captura:
|
||||||
|
"""Trozos de microfono -> frases sueltas en WAV."""
|
||||||
|
|
||||||
|
def __init__(self, boca=None, fuente=None, agresividad=0.5):
|
||||||
|
self.boca = boca # para saber si JARVIS esta hablando
|
||||||
|
self.fuente = fuente
|
||||||
|
self.agresividad = agresividad
|
||||||
|
self._proc = None
|
||||||
|
self._vad = None
|
||||||
|
self._energia = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ el detector
|
||||||
|
|
||||||
|
def _detector(self):
|
||||||
|
if self._vad is not None or self._energia is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from pysilero_vad import SileroVoiceActivityDetector
|
||||||
|
self._vad = SileroVoiceActivityDetector()
|
||||||
|
except Exception:
|
||||||
|
# Respaldo por energia: peor, pero mejor que no escuchar. Se avisa,
|
||||||
|
# porque la diferencia se nota y conviene saber en cual se esta.
|
||||||
|
print("oido: sin Silero, usando deteccion por energia")
|
||||||
|
self._energia = True
|
||||||
|
|
||||||
|
def _hay_voz(self, trozo: bytes) -> bool:
|
||||||
|
if self._vad is not None:
|
||||||
|
try:
|
||||||
|
return self._vad(trozo) >= self.agresividad
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
muestras = struct.unpack(f"{len(trozo) // 2}h", trozo)
|
||||||
|
if not muestras:
|
||||||
|
return False
|
||||||
|
media = sum(m * m for m in muestras) / len(muestras)
|
||||||
|
return (media ** 0.5) / 32768.0 > 0.015
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- escuchar
|
||||||
|
|
||||||
|
def frases(self, para_cuando=None):
|
||||||
|
"""Generador: devuelve la ruta de un WAV por cada frase oida.
|
||||||
|
|
||||||
|
El WAV es temporal y de quien lo recibe: se borra cuando quiera.
|
||||||
|
"""
|
||||||
|
self._detector()
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
_grabador(self.fuente), stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||||
|
|
||||||
|
bytes_trozo = TROZO * ANCHO
|
||||||
|
por_segundo = FRECUENCIA / TROZO
|
||||||
|
prebufer = collections.deque(maxlen=max(1, int(PREBUFER_S * por_segundo)))
|
||||||
|
cerrar_tras = int(SILENCIO_PARA_CERRAR * por_segundo)
|
||||||
|
tope = int(MAXIMO_S * por_segundo)
|
||||||
|
|
||||||
|
frase, callados, hablando = [], 0, False
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if para_cuando and para_cuando():
|
||||||
|
break
|
||||||
|
trozo = self._proc.stdout.read(bytes_trozo)
|
||||||
|
if not trozo or len(trozo) < bytes_trozo:
|
||||||
|
break
|
||||||
|
|
||||||
|
# --- la guarda: si JARVIS habla, esto es su propia voz -------
|
||||||
|
if self.boca is not None and self.boca.hablando:
|
||||||
|
# Tirar tambien lo acumulado: si estaba a media frase cuando
|
||||||
|
# JARVIS empezo a hablar, lo que quede lleva su voz encima.
|
||||||
|
prebufer.clear()
|
||||||
|
frase, callados, hablando = [], 0, False
|
||||||
|
continue
|
||||||
|
|
||||||
|
prebufer.append(trozo)
|
||||||
|
voz = self._hay_voz(trozo)
|
||||||
|
|
||||||
|
if voz and not hablando:
|
||||||
|
hablando = True
|
||||||
|
frase = list(prebufer) # con lo de antes de detectarla
|
||||||
|
callados = 0
|
||||||
|
elif hablando:
|
||||||
|
frase.append(trozo)
|
||||||
|
callados = 0 if voz else callados + 1
|
||||||
|
|
||||||
|
if hablando and (callados >= cerrar_tras or len(frase) >= tope):
|
||||||
|
dur = len(frase) * TROZO / FRECUENCIA
|
||||||
|
ruta = self._a_wav(frase) if dur >= MINIMO_S else None
|
||||||
|
frase, callados, hablando = [], 0, False
|
||||||
|
prebufer.clear()
|
||||||
|
if ruta:
|
||||||
|
yield ruta
|
||||||
|
finally:
|
||||||
|
self.para()
|
||||||
|
|
||||||
|
def _a_wav(self, trozos) -> str:
|
||||||
|
fd, ruta = tempfile.mkstemp(suffix=".wav", prefix="jarvis-oido-")
|
||||||
|
os.close(fd)
|
||||||
|
with wave.open(ruta, "wb") as w:
|
||||||
|
w.setnchannels(CANALES)
|
||||||
|
w.setsampwidth(ANCHO)
|
||||||
|
w.setframerate(FRECUENCIA)
|
||||||
|
w.writeframes(b"".join(trozos))
|
||||||
|
return ruta
|
||||||
|
|
||||||
|
def para(self):
|
||||||
|
if not self._proc:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
|
||||||
|
self._proc.wait(timeout=3)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
|
||||||
|
def fuentes() -> list:
|
||||||
|
"""Las entradas que hay, para poder elegir la del cancelador de eco."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["pactl", "list", "short", "sources"],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return []
|
||||||
|
salida = []
|
||||||
|
for linea in r.stdout.splitlines():
|
||||||
|
campos = linea.split("\t")
|
||||||
|
if len(campos) > 1 and not campos[1].endswith(".monitor"):
|
||||||
|
salida.append(campos[1])
|
||||||
|
return salida
|
||||||
143
nucleo/oido/despierta.py
Normal file
143
nucleo/oido/despierta.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
"""La palabra de activacion: "JARVIS".
|
||||||
|
|
||||||
|
Sin esto, el asistente obedece a cualquier cosa que se diga en la habitacion —y
|
||||||
|
a la tele—. Con esto, solo escucha de verdad durante un rato despues de oir su
|
||||||
|
nombre.
|
||||||
|
|
||||||
|
## Por que sobre el texto y no sobre el audio
|
||||||
|
|
||||||
|
Lo obvio seria un detector como openwakeword, que mira la forma de onda antes de
|
||||||
|
transcribir. Aqui se hace despues, sobre lo que Whisper ya escribio, y es a
|
||||||
|
proposito:
|
||||||
|
|
||||||
|
- **Ya tenemos la transcripcion.** El audio pasa por Whisper de todas formas
|
||||||
|
porque la captura la dispara el detector de voz; no hay ahorro que ganar.
|
||||||
|
- **Un modelo menos.** openwakeword son mas dependencias, otro modelo en
|
||||||
|
memoria y otro afinado que mantener.
|
||||||
|
- **Y sobre todo: Whisper oye "JARVIS" de mil maneras.** Medido en el registro
|
||||||
|
de uso real, el nombre sale como "Jarvis", "Yarvis", "Charles", "Chavez",
|
||||||
|
"Harvest"... Sobre el texto eso se arregla comparando como SUENA, que es lo
|
||||||
|
que ya hace `manos/fonetica.py` para el catalogo. Sobre la onda habria que
|
||||||
|
reentrenar.
|
||||||
|
|
||||||
|
Lo que se pierde: la transcripcion corre siempre, tambien cuando nadie ha
|
||||||
|
llamado. En una maquina donde Whisper va en la grafica y tarda dos segundos, es
|
||||||
|
un coste que ya estabamos pagando.
|
||||||
|
|
||||||
|
## Como se comporta
|
||||||
|
|
||||||
|
Se llama una vez y se queda despierto un rato, para poder encadenar ordenes sin
|
||||||
|
repetir el nombre en cada frase. Y si la frase que trae el nombre lleva ademas
|
||||||
|
la orden —"JARVIS, cuanto espacio queda"— se atiende esa misma, sin obligar a
|
||||||
|
decirlo dos veces.
|
||||||
|
"""
|
||||||
|
import difflib
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Absolutos, como el resto del nucleo: jarvis.py mete su carpeta en sys.path y
|
||||||
|
# todos los paquetes son de primer nivel. Con `..manos` esto solo se podria
|
||||||
|
# importar desde fuera, y las pruebas lo cargan suelto.
|
||||||
|
from manos.acciones import normaliza
|
||||||
|
from manos.fonetica import fonetica
|
||||||
|
|
||||||
|
NOMBRE = "jarvis"
|
||||||
|
|
||||||
|
# Cuanto sigue escuchando tras oir su nombre. Corto obliga a repetirlo en cada
|
||||||
|
# frase; largo devuelve al problema de origen, que obedezca a la tele.
|
||||||
|
DESPIERTO_S = 25.0
|
||||||
|
|
||||||
|
# Lo que Whisper escribe cuando oye "JARVIS". Salen del registro real de uso,
|
||||||
|
# no de imaginarlas.
|
||||||
|
PARECIDAS = (
|
||||||
|
"jarvis", "yarvis", "charvis", "jarbis", "yarbis", "harvis", "jervis",
|
||||||
|
"jarvi", "yarvi", "javis", "jarves", "charles", "chavez", "harvest",
|
||||||
|
"jarvix", "sharvis", "arvis",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Se compara palabra a palabra, asi que el umbral puede ser alto sin perder
|
||||||
|
# aciertos: "yarvis" contra "jarvis" da 0,83 por letra y sube por sonido.
|
||||||
|
UMBRAL = 0.80
|
||||||
|
|
||||||
|
_LIMPIA = re.compile(r"^[\s,.:;¡!¿?]+|[\s,.:;¡!¿?]+$")
|
||||||
|
|
||||||
|
# Lo que suele acompañar al nombre sin ser una orden. Si al quitar el nombre
|
||||||
|
# solo queda esto, es que llamaron y ya: "oye jarvis" no es "haz oye".
|
||||||
|
LLAMADAS = {"oye", "eh", "hey", "ey", "hola", "escucha", "atiende", "perdona",
|
||||||
|
"por favor", "porfa", "venga", "a ver"}
|
||||||
|
|
||||||
|
|
||||||
|
def _suena_al_nombre(palabra: str) -> bool:
|
||||||
|
p = _LIMPIA.sub("", palabra)
|
||||||
|
if len(p) < 4:
|
||||||
|
return False
|
||||||
|
if p in PARECIDAS:
|
||||||
|
return True
|
||||||
|
for candidata in (NOMBRE,) + PARECIDAS[:6]:
|
||||||
|
if difflib.SequenceMatcher(None, p, candidata).ratio() >= UMBRAL:
|
||||||
|
return True
|
||||||
|
if difflib.SequenceMatcher(None, fonetica(p), fonetica(candidata)).ratio() >= UMBRAL:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parte_el_nombre(texto: str):
|
||||||
|
"""(lo llamaron, lo que queda de la frase sin el nombre).
|
||||||
|
|
||||||
|
Se devuelve el resto porque "JARVIS, cuanto espacio queda" tiene que
|
||||||
|
ejecutarse en la misma frase: obligar a decir el nombre y luego la orden
|
||||||
|
aparte es exactamente lo que hace que un asistente canse.
|
||||||
|
"""
|
||||||
|
palabras = normaliza(texto).split()
|
||||||
|
if not palabras:
|
||||||
|
return False, ""
|
||||||
|
for i, p in enumerate(palabras):
|
||||||
|
if _suena_al_nombre(p):
|
||||||
|
sobra = [q for q in palabras[:i] + palabras[i + 1:]
|
||||||
|
if q not in LLAMADAS]
|
||||||
|
return True, " ".join(sobra).strip()
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
|
||||||
|
class Centinela:
|
||||||
|
"""Decide si una frase hay que atenderla o dejarla pasar."""
|
||||||
|
|
||||||
|
def __init__(self, activo=True, ventana=DESPIERTO_S):
|
||||||
|
self.activo = activo
|
||||||
|
self.ventana = ventana
|
||||||
|
self._hasta = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def despierto(self) -> bool:
|
||||||
|
return not self.activo or time.monotonic() < self._hasta
|
||||||
|
|
||||||
|
def despierta(self):
|
||||||
|
self._hasta = time.monotonic() + self.ventana
|
||||||
|
|
||||||
|
def duerme(self):
|
||||||
|
self._hasta = 0.0
|
||||||
|
|
||||||
|
def filtra(self, texto: str):
|
||||||
|
"""(hay que atenderla, texto a atender, acaba de despertar).
|
||||||
|
|
||||||
|
- Sin palabra de activacion configurada: se atiende todo.
|
||||||
|
- Dormido y sin nombre: se ignora.
|
||||||
|
- Dormido y con nombre: despierta. Si la frase traia ademas la orden,
|
||||||
|
se devuelve; si solo traia el nombre, se contesta con un acuse.
|
||||||
|
- Despierto: se atiende y se renueva la ventana, para poder encadenar.
|
||||||
|
"""
|
||||||
|
if not self.activo:
|
||||||
|
return True, texto, False
|
||||||
|
|
||||||
|
llamado, resto = parte_el_nombre(texto)
|
||||||
|
|
||||||
|
if llamado:
|
||||||
|
recien = not self.despierto
|
||||||
|
self.despierta()
|
||||||
|
return (True, resto, recien) if resto else (False, "", recien)
|
||||||
|
|
||||||
|
if self.despierto:
|
||||||
|
self.despierta() # encadenar sin repetir el nombre
|
||||||
|
return True, texto, False
|
||||||
|
|
||||||
|
return False, "", False
|
||||||
343
nucleo/oido/whisper.py
Normal file
343
nucleo/oido/whisper.py
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
"""Transcribir: arrancar whisper-server y mandarle los WAV.
|
||||||
|
|
||||||
|
Newelle dedicaba 804 lineas a esto porque tenia que descargar modelos, elegir
|
||||||
|
entre CLI y servidor, salir del sandbox y ofrecerlo todo en una pantalla de
|
||||||
|
ajustes. Nosotros ya tenemos el binario compilado con CUDA y el modelo en el
|
||||||
|
disco: queda arrancar un proceso y hacer un POST.
|
||||||
|
|
||||||
|
## Los cuatro flags que Newelle no pasaba
|
||||||
|
|
||||||
|
Esto es lo importante de este fichero, y sale de leer el codigo de upstream:
|
||||||
|
`whispercpp_handler.py` arranca el servidor con `-m -t --host --port -l` y nada
|
||||||
|
mas. Los defectos de `whisper-server` no son los mismos que los de
|
||||||
|
`whisper-cli`, asi que se estaba transcribiendo peor de lo que la propia
|
||||||
|
whisper.cpp hace por defecto:
|
||||||
|
|
||||||
|
--beam-size 5 --best-of 5 whisper-server trae beam-size -1, que es
|
||||||
|
decodificacion GREEDY. El CLI usa 5. Es la causa
|
||||||
|
mas probable de transcripciones como "esta con el
|
||||||
|
tanutusto" en un audio limpio.
|
||||||
|
-sns suprime los tokens que no son habla. Es
|
||||||
|
literalmente el flag que evita los "[Musica]" y
|
||||||
|
"(aplausos)", que son el 16,2 % de las frases que
|
||||||
|
JARVIS no entendio.
|
||||||
|
--prompt + --carry-initial-prompt
|
||||||
|
vocabulario del dominio. MEDIDO Y DESCARTADO por
|
||||||
|
defecto: ver abajo.
|
||||||
|
|
||||||
|
El prompt se genera DESDE el catalogo (`prompt_dominio`), no escrito a mano, o
|
||||||
|
se queda viejo en cuanto se añada una accion.
|
||||||
|
|
||||||
|
## Lo que dijo la medida, que no fue lo que yo esperaba
|
||||||
|
|
||||||
|
40 frases del catalogo dichas por Piper con tres voces, transcritas con cada
|
||||||
|
configuracion y contando cuantas llegan a su accion:
|
||||||
|
|
||||||
|
como lo hace Newelle (greedy, sin sns) 50,0 % 0,98 s
|
||||||
|
+ beam 5 55,0 % 1,01 s
|
||||||
|
+ beam 5 + sns 55,0 % 1,01 s
|
||||||
|
+ beam 5 + sns + prompt 52,5 % 1,12 s
|
||||||
|
|
||||||
|
Tres lecturas, por orden de importancia:
|
||||||
|
|
||||||
|
1. **El prompt EMPEORA.** Era el riesgo anotado —Whisper empieza a "oir" las
|
||||||
|
palabras que le has puesto donde no las hay— y se cumplio. Va apagado por
|
||||||
|
defecto; se deja el codigo porque con un prompt mas corto podria ganar, pero
|
||||||
|
hay que demostrarlo antes de encenderlo.
|
||||||
|
2. **`-sns` no se puede medir con este banco.** El audio de Piper no tiene
|
||||||
|
silencios ni musica, asi que no hay nada que suprimir. Se deja puesto porque
|
||||||
|
no cuesta nada y ataca un problema real que solo se ve con microfono: el
|
||||||
|
16,2 % de "[Musica]" del registro de uso.
|
||||||
|
3. **El beam sube 5 puntos y cuesta 30 ms.** Es lo unico claramente a favor, y
|
||||||
|
aun asi con 40 muestras esos 5 puntos son dos frases: esta en el limite del
|
||||||
|
ruido.
|
||||||
|
"""
|
||||||
|
import atexit
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
BINARIO = os.path.join(RAIZ, "voz", "whisper-cuda", "whisper.cpp", "build", "bin",
|
||||||
|
"whisper-server")
|
||||||
|
MODELOS = os.path.expanduser(
|
||||||
|
"~/.var/app/io.github.qwersyk.Newelle/config/models/whisper/whisper.cpp/models")
|
||||||
|
|
||||||
|
# Las libs de ggml estan al lado del binario y CUDA en /usr/local. Fuera del
|
||||||
|
# flatpak esto es una linea; dentro era media pagina de flatpak-spawn.
|
||||||
|
LIBS = "/usr/local/cuda/lib64"
|
||||||
|
|
||||||
|
# Un prompt largo no ayuda: Whisper empieza a "oir" esas palabras donde no las
|
||||||
|
# hay. Se corta.
|
||||||
|
TOPE_PROMPT = 220
|
||||||
|
|
||||||
|
# El prompt MINIMO, y el unico que va puesto por defecto: solo el nombre.
|
||||||
|
#
|
||||||
|
# Sin el, Whisper no escribe "JARVIS" nunca: se lo come y lo sustituye por la
|
||||||
|
# palabra española que mejor le cuadre acusticamente. Medido diciendoselo con
|
||||||
|
# Piper:
|
||||||
|
#
|
||||||
|
# "Jarvis, cuanto espacio queda" -> "¿Sabeis cuanto espacio queda?"
|
||||||
|
# "Yarvis, que hora es" -> "¿Ya viste que hora es?"
|
||||||
|
# "Jarvis pon musica" -> "Carbis Pond Musica."
|
||||||
|
#
|
||||||
|
# Y ninguna de esas se parece al nombre ni por letra ni por sonido, asi que el
|
||||||
|
# centinela no despertaba JAMAS. Era la causa de que el asistente no contestara.
|
||||||
|
#
|
||||||
|
# Con este prompt de cinco palabras, cuatro de cuatro. Y ojo al matiz, porque
|
||||||
|
# contradice lo de arriba solo en apariencia: lo que empeora es el prompt LARGO
|
||||||
|
# con todo el vocabulario del catalogo. Este cuesta una frase de ochenta —90,0 a
|
||||||
|
# 88,8 % de acierto general, dentro del ruido— y arregla el nombre entero.
|
||||||
|
PROMPT_NOMBRE = "JARVIS. Hablando con JARVIS."
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_dominio(catalogo=None) -> str:
|
||||||
|
"""Vocabulario que JARVIS espera oir, sacado del catalogo.
|
||||||
|
|
||||||
|
Se cogen las palabras poco comunes —las que un modelo de castellano general
|
||||||
|
no espera— y no las frases enteras: el prompt es un sesgo, no una lista de
|
||||||
|
ordenes, y llenarlo de "cuanto espacio queda" haria que las oyera en
|
||||||
|
cualquier ruido.
|
||||||
|
"""
|
||||||
|
comunes = {
|
||||||
|
"abre", "cierra", "busca", "pon", "quita", "sube", "baja", "dime",
|
||||||
|
"cuanto", "cuanta", "cuantos", "que", "el", "la", "los", "las", "de",
|
||||||
|
"del", "en", "con", "por", "para", "un", "una", "y", "o", "a", "al",
|
||||||
|
"me", "se", "lo", "es", "esta", "hay", "mi", "tu", "su",
|
||||||
|
}
|
||||||
|
# Por grupos y en rueda, no por orden del catalogo. Recorriendolo en orden,
|
||||||
|
# el tope de caracteres se lo comian entero las acciones de disco —que van
|
||||||
|
# primeras— y no llegaba ni una palabra de firefox, ventanas o pentest.
|
||||||
|
por_grupo, vistas = {}, set()
|
||||||
|
for a in (catalogo.acciones if catalogo else []):
|
||||||
|
for frase in a.frases:
|
||||||
|
for p in frase.split():
|
||||||
|
if len(p) < 4 or p in comunes or p in vistas:
|
||||||
|
continue
|
||||||
|
vistas.add(p)
|
||||||
|
por_grupo.setdefault(a.grupo or "otras", []).append(p)
|
||||||
|
|
||||||
|
palabras = []
|
||||||
|
grupos = list(por_grupo.values())
|
||||||
|
for i in range(max((len(g) for g in grupos), default=0)):
|
||||||
|
for g in grupos:
|
||||||
|
if i < len(g):
|
||||||
|
palabras.append(g[i])
|
||||||
|
# Los nombres propios importan mas que nada y no salen del catalogo tal cual
|
||||||
|
fijas = ["JARVIS", "firefox", "nmap", "ollama", "whisper", "terminal",
|
||||||
|
"escritorio", "pantalla", "wifi", "bluetooth", "github", "youtube"]
|
||||||
|
texto = ", ".join(fijas + palabras)
|
||||||
|
return texto[:TOPE_PROMPT].rsplit(",", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _mata_huerfanos():
|
||||||
|
"""whisper-server de arranques anteriores que se quedaron con la grafica.
|
||||||
|
|
||||||
|
Se buscan por la RUTA COMPLETA del binario, no por el nombre: `pkill -f
|
||||||
|
whisper` casaria con el propio pkill y con cualquier cosa que lleve la
|
||||||
|
palabra, y eso ya ha costado depuraciones. Y se leen los cmdline de /proc en
|
||||||
|
vez de llamar a pgrep, que es lo mismo pero sin proceso de por medio.
|
||||||
|
"""
|
||||||
|
yo = os.getpid()
|
||||||
|
for pid in os.listdir("/proc"):
|
||||||
|
if not pid.isdigit() or int(pid) == yo:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||||
|
orden = f.read().split(b"\0")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if orden and orden[0].decode(errors="ignore") == BINARIO:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(int(pid)), signal.SIGTERM)
|
||||||
|
except (OSError, ProcessLookupError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _puerto_libre() -> int:
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
class Oido:
|
||||||
|
"""whisper-server vivo, y un metodo para transcribir un WAV."""
|
||||||
|
|
||||||
|
def __init__(self, modelo="small", idioma="es", hilos=8, prompt=PROMPT_NOMBRE,
|
||||||
|
beam=5, gpu=True):
|
||||||
|
self.modelo = modelo
|
||||||
|
self.idioma = idioma
|
||||||
|
self.hilos = hilos
|
||||||
|
self.prompt = prompt
|
||||||
|
self.beam = beam
|
||||||
|
self.gpu = gpu
|
||||||
|
self._proc = None
|
||||||
|
self._puerto = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ruta_modelo(self) -> str:
|
||||||
|
return os.path.join(MODELOS, f"ggml-{self.modelo}.bin")
|
||||||
|
|
||||||
|
def disponible(self) -> bool:
|
||||||
|
return os.path.exists(BINARIO) and os.path.exists(self.ruta_modelo)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- arrancar
|
||||||
|
|
||||||
|
def arranca(self, espera=40) -> bool:
|
||||||
|
if self._proc and self._proc.poll() is None:
|
||||||
|
return True
|
||||||
|
if not self.disponible():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Los huerfanos de arranques anteriores, antes de nada.
|
||||||
|
#
|
||||||
|
# whisper-server se lanza en su propio grupo de procesos para poder
|
||||||
|
# matarlo entero, pero eso mismo hace que sobreviva si el nucleo muere
|
||||||
|
# de golpe (un kill, un cuelgue, cerrar la terminal). Y cada uno se
|
||||||
|
# queda con ~880 MiB de una tarjeta de 4 GB: al cuarto, el siguiente ya
|
||||||
|
# no arranca y el sintoma es "whisper no arranco", que no apunta a
|
||||||
|
# ningun sitio. Medido en esta maquina: cuatro huerfanos, 3,5 GB.
|
||||||
|
_mata_huerfanos()
|
||||||
|
|
||||||
|
self._puerto = _puerto_libre()
|
||||||
|
orden = [
|
||||||
|
BINARIO,
|
||||||
|
"-m", self.ruta_modelo,
|
||||||
|
"-t", str(self.hilos),
|
||||||
|
"--host", "127.0.0.1",
|
||||||
|
"--port", str(self._puerto),
|
||||||
|
"-l", self.idioma,
|
||||||
|
# los cuatro que Newelle no pasaba
|
||||||
|
"-sns",
|
||||||
|
"--beam-size", str(self.beam),
|
||||||
|
"--best-of", "5",
|
||||||
|
]
|
||||||
|
if self.prompt:
|
||||||
|
orden += ["--prompt", self.prompt, "--carry-initial-prompt"]
|
||||||
|
if not self.gpu:
|
||||||
|
orden.append("--no-gpu")
|
||||||
|
|
||||||
|
entorno = {**os.environ,
|
||||||
|
"LD_LIBRARY_PATH": LIBS + ":" + os.environ.get("LD_LIBRARY_PATH", "")}
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
orden, env=entorno, cwd=os.path.dirname(BINARIO),
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
# grupo propio, para poder matarlo entero como hace tareas.py
|
||||||
|
start_new_session=True)
|
||||||
|
# Y que se vaya con nosotros: sin esto se queda con su parte de la
|
||||||
|
# grafica hasta el siguiente reinicio.
|
||||||
|
atexit.register(self.para)
|
||||||
|
|
||||||
|
# Esperar a que escuche de verdad, no a que el proceso exista: cargar
|
||||||
|
# el modelo en la grafica tarda, y un POST antes de tiempo da un error
|
||||||
|
# de conexion que parece un fallo de configuracion.
|
||||||
|
limite = time.monotonic() + espera
|
||||||
|
while time.monotonic() < limite:
|
||||||
|
if self._proc.poll() is not None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", self._puerto), 0.5):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.3)
|
||||||
|
self.para()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def para(self):
|
||||||
|
if not self._proc:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
|
||||||
|
self._proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(self._proc.pid), signal.SIGKILL)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vivo(self) -> bool:
|
||||||
|
return bool(self._proc and self._proc.poll() is None)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- transcribir
|
||||||
|
|
||||||
|
def transcribe(self, wav: str) -> str:
|
||||||
|
if not self.vivo and not self.arranca():
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
with open(wav, "rb") as f:
|
||||||
|
datos = f.read()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lim = "----jarvis"
|
||||||
|
cuerpo = (
|
||||||
|
f"--{lim}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="file"; filename="a.wav"\r\n'
|
||||||
|
f"Content-Type: audio/wav\r\n\r\n"
|
||||||
|
).encode() + datos + f"\r\n--{lim}--\r\n".encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{self._puerto}/inference", data=cuerpo,
|
||||||
|
headers={"Content-Type": f"multipart/form-data; boundary={lim}"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as r:
|
||||||
|
return json.loads(r.read().decode()).get("text", "").strip()
|
||||||
|
except (urllib.error.URLError, json.JSONDecodeError, OSError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- el filtro
|
||||||
|
|
||||||
|
# Lo que Whisper devuelve cuando no habia nadie hablando. No es un fallo suyo:
|
||||||
|
# se entreno con subtitulos de YouTube, donde el silencio se anota asi.
|
||||||
|
SOLO_ETIQUETA = re.compile(r"^\s*[\[\(\*].{0,40}[\]\)\*]\s*[.!¡]*\s*$")
|
||||||
|
MULETILLAS = (
|
||||||
|
"suscribete", "suscríbete", "gracias por ver", "hasta la proxima",
|
||||||
|
"hasta la próxima", "no olvides suscribirte", "dale like",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Anotaciones que Whisper escribe cuando no hay nadie hablando. Salen de contar
|
||||||
|
# el registro real de JARVIS, no de imaginarlas: son las que mas se repiten
|
||||||
|
# entre las frases cortas que no llegan a ninguna accion.
|
||||||
|
#
|
||||||
|
# A veces vienen entre corchetes —y entonces las coge SOLO_ETIQUETA— y a veces
|
||||||
|
# sueltas, que es para lo que sirve esta lista.
|
||||||
|
ANOTACIONES = {
|
||||||
|
"musica", "música", "motor", "risas", "risa", "aplausos", "cantando",
|
||||||
|
"ruido", "silencio", "tos", "suspiro", "aplauso", "llanto", "gritos",
|
||||||
|
"ah", "ahh", "ahhh", "eh", "ehh", "mmm", "hmm", "uf", "ay",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Lo que SI se deja pasar aunque no lleve a ninguna accion: "hola", "adios",
|
||||||
|
# "gracias", "buenas". Una persona las dice de verdad y merecen respuesta; lo
|
||||||
|
# unico que cuesta dejarlas pasar es una consulta al modelo. Filtrar de mas
|
||||||
|
# aqui es peor que filtrar de menos: JARVIS parecería sordo a un saludo.
|
||||||
|
|
||||||
|
|
||||||
|
def es_ruido(texto: str) -> bool:
|
||||||
|
"""Si esto es Whisper alucinando en vez de alguien hablando.
|
||||||
|
|
||||||
|
Se aplica ANTES del emparejador y antes de anotar el fallo. Sin esto, el
|
||||||
|
16,2 % del registro de "no entendido" son "[Musica]" y "(aplausos)", y la
|
||||||
|
mejora nocturna acaba generando alias a partir de ellos.
|
||||||
|
|
||||||
|
Conservador a proposito. Lo que se cuela solo cuesta una consulta al modelo;
|
||||||
|
lo que se filtra de mas es una orden que JARVIS ignora, y eso se vive como
|
||||||
|
que no funciona.
|
||||||
|
"""
|
||||||
|
if not texto or not texto.strip():
|
||||||
|
return True
|
||||||
|
if SOLO_ETIQUETA.match(texto):
|
||||||
|
return True
|
||||||
|
limpio = texto.lower().strip(" .,!¡?¿*-—")
|
||||||
|
if limpio in ANOTACIONES:
|
||||||
|
return True
|
||||||
|
return any(m in limpio for m in MULETILLAS)
|
||||||
169
nucleo/pruebas/banco.py
Executable file
169
nucleo/pruebas/banco.py
Executable file
|
|
@ -0,0 +1,169 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Banco fijo para medir el emparejador sin volver a grabar.
|
||||||
|
|
||||||
|
banco.py --crear [n] graba y transcribe una vez (lento)
|
||||||
|
banco.py --mide puntua el catalogo actual (instantaneo)
|
||||||
|
banco.py --fallos enseña que se sigue fallando
|
||||||
|
|
||||||
|
## Por que hace falta
|
||||||
|
|
||||||
|
La mejora nocturna medía generando audio nuevo cada ronda: nueve horas, 39
|
||||||
|
rondas, +1,45 puntos de mejora contra ±1,9 puntos de ruido de medida. El ruido
|
||||||
|
era mayor que la mejora, asi que "¿esto mejora o empeora?" se decidia a cara o
|
||||||
|
cruz. Hoy ha vuelto a pasar: los flags de Whisper parecian dar +5 puntos con 40
|
||||||
|
muestras y con 150 no dan nada.
|
||||||
|
|
||||||
|
Aqui se graba UNA vez con semilla fija y se guardan las TRANSCRIPCIONES. A
|
||||||
|
partir de ahi, probar un cambio en el emparejador es instantaneo y determinista:
|
||||||
|
la misma entrada siempre, asi que dos medidas son comparables de verdad.
|
||||||
|
|
||||||
|
## La trampa que ya costo una conclusion equivocada
|
||||||
|
|
||||||
|
El 24 % del catalogo son acciones que piden argumento. Diciendolas sueltas
|
||||||
|
—"busca el texto"— el emparejador NO las dispara, y hace bien: no son una orden.
|
||||||
|
Un banco que las dice sueltas mide 56 % donde la realidad es 79 %, y hace
|
||||||
|
parecer que el emparejador esta roto cuando lo que esta roto es el banco.
|
||||||
|
Por eso `--crear` les añade argumento.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import difflib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, AQUI)
|
||||||
|
|
||||||
|
from manos.acciones import Catalogo, normaliza # noqa: E402
|
||||||
|
from oido.whisper import Oido, es_ruido # noqa: E402
|
||||||
|
|
||||||
|
RAIZ = os.path.dirname(AQUI)
|
||||||
|
PIPER = os.path.join(RAIZ, "config", "jarvis-piper.sh")
|
||||||
|
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
|
||||||
|
BANCO = os.path.join(AQUI, "datos", "banco.json")
|
||||||
|
|
||||||
|
# Fijas a proposito: son lo que hace el banco reproducible. Cambiarlas obliga a
|
||||||
|
# rehacerlo, y esa es justamente la señal de que ya no es comparable.
|
||||||
|
VOCES = ["es_ES-davefx-medium", "es_MX-claude-high", "es_ES-sharvard-medium"]
|
||||||
|
SEMILLA = 20260815
|
||||||
|
ARGUMENTOS = ["informes", "descargas", "musica", "el proyecto", "documentos"]
|
||||||
|
|
||||||
|
|
||||||
|
def crear(n: int) -> int:
|
||||||
|
cat = Catalogo([CATALOGO])
|
||||||
|
rnd = random.Random(SEMILLA)
|
||||||
|
conf = [a for a in cat.acciones if a.frases]
|
||||||
|
|
||||||
|
casos = []
|
||||||
|
for a in rnd.sample(conf, min(n, len(conf))):
|
||||||
|
frase = rnd.choice(a.frases)
|
||||||
|
if a.captura:
|
||||||
|
frase += " " + rnd.choice(ARGUMENTOS)
|
||||||
|
casos.append({"id": a.id, "frase": frase, "voz": rnd.choice(VOCES),
|
||||||
|
"captura": a.captura})
|
||||||
|
|
||||||
|
print(f" grabando {len(casos)} frases...", flush=True)
|
||||||
|
tmp = tempfile.mkdtemp(prefix="banco")
|
||||||
|
for i, c in enumerate(casos):
|
||||||
|
wav = os.path.join(tmp, f"{i:03d}.wav")
|
||||||
|
subprocess.run([PIPER, wav, c["frase"]],
|
||||||
|
env={**os.environ, "JARVIS_PIPER_VOZ": c["voz"],
|
||||||
|
"JARVIS_PIPER_TONO": "1.0"},
|
||||||
|
capture_output=True, timeout=90)
|
||||||
|
c["wav"] = wav if os.path.exists(wav) and os.path.getsize(wav) else None
|
||||||
|
|
||||||
|
oreja = Oido(modelo="small", beam=5)
|
||||||
|
if not oreja.arranca():
|
||||||
|
print(" whisper no arranco")
|
||||||
|
return 2
|
||||||
|
print(" transcribiendo...", flush=True)
|
||||||
|
for i, c in enumerate(casos):
|
||||||
|
c["oido"] = oreja.transcribe(c["wav"]) if c["wav"] else ""
|
||||||
|
c.pop("wav", None)
|
||||||
|
if (i + 1) % 40 == 0:
|
||||||
|
print(f" {i+1}/{len(casos)}", flush=True)
|
||||||
|
oreja.para()
|
||||||
|
subprocess.run(["rm", "-rf", tmp])
|
||||||
|
|
||||||
|
casos = [c for c in casos if c.get("oido")]
|
||||||
|
os.makedirs(os.path.dirname(BANCO), exist_ok=True)
|
||||||
|
with open(BANCO, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"semilla": SEMILLA, "modelo": "small", "casos": casos},
|
||||||
|
f, ensure_ascii=False, indent=1)
|
||||||
|
print(f" guardado: {len(casos)} transcripciones en {BANCO}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def carga():
|
||||||
|
try:
|
||||||
|
with open(BANCO, encoding="utf-8") as f:
|
||||||
|
return json.load(f)["casos"]
|
||||||
|
except (OSError, json.JSONDecodeError, KeyError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def mide(detalle=False) -> int:
|
||||||
|
casos = carga()
|
||||||
|
if not casos:
|
||||||
|
print(" no hay banco: crealo con --crear")
|
||||||
|
return 1
|
||||||
|
cat = Catalogo([CATALOGO])
|
||||||
|
|
||||||
|
bien = 0
|
||||||
|
oye_mal, no_relaciona, otra, ruido = [], [], [], []
|
||||||
|
for c in casos:
|
||||||
|
t = c["oido"]
|
||||||
|
if es_ruido(t):
|
||||||
|
ruido.append(c)
|
||||||
|
continue
|
||||||
|
a, _ = cat.busca(t)
|
||||||
|
if a is not None and a.id == c["id"]:
|
||||||
|
bien += 1
|
||||||
|
continue
|
||||||
|
p = difflib.SequenceMatcher(None, normaliza(t), normaliza(c["frase"])).ratio()
|
||||||
|
if p < 0.75:
|
||||||
|
oye_mal.append((c, t, p))
|
||||||
|
elif a is None:
|
||||||
|
no_relaciona.append((c, t, p))
|
||||||
|
else:
|
||||||
|
otra.append((c, t, a.id))
|
||||||
|
|
||||||
|
n = len(casos)
|
||||||
|
print(f" {n} frases · acierto {100*bien/n:.1f} %\n")
|
||||||
|
print(f" {'que pasa':28s} {'n':>4s} {'%':>7s}")
|
||||||
|
print(" " + "-" * 44)
|
||||||
|
for nombre, k in [("bien", bien), ("oye bien, NO RELACIONA", len(no_relaciona)),
|
||||||
|
("OYE MAL la frase", len(oye_mal)),
|
||||||
|
("oye bien, OTRA accion", len(otra)),
|
||||||
|
("lo tira por ruido", len(ruido))]:
|
||||||
|
print(f" {nombre:28s} {k:4d} {100*k/n:6.1f} %")
|
||||||
|
|
||||||
|
if detalle:
|
||||||
|
print("\n --- oye bien pero no relaciona ---")
|
||||||
|
for c, t, p in no_relaciona:
|
||||||
|
print(f" {c['id']:24s} dijo: {c['frase']}")
|
||||||
|
print(f" {'':24s} oyo: {t} ({p:.2f})")
|
||||||
|
print("\n --- se la lleva otra accion (lo peor: ejecuta lo que no es) ---")
|
||||||
|
for c, t, o in otra:
|
||||||
|
print(f" {c['id']:24s} -> {o} oyo: {t}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--crear", action="store_true")
|
||||||
|
p.add_argument("--mide", action="store_true")
|
||||||
|
p.add_argument("--fallos", action="store_true")
|
||||||
|
p.add_argument("n", nargs="?", type=int, default=150)
|
||||||
|
a = p.parse_args()
|
||||||
|
if a.crear:
|
||||||
|
return crear(a.n)
|
||||||
|
return mide(detalle=a.fallos)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
133
nucleo/pruebas/prueba_candado.py
Executable file
133
nucleo/pruebas/prueba_candado.py
Executable file
|
|
@ -0,0 +1,133 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""El candado: que tape, que rechace lo que no es, y que NO obedezca.
|
||||||
|
|
||||||
|
./venv/bin/python pruebas/prueba_candado.py
|
||||||
|
|
||||||
|
Lo que de verdad importa aqui no es que el panel se ponga negro —eso es
|
||||||
|
cosmetica— sino que con el candado puesto **no se ejecute nada**. El panel tapado
|
||||||
|
pero obedeciendo seria peor que no tener candado, porque da sensacion de
|
||||||
|
seguridad sin darla.
|
||||||
|
|
||||||
|
No se prueba con la contraseña de verdad: no la tenemos ni hace falta. Se
|
||||||
|
comprueba que una mala se rechaza y que mientras tanto no pasa ni una orden.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, AQUI)
|
||||||
|
|
||||||
|
from boca.voz import Boca # noqa: E402
|
||||||
|
from cara.panel import Panel # noqa: E402
|
||||||
|
from manos import Manos # noqa: E402
|
||||||
|
|
||||||
|
fallos = []
|
||||||
|
|
||||||
|
|
||||||
|
def afirma(condicion, queja):
|
||||||
|
print(f" {'OK ' if condicion else 'MAL'} {queja}")
|
||||||
|
if not condicion:
|
||||||
|
fallos.append(queja)
|
||||||
|
|
||||||
|
|
||||||
|
class CaraFalsa:
|
||||||
|
"""Se queda con lo que el panel manda, sin levantar ningun servidor."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.mensajes = []
|
||||||
|
self.conectados = 1
|
||||||
|
|
||||||
|
def manda(self, tipo, datos):
|
||||||
|
self.mensajes.append((tipo, datos))
|
||||||
|
|
||||||
|
def de(self, tipo):
|
||||||
|
return [d for t, d in self.mensajes if t == tipo]
|
||||||
|
|
||||||
|
|
||||||
|
class BocaMuda(Boca):
|
||||||
|
"""Como la de verdad pero sin sonar: la prueba no tiene que hablar."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("davefx")
|
||||||
|
self.dicho = []
|
||||||
|
|
||||||
|
def di(self, texto):
|
||||||
|
self.dicho.append(texto); return True
|
||||||
|
|
||||||
|
def di_si_libre(self, texto):
|
||||||
|
self.dicho.append(texto); return True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
manos = Manos()
|
||||||
|
cara = CaraFalsa()
|
||||||
|
boca = BocaMuda()
|
||||||
|
p = Panel(cara, boca, manos, candado=True)
|
||||||
|
|
||||||
|
print("\n al abrir")
|
||||||
|
p.arranca()
|
||||||
|
time.sleep(0.2)
|
||||||
|
afirma(not p.desbloqueado, "nace bloqueado")
|
||||||
|
afirma(bool(cara.de("bloqueo")), "pide la contraseña nada mas abrir")
|
||||||
|
|
||||||
|
print("\n con el candado puesto NO obedece")
|
||||||
|
antes = len(cara.mensajes)
|
||||||
|
for orden in ("prueba", "voz:siguiente", "parar"):
|
||||||
|
p.pulsado("accion", orden)
|
||||||
|
time.sleep(1.0)
|
||||||
|
afirma(not boca.dicho, f"no ha hablado ({boca.dicho})")
|
||||||
|
# Desde que la tanda va por tareas, lo que se ve al ejecutar son mensajes
|
||||||
|
# "tarea" (una tarjeta por orden), no "respuesta".
|
||||||
|
hechas = [d for t, d in cara.mensajes[antes:] if t in ("tarea", "respuesta")]
|
||||||
|
afirma(not hechas, f"no ha ejecutado nada ({len(hechas)} tareas o respuestas)")
|
||||||
|
# Solo se miran las respuestas a lo pulsado: la telemetria sigue latiendo
|
||||||
|
# en su hilo cada segundo y se cuela entre medias.
|
||||||
|
contestaciones = [t for t, _ in cara.mensajes[antes:] if t != "telemetria"]
|
||||||
|
afirma(contestaciones and all(t == "bloqueo" for t in contestaciones),
|
||||||
|
f"a cada intento contesta pidiendo la contraseña ({contestaciones})")
|
||||||
|
|
||||||
|
print("\n una contraseña mala se rechaza")
|
||||||
|
antes = len(cara.mensajes)
|
||||||
|
p.pulsado("accion", "desbloquear:no-es-esta-contrasena-12345")
|
||||||
|
time.sleep(0.5)
|
||||||
|
afirma(not p.desbloqueado, "sigue bloqueado")
|
||||||
|
ultimos = cara.de("bloqueo")[-1:] or [{}]
|
||||||
|
afirma(ultimos[0].get("error") is True, "y lo dice como error")
|
||||||
|
afirma(not cara.de("desbloqueado"), "no manda 'desbloqueado'")
|
||||||
|
|
||||||
|
print("\n una vacia ni se intenta")
|
||||||
|
antes = len(cara.mensajes)
|
||||||
|
p.pulsado("accion", "desbloquear:")
|
||||||
|
afirma(len(cara.mensajes) == antes, "no molesta a sudo con una clave vacia")
|
||||||
|
|
||||||
|
print("\n sin candado, obedece")
|
||||||
|
cara2, boca2 = CaraFalsa(), BocaMuda()
|
||||||
|
p2 = Panel(cara2, boca2, manos, candado=False)
|
||||||
|
p2.arranca()
|
||||||
|
afirma(p2.desbloqueado, "nace desbloqueado")
|
||||||
|
afirma(not cara2.de("bloqueo"), "no pide contraseña")
|
||||||
|
p2.pulsado("accion", "prueba")
|
||||||
|
time.sleep(3.0)
|
||||||
|
tarjetas = {d["id"] for d in cara2.de("tarea")}
|
||||||
|
afirma(bool(tarjetas), f"ejecuta de verdad ({len(tarjetas)} tarjetas)")
|
||||||
|
afirma(any(d["estado"] == "hecha" for d in cara2.de("tarea")),
|
||||||
|
"y las tareas llegan a terminar")
|
||||||
|
|
||||||
|
print("\n y se puede volver a echar")
|
||||||
|
p2.bloquea()
|
||||||
|
afirma(not p2.desbloqueado, "bloquea() cierra aunque arrancara sin candado")
|
||||||
|
antes = len(cara2.mensajes)
|
||||||
|
p2.pulsado("accion", "prueba")
|
||||||
|
time.sleep(0.6)
|
||||||
|
afirma(not [d for t, d in cara2.mensajes[antes:] if t in ("tarea", "respuesta")],
|
||||||
|
"y despues de cerrar ya no obedece")
|
||||||
|
|
||||||
|
p.para(); p2.para()
|
||||||
|
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
|
||||||
|
return 1 if fallos else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
122
nucleo/pruebas/prueba_cara.py
Executable file
122
nucleo/pruebas/prueba_cara.py
Executable file
|
|
@ -0,0 +1,122 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""El panel: que lo que manda el nucleo sea lo que el HUD sabe leer.
|
||||||
|
|
||||||
|
./venv/bin/python pruebas/prueba_cara.py
|
||||||
|
|
||||||
|
## Por que existe esta prueba
|
||||||
|
|
||||||
|
Montando la telemetria, `ps` devolvia el uso de CPU como cadena y el nucleo la
|
||||||
|
mandaba tal cual. El HUD hace `p.cpu.toFixed(0)`, y una cadena no tiene
|
||||||
|
`toFixed`: la excepcion abortaba el manejador ENTERO y se llevaba por delante
|
||||||
|
todo lo que se pinta despues. En pantalla se veia que CPU, RAM y grafica
|
||||||
|
salian en "--", y el fallo no apuntaba a los procesos por ningun lado.
|
||||||
|
|
||||||
|
Ese es el problema de un puente sin contrato: un campo con el tipo equivocado
|
||||||
|
apaga media pantalla y no dice donde. Aqui se fija el contrato —que campo, de
|
||||||
|
que tipo y en que unidad— comprobando ademas que `hud.js` lo sigue usando asi,
|
||||||
|
para que enterarse no dependa de mirar la pantalla.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, AQUI)
|
||||||
|
|
||||||
|
from cara.panel import Telemetria # noqa: E402
|
||||||
|
|
||||||
|
HUD = os.path.join(AQUI, "cara", "hud")
|
||||||
|
|
||||||
|
fallos = []
|
||||||
|
|
||||||
|
|
||||||
|
def afirma(condicion, queja):
|
||||||
|
print(f" {'OK ' if condicion else 'MAL'} {queja}")
|
||||||
|
if not condicion:
|
||||||
|
fallos.append(queja)
|
||||||
|
|
||||||
|
|
||||||
|
def numero(v):
|
||||||
|
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("\n el puente esta puesto")
|
||||||
|
with open(os.path.join(HUD, "index.html"), encoding="utf-8") as f:
|
||||||
|
html = f.read()
|
||||||
|
afirma("puente.js" in html, "index.html carga puente.js")
|
||||||
|
# Se comparan las etiquetas <script>, no el texto suelto: el comentario que
|
||||||
|
# hay encima menciona hud.js y una busqueda ingenua lo encuentra antes.
|
||||||
|
scripts = re.findall(r'<script\s+src="([^"]+)"', html)
|
||||||
|
afirma("puente.js" in scripts and "hud.js" in scripts
|
||||||
|
and scripts.index("puente.js") < scripts.index("hud.js"),
|
||||||
|
"y lo carga ANTES que hud.js, que pregunta por window.webkit al arrancar")
|
||||||
|
with open(os.path.join(HUD, "puente.js"), encoding="utf-8") as f:
|
||||||
|
puente = f.read()
|
||||||
|
afirma("WebSocket" in puente, "el puente habla por WebSocket")
|
||||||
|
afirma("messageHandlers" in puente,
|
||||||
|
"suplanta window.webkit, asi hud.js no se toca")
|
||||||
|
afirma("cola" in puente, "guarda lo pulsado mientras reconecta")
|
||||||
|
|
||||||
|
print("\n la telemetria: campos, tipos y unidades")
|
||||||
|
d = Telemetria().lee()
|
||||||
|
Telemetria().lee() # el segundo tiene ya las diferencias de cpu
|
||||||
|
|
||||||
|
afirma(numero(d.get("cpu")), "cpu es un numero")
|
||||||
|
afirma(isinstance(d.get("nucleos"), list) and all(numero(x) for x in d["nucleos"]),
|
||||||
|
f"nucleos es una lista de numeros ({len(d.get('nucleos', []))})")
|
||||||
|
for clave in ("ram", "swap"):
|
||||||
|
v = d.get(clave, {})
|
||||||
|
afirma(numero(v.get("usada")) and numero(v.get("total")),
|
||||||
|
f"{clave} lleva usada y total")
|
||||||
|
afirma(0 < v.get("total", 0) < 1024,
|
||||||
|
f"{clave} viene en GB, no en MB ({v.get('total', 0):.1f})")
|
||||||
|
disco = d.get("disco", {})
|
||||||
|
afirma(0 < disco.get("total", 0) < 200,
|
||||||
|
f"disco viene en TB, no en GB ({disco.get('total', 0):.2f})")
|
||||||
|
afirma(isinstance(d.get("carga"), list),
|
||||||
|
"carga es una lista: el HUD hace carga.map()")
|
||||||
|
afirma(d.get("uptime", 0) > 60,
|
||||||
|
"uptime va en segundos: el HUD decide solo si pone minutos o dias")
|
||||||
|
|
||||||
|
if "gpu" in d:
|
||||||
|
g = d["gpu"]
|
||||||
|
afirma(all(numero(g.get(k)) for k in ("uso", "vram_usada", "vram_total")),
|
||||||
|
"la grafica manda uso, vram_usada y vram_total")
|
||||||
|
afirma(0 < g.get("vram_total", 0) < 128,
|
||||||
|
f"la vram viene en GB ({g.get('vram_total', 0):.1f})")
|
||||||
|
|
||||||
|
print("\n los procesos: el campo que apago media pantalla")
|
||||||
|
procs = d.get("procesos", [])
|
||||||
|
afirma(bool(procs), f"llegan procesos ({len(procs)})")
|
||||||
|
for p in procs[:3]:
|
||||||
|
afirma(numero(p.get("cpu")) and numero(p.get("mem")),
|
||||||
|
f"{p.get('nombre','?')}: cpu y mem son numeros, no cadenas")
|
||||||
|
afirma(all("pid" in p for p in procs), "todos traen pid, que el HUD pone en el title")
|
||||||
|
|
||||||
|
print("\n y el HUD sigue esperando eso mismo")
|
||||||
|
with open(os.path.join(HUD, "hud.js"), encoding="utf-8") as f:
|
||||||
|
js = f.read()
|
||||||
|
# Si alguien cambia hud.js para leer otro campo, esta prueba no lo sabria
|
||||||
|
# mirando solo al nucleo: se comprueba en el propio JavaScript.
|
||||||
|
for campo in ("datos.cpu", "datos.ram", "datos.swap", "datos.disco",
|
||||||
|
"datos.nucleos", "datos.procesos"):
|
||||||
|
afirma(campo in js, f"hud.js lee {campo}")
|
||||||
|
afirma("p.cpu.toFixed" in js,
|
||||||
|
"hud.js llama a toFixed sobre el cpu de cada proceso (por eso tiene que ser numero)")
|
||||||
|
|
||||||
|
# Todo lo que se manda tiene que poder viajar por el socket.
|
||||||
|
print("\n serializable")
|
||||||
|
try:
|
||||||
|
json.dumps(d)
|
||||||
|
afirma(True, "la telemetria entera cabe en JSON")
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
afirma(False, f"la telemetria no es JSON: {e}")
|
||||||
|
|
||||||
|
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
|
||||||
|
return 1 if fallos else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
133
nucleo/pruebas/prueba_dialogo.py
Executable file
133
nucleo/pruebas/prueba_dialogo.py
Executable file
|
|
@ -0,0 +1,133 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pulsar una accion PREGUNTA lo que falta y la hace.
|
||||||
|
|
||||||
|
./venv/bin/python pruebas/prueba_dialogo.py
|
||||||
|
|
||||||
|
Antes, pulsar en "sabe hacer" recitaba la frase que dispara la accion. Eso es un
|
||||||
|
manual de instrucciones, y uno pulsa justo porque no quiere leerse el manual.
|
||||||
|
|
||||||
|
Lo que se comprueba, por orden de importancia:
|
||||||
|
|
||||||
|
1. que lo destructivo NO se dispare de un clic
|
||||||
|
2. que lo destructivo CON argumento pregunte las DOS cosas
|
||||||
|
3. que un "no" cancele de verdad
|
||||||
|
4. que la pregunta caduque, para que una frase suelta media conversacion
|
||||||
|
despues no se tome por la respuesta
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, AQUI)
|
||||||
|
|
||||||
|
from cara.panel import Panel # noqa: E402
|
||||||
|
from manos import Manos # noqa: E402
|
||||||
|
from manos.preguntar import pregunta_por, es_si # noqa: E402
|
||||||
|
|
||||||
|
fallos = []
|
||||||
|
|
||||||
|
|
||||||
|
def afirma(condicion, queja):
|
||||||
|
print(f" {'OK ' if condicion else 'MAL'} {queja}")
|
||||||
|
if not condicion:
|
||||||
|
fallos.append(queja)
|
||||||
|
|
||||||
|
|
||||||
|
class CaraFalsa:
|
||||||
|
conectados = 1
|
||||||
|
def __init__(self): self.m = []
|
||||||
|
def manda(self, t, d): self.m.append((t, d))
|
||||||
|
|
||||||
|
|
||||||
|
class BocaMuda:
|
||||||
|
voz = "davefx"
|
||||||
|
def __init__(self): self.dicho = []
|
||||||
|
def di(self, t): self.dicho.append(t); return True
|
||||||
|
def di_si_libre(self, t): return self.di(t)
|
||||||
|
def disponibles(self): return ["davefx"]
|
||||||
|
|
||||||
|
|
||||||
|
def nuevo():
|
||||||
|
cara, boca = CaraFalsa(), BocaMuda()
|
||||||
|
return Panel(cara, boca, Manos(), candado=False), boca
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("\n las preguntas salen del catalogo")
|
||||||
|
manos = Manos()
|
||||||
|
conArg = [a for a in manos.catalogo.acciones if a.captura]
|
||||||
|
genericas = [a for a in conArg if pregunta_por(a) == "¿El que, señor?"]
|
||||||
|
afirma(len(conArg) >= 30, f"{len(conArg)} acciones piden argumento")
|
||||||
|
afirma(len(genericas) <= 10,
|
||||||
|
f"solo {len(genericas)} caen en la pregunta generica, de {len(conArg)}")
|
||||||
|
afirma(all(pregunta_por(a).endswith("señor?") for a in conArg),
|
||||||
|
"todas preguntan en el registro de la casa")
|
||||||
|
afirma("cierra" not in [pregunta_por(a).split()[0].strip("¿").lower()
|
||||||
|
for a in conArg],
|
||||||
|
"y ninguna empieza por el verbo de la orden")
|
||||||
|
|
||||||
|
print("\n con argumento: pregunta y ejecuta con lo que le digas")
|
||||||
|
p, boca = nuevo()
|
||||||
|
p.pulsado("accion", "explica:buscar_fichero"); time.sleep(0.2)
|
||||||
|
afirma(bool(p.esperando()), "se queda esperando respuesta")
|
||||||
|
afirma(any("fichero" in d for d in boca.dicho), f"pregunta cual: {boca.dicho}")
|
||||||
|
p.responde("informes"); time.sleep(1.5)
|
||||||
|
tareas = [t.titulo for t in p.tareas.todas()]
|
||||||
|
afirma(any("informes" in t for t in tareas), f"lo ejecuta con el argumento: {tareas}")
|
||||||
|
afirma(not p.esperando(), "y deja de esperar")
|
||||||
|
p.para()
|
||||||
|
|
||||||
|
print("\n lo destructivo NO se dispara de un clic")
|
||||||
|
p, boca = nuevo()
|
||||||
|
p.pulsado("accion", "explica:ff_firefox_cerrar"); time.sleep(0.3)
|
||||||
|
afirma(not p.tareas.todas(), "no ha lanzado nada al pulsar")
|
||||||
|
afirma(bool(p.esperando()), "esta esperando confirmacion")
|
||||||
|
p.responde("no"); time.sleep(0.4)
|
||||||
|
afirma(not p.tareas.todas(), "y con un 'no' sigue sin lanzar nada")
|
||||||
|
p.para()
|
||||||
|
|
||||||
|
print("\n destructivo CON argumento: las dos preguntas")
|
||||||
|
p, boca = nuevo()
|
||||||
|
p.pulsado("accion", "explica:proceso_matar"); time.sleep(0.2)
|
||||||
|
afirma(bool(p.esperando()), "pregunta el argumento")
|
||||||
|
p.responde("firefox"); time.sleep(0.3)
|
||||||
|
afirma(bool(p.esperando()),
|
||||||
|
"y DESPUES pide confirmacion, no ejecuta directo")
|
||||||
|
afirma(any("firefox" in d for d in boca.dicho),
|
||||||
|
"la confirmacion dice sobre que va, no un cheque en blanco")
|
||||||
|
p.responde("no"); time.sleep(0.4)
|
||||||
|
afirma(not p.tareas.todas(), "un 'no' lo cancela")
|
||||||
|
p.para()
|
||||||
|
|
||||||
|
print("\n lo que no pregunta nada, se hace")
|
||||||
|
p, boca = nuevo()
|
||||||
|
p.pulsado("accion", "explica:disco_libre"); time.sleep(1.5)
|
||||||
|
afirma(bool(p.tareas.todas()), "una accion sin argumento ni riesgo se ejecuta")
|
||||||
|
afirma(not p.esperando(), "sin preguntar nada")
|
||||||
|
p.para()
|
||||||
|
|
||||||
|
print("\n la pregunta caduca")
|
||||||
|
p, boca = nuevo()
|
||||||
|
p.ESPERA_S = 0.5
|
||||||
|
p.pulsado("accion", "explica:buscar_fichero"); time.sleep(0.2)
|
||||||
|
afirma(bool(p.esperando()), "recien preguntada, espera")
|
||||||
|
time.sleep(0.8)
|
||||||
|
afirma(not p.esperando(),
|
||||||
|
"pasado el plazo la olvida: una frase suelta media conversacion "
|
||||||
|
"despues no puede tomarse por la respuesta")
|
||||||
|
p.para()
|
||||||
|
|
||||||
|
print("\n el si y el no, como los dice la gente")
|
||||||
|
for t, esperado in [("si", True), ("Sí.", True), ("vale", True),
|
||||||
|
("claro que si", True), ("no", False), ("dejalo", False),
|
||||||
|
("no, mejor no", False), ("informes", None),
|
||||||
|
("el fichero de agosto", None)]:
|
||||||
|
afirma(es_si(t) is esperado, f"{t!r} -> {es_si(t)}")
|
||||||
|
|
||||||
|
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
|
||||||
|
return 1 if fallos else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
159
nucleo/pruebas/prueba_eco.py
Executable file
159
nucleo/pruebas/prueba_eco.py
Executable file
|
|
@ -0,0 +1,159 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""La prueba que Newelle no pasa: que JARVIS no se oiga a si mismo.
|
||||||
|
|
||||||
|
En Newelle, mas del 21 % de lo que oye y no entiende es su propia voz. Esta
|
||||||
|
prueba lo comprueba de verdad, con el microfono y los altavoces, en dos partes:
|
||||||
|
|
||||||
|
A. Suena una frase POR FUERA de JARVIS (un reproductor a pelo, como si
|
||||||
|
hablara una persona). Tiene que captarla. Si no, el microfono no llega a
|
||||||
|
los altavoces y la parte B no demuestra nada.
|
||||||
|
|
||||||
|
B. La misma frase, pero dicha POR JARVIS. NO tiene que captarla.
|
||||||
|
|
||||||
|
Las dos partes juntas son la prueba: A sola no dice nada, B sola podria pasar
|
||||||
|
simplemente porque el microfono no oye nada.
|
||||||
|
|
||||||
|
./venv/bin/python pruebas/prueba_eco.py
|
||||||
|
|
||||||
|
Necesita altavoces encendidos y a un volumen normal. Si estan en silencio, la
|
||||||
|
parte A falla y avisa.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, AQUI)
|
||||||
|
|
||||||
|
from boca.voz import Boca # noqa: E402
|
||||||
|
from oido.captura import Captura # noqa: E402
|
||||||
|
from oido.whisper import Oido, es_ruido # noqa: E402
|
||||||
|
|
||||||
|
FRASE = "El sistema esta operativo y a su disposicion, señor."
|
||||||
|
ESPERA = 14 # cuanto se escucha en cada parte
|
||||||
|
|
||||||
|
|
||||||
|
def _se_parece(oido: str, dicho: str) -> bool:
|
||||||
|
"""Si lo transcrito es la frase que se dijo, aunque venga maltratada.
|
||||||
|
|
||||||
|
Se compara por palabras y no por parecido de cadena: un eco recortado
|
||||||
|
("operativo y a su disposicion") no se parece a la frase entera como
|
||||||
|
cadena, pero comparte casi todas sus palabras, y eso es inequivoco.
|
||||||
|
"""
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
def palabras(t):
|
||||||
|
t = unicodedata.normalize("NFD", t.lower())
|
||||||
|
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||||
|
return {p.strip(".,¡!¿?") for p in t.split() if len(p) > 3}
|
||||||
|
|
||||||
|
a, b = palabras(oido), palabras(dicho)
|
||||||
|
return bool(b) and len(a & b) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def escucha_un_rato(captura, oreja, segundos):
|
||||||
|
"""Devuelve lo que se haya oido en ese rato."""
|
||||||
|
oido = []
|
||||||
|
fin = time.monotonic() + segundos
|
||||||
|
hilo_paro = threading.Event()
|
||||||
|
|
||||||
|
def bucle():
|
||||||
|
for wav in captura.frases(para_cuando=lambda: hilo_paro.is_set()):
|
||||||
|
try:
|
||||||
|
t = oreja.transcribe(wav)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(wav)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if t and not es_ruido(t):
|
||||||
|
oido.append(t)
|
||||||
|
|
||||||
|
h = threading.Thread(target=bucle, daemon=True)
|
||||||
|
h.start()
|
||||||
|
while time.monotonic() < fin:
|
||||||
|
time.sleep(0.2)
|
||||||
|
hilo_paro.set()
|
||||||
|
captura.para()
|
||||||
|
h.join(timeout=5)
|
||||||
|
return oido
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
boca = Boca("davefx")
|
||||||
|
oreja = Oido(modelo="small")
|
||||||
|
if not oreja.disponible():
|
||||||
|
print(" no esta whisper o el modelo; no se puede probar")
|
||||||
|
return 2
|
||||||
|
print(" arrancando whisper...")
|
||||||
|
if not oreja.arranca():
|
||||||
|
print(" whisper no arranco")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# Se genera el WAV una vez para poder reproducirlo por los dos caminos con
|
||||||
|
# exactamente el mismo audio.
|
||||||
|
wav = boca._genera(FRASE)
|
||||||
|
if not wav:
|
||||||
|
print(" no se pudo generar la voz")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
fallos = 0
|
||||||
|
|
||||||
|
# --- A: suena por fuera, tiene que oirse -----------------------------
|
||||||
|
print("\n A · suena por fuera (como si hablara alguien)")
|
||||||
|
captura = Captura(boca=boca)
|
||||||
|
def suelta_fuera():
|
||||||
|
time.sleep(2.5)
|
||||||
|
subprocess.run(["paplay", wav], capture_output=True)
|
||||||
|
threading.Thread(target=suelta_fuera, daemon=True).start()
|
||||||
|
oido_a = escucha_un_rato(captura, oreja, ESPERA)
|
||||||
|
for t in oido_a:
|
||||||
|
print(f" oido: {t!r}")
|
||||||
|
if oido_a:
|
||||||
|
print(" OK el microfono llega a los altavoces")
|
||||||
|
else:
|
||||||
|
print(" MAL no se oyo nada: ¿altavoces en silencio? sin esto la")
|
||||||
|
print(" parte B no demuestra nada")
|
||||||
|
fallos += 1
|
||||||
|
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
# --- B: lo dice JARVIS, NO tiene que oirse ---------------------------
|
||||||
|
print("\n B · lo dice JARVIS (la guarda tiene que taparlo)")
|
||||||
|
captura = Captura(boca=boca)
|
||||||
|
def suelta_jarvis():
|
||||||
|
time.sleep(2.5)
|
||||||
|
boca.di(FRASE)
|
||||||
|
threading.Thread(target=suelta_jarvis, daemon=True).start()
|
||||||
|
oido_b = escucha_un_rato(captura, oreja, ESPERA)
|
||||||
|
|
||||||
|
# Lo que se juzga es si se colo LA FRASE, no si se transcribio algo.
|
||||||
|
#
|
||||||
|
# Esto costo una conclusion equivocada: probando con colas de 350, 700 y
|
||||||
|
# 1100 ms el resultado no era monotono —1100 fallaba y 700 no—, lo cual es
|
||||||
|
# imposible si el unico factor fuera el eco. Mirando lo que se colaba, eran
|
||||||
|
# "¡Adios!" y un parrafo inventado: Whisper ALUCINANDO sobre el silencio,
|
||||||
|
# que es otro problema distinto y da igual lo larga que sea la guarda.
|
||||||
|
eco = [t for t in oido_b if _se_parece(t, FRASE)]
|
||||||
|
alucinado = [t for t in oido_b if t not in eco]
|
||||||
|
|
||||||
|
for t in eco:
|
||||||
|
print(f" ECO: {t!r}")
|
||||||
|
for t in alucinado:
|
||||||
|
print(f" (whisper alucinando sobre el silencio: {t[:52]!r})")
|
||||||
|
|
||||||
|
if not eco:
|
||||||
|
print(" OK JARVIS no se oye a si mismo")
|
||||||
|
else:
|
||||||
|
print(" MAL se ha oido hablar: la guarda no tapa")
|
||||||
|
fallos += 1
|
||||||
|
|
||||||
|
oreja.para()
|
||||||
|
print(f"\n {'todo en orden' if not fallos else str(fallos) + ' fallan'}")
|
||||||
|
return 1 if fallos else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
173
nucleo/pruebas/prueba_manos.py
Executable file
173
nucleo/pruebas/prueba_manos.py
Executable file
|
|
@ -0,0 +1,173 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""El emparejador: que siga acertando y, sobre todo, que no se invente ordenes.
|
||||||
|
|
||||||
|
./venv/bin/python pruebas/prueba_manos.py
|
||||||
|
|
||||||
|
Porta las comprobaciones de `pruebas/08_acciones.sh` de la epoca de Newelle y
|
||||||
|
añade las que hacen falta ahora que el emparejador compara tambien por sonido.
|
||||||
|
|
||||||
|
La regla que ordena este fichero: **equivocarse cuesta mas que no reaccionar.**
|
||||||
|
Esto lanza comandos en la maquina; que no entienda una orden es molesto, que
|
||||||
|
ejecute la que no es puede costar trabajo perdido. Por eso las comprobaciones de
|
||||||
|
falsos positivos son las que mandan, y por eso el suelo de acierto va aparte y
|
||||||
|
mas bajo de lo medido: para que una mejora legitima no obligue a tocar el test,
|
||||||
|
pero una regresion de verdad lo tire.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, AQUI)
|
||||||
|
|
||||||
|
from manos.acciones import Catalogo # noqa: E402
|
||||||
|
from manos.fonetica import fonetica # noqa: E402
|
||||||
|
from oido.whisper import es_ruido # noqa: E402
|
||||||
|
|
||||||
|
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
|
||||||
|
BANCO = os.path.join(AQUI, "datos", "banco.json")
|
||||||
|
|
||||||
|
# Medido el 15 de agosto de 2026: 84,7 %. El suelo se pone tres puntos por
|
||||||
|
# debajo para dejar sitio al ruido de un banco de 150, donde una frase es 0,67.
|
||||||
|
SUELO = 81.0
|
||||||
|
|
||||||
|
# Lo que NO puede disparar una accion jamas. Las cinco primeras vienen del test
|
||||||
|
# 08; el resto son conversacion que empieza igual que una orden, que es
|
||||||
|
# exactamente el riesgo de comparar por sonido.
|
||||||
|
CHARLA = [
|
||||||
|
"necesito un abrazo", "quiero contarte una cosa", "necesito pensar",
|
||||||
|
"puedes explicarme como funciona el kernel", "me gustaria que fueramos amigos",
|
||||||
|
"que tal estas", "como te encuentras hoy", "cuentame un chiste",
|
||||||
|
"que opinas de la musica electronica", "me apetece un cafe",
|
||||||
|
"hoy hace un dia estupendo", "estoy pensando en cambiar de trabajo",
|
||||||
|
"quien fue alan turing", "explicame la teoria de la relatividad",
|
||||||
|
"me gusta mucho como suena tu voz", "cuanto tiempo llevas funcionando",
|
||||||
|
"no se que hacer este fin de semana", "que peliculas me recomiendas",
|
||||||
|
"he tenido un dia muy largo", "sabes cantar",
|
||||||
|
"no quiero que abras nada", "no busques nada por ahora",
|
||||||
|
"antes buscabas mejor las cosas", "el disco duro de mi hermano se rompio",
|
||||||
|
"la memoria me falla ultimamente", "que temperatura hace en la calle",
|
||||||
|
"me han abierto una cuenta en el banco", "cierro los ojos y pienso",
|
||||||
|
"mi ventana da al patio", "la pantalla del movil se ha roto",
|
||||||
|
"ese proceso judicial fue largo", "tengo un fichero de esos antiguos",
|
||||||
|
"el kernel de la cuestion es otro", "hablame de la red de metro",
|
||||||
|
"pon atencion a lo que te digo", "sube el animo que no pasa nada",
|
||||||
|
"baja la voz que estan durmiendo", "abre tu corazon",
|
||||||
|
"escanea el documento con la impresora", "mata el tiempo como puedas",
|
||||||
|
"el volumen de trabajo es enorme", "que hora era cuando llegaste",
|
||||||
|
"las descargas de agua fueron fuertes", "mi carpeta de fotos favorita",
|
||||||
|
"el brillo de sus ojos", "conecta conmigo emocionalmente",
|
||||||
|
"quiero que sepas que me importa", "necesito que me escuches",
|
||||||
|
"puedes decirme si estoy equivocado", "me gustaria saber tu opinion",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Como habla la gente de verdad. Sin quitar la cortesia no encajaba ninguna:
|
||||||
|
# era el fallo de casi todas las ordenes reales que se perdian.
|
||||||
|
CON_CORTESIA = [
|
||||||
|
("Necesito que pongas la película de Iron Man.", "pelicula_abrir"),
|
||||||
|
("¿Puedes poner la película de Iron Man?", "pelicula_abrir"),
|
||||||
|
("Necesito que me abras una terminal.", "terminal_abrir"),
|
||||||
|
("¿Podrías abrir una terminal?", "terminal_abrir"),
|
||||||
|
("Me puedes decir cuánto espacio queda.", "disco_libre"),
|
||||||
|
]
|
||||||
|
|
||||||
|
fallos = []
|
||||||
|
|
||||||
|
|
||||||
|
def afirma(condicion, queja):
|
||||||
|
if condicion:
|
||||||
|
print(f" OK {queja}")
|
||||||
|
else:
|
||||||
|
print(f" MAL {queja}")
|
||||||
|
fallos.append(queja)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
cat = Catalogo([CATALOGO])
|
||||||
|
|
||||||
|
print("\n el catalogo")
|
||||||
|
afirma(len(cat.acciones) >= 150, f"{len(cat.acciones)} acciones cargadas")
|
||||||
|
afirma(not cat.errores, f"sin errores de carga ({len(cat.errores)})")
|
||||||
|
ids = [a.id for a in cat.acciones]
|
||||||
|
afirma(len(ids) == len(set(ids)), "no hay ids repetidos")
|
||||||
|
afirma(all(a.acuse for a in cat.acciones), "todas tienen acuse")
|
||||||
|
afirma(all("{argumento" in a.comando for a in cat.acciones if a.captura),
|
||||||
|
"las que capturan argumento lo usan en el comando")
|
||||||
|
afirma(all(a.confirmar for a in cat.acciones if "rm -rf" in a.comando),
|
||||||
|
"nada que borre se ejecuta sin confirmar")
|
||||||
|
|
||||||
|
print("\n entiende como habla la gente")
|
||||||
|
for frase, esperado in CON_CORTESIA:
|
||||||
|
a, _ = cat.busca(frase)
|
||||||
|
afirma(a is not None and a.id == esperado,
|
||||||
|
f"'{frase[:38]}' -> {esperado}"
|
||||||
|
+ ("" if a is None or a.id == esperado else f" (fue a {a.id})"))
|
||||||
|
|
||||||
|
print("\n y NO se inventa ordenes")
|
||||||
|
coladas = [(c, cat.busca(c)[0]) for c in CHARLA]
|
||||||
|
coladas = [(c, a.id) for c, a in coladas if a is not None]
|
||||||
|
afirma(not coladas,
|
||||||
|
f"ninguna de las {len(CHARLA)} frases de charla dispara nada"
|
||||||
|
+ ("" if not coladas else f" — se colo {coladas[0][0]!r} -> {coladas[0][1]}"))
|
||||||
|
|
||||||
|
print("\n el argumento viene de un microfono: no puede ser una orden")
|
||||||
|
for veneno in ["gatos; rm -rf /", 'algo " ; touch /tmp/jarvis_pwned ; "']:
|
||||||
|
a, arg = cat.busca(f"busca en internet {veneno}")
|
||||||
|
if a is None:
|
||||||
|
fallos.append(f"no encaja la busqueda con {veneno!r}")
|
||||||
|
print(f" MAL no encaja la busqueda con {veneno!r}")
|
||||||
|
continue
|
||||||
|
comando = a.comando.replace("{argumento_url}", shlex.quote(arg))
|
||||||
|
comando = comando.replace("{argumento}", shlex.quote(arg))
|
||||||
|
afirma("; rm" not in comando and "; touch" not in comando,
|
||||||
|
f"entrecomillado: {veneno[:24]!r}")
|
||||||
|
|
||||||
|
print("\n el filtro de alucinaciones de Whisper")
|
||||||
|
for t in ["[Música]", "(aplausos)", "[motor]", "¡Suscríbete!", ""]:
|
||||||
|
afirma(es_ruido(t), f"descarta {t!r}")
|
||||||
|
for t in ["cuanto espacio queda", "abre la terminal"]:
|
||||||
|
afirma(not es_ruido(t), f"deja pasar {t!r}")
|
||||||
|
|
||||||
|
print("\n la comparacion por sonido")
|
||||||
|
# Lo que se afirma es que ACERCA, no que iguale: `parecido()` se queda con
|
||||||
|
# el mayor de los dos parecidos, asi que basta con que el del sonido suba.
|
||||||
|
# Exigir igualdad seria pedirle a esto que fuera un Metaphone, y no lo es
|
||||||
|
# a proposito: comprimir mas junta ordenes que no se parecen.
|
||||||
|
import difflib
|
||||||
|
def sube(a, b):
|
||||||
|
letra = difflib.SequenceMatcher(None, a, b).ratio()
|
||||||
|
sonido = difflib.SequenceMatcher(None, fonetica(a), fonetica(b)).ratio()
|
||||||
|
return sonido > letra
|
||||||
|
afirma(sube("abre van kamp", "abre bandcamp"), "'van kamp' se acerca a 'bandcamp'")
|
||||||
|
afirma(fonetica("haz") == fonetica("has"), "'haz' suena igual que 'has'")
|
||||||
|
afirma(fonetica("llave") == fonetica("yave"), "'yave' suena igual que 'llave'")
|
||||||
|
afirma(fonetica("bino") == fonetica("vino"), "la b y la v son el mismo sonido")
|
||||||
|
# Y lo que importa de verdad: que no acerque lo que no se parece.
|
||||||
|
afirma(not sube("abre la terminal", "apaga la pantalla"),
|
||||||
|
"y NO acerca ordenes distintas")
|
||||||
|
|
||||||
|
print("\n el banco fijo")
|
||||||
|
if not os.path.exists(BANCO):
|
||||||
|
print(" ·· no hay banco; crealo con pruebas/banco.py --crear")
|
||||||
|
else:
|
||||||
|
casos = json.load(open(BANCO, encoding="utf-8"))["casos"]
|
||||||
|
bien = otra = 0
|
||||||
|
for c in casos:
|
||||||
|
a, _ = cat.busca(c["oido"])
|
||||||
|
if a is None:
|
||||||
|
continue
|
||||||
|
if a.id == c["id"]:
|
||||||
|
bien += 1
|
||||||
|
else:
|
||||||
|
otra += 1
|
||||||
|
pct = 100 * bien / len(casos)
|
||||||
|
afirma(pct >= SUELO, f"acierto {pct:.1f} % sobre {len(casos)} (suelo {SUELO})")
|
||||||
|
afirma(otra == 0, f"ninguna orden acaba en la accion equivocada ({otra})")
|
||||||
|
|
||||||
|
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
|
||||||
|
return 1 if fallos else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
243
nucleo/pruebas/prueba_voz.py
Executable file
243
nucleo/pruebas/prueba_voz.py
Executable file
|
|
@ -0,0 +1,243 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""La cadena de voz entera, con altavoces y microfono de verdad.
|
||||||
|
|
||||||
|
./venv/bin/python pruebas/prueba_voz.py
|
||||||
|
|
||||||
|
Las otras pruebas miran piezas: que el emparejador acierte, que el candado
|
||||||
|
cierre, que el puente mande los campos que el HUD lee. Ninguna contesta a la
|
||||||
|
pregunta que de verdad importa —**si le hablo, ¿me contesta?**— y por eso el
|
||||||
|
fallo de que oia bien pero descartaba en silencio llego hasta el usuario.
|
||||||
|
|
||||||
|
Esta habla por los altavoces y escucha por el microfono, como una persona. Es
|
||||||
|
lenta y necesita el volumen puesto, pero es la unica que prueba lo que se usa.
|
||||||
|
|
||||||
|
## Como funciona
|
||||||
|
|
||||||
|
Se genera la frase con Piper, se reproduce POR FUERA de la boca de JARVIS —con
|
||||||
|
un reproductor a pelo, para que la guarda del eco no la tape— y se mira que la
|
||||||
|
cadena entera reaccione. Que el microfono llega a los altavoces ya lo demuestra
|
||||||
|
prueba_eco.py; aqui se da por hecho y se avisa si falla.
|
||||||
|
|
||||||
|
## Lo que se comprueba, en orden
|
||||||
|
|
||||||
|
1. sin llamarle se oye pero NO se atiende, y se dice por que
|
||||||
|
2. llamandole se atiende y contesta
|
||||||
|
3. encadenando la siguiente sin repetir el nombre
|
||||||
|
4. como lo oye Whisper "Yarvis" y "Charles" tambien valen
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, AQUI)
|
||||||
|
|
||||||
|
from boca.voz import Boca # noqa: E402
|
||||||
|
from manos import Manos # noqa: E402
|
||||||
|
from oido.captura import Captura # noqa: E402
|
||||||
|
from oido.despierta import Centinela, NOMBRE # noqa: E402
|
||||||
|
from oido.whisper import Oido, es_ruido # noqa: E402
|
||||||
|
from jarvis import atiende # noqa: E402
|
||||||
|
|
||||||
|
ESPERA = 16 # cuanto se escucha por cada frase dicha
|
||||||
|
fallos = []
|
||||||
|
ajenas = [] # lo que se oyo y no era de la prueba: ruido de la sala
|
||||||
|
|
||||||
|
|
||||||
|
def _se_parece(oido: str, dicho: str) -> bool:
|
||||||
|
"""Si lo transcrito es la frase que se reprodujo, aunque venga maltratada.
|
||||||
|
|
||||||
|
Hace falta porque la prueba usa el microfono de la habitacion: si hay una
|
||||||
|
tele, una conversacion o un video sonando, el bucle oye ESO y una prueba
|
||||||
|
ingenua lo da por bueno. Ha pasado —una pasada dio por valida "¡No te
|
||||||
|
olvides de todo ese chico!"— y una prueba que se cree cualquier ruido no
|
||||||
|
prueba nada.
|
||||||
|
"""
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
def palabras(t):
|
||||||
|
t = unicodedata.normalize("NFD", (t or "").lower())
|
||||||
|
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||||
|
return {p.strip(".,¡!¿?«»") for p in t.split() if len(p) > 3}
|
||||||
|
|
||||||
|
a, b = palabras(oido), palabras(dicho)
|
||||||
|
if not b:
|
||||||
|
return False
|
||||||
|
return len(a & b) >= max(1, min(2, len(b) - 1))
|
||||||
|
|
||||||
|
|
||||||
|
def afirma(condicion, queja):
|
||||||
|
print(f" {'OK ' if condicion else 'MAL'} {queja}")
|
||||||
|
if not condicion:
|
||||||
|
fallos.append(queja)
|
||||||
|
|
||||||
|
|
||||||
|
class Escucha:
|
||||||
|
"""El bucle de voz de jarvis.py, reducido a lo que hace falta probar."""
|
||||||
|
|
||||||
|
def __init__(self, boca, oreja, centinela):
|
||||||
|
self.boca, self.oreja, self.centinela = boca, oreja, centinela
|
||||||
|
self.manos = Manos()
|
||||||
|
self.oido = [] # (texto, atendido, motivo)
|
||||||
|
self.dicho = [] # lo que ha contestado
|
||||||
|
self._para = threading.Event()
|
||||||
|
self._hilo = None
|
||||||
|
|
||||||
|
def arranca(self):
|
||||||
|
self._hilo = threading.Thread(target=self._bucle, daemon=True)
|
||||||
|
self._hilo.start()
|
||||||
|
|
||||||
|
def _bucle(self):
|
||||||
|
cap = Captura(boca=self.boca)
|
||||||
|
for wav in cap.frases(para_cuando=lambda: self._para.is_set()):
|
||||||
|
try:
|
||||||
|
texto = self.oreja.transcribe(wav)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(wav)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if es_ruido(texto):
|
||||||
|
continue
|
||||||
|
|
||||||
|
atender, orden, _ = self.centinela.filtra(texto)
|
||||||
|
if not atender:
|
||||||
|
self.oido.append((texto, False, "no le han llamado"))
|
||||||
|
continue
|
||||||
|
self.oido.append((texto, True, ""))
|
||||||
|
respuesta = atiende(orden, self.manos, None, traza=lambda t: None)
|
||||||
|
if respuesta:
|
||||||
|
self.dicho.append(respuesta)
|
||||||
|
cap.para()
|
||||||
|
|
||||||
|
def para(self):
|
||||||
|
self._para.set()
|
||||||
|
if self._hilo:
|
||||||
|
self._hilo.join(timeout=6)
|
||||||
|
|
||||||
|
def suelta(self, frase, espera=ESPERA):
|
||||||
|
"""Dice la frase POR FUERA de JARVIS y espera a que reaccione.
|
||||||
|
|
||||||
|
Solo cuenta lo que se PARECE a lo reproducido: si hay ruido en la
|
||||||
|
habitacion, el bucle lo oye tambien y darlo por bueno haria pasar la
|
||||||
|
prueba sin haber probado nada.
|
||||||
|
"""
|
||||||
|
antes_oido, antes_dicho = len(self.oido), len(self.dicho)
|
||||||
|
wav = self.boca._genera(frase)
|
||||||
|
if not wav:
|
||||||
|
return None, None
|
||||||
|
time.sleep(0.6)
|
||||||
|
subprocess.run(["paplay", wav], capture_output=True)
|
||||||
|
|
||||||
|
fin = time.monotonic() + espera
|
||||||
|
mio = None
|
||||||
|
while time.monotonic() < fin:
|
||||||
|
for entrada in self.oido[antes_oido:]:
|
||||||
|
if _se_parece(entrada[0], frase):
|
||||||
|
mio = entrada
|
||||||
|
break
|
||||||
|
if mio and (not mio[1] or len(self.dicho) > antes_dicho):
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
nuevo_dicho = self.dicho[antes_dicho:]
|
||||||
|
ajeno = [o[0] for o in self.oido[antes_oido:] if o is not mio]
|
||||||
|
if ajeno:
|
||||||
|
ajenas.extend(ajeno)
|
||||||
|
print(f" (ruido de la habitacion, ignorado: {ajeno[0][:44]!r})")
|
||||||
|
return mio, (nuevo_dicho[-1] if nuevo_dicho else None)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
boca = Boca("davefx")
|
||||||
|
oreja = Oido(modelo="small")
|
||||||
|
if not oreja.disponible():
|
||||||
|
print(" no esta whisper o el modelo; no se puede probar")
|
||||||
|
return 2
|
||||||
|
print(" arrancando whisper...")
|
||||||
|
if not oreja.arranca():
|
||||||
|
print(" whisper no arranco")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
centinela = Centinela(activo=True)
|
||||||
|
esc = Escucha(boca, oreja, centinela)
|
||||||
|
esc.arranca()
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
print("\n 0 · con la palabra APAGADA (el defecto): atiende directo")
|
||||||
|
centinela.activo = False
|
||||||
|
oido, dicho = esc.suelta("Cuanta memoria queda.")
|
||||||
|
if oido:
|
||||||
|
print(f" oyo: {oido[0]!r}")
|
||||||
|
if dicho:
|
||||||
|
print(f" dijo: {dicho}")
|
||||||
|
afirma(oido is not None and oido[1],
|
||||||
|
"sin exigir el nombre, atiende lo que oiga")
|
||||||
|
afirma(dicho is not None, "y contesta")
|
||||||
|
centinela.activo = True
|
||||||
|
centinela.duerme()
|
||||||
|
|
||||||
|
print("\n 1 · con la palabra puesta y sin llamarle: oye pero no atiende")
|
||||||
|
oido, dicho = esc.suelta("Cuanto espacio queda en el disco.")
|
||||||
|
if oido is None:
|
||||||
|
print(" ·· no se oyo nada: ¿altavoces en silencio? sin esto el resto")
|
||||||
|
print(" de la prueba no demuestra nada")
|
||||||
|
fallos.append("el microfono no capta los altavoces")
|
||||||
|
else:
|
||||||
|
print(f" oyo: {oido[0]!r}")
|
||||||
|
afirma(not oido[1], "lo oye pero no lo atiende, porque no le han llamado")
|
||||||
|
afirma(dicho is None, "y no contesta")
|
||||||
|
|
||||||
|
centinela.duerme()
|
||||||
|
print(f"\n 2 · llamandole por su nombre")
|
||||||
|
oido, dicho = esc.suelta(f"{NOMBRE.capitalize()}, cuanto espacio queda.")
|
||||||
|
if oido:
|
||||||
|
print(f" oyo: {oido[0]!r}")
|
||||||
|
if dicho:
|
||||||
|
print(f" dijo: {dicho}")
|
||||||
|
afirma(oido is not None and oido[1], "lo atiende")
|
||||||
|
afirma(dicho is not None, "y contesta algo")
|
||||||
|
afirma(dicho is not None and any(c.isdigit() for c in dicho),
|
||||||
|
"con una cifra de verdad, no una excusa")
|
||||||
|
|
||||||
|
print("\n 3 · encadenando, sin repetir el nombre")
|
||||||
|
oido, dicho = esc.suelta("Cuanta memoria queda.")
|
||||||
|
if oido:
|
||||||
|
print(f" oyo: {oido[0]!r}")
|
||||||
|
afirma(oido is not None and oido[1],
|
||||||
|
"sigue despierto y atiende sin que le vuelvan a llamar")
|
||||||
|
|
||||||
|
print("\n 4 · como lo oye Whisper de verdad")
|
||||||
|
centinela.duerme()
|
||||||
|
oido, dicho = esc.suelta("Yarvis, que hora es.")
|
||||||
|
if oido:
|
||||||
|
print(f" oyo: {oido[0]!r}")
|
||||||
|
afirma(oido is not None and oido[1],
|
||||||
|
"'Yarvis' tambien le despierta (Whisper escribe el nombre de mil formas)")
|
||||||
|
|
||||||
|
esc.para()
|
||||||
|
oreja.para()
|
||||||
|
|
||||||
|
# Una prueba que usa el microfono de la sala no puede fallar en silencio por
|
||||||
|
# culpa de la sala. Si ha entrado mucho ruido, el resultado no dice nada del
|
||||||
|
# codigo y hay que decirlo, no soltar una pared de MAL.
|
||||||
|
if fallos and len(ajenas) >= 2:
|
||||||
|
print(f"\n ┌─ SALA RUIDOSA ─────────────────────────────────────────")
|
||||||
|
print(f" │ Han entrado {len(ajenas)} frases que no eran de la prueba.")
|
||||||
|
print(f" │ Con audio sonando cerca del microfono, este resultado NO")
|
||||||
|
print(f" │ dice nada del codigo. Ejemplos de lo que se colo:")
|
||||||
|
for a in ajenas[:3]:
|
||||||
|
print(f" │ {a[:52]!r}")
|
||||||
|
print(f" │ Repitelo en silencio.")
|
||||||
|
print(f" └────────────────────────────────────────────────────────")
|
||||||
|
print(f"\n {len(fallos)} fallan, pero no son concluyentes\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
|
||||||
|
return 1 if fallos else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
175
nucleo/pruebas/repasa_acciones.py
Executable file
175
nucleo/pruebas/repasa_acciones.py
Executable file
|
|
@ -0,0 +1,175 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Ejecuta TODAS las acciones del catalogo y dice cuales funcionan de verdad.
|
||||||
|
|
||||||
|
Por que hace falta: hasta hoy los comandos libres del modelo corrian dentro del
|
||||||
|
sandbox, donde no hay ni el sistema ni los discos. Se arreglo (virtualization a
|
||||||
|
false), pero las 134 acciones del catalogo nunca se habian ejecutado todas
|
||||||
|
seguidas contra la maquina real: la suite prueba que las FRASES caen en la
|
||||||
|
accion correcta, no que el comando de cada accion haga algo util.
|
||||||
|
|
||||||
|
Que se ejecuta y que no:
|
||||||
|
|
||||||
|
- Las destructivas (`confirmar: true`) y las que piden root NO se ejecutan.
|
||||||
|
En uso normal pasan por un dialogo que aprueba una persona; aqui no hay
|
||||||
|
persona, y aprobarlas solas seria justo lo que el diseño evita.
|
||||||
|
- Las que abren ventanas (firefox, xdg-open) se ejecutan solo con --abrir,
|
||||||
|
porque llenan la pantalla. Sin esa opcion se comprueba que el programa
|
||||||
|
existe, que es lo que puede fallar.
|
||||||
|
- El resto se ejecuta entero y se mira que conteste algo con sentido.
|
||||||
|
|
||||||
|
python repasar_acciones.py [--abrir] [--grupo sistema] [--json salida.json]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
RAIZ = os.path.dirname(AQUI) # la capa JARVIS, para los argumentos de prueba
|
||||||
|
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
|
||||||
|
|
||||||
|
# Argumentos de mentira para las acciones que capturan uno. Se eligen inocuos:
|
||||||
|
# nada que borre, mueva o mande nada fuera.
|
||||||
|
ARGUMENTOS = {
|
||||||
|
"buscar_fichero": "README.md",
|
||||||
|
"buscar_carpeta": "config",
|
||||||
|
"grep_recursivo": "JARVIS",
|
||||||
|
"leer_fichero": "README.md", # un NOMBRE, que es lo que se dice por voz
|
||||||
|
"abrir_carpeta": RAIZ,
|
||||||
|
"tamano_carpeta": os.path.join(RAIZ, "config"),
|
||||||
|
"buscar_youtube": "musica",
|
||||||
|
"buscar_wikipedia": "madrid",
|
||||||
|
"buscar_github": "newelle",
|
||||||
|
"traducir": "hola",
|
||||||
|
"buscar_web": "que hora es",
|
||||||
|
"matar_proceso": "proceso_que_no_existe_jarvis",
|
||||||
|
"ping": "127.0.0.1",
|
||||||
|
}
|
||||||
|
POR_DEFECTO = "prueba"
|
||||||
|
|
||||||
|
# Lo que abre o MUEVE ventanas. ventanas.sh reordena el escritorio del usuario:
|
||||||
|
# ejecutarlo en un repaso automatico le cambia las ventanas de sitio sin avisar.
|
||||||
|
VENTANA = re.compile(r"\b(firefox|xdg-open|gnome-terminal|nautilus|vlc|mpv|eog|"
|
||||||
|
r"gedit|gnome-text-editor|totem|ventanas\.sh|xdotool|wmctrl)\b")
|
||||||
|
|
||||||
|
|
||||||
|
def carga():
|
||||||
|
with open(CATALOGO, encoding="utf-8") as f:
|
||||||
|
d = json.load(f)
|
||||||
|
return d.get("acciones", d if isinstance(d, list) else [])
|
||||||
|
|
||||||
|
|
||||||
|
def prefijo_host():
|
||||||
|
"""Las acciones llevan host:true y en uso salen del sandbox. Aqui ya
|
||||||
|
estamos fuera, asi que no hace falta prefijo."""
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def ejecuta(accion, argumento, tope=25):
|
||||||
|
import shlex
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
comando = accion.get("comando") or ""
|
||||||
|
comando = comando.replace("{argumento}", shlex.quote(argumento))
|
||||||
|
comando = comando.replace("{argumento_url}",
|
||||||
|
shlex.quote(urllib.parse.quote_plus(argumento)))
|
||||||
|
t0 = time.monotonic()
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["bash", "-lc", comando], capture_output=True,
|
||||||
|
text=True, errors="replace", timeout=tope)
|
||||||
|
salida = (r.stdout or r.stderr or "").strip()
|
||||||
|
return r.returncode, salida, time.monotonic() - t0
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return -1, f"(no termino en {tope}s)", time.monotonic() - t0
|
||||||
|
except OSError as e:
|
||||||
|
return -2, f"(no se pudo ejecutar: {e})", time.monotonic() - t0
|
||||||
|
|
||||||
|
|
||||||
|
def programa_existe(comando):
|
||||||
|
"""El primer ejecutable de la orden, para las que abren o mueven ventanas.
|
||||||
|
|
||||||
|
Algunos son ordenes del PATH (firefox) y otros son scripts nuestros con
|
||||||
|
ruta completa (config/ventanas.sh): `command -v` solo sirve para los
|
||||||
|
primeros, y con los segundos hay que mirar el fichero.
|
||||||
|
"""
|
||||||
|
m = VENTANA.search(comando)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
nombre = m.group(1)
|
||||||
|
if nombre.endswith(".sh"):
|
||||||
|
# sacar la ruta entera tal como aparece en el comando
|
||||||
|
for trozo in comando.split():
|
||||||
|
if trozo.endswith(nombre):
|
||||||
|
ruta = os.path.expandvars(os.path.expanduser(trozo))
|
||||||
|
return os.access(ruta, os.X_OK)
|
||||||
|
return False
|
||||||
|
return subprocess.run(["bash", "-lc", f"command -v {nombre}"],
|
||||||
|
capture_output=True).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--abrir", action="store_true",
|
||||||
|
help="ejecutar tambien las que abren ventanas")
|
||||||
|
p.add_argument("--grupo")
|
||||||
|
p.add_argument("--json")
|
||||||
|
p.add_argument("--tope", type=int, default=25)
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
acciones = carga()
|
||||||
|
if args.grupo:
|
||||||
|
acciones = [a for a in acciones if a.get("grupo") == args.grupo]
|
||||||
|
|
||||||
|
resultados = []
|
||||||
|
print(f" repasando {len(acciones)} acciones"
|
||||||
|
f"{' del grupo ' + args.grupo if args.grupo else ''}\n", flush=True)
|
||||||
|
|
||||||
|
for a in acciones:
|
||||||
|
ident = a.get("id", "?")
|
||||||
|
comando = a.get("comando") or ""
|
||||||
|
arg = ARGUMENTOS.get(ident, POR_DEFECTO if a.get("captura") else "")
|
||||||
|
|
||||||
|
if not comando:
|
||||||
|
estado, detalle = "solo habla", a.get("acuse", "")[:50]
|
||||||
|
elif a.get("confirmar") or "sudo" in comando:
|
||||||
|
# no se aprueban solas: en uso las aprueba una persona
|
||||||
|
estado, detalle = "saltada", "destructiva o con root"
|
||||||
|
elif VENTANA.search(comando) and not args.abrir:
|
||||||
|
hay = programa_existe(comando)
|
||||||
|
estado = "programa ok" if hay else "FALTA PROGRAMA"
|
||||||
|
detalle = VENTANA.search(comando).group(1)
|
||||||
|
else:
|
||||||
|
codigo, salida, tardo = ejecuta(a, arg, args.tope)
|
||||||
|
una_linea = " ".join(salida.split())[:70]
|
||||||
|
if codigo == 0:
|
||||||
|
estado = "ok" if salida else "ok (sin salida)"
|
||||||
|
else:
|
||||||
|
estado = f"FALLO ({codigo})"
|
||||||
|
detalle = f"{tardo:4.1f}s {una_linea}"
|
||||||
|
|
||||||
|
resultados.append({"id": ident, "grupo": a.get("grupo"),
|
||||||
|
"estado": estado, "detalle": detalle})
|
||||||
|
marca = "!" if "FALLO" in estado or "FALTA" in estado else " "
|
||||||
|
print(f" {marca} {ident:26s} {estado:16s} {detalle}", flush=True)
|
||||||
|
|
||||||
|
print()
|
||||||
|
from collections import Counter
|
||||||
|
c = Counter(r["estado"].split(" (")[0].split(" ")[0] for r in resultados)
|
||||||
|
for k, v in c.most_common():
|
||||||
|
print(f" {v:3d} {k}")
|
||||||
|
malas = [r for r in resultados if "FALLO" in r["estado"] or "FALTA" in r["estado"]]
|
||||||
|
print(f"\n {len(resultados) - len(malas)} de {len(resultados)} responden bien")
|
||||||
|
if malas:
|
||||||
|
print(" fallan: " + ", ".join(r["id"] for r in malas))
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
with open(args.json, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(resultados, f, ensure_ascii=False, indent=2)
|
||||||
|
return 1 if malas else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
0
nucleo/saber/__init__.py
Normal file
0
nucleo/saber/__init__.py
Normal file
159
nucleo/saber/busca.py
Normal file
159
nucleo/saber/busca.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
"""Buscar en el saber de COFRE lo que hace falta para contestar una pregunta.
|
||||||
|
|
||||||
|
Esto es la mitad "recuperar" de RAG: dada una pregunta, devuelve los fragmentos
|
||||||
|
de los apuntes que mas se le parecen, para pasarselos al cerebro. El cerebro
|
||||||
|
construye la respuesta desde ESE texto, no desde lo que recuerde, que es lo que
|
||||||
|
evita que se invente flags.
|
||||||
|
|
||||||
|
## Por que se carga una vez y se guarda
|
||||||
|
|
||||||
|
El indice son 2.866 vectores. Cargarlo y encodear la pregunta cuesta, pero solo
|
||||||
|
la primera vez: el modelo y los vectores se quedan en memoria. Una consulta
|
||||||
|
despues es un producto escalar contra 2.866 filas, milisegundos.
|
||||||
|
|
||||||
|
## Por que no una base de datos vectorial
|
||||||
|
|
||||||
|
FAISS, Chroma y compañia son para millones de vectores. Con 2.866, numpy y un
|
||||||
|
producto escalar es mas rapido de arrancar, no añade una dependencia pesada, y
|
||||||
|
el indice es un jsonl que se puede abrir y leer con los ojos.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
INDICE = os.path.join(AQUI, "datos", "saber.jsonl")
|
||||||
|
|
||||||
|
_modelo = None
|
||||||
|
_entradas = None
|
||||||
|
_matriz = None
|
||||||
|
|
||||||
|
|
||||||
|
def _carga():
|
||||||
|
global _modelo, _entradas, _matriz
|
||||||
|
if _entradas is not None:
|
||||||
|
return _entradas is not False
|
||||||
|
|
||||||
|
if not os.path.exists(INDICE):
|
||||||
|
_entradas = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from model2vec import StaticModel
|
||||||
|
|
||||||
|
entradas, vectores = [], []
|
||||||
|
with open(INDICE, encoding="utf-8") as f:
|
||||||
|
for l in f:
|
||||||
|
try:
|
||||||
|
d = json.loads(l)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
v = d.pop("v", None)
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
entradas.append(d)
|
||||||
|
vectores.append(v)
|
||||||
|
|
||||||
|
_entradas = entradas
|
||||||
|
_matriz = np.array(vectores, dtype="float32")
|
||||||
|
# normalizar una vez: asi la similitud es un simple producto escalar
|
||||||
|
normas = np.linalg.norm(_matriz, axis=1, keepdims=True)
|
||||||
|
_matriz = _matriz / np.clip(normas, 1e-9, None)
|
||||||
|
_modelo = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(t: str) -> str:
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
t = unicodedata.normalize("NFD", (t or "").lower())
|
||||||
|
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||||
|
return re.sub(r"[^a-z0-9 ]", " ", t)
|
||||||
|
|
||||||
|
|
||||||
|
# El texto normalizado de cada entrada y su nombre, cacheados para el bono.
|
||||||
|
_texto_norm = None
|
||||||
|
_nombre_norm = None
|
||||||
|
|
||||||
|
|
||||||
|
def _bono_palabras(palabras):
|
||||||
|
"""Un vector de bonos, uno por entrada, segun cuantas palabras compartan."""
|
||||||
|
import numpy as np
|
||||||
|
global _texto_norm, _nombre_norm
|
||||||
|
if _texto_norm is None:
|
||||||
|
_texto_norm = [set(_norm(e["texto"]).split()) for e in _entradas]
|
||||||
|
_nombre_norm = [_norm(e.get("herramienta", "")) for e in _entradas]
|
||||||
|
bono = np.zeros(len(_entradas), dtype="float32")
|
||||||
|
for i, (palabras_e, nombre) in enumerate(zip(_texto_norm, _nombre_norm)):
|
||||||
|
comunes = len(palabras & palabras_e)
|
||||||
|
b = 0.08 * comunes # cada palabra compartida suma
|
||||||
|
if nombre and nombre in palabras: # y nombrar la herramienta, mucho
|
||||||
|
b += 0.5
|
||||||
|
bono[i] = b
|
||||||
|
return bono
|
||||||
|
|
||||||
|
|
||||||
|
def busca(pregunta: str, cuantos: int = 5, perfil: str = None) -> list:
|
||||||
|
"""Los fragmentos mas parecidos a la pregunta, mejor primero.
|
||||||
|
|
||||||
|
Cada uno lleva su similitud (0-1), de que herramienta y fichero viene, y el
|
||||||
|
texto. perfil filtra por carpeta de COFRE si se quiere acotar.
|
||||||
|
"""
|
||||||
|
if not _carga() or not pregunta.strip():
|
||||||
|
return []
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
q = _modelo.encode([pregunta])[0]
|
||||||
|
q = q / max(float(np.linalg.norm(q)), 1e-9)
|
||||||
|
sim = _matriz @ q
|
||||||
|
|
||||||
|
# Impulso por palabras exactas (busqueda hibrida).
|
||||||
|
#
|
||||||
|
# Los embeddings estaticos fallan cuando la pregunta va en castellano y el
|
||||||
|
# texto en ingles —"cambiar permisos" no se parece a "change file mode
|
||||||
|
# bits"—. Pero si la pregunta NOMBRA la herramienta ("como uso chmod") o
|
||||||
|
# comparte palabras con el texto, eso es una señal fuerte que la similitud
|
||||||
|
# semantica sola no aprovecha. Se suma un bono por cada palabra de la
|
||||||
|
# pregunta que aparezca, y uno grande si coincide el nombre de la
|
||||||
|
# herramienta: asi "usa nmap para..." lleva nmap al primer puesto.
|
||||||
|
palabras = {w for w in _norm(pregunta).split() if len(w) > 3}
|
||||||
|
if palabras:
|
||||||
|
bono = _bono_palabras(palabras)
|
||||||
|
sim = sim + bono
|
||||||
|
|
||||||
|
orden = np.argsort(-sim)[: cuantos * 6]
|
||||||
|
salida, vistos = [], set()
|
||||||
|
for i in orden:
|
||||||
|
e = _entradas[i]
|
||||||
|
if perfil and e.get("perfil") != perfil:
|
||||||
|
continue
|
||||||
|
# sin duplicados: OSCP_APUNTES es copia de los apuntes de primer nivel.
|
||||||
|
# Se comparan los primeros 120 caracteres, que basta para reconocerlos.
|
||||||
|
firma = e["texto"][:120]
|
||||||
|
if firma in vistos:
|
||||||
|
continue
|
||||||
|
vistos.add(firma)
|
||||||
|
salida.append({**e, "sim": round(float(sim[i]), 3)})
|
||||||
|
if len(salida) >= cuantos:
|
||||||
|
break
|
||||||
|
return salida
|
||||||
|
|
||||||
|
|
||||||
|
def contexto(pregunta: str, cuantos: int = 5, minimo: float = 0.35) -> str:
|
||||||
|
"""Lo mismo, pero ya montado como texto para meterle al cerebro.
|
||||||
|
|
||||||
|
Se corta por debajo de `minimo` de similitud: pasarle al modelo fragmentos
|
||||||
|
que no vienen a cuento es lo que le hace mezclar herramientas que no tienen
|
||||||
|
nada que ver. Mejor darle poco y bueno que mucho y ruidoso.
|
||||||
|
"""
|
||||||
|
trozos = [t for t in busca(pregunta, cuantos) if t["sim"] >= minimo]
|
||||||
|
if not trozos:
|
||||||
|
return ""
|
||||||
|
lineas = ["De los apuntes de COFRE del usuario:\n"]
|
||||||
|
for t in trozos:
|
||||||
|
fuente = t.get("fichero") or f"{t['perfil']}/{t['herramienta']}"
|
||||||
|
lineas.append(f"--- {fuente} ---\n{t['texto']}\n")
|
||||||
|
return "\n".join(lineas)
|
||||||
|
|
||||||
|
|
||||||
|
def disponible() -> bool:
|
||||||
|
return os.path.exists(INDICE)
|
||||||
110
nucleo/saber/enriquece/README.md
Normal file
110
nucleo/saber/enriquece/README.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Enriquecer el RAG a nivel de pentesting profesional
|
||||||
|
|
||||||
|
El RAG del naranja ya funcionaba, pero se dejaba fuera lo mejor que hay en el
|
||||||
|
disco. Medido antes de empezar: **de las 72 notas del `OSCP_Vault` —la
|
||||||
|
metodologia OSCP escrita a mano, con comandos reales— había 0 en el índice.**
|
||||||
|
`indexa.py` las descartaba por dos motivos a la vez: el nombre (`SQL
|
||||||
|
injection.md` no lleva "notas" ni "guia") y la profundidad (viven a 5 niveles).
|
||||||
|
|
||||||
|
Este pipeline las rescata, multiplica su recuperabilidad con preguntas
|
||||||
|
generadas, y **mide** que el resultado es mejor antes de tocar nada.
|
||||||
|
|
||||||
|
## Correrlo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./correr_noche.sh # las cuatro fases, y decide si promociona
|
||||||
|
./correr_noche.sh --sin-promo # igual, pero deja el vivo intacto
|
||||||
|
```
|
||||||
|
|
||||||
|
Es reanudable: cada fase se salta si ya está hecha, así que si se corta,
|
||||||
|
relanzarlo continúa. La fase larga (síntesis) es reanudable fragmento a
|
||||||
|
fragmento.
|
||||||
|
|
||||||
|
## Las cuatro fases
|
||||||
|
|
||||||
|
| | Script | Qué hace | Coste |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `cosecha.py` | rescata la metodología de COFRE que faltaba | segundos, CPU |
|
||||||
|
| 2 | `sintetiza.py` | preguntas en castellano por cada fragmento | **horas**, modelo local |
|
||||||
|
| 3 | `reindexa.py` | une todo y calcula vectores → `saber.jsonl.nuevo` | minutos, CPU |
|
||||||
|
| 4 | `evalua.py` | mide recuperación vivo vs candidato | segundos |
|
||||||
|
|
||||||
|
### 1 · Cosecha
|
||||||
|
|
||||||
|
Donde `indexa.py` es prudente (solo ficheros que *parecen* documentación, 3
|
||||||
|
niveles), esto es agresivo con el oro: las carpetas de metodología (OSCP vaults,
|
||||||
|
apuntes, cheatsheets) enteras, sin límite de profundidad. Fuera de ahí pone un
|
||||||
|
**tope por herramienta**: si un repo tiene más de 25 documentos, es una base de
|
||||||
|
datos (exploitdb son 29.925 ficheros), no unos apuntes, y solo se coge su
|
||||||
|
README. Así el oro no se ahoga en ruido.
|
||||||
|
|
||||||
|
### 2 · Síntesis — indexación multi-representación
|
||||||
|
|
||||||
|
El punto flaco de los embeddings estáticos: "cómo saco una shell reversa" no se
|
||||||
|
parece vectorialmente a `bash -i >& /dev/tcp/...`. La solución es **embeber la
|
||||||
|
pregunta y devolver el fragmento**. Por cada fragmento, el modelo local escribe
|
||||||
|
las preguntas que responde; se indexa la pregunta, pero lo que se le muestra al
|
||||||
|
cerebro es el fragmento **literal**.
|
||||||
|
|
||||||
|
Clave: el modelo **solo escribe preguntas, nunca reescribe comandos**. Si
|
||||||
|
inventa una pregunta rara, esa entrada recupera peor y ya está; no corrompe una
|
||||||
|
respuesta. Y el fragmento crudo sigue en el índice por su lado.
|
||||||
|
|
||||||
|
### 3 · Reindexado
|
||||||
|
|
||||||
|
Une el índice vivo (comandos Linux, glosario, fichas), la cosecha y la síntesis;
|
||||||
|
deduplica por prefijo; calcula los vectores con el mismo `model2vec` de siempre.
|
||||||
|
Escribe a `saber.jsonl.nuevo`. **No toca el vivo.** `busca.py` no cambia.
|
||||||
|
|
||||||
|
### 4 · Evaluación
|
||||||
|
|
||||||
|
`eval_set.jsonl` son ~45 preguntas de pentesting con las palabras que un
|
||||||
|
fragmento correcto debe contener. Mide `hit@1`, `hit@5` y similitud media, del
|
||||||
|
índice vivo contra el candidato. Determinista y reproducible, sin juez-LLM.
|
||||||
|
|
||||||
|
## La decisión, y por qué es segura
|
||||||
|
|
||||||
|
El índice vivo **solo se sustituye si la evaluación dice que el candidato es
|
||||||
|
mejor**, y antes se guarda `saber.jsonl.antes-<fecha>`. Si empeora, se queda el
|
||||||
|
vivo y el candidato espera revisión. Siempre reversible con un `cp`.
|
||||||
|
|
||||||
|
## Ficheros que genera (en `../../datos/`)
|
||||||
|
|
||||||
|
cosecha.jsonl la metodología rescatada, sin vectores
|
||||||
|
sintesis.jsonl las preguntas generadas (+ .hecho, el marcador)
|
||||||
|
saber.jsonl.nuevo el índice candidato, con vectores
|
||||||
|
informe_rag.txt los números de la evaluación
|
||||||
|
noche_rag.log el registro de la noche
|
||||||
|
saber.jsonl.antes-* copia del índice anterior, si se promocionó
|
||||||
|
|
||||||
|
## Iteraciones posteriores a la primera noche
|
||||||
|
|
||||||
|
- **Troceado que no tira comandos cortos** (`cosecha.py`). El troceado por
|
||||||
|
encabezado descartaba toda sección de menos de 60 caracteres, y eso perdía
|
||||||
|
el oro: `## Detección time-based` + `' AND SLEEP(5)-- -` son 50. Ahora una
|
||||||
|
sección corta se pega a la siguiente y cada fragmento lleva delante el título
|
||||||
|
de la nota. `SLEEP` pasó de 0 a 13 apariciones en el índice; hit@1 80→82 %.
|
||||||
|
El salto en la métrica es pequeño porque las preguntas que quedan son
|
||||||
|
cross-lingual, y de eso no salva el troceado.
|
||||||
|
|
||||||
|
- **`experimento_embedder.py`** — mide, sin tocar nada, si un transformer (e5,
|
||||||
|
bge) supera al model2vec estático. **Resultado (16 ago): NO compensa.**
|
||||||
|
`intfloat/multilingual-e5-base` empató con el estático (hit@1 86 %, hit@5
|
||||||
|
95 % los dos): arregla las 2 preguntas que el estático fallaba, pero rompe
|
||||||
|
otras 2 distintas. A cambio pediría ~50-150 ms por consulta en CPU y una
|
||||||
|
dependencia de torch en cada búsqueda. Medido antes de cambiar → **el
|
||||||
|
estático se queda.** El trabajo de recuperación lo hace la búsqueda híbrida
|
||||||
|
(coseno + bono por palabras), no el embedder, y por eso subir el embedder no
|
||||||
|
mueve la aguja. Reproducible: `./experimento_embedder.py`.
|
||||||
|
|
||||||
|
- **Re-síntesis limpia** sobre el troceado nuevo (`SABER_BASE` en `reindexa.py`
|
||||||
|
reconstruye desde el índice original en vez de apilar, para no inflar). Da a
|
||||||
|
los comandos rescatados (SLEEP y demás) su gancho-pregunta en castellano, que
|
||||||
|
es lo que de verdad ataca las consultas cross-lingual —no el embedder—.
|
||||||
|
|
||||||
|
## Nota sobre la tarjeta
|
||||||
|
|
||||||
|
La síntesis usa el modelo local por `127.0.0.1:11434`. Fija `qwen3.5:4b-jarvis`
|
||||||
|
(el naranja, que no inventa comandos). Si dejas el TUI verde abierto, ollama
|
||||||
|
tiene que cambiar de modelo en cada petición y la noche va más lenta: para la
|
||||||
|
síntesis más rápida, cierra el verde.
|
||||||
92
nucleo/saber/enriquece/correr_noche.sh
Executable file
92
nucleo/saber/enriquece/correr_noche.sh
Executable file
|
|
@ -0,0 +1,92 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# El pipeline de la noche: mejora el RAG hasta nivel pentesting profesional.
|
||||||
|
#
|
||||||
|
# ./correr_noche.sh corre las cuatro fases y decide
|
||||||
|
# ./correr_noche.sh --sin-promo igual, pero NO sustituye el indice vivo
|
||||||
|
#
|
||||||
|
# ── Que hace, en orden ─────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# 1. COSECHA rescata la metodologia de COFRE que el indexador se dejaba
|
||||||
|
# (el OSCP_Vault entero, 0 de 72 notas estaban en el indice).
|
||||||
|
# 2. SINTETIZA por cada fragmento, preguntas en castellano que lo encuentran.
|
||||||
|
# Es la fase larga: horas, con el modelo local. Reanudable.
|
||||||
|
# 3. REINDEXA une vivo + cosecha + sintesis y calcula los vectores. Escribe
|
||||||
|
# a saber.jsonl.nuevo. NO toca el indice vivo.
|
||||||
|
# 4. EVALUA mide recuperacion del vivo contra el candidato, con numeros.
|
||||||
|
#
|
||||||
|
# ── No rompe nada ──────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# El indice vivo solo se sustituye si evalua DICE que el candidato es mejor, y
|
||||||
|
# antes se guarda una copia con fecha. Si el candidato empeora, se queda el
|
||||||
|
# vivo y el candidato espera revision. Reversible siempre.
|
||||||
|
#
|
||||||
|
# Cada fase se salta si ya esta hecha, asi que relanzarlo continua donde iba.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
AQUI=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
|
||||||
|
RAIZ=$(cd -P "$AQUI/../.." && pwd) # .../nucleo
|
||||||
|
DATOS="$RAIZ/datos"
|
||||||
|
PY="$RAIZ/venv/bin/python"
|
||||||
|
[ -x "$PY" ] || PY=python3
|
||||||
|
LOG="$DATOS/noche_rag.log"
|
||||||
|
|
||||||
|
# El modelo NO se fija aqui a proposito: sintetiza.py mira que tiene ollama
|
||||||
|
# cargado y usa ESE, para no forzar cambios en la tarjeta de 4 GB. Si el verde
|
||||||
|
# esta abierto, generara con el verde; si no, cae al naranja. Solo se fuerza si
|
||||||
|
# exportas JARVIS_MODELO tu. Aun asi, para la noche mas rapida y limpia, lo
|
||||||
|
# suyo es cerrar el TUI verde: con un solo modelo ollama no recarga nada.
|
||||||
|
|
||||||
|
PROMO=si
|
||||||
|
[ "${1:-}" = "--sin-promo" ] && PROMO=no
|
||||||
|
|
||||||
|
fecha() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||||
|
fase() { echo "" | tee -a "$LOG"; echo "[$(fecha)] === FASE $* ===" | tee -a "$LOG"; }
|
||||||
|
|
||||||
|
echo "[$(fecha)] arranca el pipeline de la noche (modelo $JARVIS_MODELO)" | tee -a "$LOG"
|
||||||
|
|
||||||
|
# ── 1. COSECHA ─────────────────────────────────────────────────────────────
|
||||||
|
fase "1/4 COSECHA"
|
||||||
|
if [ -s "$DATOS/cosecha.jsonl" ]; then
|
||||||
|
echo " ya hecha ($(wc -l <"$DATOS/cosecha.jsonl") fragmentos), se salta" | tee -a "$LOG"
|
||||||
|
else
|
||||||
|
"$PY" "$AQUI/cosecha.py" 2>&1 | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. SINTETIZA (la larga) ────────────────────────────────────────────────
|
||||||
|
fase "2/4 SINTETIZA (horas; reanudable)"
|
||||||
|
# hecha del todo = el .hecho llego al ultimo fragmento
|
||||||
|
TOTAL=$(wc -l <"$DATOS/cosecha.jsonl")
|
||||||
|
HECHO=$(cat "$DATOS/sintesis.jsonl.hecho" 2>/dev/null || echo -1)
|
||||||
|
if [ "$HECHO" -ge "$((TOTAL - 1))" ] 2>/dev/null; then
|
||||||
|
echo " ya completa ($(wc -l <"$DATOS/sintesis.jsonl" 2>/dev/null || echo 0) preguntas)" | tee -a "$LOG"
|
||||||
|
else
|
||||||
|
"$PY" "$AQUI/sintetiza.py" 2>&1 | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. REINDEXA ────────────────────────────────────────────────────────────
|
||||||
|
fase "3/4 REINDEXA"
|
||||||
|
"$PY" "$AQUI/reindexa.py" 2>&1 | tee -a "$LOG"
|
||||||
|
|
||||||
|
# ── 4. EVALUA ──────────────────────────────────────────────────────────────
|
||||||
|
fase "4/4 EVALUA"
|
||||||
|
INFORME="$DATOS/informe_rag.txt"
|
||||||
|
"$PY" "$AQUI/evalua.py" 2>&1 | tee "$INFORME" | tee -a "$LOG"
|
||||||
|
VEREDICTO=$(grep -oE 'VEREDICTO (PROMOCIONAR|MANTENER)' "$INFORME" | awk '{print $2}' | tail -1)
|
||||||
|
|
||||||
|
# ── Decision ───────────────────────────────────────────────────────────────
|
||||||
|
fase "DECISION"
|
||||||
|
if [ "$PROMO" = no ]; then
|
||||||
|
echo " --sin-promo: dejo el candidato en saber.jsonl.nuevo sin tocar el vivo" | tee -a "$LOG"
|
||||||
|
elif [ "$VEREDICTO" = PROMOCIONAR ]; then
|
||||||
|
COPIA="$DATOS/saber.jsonl.antes-$(date +%Y%m%d-%H%M)"
|
||||||
|
cp "$DATOS/saber.jsonl" "$COPIA"
|
||||||
|
mv "$DATOS/saber.jsonl.nuevo" "$DATOS/saber.jsonl"
|
||||||
|
echo " PROMOCIONADO. El candidato es mejor y ya es el indice vivo." | tee -a "$LOG"
|
||||||
|
echo " copia del anterior: $COPIA" | tee -a "$LOG"
|
||||||
|
echo " para deshacer: cp '$COPIA' '$DATOS/saber.jsonl'" | tee -a "$LOG"
|
||||||
|
else
|
||||||
|
echo " MANTENIDO el vivo: el candidato no mejora la evaluacion." | tee -a "$LOG"
|
||||||
|
echo " candidato en saber.jsonl.nuevo e informe en informe_rag.txt para revisar." | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$(fecha)] pipeline terminado" | tee -a "$LOG"
|
||||||
282
nucleo/saber/enriquece/cosecha.py
Executable file
282
nucleo/saber/enriquece/cosecha.py
Executable file
|
|
@ -0,0 +1,282 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Cosecha la metodologia de pentesting que el indexador normal se deja fuera.
|
||||||
|
|
||||||
|
./cosecha.py recorre COFRE y escribe datos/cosecha.jsonl
|
||||||
|
./cosecha.py --cuenta dice que cosecharia, sin escribir
|
||||||
|
|
||||||
|
## Por que existe, aparte de indexa.py
|
||||||
|
|
||||||
|
`indexa.py` es conservador a proposito: solo mira ficheros cuyo NOMBRE parece
|
||||||
|
documentacion (readme, notas, guia...) y no baja mas de 3 niveles. Eso funciona
|
||||||
|
para las herramientas, pero **tira la mejor metodologia que hay en el disco**:
|
||||||
|
|
||||||
|
- El `OSCP_Vault` son 72 notas OSCP escritas a mano —`SQL injection.md`,
|
||||||
|
`Reverse shell.md`, `LFI a RCE.md`, `msfvenom.md`...— con comandos reales y
|
||||||
|
enlaces cruzados. Ninguna entra: el nombre no lleva "notas/guia", y ademas
|
||||||
|
viven en `OSCP_APUNTES/OSCP_Vault/02 - Explotacion web/`, a 5 niveles.
|
||||||
|
- Igual con las cheatsheets y apuntes sueltos de AD, tunneling, privesc.
|
||||||
|
|
||||||
|
Medido antes de escribir esto: **0 de 72 notas del Vault estaban en el indice.**
|
||||||
|
|
||||||
|
Este cosechador es agresivo donde el otro es prudente: en los perfiles
|
||||||
|
ofensivos coge TODO .md/.txt que no sea ruido evidente, a la profundidad que
|
||||||
|
haga falta, y marca de que TEMA es por la carpeta que lo contiene (las del Vault
|
||||||
|
van numeradas: "01 - Enumeracion", "02 - Explotacion web"...).
|
||||||
|
|
||||||
|
No pisa a indexa.py: escribe a un fichero aparte. La union y el deduplicado los
|
||||||
|
hace reindexa.py.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(t):
|
||||||
|
t = unicodedata.normalize("NFD", (t or "").lower())
|
||||||
|
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||||
|
return re.sub(r"[^a-z0-9 ]", " ", t)
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
RAIZ = os.path.dirname(os.path.dirname(AQUI)) # .../nucleo
|
||||||
|
COFRE = os.path.expanduser("~/COFRE")
|
||||||
|
SALIDA = os.path.join(RAIZ, "datos", "cosecha.jsonl")
|
||||||
|
|
||||||
|
# Los perfiles ofensivos: aqui vive el saber de pentesting. En estos se cosecha
|
||||||
|
# a lo ancho. (LINUX se queda para indexa.py, que ya lo hace bien con man pages.)
|
||||||
|
PERFILES = ["PENTESTERS", "PHISHERS", "REVERSERS", "DEFACERS", "SNEAKERS",
|
||||||
|
"HARD-WARERS", "PROTECTORS", "ZAPPERS", "AUTOMATAS"]
|
||||||
|
|
||||||
|
# Carpetas de oro: se recorren ENTERAS, sin limite de profundidad, porque su
|
||||||
|
# valor esta justo en las notas hondas.
|
||||||
|
ORO = re.compile(r"(OSCP_APUNTES|OSCP_Vault|TELMO_OSCP|RedTeam-Tools|"
|
||||||
|
r"apuntes|cheat|vault|metodolog|notas)", re.I)
|
||||||
|
|
||||||
|
# Lo que no se mira nunca: dependencias, control de versiones, binarios.
|
||||||
|
IGNORA = re.compile(
|
||||||
|
r"(^|/)(node_modules|\.git|\.obsidian|vendor|dist|build|__pycache__|"
|
||||||
|
r"\.venv|venv|site-packages|target|\.cache|\.github)(/|$)", re.I)
|
||||||
|
|
||||||
|
# Ficheros que son plantilla o licencia, no saber.
|
||||||
|
RUIDO = re.compile(r"(code_of_conduct|contributing|changelog|license|copying|"
|
||||||
|
r"pull_request|issue_template|\.min\.|package-lock)", re.I)
|
||||||
|
|
||||||
|
# Fuera de las carpetas de ORO, solo se coge lo que PARECE documentacion por el
|
||||||
|
# nombre. Es la misma idea que indexa.py, un poco mas ancha (tutorial, writeup,
|
||||||
|
# walkthrough). Sin esto entra cada .md de cada exploit.
|
||||||
|
DOC = re.compile(r"(readme|install|usage|apuntes|notas|trucos|howto|tutorial|"
|
||||||
|
r"walkthrough|writeup|write-up|guide|guia|cheat|manual|tips|"
|
||||||
|
r"metodolog|documentation|docs?)", re.I)
|
||||||
|
|
||||||
|
# Tope de ficheros por herramienta fuera del ORO. exploitdb tiene 29.925 .md:
|
||||||
|
# es una base de datos, no unos apuntes, y meterla entera ahoga el oro. Si una
|
||||||
|
# herramienta pasa de esto, se la trata como repo a granel y solo se coge su
|
||||||
|
# documentacion de primer nivel (README).
|
||||||
|
CAP_FICHEROS = 25
|
||||||
|
|
||||||
|
MIN_FRAGMENTO = 60
|
||||||
|
MAX_FRAGMENTO = 1200
|
||||||
|
MAX_PROFUNDIDAD = 4 # niveles bajo el perfil, salvo en carpetas de ORO
|
||||||
|
|
||||||
|
# Tope de tamano por fichero. Una nota de metodologia son unos pocos KB; un .txt
|
||||||
|
# de 300 MB es una wordlist (SecLists y similares llevan .txt de gigabytes) y
|
||||||
|
# leerlo entero revienta la maquina —la swap de este equipo es de 976 MiB—. Por
|
||||||
|
# encima de esto no es saber, es un diccionario, y no se lee.
|
||||||
|
MAX_BYTES = 512 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _profundidad(ruta):
|
||||||
|
return ruta.rstrip("/").count("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _tema(ruta):
|
||||||
|
"""El tema, deducido de la carpeta. Las del Vault van numeradas."""
|
||||||
|
for parte in reversed(ruta.split(os.sep)):
|
||||||
|
# "02 - Explotacion web" -> "explotacion web"
|
||||||
|
m = re.match(r"^\d{2}\s*[-.]\s*(.+)$", parte)
|
||||||
|
if m:
|
||||||
|
return m.group(1).strip().lower()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _herramienta(ruta, perfil):
|
||||||
|
rel = os.path.relpath(ruta, os.path.join(COFRE, perfil)).split(os.sep)
|
||||||
|
return rel[0] if len(rel) > 1 else perfil
|
||||||
|
|
||||||
|
|
||||||
|
def _prefija(titulo, seccion):
|
||||||
|
"""Lleva el titulo de la nota delante de la seccion, salvo que ya lo tenga.
|
||||||
|
|
||||||
|
Sin esto, un fragmento como "## Deteccion time-based\n' AND SLEEP(5)" no
|
||||||
|
sabe de que va —¿time-based de que?—. Con "(SQL injection) ..." delante, el
|
||||||
|
vector y el cerebro tienen el contexto. Se evita duplicar si el titulo ya
|
||||||
|
aparece en la cabecera de la seccion."""
|
||||||
|
if titulo and _norm(titulo) not in _norm(seccion[:80]):
|
||||||
|
return f"({titulo}) {seccion}"
|
||||||
|
return seccion
|
||||||
|
|
||||||
|
|
||||||
|
def trocea(texto):
|
||||||
|
"""En fragmentos por encabezado, fusionando los cortos y con el titulo.
|
||||||
|
|
||||||
|
Antes se partia por encabezado y se TIRABA toda seccion de menos de 60
|
||||||
|
caracteres. Eso perdia justo lo mejor: una seccion "## Deteccion time-based"
|
||||||
|
con `' AND SLEEP(5)-- -` son 50 caracteres, y es oro. Ahora una seccion
|
||||||
|
corta se PEGA a la siguiente en vez de tirarse, y cada fragmento lleva
|
||||||
|
delante el titulo de la nota para no quedar sin contexto.
|
||||||
|
"""
|
||||||
|
texto = re.sub(r"\A---\n.*?\n---\n", "", texto, flags=re.S)
|
||||||
|
m = re.search(r"^#\s+(.+)", texto, re.M)
|
||||||
|
titulo = m.group(1).strip() if m else ""
|
||||||
|
|
||||||
|
crudas = [s.strip() for s in re.split(r"\n(?=#{1,3}\s)", texto) if s.strip()]
|
||||||
|
|
||||||
|
# fusionar hacia adelante mientras la seccion sea demasiado corta
|
||||||
|
fundidas, i = [], 0
|
||||||
|
while i < len(crudas):
|
||||||
|
sec = crudas[i]
|
||||||
|
while len(sec) < MIN_FRAGMENTO and i + 1 < len(crudas):
|
||||||
|
i += 1
|
||||||
|
sec += "\n\n" + crudas[i]
|
||||||
|
fundidas.append(sec)
|
||||||
|
i += 1
|
||||||
|
# si la ultima quedo corta, se dobla sobre la anterior. Se saca primero y
|
||||||
|
# se indexa despues: fundidas.pop() en el lado derecho ya habria acortado
|
||||||
|
# la lista y fundidas[-2] se saldria de rango.
|
||||||
|
if len(fundidas) >= 2 and len(fundidas[-1]) < MIN_FRAGMENTO:
|
||||||
|
cola = fundidas.pop()
|
||||||
|
fundidas[-1] += "\n\n" + cola
|
||||||
|
|
||||||
|
for sec in fundidas:
|
||||||
|
if len(sec) < MIN_FRAGMENTO:
|
||||||
|
continue # una nota entera diminuta
|
||||||
|
if len(sec) <= MAX_FRAGMENTO:
|
||||||
|
yield _prefija(titulo, sec)
|
||||||
|
else:
|
||||||
|
buf = ""
|
||||||
|
for parrafo in re.split(r"\n\s*\n", sec):
|
||||||
|
if len(buf) + len(parrafo) > MAX_FRAGMENTO and buf:
|
||||||
|
yield _prefija(titulo, buf.strip())
|
||||||
|
buf = ""
|
||||||
|
buf += parrafo + "\n\n"
|
||||||
|
if len(buf.strip()) >= MIN_FRAGMENTO:
|
||||||
|
yield _prefija(titulo, buf.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _candidato(ruta, fich, en_oro):
|
||||||
|
"""Un fichero vale si es texto, no es ruido, no es enorme, y —fuera del
|
||||||
|
oro— su nombre parece documentacion."""
|
||||||
|
if not fich.lower().endswith((".md", ".txt")):
|
||||||
|
return False
|
||||||
|
if RUIDO.search(fich):
|
||||||
|
return False
|
||||||
|
if not en_oro and not DOC.search(fich):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return os.path.getsize(ruta) <= MAX_BYTES
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def ficheros(perfil):
|
||||||
|
"""Los ficheros a cosechar de un perfil, con el tope por herramienta.
|
||||||
|
|
||||||
|
Se recorre cada herramienta (subcarpeta de primer nivel) por separado para
|
||||||
|
poder contar sus ficheros: si pasa del cap y no es oro, es un repo a granel
|
||||||
|
(exploitdb) y solo se coge su README, no sus 30.000 entradas.
|
||||||
|
"""
|
||||||
|
base = os.path.join(COFRE, perfil)
|
||||||
|
if not os.path.isdir(base):
|
||||||
|
return
|
||||||
|
|
||||||
|
# sueltos en la raiz del perfil (apuntes-ad-pentest.md, trucos_trampas.md)
|
||||||
|
for fich in sorted(os.listdir(base)):
|
||||||
|
ruta = os.path.join(base, fich)
|
||||||
|
if os.path.isfile(ruta) and _candidato(ruta, fich, en_oro=True):
|
||||||
|
yield ruta, True
|
||||||
|
|
||||||
|
for herr in sorted(os.listdir(base)):
|
||||||
|
raiz_h = os.path.join(base, herr)
|
||||||
|
if not os.path.isdir(raiz_h) or herr.startswith("."):
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidatos = []
|
||||||
|
for raiz, dirs, fichs in os.walk(raiz_h):
|
||||||
|
if IGNORA.search(raiz):
|
||||||
|
dirs[:] = []
|
||||||
|
continue
|
||||||
|
en_oro = bool(ORO.search(raiz))
|
||||||
|
if not en_oro and _profundidad(raiz) - _profundidad(raiz_h) > MAX_PROFUNDIDAD:
|
||||||
|
dirs[:] = []
|
||||||
|
continue
|
||||||
|
for fich in sorted(fichs):
|
||||||
|
ruta = os.path.join(raiz, fich)
|
||||||
|
if _candidato(ruta, fich, en_oro):
|
||||||
|
candidatos.append((ruta, en_oro, _profundidad(ruta)))
|
||||||
|
|
||||||
|
del_oro = [c for c in candidatos if c[1]]
|
||||||
|
resto = [c for c in candidatos if not c[1]]
|
||||||
|
|
||||||
|
# el oro entra siempre y entero
|
||||||
|
for ruta, en_oro, _ in del_oro:
|
||||||
|
yield ruta, True
|
||||||
|
|
||||||
|
# el resto, con tope: si es un repo a granel, solo lo mas alto
|
||||||
|
if len(resto) > CAP_FICHEROS:
|
||||||
|
resto.sort(key=lambda c: c[2]) # los menos hondos primero
|
||||||
|
resto = resto[:CAP_FICHEROS]
|
||||||
|
for ruta, en_oro, _ in resto:
|
||||||
|
yield ruta, False
|
||||||
|
|
||||||
|
|
||||||
|
def construye(solo_cuenta=False):
|
||||||
|
salida = []
|
||||||
|
for perfil in PERFILES:
|
||||||
|
n_doc = n_frag = 0
|
||||||
|
for ruta, en_oro in ficheros(perfil):
|
||||||
|
try:
|
||||||
|
with open(ruta, encoding="utf-8", errors="ignore") as f:
|
||||||
|
texto = f.read()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if len(texto.strip()) < MIN_FRAGMENTO:
|
||||||
|
continue
|
||||||
|
tema = _tema(ruta)
|
||||||
|
herr = _herramienta(ruta, perfil)
|
||||||
|
rel = os.path.relpath(ruta, COFRE)
|
||||||
|
hubo = False
|
||||||
|
for trozo in trocea(texto):
|
||||||
|
salida.append({
|
||||||
|
"tipo": "metodologia" if en_oro else "apunte",
|
||||||
|
"herramienta": herr, "perfil": perfil,
|
||||||
|
"fichero": rel, "tema": tema, "texto": trozo,
|
||||||
|
})
|
||||||
|
n_frag += 1
|
||||||
|
hubo = True
|
||||||
|
n_doc += hubo
|
||||||
|
print(f" {perfil:14s} {n_doc:5d} docs {n_frag:6d} fragmentos", flush=True)
|
||||||
|
|
||||||
|
oro = sum(1 for e in salida if e["tipo"] == "metodologia")
|
||||||
|
print(f"\n total: {len(salida)} fragmentos ({oro} de metodologia)", flush=True)
|
||||||
|
if solo_cuenta:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(SALIDA), exist_ok=True)
|
||||||
|
with open(SALIDA, "w", encoding="utf-8") as f:
|
||||||
|
for e in salida:
|
||||||
|
f.write(json.dumps(e, ensure_ascii=False) + "\n")
|
||||||
|
print(f" escrito: {SALIDA}", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--cuenta", action="store_true")
|
||||||
|
a = p.parse_args()
|
||||||
|
return construye(solo_cuenta=a.cuenta)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
45
nucleo/saber/enriquece/eval_set.jsonl
Normal file
45
nucleo/saber/enriquece/eval_set.jsonl
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
{"pregunta": "como saco una shell reversa desde bash", "espera": ["bash -i", "/dev/tcp", "reverse", "nc "]}
|
||||||
|
{"pregunta": "generar un payload de reverse shell con msfvenom", "espera": ["msfvenom", "-p ", "lhost", "payload"]}
|
||||||
|
{"pregunta": "estabilizar una shell tty para que funcione bien", "espera": ["python", "pty", "stty", "script /dev/null"]}
|
||||||
|
{"pregunta": "bypass de login por inyeccion sql", "espera": ["or '1'='1", "or 1=1", "-- -", "union select"]}
|
||||||
|
{"pregunta": "detectar sql injection a ciegas basada en tiempo", "espera": ["sleep", "waitfor", "time-based", "benchmark"]}
|
||||||
|
{"pregunta": "de una sqli a ejecucion de comandos", "espera": ["xp_cmdshell", "load_file", "into outfile", "rce"]}
|
||||||
|
{"pregunta": "explotar un local file inclusion para conseguir rce", "espera": ["lfi", "php://", "log poisoning", "/proc/self/environ", "data://"]}
|
||||||
|
{"pregunta": "probar server side template injection", "espera": ["ssti", "{{7*7", "jinja", "${", "twig"]}
|
||||||
|
{"pregunta": "payload basico de xss para robar cookie", "espera": ["<script", "document.cookie", "onerror", "alert("]}
|
||||||
|
{"pregunta": "inyeccion de comandos en un parametro web", "espera": ["command injection", ";", "| ", "$(", "`", "&&"]}
|
||||||
|
{"pregunta": "saltarme un filtro de subida de ficheros", "espera": ["file upload", "magic bytes", ".phtml", "content-type", "double extension"]}
|
||||||
|
{"pregunta": "descubrir directorios y ficheros ocultos en una web", "espera": ["gobuster", "ffuf", "feroxbuster", "dirb", "wordlist"]}
|
||||||
|
{"pregunta": "path traversal para leer etc passwd", "espera": ["../", "directory traversal", "/etc/passwd", "%2e"]}
|
||||||
|
{"pregunta": "enumerar un servidor smb", "espera": ["smbclient", "enum4linux", "smbmap", "crackmapexec", "139", "445"]}
|
||||||
|
{"pregunta": "escaneo de puertos y servicios con nmap", "espera": ["nmap", "-sv", "-sc", "-p-", "-a "]}
|
||||||
|
{"pregunta": "como paso de usuario normal a root en linux", "espera": ["privesc", "sudo -l", "suid", "linpeas", "gtfobins"]}
|
||||||
|
{"pregunta": "escalada de privilegios en windows", "espera": ["winpeas", "privesc", "seimpersonate", "potato", "token"]}
|
||||||
|
{"pregunta": "ataque dcsync para sacar hashes del dominio", "espera": ["dcsync", "secretsdump", "mimikatz", "lsadump"]}
|
||||||
|
{"pregunta": "pass the hash para autenticarme sin la contrasena", "espera": ["pass-the-hash", "pass the hash", "-hashes", "ntlm", "pth"]}
|
||||||
|
{"pregunta": "kerberoasting para sacar tickets de servicio", "espera": ["kerberoast", "getuserspns", "spn", "ticket"]}
|
||||||
|
{"pregunta": "usar bloodhound para mapear el active directory", "espera": ["bloodhound", "sharphound", "neo4j", "collector"]}
|
||||||
|
{"pregunta": "dumpear credenciales de memoria con mimikatz", "espera": ["mimikatz", "sekurlsa", "logonpasswords", "lsass"]}
|
||||||
|
{"pregunta": "usar impacket para ejecutar comandos remotos", "espera": ["impacket", "psexec", "wmiexec", "smbexec"]}
|
||||||
|
{"pregunta": "crackear un hash con john the ripper", "espera": ["john", "--wordlist", "rockyou", "--format"]}
|
||||||
|
{"pregunta": "crackear hashes con hashcat", "espera": ["hashcat", "-m ", "rockyou", "-a "]}
|
||||||
|
{"pregunta": "ataque de fuerza bruta a un login ssh", "espera": ["hydra", "medusa", "-l ", "-p ", "ssh"]}
|
||||||
|
{"pregunta": "transferir un fichero a la maquina victima", "espera": ["scp", "http.server", "certutil", "wget", "curl", "impacket-smbserver"]}
|
||||||
|
{"pregunta": "montar un tunel para pivotar en la red interna", "espera": ["chisel", "ligolo", "ssh -l", "proxychains", "socks"]}
|
||||||
|
{"pregunta": "pivoting con ssh port forwarding", "espera": ["ssh -l", "ssh -r", "-d ", "port forward", "dynamic"]}
|
||||||
|
{"pregunta": "usar metasploit para explotar un servicio", "espera": ["metasploit", "msfconsole", "use exploit", "set rhost"]}
|
||||||
|
{"pregunta": "escanear vulnerabilidades web con nuclei", "espera": ["nuclei", "-t ", "template", "-u "]}
|
||||||
|
{"pregunta": "escanear un wordpress en busca de fallos", "espera": ["wpscan", "--enumerate", "--url", "plugin"]}
|
||||||
|
{"pregunta": "buscar exploits publicos de un servicio", "espera": ["searchsploit", "exploit-db", "exploitdb"]}
|
||||||
|
{"pregunta": "evadir un antivirus o edr al ejecutar payload", "espera": ["av evasion", "amsi", "bypass", "obfusc", "edr"]}
|
||||||
|
{"pregunta": "hacer un buffer overflow clasico", "espera": ["buffer overflow", "eip", "pattern_create", "badchars", "jmp esp"]}
|
||||||
|
{"pregunta": "filtrado de salida y como saltarmelo", "espera": ["egress", "filtering", "puerto", "443", "outbound"]}
|
||||||
|
{"pregunta": "enumerar usuarios y recursos de un active directory", "espera": ["enum", "ldap", "rpcclient", "net user", "adperti"]}
|
||||||
|
{"pregunta": "atacar autenticacion con credenciales por defecto", "espera": ["authentication", "default", "brute", "credential", "login"]}
|
||||||
|
{"pregunta": "descubrir contenido con fuzzing de parametros", "espera": ["ffuf", "fuzz", "content discovery", "wordlist"]}
|
||||||
|
{"pregunta": "usar burp repeater e intruder para probar peticiones", "espera": ["burp", "repeater", "intruder", "proxy"]}
|
||||||
|
{"pregunta": "escaneo de red para descubrir hosts vivos", "espera": ["nmap", "-sn", "ping sweep", "netdiscover", "arp"]}
|
||||||
|
{"pregunta": "conseguir persistencia tras comprometer una maquina", "espera": ["persistenc", "cron", "scheduled task", "backdoor", "registry"]}
|
||||||
|
{"pregunta": "como escaneo redes wifi y capturo handshakes", "espera": ["airodump", "aircrack", "handshake", "wpa", "airgeddon"]}
|
||||||
|
{"pregunta": "usar crackmapexec para barrer una red windows", "espera": ["crackmapexec", "cme ", "smb", "--shares", "spray"]}
|
||||||
|
{"pregunta": "leer un hash ntlm y reutilizarlo por la red", "espera": ["ntlm", "hash", "relay", "responder", "ntlmrelayx"]}
|
||||||
173
nucleo/saber/enriquece/evalua.py
Executable file
173
nucleo/saber/enriquece/evalua.py
Executable file
|
|
@ -0,0 +1,173 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Mide si el indice candidato recupera mejor que el vivo. Con numeros.
|
||||||
|
|
||||||
|
./evalua.py compara vivo contra saber.jsonl.nuevo
|
||||||
|
./evalua.py A.jsonl B.jsonl compara dos indices cualesquiera
|
||||||
|
|
||||||
|
## Que mide
|
||||||
|
|
||||||
|
Un conjunto de preguntas de pentesting (eval_set.jsonl), cada una con las
|
||||||
|
palabras que un fragmento CORRECTO tiene que contener (un comando, una
|
||||||
|
herramienta, un patron). Para cada indice se busca la pregunta y se mira si
|
||||||
|
alguno de los 5 primeros resultados contiene lo esperado.
|
||||||
|
|
||||||
|
hit@1 la pregunta se resuelve con el PRIMER resultado
|
||||||
|
hit@5 se resuelve con alguno de los cinco primeros
|
||||||
|
sim similitud media del mejor resultado
|
||||||
|
|
||||||
|
La busqueda replica la de busca.py (coseno + bono por palabras) para que la
|
||||||
|
comparacion sea justa: lo unico que cambia entre las dos columnas es el indice.
|
||||||
|
|
||||||
|
## Por que asi y no "le pregunte al modelo si estaba bien"
|
||||||
|
|
||||||
|
Un juez-LLM sobre una tarjeta de 4 GB es lento y ruidoso. Esto es determinista,
|
||||||
|
reproducible y honesto: la palabra esperada esta o no esta en el texto
|
||||||
|
recuperado. Es una cota inferior de la calidad —un fragmento puede ser util sin
|
||||||
|
contener el literal— pero sirve para COMPARAR dos indices, que es lo que decide
|
||||||
|
si se promociona el candidato.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
RAIZ = os.path.dirname(os.path.dirname(AQUI))
|
||||||
|
DATOS = os.path.join(RAIZ, "datos")
|
||||||
|
VIVO = os.path.join(DATOS, "saber.jsonl")
|
||||||
|
NUEVO = os.path.join(DATOS, "saber.jsonl.nuevo")
|
||||||
|
EVAL = os.path.join(AQUI, "eval_set.jsonl")
|
||||||
|
MODELO = "minishlab/potion-multilingual-128M"
|
||||||
|
|
||||||
|
_modelo = None
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(t):
|
||||||
|
t = unicodedata.normalize("NFD", (t or "").lower())
|
||||||
|
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||||
|
return re.sub(r"[^a-z0-9 ]", " ", t)
|
||||||
|
|
||||||
|
|
||||||
|
def _carga_modelo():
|
||||||
|
global _modelo
|
||||||
|
if _modelo is None:
|
||||||
|
from model2vec import StaticModel
|
||||||
|
_modelo = StaticModel.from_pretrained(MODELO)
|
||||||
|
return _modelo
|
||||||
|
|
||||||
|
|
||||||
|
def _carga_indice(ruta):
|
||||||
|
import numpy as np
|
||||||
|
entradas, vs = [], []
|
||||||
|
with open(ruta, encoding="utf-8") as f:
|
||||||
|
for l in f:
|
||||||
|
try:
|
||||||
|
d = json.loads(l)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
v = d.pop("v", None)
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
entradas.append(d)
|
||||||
|
vs.append(v)
|
||||||
|
M = np.array(vs, dtype="float32")
|
||||||
|
M /= np.clip(np.linalg.norm(M, axis=1, keepdims=True), 1e-9, None)
|
||||||
|
txt = [set(_norm(e["texto"]).split()) for e in entradas]
|
||||||
|
nom = [_norm(e.get("herramienta", "")) for e in entradas]
|
||||||
|
return entradas, M, txt, nom
|
||||||
|
|
||||||
|
|
||||||
|
def _busca(indice, pregunta, cuantos=5):
|
||||||
|
import numpy as np
|
||||||
|
entradas, M, txt, nom = indice
|
||||||
|
q = _carga_modelo().encode([pregunta])[0]
|
||||||
|
q = q / max(float(np.linalg.norm(q)), 1e-9)
|
||||||
|
sim = M @ q
|
||||||
|
palabras = {w for w in _norm(pregunta).split() if len(w) > 3}
|
||||||
|
if palabras:
|
||||||
|
bono = np.zeros(len(entradas), dtype="float32")
|
||||||
|
for i in range(len(entradas)):
|
||||||
|
b = 0.08 * len(palabras & txt[i])
|
||||||
|
if nom[i] and nom[i] in palabras:
|
||||||
|
b += 0.5
|
||||||
|
bono[i] = b
|
||||||
|
sim = sim + bono
|
||||||
|
orden = np.argsort(-sim)[: cuantos * 4]
|
||||||
|
salida, vistos = [], set()
|
||||||
|
for i in orden:
|
||||||
|
firma = entradas[i]["texto"][:120]
|
||||||
|
if firma in vistos:
|
||||||
|
continue
|
||||||
|
vistos.add(firma)
|
||||||
|
salida.append((float(sim[i]), entradas[i]["texto"]))
|
||||||
|
if len(salida) >= cuantos:
|
||||||
|
break
|
||||||
|
return salida
|
||||||
|
|
||||||
|
|
||||||
|
def evalua(ruta):
|
||||||
|
indice = _carga_indice(ruta)
|
||||||
|
casos = [json.loads(l) for l in open(EVAL, encoding="utf-8") if l.strip()]
|
||||||
|
hit1 = hit5 = 0
|
||||||
|
simtop = 0.0
|
||||||
|
fallos = []
|
||||||
|
for c in casos:
|
||||||
|
esperado = [_norm(k).strip() for k in c["espera"]]
|
||||||
|
res = _busca(indice, c["pregunta"])
|
||||||
|
simtop += res[0][0] if res else 0.0
|
||||||
|
textos = [_norm(t) for _, t in res]
|
||||||
|
acierta = lambda t: any(k and k in t for k in esperado)
|
||||||
|
if res and acierta(textos[0]):
|
||||||
|
hit1 += 1
|
||||||
|
if any(acierta(t) for t in textos):
|
||||||
|
hit5 += 1
|
||||||
|
else:
|
||||||
|
fallos.append(c["pregunta"])
|
||||||
|
n = len(casos)
|
||||||
|
return {"n": n, "hit1": hit1, "hit5": hit5,
|
||||||
|
"sim": simtop / n if n else 0.0, "fallos": fallos}
|
||||||
|
|
||||||
|
|
||||||
|
def _pinta(nombre, r):
|
||||||
|
print(f" {nombre}")
|
||||||
|
print(f" hit@1 {r['hit1']:3d}/{r['n']} ({100*r['hit1']//r['n']}%)")
|
||||||
|
print(f" hit@5 {r['hit5']:3d}/{r['n']} ({100*r['hit5']//r['n']}%)")
|
||||||
|
print(f" sim {r['sim']:.3f}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = sys.argv[1] if len(sys.argv) > 1 else VIVO
|
||||||
|
b = sys.argv[2] if len(sys.argv) > 2 else NUEVO
|
||||||
|
if not os.path.exists(b):
|
||||||
|
print(f" no existe el candidato: {b}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("Evaluando el indice VIVO...", flush=True)
|
||||||
|
rv = evalua(a)
|
||||||
|
print("Evaluando el indice CANDIDATO...", flush=True)
|
||||||
|
rn = evalua(b)
|
||||||
|
|
||||||
|
print("\n" + "=" * 44)
|
||||||
|
_pinta("VIVO " + os.path.basename(a), rv)
|
||||||
|
print()
|
||||||
|
_pinta("CANDIDATO " + os.path.basename(b), rn)
|
||||||
|
print("=" * 44)
|
||||||
|
|
||||||
|
mejora5 = rn["hit5"] - rv["hit5"]
|
||||||
|
mejora1 = rn["hit1"] - rv["hit1"]
|
||||||
|
print(f"\n hit@5: {mejora5:+d} hit@1: {mejora1:+d}")
|
||||||
|
|
||||||
|
# el veredicto que lee correr_noche.sh
|
||||||
|
promociona = rn["hit5"] >= rv["hit5"] and rn["hit1"] >= rv["hit1"] - 1
|
||||||
|
print(f" VEREDICTO {'PROMOCIONAR' if promociona else 'MANTENER'}")
|
||||||
|
|
||||||
|
if rn["fallos"]:
|
||||||
|
print(f"\n el candidato aun no resuelve {len(rn['fallos'])}:")
|
||||||
|
for q in rn["fallos"][:12]:
|
||||||
|
print(f" · {q}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
142
nucleo/saber/enriquece/experimento_embedder.py
Normal file
142
nucleo/saber/enriquece/experimento_embedder.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Experimento: mide si un embedder transformer supera al model2vec estatico.
|
||||||
|
|
||||||
|
./experimento_embedder.py prueba intfloat/multilingual-e5-base
|
||||||
|
./experimento_embedder.py BAAI/bge-m3 prueba otro modelo
|
||||||
|
|
||||||
|
## Por que este experimento
|
||||||
|
|
||||||
|
model2vec (potion-multilingual-128M) es un embedder ESTATICO: rapidisimo (un
|
||||||
|
vector por token, precalculado, sin red neuronal en la consulta) pero flojo en
|
||||||
|
lo semantico. Su punto ciego se ve en las dos preguntas que el RAG no resuelve:
|
||||||
|
|
||||||
|
"detectar sql injection a ciegas basada en tiempo" -> no encuentra SLEEP
|
||||||
|
"descubrir directorios ocultos en una web" -> no encuentra gobuster
|
||||||
|
|
||||||
|
Las dos son cross-lingual: la pregunta va en castellano coloquial y el comando
|
||||||
|
en ingles tecnico ("time-based", "content discovery"). Un transformer de verdad
|
||||||
|
—e5, bge— entiende eso; el estatico no.
|
||||||
|
|
||||||
|
Este experimento NO toca nada: coge una MUESTRA del indice vivo (para que quepa
|
||||||
|
en memoria y sea rapido), la re-embebe con el transformer, y corre la misma
|
||||||
|
evaluacion. Si gana claro, merece la pena cambiar busca.py; si no, no.
|
||||||
|
|
||||||
|
## El coste que habria que pagar si se adopta
|
||||||
|
|
||||||
|
El estatico encodea la consulta en microsegundos. Un transformer en CPU tarda
|
||||||
|
~50-150 ms por consulta. Para un asistente de voz local es asumible, pero no es
|
||||||
|
gratis, y por eso se mide antes de decidir.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
RAIZ = os.path.dirname(os.path.dirname(AQUI))
|
||||||
|
VIVO = os.path.join(RAIZ, "datos", "saber.jsonl")
|
||||||
|
EVAL = os.path.join(AQUI, "eval_set.jsonl")
|
||||||
|
MODELO_TF = sys.argv[1] if len(sys.argv) > 1 else "intfloat/multilingual-e5-base"
|
||||||
|
|
||||||
|
# Muestra: todo lo que un caso de eval podria querer + relleno, para no cargar
|
||||||
|
# 14.000 vectores de transformer en RAM. Se cogen todas las entradas de
|
||||||
|
# metodologia y pregunta (el oro) mas una parte del resto.
|
||||||
|
MAX_ENTRADAS = 6000
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(t):
|
||||||
|
t = unicodedata.normalize("NFD", (t or "").lower())
|
||||||
|
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||||
|
return re.sub(r"[^a-z0-9 ]", " ", t)
|
||||||
|
|
||||||
|
|
||||||
|
def _muestra():
|
||||||
|
oro, resto = [], []
|
||||||
|
with open(VIVO, encoding="utf-8") as f:
|
||||||
|
for l in f:
|
||||||
|
try:
|
||||||
|
d = json.loads(l)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
(oro if d.get("tipo") in ("metodologia", "pregunta") else resto).append(d)
|
||||||
|
ent = oro + resto[: max(0, MAX_ENTRADAS - len(oro))]
|
||||||
|
return ent
|
||||||
|
|
||||||
|
|
||||||
|
def _eval(nombre, encode_corpus, encode_query, entradas):
|
||||||
|
import numpy as np
|
||||||
|
textos = [(e.get("indexar") or e["texto"]) for e in entradas]
|
||||||
|
M = encode_corpus(textos)
|
||||||
|
M = M / np.clip(np.linalg.norm(M, axis=1, keepdims=True), 1e-9, None)
|
||||||
|
palabras_e = [set(_norm(e["texto"]).split()) for e in entradas]
|
||||||
|
nom = [_norm(e.get("herramienta", "")) for e in entradas]
|
||||||
|
|
||||||
|
casos = [json.loads(l) for l in open(EVAL, encoding="utf-8") if l.strip()]
|
||||||
|
hit1 = hit5 = 0
|
||||||
|
fallos = []
|
||||||
|
for c in casos:
|
||||||
|
q = encode_query([c["pregunta"]])[0]
|
||||||
|
q = q / max(float(np.linalg.norm(q)), 1e-9)
|
||||||
|
sim = M @ q
|
||||||
|
pal = {w for w in _norm(c["pregunta"]).split() if len(w) > 3}
|
||||||
|
if pal:
|
||||||
|
bono = np.array([0.08 * len(pal & palabras_e[i]) +
|
||||||
|
(0.5 if nom[i] and nom[i] in pal else 0.0)
|
||||||
|
for i in range(len(entradas))], dtype="float32")
|
||||||
|
sim = sim + bono
|
||||||
|
orden = np.argsort(-sim)[:5]
|
||||||
|
esperado = [_norm(k).strip() for k in c["espera"]]
|
||||||
|
tops = [_norm(entradas[i]["texto"]) for i in orden]
|
||||||
|
ok = lambda t: any(k and k in t for k in esperado)
|
||||||
|
if tops and ok(tops[0]):
|
||||||
|
hit1 += 1
|
||||||
|
if any(ok(t) for t in tops):
|
||||||
|
hit5 += 1
|
||||||
|
else:
|
||||||
|
fallos.append(c["pregunta"])
|
||||||
|
n = len(casos)
|
||||||
|
print(f"\n {nombre}")
|
||||||
|
print(f" hit@1 {hit1:3d}/{n} ({100*hit1//n}%)")
|
||||||
|
print(f" hit@5 {hit5:3d}/{n} ({100*hit5//n}%)")
|
||||||
|
if fallos:
|
||||||
|
print(f" no resuelve {len(fallos)}: " + "; ".join(fallos[:6]))
|
||||||
|
return hit1, hit5
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
entradas = _muestra()
|
||||||
|
print(f"Muestra: {len(entradas)} entradas del indice vivo")
|
||||||
|
|
||||||
|
# 1) model2vec estatico, la referencia
|
||||||
|
from model2vec import StaticModel
|
||||||
|
est = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
|
||||||
|
enc_est = lambda xs: est.encode(xs)
|
||||||
|
h1e, h5e = _eval("model2vec (estatico, el actual)", enc_est, enc_est, entradas)
|
||||||
|
|
||||||
|
# 2) el transformer a prueba
|
||||||
|
print(f"\n cargando {MODELO_TF} (puede descargar ~1-2 GB la 1a vez)...", flush=True)
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
tf = SentenceTransformer(MODELO_TF)
|
||||||
|
# los modelos e5 piden prefijos "query:" y "passage:"
|
||||||
|
es_e5 = "e5" in MODELO_TF.lower()
|
||||||
|
enc_doc = lambda xs: tf.encode([("passage: " + x) if es_e5 else x for x in xs],
|
||||||
|
show_progress_bar=False, batch_size=32)
|
||||||
|
enc_q = lambda xs: tf.encode([("query: " + x) if es_e5 else x for x in xs],
|
||||||
|
show_progress_bar=False, batch_size=32)
|
||||||
|
h1t, h5t = _eval(f"{MODELO_TF} (transformer)", enc_doc, enc_q, entradas)
|
||||||
|
|
||||||
|
print("\n" + "=" * 44)
|
||||||
|
print(f" hit@1 estatico {h1e} -> transformer {h1t} ({h1t-h1e:+d})")
|
||||||
|
print(f" hit@5 estatico {h5e} -> transformer {h5t} ({h5t-h5e:+d})")
|
||||||
|
print("=" * 44)
|
||||||
|
if h5t > h5e or (h5t == h5e and h1t > h1e):
|
||||||
|
print(" El transformer gana. Merece plantear el cambio en busca.py")
|
||||||
|
print(" (coste: ~50-150 ms por consulta en CPU, hoy es instantaneo).")
|
||||||
|
else:
|
||||||
|
print(" El transformer NO compensa aqui. El estatico se queda.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
113
nucleo/saber/enriquece/reindexa.py
Executable file
113
nucleo/saber/enriquece/reindexa.py
Executable file
|
|
@ -0,0 +1,113 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Construye el indice candidato uniendo lo viejo, lo cosechado y lo sintetizado.
|
||||||
|
|
||||||
|
./reindexa.py escribe datos/saber.jsonl.nuevo (NO toca el vivo)
|
||||||
|
|
||||||
|
## Que une
|
||||||
|
|
||||||
|
1. El indice vivo (datos/saber.jsonl): comandos de Linux, glosario, fichas,
|
||||||
|
servidores... todo lo que cosecha no produce. Se conserva entero.
|
||||||
|
2. cosecha.jsonl: la metodologia de pentesting que faltaba.
|
||||||
|
3. sintesis.jsonl: preguntas en castellano que apuntan a esos fragmentos.
|
||||||
|
|
||||||
|
## Indexacion multi-representacion
|
||||||
|
|
||||||
|
Cada entrada se EMBEBE por su campo `indexar` si lo tiene (las preguntas
|
||||||
|
sintetizadas), y si no por `texto` (todo lo demas). Asi una entrada de pregunta
|
||||||
|
se compara con la consulta del usuario pregunta-contra-pregunta, pero lo que se
|
||||||
|
le muestra al cerebro sigue siendo el fragmento literal de `texto`. busca.py no
|
||||||
|
cambia: solo lee `texto` y `v`.
|
||||||
|
|
||||||
|
## No destruye nada
|
||||||
|
|
||||||
|
Escribe a `saber.jsonl.nuevo`. Quien decide si sustituye al vivo es evalua.py +
|
||||||
|
correr_noche.sh, y solo si mide que es mejor, guardando antes una copia.
|
||||||
|
|
||||||
|
## Deduplicado
|
||||||
|
|
||||||
|
Por los primeros 120 caracteres del texto normalizado (para las entradas
|
||||||
|
normales) o de la pregunta (para las sintetizadas). OSCP_APUNTES duplica apuntes
|
||||||
|
de primer nivel; esto los colapsa.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
RAIZ = os.path.dirname(os.path.dirname(AQUI))
|
||||||
|
DATOS = os.path.join(RAIZ, "datos")
|
||||||
|
# La base son las entradas que cosecha NO produce (comandos Linux, glosario,
|
||||||
|
# fichas). Por defecto el indice vivo, pero se puede fijar con SABER_BASE al
|
||||||
|
# indice ORIGINAL para una reconstruccion limpia que no apile sintesis sobre
|
||||||
|
# sintesis y no infle el indice a cada pasada.
|
||||||
|
VIVO = os.environ.get("SABER_BASE") or os.path.join(DATOS, "saber.jsonl")
|
||||||
|
COSECHA = os.path.join(DATOS, "cosecha.jsonl")
|
||||||
|
SINTESIS = os.path.join(DATOS, "sintesis.jsonl")
|
||||||
|
NUEVO = os.path.join(DATOS, "saber.jsonl.nuevo")
|
||||||
|
|
||||||
|
MODELO = "minishlab/potion-multilingual-128M"
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(t):
|
||||||
|
t = unicodedata.normalize("NFD", (t or "").lower())
|
||||||
|
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||||
|
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", t)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _lee(ruta, quita_v=False):
|
||||||
|
if not os.path.exists(ruta):
|
||||||
|
return
|
||||||
|
with open(ruta, encoding="utf-8") as f:
|
||||||
|
for l in f:
|
||||||
|
try:
|
||||||
|
d = json.loads(l)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if quita_v:
|
||||||
|
d.pop("v", None)
|
||||||
|
yield d
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
entradas, vistos = [], set()
|
||||||
|
fuentes = [("vivo", VIVO, True), ("cosecha", COSECHA, False),
|
||||||
|
("sintesis", SINTESIS, False)]
|
||||||
|
cuenta = {}
|
||||||
|
for nombre, ruta, quita in fuentes:
|
||||||
|
n = 0
|
||||||
|
for d in _lee(ruta, quita_v=quita):
|
||||||
|
clave_txt = d.get("indexar") or d.get("texto", "")
|
||||||
|
firma = _norm(clave_txt)[:120]
|
||||||
|
if not firma or firma in vistos:
|
||||||
|
continue
|
||||||
|
vistos.add(firma)
|
||||||
|
entradas.append(d)
|
||||||
|
n += 1
|
||||||
|
cuenta[nombre] = n
|
||||||
|
print(f" {nombre:9s} {n:6d} entradas nuevas", flush=True)
|
||||||
|
|
||||||
|
print(f"\n total tras deduplicar: {len(entradas)}", flush=True)
|
||||||
|
|
||||||
|
print(" cargando el modelo de embeddings...", flush=True)
|
||||||
|
from model2vec import StaticModel
|
||||||
|
modelo = StaticModel.from_pretrained(MODELO)
|
||||||
|
|
||||||
|
# se embebe `indexar` si existe (la pregunta), si no `texto`
|
||||||
|
a_embeber = [(e.get("indexar") or e["texto"]) for e in entradas]
|
||||||
|
print(f" calculando {len(a_embeber)} vectores...", flush=True)
|
||||||
|
vectores = modelo.encode(a_embeber, show_progress_bar=False)
|
||||||
|
|
||||||
|
with open(NUEVO, "w", encoding="utf-8") as f:
|
||||||
|
for e, v in zip(entradas, vectores):
|
||||||
|
e["v"] = [round(float(x), 5) for x in v]
|
||||||
|
f.write(json.dumps(e, ensure_ascii=False) + "\n")
|
||||||
|
print(f" escrito: {NUEVO} ({os.path.getsize(NUEVO)//1024} KB)", flush=True)
|
||||||
|
print(f" RESUMEN vivo={cuenta['vivo']} cosecha={cuenta['cosecha']} "
|
||||||
|
f"sintesis={cuenta['sintesis']} total={len(entradas)}", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
207
nucleo/saber/enriquece/sintetiza.py
Executable file
207
nucleo/saber/enriquece/sintetiza.py
Executable file
|
|
@ -0,0 +1,207 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Genera preguntas en castellano para cada fragmento cosechado.
|
||||||
|
|
||||||
|
./sintetiza.py procesa datos/cosecha.jsonl (reanudable, horas)
|
||||||
|
./sintetiza.py --muestra 5 ensena 5 y para
|
||||||
|
|
||||||
|
## Que problema resuelve, y por que no corrompe comandos
|
||||||
|
|
||||||
|
El indice usa embeddings estaticos. Su punto flaco conocido: una pregunta
|
||||||
|
coloquial en castellano —"como saco una shell reversa"— no se parece
|
||||||
|
vectorialmente a un fragmento tecnico en ingles con `bash -i >& /dev/tcp/...`.
|
||||||
|
La busqueda hibrida por palabras ayuda, pero solo si coinciden literales.
|
||||||
|
|
||||||
|
La solucion es **indexacion multi-representacion**: por cada fragmento se generan
|
||||||
|
las preguntas que ese fragmento responde, y se EMBEBE LA PREGUNTA apuntando al
|
||||||
|
fragmento. Asi la consulta del usuario se compara contra otras preguntas, que es
|
||||||
|
comparar peras con peras.
|
||||||
|
|
||||||
|
Clave para no meter la pata: la respuesta que se le muestra al cerebro es el
|
||||||
|
**fragmento original, literal** (campo `texto`). El modelo local solo escribe la
|
||||||
|
PREGUNTA (campo `indexar`). Si inventa una pregunta rara, lo peor que pasa es que
|
||||||
|
esa entrada no recupere bien; **nunca corrompe un comando**, porque no reescribe
|
||||||
|
la respuesta. Y el fragmento crudo sigue en el indice por su lado (lo pone
|
||||||
|
cosecha), asi que la cobertura solo puede subir.
|
||||||
|
|
||||||
|
## El modelo
|
||||||
|
|
||||||
|
Habla con el ollama local. Usa el modelo que ya este CARGADO (mira /api/ps) para
|
||||||
|
no forzar a la tarjeta de 4 GB a cambiar de modelo con el carril verde abierto;
|
||||||
|
si no hay ninguno, carga qwen3.5:4b-jarvis. think:false —qwen3.5 razona por
|
||||||
|
defecto y tardaria 20x—.
|
||||||
|
|
||||||
|
## Reanudable
|
||||||
|
|
||||||
|
Un trabajo de horas no puede perder todo si se corta. Se apunta en un .hecho por
|
||||||
|
que linea de cosecha.jsonl iba, y al arrancar salta hasta ahi. La metodologia
|
||||||
|
(el oro) se procesa PRIMERO, para que si no termina la noche, lo valioso este.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
RAIZ = os.path.dirname(os.path.dirname(AQUI))
|
||||||
|
COSECHA = os.path.join(RAIZ, "datos", "cosecha.jsonl")
|
||||||
|
SALIDA = os.path.join(RAIZ, "datos", "sintesis.jsonl")
|
||||||
|
MARCA = SALIDA + ".hecho"
|
||||||
|
ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434")
|
||||||
|
|
||||||
|
INSTRUCCION = """Eres un generador de preguntas para un buscador de apuntes de
|
||||||
|
pentesting. Te doy un fragmento de apuntes tecnicos. Devuelve de UNA a TRES
|
||||||
|
preguntas, en castellano, que una persona haria por voz y que ese fragmento
|
||||||
|
responde.
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
- Preguntas naturales y variadas, como las diria alguien: "como...", "que
|
||||||
|
comando...", "cual es la forma de...".
|
||||||
|
- NO respondas, NO copies comandos: solo las preguntas.
|
||||||
|
- Si el fragmento es ruido (un titulo suelto, una lista de enlaces), devuelve
|
||||||
|
una lista vacia.
|
||||||
|
|
||||||
|
Responde SOLO con JSON: {"preguntas": ["...", "..."]}"""
|
||||||
|
|
||||||
|
|
||||||
|
FIJO = os.environ.get("JARVIS_MODELO") # si se fija a mano, manda
|
||||||
|
RESPALDO = "qwen3.5:4b-jarvis"
|
||||||
|
|
||||||
|
|
||||||
|
def _modelo_cargado():
|
||||||
|
"""El modelo que ollama ya tiene en memoria, para no forzar un cambio."""
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(ENDPOINT + "/api/ps", timeout=5) as r:
|
||||||
|
m = json.load(r).get("models", [])
|
||||||
|
if m:
|
||||||
|
return m[0]["name"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _modelo():
|
||||||
|
"""Que modelo usar AHORA. Se re-mira cada tanto en vez de fijarlo al
|
||||||
|
arrancar: en una tarjeta de 4 GB solo cabe uno, asi que si el carril verde
|
||||||
|
tiene el suyo cargado, generamos con ESE (una pregunta no necesita el ajuste
|
||||||
|
anti-alucinacion, solo la respuesta lo necesitaria y la respuesta es el
|
||||||
|
fragmento literal). Asi no se fuerza a ollama a cambiar de modelo en cada
|
||||||
|
peticion, que en esta maquina cuesta 2-3 s de recarga.
|
||||||
|
"""
|
||||||
|
return FIJO or _modelo_cargado() or RESPALDO
|
||||||
|
|
||||||
|
|
||||||
|
MODELO = _modelo()
|
||||||
|
|
||||||
|
|
||||||
|
def _pide(fragmento, modelo):
|
||||||
|
cuerpo = json.dumps({
|
||||||
|
"model": modelo,
|
||||||
|
"messages": [{"role": "system", "content": INSTRUCCION},
|
||||||
|
{"role": "user", "content": fragmento[:1400]}],
|
||||||
|
"stream": False, "think": False,
|
||||||
|
"options": {"temperature": 0.7, "num_predict": 200},
|
||||||
|
"format": "json",
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(ENDPOINT + "/api/chat", data=cuerpo,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as r:
|
||||||
|
contenido = json.loads(r.read())["message"]["content"]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
d = json.loads(contenido)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
m = re.search(r"\[.*\]", contenido, re.S)
|
||||||
|
if not m:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
d = {"preguntas": json.loads(m.group(0))}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return []
|
||||||
|
preguntas = d.get("preguntas") if isinstance(d, dict) else d
|
||||||
|
if not isinstance(preguntas, list):
|
||||||
|
return []
|
||||||
|
fuera = []
|
||||||
|
for p in preguntas:
|
||||||
|
if isinstance(p, str) and 8 <= len(p.strip()) <= 200:
|
||||||
|
fuera.append(p.strip())
|
||||||
|
return fuera[:3]
|
||||||
|
|
||||||
|
|
||||||
|
def fragmentos():
|
||||||
|
"""Los fragmentos de cosecha, el ORO (metodologia) primero."""
|
||||||
|
todos = []
|
||||||
|
with open(COSECHA, encoding="utf-8") as f:
|
||||||
|
for l in f:
|
||||||
|
try:
|
||||||
|
todos.append(json.loads(l))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
todos.sort(key=lambda d: 0 if d.get("tipo") == "metodologia" else 1)
|
||||||
|
return todos
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--muestra", type=int, default=0)
|
||||||
|
a = p.parse_args()
|
||||||
|
|
||||||
|
if not os.path.exists(COSECHA):
|
||||||
|
print(" no hay cosecha.jsonl: corre cosecha.py primero")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
frags = fragmentos()
|
||||||
|
print(f" {len(frags)} fragmentos · modelo {MODELO}", flush=True)
|
||||||
|
|
||||||
|
if a.muestra:
|
||||||
|
for fr in frags[:a.muestra]:
|
||||||
|
print(f"\n ── {fr.get('fichero')} ──")
|
||||||
|
for q in _pide(fr["texto"], MODELO):
|
||||||
|
print(f" P: {q}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
desde = 0
|
||||||
|
if os.path.exists(MARCA) and os.path.exists(SALIDA):
|
||||||
|
try:
|
||||||
|
desde = int(open(MARCA).read().strip()) + 1
|
||||||
|
except (ValueError, OSError):
|
||||||
|
desde = 0
|
||||||
|
modo = "a" if desde else "w"
|
||||||
|
hechas = sum(1 for _ in open(SALIDA)) if desde and os.path.exists(SALIDA) else 0
|
||||||
|
if desde:
|
||||||
|
print(f" reanudando desde el fragmento {desde} ({hechas} preguntas)", flush=True)
|
||||||
|
|
||||||
|
total = len(frags)
|
||||||
|
modelo = MODELO
|
||||||
|
with open(SALIDA, modo, encoding="utf-8", buffering=1) as out:
|
||||||
|
for i in range(desde, total):
|
||||||
|
# re-mirar que modelo esta cargado cada 25: si el verde se abre o se
|
||||||
|
# cierra durante la noche, seguimos al que este en la tarjeta.
|
||||||
|
if i % 25 == 0:
|
||||||
|
modelo = _modelo()
|
||||||
|
fr = frags[i]
|
||||||
|
for q in _pide(fr["texto"], modelo):
|
||||||
|
out.write(json.dumps({
|
||||||
|
"tipo": "pregunta",
|
||||||
|
"herramienta": fr.get("herramienta"),
|
||||||
|
"perfil": fr.get("perfil"),
|
||||||
|
"fichero": fr.get("fichero"),
|
||||||
|
"tema": fr.get("tema", ""),
|
||||||
|
"texto": fr["texto"], # la respuesta: el fragmento LITERAL
|
||||||
|
"indexar": q, # lo que se embebe: la pregunta
|
||||||
|
}, ensure_ascii=False) + "\n")
|
||||||
|
hechas += 1
|
||||||
|
with open(MARCA, "w") as m:
|
||||||
|
m.write(str(i))
|
||||||
|
if (i + 1) % 25 == 0:
|
||||||
|
print(f" PROGRESO sintetiza hecho={i+1} total={total} "
|
||||||
|
f"preguntas={hechas}", flush=True)
|
||||||
|
print(f"\n hecho: {hechas} preguntas en {SALIDA}", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
94
nucleo/saber/glosario.py
Normal file
94
nucleo/saber/glosario.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""Tareas comunes de Linux en castellano, mapeadas al comando.
|
||||||
|
|
||||||
|
El puente que las man pages no dan: estan en ingles, y "cambiar permisos" no se
|
||||||
|
parece a "change file mode bits". Aqui viven las tareas del dia a dia dichas
|
||||||
|
como las diria el usuario, con el comando y una linea de como se usa. Es
|
||||||
|
curado a mano a proposito: son las 50 cosas que de verdad se piden, y una
|
||||||
|
traduccion buena vale mas que mil man pages mal recuperadas.
|
||||||
|
|
||||||
|
Se anaden al indice como entradas normales, asi que se buscan igual que todo lo
|
||||||
|
demas y ademas se llevan el impulso por palabras exactas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# (lo que se pide en castellano, el comando, como se usa)
|
||||||
|
TAREAS = [
|
||||||
|
("cambiar los permisos de un fichero o carpeta", "chmod",
|
||||||
|
"chmod 755 fichero (rwx dueño, rx resto). chmod +x da permiso de ejecucion."),
|
||||||
|
("cambiar el dueño de un fichero", "chown",
|
||||||
|
"chown usuario:grupo fichero. Con -R para una carpeta entera."),
|
||||||
|
("comprimir una carpeta en un archivo", "tar",
|
||||||
|
"tar czf archivo.tar.gz carpeta/ comprime. tar xzf archivo.tar.gz extrae."),
|
||||||
|
("descomprimir un zip", "unzip", "unzip archivo.zip. Con -d carpeta para donde."),
|
||||||
|
("buscar texto dentro de ficheros", "grep",
|
||||||
|
"grep -r 'texto' carpeta/ busca en todo. grep -i ignora mayusculas."),
|
||||||
|
("buscar ficheros por nombre", "find",
|
||||||
|
"find /ruta -name '*.txt' busca por nombre. -type f solo ficheros."),
|
||||||
|
("ver los puertos abiertos en escucha", "ss",
|
||||||
|
"ss -tlnp lista los puertos TCP en escucha con su proceso."),
|
||||||
|
("ver que procesos consumen mas", "top",
|
||||||
|
"top o htop en tiempo real. ps aux --sort=-%cpu para una foto."),
|
||||||
|
("matar un proceso", "kill",
|
||||||
|
"kill PID lo termina. kill -9 PID a la fuerza. pkill nombre por nombre."),
|
||||||
|
("ver el espacio libre en disco", "df", "df -h muestra el espacio por particion."),
|
||||||
|
("ver cuanto ocupa una carpeta", "du", "du -sh carpeta/ el tamaño total."),
|
||||||
|
("ver la memoria libre", "free", "free -h muestra RAM y swap."),
|
||||||
|
("descargar un fichero de internet", "wget",
|
||||||
|
"wget URL descarga. curl -O URL tambien. curl -L sigue redirecciones."),
|
||||||
|
("copiar ficheros a un servidor", "scp",
|
||||||
|
"scp fichero usuario@servidor:/ruta lo sube. scp -r para carpetas."),
|
||||||
|
("sincronizar carpetas", "rsync",
|
||||||
|
"rsync -av origen/ destino/ copia solo lo cambiado. -z comprime en red."),
|
||||||
|
("editar un fichero de texto", "nano",
|
||||||
|
"nano fichero (facil) o vim fichero. Ctrl+O guarda y Ctrl+X sale en nano."),
|
||||||
|
("ver el contenido de un fichero", "cat",
|
||||||
|
"cat fichero. less fichero para leer largo. head/tail para principio/final."),
|
||||||
|
("ver los ultimos cambios de un fichero de log", "tail",
|
||||||
|
"tail -f log sigue en vivo. tail -n 50 las ultimas 50 lineas."),
|
||||||
|
("ver los servicios del sistema", "systemctl",
|
||||||
|
"systemctl status servicio. start/stop/restart para manejarlo."),
|
||||||
|
("ver los mensajes del sistema", "journalctl",
|
||||||
|
"journalctl -xe lo reciente. journalctl -u servicio de un servicio."),
|
||||||
|
("montar un disco o usb", "mount",
|
||||||
|
"mount /dev/sdX1 /mnt monta. lsblk lista los discos. umount desmonta."),
|
||||||
|
("ver la configuracion de red", "ip",
|
||||||
|
"ip a las interfaces y sus IP. ip r la tabla de rutas."),
|
||||||
|
("escanear una red o unos puertos", "nmap",
|
||||||
|
"nmap -sV objetivo detecta servicios. nmap -p- todos los puertos."),
|
||||||
|
("ver el uso de red en vivo", "iftop", "iftop o nload muestran el trafico en vivo."),
|
||||||
|
("cambiar de directorio y listar", "ls",
|
||||||
|
"ls -la lista con detalles y ocultos. cd carpeta entra. pwd dice donde estas."),
|
||||||
|
("crear una carpeta", "mkdir", "mkdir carpeta. mkdir -p a/b/c crea la ruta entera."),
|
||||||
|
("mover o renombrar", "mv", "mv origen destino mueve o renombra."),
|
||||||
|
("copiar ficheros", "cp", "cp origen destino. cp -r para carpetas."),
|
||||||
|
("borrar ficheros", "rm",
|
||||||
|
"rm fichero. rm -r carpeta. CUIDADO: no hay papelera, es definitivo."),
|
||||||
|
("dar permisos de administrador a un comando", "sudo",
|
||||||
|
"sudo comando lo ejecuta como root. Pide tu contraseña."),
|
||||||
|
("ver el historial de comandos", "history", "history lista lo tecleado. Ctrl+R busca."),
|
||||||
|
("programar una tarea periodica", "cron",
|
||||||
|
"crontab -e edita tus tareas. Formato: min hora dia mes diasemana comando."),
|
||||||
|
("ver la temperatura y el hardware", "sensors",
|
||||||
|
"sensors muestra temperaturas. lscpu la CPU, lspci los dispositivos."),
|
||||||
|
("gestionar paquetes en Debian", "apt",
|
||||||
|
"apt install paquete, apt update actualiza la lista, apt upgrade el sistema."),
|
||||||
|
("ver o cambiar variables de entorno", "export",
|
||||||
|
"export VAR=valor la pone. env las lista. echo $VAR la muestra."),
|
||||||
|
("comprobar si un comando esta instalado", "which",
|
||||||
|
"which comando dice donde esta, o nada si no esta. type comando tambien."),
|
||||||
|
("ver diferencias entre dos ficheros", "diff",
|
||||||
|
"diff a b muestra las diferencias. diff -u formato de parche."),
|
||||||
|
("contar lineas palabras o bytes", "wc",
|
||||||
|
"wc -l lineas, wc -w palabras. cat fichero | wc -l cuenta lineas."),
|
||||||
|
("conectarse a una base de datos", "psql",
|
||||||
|
"psql -U usuario base para Postgres. mysql -u usuario -p para MySQL."),
|
||||||
|
("ver quien esta conectado", "who", "who los usuarios. w con lo que hacen."),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def entradas():
|
||||||
|
for pide, cmd, como in TAREAS:
|
||||||
|
yield {
|
||||||
|
"tipo": "tarea", "herramienta": cmd, "perfil": "LINUX",
|
||||||
|
"fichero": f"tarea comun: {cmd}",
|
||||||
|
"texto": f"Para {pide}: usa {cmd}. {como}",
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue