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