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:
sito 2026-08-16 15:28:31 +02:00
commit 8e4bc8ad94
125 changed files with 25033 additions and 0 deletions

40
.gitignore vendored Normal file
View 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
View 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
View 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
View 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.
![Linux](https://img.shields.io/badge/Linux-Debian%2012-A81D33?logo=debian&logoColor=white)
![GNOME](https://img.shields.io/badge/GNOME-X11-4A86CF?logo=gnome&logoColor=white)
![Python](https://img.shields.io/badge/Python-3.11-3776AB?logo=python&logoColor=white)
![Ollama](https://img.shields.io/badge/Ollama-qwen3.5%3A4b-000000?logo=ollama&logoColor=white)
![NVIDIA](https://img.shields.io/badge/CUDA-sm__75-76B900?logo=nvidia&logoColor=white)
![whisper.cpp](https://img.shields.io/badge/whisper.cpp-STT-555555)
![Piper](https://img.shields.io/badge/Piper-TTS-555555)
```
┌───────────────────────────┐
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 panel](docs/panel.png)
-->
## 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.

View 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
View 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
View 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())

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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())

File diff suppressed because it is too large Load diff

58
config/jarvis-piper.sh Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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())

View 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
View 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("")
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
View 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
View 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"

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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" "$@"

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,6 @@
datos/cache_voz/
venv/
__pycache__/
*.pyc
datos/saber.jsonl
datos/diario.jsonl

135
nucleo/arranca.sh Executable file
View 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
View file

210
nucleo/boca/voz.py Normal file
View 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
View file

948
nucleo/cara/hud/hud.js Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

575
nucleo/cara/panel.py Normal file
View 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
View 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
View 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

View file

316
nucleo/cerebro/ollama.py Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

290
nucleo/estado.py Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

188
nucleo/oido/captura.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

159
nucleo/saber/busca.py Normal file
View 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)

View 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.

View 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
View 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())

View 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
View 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())

View 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())

View 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())

View 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
View 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