JARVIS: asistente de voz local para Linux
Nucleo propio: oye con whisper.cpp, piensa con un modelo de Ollama, habla con Piper, y hace RAG sobre los apuntes del usuario. 100% local, sin cuentas ni claves. Escrito bajo una restriccion dura, 4 GB de VRAM: el cerebro y whisper comparten tarjeta y solo caben porque estan dimensionados para ello. El RAG usa embeddings estaticos con busqueda hibrida; la voz clonada se sirve de una cache de frases. Incluye instalador (install.sh), requisitos, y documentacion del stack, del manejo de root y de las acciones. Los apuntes indexados y el diario NO se incluyen: son privados y el .gitignore los bloquea.
This commit is contained in:
commit
8e4bc8ad94
125 changed files with 25033 additions and 0 deletions
6
nucleo/.gitignore
vendored
Normal file
6
nucleo/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
datos/cache_voz/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
datos/saber.jsonl
|
||||
datos/diario.jsonl
|
||||
135
nucleo/arranca.sh
Executable file
135
nucleo/arranca.sh
Executable file
|
|
@ -0,0 +1,135 @@
|
|||
#!/usr/bin/env bash
|
||||
# Lanzador del JARVIS naranja: el nucleo, fuera del flatpak.
|
||||
#
|
||||
# arranca.sh levanta ollama si hace falta, abre el panel y escucha
|
||||
# arranca.sh --panel solo el panel, sin microfono
|
||||
# arranca.sh --stop lo para y libera la grafica
|
||||
# arranca.sh --estado dice si esta vivo
|
||||
#
|
||||
# Comparado con el lanzador del JARVIS azul, aqui sobra casi todo: no hay que
|
||||
# adivinar la rama del flatpak, ni leer gsettings con escapado de GVariant, ni
|
||||
# preguntarle a la aplicacion que modelo tiene puesto. El nucleo lo sabe porque
|
||||
# es suyo.
|
||||
#
|
||||
# Lo que SI se conserva, porque en esta maquina importa: al parar se le dice a
|
||||
# ollama que suelte el modelo. Son 2,1 GB de una tarjeta de 4, y sin esto whisper
|
||||
# se queda sin sitio la proxima vez.
|
||||
set -uo pipefail
|
||||
|
||||
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
|
||||
PYTHON=$RAIZ/venv/bin/python
|
||||
[ -x "$PYTHON" ] || PYTHON=$(command -v python3)
|
||||
OLLAMA_URL=${JARVIS_OLLAMA:-http://127.0.0.1:11434}
|
||||
REGISTRO=$RAIZ/jarvis.log
|
||||
PUERTO=${JARVIS_PUERTO:-8790}
|
||||
|
||||
# Fichero de PID, no pgrep.
|
||||
#
|
||||
# El primer intento fue marcar el proceso con una variable de entorno y buscarla
|
||||
# con `pgrep -f`. No funciona: pgrep mira la LINEA DE ORDENES, y `env VAR=1 cmd`
|
||||
# no deja la variable ahi. El sintoma fue peor que un simple "no lo encuentra":
|
||||
# el lanzador creia que no habia nada corriendo, arrancaba un segundo, y ese
|
||||
# moria con "address already in use" mientras el primero seguia vivo.
|
||||
#
|
||||
# Y buscar por el nombre del script tiene el problema de siempre: el patron
|
||||
# aparece en la linea de ordenes del propio pgrep. Un fichero de PID no se
|
||||
# equivoca.
|
||||
PID=${XDG_RUNTIME_DIR:-/tmp}/jarvis-nucleo.pid
|
||||
|
||||
aviso() { command -v notify-send >/dev/null && \
|
||||
notify-send -i "$RAIZ/jarvis-naranja.svg" "JARVIS" "$1"; }
|
||||
|
||||
modelo() {
|
||||
grep -oP 'MODELO = os\.environ\.get\("JARVIS_MODELO", "\K[^"]+' \
|
||||
"$RAIZ/cerebro/ollama.py" 2>/dev/null || echo "qwen3.5:4b-jarvis"
|
||||
}
|
||||
|
||||
# Vivo = hay un pid apuntado Y ese proceso existe. Un pid huerfano de un
|
||||
# reinicio no puede hacernos creer que sigue en marcha.
|
||||
vivo() {
|
||||
[ -f "$PID" ] || return 1
|
||||
local p; p=$(cat "$PID" 2>/dev/null)
|
||||
[ -n "$p" ] && kill -0 "$p" 2>/dev/null
|
||||
}
|
||||
ollama_up() { curl -s --max-time 2 "$OLLAMA_URL/api/version" >/dev/null 2>&1; }
|
||||
puerto_ocupado() { curl -s --max-time 2 "http://127.0.0.1:$PUERTO/" >/dev/null 2>&1; }
|
||||
|
||||
case "${1:-arranca}" in
|
||||
--stop)
|
||||
# Tambien lo que este ocupando el puerto sin fichero de pid: si no, un
|
||||
# huerfano impide arrancar de nuevo y --stop parece no hacer nada.
|
||||
if ! vivo && puerto_ocupado && command -v fuser >/dev/null; then
|
||||
fuser -k "$PUERTO/tcp" >/dev/null 2>&1
|
||||
fi
|
||||
if vivo; then
|
||||
kill "$(cat "$PID")" 2>/dev/null
|
||||
# margen para que suelte el puerto antes de que nadie lo reclame
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do vivo || break; sleep 0.3; done
|
||||
vivo && kill -9 "$(cat "$PID")" 2>/dev/null
|
||||
fi
|
||||
rm -f "$PID"
|
||||
if ollama_up; then
|
||||
# keep_alive 0: suelta el modelo ya, no dentro de cinco minutos
|
||||
curl -s "$OLLAMA_URL/api/generate" \
|
||||
-d "{\"model\":\"$(modelo)\",\"keep_alive\":0}" >/dev/null 2>&1
|
||||
aviso "Detenido, grafica liberada"
|
||||
else
|
||||
aviso "Detenido"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
|
||||
--estado)
|
||||
# Se mira el pid Y el puerto. Con solo el pid, una instancia arrancada a
|
||||
# mano —o superviviente de un lanzador que murio— daba "parado" mientras
|
||||
# el panel contestaba tan tranquilo, que es la peor respuesta posible:
|
||||
# la que hace dudar de si el problema es el programa o quien pregunta.
|
||||
if vivo; then
|
||||
echo "JARVIS naranja: vivo"
|
||||
exit 0
|
||||
fi
|
||||
if puerto_ocupado; then
|
||||
echo "JARVIS naranja: vivo, pero arrancado por fuera de este lanzador"
|
||||
echo " (no hay $PID; para pararlo: fuser -k $PUERTO/tcp)"
|
||||
exit 0
|
||||
fi
|
||||
echo "JARVIS naranja: parado"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if vivo || puerto_ocupado; then
|
||||
# Ya esta abierto: en vez de levantar otro —que moriria con "address already
|
||||
# in use" y dejaria un rastro confuso— se trae el panel al frente.
|
||||
aviso "Ya estaba en marcha"
|
||||
# La MISMA ventana que abre el arranque normal. Antes esto era xdg-open, que
|
||||
# abre el navegador POR DEFECTO en una pestaña: segun si JARVIS ya estaba
|
||||
# abierto o no, el mismo icono daba una aplicacion o una pestaña de Firefox.
|
||||
setsid "$RAIZ/ventana.sh" "http://127.0.0.1:$PUERTO/" >/dev/null 2>&1 &
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ollama, si no esta ya. En segundo plano y sin esperar: el catalogo contesta
|
||||
# las ordenes conocidas sin tocar el modelo, asi que JARVIS es util desde el
|
||||
# primer segundo aunque ollama tarde en calentar.
|
||||
if ! ollama_up && command -v ollama >/dev/null; then
|
||||
setsid ollama serve >/dev/null 2>&1 &
|
||||
fi
|
||||
|
||||
# El indice de COFRE, si no esta. En segundo plano: JARVIS arranca sin el y el
|
||||
# cerebro lo usa en cuanto este listo. Reconstruirlo a mano: saber/indexa.py
|
||||
if [ ! -f "$RAIZ/datos/saber.jsonl" ]; then
|
||||
setsid "$PYTHON" "$RAIZ/saber/indexa.py" >/dev/null 2>&1 &
|
||||
fi
|
||||
|
||||
: > "$REGISTRO" 2>/dev/null || REGISTRO=/dev/null
|
||||
aviso "Arrancando"
|
||||
|
||||
MODO=(--panel --escucha)
|
||||
[ "${1:-}" = "--panel" ] && MODO=(--panel)
|
||||
|
||||
# El pid se apunta ANTES del exec y es el de este mismo shell, que con exec
|
||||
# pasa a ser el de Python: asi el fichero nunca apunta a un proceso que no es.
|
||||
echo $$ > "$PID"
|
||||
trap 'rm -f "$PID"' EXIT
|
||||
exec "$PYTHON" -u "$RAIZ/jarvis.py" "${MODO[@]}" >>"$REGISTRO" 2>&1
|
||||
0
nucleo/boca/__init__.py
Normal file
0
nucleo/boca/__init__.py
Normal file
210
nucleo/boca/voz.py
Normal file
210
nucleo/boca/voz.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"""La boca de JARVIS: convertir texto en sonido, y saber cuando esta sonando.
|
||||
|
||||
Esto es la version de `voces.py` + la mitad de `tts.py` de Newelle, pero sin el
|
||||
motor de handlers: aqui solo hablamos por Piper y por el clon, y los dos son un
|
||||
script de shell que escribe un WAV. Todo lo demas sobraba.
|
||||
|
||||
## Lo que hay que hacer bien y en Newelle estaba mal
|
||||
|
||||
**Saber que esta hablando, y saberlo A TIEMPO.** El eco que envenena el registro
|
||||
de JARVIS —el 21 % confirmado de las frases que no entiende son suyas— viene de
|
||||
que Newelle marca `assistant_speaking` con `GLib.idle_add`, que es asincrono: el
|
||||
audio ya esta sonando mientras el hilo de grabacion sigue leyendo `False`. Aqui
|
||||
la bandera se pone ANTES de lanzar el reproductor y se quita con retardo, porque
|
||||
cuando el proceso termina el sonido todavia esta viajando hasta el microfono.
|
||||
|
||||
**Un solo altavoz.** Con tareas en paralelo, dos respuestas a la vez se pisan.
|
||||
Se habla por turnos con un cerrojo, igual que hacia `_habla` en el panel.
|
||||
|
||||
## La cache
|
||||
|
||||
Clave `sha1(voz|texto normalizado)`. Piper tarda entre medio segundo y dos por
|
||||
frase, y JARVIS repite mucho ("Enseguida, señor", "No he podido, señor"): con
|
||||
cache eso es un `cp`.
|
||||
|
||||
Se guarda en `datos/cache_voz/`, no en la del clon: aquella la genera
|
||||
`generar_cache_voz.py` con su propia clave (sha1 solo del texto) y es de solo
|
||||
lectura para nosotros. `jarvis-voz.sh` ya la consulta por dentro.
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
CONFIG = os.path.join(RAIZ, "config")
|
||||
MODELOS = os.path.join(RAIZ, "voz", "modelos")
|
||||
CACHE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"datos", "cache_voz")
|
||||
|
||||
# Cuanto se sigue considerando "hablando" despues de que el reproductor termine.
|
||||
# El sonido tarda en salir del buffer de la tarjeta y en llegar al microfono; sin
|
||||
# esta cola, la ultima silaba de JARVIS se cuela en la siguiente transcripcion.
|
||||
#
|
||||
# El valor sale de medirlo con pruebas/prueba_eco.py, no de suponerlo: con 350
|
||||
# la frase entera dejaba de colarse pero seguia escapandose un fragmento.
|
||||
COLA_MS = int(os.environ.get("JARVIS_COLA_MS", "700"))
|
||||
|
||||
|
||||
def _piper(modelo):
|
||||
return {
|
||||
"orden": [os.path.join(CONFIG, "jarvis-piper.sh")],
|
||||
"entorno": {"JARVIS_PIPER_VOZ": modelo, "JARVIS_PIPER_TONO": "1.0"},
|
||||
"necesita": os.path.join(MODELOS, modelo + ".onnx"),
|
||||
}
|
||||
|
||||
|
||||
# El orden es el de rotacion. davefx primero porque es la que se eligio a oido.
|
||||
VOCES = {
|
||||
"davefx": _piper("es_ES-davefx-medium"),
|
||||
"clon": {
|
||||
"orden": [os.path.join(CONFIG, "jarvis-voz.sh")],
|
||||
"entorno": {},
|
||||
"necesita": os.path.join(CONFIG, "jarvis-voz.sh"),
|
||||
},
|
||||
"claude": _piper("es_MX-claude-high"),
|
||||
"ald": _piper("es_MX-ald-medium"),
|
||||
"sharvard": _piper("es_ES-sharvard-medium"),
|
||||
}
|
||||
|
||||
POR_DEFECTO = "davefx"
|
||||
|
||||
# Se prueban en orden. paplay y pw-play respetan el enrutado de PipeWire, que es
|
||||
# lo que hace falta para que el cancelador de eco tenga la referencia de lo que
|
||||
# sale por los altavoces. aplay habla con ALSA directamente y se la salta.
|
||||
REPRODUCTORES = (["paplay"], ["pw-play"], ["aplay", "-q"])
|
||||
|
||||
|
||||
def _normaliza(texto: str) -> str:
|
||||
return re.sub(r"\s+", " ", texto or "").strip()
|
||||
|
||||
|
||||
def _clave(voz: str, texto: str) -> str:
|
||||
return hashlib.sha1(f"{voz}|{_normaliza(texto)}".encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _reproductor():
|
||||
for r in REPRODUCTORES:
|
||||
if shutil.which(r[0]):
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
class Boca:
|
||||
"""Todo lo que suena sale de aqui, y de uno en uno."""
|
||||
|
||||
def __init__(self, voz: str = POR_DEFECTO, al_cambiar=None):
|
||||
self.voz = voz if voz in VOCES else POR_DEFECTO
|
||||
self._turno = threading.Lock()
|
||||
self._hablando = False
|
||||
self._hasta = 0.0 # instante hasta el que sigue contando como hablando
|
||||
self._al_cambiar = al_cambiar # se avisa al oido para que se calle
|
||||
os.makedirs(CACHE, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------ el estado
|
||||
|
||||
@property
|
||||
def hablando(self) -> bool:
|
||||
"""Si esta sonando algo, incluida la cola.
|
||||
|
||||
El oido consulta esto en cada trozo de audio. Tiene que ser barato y,
|
||||
sobre todo, no puede mentir por defecto: mas vale sobrar 300 ms de
|
||||
silencio que colar la propia voz en la transcripcion.
|
||||
"""
|
||||
return self._hablando or time.monotonic() < self._hasta
|
||||
|
||||
def _marca(self, hablando: bool):
|
||||
self._hablando = hablando
|
||||
if not hablando:
|
||||
self._hasta = time.monotonic() + COLA_MS / 1000.0
|
||||
if self._al_cambiar:
|
||||
try:
|
||||
self._al_cambiar(hablando)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------- generar
|
||||
|
||||
def _genera(self, texto: str) -> str | None:
|
||||
"""Devuelve la ruta del WAV, de cache o recien hecho."""
|
||||
voz = VOCES[self.voz]
|
||||
destino = os.path.join(CACHE, _clave(self.voz, texto) + ".wav")
|
||||
if os.path.exists(destino) and os.path.getsize(destino) > 0:
|
||||
return destino
|
||||
|
||||
entorno = {**os.environ, **voz["entorno"]}
|
||||
try:
|
||||
subprocess.run(voz["orden"] + [destino, texto], env=entorno,
|
||||
capture_output=True, timeout=120)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return None
|
||||
if not os.path.exists(destino) or os.path.getsize(destino) == 0:
|
||||
# Que no se quede un fichero vacio en la cache: el siguiente intento
|
||||
# lo daria por bueno y JARVIS se quedaria mudo para esa frase.
|
||||
if os.path.exists(destino):
|
||||
os.unlink(destino)
|
||||
return None
|
||||
return destino
|
||||
|
||||
# -------------------------------------------------------------- hablar
|
||||
|
||||
def di(self, texto: str) -> bool:
|
||||
"""Habla, esperando turno. Devuelve si sono."""
|
||||
texto = _normaliza(texto)
|
||||
if not texto:
|
||||
return False
|
||||
with self._turno:
|
||||
return self._suelta(texto)
|
||||
|
||||
def di_si_libre(self, texto: str) -> bool:
|
||||
"""Habla solo si nadie tiene el turno. Para avisos que no merecen cola."""
|
||||
if not self._turno.acquire(blocking=False):
|
||||
return False
|
||||
try:
|
||||
return self._suelta(_normaliza(texto))
|
||||
finally:
|
||||
self._turno.release()
|
||||
|
||||
def _suelta(self, texto: str) -> bool:
|
||||
wav = self._genera(texto)
|
||||
if not wav:
|
||||
return False
|
||||
orden = _reproductor()
|
||||
if not orden:
|
||||
return False
|
||||
|
||||
# La bandera ANTES de lanzar el reproductor, no despues y no en otro
|
||||
# hilo. Este es exactamente el fallo de Newelle que causa el eco.
|
||||
self._marca(True)
|
||||
try:
|
||||
subprocess.run(orden + [wav], capture_output=True, timeout=300)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
finally:
|
||||
self._marca(False)
|
||||
return True
|
||||
|
||||
# --------------------------------------------------------------- voces
|
||||
|
||||
def disponibles(self) -> list:
|
||||
return [n for n, v in VOCES.items() if os.path.exists(v["necesita"])]
|
||||
|
||||
def cambia(self, nombre: str) -> bool:
|
||||
if nombre not in VOCES or not os.path.exists(VOCES[nombre]["necesita"]):
|
||||
return False
|
||||
self.voz = nombre
|
||||
return True
|
||||
|
||||
def siguiente(self) -> str:
|
||||
lista = self.disponibles()
|
||||
if not lista:
|
||||
return self.voz
|
||||
try:
|
||||
i = lista.index(self.voz)
|
||||
except ValueError:
|
||||
i = -1
|
||||
self.voz = lista[(i + 1) % len(lista)]
|
||||
return self.voz
|
||||
0
nucleo/cara/__init__.py
Normal file
0
nucleo/cara/__init__.py
Normal file
948
nucleo/cara/hud/hud.js
Normal file
948
nucleo/cara/hud/hud.js
Normal file
|
|
@ -0,0 +1,948 @@
|
|||
/* HUD de JARVIS.
|
||||
*
|
||||
* Python empuja eventos con window.jarvis.recibe({tipo, datos}) y el HUD
|
||||
* devuelve pulsaciones por window.webkit.messageHandlers.
|
||||
*
|
||||
* Los niveles llegan a ~15 por segundo para no cargar el puente; el dibujo
|
||||
* va a 60 con requestAnimationFrame e interpola entre envios, asi que se ve
|
||||
* fluido sin pagarlo en llamadas. */
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const BANDAS = 12;
|
||||
const MAX_ACCIONES = 8;
|
||||
|
||||
// El reactor no lleva colores propios: los lee de las variables del CSS, asi
|
||||
// que cambiar la paleta repinta tambien el canvas sin tocar JavaScript. Un
|
||||
// solo tema —naranja neon sobre negro— y por eso ya no hay lista ni rotacion.
|
||||
const VARIABLES = {
|
||||
reposo: ["--r-vivo", "--r-hondo", "--r-nucleo"],
|
||||
escuchando: ["--r-vivo", "--r-hondo", "--r-nucleo"],
|
||||
hablando: ["--r-habla", "--r-habla-hondo", "--r-habla-nucleo"],
|
||||
parado: ["--r-parado", "--r-parado-hondo", "--r-parado-nucleo"],
|
||||
};
|
||||
|
||||
let COLORES = {};
|
||||
|
||||
function leeColores() {
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
COLORES = {};
|
||||
for (const [estado, vars] of Object.entries(VARIABLES)) {
|
||||
const [vivo, hondo, nucleo] = vars.map((v) => css.getPropertyValue(v).trim());
|
||||
COLORES[estado] = { vivo, hondo, nucleo };
|
||||
}
|
||||
}
|
||||
|
||||
// La lista de voces vive en Python (utility/voces.py), no aqui: el boton solo
|
||||
// pide "la siguiente" y pinta lo que le contesten. Asi el rotulo no puede
|
||||
// decir una voz distinta de la que esta puesta de verdad.
|
||||
function ponVoz(datos) {
|
||||
const boton = document.getElementById("voz");
|
||||
if (!boton || !datos) return;
|
||||
boton.querySelector(".rotulo").textContent = "voz: " + (datos.nombre || "?");
|
||||
boton.dataset.voz = datos.id || "";
|
||||
// el motivo del fallo se lee al pasar por encima, no ocupa sitio en pantalla
|
||||
boton.title = datos.error
|
||||
? "No pude cambiar de voz: " + datos.error
|
||||
: "Cambiar la voz. La estrena diciendo una frase";
|
||||
boton.classList.toggle("fallo", Boolean(datos.error));
|
||||
}
|
||||
|
||||
// Igual que la voz: la lista vive en Python (utility/cerebro.py). Pero este
|
||||
// boton marca ademas si lo que se dice SALE de la maquina, porque eso no se
|
||||
// puede saber mirando el panel de ninguna otra forma.
|
||||
function ponCerebro(datos) {
|
||||
const boton = document.getElementById("cerebro");
|
||||
if (!boton || !datos) return;
|
||||
boton.querySelector(".rotulo").textContent = "cerebro: " + (datos.nombre || "?");
|
||||
boton.dataset.cerebro = datos.id || "";
|
||||
boton.classList.toggle("fuera", Boolean(datos.fuera));
|
||||
boton.classList.toggle("fallo", Boolean(datos.error));
|
||||
boton.title = datos.error
|
||||
? "No pude cambiar de cerebro: " + datos.error
|
||||
: (datos.fuera
|
||||
? "Piensa con Claude (" + (datos.detalle || "") + "). La voz sigue "
|
||||
+ "siendo local, pero lo que preguntes sale de esta maquina."
|
||||
: "Piensa aqui dentro (" + (datos.detalle || "") + "). Nada sale de "
|
||||
+ "la maquina.");
|
||||
if (datos.nombre && datos.nombre !== "\u2014") {
|
||||
aConsola("cerebro: " + datos.nombre
|
||||
+ (datos.fuera ? " (las preguntas salen de la maquina)"
|
||||
: " (todo local)"), "orden");
|
||||
}
|
||||
}
|
||||
|
||||
function ponDicho(texto, atendido, motivo) {
|
||||
elDicho.textContent = texto || "";
|
||||
elDicho.classList.toggle("visible", Boolean(texto));
|
||||
elDicho.classList.toggle("desoido", !atendido);
|
||||
elDicho.dataset.motivo = atendido ? "" : (motivo || "");
|
||||
}
|
||||
|
||||
// La conversacion tambien va a la consola. Bajo el reactor solo se ve la
|
||||
// ultima frase; aqui queda el hilo entero con su hora, que es lo que hace
|
||||
// falta para entender por que no reacciono hace dos minutos.
|
||||
function aConsolaDicho(texto, atendido, motivo) {
|
||||
if (!texto) return;
|
||||
if (atendido) {
|
||||
aConsola("> " + texto, "orden");
|
||||
} else {
|
||||
aConsola("> " + texto, "malo");
|
||||
if (motivo) aConsola(" " + motivo, "malo");
|
||||
}
|
||||
}
|
||||
|
||||
const ETIQUETAS = {
|
||||
reposo: "en reposo",
|
||||
// "escuchando" prometia mas de lo que hacia: el microfono esta abierto,
|
||||
// pero mientras no se le llame por su nombre no atiende nada. Decirlo evita
|
||||
// la duda de si el problema es el microfono.
|
||||
dormido: "diga jarvis",
|
||||
escuchando: "escuchando",
|
||||
pensando: "pensando",
|
||||
preguntando: "conteste",
|
||||
hablando: "respondiendo",
|
||||
parado: "desconectado",
|
||||
};
|
||||
|
||||
const nivelesObjetivo = new Array(BANDAS).fill(0.04);
|
||||
const nivelesActuales = new Array(BANDAS).fill(0.04);
|
||||
let estado = "parado";
|
||||
let fase = 0;
|
||||
|
||||
const lienzo = document.getElementById("reactor");
|
||||
const ctx = lienzo.getContext("2d");
|
||||
const elEstado = document.getElementById("estado");
|
||||
const elDicho = document.getElementById("dicho");
|
||||
const elRegistro = document.getElementById("registro");
|
||||
|
||||
/* ------------------------------------------------------------ dibujo */
|
||||
|
||||
function ajustarLienzo() {
|
||||
const escala = window.devicePixelRatio || 1;
|
||||
const lado = lienzo.clientWidth;
|
||||
lienzo.width = lado * escala;
|
||||
lienzo.height = lado * escala;
|
||||
ctx.setTransform(escala, 0, 0, escala, 0, 0);
|
||||
}
|
||||
|
||||
function dibujar() {
|
||||
const lado = lienzo.clientWidth;
|
||||
const cx = lado / 2;
|
||||
const cy = lado / 2;
|
||||
const color = COLORES[estado] || COLORES.reposo;
|
||||
|
||||
ctx.clearRect(0, 0, lado, lado);
|
||||
|
||||
let media = 0;
|
||||
for (let i = 0; i < BANDAS; i++) {
|
||||
nivelesActuales[i] += (nivelesObjetivo[i] - nivelesActuales[i]) * 0.25;
|
||||
media += nivelesActuales[i];
|
||||
}
|
||||
media /= BANDAS;
|
||||
|
||||
const radioBase = lado * 0.17;
|
||||
const largoMax = lado * 0.20;
|
||||
|
||||
// halo: crece con la voz, da la sensacion de energia sin dibujar nada mas
|
||||
const halo = ctx.createRadialGradient(cx, cy, radioBase * 0.2, cx, cy,
|
||||
radioBase + largoMax * (0.5 + media));
|
||||
halo.addColorStop(0, hexA(color.vivo, 0.20 + media * 0.30));
|
||||
halo.addColorStop(0.55, hexA(color.vivo, 0.06));
|
||||
halo.addColorStop(1, hexA(color.vivo, 0));
|
||||
ctx.fillStyle = halo;
|
||||
ctx.fillRect(0, 0, lado, lado);
|
||||
|
||||
// anillos de fondo, quietos: dan escala a lo que se mueve
|
||||
ctx.strokeStyle = hexA(color.vivo, 0.10);
|
||||
ctx.lineWidth = 1;
|
||||
for (const f of [0.62, 0.82, 1.0]) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, (radioBase + largoMax) * f, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// espigas: una por banda del analizador
|
||||
ctx.lineCap = "round";
|
||||
for (let i = 0; i < BANDAS; i++) {
|
||||
const ang = (i / BANDAS) * Math.PI * 2 + fase - Math.PI / 2;
|
||||
const nivel = Math.min(1, nivelesActuales[i]);
|
||||
const r0 = radioBase;
|
||||
const r1 = radioBase + largoMax * (0.10 + nivel * 0.9);
|
||||
|
||||
const grad = ctx.createLinearGradient(
|
||||
cx + Math.cos(ang) * r0, cy + Math.sin(ang) * r0,
|
||||
cx + Math.cos(ang) * r1, cy + Math.sin(ang) * r1);
|
||||
grad.addColorStop(0, hexA(color.vivo, 0.95));
|
||||
grad.addColorStop(1, hexA(color.hondo, 0.15));
|
||||
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = Math.max(2, lado * 0.010);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + Math.cos(ang) * r0, cy + Math.sin(ang) * r0);
|
||||
ctx.lineTo(cx + Math.cos(ang) * r1, cy + Math.sin(ang) * r1);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// tres arcos girando a distinta velocidad y sentido: nunca parece congelado
|
||||
ctx.lineWidth = Math.max(1.5, lado * 0.005);
|
||||
const arcos = [
|
||||
{ r: 1.30, giro: 1.0, largo: 1.5, alfa: 0.55 },
|
||||
{ r: 1.52, giro: -0.6, largo: 1.0, alfa: 0.38 },
|
||||
{ r: 1.72, giro: 0.35, largo: 2.2, alfa: 0.22 },
|
||||
];
|
||||
for (const a of arcos) {
|
||||
const inicio = fase * a.giro;
|
||||
ctx.strokeStyle = hexA(color.vivo, a.alfa);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radioBase * a.r, inicio, inicio + a.largo);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// triangulo del icono
|
||||
const rt = radioBase * 0.66;
|
||||
ctx.strokeStyle = hexA(color.nucleo, 0.75);
|
||||
ctx.lineWidth = Math.max(1.5, lado * 0.004);
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ang = (i / 3) * Math.PI * 2 - Math.PI / 2;
|
||||
const x = cx + Math.cos(ang) * rt;
|
||||
const y = cy + Math.sin(ang) * rt;
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
|
||||
// nucleo latiendo
|
||||
const rn = radioBase * (0.26 + media * 0.14);
|
||||
const gn = ctx.createRadialGradient(cx, cy, 0, cx, cy, rn * 2.4);
|
||||
gn.addColorStop(0, hexA(color.nucleo, 1));
|
||||
gn.addColorStop(0.4, hexA(color.vivo, 0.65));
|
||||
gn.addColorStop(1, hexA(color.vivo, 0));
|
||||
ctx.fillStyle = gn;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, rn * 2.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
fase += estado === "hablando" ? 0.020 : 0.007;
|
||||
requestAnimationFrame(dibujar);
|
||||
}
|
||||
|
||||
function hexA(hex, alfa) {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alfa})`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ eventos */
|
||||
|
||||
function ponEstado(nuevo) {
|
||||
if (nuevo === estado) return;
|
||||
estado = nuevo;
|
||||
elEstado.textContent = ETIQUETAS[nuevo] || nuevo;
|
||||
elEstado.className = "estado " + nuevo;
|
||||
}
|
||||
|
||||
function ponMedidor(clave, porcentaje, texto) {
|
||||
const fila = document.querySelector(`.medidor[data-clave="${clave}"]`);
|
||||
if (!fila) return;
|
||||
fila.querySelector("i").style.width = Math.min(100, porcentaje) + "%";
|
||||
fila.querySelector(".cifra").textContent = texto;
|
||||
}
|
||||
|
||||
// El rotulo dice lo que PASA, no lo que hace el boton: leer "escuchando" y
|
||||
// ver el punto encendido no deja lugar a dudas; "silenciar" obligaria a
|
||||
// deducir si es lo que hace o lo que esta puesto.
|
||||
const ROTULOS = {
|
||||
parar: { on: "parar", off: "reanudar" },
|
||||
// El rotulo dice como ESTA: "escuchando" es que atiende todo lo que oiga,
|
||||
// "solo con nombre" es que hay que llamarle antes de cada orden.
|
||||
sueno: { on: "escuchando", off: "solo con nombre" },
|
||||
};
|
||||
|
||||
function ponRotulo(boton) {
|
||||
const textos = ROTULOS[boton.dataset.accion];
|
||||
if (!textos) return;
|
||||
boton.querySelector(".rotulo").textContent = textos[boton.dataset.estado] || "";
|
||||
}
|
||||
|
||||
function ponControles(datos) {
|
||||
for (const [accion, encendido] of Object.entries(datos)) {
|
||||
const boton = document.querySelector(`.controles button[data-accion="${accion}"]`);
|
||||
if (!boton) continue;
|
||||
boton.dataset.estado = encendido ? "on" : "off";
|
||||
ponRotulo(boton);
|
||||
}
|
||||
// Parado no es el estado de un boton, es el estado del asistente: se marca
|
||||
// en el documento entero. Un HUD encendido con un boton pequeño en rojo se
|
||||
// lee como "funcionando"; apagado no se puede confundir.
|
||||
if (datos.parar !== undefined) {
|
||||
document.body.classList.toggle("parado", !datos.parar);
|
||||
if (!datos.parar) ponEstado("parado");
|
||||
}
|
||||
}
|
||||
|
||||
// Formatos: cada constante se lee de un vistazo o no sirve de nada, asi que
|
||||
// las unidades se eligen segun la magnitud en vez de soltar el numero crudo.
|
||||
const CONSTANTES = {
|
||||
temperatura: (v) => `${v.toFixed(0)} °C`,
|
||||
carga: (v) => v.map((x) => x.toFixed(2)).join(" "),
|
||||
uptime: (v) => (v < 3600 ? `${(v / 60).toFixed(0)} min`
|
||||
: v < 86400 ? `${(v / 3600).toFixed(1)} h`
|
||||
: `${(v / 86400).toFixed(1)} dias`),
|
||||
bateria: (v) => `${v.toFixed(0)} %`,
|
||||
red: (v) => `${fmtKB(v.baja)} ↓ ${fmtKB(v.sube)} ↑`,
|
||||
};
|
||||
|
||||
function fmtKB(kb) {
|
||||
return kb >= 1024 ? `${(kb / 1024).toFixed(1)}M` : `${kb.toFixed(0)}K`;
|
||||
}
|
||||
|
||||
function ponConstantes(datos) {
|
||||
for (const [clave, formato] of Object.entries(CONSTANTES)) {
|
||||
if (datos[clave] === undefined) continue;
|
||||
const fila = document.querySelector(`.constantes li[data-clave="${clave}"]`);
|
||||
if (!fila) continue;
|
||||
fila.querySelector("b").textContent = formato(datos[clave]);
|
||||
// 80 grados es donde empieza a preocupar en un portatil
|
||||
if (clave === "temperatura") fila.classList.toggle("alerta", datos[clave] >= 80);
|
||||
}
|
||||
}
|
||||
|
||||
// Las medidas de JARVIS: latencia, a donde va cada orden, y el RAG. Salen del
|
||||
// diario y las manda el panel cada 5 s por el canal "medidas".
|
||||
const MEDIDAS = {
|
||||
lat_p50: (v) => `${v.toFixed(1)} s`,
|
||||
lat_p95: (v) => `${v.toFixed(1)} s`,
|
||||
pct_cerebro: (v) => `${v} %`,
|
||||
pct_ruido: (v) => `${v} %`,
|
||||
vol24: (v) => `${v}`,
|
||||
rag: (v) => `${v.hit5} %`,
|
||||
};
|
||||
|
||||
function ponMedidas(datos) {
|
||||
if (!datos || !datos.n) return;
|
||||
const caja = document.getElementById("rendimiento");
|
||||
if (!caja) return;
|
||||
for (const [clave, formato] of Object.entries(MEDIDAS)) {
|
||||
if (datos[clave] === undefined) continue;
|
||||
const fila = caja.querySelector(`li[data-clave="${clave}"]`);
|
||||
if (!fila) continue;
|
||||
fila.querySelector("b").textContent = formato(datos[clave]);
|
||||
}
|
||||
// latencia p95: por encima de 5 s la mediana ya se nota al hablar
|
||||
const p95 = caja.querySelector('li[data-clave="lat_p95"]');
|
||||
if (p95) p95.classList.toggle("alerta", datos.lat_p95 >= 8);
|
||||
// ruido alto = Whisper oyendose a si mismo, envenena el diario
|
||||
const rr = caja.querySelector('li[data-clave="pct_ruido"]');
|
||||
if (rr) rr.classList.toggle("alerta", datos.pct_ruido >= 25);
|
||||
|
||||
// la barra de reparto: cerebro (lento) vs catalogo (instantaneo) vs ruido
|
||||
const barra = document.getElementById("reparto");
|
||||
if (barra) {
|
||||
const set = (sel, f) => {
|
||||
const t = barra.querySelector(sel);
|
||||
if (t) t.style.setProperty("--f", Math.max(0, f));
|
||||
};
|
||||
set(".cerebro", datos.pct_cerebro);
|
||||
set(".catalogo", datos.pct_catalogo);
|
||||
set(".ruido", datos.pct_ruido);
|
||||
barra.title = `al cerebro ${datos.pct_cerebro}% · catalogo ${datos.pct_catalogo}% · ruido ${datos.pct_ruido}% (ultimas ${datos.n})`;
|
||||
}
|
||||
}
|
||||
|
||||
function ponNucleos(cargas) {
|
||||
const caja = document.getElementById("nucleos");
|
||||
while (caja.children.length < cargas.length) caja.appendChild(document.createElement("span"));
|
||||
cargas.forEach((carga, i) => {
|
||||
const celda = caja.children[i];
|
||||
celda.style.setProperty("--carga", Math.min(100, carga) + "%");
|
||||
celda.title = `nucleo ${i}: ${carga.toFixed(0)} %`;
|
||||
});
|
||||
}
|
||||
|
||||
function ponProcesos(lista) {
|
||||
const caja = document.getElementById("procesos");
|
||||
caja.textContent = "";
|
||||
for (const p of lista) {
|
||||
const li = document.createElement("li");
|
||||
// el propio asistente se marca: es el que interesa vigilar
|
||||
if (/newelle|ollama|llama|python/.test(p.nombre)) li.className = "propio";
|
||||
const n = document.createElement("span");
|
||||
n.className = "nombre";
|
||||
n.textContent = p.nombre;
|
||||
n.title = `pid ${p.pid}`;
|
||||
const c = document.createElement("span");
|
||||
c.className = "pcpu";
|
||||
c.textContent = p.cpu.toFixed(0) + "%";
|
||||
const m = document.createElement("span");
|
||||
m.className = "pmem";
|
||||
m.textContent = p.mem.toFixed(1) + "%";
|
||||
li.append(n, c, m);
|
||||
caja.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- catalogo */
|
||||
|
||||
let CATALOGO = [];
|
||||
|
||||
function pintaCatalogo(filtro = "") {
|
||||
const caja = document.getElementById("catalogo");
|
||||
caja.textContent = "";
|
||||
const f = filtro.trim().toLowerCase();
|
||||
let total = 0;
|
||||
|
||||
for (const { grupo, acciones } of CATALOGO) {
|
||||
const encajan = acciones.filter(
|
||||
(a) => !f || a.frase.includes(f) || a.id.includes(f) || grupo.includes(f));
|
||||
if (!encajan.length) continue;
|
||||
|
||||
const titulo = document.createElement("p");
|
||||
titulo.className = "grupo";
|
||||
titulo.textContent = `${grupo} · ${encajan.length}`;
|
||||
caja.appendChild(titulo);
|
||||
|
||||
for (const a of encajan) {
|
||||
total++;
|
||||
const fila = document.createElement("div");
|
||||
fila.className = "catalogo-item";
|
||||
fila.title = "Pulsa para que te la explique en voz alta";
|
||||
fila.tabIndex = 0;
|
||||
fila.setAttribute("role", "button");
|
||||
|
||||
// Pulsar una accion la EXPLICA, no la ejecuta. Es la diferencia entre
|
||||
// una lista de referencia y un campo de minas: uno mira "sabe hacer"
|
||||
// justo cuando no sabe que hace cada cosa, y ahi el clic no puede
|
||||
// disparar un comando en la maquina.
|
||||
const explica = () => {
|
||||
try {
|
||||
window.webkit.messageHandlers.accion.postMessage("explica:" + a.id);
|
||||
} catch (e) { /* suelto en el navegador: no hay a quien preguntar */ }
|
||||
};
|
||||
fila.addEventListener("click", explica);
|
||||
fila.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); explica(); }
|
||||
});
|
||||
|
||||
const texto = document.createElement("span");
|
||||
texto.textContent = a.frase;
|
||||
fila.appendChild(texto);
|
||||
|
||||
// las que esperan algo detras se marcan: si no, uno dice la frase a
|
||||
// secas, no pasa nada y parece que la accion esta rota
|
||||
if (a.captura) {
|
||||
const hueco = document.createElement("span");
|
||||
hueco.className = "hueco";
|
||||
hueco.textContent = "+ que";
|
||||
fila.appendChild(hueco);
|
||||
}
|
||||
if (a.confirmar) {
|
||||
const aviso = document.createElement("span");
|
||||
aviso.className = "aviso";
|
||||
aviso.textContent = "⚠";
|
||||
aviso.title = "pide confirmacion antes de hacerlo";
|
||||
fila.appendChild(aviso);
|
||||
}
|
||||
caja.appendChild(fila);
|
||||
}
|
||||
}
|
||||
|
||||
if (!total) {
|
||||
const vacio = document.createElement("p");
|
||||
vacio.className = "vacio";
|
||||
vacio.textContent = "nada encaja con ese filtro";
|
||||
caja.appendChild(vacio);
|
||||
}
|
||||
}
|
||||
|
||||
// Lista numerada de resultados de una busqueda: se puede leer y luego decir
|
||||
// "abre el tercero". Va en la vista de acciones hechas, encabezada por el
|
||||
// titulo de la busqueda.
|
||||
function muestraCandado(datos) {
|
||||
const c = document.getElementById("candado");
|
||||
const msg = document.getElementById("candado-msg");
|
||||
msg.textContent = datos.mensaje || "Introduzca la contraseña";
|
||||
msg.classList.toggle("error", !!datos.error);
|
||||
c.classList.remove("oculto");
|
||||
const clave = document.getElementById("candado-clave");
|
||||
clave.value = "";
|
||||
clave.focus();
|
||||
}
|
||||
|
||||
function enviaClave() {
|
||||
const clave = document.getElementById("candado-clave");
|
||||
if (!clave.value) return;
|
||||
try {
|
||||
window.webkit.messageHandlers.accion.postMessage("desbloquear:" + clave.value);
|
||||
} catch (e) { /* suelto en el navegador: no hay a quien mandarla */ }
|
||||
clave.value = "";
|
||||
document.getElementById("candado-msg").textContent = "Comprobando...";
|
||||
}
|
||||
|
||||
function muestraResultados(datos) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "resultados";
|
||||
const h = document.createElement("span");
|
||||
h.className = "herramienta";
|
||||
h.textContent = datos.titulo || "resultados";
|
||||
li.appendChild(h);
|
||||
const ol = document.createElement("ol");
|
||||
for (const item of (datos.items || [])) {
|
||||
const el = document.createElement("li");
|
||||
el.value = item.n;
|
||||
el.textContent = item.texto;
|
||||
ol.appendChild(el);
|
||||
}
|
||||
li.appendChild(ol);
|
||||
elRegistro.prepend(li);
|
||||
while (elRegistro.children.length > MAX_ACCIONES) {
|
||||
elRegistro.lastElementChild.remove();
|
||||
}
|
||||
cambiaVista("registro");
|
||||
}
|
||||
|
||||
|
||||
/* ------------------------------------------------------- la consola ---
|
||||
|
||||
Lo que echa de menos cualquiera que venga de una terminal: ver que esta
|
||||
pasando MIENTRAS pasa, y poder cortarlo. Las tarjetas dicen el estado;
|
||||
esto dice el detalle, linea a linea y en orden. */
|
||||
|
||||
const elConsola = document.getElementById("consola");
|
||||
const elConsolaEstado = document.getElementById("consola-estado");
|
||||
const MAX_LINEAS_CONSOLA = 400; // mas que esto no se lee y pesa
|
||||
|
||||
function aConsola(texto, clase) {
|
||||
if (!elConsola || !texto) return;
|
||||
const linea = document.createElement("span");
|
||||
linea.className = "linea " + (clase || "");
|
||||
const hora = new Date().toTimeString().slice(0, 8);
|
||||
linea.textContent = hora + " " + texto;
|
||||
elConsola.appendChild(linea);
|
||||
while (elConsola.childElementCount > MAX_LINEAS_CONSOLA) {
|
||||
elConsola.firstElementChild.remove();
|
||||
}
|
||||
// pegada abajo, como una terminal: si el usuario ha subido a leer, se
|
||||
// respeta y no se le arrastra el scroll
|
||||
const pegada = elConsola.scrollHeight - elConsola.scrollTop
|
||||
- elConsola.clientHeight < 60;
|
||||
if (pegada) elConsola.scrollTop = elConsola.scrollHeight;
|
||||
}
|
||||
|
||||
function consolaEstado(texto, ocupado) {
|
||||
if (!elConsolaEstado) return;
|
||||
elConsolaEstado.textContent = texto;
|
||||
elConsolaEstado.classList.toggle("ocupado", Boolean(ocupado));
|
||||
const b = document.getElementById("consola-parar");
|
||||
if (b) b.disabled = !ocupado;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- tareas en marcha */
|
||||
|
||||
// Cuanto se queda en pantalla una tarea que termino bien y fue rapida.
|
||||
// Las lentas y las que fallan NO se van solas: si algo tardo doce segundos
|
||||
// uno quiere leer que salio, y si fallo, mas todavia.
|
||||
const RAPIDA_S = 2.0;
|
||||
const DESVANECE_MS = 3000;
|
||||
|
||||
const elTareas = document.getElementById("tareas");
|
||||
const tarjetas = new Map(); // id -> {nodo, reloj, fin}
|
||||
|
||||
function mandaTarea(orden, id) {
|
||||
try {
|
||||
window.webkit.messageHandlers.accion.postMessage(`${orden}:${id}`);
|
||||
} catch (e) {
|
||||
// suelto en el navegador, sin Python detras: se simula para poder ver
|
||||
// el diseño sin levantar la aplicacion entera
|
||||
if (orden === "cerrar_tarea") quitaTarjeta(id);
|
||||
}
|
||||
}
|
||||
|
||||
function creaTarjeta(t) {
|
||||
const nodo = document.createElement("div");
|
||||
nodo.className = "tarea";
|
||||
nodo.dataset.id = t.id;
|
||||
|
||||
const titulo = document.createElement("div");
|
||||
titulo.className = "titulo";
|
||||
titulo.textContent = t.titulo;
|
||||
titulo.title = t.titulo;
|
||||
|
||||
const fila = document.createElement("div");
|
||||
fila.className = "fila";
|
||||
|
||||
const reloj = document.createElement("span");
|
||||
reloj.className = "reloj";
|
||||
const giro = document.createElement("i");
|
||||
giro.className = "giro";
|
||||
reloj.append(giro, document.createTextNode("0.0s"));
|
||||
|
||||
const parar = document.createElement("button");
|
||||
parar.textContent = "■";
|
||||
parar.title = "Parar esta tarea";
|
||||
parar.addEventListener("click", () => mandaTarea("parar_tarea", t.id));
|
||||
|
||||
const cerrar = document.createElement("button");
|
||||
cerrar.textContent = "×";
|
||||
cerrar.title = "Cerrar la tarjeta (si sigue viva, la para)";
|
||||
cerrar.addEventListener("click", () => mandaTarea("cerrar_tarea", t.id));
|
||||
|
||||
const botones = document.createElement("span");
|
||||
botones.append(parar, cerrar);
|
||||
fila.append(reloj, botones);
|
||||
|
||||
const eco = document.createElement("div");
|
||||
eco.className = "eco";
|
||||
|
||||
nodo.append(titulo, fila, eco);
|
||||
elTareas.append(nodo);
|
||||
|
||||
const ficha = { nodo, reloj, parar, eco, fin: null };
|
||||
tarjetas.set(t.id, ficha);
|
||||
return ficha;
|
||||
}
|
||||
|
||||
function ponTarea(t) {
|
||||
const nueva = !tarjetas.has(t.id);
|
||||
const ficha = tarjetas.get(t.id) || creaTarjeta(t);
|
||||
// la consola cuenta el detalle que no cabe en la tarjeta
|
||||
if (nueva) aConsola("$ " + t.titulo, "orden");
|
||||
const ultima = (t.lineas && t.lineas.length) ? t.lineas[t.lineas.length - 1] : "";
|
||||
if (ultima && ficha.ultimoEco !== ultima) { ficha.ultimoEco = ultima; aConsola(" " + ultima); }
|
||||
if (t.estado !== "corriendo" && ficha.estadoPrevio !== t.estado) {
|
||||
ficha.estadoPrevio = t.estado;
|
||||
aConsola(" → " + t.estado + " (" + t.segundos.toFixed(1) + "s)",
|
||||
t.estado === "fallo" ? "malo" : "bueno");
|
||||
}
|
||||
const vivas = [...tarjetas.values()].filter((f) => f.fin === null).length;
|
||||
consolaEstado(vivas ? vivas + " en marcha" : "en reposo", vivas > 0);
|
||||
const { nodo, eco, parar } = ficha;
|
||||
|
||||
nodo.dataset.estado = t.estado;
|
||||
eco.textContent = (t.lineas && t.lineas.length)
|
||||
? t.lineas[t.lineas.length - 1] : "";
|
||||
|
||||
if (t.estado === "corriendo") {
|
||||
ficha.fin = null;
|
||||
parar.disabled = false;
|
||||
ponReloj(ficha, t.segundos);
|
||||
return;
|
||||
}
|
||||
|
||||
// Terminada: el reloj se congela en lo que tardo y el boton de parar ya
|
||||
// no tiene nada que parar.
|
||||
ficha.fin = t.segundos;
|
||||
parar.disabled = true;
|
||||
ponReloj(ficha, t.segundos);
|
||||
|
||||
if (t.estado === "hecha" && t.segundos <= RAPIDA_S) {
|
||||
// Lo instantaneo no merece una tarjeta permanente: aparece, confirma
|
||||
// que se hizo, y se va sola.
|
||||
setTimeout(() => {
|
||||
nodo.classList.add("desvanece");
|
||||
setTimeout(() => quitaTarjeta(t.id), 520);
|
||||
}, DESVANECE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function ponReloj(ficha, segundos) {
|
||||
// El nodo de texto es el segundo hijo: el primero es el punto que late
|
||||
ficha.reloj.lastChild.textContent = segundos.toFixed(1) + "s";
|
||||
}
|
||||
|
||||
function quitaTarjeta(id) {
|
||||
const ficha = tarjetas.get(id);
|
||||
if (!ficha) return;
|
||||
ficha.nodo.remove();
|
||||
tarjetas.delete(id);
|
||||
}
|
||||
|
||||
// El reloj de las vivas lo lleva el HUD, no Python: si el tiempo solo
|
||||
// avanzase con los avisos del puente, una tarea callada —un `find` largo que
|
||||
// no escribe nada— tendria el contador congelado y pareceria colgada.
|
||||
setInterval(() => {
|
||||
for (const [id, ficha] of tarjetas) {
|
||||
if (ficha.fin !== null) continue;
|
||||
const t = parseFloat(ficha.reloj.lastChild.textContent) || 0;
|
||||
ponReloj(ficha, t + 0.1);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
function anotaAccion(datos) {
|
||||
const li = document.createElement("li");
|
||||
if (datos.error) li.className = "error";
|
||||
const h = document.createElement("span");
|
||||
h.className = "herramienta";
|
||||
h.textContent = datos.herramienta || "accion";
|
||||
const s = document.createElement("span");
|
||||
s.className = "salida";
|
||||
s.textContent = datos.salida || "";
|
||||
li.append(h, s);
|
||||
elRegistro.prepend(li);
|
||||
// al ejecutar algo se salta sola a la vista de lo hecho: es lo que uno
|
||||
// quiere ver en ese momento
|
||||
cambiaVista("registro");
|
||||
while (elRegistro.children.length > MAX_ACCIONES) {
|
||||
elRegistro.lastElementChild.remove();
|
||||
}
|
||||
}
|
||||
|
||||
window.jarvis = {
|
||||
recibe(mensaje) {
|
||||
const { tipo, datos } = mensaje;
|
||||
|
||||
if (tipo === "pulso") {
|
||||
for (let i = 0; i < BANDAS && i < datos.niveles.length; i++) {
|
||||
nivelesObjetivo[i] = datos.niveles[i];
|
||||
}
|
||||
ponEstado(datos.estado);
|
||||
|
||||
} else if (tipo === "estado") {
|
||||
ponEstado(datos);
|
||||
|
||||
} else if (tipo === "dicho") {
|
||||
// Lo que ha oido, tanto si le hace caso como si no. Lo segundo importa
|
||||
// igual o mas: sin verlo, un JARVIS que te oye pero te ignora —porque
|
||||
// no le has llamado o porque esta bloqueado— es indistinguible de un
|
||||
// microfono roto.
|
||||
ponDicho(datos.texto, datos.atendido !== false, datos.motivo);
|
||||
aConsolaDicho(datos.texto, datos.atendido !== false, datos.motivo);
|
||||
|
||||
} else if (tipo === "preguntando") {
|
||||
// Una pregunta en el aire cambia lo que significa hablar: lo siguiente
|
||||
// que digas es la RESPUESTA, no una orden. Si no se ve, uno contesta
|
||||
// "informes" y no entiende por que no ha buscado nada.
|
||||
document.documentElement.classList.toggle("preguntando",
|
||||
Boolean(datos.texto));
|
||||
if (datos.texto) {
|
||||
ponEstado("preguntando");
|
||||
aConsola("? " + datos.texto, "orden");
|
||||
}
|
||||
|
||||
} else if (tipo === "respuesta") {
|
||||
ponDicho(datos.texto, true, "");
|
||||
aConsola(" " + datos.texto, "bueno");
|
||||
|
||||
} else if (tipo === "accion") {
|
||||
anotaAccion(datos);
|
||||
|
||||
} else if (tipo === "tarea") {
|
||||
ponTarea(datos);
|
||||
|
||||
} else if (tipo === "tarea_fuera") {
|
||||
quitaTarjeta(datos.id);
|
||||
|
||||
} else if (tipo === "bloqueo") {
|
||||
muestraCandado(datos);
|
||||
|
||||
} else if (tipo === "desbloqueado") {
|
||||
document.getElementById("candado").classList.add("oculto");
|
||||
|
||||
} else if (tipo === "resultados") {
|
||||
muestraResultados(datos);
|
||||
|
||||
} else if (tipo === "voz") {
|
||||
ponVoz(datos);
|
||||
|
||||
} else if (tipo === "cerebro") {
|
||||
ponCerebro(datos);
|
||||
|
||||
} else if (tipo === "catalogo") {
|
||||
CATALOGO = datos;
|
||||
pintaCatalogo(document.getElementById("filtro").value);
|
||||
|
||||
} else if (tipo === "controles") {
|
||||
ponControles(datos);
|
||||
|
||||
} else if (tipo === "medidas") {
|
||||
ponMedidas(datos);
|
||||
|
||||
} else if (tipo === "telemetria") {
|
||||
if (datos.nucleos) ponNucleos(datos.nucleos);
|
||||
ponConstantes(datos);
|
||||
if (datos.swap) {
|
||||
ponMedidor("swap", 100 * datos.swap.usada / datos.swap.total,
|
||||
`${datos.swap.usada.toFixed(1)}/${datos.swap.total.toFixed(0)} GB`);
|
||||
}
|
||||
if (datos.disco) {
|
||||
ponMedidor("disco", 100 * datos.disco.usado / datos.disco.total,
|
||||
`${datos.disco.usado.toFixed(2)}/${datos.disco.total.toFixed(1)} TB`);
|
||||
}
|
||||
if (datos.procesos) ponProcesos(datos.procesos);
|
||||
if (datos.cpu !== undefined) {
|
||||
ponMedidor("cpu", datos.cpu, datos.cpu.toFixed(0) + " %");
|
||||
}
|
||||
if (datos.ram) {
|
||||
ponMedidor("ram", 100 * datos.ram.usada / datos.ram.total,
|
||||
`${datos.ram.usada.toFixed(0)}/${datos.ram.total.toFixed(0)} GB`);
|
||||
}
|
||||
if (datos.gpu) {
|
||||
ponMedidor("gpu", datos.gpu.uso, datos.gpu.uso.toFixed(0) + " %");
|
||||
ponMedidor("vram",
|
||||
100 * datos.gpu.vram_usada / datos.gpu.vram_total,
|
||||
`${datos.gpu.vram_usada.toFixed(1)}/${datos.gpu.vram_total.toFixed(1)} GB`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------ arranque */
|
||||
|
||||
// Los interruptores NO se pintan al pulsarlos: se manda la orden y se espera
|
||||
// a que Python conteste con el estado real (evento "controles"). Asi el
|
||||
// boton nunca dice "silenciado" con el microfono abierto.
|
||||
document.querySelectorAll(".controles button[data-accion]").forEach((boton) => {
|
||||
boton.addEventListener("click", () => {
|
||||
try {
|
||||
window.webkit.messageHandlers.accion.postMessage(boton.dataset.accion);
|
||||
} catch (e) {
|
||||
// suelto en el navegador: no hay Python, se conmuta a mano para poder
|
||||
// ver como queda el diseño
|
||||
if (boton.dataset.estado) {
|
||||
boton.dataset.estado = boton.dataset.estado === "on" ? "off" : "on";
|
||||
ponRotulo(boton);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function cambiaVista(cual) {
|
||||
for (const b of document.querySelectorAll(".pestanas button")) {
|
||||
b.classList.toggle("activa", b.dataset.vista === cual);
|
||||
}
|
||||
for (const v of document.querySelectorAll(".vista")) {
|
||||
v.classList.toggle("oculta", v.id !== "vista-" + cual);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll(".pestanas button").forEach((b) => {
|
||||
b.addEventListener("click", () => cambiaVista(b.dataset.vista));
|
||||
});
|
||||
|
||||
document.getElementById("filtro").addEventListener("input", (e) => {
|
||||
pintaCatalogo(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("candado-ok").addEventListener("click", enviaClave);
|
||||
document.getElementById("candado-clave").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") enviaClave();
|
||||
});
|
||||
|
||||
// Cortar lo que este corriendo: el boton y Ctrl+C, que es lo que los dedos
|
||||
// hacen solos cuando algo se atasca.
|
||||
function cortarTodo() {
|
||||
let n = 0;
|
||||
for (const [id, ficha] of tarjetas) {
|
||||
if (ficha.fin === null) { mandaTarea("parar_tarea", id); n++; }
|
||||
}
|
||||
aConsola(n ? "^C cortando " + n + " tarea(s)" : "^C no hay nada corriendo", "malo");
|
||||
}
|
||||
|
||||
const botonCortar = document.getElementById("consola-parar");
|
||||
if (botonCortar) botonCortar.addEventListener("click", cortarTodo);
|
||||
|
||||
document.addEventListener("keydown", (ev) => {
|
||||
if (ev.ctrlKey && (ev.key === "c" || ev.key === "C")) {
|
||||
// si hay texto seleccionado es que quieren copiar, no cortar
|
||||
if (String(window.getSelection() || "").length) return;
|
||||
ev.preventDefault();
|
||||
cortarTodo();
|
||||
}
|
||||
});
|
||||
|
||||
// Lanza la tanda de prueba. No cambia nada en el boton: lo que hay que mirar
|
||||
// son las tarjetas, que es justo lo que se quiere comprobar.
|
||||
document.getElementById("prueba").addEventListener("click", (ev) => {
|
||||
const b = ev.currentTarget;
|
||||
b.disabled = true;
|
||||
b.querySelector(".rotulo").textContent = "probando";
|
||||
setTimeout(() => {
|
||||
b.disabled = false;
|
||||
b.querySelector(".rotulo").textContent = "prueba";
|
||||
}, 4000);
|
||||
try {
|
||||
window.webkit.messageHandlers.accion.postMessage("prueba");
|
||||
} catch (e) { /* suelto en el navegador: no hay Python que lance nada */ }
|
||||
});
|
||||
|
||||
// No se pinta al pulsar: rota Python y contesta con la voz que quedo. Mientras
|
||||
// llega, se avisa de que esta en ello, que cambiar de motor tarda un momento.
|
||||
document.getElementById("voz").addEventListener("click", (ev) => {
|
||||
ev.currentTarget.querySelector(".rotulo").textContent = "voz: ...";
|
||||
try {
|
||||
window.webkit.messageHandlers.accion.postMessage("voz:siguiente");
|
||||
} catch (e) { /* suelto en el navegador: no hay Python que rote nada */ }
|
||||
});
|
||||
|
||||
const botonCerebro = document.getElementById("cerebro");
|
||||
if (botonCerebro) botonCerebro.addEventListener("click", (ev) => {
|
||||
ev.currentTarget.querySelector(".rotulo").textContent = "cerebro: ...";
|
||||
try {
|
||||
window.webkit.messageHandlers.accion.postMessage("cerebro:siguiente");
|
||||
} catch (e) { /* suelto en el navegador: no hay Python que rote nada */ }
|
||||
});
|
||||
|
||||
window.addEventListener("resize", ajustarLienzo);
|
||||
ajustarLienzo();
|
||||
leeColores();
|
||||
requestAnimationFrame(dibujar);
|
||||
|
||||
try {
|
||||
window.webkit.messageHandlers.listo.postMessage("listo");
|
||||
} catch (e) {
|
||||
// Abierto en un navegador normal: se llena solo con datos falsos, para
|
||||
// poder iterar el diseño sin arrancar Newelle.
|
||||
ponEstado("escuchando");
|
||||
setInterval(() => {
|
||||
for (let i = 0; i < BANDAS; i++) nivelesObjetivo[i] = Math.random() * 0.9;
|
||||
}, 120);
|
||||
|
||||
elDicho.textContent = "how much disk space is left?";
|
||||
elDicho.classList.add("visible");
|
||||
|
||||
[
|
||||
{ herramienta: "run_command", salida: "df -h /\n/dev/nvme0n1p2 916G 70G 846G 8% /" },
|
||||
{ herramienta: "system_info", salida: "62 GB RAM · 16 nucleos · NVIDIA T1200 4 GB" },
|
||||
{ herramienta: "websearch", salida: "3 resultados descartados por poco fiables" },
|
||||
].forEach(anotaAccion);
|
||||
cambiaVista("catalogo");
|
||||
|
||||
window.jarvis.recibe({ tipo: "catalogo", datos: [
|
||||
{ grupo: "sistema", acciones: [
|
||||
{ id: "disco_libre", frase: "cuanto espacio queda", captura: false, confirmar: false },
|
||||
{ id: "memoria", frase: "cuanta memoria", captura: false, confirmar: false },
|
||||
{ id: "temperatura", frase: "que temperatura hay", captura: false, confirmar: false },
|
||||
]},
|
||||
{ grupo: "ficheros", acciones: [
|
||||
{ id: "buscar_fichero", frase: "busca el fichero", captura: true, confirmar: false },
|
||||
{ id: "proceso_matar", frase: "mata el proceso", captura: true, confirmar: true },
|
||||
]},
|
||||
{ grupo: "firefox", acciones: [
|
||||
{ id: "ff_buscar_youtube", frase: "busca en youtube", captura: true, confirmar: false },
|
||||
{ id: "ff_gmail", frase: "abre el correo", captura: false, confirmar: false },
|
||||
]},
|
||||
]});
|
||||
|
||||
window.jarvis.recibe({ tipo: "telemetria", datos: {
|
||||
cpu: 34,
|
||||
ram: { usada: 18.4, total: 62.5 },
|
||||
gpu: { uso: 71, vram_usada: 2.9, vram_total: 4.0 },
|
||||
nucleos: Array.from({ length: 16 }, (_, i) => 20 + ((i * 37) % 70)),
|
||||
swap: { usada: 0.4, total: 1.0 },
|
||||
disco: { usado: 1.01, total: 1.9 },
|
||||
temperatura: 59,
|
||||
carga: [1.81, 2.96, 4.08],
|
||||
uptime: 21600,
|
||||
bateria: 100,
|
||||
red: { baja: 1840.5, sube: 220.2 },
|
||||
procesos: [
|
||||
{ pid: 2693, nombre: "ollama", cpu: 182.4, mem: 5.8 },
|
||||
{ pid: 1436, nombre: "newelle", cpu: 33.5, mem: 1.8 },
|
||||
{ pid: 9422, nombre: "firefox-esr", cpu: 12.1, mem: 4.2 },
|
||||
{ pid: 3829, nombre: "gnome-shell", cpu: 6.3, mem: 2.1 },
|
||||
{ pid: 1201, nombre: "pipewire", cpu: 2.0, mem: 0.3 },
|
||||
],
|
||||
}});
|
||||
}
|
||||
})();
|
||||
179
nucleo/cara/hud/index.html
Normal file
179
nucleo/cara/hud/index.html
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<!DOCTYPE html>
|
||||
<!-- HUD de JARVIS. Se carga desde config/jarvis-hud/, asi que este fichero
|
||||
se puede editar y recargar la pestaña sin recompilar el flatpak. -->
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>JARVIS</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="hud">
|
||||
|
||||
<!-- columna izquierda: constantes de la maquina -->
|
||||
<aside class="telemetria" id="telemetria">
|
||||
<h2>sistema</h2>
|
||||
<div class="medidor" data-clave="cpu">
|
||||
<span class="etiqueta">cpu</span>
|
||||
<div class="barra"><i></i></div>
|
||||
<span class="cifra">--</span>
|
||||
</div>
|
||||
<div class="medidor" data-clave="ram">
|
||||
<span class="etiqueta">ram</span>
|
||||
<div class="barra"><i></i></div>
|
||||
<span class="cifra">--</span>
|
||||
</div>
|
||||
<div class="medidor" data-clave="gpu">
|
||||
<span class="etiqueta">gpu</span>
|
||||
<div class="barra"><i></i></div>
|
||||
<span class="cifra">--</span>
|
||||
</div>
|
||||
<div class="medidor" data-clave="vram">
|
||||
<span class="etiqueta">vram</span>
|
||||
<div class="barra"><i></i></div>
|
||||
<span class="cifra">--</span>
|
||||
</div>
|
||||
|
||||
<div class="medidor" data-clave="swap">
|
||||
<span class="etiqueta">swap</span>
|
||||
<div class="barra"><i></i></div>
|
||||
<span class="cifra">--</span>
|
||||
</div>
|
||||
<div class="medidor" data-clave="disco">
|
||||
<span class="etiqueta">disco</span>
|
||||
<div class="barra"><i></i></div>
|
||||
<span class="cifra">--</span>
|
||||
</div>
|
||||
|
||||
<h2 class="separado">nucleos</h2>
|
||||
<div class="nucleos" id="nucleos"></div>
|
||||
|
||||
<!-- constantes: no llevan barra porque no son porcentajes de nada -->
|
||||
<ul class="constantes" id="constantes">
|
||||
<li data-clave="temperatura"><span>temp</span><b>--</b></li>
|
||||
<li data-clave="carga"><span>carga</span><b>--</b></li>
|
||||
<li data-clave="red"><span>red</span><b>--</b></li>
|
||||
<li data-clave="uptime"><span>encendido</span><b>--</b></li>
|
||||
<li data-clave="bateria"><span>bateria</span><b>--</b></li>
|
||||
</ul>
|
||||
|
||||
<h2 class="separado">procesos</h2>
|
||||
<ul class="procesos" id="procesos"></ul>
|
||||
|
||||
<!-- rendimiento del propio JARVIS: sale del diario, no del sistema -->
|
||||
<h2 class="separado">rendimiento</h2>
|
||||
<div class="reparto" id="reparto" title="a donde va cada orden">
|
||||
<span class="tramo cerebro" style="--f:0"></span>
|
||||
<span class="tramo catalogo" style="--f:0"></span>
|
||||
<span class="tramo ruido" style="--f:0"></span>
|
||||
</div>
|
||||
<ul class="constantes" id="rendimiento">
|
||||
<li data-clave="lat_p50"><span>latencia med.</span><b>--</b></li>
|
||||
<li data-clave="lat_p95"><span>latencia p95</span><b>--</b></li>
|
||||
<li data-clave="pct_cerebro"><span>al cerebro</span><b>--</b></li>
|
||||
<li data-clave="pct_ruido"><span>ruido</span><b>--</b></li>
|
||||
<li data-clave="vol24"><span>frases 24h</span><b>--</b></li>
|
||||
<li data-clave="rag"><span>RAG hit@5</span><b>--</b></li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- centro: el reactor, el estado y lo que hay en marcha -->
|
||||
<section class="nucleo">
|
||||
<canvas id="reactor" width="520" height="520"></canvas>
|
||||
<p class="estado" id="estado">en reposo</p>
|
||||
<p class="dicho" id="dicho"></p>
|
||||
|
||||
<!-- una tarjetita por orden en marcha. Van aqui debajo y no flotando
|
||||
sobre el reactor para que quepan varias sin taparlo. -->
|
||||
<div class="tareas" id="tareas"></div>
|
||||
</section>
|
||||
|
||||
<!-- columna derecha: lo que va haciendo -->
|
||||
<aside class="acciones">
|
||||
<h2>
|
||||
acciones
|
||||
<span class="pestanas">
|
||||
<button data-vista="catalogo" class="activa">sabe hacer</button>
|
||||
<button data-vista="registro">hechas</button>
|
||||
<button data-vista="consola">consola</button>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<!-- lo que sabe hacer: sale de las acciones rapidas, no esta escrito aqui -->
|
||||
<div class="vista" id="vista-catalogo">
|
||||
<input id="filtro" type="search" placeholder="filtrar..." autocomplete="off">
|
||||
<div id="catalogo"></div>
|
||||
</div>
|
||||
|
||||
<!-- lo que va haciendo -->
|
||||
<div class="vista oculta" id="vista-registro">
|
||||
<ul id="registro"></ul>
|
||||
</div>
|
||||
|
||||
<!-- La consola: lo que esta pasando AHORA, linea a linea, como en una
|
||||
terminal. Con su boton de cortar, que es lo que se echa de menos
|
||||
cuando algo se queda pensando y no sabes ni que esta haciendo. -->
|
||||
<div class="vista oculta" id="vista-consola">
|
||||
<div class="consola-barra">
|
||||
<span id="consola-estado">en reposo</span>
|
||||
<button id="consola-parar" title="Cortar lo que se este ejecutando (Ctrl+C)">■ cortar</button>
|
||||
</div>
|
||||
<pre id="consola"></pre>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
|
||||
<nav class="controles">
|
||||
<button data-accion="parar" data-estado="on" class="principal"
|
||||
title="Cierra el microfono y corta la voz. Pulsa otra vez para reanudar">
|
||||
<span class="punto"></span>
|
||||
<span class="rotulo">parar</span>
|
||||
</button>
|
||||
|
||||
<!-- Rota entre las voces disponibles y estrena la nueva diciendo una frase.
|
||||
El rotulo lo pone Python al confirmar el cambio, no el clic. -->
|
||||
<button id="voz" class="secundario" title="Cambiar la voz. La estrena diciendo una frase">
|
||||
<span class="rotulo">voz</span>
|
||||
</button>
|
||||
|
||||
<!-- Dormir / despertar. El rotulo dice como ESTA, no lo que hace el boton:
|
||||
leer "dormido" y ver el punto apagado no deja lugar a dudas; "despertar"
|
||||
obligaria a deducir si es lo que hace o lo que esta puesto. -->
|
||||
<button data-accion="sueno" data-estado="on" class="secundario"
|
||||
title="Atiende todo lo que oiga. Pulsa para exigir que le llames «JARVIS» antes de cada orden — util si hay una tele cerca">
|
||||
<span class="punto"></span>
|
||||
<span class="rotulo">escuchando</span>
|
||||
</button>
|
||||
|
||||
<!-- Con que piensa. Se marca aparte cuando sale de la maquina: es el unico
|
||||
boton del panel que cambia donde van a parar las palabras del usuario,
|
||||
y eso no puede quedar escondido en un tooltip. -->
|
||||
<button id="cerebro" class="secundario" title="Cambiar con que piensa JARVIS">
|
||||
<span class="rotulo">cerebro</span>
|
||||
</button>
|
||||
|
||||
<!-- Lanza varias acciones de verdad a la vez, para ver las tarjetas
|
||||
trabajando sin tener que darle ordenes por voz. Es la forma de
|
||||
comprobar de un vistazo que el panel hace lo que dice. -->
|
||||
<button id="prueba" class="secundario" title="Lanza varias tareas reales a la vez para verlas trabajar">
|
||||
<span class="rotulo">prueba</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- candado: tapa todo hasta que se teclea la contraseña -->
|
||||
<div class="candado oculto" id="candado">
|
||||
<div class="candado-caja">
|
||||
<div class="candado-icono">▲</div>
|
||||
<p class="candado-msg" id="candado-msg">Introduzca la contraseña</p>
|
||||
<input id="candado-clave" type="password" autocomplete="off"
|
||||
placeholder="Contraseña" autofocus>
|
||||
<button id="candado-ok">Desbloquear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- el puente va antes: hud.js pregunta por window.webkit al cargarse -->
|
||||
<script src="puente.js"></script>
|
||||
<script src="hud.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
97
nucleo/cara/hud/puente.js
Normal file
97
nucleo/cara/hud/puente.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/* El puente entre el HUD y el nucleo, por WebSocket.
|
||||
*
|
||||
* En la version sobre Newelle el HUD vivia dentro de un widget WebKit y hablaba
|
||||
* por window.webkit.messageHandlers. Fuera del flatpak no hay tal widget, pero
|
||||
* hud.js usa ese puente en ocho sitios y no merece la pena tocarlos: aqui se
|
||||
* SUPLANTA window.webkit con un objeto que manda lo mismo por el socket.
|
||||
*
|
||||
* Asi hud.js se queda exactamente igual que estaba —cero diff sobre 2.064
|
||||
* lineas que ya funcionaban— y ademas sigue abriendose suelto en un navegador
|
||||
* para mirar el diseño, que es como se ha estado trabajando en el.
|
||||
*
|
||||
* Este fichero se carga ANTES que hud.js: cuando hud.js pregunte por
|
||||
* window.webkit, ya esta.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Ya estamos dentro de WebKit (queda de la epoca de Newelle): no tocar nada.
|
||||
if (window.webkit && window.webkit.messageHandlers) return;
|
||||
|
||||
const URL = "ws://" + (location.host || "127.0.0.1:8790") + "/puente";
|
||||
|
||||
let socket = null;
|
||||
let cola = []; // lo que se pulse antes de que abra el socket
|
||||
let esperaReconexion = 500;
|
||||
|
||||
function manda(canal, dato) {
|
||||
const mensaje = JSON.stringify({ canal: canal, dato: String(dato) });
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(mensaje);
|
||||
} else {
|
||||
// No se pierde: un boton pulsado mientras reconecta tiene que llegar
|
||||
// igual, o el panel parece que se ha colgado. Con tope, que si el nucleo
|
||||
// esta caido de verdad no queremos acumular mil pulsaciones.
|
||||
if (cola.length < 50) cola.push(mensaje);
|
||||
}
|
||||
}
|
||||
|
||||
// El mismo interfaz que ofrecia WebKit, para que hud.js no note el cambio.
|
||||
window.webkit = {
|
||||
messageHandlers: {
|
||||
accion: { postMessage: (d) => manda("accion", d) },
|
||||
listo: { postMessage: (d) => manda("listo", d) },
|
||||
},
|
||||
};
|
||||
|
||||
function conecta() {
|
||||
try {
|
||||
socket = new WebSocket(URL);
|
||||
} catch (e) {
|
||||
reintenta();
|
||||
return;
|
||||
}
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
esperaReconexion = 500;
|
||||
marcaCaido(false);
|
||||
const pendientes = cola;
|
||||
cola = [];
|
||||
pendientes.forEach((m) => socket.send(m));
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (ev) => {
|
||||
let mensaje;
|
||||
try {
|
||||
mensaje = JSON.parse(ev.data);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
// El formato {tipo, datos} es el mismo que empujaba Python por
|
||||
// evaluate_javascript, asi que recibe() no cambia.
|
||||
if (window.jarvis && window.jarvis.recibe) window.jarvis.recibe(mensaje);
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => { marcaCaido(true); reintenta(); });
|
||||
socket.addEventListener("error", () => { try { socket.close(); } catch (e) {} });
|
||||
}
|
||||
|
||||
function reintenta() {
|
||||
// Espera creciente hasta 8 s: reintentar cada 500 ms contra un nucleo
|
||||
// parado calienta el portatil para nada.
|
||||
setTimeout(conecta, esperaReconexion);
|
||||
esperaReconexion = Math.min(esperaReconexion * 2, 8000);
|
||||
}
|
||||
|
||||
// Que se vea que el panel esta hablando solo. Un HUD con datos congelados y
|
||||
// sin avisar es peor que uno que dice claramente que perdio el hilo.
|
||||
function marcaCaido(caido) {
|
||||
document.documentElement.classList.toggle("sin-nucleo", caido);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", conecta);
|
||||
} else {
|
||||
conecta();
|
||||
}
|
||||
})();
|
||||
1233
nucleo/cara/hud/style.css
Normal file
1233
nucleo/cara/hud/style.css
Normal file
File diff suppressed because it is too large
Load diff
575
nucleo/cara/panel.py
Normal file
575
nucleo/cara/panel.py
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
"""Lo que une el nucleo con la cara: que se empuja al HUD y que se hace al pulsar.
|
||||
|
||||
`servidor.py` solo mueve bytes; aqui vive el significado. Es el equivalente de
|
||||
lo que hacia `jarvis.py` dentro de Newelle —el fichero de 1.244 lineas que era
|
||||
lo unico acoplado al framework— pero sin GTK, sin WebKit y sin el ciclo de vida
|
||||
de la aplicacion de por medio.
|
||||
|
||||
## Lo que se empuja y cada cuanto
|
||||
|
||||
telemetria 1 s cpu, memoria, grafica, disco, procesos
|
||||
pulso 15/s solo mientras escucha o habla; es lo que mueve el reactor
|
||||
estado cuando cambia
|
||||
tarea cuando cambia una
|
||||
|
||||
La telemetria cuesta procesos (`nvidia-smi`, `ps`), asi que va en su propio hilo
|
||||
y a su ritmo, no en el bucle de voz. Y el pulso solo se manda si hay alguien
|
||||
mirando: sin panel abierto son quince mensajes por segundo a nadie.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from manos.preguntar import confirmacion, es_si, pregunta_por
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _lee(ruta, defecto=""):
|
||||
try:
|
||||
with open(ruta) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return defecto
|
||||
|
||||
|
||||
def _manda(orden, tope=4000):
|
||||
try:
|
||||
r = subprocess.run(orden, capture_output=True, text=True, timeout=10)
|
||||
return (r.stdout or "").strip()[:tope]
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
|
||||
|
||||
class Telemetria:
|
||||
"""Las constantes de la maquina, leidas baratas.
|
||||
|
||||
Todo de /proc salvo la grafica, que necesita nvidia-smi. Se lee /proc a
|
||||
mano en vez de usar psutil para no arrastrar una dependencia por cuatro
|
||||
numeros que estan en ficheros de texto.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._cpu_previo = None
|
||||
self._nucleos_previo = None
|
||||
self._hay_nvidia = shutil.which("nvidia-smi") is not None
|
||||
|
||||
def _cpu(self):
|
||||
try:
|
||||
with open("/proc/stat") as f:
|
||||
lineas = [l for l in f if l.startswith("cpu")]
|
||||
except OSError:
|
||||
return 0, []
|
||||
def uso(campos):
|
||||
n = [int(x) for x in campos[1:11]]
|
||||
ocupado = sum(n) - n[3] - n[4]
|
||||
return ocupado, sum(n)
|
||||
ahora = [uso(l.split()) for l in lineas]
|
||||
previo, self._cpu_previo = self._cpu_previo, ahora
|
||||
if not previo or len(previo) != len(ahora):
|
||||
return 0, [0] * (len(ahora) - 1)
|
||||
def pct(a, b):
|
||||
d_ocu, d_tot = a[0] - b[0], a[1] - b[1]
|
||||
return round(100 * d_ocu / d_tot) if d_tot > 0 else 0
|
||||
return pct(ahora[0], previo[0]), [pct(a, b) for a, b in
|
||||
zip(ahora[1:], previo[1:])]
|
||||
|
||||
def _memoria(self):
|
||||
datos = {}
|
||||
try:
|
||||
with open("/proc/meminfo") as f:
|
||||
for l in f:
|
||||
k, _, v = l.partition(":")
|
||||
datos[k] = int(v.split()[0]) // 1024 # MB
|
||||
except OSError:
|
||||
return 0, 0, 0, 0
|
||||
total = datos.get("MemTotal", 0)
|
||||
libre = datos.get("MemAvailable", 0)
|
||||
stotal = datos.get("SwapTotal", 0)
|
||||
slibre = datos.get("SwapFree", 0)
|
||||
return total, total - libre, stotal, stotal - slibre
|
||||
|
||||
def _grafica(self):
|
||||
if not self._hay_nvidia:
|
||||
return None
|
||||
s = _manda(["nvidia-smi",
|
||||
"--query-gpu=utilization.gpu,memory.used,memory.total",
|
||||
"--format=csv,noheader,nounits"])
|
||||
try:
|
||||
u, usada, total = (int(x.strip()) for x in s.split(",")[:3])
|
||||
return {"uso": u, "usada": usada, "total": total}
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
def _temperatura(self):
|
||||
mejor = 0
|
||||
for zona in range(12):
|
||||
t = _lee(f"/sys/class/thermal/thermal_zone{zona}/temp")
|
||||
if t.isdigit():
|
||||
mejor = max(mejor, int(t) // 1000)
|
||||
return mejor
|
||||
|
||||
def _procesos(self, tope=5):
|
||||
"""Los que mas CPU comen.
|
||||
|
||||
cpu y mem van como NUMEROS, no como las cadenas que devuelve ps. El HUD
|
||||
hace `p.cpu.toFixed(0)`, y una cadena no tiene toFixed: la excepcion
|
||||
aborta el manejador entero de telemetria y se lleva por delante todo lo
|
||||
que se pinta despues —cpu, ram y grafica se quedaban en "--"—. El
|
||||
sintoma no apuntaba a los procesos en absoluto.
|
||||
"""
|
||||
s = _manda(["ps", "-eo", "pid,comm,pcpu,pmem", "--sort=-pcpu"])
|
||||
salida = []
|
||||
for l in s.splitlines()[1:tope + 1]:
|
||||
c = l.split(None, 3)
|
||||
if len(c) < 4:
|
||||
continue
|
||||
try:
|
||||
salida.append({"pid": int(c[0]), "nombre": c[1][:18],
|
||||
"cpu": float(c[2]), "mem": float(c[3].split()[0])})
|
||||
except ValueError:
|
||||
continue
|
||||
return salida
|
||||
|
||||
def _disco(self):
|
||||
try:
|
||||
st = os.statvfs("/")
|
||||
total = st.f_blocks * st.f_frsize / 1e9
|
||||
libre = st.f_bavail * st.f_frsize / 1e9
|
||||
return round(total - libre, 1), round(total, 1)
|
||||
except OSError:
|
||||
return 0, 0
|
||||
|
||||
def _red(self):
|
||||
"""Bytes acumulados por todas las interfaces menos la de loopback."""
|
||||
baja = sube = 0
|
||||
try:
|
||||
with open("/proc/net/dev") as f:
|
||||
for l in f.readlines()[2:]:
|
||||
nombre, _, resto = l.partition(":")
|
||||
if nombre.strip() == "lo":
|
||||
continue
|
||||
c = resto.split()
|
||||
baja += int(c[0]); sube += int(c[8])
|
||||
except (OSError, IndexError, ValueError):
|
||||
return None
|
||||
previo, self._red_previo = getattr(self, "_red_previo", None), (baja, sube, time.time())
|
||||
if not previo:
|
||||
return {"baja": 0, "sube": 0}
|
||||
dt = max(self._red_previo[2] - previo[2], 0.001)
|
||||
return {"baja": max(0, (baja - previo[0]) / dt),
|
||||
"sube": max(0, (sube - previo[1]) / dt)}
|
||||
|
||||
def _bateria(self):
|
||||
for n in range(3):
|
||||
p = _lee(f"/sys/class/power_supply/BAT{n}/capacity")
|
||||
if p.isdigit():
|
||||
return int(p)
|
||||
return None
|
||||
|
||||
def lee(self) -> dict:
|
||||
"""Las unidades son las que espera el HUD, no las de /proc.
|
||||
|
||||
Se convierte aqui y no en JavaScript porque el formato lo fija la
|
||||
pantalla: hud.js escribe "GB" y "TB" literalmente al lado del numero
|
||||
(ponMedidor), asi que mandar megas se ve como "63488/64256 GB" y parece
|
||||
que el panel esta roto cuando lo que esta mal es quien lo llena.
|
||||
"""
|
||||
cpu, nucleos = self._cpu()
|
||||
mtotal, musada, stotal, susada = self._memoria()
|
||||
dusado, dtotal = self._disco()
|
||||
arriba = _lee("/proc/uptime", "0").split()[0]
|
||||
datos = {
|
||||
"cpu": cpu,
|
||||
"nucleos": nucleos,
|
||||
"ram": {"usada": musada / 1024, "total": mtotal / 1024}, # GB
|
||||
"swap": {"usada": susada / 1024, "total": max(stotal / 1024, 0.01)},
|
||||
"disco": {"usado": dusado / 1000, "total": dtotal / 1000}, # TB
|
||||
"temperatura": self._temperatura(),
|
||||
"carga": [float(x) for x in _lee("/proc/loadavg", "0 0 0").split()[:3]],
|
||||
"uptime": float(arriba) if arriba else 0, # segundos
|
||||
"procesos": self._procesos(),
|
||||
}
|
||||
g = self._grafica()
|
||||
if g:
|
||||
datos["gpu"] = {"uso": g["uso"],
|
||||
"vram_usada": g["usada"] / 1024, # GB
|
||||
"vram_total": g["total"] / 1024}
|
||||
red = self._red()
|
||||
if red:
|
||||
datos["red"] = red
|
||||
bat = self._bateria()
|
||||
if bat is not None:
|
||||
datos["bateria"] = bat
|
||||
return datos
|
||||
|
||||
|
||||
class Panel:
|
||||
"""El nucleo visto desde la cara."""
|
||||
|
||||
def __init__(self, cara, boca, manos, cerebro=None, estado_voz=None,
|
||||
candado=True, centinela=None):
|
||||
self.cara = cara
|
||||
self.boca = boca
|
||||
self.manos = manos
|
||||
self.cerebro = cerebro
|
||||
self.centinela = centinela # quien decide si hay que llamarle
|
||||
self.estado_voz = estado_voz # callable -> "escuchando"|"hablando"|...
|
||||
self.telemetria = Telemetria()
|
||||
self._parar = threading.Event()
|
||||
|
||||
# Las tarjetas: una por orden en marcha, con su reloj y su boton de
|
||||
# parar. El prefijo va vacio porque aqui ya estamos en el host; dentro
|
||||
# del flatpak era `flatpak-spawn --host --watch-bus`.
|
||||
from manos.tareas import GestorTareas
|
||||
self.tareas = GestorTareas(al_cambiar=self._tarea_cambio, prefijo_host=[])
|
||||
|
||||
# El candado. Tapa el panel y, sobre todo, hace que no obedezca: sin
|
||||
# esto cualquiera que pase por delante del portatil abierto le puede
|
||||
# dar ordenes a una maquina con sudo.
|
||||
#
|
||||
# La contraseña es la del sistema, comprobada con `sudo -S -v`, que NO
|
||||
# ejecuta nada: solo valida. Asi no hay una contraseña propia que
|
||||
# guardar en ningun sitio, que es una cosa menos que se puede filtrar.
|
||||
self.candado = candado
|
||||
self.desbloqueado = not candado
|
||||
self._intentos = 0
|
||||
|
||||
# Lo que se ha preguntado y esta esperando respuesta hablada.
|
||||
# (accion, para_que, hasta_cuando). para_que es "argumento" o "confirmar".
|
||||
self.pendiente = None
|
||||
|
||||
# -------------------------------------------------------------- empujar
|
||||
|
||||
def arranca(self):
|
||||
threading.Thread(target=self._bucle_telemetria, daemon=True).start()
|
||||
if self.candado and not self.desbloqueado:
|
||||
self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"})
|
||||
self.manda_catalogo()
|
||||
self.manda_controles()
|
||||
self.cara.manda("voz", {"nombre": self.boca.voz, "id": self.boca.voz})
|
||||
if self.cerebro:
|
||||
self.cara.manda("cerebro", {"nombre": "local", "id": "local",
|
||||
"detalle": self.cerebro.modelo,
|
||||
"fuera": False})
|
||||
|
||||
def _bucle_telemetria(self):
|
||||
tic = 0
|
||||
while not self._parar.is_set():
|
||||
# Sin nadie mirando no se gasta un nvidia-smi por segundo.
|
||||
if self.cara.conectados:
|
||||
try:
|
||||
self.cara.manda("telemetria", self.telemetria.lee())
|
||||
except Exception:
|
||||
pass
|
||||
# Las medidas de JARVIS (latencia, reparto, RAG) cambian mucho
|
||||
# mas despacio que la CPU: se recalculan cada 5 s, no cada uno.
|
||||
# Salen del diario, que es leer un fichero, no del sistema.
|
||||
if tic % 5 == 0:
|
||||
try:
|
||||
from manos.medidas import lee_medidas
|
||||
self.cara.manda("medidas", lee_medidas())
|
||||
except Exception:
|
||||
pass
|
||||
tic += 1
|
||||
self._parar.wait(1.0)
|
||||
|
||||
def manda_catalogo(self):
|
||||
"""Lo que sabe hacer, agrupado, para la pestaña de la derecha."""
|
||||
grupos = {}
|
||||
for a in self.manos.catalogo.acciones:
|
||||
if not a.frases:
|
||||
continue
|
||||
grupos.setdefault(a.grupo or "otras", []).append({
|
||||
"id": a.id,
|
||||
"frase": a.frases[0],
|
||||
"argumento": a.captura,
|
||||
"confirma": a.confirmar,
|
||||
})
|
||||
datos = [{"grupo": g, "acciones": v} for g, v in
|
||||
sorted(grupos.items(), key=lambda x: -len(x[1]))]
|
||||
self.cara.manda("catalogo", datos)
|
||||
|
||||
def _tarea_cambio(self, tarea):
|
||||
"""Llamado desde el hilo de la tarea cada vez que hay algo que enseñar."""
|
||||
try:
|
||||
self.cara.manda("tarea", tarea.resumen())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def lanza(self, accion, argumento=""):
|
||||
"""Ejecuta una accion como TAREA, con tarjeta en el panel.
|
||||
|
||||
Se usa para lo que tarda. Lo instantaneo —el disco, la hora— no merece
|
||||
una tarjeta: aparece, parpadea y se va antes de que nadie la lea.
|
||||
"""
|
||||
return self.tareas.lanza(accion, argumento)
|
||||
|
||||
def manda_controles(self):
|
||||
"""El estado real de los interruptores, para que el rotulo no mienta.
|
||||
|
||||
Los botones NO se pintan al pulsarlos: se manda la orden y se espera a
|
||||
que el nucleo conteste con como quedo. Asi el boton nunca dice
|
||||
"despierto" con el centinela dormido.
|
||||
"""
|
||||
self.cara.manda("controles", {
|
||||
# encendido = atiende todo; apagado = solo si le llaman
|
||||
"sueno": not (self.centinela and self.centinela.activo),
|
||||
})
|
||||
|
||||
def estado(self, cual: str):
|
||||
self.cara.manda("estado", cual)
|
||||
|
||||
def dicho(self, texto: str, atendido=True, motivo=""):
|
||||
"""Lo que se ha oido, SIEMPRE, tambien cuando se descarta.
|
||||
|
||||
Esto es lo que faltaba y por lo que el panel parecia muerto: se oia, se
|
||||
transcribia, y si no llevaba la palabra de activacion se tiraba en
|
||||
silencio. Desde la pantalla eso es indistinguible de un microfono
|
||||
estropeado, y no hay forma de saber si el problema es que no te oye, que
|
||||
te oye mal, o que te oye bien y no te hace caso.
|
||||
|
||||
El diccionario y no la cadena pelada: el HUD lee datos.texto.
|
||||
"""
|
||||
self.cara.manda("dicho", {"texto": texto, "atendido": atendido,
|
||||
"motivo": motivo})
|
||||
|
||||
def respuesta(self, texto: str):
|
||||
self.cara.manda("respuesta", {"texto": texto})
|
||||
|
||||
def para(self):
|
||||
self._parar.set()
|
||||
try:
|
||||
self.tareas.para_todas()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --------------------------------------------------------------- pulsar
|
||||
|
||||
def pulsado(self, canal: str, dato: str):
|
||||
"""Lo que llega del panel. Corre en su propio hilo, puede tardar."""
|
||||
if canal == "listo":
|
||||
self.arranca()
|
||||
return
|
||||
if canal != "accion":
|
||||
return
|
||||
|
||||
# Desbloquear es lo unico que se atiende con el candado puesto.
|
||||
if dato.startswith("desbloquear:"):
|
||||
self._intenta_desbloquear(dato.split(":", 1)[1])
|
||||
return
|
||||
|
||||
# Todo lo demas son ORDENES, y con el candado puesto no hay ordenes.
|
||||
if self.candado and not self.desbloqueado:
|
||||
self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"})
|
||||
return
|
||||
|
||||
if dato == "voz:siguiente":
|
||||
nueva = self.boca.siguiente()
|
||||
self.cara.manda("voz", {"nombre": nueva, "id": nueva})
|
||||
self.boca.di("Buenas noches, señor.")
|
||||
|
||||
elif dato == "prueba":
|
||||
threading.Thread(target=self._tanda_de_prueba, daemon=True).start()
|
||||
|
||||
elif dato.startswith("explica:"):
|
||||
self._explica(dato.split(":", 1)[1])
|
||||
|
||||
elif dato.startswith("parar_tarea:"):
|
||||
self.tareas.para(dato.split(":", 1)[1])
|
||||
|
||||
elif dato.startswith("cerrar_tarea:"):
|
||||
ident = dato.split(":", 1)[1]
|
||||
self.tareas.cierra(ident)
|
||||
self.cara.manda("tarea_fuera", {"id": ident})
|
||||
|
||||
elif dato == "sueno":
|
||||
# Cambia si hace falta llamarle por su nombre.
|
||||
#
|
||||
# Por defecto NO hace falta: medido sobre 412 frases reales, el
|
||||
# ruido ambiente no dispara nada, asi que exigir el nombre en cada
|
||||
# frase era peaje sin contrapartida. El interruptor se queda para
|
||||
# cuando haya una tele cerca.
|
||||
if not self.centinela:
|
||||
return
|
||||
self.centinela.activo = not self.centinela.activo
|
||||
if self.centinela.activo:
|
||||
self.centinela.duerme()
|
||||
self.boca.di_si_libre("Llameme por mi nombre, señor.")
|
||||
else:
|
||||
self.boca.di_si_libre("Le escucho, señor.")
|
||||
self.manda_controles()
|
||||
self.estado("escuchando" if not self.centinela.activo else "dormido")
|
||||
|
||||
elif dato.startswith("parar"):
|
||||
# Cortar lo que este pasando AHORA: las tareas en marcha y la voz.
|
||||
n = 0
|
||||
for t in self.tareas.todas():
|
||||
if t.estado == "corriendo" and self.tareas.para(t.id):
|
||||
n += 1
|
||||
self.boca.di_si_libre(f"Cortadas {n} tareas, señor." if n
|
||||
else "No habia nada corriendo, señor.")
|
||||
|
||||
# Cuanto se espera la respuesta a una pregunta antes de darla por olvidada.
|
||||
# Corto cansa —hay que pensar que fichero era—; largo hace que una frase
|
||||
# suelta media conversacion despues se tome por la respuesta.
|
||||
ESPERA_S = 45
|
||||
|
||||
def _explica(self, ident: str):
|
||||
"""Pulsar una accion PREGUNTA lo que falta y la hace.
|
||||
|
||||
No recita la frase que la dispara. "Diga «busca el fichero» y a
|
||||
continuacion lo que busca" es un manual de instrucciones, y uno pulsa
|
||||
justo porque no quiere leerse el manual.
|
||||
|
||||
espera argumento -> pregunta cual y lo ejecuta con lo que digas
|
||||
no tiene vuelta -> pide confirmacion antes
|
||||
ninguna cosa -> la hace, que es a lo que has pulsado
|
||||
|
||||
Lo destructivo sigue sin dispararse de un clic: pasa por un si o un no
|
||||
hablado, que es la misma red que tenia antes pero sin obligar a leer.
|
||||
"""
|
||||
accion = next((a for a in self.manos.catalogo.acciones if a.id == ident), None)
|
||||
if accion is None:
|
||||
return
|
||||
|
||||
if accion.captura:
|
||||
self._pregunta(accion, "argumento", pregunta_por(accion))
|
||||
elif accion.confirmar:
|
||||
self._pregunta(accion, "confirmar", confirmacion(accion))
|
||||
else:
|
||||
self.tareas.lanza(accion, "")
|
||||
|
||||
def _pregunta(self, accion, para_que, texto, argumento=""):
|
||||
self._argumento_pendiente = argumento
|
||||
self.pendiente = (accion, para_que, time.monotonic() + self.ESPERA_S,
|
||||
getattr(self, "_argumento_pendiente", ""))
|
||||
self.cara.manda("preguntando", {"texto": texto, "accion": accion.id})
|
||||
self.cara.manda("respuesta", {"texto": texto})
|
||||
self.boca.di(texto)
|
||||
|
||||
def esperando(self):
|
||||
"""La pregunta en el aire, o None si no hay o ya caduco."""
|
||||
if not self.pendiente:
|
||||
return None
|
||||
if time.monotonic() > self.pendiente[2]:
|
||||
self.olvida()
|
||||
return None
|
||||
return self.pendiente
|
||||
|
||||
def olvida(self):
|
||||
self.pendiente = None
|
||||
self.cara.manda("preguntando", {"texto": ""})
|
||||
|
||||
def responde(self, texto: str) -> bool:
|
||||
"""Contesta a lo preguntado. Devuelve si consumio la frase.
|
||||
|
||||
Se llama desde el bucle de voz ANTES del emparejador: mientras hay una
|
||||
pregunta en el aire, lo que se dice es la respuesta, no una orden nueva.
|
||||
"""
|
||||
pend = self.esperando()
|
||||
if not pend:
|
||||
return False
|
||||
accion, para_que, _, argumento = pend
|
||||
|
||||
if para_que == "confirmar":
|
||||
respuesta = es_si(texto)
|
||||
if respuesta is None:
|
||||
self.boca.di("No le he entendido. ¿Si o no, señor?")
|
||||
return True
|
||||
self.olvida()
|
||||
if respuesta:
|
||||
self.tareas.lanza(accion, argumento)
|
||||
else:
|
||||
self.boca.di("De acuerdo, lo dejo.")
|
||||
return True
|
||||
|
||||
# argumento: cualquier cosa que no sea una cancelacion vale
|
||||
if es_si(texto) is False:
|
||||
self.olvida()
|
||||
self.boca.di("De acuerdo, lo dejo.")
|
||||
return True
|
||||
self.olvida()
|
||||
arg = texto.strip()
|
||||
|
||||
# Si ademas no tiene vuelta atras, se confirma AHORA que ya se sabe
|
||||
# sobre que. Hay siete acciones que piden argumento y confirmacion
|
||||
# —"mata el proceso", "que puertos tiene abiertos"— y preguntando solo
|
||||
# el argumento se saltaban la red entera. Y confirmar antes de saber el
|
||||
# argumento seria pedir un cheque en blanco: "¿mato el proceso?" "¿cual?"
|
||||
if accion.confirmar:
|
||||
self._pregunta(accion, "confirmar",
|
||||
f"«{accion.frases[0]} {arg}». Esto no tiene vuelta "
|
||||
f"atras. ¿Confirma, señor?", argumento=arg)
|
||||
return True
|
||||
|
||||
self.tareas.lanza(accion, arg)
|
||||
return True
|
||||
|
||||
def _intenta_desbloquear(self, clave: str):
|
||||
if not clave:
|
||||
return
|
||||
# La misma GestorRoot de las manos: si la contraseña es buena, se queda
|
||||
# ahi y el cerebro puede hacer sudo durante la sesion. Desbloquear el
|
||||
# panel y abrir el root son el mismo gesto, con la misma contraseña.
|
||||
try:
|
||||
valida = self.manos.root.valida(clave)
|
||||
except Exception as e:
|
||||
self.cara.manda("bloqueo", {"mensaje": f"No se pudo comprobar: {e}",
|
||||
"error": True})
|
||||
return
|
||||
|
||||
if not valida:
|
||||
self._intentos += 1
|
||||
# Sin bloqueo por intentos ni espera creciente: la contraseña es la
|
||||
# del sistema y quien la teclea esta sentado delante del portatil.
|
||||
# Contarlos sirve para que el mensaje sea util, no para castigar.
|
||||
self.cara.manda("bloqueo", {
|
||||
"mensaje": "No es esa" + (f" ({self._intentos} intentos)"
|
||||
if self._intentos > 1 else ""),
|
||||
"error": True})
|
||||
return
|
||||
|
||||
self._intentos = 0
|
||||
self.desbloqueado = True
|
||||
self.manos.root.recuerda(clave) # root disponible para la sesion
|
||||
self.cara.manda("desbloqueado", True)
|
||||
self.boca.di_si_libre("A su servicio, señor.")
|
||||
|
||||
def bloquea(self):
|
||||
"""Echa el candado, aunque se arrancara sin el.
|
||||
|
||||
Que `--sin-candado` sea un arranque comodo no significa que uno no
|
||||
pueda querer cerrarlo al levantarse de la silla. Por eso esto ACTIVA el
|
||||
candado en vez de comprobar si estaba activo: negarse a cerrar porque
|
||||
no estaba puesto seria justo lo contrario de lo que se pide.
|
||||
"""
|
||||
self.candado = True
|
||||
self.desbloqueado = False
|
||||
self._intentos = 0
|
||||
self.manos.root.olvida() # y se cierra el root con el candado
|
||||
self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"})
|
||||
|
||||
# Elegidas para que se vea el ciclo entero: una que acaba al instante, dos
|
||||
# que tardan un poco, y una larga a la que da tiempo a pulsarle el boton de
|
||||
# parar. Todas de solo lectura.
|
||||
# Cada una comprobada contra el catalogo: una que no encaje no lanza
|
||||
# tarjeta y la tanda queda coja sin decir por que.
|
||||
PRUEBA = ["cuanto espacio queda", "cuanta memoria", "que temperatura hay",
|
||||
"que esta consumiendo", "cuantos procesos hay"]
|
||||
|
||||
def _tanda_de_prueba(self):
|
||||
"""Lanza varias a la vez para ver las tarjetas trabajando.
|
||||
|
||||
Van como TAREAS y no ejecutadas a pelo: el objetivo es justo ver el
|
||||
ciclo entero —aparecer, contar segundos, terminar— y poder pulsarle el
|
||||
boton de parar a la que tarda.
|
||||
"""
|
||||
for orden in self.PRUEBA:
|
||||
accion, argumento = self.manos.busca(orden)
|
||||
if accion is None:
|
||||
continue
|
||||
self.tareas.lanza(accion, argumento)
|
||||
time.sleep(0.25)
|
||||
173
nucleo/cara/servidor.py
Normal file
173
nucleo/cara/servidor.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""La cara: sirve el HUD y lo mantiene al dia por WebSocket.
|
||||
|
||||
## Por que un servidor y no una ventana
|
||||
|
||||
En Newelle el HUD era un widget WebKit dentro de la aplicacion. Sacandolo a un
|
||||
servidor local se gana mas de lo que parece:
|
||||
|
||||
- el panel es una pagina normal: se abre con `chromium --app=`, en cualquier
|
||||
navegador, o desde el movil o una tablet en la misma red;
|
||||
- se puede mirar el diseño sin arrancar nada, que es como se ha trabajado en
|
||||
el todo el tiempo;
|
||||
- desaparece GTK de la cara, que era la ultima atadura al framework.
|
||||
|
||||
El formato de mensajes no cambia —{tipo, datos}, igual que empujaba Python por
|
||||
evaluate_javascript— asi que `hud.js` se queda sin tocar. Lo unico nuevo es
|
||||
`puente.js`, que suplanta `window.webkit` con el socket.
|
||||
|
||||
## Escucha solo en local
|
||||
|
||||
127.0.0.1 a proposito. Este panel ejecuta ordenes en la maquina: abrirlo a la
|
||||
red seria dar una terminal a cualquiera del wifi. Para verlo desde el movil, un
|
||||
tunel ssh, que ademas obliga a autenticarse.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import threading
|
||||
|
||||
HUD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hud")
|
||||
PUERTO = int(os.environ.get("JARVIS_PUERTO", "8790"))
|
||||
|
||||
FICHEROS = {
|
||||
"/": "index.html",
|
||||
"/index.html": "index.html",
|
||||
"/hud.js": "hud.js",
|
||||
"/puente.js": "puente.js",
|
||||
"/style.css": "style.css",
|
||||
}
|
||||
|
||||
|
||||
class Cara:
|
||||
"""El panel: se le empujan eventos y devuelve lo que se pulsa."""
|
||||
|
||||
def __init__(self, al_pulsar=None, puerto=PUERTO):
|
||||
self.al_pulsar = al_pulsar # (canal, dato) -> None
|
||||
self.puerto = puerto
|
||||
self._clientes = set()
|
||||
self._bucle = None
|
||||
self._hilo = None
|
||||
self._ultimo = {} # el estado, para el que llegue tarde
|
||||
|
||||
# ------------------------------------------------------------- arrancar
|
||||
|
||||
def arranca(self):
|
||||
"""Levanta el servidor en un hilo aparte y vuelve enseguida.
|
||||
|
||||
Devuelve la URL, o None si no se pudo. El motivo mas comun es que ya
|
||||
haya otro JARVIS abierto: sin este manejo, el fallo salia como una traza
|
||||
de asyncio dentro de un hilo, con "address already in use" enterrado en
|
||||
la linea veinte, y el proceso seguia vivo sin panel.
|
||||
"""
|
||||
listo = threading.Event()
|
||||
|
||||
def corre():
|
||||
self._bucle = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._bucle)
|
||||
try:
|
||||
self._bucle.run_until_complete(self._sirve(listo))
|
||||
except OSError as e:
|
||||
self._error = e
|
||||
listo.set()
|
||||
return
|
||||
self._bucle.run_forever()
|
||||
|
||||
self._error = None
|
||||
self._hilo = threading.Thread(target=corre, daemon=True)
|
||||
self._hilo.start()
|
||||
listo.wait(timeout=10)
|
||||
if self._error is not None:
|
||||
import errno
|
||||
if self._error.errno == errno.EADDRINUSE:
|
||||
print(f" el puerto {self.puerto} ya esta cogido: ¿hay otro "
|
||||
f"JARVIS abierto? (arranca.sh --stop, o JARVIS_PUERTO=otro)")
|
||||
else:
|
||||
print(f" no se pudo levantar el panel: {self._error}")
|
||||
return None
|
||||
return f"http://127.0.0.1:{self.puerto}/"
|
||||
|
||||
async def _sirve(self, listo):
|
||||
import websockets
|
||||
from websockets.http11 import Response
|
||||
from websockets.datastructures import Headers
|
||||
|
||||
async def http(conexion, peticion):
|
||||
"""Sirve los ficheros del HUD; deja pasar el WebSocket."""
|
||||
ruta = peticion.path.split("?")[0]
|
||||
if ruta == "/puente":
|
||||
return None # que siga el apreton de manos
|
||||
nombre = FICHEROS.get(ruta)
|
||||
if not nombre:
|
||||
return Response(404, "Not Found", Headers(), b"no esta\n")
|
||||
try:
|
||||
with open(os.path.join(HUD, nombre), "rb") as f:
|
||||
cuerpo = f.read()
|
||||
except OSError:
|
||||
return Response(404, "Not Found", Headers(), b"no esta\n")
|
||||
tipo = mimetypes.guess_type(nombre)[0] or "application/octet-stream"
|
||||
cabeceras = Headers({
|
||||
"Content-Type": tipo + ("; charset=utf-8" if "text" in tipo
|
||||
or "javascript" in tipo else ""),
|
||||
"Content-Length": str(len(cuerpo)),
|
||||
# Sin cache: el HUD se edita en caliente y recargar tiene que
|
||||
# traer lo nuevo, no lo de hace media hora.
|
||||
"Cache-Control": "no-store",
|
||||
})
|
||||
return Response(200, "OK", cabeceras, cuerpo)
|
||||
|
||||
self._servidor = await websockets.serve(
|
||||
self._cliente, "127.0.0.1", self.puerto, process_request=http)
|
||||
listo.set()
|
||||
|
||||
# -------------------------------------------------------------- clientes
|
||||
|
||||
async def _cliente(self, ws):
|
||||
self._clientes.add(ws)
|
||||
try:
|
||||
# Al conectar se le manda el estado que ya habia. Sin esto, abrir el
|
||||
# panel a media sesion enseña un HUD vacio hasta que algo cambie.
|
||||
for mensaje in self._ultimo.values():
|
||||
await ws.send(json.dumps(mensaje))
|
||||
async for crudo in ws:
|
||||
try:
|
||||
m = json.loads(crudo)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if self.al_pulsar:
|
||||
# En otro hilo: atender una pulsacion puede tardar (hablar,
|
||||
# cambiar de voz, lanzar una tarea) y el bucle del socket no
|
||||
# puede quedarse esperando o el panel se congela entero.
|
||||
threading.Thread(
|
||||
target=self.al_pulsar,
|
||||
args=(m.get("canal", ""), m.get("dato", "")),
|
||||
daemon=True).start()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._clientes.discard(ws)
|
||||
|
||||
# --------------------------------------------------------------- empujar
|
||||
|
||||
def manda(self, tipo: str, datos):
|
||||
"""Empuja un evento al panel. Se puede llamar desde cualquier hilo."""
|
||||
mensaje = {"tipo": tipo, "datos": datos}
|
||||
# Se recuerda lo ultimo de cada tipo salvo lo que caduca al instante:
|
||||
# el pulso de la voz a 15 por segundo no tiene sentido reenviarlo.
|
||||
if tipo not in ("pulso",):
|
||||
self._ultimo[tipo] = mensaje
|
||||
if not self._bucle or not self._clientes:
|
||||
return
|
||||
crudo = json.dumps(mensaje, ensure_ascii=False)
|
||||
asyncio.run_coroutine_threadsafe(self._reparte(crudo), self._bucle)
|
||||
|
||||
async def _reparte(self, crudo):
|
||||
for ws in list(self._clientes):
|
||||
try:
|
||||
await ws.send(crudo)
|
||||
except Exception:
|
||||
self._clientes.discard(ws)
|
||||
|
||||
@property
|
||||
def conectados(self) -> int:
|
||||
return len(self._clientes)
|
||||
26
nucleo/cerebro/Modelfile
Normal file
26
nucleo/cerebro/Modelfile
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# El modelo de JARVIS: qwen3.5 4B con parametros de asistente CONCISO.
|
||||
#
|
||||
# La build de qwen3.5 viene con temperature 1 y presence_penalty 1.5. Eso
|
||||
# esta pensado para escritura creativa, no para un asistente de voz: el
|
||||
# presence_penalty alto empuja al modelo a meter temas nuevos que no vienen a
|
||||
# cuento —de ahi que acabara las frases soltando cuanta RAM queda— y la
|
||||
# temperature alta lo hace divagar en vez de contestar y callar.
|
||||
#
|
||||
# Reconstruir: ollama create qwen3.5:4b-jarvis -f nucleo/cerebro/Modelfile
|
||||
|
||||
FROM qwen3.5:4b
|
||||
|
||||
# Bajo y estable: para "cuanto espacio queda" no hay creatividad que aportar,
|
||||
# hay una cifra que dar.
|
||||
PARAMETER temperature 0.4
|
||||
# El que causaba el relleno. 0 = no penaliza repetir, asi que no siente la
|
||||
# necesidad de introducir temas nuevos para variar.
|
||||
PARAMETER presence_penalty 0.0
|
||||
PARAMETER frequency_penalty 0.0
|
||||
PARAMETER top_p 0.9
|
||||
PARAMETER top_k 20
|
||||
# Que pare cuando acabe la respuesta, no cuando se le acabe la cuerda.
|
||||
PARAMETER num_predict 300
|
||||
# 24 de 32 capas en la grafica: deja sitio a whisper en los 4 GB.
|
||||
PARAMETER num_gpu 24
|
||||
PARAMETER num_ctx 4096
|
||||
0
nucleo/cerebro/__init__.py
Normal file
0
nucleo/cerebro/__init__.py
Normal file
316
nucleo/cerebro/ollama.py
Normal file
316
nucleo/cerebro/ollama.py
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
"""Pensar: hablar con ollama y dejarle usar las manos.
|
||||
|
||||
Newelle dedica 578 lineas a esto porque soporta quince motores distintos y
|
||||
tiene que traducir el formato de herramientas de cada uno. Nosotros hablamos
|
||||
solo con ollama, que trae tool calling nativo en `/api/chat`.
|
||||
|
||||
## Dos herramientas, no una
|
||||
|
||||
La primera version tenia una sola —"ejecuta una orden"— y salio mal de una
|
||||
forma instructiva. Medido con tres modelos:
|
||||
|
||||
4B "abre una terminal" -> ejecuta_orden("xterm -e bash")
|
||||
9B "cuanto espacio queda" -> ejecuta_orden("df -h / | grep ...")
|
||||
9B "abre una terminal" -> ejecuta_orden("open -a Terminal") (¡macOS!)
|
||||
|
||||
Los modelos son serviciales: si les dejas un hueco donde cabe un comando, lo
|
||||
escriben. Y el 9B, que es mas listo, lo hacia MAS —escribia pipelines enteras y
|
||||
hasta ordenes de otro sistema operativo—.
|
||||
|
||||
La solucion no es rogarle al modelo en el prompt, es que el hueco no quepa. Se
|
||||
parten en dos herramientas con fronteras claras:
|
||||
|
||||
`orden_del_catalogo` recibe castellano, se lo come el emparejador. La
|
||||
descripcion lleva ejemplos REALES sacados del catalogo,
|
||||
no inventados, y dice explicitamente que no acepta
|
||||
comandos.
|
||||
`comando_de_shell` para lo que no esta en el catalogo. Aqui SI se espera
|
||||
un comando, asi que el modelo tiene donde poner lo que
|
||||
queria poner, y deja de meterlo donde no toca.
|
||||
|
||||
## Por que el catalogo va antes que esto
|
||||
|
||||
Las 150 acciones se emparejan ANTES de llamar a ningun modelo. Esto solo se
|
||||
usa para lo que no encaja —alrededor del 15 %— y para conversar. Por eso un
|
||||
cerebro lento duele menos de lo que parece: la mayoria de las ordenes ni pasan
|
||||
por aqui.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434")
|
||||
MODELO = os.environ.get("JARVIS_MODELO", "qwen3.5:4b-jarvis")
|
||||
|
||||
# Cuantas vueltas de "llama a una herramienta -> toma el resultado" se permiten
|
||||
# antes de cortar. Sin tope, un modelo que se atasca pidiendo lo mismo deja al
|
||||
# usuario esperando para siempre.
|
||||
VUELTAS = 4
|
||||
|
||||
PERSONA = """Eres JARVIS, el asistente de voz de esta maquina.
|
||||
|
||||
REGLA QUE NO SE ROMPE NUNCA: no des ni un solo dato sobre este ordenador sin
|
||||
haberlo comprobado antes con una herramienta. Ni el espacio del disco, ni la
|
||||
memoria, ni cuantos ficheros hay, ni si algo esta abierto, ni la hora. Aunque
|
||||
creas saberlo. Aunque parezca obvio. Si no lo has comprobado en esta misma
|
||||
conversacion, lo compruebas.
|
||||
|
||||
Inventarse una cifra que suena razonable es el peor fallo que puedes cometer,
|
||||
porque el usuario no tiene forma de saber que te la has inventado.
|
||||
|
||||
Y AL REVES: contesta SOLO lo que te preguntan. No añadas al final de tus
|
||||
respuestas datos del sistema que nadie ha pedido —cuanta RAM queda, el espacio
|
||||
del disco, la hora—. Si te preguntan como estas, "bien, señor" y punto; no lleva
|
||||
un informe de memoria detras.
|
||||
|
||||
Para hacer algo, el orden es:
|
||||
1. orden_del_catalogo, siempre que lo que te piden se parezca a algo de la
|
||||
lista. Es lo que JARVIS sabe hacer bien y esta probado.
|
||||
2. comando_de_shell solo si de verdad no esta en el catalogo.
|
||||
|
||||
Si te preguntan COMO se hace algo tecnico —una tecnica de pentesting, un flag,
|
||||
una herramienta— y no lo tienes claro o no aparece en la documentacion de abajo,
|
||||
usa buscar_en_apuntes con palabras clave ANTES de contestar. Ahi estan los
|
||||
comandos que el usuario ya ha probado; es mejor buscarlos que inventarlos. Si la
|
||||
primera busqueda no da, reformula y prueba otra vez.
|
||||
|
||||
Tu respuesta se convierte en AUDIO y se escucha en voz alta:
|
||||
- Castellano siempre. Trata al usuario de usted y llamale "señor" en la primera
|
||||
frase.
|
||||
- Una o dos frases. Nada mas, salvo que te pidan detalle.
|
||||
- Texto plano: ni asteriscos, ni vinetas, ni bloques de codigo, ni rutas largas.
|
||||
- Los numeros redondeados y en palabras: "quedan setecientos gigas".
|
||||
- Si algo falla, di en una frase QUE fallo. Sin disculpas de tres lineas.
|
||||
|
||||
Tienes acceso real a esta maquina y no estas en ningun contenedor. No digas
|
||||
nunca que no puedes. En concreto, con comando_de_shell puedes:
|
||||
- Ejecutar cualquier herramienta instalada, incluidas las de COFRE. Si no
|
||||
recuerdas como se usa una, en la documentacion del usuario de abajo suele
|
||||
estar el comando exacto.
|
||||
- Conectarte a sus servidores: "ssh <host> '<comando>'" corre algo en el
|
||||
servidor. Sus hosts estan en su configuracion; abajo veras cual encaja.
|
||||
- Tareas de administrador, SOLO si el candado esta abierto. Si algo lo necesita
|
||||
y no lo esta, dilo: que desbloquee y lo repites.
|
||||
Antes de nada que borre datos o cambie el sistema, di lo que vas a hacer."""
|
||||
|
||||
|
||||
def _ejemplos(catalogo, cuantos=8) -> str:
|
||||
"""Ordenes de verdad del catalogo, repartidas entre grupos.
|
||||
|
||||
Van en la descripcion de la herramienta porque es lo unico que consigue que
|
||||
el modelo pase castellano en vez de inventarse un comando. Salen del
|
||||
catalogo y no escritas a mano para que no se queden viejas.
|
||||
"""
|
||||
por_grupo = {}
|
||||
for a in catalogo.acciones:
|
||||
if a.frases and not a.captura:
|
||||
por_grupo.setdefault(a.grupo or "otras", []).append(a.frases[0])
|
||||
muestra = []
|
||||
grupos = list(por_grupo.values())
|
||||
for i in range(cuantos):
|
||||
for g in grupos:
|
||||
if i < len(g) and len(muestra) < cuantos:
|
||||
muestra.append(g[i])
|
||||
return ", ".join(f'"{m}"' for m in muestra)
|
||||
|
||||
|
||||
def herramientas(catalogo) -> list:
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "orden_del_catalogo",
|
||||
"description": (
|
||||
"Ejecuta una de las ordenes que JARVIS ya sabe hacer. "
|
||||
"Recibe la orden EN CASTELLANO, tal y como la diria una "
|
||||
"persona. NO acepta comandos de shell: si escribes 'df -h' "
|
||||
"o 'xterm' falla. Ejemplos validos: "
|
||||
+ _ejemplos(catalogo) + ". "
|
||||
"Usala siempre que puedas antes que el shell."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"orden": {
|
||||
"type": "string",
|
||||
"description": ("la orden en castellano, en palabras, "
|
||||
"nunca un comando"),
|
||||
}
|
||||
},
|
||||
"required": ["orden"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "buscar_en_apuntes",
|
||||
"description": (
|
||||
"Busca en los apuntes de pentesting y Linux del usuario (su "
|
||||
"COFRE) el comando o la explicacion que necesites. Usala "
|
||||
"cuando te pregunten COMO se hace algo tecnico —una tecnica, "
|
||||
"un flag, una herramienta— y no lo tengas ya delante en la "
|
||||
"documentacion. Puedes reformular la consulta con tus propias "
|
||||
"palabras clave y buscar varias veces hasta dar con ello. "
|
||||
"Devuelve los fragmentos mas parecidos, con su fuente, para "
|
||||
"que respondas desde ahi y no de memoria."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"consulta": {
|
||||
"type": "string",
|
||||
"description": ("que buscar, en pocas palabras clave; "
|
||||
"el nombre de la herramienta ayuda"),
|
||||
}
|
||||
},
|
||||
"required": ["consulta"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "comando_de_shell",
|
||||
"description": (
|
||||
"Ejecuta un comando en la terminal de esta maquina, que es "
|
||||
"Debian con GNOME. Solo para lo que NO esta en el catalogo. "
|
||||
"Es de solo lectura: no ejecutes nada que borre o modifique "
|
||||
"sin que te lo pidan expresamente."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"comando": {"type": "string",
|
||||
"description": "el comando, para bash"},
|
||||
},
|
||||
"required": ["comando"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class Cerebro:
|
||||
def __init__(self, catalogo, modelo=MODELO, endpoint=ENDPOINT, en_ram=False,
|
||||
manos=None):
|
||||
self.catalogo = catalogo
|
||||
self.modelo = modelo
|
||||
self.endpoint = endpoint
|
||||
# num_gpu 0 fuerza CPU: el modelo entero en RAM. Util para probar uno
|
||||
# mas grande del que cabe en la grafica, a costa de velocidad.
|
||||
self.capas = 0 if en_ram else None
|
||||
self.manos = manos # a quien pedirle que ejecute
|
||||
self.historia = []
|
||||
|
||||
def _saber(self, texto: str) -> str:
|
||||
"""Los apuntes de COFRE que vengan a cuento, para meterlos al prompt.
|
||||
|
||||
Es la mitad "aumentar" de RAG: antes de contestar, se le pone delante al
|
||||
modelo el trozo de los apuntes del usuario que mas se parece a lo que
|
||||
pregunta. Asi construye la respuesta desde SU documentacion —flags
|
||||
reales, comandos que ha probado— en vez de desde lo que recuerde, que
|
||||
para 668 herramientas seria inventarselo.
|
||||
|
||||
Si no hay indice o nada encaja lo bastante, devuelve cadena vacia y el
|
||||
prompt se queda como estaba: la funcion no puede empeorar una respuesta,
|
||||
solo mejorarla.
|
||||
"""
|
||||
try:
|
||||
from saber.busca import contexto, disponible
|
||||
except Exception:
|
||||
return ""
|
||||
if not disponible():
|
||||
return ""
|
||||
trozos = contexto(texto, cuantos=4, minimo=0.38)
|
||||
if not trozos:
|
||||
return ""
|
||||
return ("\n\n## Documentacion del usuario relevante a esto\n"
|
||||
"Usa ESTO para responder, son sus apuntes probados. Si el "
|
||||
"comando exacto esta aqui, dalo tal cual; no te inventes flags "
|
||||
"que no aparezcan.\n\n" + trozos)
|
||||
|
||||
def _pide(self, mensajes, tools=None) -> dict:
|
||||
cuerpo = {"model": self.modelo, "messages": mensajes, "stream": False,
|
||||
"think": False, "options": {"temperature": 0.3}}
|
||||
if tools:
|
||||
cuerpo["tools"] = tools
|
||||
if self.capas is not None:
|
||||
cuerpo["options"]["num_gpu"] = self.capas
|
||||
req = urllib.request.Request(
|
||||
self.endpoint + "/api/chat", data=json.dumps(cuerpo).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=300) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
def responde(self, texto: str, al_ejecutar=None) -> str:
|
||||
"""Contesta, usando las manos si hace falta. Devuelve lo que hay que decir."""
|
||||
self.historia.append({"role": "user", "content": texto})
|
||||
sistema = PERSONA + self._saber(texto)
|
||||
mensajes = [{"role": "system", "content": sistema}] + self.historia[-8:]
|
||||
tools = herramientas(self.catalogo)
|
||||
|
||||
for _ in range(VUELTAS):
|
||||
try:
|
||||
d = self._pide(mensajes, tools)
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
return f"No he podido pensar, señor: {str(e)[:50]}"
|
||||
|
||||
m = d.get("message", {})
|
||||
llamadas = m.get("tool_calls") or []
|
||||
if not llamadas:
|
||||
dicho = (m.get("content") or "").strip()
|
||||
self.historia.append({"role": "assistant", "content": dicho})
|
||||
return dicho
|
||||
|
||||
mensajes.append(m)
|
||||
for lc in llamadas:
|
||||
f = lc.get("function", {})
|
||||
args = f.get("arguments") or {}
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
resultado = self._usa(f.get("name", ""), args, al_ejecutar)
|
||||
mensajes.append({"role": "tool", "content": resultado[:2000]})
|
||||
|
||||
return "Me he liado dando vueltas, señor. Pruebe a pedirmelo de otra forma."
|
||||
|
||||
def _usa(self, nombre: str, args: dict, al_ejecutar=None) -> str:
|
||||
# buscar_en_apuntes NO pasa por las manos: es lectura del indice, no
|
||||
# toca la maquina, asi que funciona aunque el candado este echado.
|
||||
if nombre == "buscar_en_apuntes":
|
||||
return self._busca_apuntes(args.get("consulta", ""))
|
||||
if self.manos is None:
|
||||
return "no hay manos conectadas"
|
||||
if nombre == "orden_del_catalogo":
|
||||
return self.manos.por_orden(args.get("orden", ""), al_ejecutar)
|
||||
if nombre == "comando_de_shell":
|
||||
return self.manos.por_shell(args.get("comando", ""), al_ejecutar)
|
||||
return f"no existe la herramienta {nombre}"
|
||||
|
||||
def _busca_apuntes(self, consulta: str) -> str:
|
||||
"""El RAG como herramienta: el modelo busca cuando lo decide.
|
||||
|
||||
La inyeccion pasiva de _saber() usa la frase LITERAL del usuario y
|
||||
dispara siempre. Esto es lo otro: el modelo reformula la consulta con
|
||||
sus palabras y busca a proposito, que es lo que salva los casos donde el
|
||||
usuario dice una cosa y el apunte esta escrito de otra."""
|
||||
consulta = (consulta or "").strip()
|
||||
if not consulta:
|
||||
return "dime que buscar"
|
||||
try:
|
||||
from saber.busca import busca
|
||||
except Exception:
|
||||
return "no tengo los apuntes indexados"
|
||||
trozos = [t for t in busca(consulta, cuantos=5) if t.get("sim", 0) >= 0.3]
|
||||
if not trozos:
|
||||
return ("no encontre nada en los apuntes sobre eso; prueba con otras "
|
||||
"palabras o el nombre de la herramienta")
|
||||
lineas = []
|
||||
for t in trozos:
|
||||
fuente = t.get("fichero") or f"{t.get('perfil')}/{t.get('herramienta')}"
|
||||
lineas.append(f"[{fuente}]\n{t['texto'][:500]}")
|
||||
return "\n\n".join(lineas)
|
||||
468
nucleo/datos/_generar.py
Normal file
468
nucleo/datos/_generar.py
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Genera acciones.json.
|
||||
|
||||
Se escribe aqui y no a mano en el JSON porque son mas de cien entradas con la
|
||||
misma forma: en tabla se leen de un tirón y se ve si falta un acuse o si dos
|
||||
frases chocan. El JSON es el producto; este fichero es la fuente.
|
||||
|
||||
python3 _generar.py && python3 -m json.tool acciones.json >/dev/null
|
||||
|
||||
Columnas: id | frases (| separadas) | acuse | comando | respuesta
|
||||
|
||||
Reglas que conviene no romper:
|
||||
- Las frases van sin tildes ni signos: el motor normaliza lo que oye, pero
|
||||
aqui se comparan tal cual.
|
||||
- Frases cortas y genericas ("abre") arrastran todo lo demas; se prefieren
|
||||
de tres palabras o mas, que es como se habla de verdad.
|
||||
- Todo lo que borre, mate o apague lleva confirmar=True.
|
||||
- {argumento} solo en las que declaran captura.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
# --------------------------------------------------------------- sistema
|
||||
SISTEMA = [
|
||||
("disco_libre", "cuanto espacio queda|espacio en disco|espacio libre en disco|cuanto disco queda",
|
||||
"Un momento, lo miro.", "df -h / | tail -1",
|
||||
"Quedan {campo4} libres de {campo2}, al {campo5} de uso."),
|
||||
("disco_home", "como esta mi disco|cuanto ocupa mi disco|espacio de mi disco",
|
||||
"Un momento.", "df -h ~ | tail -1",
|
||||
"Su disco esta al {campo5}, con {campo4} libres de {campo2}."),
|
||||
("disco_todos", "discos montados|particiones|unidades montadas",
|
||||
"Enseguida.", "df -h --output=target,size,pcent -x tmpfs -x devtmpfs | tail -n +2",
|
||||
"Hay {lineas} unidades montadas. {salida}"),
|
||||
("memoria", "cuanta memoria|memoria libre|cuanta ram|ram libre",
|
||||
"Voy.", "free -g | awk '/Mem:/{print $2, $3, $7}'",
|
||||
"De {campo1} gigas hay {campo2} en uso y {campo3} disponibles."),
|
||||
("swap", "como esta la swap|memoria de intercambio",
|
||||
"Voy.", "free -g | awk '/Swap:/{print $2, $3}'",
|
||||
"Swap: {campo2} gigas usados de {campo1}."),
|
||||
("cpu_carga", "carga del sistema|como va el procesador|carga de cpu",
|
||||
"Un segundo.", "uptime | sed 's/.*average: //'",
|
||||
"La carga media es {ultima}."),
|
||||
("cpu_modelo", "que procesador tengo|modelo de procesador|que cpu tengo",
|
||||
"Enseguida.", "lscpu | grep 'Model name' | cut -d: -f2 | xargs",
|
||||
"Lleva un {ultima}."),
|
||||
("cpu_nucleos", "cuantos nucleos tengo|cuantos hilos tengo",
|
||||
"Voy.", "nproc", "Tiene {ultima} hilos disponibles."),
|
||||
("temperatura", "que temperatura hay|como esta de temperatura|se esta calentando",
|
||||
"Lo compruebo.", "for z in /sys/class/thermal/thermal_zone*/temp; do cat $z; done | sort -rn | head -1 | awk '{printf \"%.0f\", $1/1000}'",
|
||||
"El punto mas caliente esta a {ultima} grados."),
|
||||
("gpu_estado", "como esta la grafica|estado de la gpu|uso de la grafica",
|
||||
"Un momento.",
|
||||
"nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu --format=csv,noheader",
|
||||
"La grafica esta asi: {ultima}."),
|
||||
("uptime", "cuanto lleva encendido|desde cuando esta encendido|tiempo encendido",
|
||||
"Voy.", "uptime -p | sed 's/^up //; s/ days\\?/ dias/; s/ hours\\?/ horas/; s/ minutes\\?/ minutos/; s/,\\([^,]*\\)$/ y\\1/'",
|
||||
"Lleva encendido {ultima}."),
|
||||
("bateria", "cuanta bateria queda|como esta la bateria|nivel de bateria",
|
||||
"Lo miro.", "cat /sys/class/power_supply/BAT*/capacity 2>/dev/null | head -1",
|
||||
"La bateria esta al {ultima} por ciento."),
|
||||
("kernel", "que kernel tengo|version del kernel|que nucleo tengo",
|
||||
"Enseguida.", "uname -r", "Kernel {ultima}."),
|
||||
("distro", "que distribucion tengo|que sistema tengo|que linux tengo",
|
||||
"Voy.", "grep PRETTY_NAME /etc/os-release | cut -d'\"' -f2",
|
||||
"Esta usando {ultima}."),
|
||||
("fecha", "que hora es|que dia es|dime la fecha",
|
||||
"", "date '+%H:%M del %A %d de %B'", "Son las {salida}."),
|
||||
("procesos_top", "que esta consumiendo|que consume mas|procesos que mas gastan|quien se come la cpu",
|
||||
"Lo compruebo.", "ps -eo pcpu,comm --sort=-pcpu --no-headers | head -5 | awk '{c=$1; $1=\"\"; sub(/^ +/,\"\"); printf \"%s al %s por ciento\\n\", $0, c}'",
|
||||
"Lo que mas consume es {primera}."),
|
||||
("procesos_memoria", "que consume mas memoria|quien se come la ram",
|
||||
"Lo compruebo.", "ps -eo comm,pmem --sort=-pmem --no-headers | head -5",
|
||||
"Por memoria: {salida}"),
|
||||
("procesos_cuantos", "cuantos procesos hay|cuantos procesos corren",
|
||||
"Voy.", "ps -e --no-headers | wc -l", "Hay {ultima} procesos en marcha."),
|
||||
("red_ip", "cual es mi ip|mi ip local|que ip tengo",
|
||||
"Un momento.", "hostname -I | awk '{print $1}'", "Su IP local es {ultima}."),
|
||||
("red_ip_publica", "cual es mi ip publica|ip externa",
|
||||
"Salgo a mirarlo.", "curl -s --max-time 6 ifconfig.me", "Su IP publica es {ultima}."),
|
||||
("red_wifi", "a que wifi estoy conectado|red wifi|como se llama la wifi",
|
||||
"Voy.", "nmcli -t -f NAME,TYPE connection show --active | grep wireless | cut -d: -f1",
|
||||
"Conectado a {ultima}."),
|
||||
("red_velocidad", "cuanto trafico de red|trafico de red",
|
||||
"Lo miro.", "cat /proc/net/dev | awk '/wlp|enp/{rx+=$2; tx+=$10} END{printf \"%.1f %.1f\", rx/1e9, tx/1e9}'",
|
||||
"Desde el arranque: {campo1} gigas bajados y {campo2} subidos."),
|
||||
("red_ping", "hay internet|tengo conexion|funciona internet",
|
||||
"Lo compruebo.",
|
||||
"ping -c1 -W2 1.1.1.1 >/dev/null 2>&1 && echo si || echo no",
|
||||
"Conexion: {ultima}."),
|
||||
("puertos", "que puertos hay abiertos|puertos a la escucha|quien esta escuchando",
|
||||
"Un momento.", "ss -ltnp 2>/dev/null | awk 'NR>1{print $4}' | sort -u | head -8",
|
||||
"A la escucha: {salida}"),
|
||||
("servicios_fallidos", "hay servicios caidos|servicios con fallos|algo ha fallado",
|
||||
"Lo reviso.", "systemctl --failed --no-legend | wc -l",
|
||||
"Servicios en fallo: {ultima}."),
|
||||
("actualizaciones", "hay actualizaciones|paquetes por actualizar",
|
||||
"Lo compruebo, tarda un poco.",
|
||||
"apt list --upgradable 2>/dev/null | tail -n +2 | wc -l",
|
||||
"Hay {ultima} paquetes por actualizar."),
|
||||
("usuarios", "quien esta conectado|hay alguien conectado",
|
||||
"Voy.", "who | wc -l", "Hay {ultima} sesiones abiertas."),
|
||||
("gpu_procesos", "que usa la grafica|quien usa la gpu",
|
||||
"Lo miro.", "O=$(nvidia-smi --query-compute-apps=process_name,used_gpu_memory --format=csv,noheader | awk -F, '{n=split($1,p,\"/\"); printf \"%s%s\\n\", p[n], $2}'); echo \"${O:-nada, la grafica esta libre}\"",
|
||||
"En la grafica: {primera}"),
|
||||
("audio_salida", "por donde suena|salida de audio|que altavoz esta puesto",
|
||||
"Voy.", "pactl get-default-sink 2>/dev/null", "La salida es {ultima}."),
|
||||
("audio_micro", "que microfono hay puesto|entrada de audio",
|
||||
"Voy.", "pactl get-default-source 2>/dev/null", "La entrada es {ultima}."),
|
||||
("pantallas", "cuantas pantallas hay|monitores conectados",
|
||||
"Voy.", "n=$(xrandr --listmonitors 2>/dev/null | head -1 | awk '{print $2}'); xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $NF}' | paste -sd', '; echo \"Tiene $n pantalla$([ \"$n\" = 1 ] || echo s) conectada$([ \"$n\" = 1 ] || echo s)\"",
|
||||
"{ultima}"),
|
||||
("dispositivos_usb", "que hay conectado por usb|dispositivos usb",
|
||||
"Un momento.", "lsusb | cut -d' ' -f7- | head -6", "Por USB: {salida}"),
|
||||
("bluetooth", "que hay emparejado por bluetooth|dispositivos bluetooth",
|
||||
"Voy.", "bluetoothctl devices 2>/dev/null | cut -d' ' -f3- | head -5",
|
||||
"Emparejados: {salida}"),
|
||||
("flatpaks", "que flatpaks tengo|aplicaciones flatpak",
|
||||
"Voy.", "flatpak list --app --columns=name | head -10", "Instalados: {salida}"),
|
||||
("docker", "hay contenedores corriendo|contenedores docker",
|
||||
"Lo miro.", "O=$(docker ps --format '{{.Names}}' 2>/dev/null | head -5); echo \"${O:-ninguno en marcha}\"",
|
||||
"Contenedores: {salida}"),
|
||||
("git_estado", "como esta el repositorio|estado de git",
|
||||
"Voy.", "cd ~/COFRE/CODERS/JARVIS && git status --short | wc -l",
|
||||
"Hay {ultima} ficheros con cambios sin guardar."),
|
||||
("ollama_estado", "esta ollama funcionando|como esta el modelo|que modelo hay cargado",
|
||||
"Lo compruebo.",
|
||||
"M=$(curl -s --max-time 3 http://localhost:11434/api/ps | grep -o '\"name\":\"[^\"]*' | cut -d'\"' -f4 | head -1); echo \"${M:-ninguno ahora mismo}\"",
|
||||
"Modelo cargado: {ultima}."),
|
||||
("apagar_pantalla", "apaga la pantalla|apagar pantalla",
|
||||
"Enseguida.", "xset dpms force off", "Pantalla apagada."),
|
||||
# --- administracion: necesitan root, disparan el dialogo de contraseña ---
|
||||
# (Las de pentesting van en su propia lista, PENTEST, mas abajo.)
|
||||
("actualizar_lista", "actualiza los repositorios|refresca los paquetes|actualiza la lista de paquetes",
|
||||
"Voy, necesito permiso de administrador.", "sudo apt update",
|
||||
"Repositorios actualizados, señor.", True),
|
||||
("espacio_liberar", "limpia los paquetes viejos|libera espacio de apt|limpia el cache de paquetes",
|
||||
"Necesito permiso para esto.", "sudo apt autoclean",
|
||||
"Cache de paquetes limpiada, señor.", True),
|
||||
("servicio_reiniciar", "reinicia el servicio de red|reinicia la red",
|
||||
"Necesito permiso de administrador.", "sudo systemctl restart NetworkManager",
|
||||
"Red reiniciada, señor.", True),
|
||||
("fecha_sincronizar", "sincroniza la hora|pon la hora en hora",
|
||||
"Un momento, con permiso.", "sudo systemctl restart systemd-timesyncd",
|
||||
"Hora sincronizada, señor.", True),
|
||||
("bloquear", "bloquea la sesion|bloquear pantalla|echa el cerrojo",
|
||||
"Ahora mismo.", "loginctl lock-session", "Sesion bloqueada."),
|
||||
("volumen_subir", "sube el volumen|mas volumen",
|
||||
"", "pactl set-sink-volume @DEFAULT_SINK@ +10%", "Volumen subido."),
|
||||
("volumen_bajar", "baja el volumen|menos volumen",
|
||||
"", "pactl set-sink-volume @DEFAULT_SINK@ -10%", "Volumen bajado."),
|
||||
("volumen_silencio", "silencia el sistema|quita el sonido|mutea el sistema",
|
||||
"", "pactl set-sink-mute @DEFAULT_SINK@ toggle", "Sonido conmutado."),
|
||||
("brillo_subir", "sube el brillo|mas brillo",
|
||||
"", "brightnessctl set +10% 2>/dev/null || xrandr --output $(xrandr | awk '/ connected/{print $1; exit}') --brightness 1",
|
||||
"Brillo subido."),
|
||||
("captura_pantalla", "hazme una captura|captura de pantalla|haz una foto de la pantalla",
|
||||
"Enseguida se la hago.", "D=$(xdg-user-dir PICTURES 2>/dev/null || echo ~); F=$D/captura-$(date +%H%M%S).png; import -window root \"$F\" 2>/dev/null || gnome-screenshot -f \"$F\" 2>/dev/null || ffmpeg -loglevel error -f x11grab -video_size $(xdpyinfo | awk \"/dimensions/{print \\$2}\") -i $DISPLAY -frames:v 1 -y \"$F\" 2>/dev/null; [ -s \"$F\" ] && echo \"$F\"",
|
||||
"Captura guardada."),
|
||||
("papelera_tamano", "cuanto ocupa la papelera|papelera llena",
|
||||
"Voy.", "du -sh ~/.local/share/Trash 2>/dev/null | cut -f1",
|
||||
"La papelera ocupa {ultima}."),
|
||||
("descargas_listar", "que hay en descargas|ultimas descargas|que me he bajado",
|
||||
"Voy.", "ls -t ~/Downloads 2>/dev/null | head -6", "Hay {lineas} cosas en descargas"),
|
||||
("descargas_abrir", "abre la carpeta de descargas|abreme descargas",
|
||||
"Enseguida se la abro.", "xdg-open ~/Downloads", "Carpeta de descargas abierta."),
|
||||
("documentos_abrir", "abre mis documentos|abreme documentos",
|
||||
"Enseguida.", "xdg-open ~/Documents", "Documentos abierto."),
|
||||
("cofre_abrir", "abre el cofre|abreme el cofre",
|
||||
"Enseguida.", "xdg-open ~/COFRE", "COFRE abierto."),
|
||||
("ficheros_recientes", "que he tocado ultimamente|ficheros recientes|en que estaba trabajando",
|
||||
"Lo miro.",
|
||||
"find ~ -maxdepth 3 -type f -mmin -180 -not -path '*/.*' 2>/dev/null | head -6",
|
||||
"Tocado en las ultimas horas: {salida}"),
|
||||
("grandes", "que ocupa mas sitio|que tengo en descargas|que me esta llenando la carpeta",
|
||||
"Lo miro.",
|
||||
"du -ah ~/Downloads ~/Descargas ~/Documents ~/Desktop 2>/dev/null | sort -rh | head -6",
|
||||
"Lo mas grande: {salida}"),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------- con argumento
|
||||
CAPTURA = [
|
||||
("buscar_fichero", "busca el fichero|buscame el fichero|encuentra el fichero",
|
||||
"Lo busco.", "timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS $HOME/Downloads -iname '*{argumento}*' -not -path '*/.*' -not -path '*/node_modules/*' -not -path '*venv*' 2>/dev/null | head -12",
|
||||
"He encontrado {lineas}"),
|
||||
("buscar_carpeta", "busca la carpeta|encuentra la carpeta",
|
||||
"La busco.", "find ~ -type d -iname '*{argumento}*' -not -path '*/.*' 2>/dev/null | head -12",
|
||||
"Carpetas encontradas: {salida}"),
|
||||
# busca un TEXTO dentro de los ficheros, recursivo, con ruta y linea
|
||||
("grep_recursivo", "busca el texto|busca la palabra en mis ficheros|donde pone|que fichero contiene",
|
||||
"Lo busco en sus ficheros.", "timeout 12 grep -rIn --exclude-dir='.*' --exclude-dir='*venv*' --exclude-dir=node_modules --exclude-dir=__pycache__ --exclude-dir=modelos --exclude-dir=muestras -m1 {argumento} $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS 2>/dev/null | head -12",
|
||||
"{lineas} ficheros lo contienen."),
|
||||
("leer_fichero", "leeme el fichero|abre y leeme|lee el fichero",
|
||||
"Voy a leerlo.", "f=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS -iname '*{argumento}*' -type f -not -path '*/.*' 2>/dev/null | head -1); [ -n \"$f\" ] && head -c 800 \"$f\" || echo \"no lo encuentro\"",
|
||||
"{salida}, señor"),
|
||||
("abrir_fichero", "abre el fichero|abreme el fichero",
|
||||
"Enseguida se lo abro.",
|
||||
"f=$(find ~ -iname '*{argumento}*' -type f -not -path '*/.*' 2>/dev/null | head -1); "
|
||||
"[ -n \"$f\" ] && xdg-open \"$f\" && echo \"$f\" || echo 'no lo encuentro'",
|
||||
"Abierto {ultima}."),
|
||||
("abrir_carpeta", "abre la carpeta|abreme la carpeta",
|
||||
"Enseguida.", "a={argumento}; if [ -d \"$a\" ]; then d=\"$a\"; else d=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE $HOME/Downloads -type d -iname \"*$a*\" -not -path \"*/.*\" -not -path \"*/node_modules/*\" 2>/dev/null | head -1); fi; if [ -n \"$d\" ]; then xdg-open \"$d\" >/dev/null 2>&1 & echo \"Abierta $(basename \"$d\")\"; else echo \"No encuentro esa carpeta\"; fi",
|
||||
"{ultima}"),
|
||||
("tamano_carpeta", "cuanto ocupa la carpeta|tamaño de la carpeta",
|
||||
"Lo calculo.",
|
||||
"a={argumento}; if [ -d \"$a\" ]; then d=\"$a\"; else d=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE $HOME/Downloads -type d -iname \"*$a*\" -not -path \"*/.*\" 2>/dev/null | head -1); fi; [ -n \"$d\" ] && du -sh \"$d\" 2>/dev/null | cut -f1 || echo \"no la encuentro\"",
|
||||
"Ocupa {ultima}."),
|
||||
("proceso_buscar", "esta corriendo|se esta ejecutando|hay algun proceso de",
|
||||
"Lo compruebo.", "pgrep -c -f {argumento} 2>/dev/null || echo 0",
|
||||
"Hay {ultima} procesos que encajan."),
|
||||
("proceso_matar", "mata el proceso|cierra el proceso|termina el proceso",
|
||||
"Antes de matarlo, confirmeme.", "pkill -f {argumento}",
|
||||
"Proceso terminado.", True),
|
||||
("instalado", "tengo instalado|esta instalado",
|
||||
"Lo miro.", "command -v {argumento} >/dev/null && echo si || echo no",
|
||||
"Instalado: {ultima}."),
|
||||
("manual", "para que sirve el comando|que hace el comando",
|
||||
"Voy.", "whatis {argumento} 2>/dev/null | head -1", "{salida}"),
|
||||
]
|
||||
|
||||
# --------------------------------------------------------------- oasis
|
||||
# --------------------------------------------------------------- pentesting
|
||||
# Red de seguridad: los escaneos ACTIVOS (que mandan paquetes a otros) llevan
|
||||
# confirmar=True, asi que el dialogo muestra el comando exacto antes de lanzarse
|
||||
# y la voz no puede disparar un escaneo sola. Los que apuntan a un objetivo se
|
||||
# limitan a la RED LOCAL por construccion (autodetectan la subred o exigen IP
|
||||
# privada); un objetivo de fuera se rechaza. Lo de solo lectura —buscar un
|
||||
# exploit en la base local, resolver un dominio— va directo, no toca a nadie.
|
||||
#
|
||||
# Que apunte solo a la red local es a proposito: un asistente por voz que
|
||||
# escanea lo que crea haber entendido, con salida al host, es un incidente
|
||||
# esperando. Para objetivos de fuera, mejor a mano y con intencion.
|
||||
PENTEST = [
|
||||
("red_dispositivos", "escanea mi red|que hay en mi red|dispositivos en la red|quien esta en la red",
|
||||
"Voy a escanear la red local, necesito su permiso.",
|
||||
"R=$(ip route | awk '/proto.*src/{for(i=1;i<=NF;i++)if($i~/\\//)print $i; exit}'); "
|
||||
"sudo nmap -sn ${R:-192.168.1.0/24} | grep -E 'Nmap scan|MAC' | head -30",
|
||||
"Estos equipos hay en la red, señor: {salida}", True),
|
||||
("puertos_host", "que puertos tiene abiertos|escanea los puertos de|escanea el host",
|
||||
"Un escaneo de puertos, con su permiso.",
|
||||
"T={argumento}; echo \"$T\" | grep -qE '^(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|127\\.|localhost)' "
|
||||
"&& nmap -F \"$T\" | grep -E '^[0-9]+/|Nmap scan' | head -20 "
|
||||
"|| echo 'Solo objetivos de la red local, señor.'",
|
||||
"{salida}", True),
|
||||
("mis_puertos", "que puertos tengo abiertos yo|mis puertos abiertos|que estoy exponiendo",
|
||||
"Lo compruebo.",
|
||||
"ss -ltnp 2>/dev/null | awk 'NR>1{print $4}' | sort -u | head -15",
|
||||
"Tengo {lineas} puertos a la escucha"),
|
||||
("exploit_buscar", "busca un exploit para|busca exploits de|hay algun exploit de",
|
||||
"Lo busco en la base de exploits.",
|
||||
"searchsploit {argumento} 2>/dev/null | grep -vE '^-|Shellcode' | head -12",
|
||||
"Esto he encontrado, señor: {salida}"),
|
||||
("dominio_resolver", "resuelve el dominio|que ip tiene el dominio|a que ip apunta",
|
||||
"Lo resuelvo.", "dig +short {argumento} 2>/dev/null | head -5",
|
||||
"Resuelve a: {salida}"),
|
||||
("dominio_whois", "de quien es el dominio|quien registro|whois de",
|
||||
"Lo consulto.", "whois {argumento} 2>/dev/null | grep -iE 'registrar:|creation|expir|org' | head -6",
|
||||
"{salida}"),
|
||||
("cabeceras_web", "que servidor usa|cabeceras de|que tecnologia usa la web",
|
||||
"Miro las cabeceras.",
|
||||
"curl -sI --max-time 8 {argumento_url} 2>/dev/null | grep -iE 'server:|x-powered|location' | head -6",
|
||||
"{salida}"),
|
||||
("hash_identificar", "que tipo de hash es|identifica el hash",
|
||||
"Lo miro.",
|
||||
"H={argumento}; L=${#H}; case $L in 32)echo 'MD5, probablemente';; 40)echo 'SHA1';; "
|
||||
"64)echo 'SHA256';; *)echo \"$L caracteres, no lo tengo claro\";; esac",
|
||||
"{ultima}, señor."),
|
||||
]
|
||||
|
||||
# las con argumento del bloque PENTEST (para marcar captura=True)
|
||||
PENTEST_CAPTURA = {"puertos_host", "exploit_buscar", "dominio_resolver",
|
||||
"dominio_whois", "cabeceras_web", "hash_identificar"}
|
||||
|
||||
OASIS = [
|
||||
("oasis_corriendo", "esta oasis funcionando|esta corriendo oasis|oasis esta levantado",
|
||||
"Lo compruebo.",
|
||||
"pgrep -c -f 'oasis|ssb-server|sbot' 2>/dev/null || echo 0",
|
||||
"Procesos de Oasis en marcha: {ultima}."),
|
||||
("oasis_sincronizando", "estan sincronizando los pubs|como van los pubs|se estan sincronizando los pubs",
|
||||
"Voy a mirar las conexiones.",
|
||||
"python3 -c \"import json,time; d=json.load(open('$HOME/.ssb/conn.json')); "
|
||||
"act=sum(1 for v in d.values() if time.time()*1000-v.get('stateChange',0)<300000); "
|
||||
"fail=sum(1 for v in d.values() if v.get('failure',0)>0); "
|
||||
"print(f'{len(d)} pubs, {act} activos hace poco, {fail} con fallos')\"",
|
||||
"{ultima}."),
|
||||
("oasis_conexiones", "cuantos pubs hay conectados|conexiones de oasis",
|
||||
"Un momento.",
|
||||
"cat ~/.ssb/conn.json 2>/dev/null | grep -c 'connected' || echo 0",
|
||||
"Pubs conectados: {ultima}."),
|
||||
("oasis_tamano", "cuanto ocupa oasis|tamaño de la base de ssb",
|
||||
"Lo calculo.", "du -sh ~/.ssb 2>/dev/null | cut -f1",
|
||||
"La base de SSB ocupa {ultima}."),
|
||||
("oasis_abrir", "abre oasis|abreme oasis",
|
||||
"Enseguida se lo abro.",
|
||||
"xdg-open http://localhost:3000 2>/dev/null && echo abierto || echo 'no responde'",
|
||||
"Oasis: {ultima}."),
|
||||
("oasis_blobs", "cuanto ocupan los blobs|tamaño de los blobs",
|
||||
"Voy.", "du -sh ~/.ssb/blobs 2>/dev/null | cut -f1", "Los blobs ocupan {ultima}."),
|
||||
]
|
||||
|
||||
|
||||
# Alias foneticos: lo que Whisper devuelve DE VERDAD, no lo que uno escribe.
|
||||
# Medido con ocho ordenes habladas: "abre el correo" se transcribe "a ver el
|
||||
# correo" (en castellano suenan igual), "los pubs" sale "los push" o "los
|
||||
# puos", y "youtube" se parte en "yo tube". Añadirlos sube los aciertos sin
|
||||
# tocar el modelo ni añadir un milisegundo.
|
||||
ALIAS = {
|
||||
"ff_gmail": ["a ver el correo", "abrir el correo"],
|
||||
"oasis_sincronizando": ["estan sincronizando los push", "estan sincronizando los puos",
|
||||
"sincronizando los pubs", "como van los push"],
|
||||
"oasis_corriendo": ["esta oasis funcionando", "esta corriendo casis"],
|
||||
"ff_buscar_youtube": ["busca en yo tube", "buscan youtube", "buscan yo tube",
|
||||
"busca en you tube"],
|
||||
"disco_libre": ["cuando espacio queda", "cuanto espacio quedan"],
|
||||
"ff_youtube": ["abre yo tube", "abre you tube"],
|
||||
"descargas_abrir": ["abre descargas", "abreme las descargas"],
|
||||
"procesos_top": ["que esta consumiendo mas", "quien consume"],
|
||||
"temperatura": ["que temperatura tiene", "como esta la temperatura"],
|
||||
"bateria": ["cuanta bateria tengo", "como va la bateria"],
|
||||
}
|
||||
|
||||
|
||||
def con_señor(texto: str) -> str:
|
||||
"""Mete el trato en las frases fijas.
|
||||
|
||||
Son las que mas suenan —el acuse va en cada orden— y hasta ahora eran las
|
||||
unicas que no trataban de señor, asi que el personaje se caia justo ahi.
|
||||
"""
|
||||
if not texto or "señor" in texto.lower():
|
||||
return texto
|
||||
if texto.endswith("."):
|
||||
return texto[:-1] + ", señor."
|
||||
if texto.endswith("?"):
|
||||
return texto[:-1] + ", señor?"
|
||||
return texto + ", señor"
|
||||
|
||||
|
||||
# Abrir una terminal y poner un video: de lo primero que se le pide a un
|
||||
# asistente de escritorio, y no estaban. Las busquedas van acotadas y con
|
||||
# tope porque el home son casi mil gigas y un find sin limite no vuelve.
|
||||
ESCRITORIO = [
|
||||
("terminal_abrir", "abre una terminal|abreme una terminal|abre la terminal|saca una terminal|quiero una terminal",
|
||||
"Ahora mismo.", "gnome-terminal 2>/dev/null || kgx 2>/dev/null || xterm 2>/dev/null &",
|
||||
"Terminal abierta."),
|
||||
("terminal_aqui", "abre una terminal en el cofre|terminal en el cofre",
|
||||
"Enseguida.", "gnome-terminal --working-directory=$HOME/COFRE 2>/dev/null || kgx 2>/dev/null &",
|
||||
"Ahi la tiene, en el cofre."),
|
||||
("videos_listar", "que peliculas tengo|que videos tengo|lista mis videos",
|
||||
"Un momento, lo miro.", "timeout 12 find $HOME/Videos $HOME/Downloads $HOME/Desktop -type f -regextype posix-extended -iregex '.*\\.(mp4|mkv|avi|webm|mov|m4v)$' 2>/dev/null | head -12",
|
||||
"He encontrado {lineas}."),
|
||||
]
|
||||
|
||||
# esta captura el nombre de la pelicula
|
||||
ESCRITORIO_CAPTURA = [
|
||||
("pelicula_abrir", "pon la pelicula|abre la pelicula|reproduce el video|pon el video|quiero ver",
|
||||
"Enseguida se la pongo.", "v=$(timeout 12 find $HOME/Videos $HOME/Downloads $HOME/Desktop -type f -regextype posix-extended -iregex '.*{argumento}.*\\.(mp4|mkv|avi|webm|mov|m4v)$' 2>/dev/null | head -1); if [ -n \"$v\" ]; then xdg-open \"$v\" >/dev/null 2>&1 & echo \"Poniendo $(basename \"$v\")\"; else echo \"No encuentro ese video\"; fi",
|
||||
"{ultima}"),
|
||||
]
|
||||
|
||||
# Ventanas y pantallas. Toda la logica vive en config/ventanas.sh (un script del
|
||||
# host): aqui solo estan las frases y la llamada.
|
||||
#
|
||||
# Las frases llevan TRES palabras o mas y evitan a proposito ser subcadena de
|
||||
# las que ya existen con "pantalla" o "ventana" dentro —apagar_pantalla,
|
||||
# bloquear, captura_pantalla, ff_firefox_privado, ff_firefox_cerrar,
|
||||
# proceso_matar, terminal_abrir—, porque el emparejador gana por frase mas
|
||||
# larga sin mirar el grupo y una frase mal elegida se las come.
|
||||
VENTANAS = [
|
||||
("ventanas_listar", "que ventanas tengo abiertas|que ventanas hay abiertas|lista las ventanas",
|
||||
"Un momento, lo miro.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh listar",
|
||||
"Tiene {lineas} ventanas abiertas."),
|
||||
|
||||
("ventana_izquierda", "pon la ventana a la izquierda|manda la ventana a la izquierda|esta ventana a la izquierda",
|
||||
"Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta izquierda", "{ultima}"),
|
||||
|
||||
("ventana_derecha", "pon la ventana a la derecha|manda la ventana a la derecha|esta ventana a la derecha",
|
||||
"Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta derecha", "{ultima}"),
|
||||
|
||||
("ventana_arriba", "pon la ventana arriba del todo|manda la ventana arriba",
|
||||
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta arriba", "{ultima}"),
|
||||
|
||||
("ventana_abajo", "pon la ventana abajo del todo|manda la ventana abajo",
|
||||
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta abajo", "{ultima}"),
|
||||
|
||||
("ventana_centrar", "centra la ventana activa|pon la ventana en el centro",
|
||||
"Enseguida.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta centro", "{ultima}"),
|
||||
|
||||
("ventana_maximizar", "maximiza la ventana activa|pon la ventana a pantalla completa",
|
||||
"Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh maximiza esta", "{ultima}"),
|
||||
|
||||
("ventana_restaurar", "restaura la ventana activa|quita la pantalla completa",
|
||||
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh restaura esta", "{ultima}"),
|
||||
|
||||
("ventana_minimizar", "minimiza la ventana activa|esconde la ventana activa",
|
||||
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh minimiza esta", "{ultima}"),
|
||||
]
|
||||
|
||||
# Estas capturan lo que se dice detras: el nombre de la pantalla, el numero de
|
||||
# escritorio o el mote que se le quiera poner a un monitor.
|
||||
VENTANAS_CAPTURA = [
|
||||
("ventana_a_pantalla", "manda la ventana a la pantalla|pasa la ventana a la pantalla",
|
||||
"Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh mover esta {argumento}", "{ultima}"),
|
||||
|
||||
("ventana_al_escritorio", "manda la ventana al escritorio|pasa la ventana al escritorio",
|
||||
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh escritorio esta {argumento}", "{ultima}"),
|
||||
|
||||
("pantalla_nombrar", "a esta pantalla llamala|llama a esta pantalla",
|
||||
"Anotado.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh alias 1 {argumento}", "{ultima}"),
|
||||
]
|
||||
|
||||
|
||||
def fila(t, grupo, captura=False):
|
||||
ident, frases, acuse, comando, respuesta = t[:5]
|
||||
confirmar = len(t) > 5 and t[5]
|
||||
return {
|
||||
"id": ident,
|
||||
"grupo": grupo,
|
||||
"frases": frases.split("|"),
|
||||
"acuse": con_señor(acuse or "Voy."),
|
||||
"comando": comando,
|
||||
"host": True,
|
||||
"respuesta": con_señor(respuesta),
|
||||
"confirmar": bool(confirmar),
|
||||
"captura": captura,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
acciones = [fila(t, "sistema") for t in SISTEMA]
|
||||
acciones += [fila(t, "sistema") for t in ESCRITORIO]
|
||||
acciones += [fila(t, "sistema", captura=True) for t in ESCRITORIO_CAPTURA]
|
||||
acciones += [fila(t, "ventanas") for t in VENTANAS]
|
||||
acciones += [fila(t, "ventanas", captura=True) for t in VENTANAS_CAPTURA]
|
||||
acciones += [fila(t, "ficheros", captura=True) for t in CAPTURA]
|
||||
acciones += [fila(t, "oasis") for t in OASIS]
|
||||
acciones += [fila(t, "pentest", captura=t[0] in PENTEST_CAPTURA) for t in PENTEST]
|
||||
|
||||
# las de firefox viven en su propio fichero, que son otras cincuenta
|
||||
firefox = pathlib.Path(__file__).parent / "_firefox.py"
|
||||
if firefox.exists():
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("firefox", firefox)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
acciones += mod.acciones()
|
||||
|
||||
# Los alias se aplican aqui, sobre la lista completa: si se hicieran al
|
||||
# construir cada fila, las de firefox se quedarian fuera porque se generan
|
||||
# en otro fichero. Se aprende por las malas.
|
||||
for accion in acciones:
|
||||
extra = ALIAS.get(accion["id"])
|
||||
if extra:
|
||||
accion["frases"] += [f for f in extra if f not in accion["frases"]]
|
||||
|
||||
salida = pathlib.Path(__file__).parent / "acciones.json"
|
||||
salida.write_text(json.dumps({"version": 1, "acciones": acciones},
|
||||
indent=2, ensure_ascii=False) + "\n")
|
||||
|
||||
ids = [a["id"] for a in acciones]
|
||||
print(f"{len(acciones)} acciones escritas en {salida.name}")
|
||||
if len(ids) != len(set(ids)):
|
||||
repes = {i for i in ids if ids.count(i) > 1}
|
||||
print(f"CUIDADO, ids repetidos: {repes}")
|
||||
for grupo in sorted({a["grupo"] for a in acciones}):
|
||||
print(f" {grupo:<10} {sum(1 for a in acciones if a['grupo'] == grupo)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2179
nucleo/datos/acciones.json
Normal file
2179
nucleo/datos/acciones.json
Normal file
File diff suppressed because it is too large
Load diff
290
nucleo/estado.py
Executable file
290
nucleo/estado.py
Executable file
|
|
@ -0,0 +1,290 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Por donde va la migracion, en la terminal.
|
||||
|
||||
./estado.py las barras
|
||||
./estado.py -v y que falta exactamente en cada hito
|
||||
./estado.py --rapido sin ejecutar nada, solo mirar ficheros
|
||||
|
||||
El porcentaje NO se escribe a mano: sale de comprobaciones de verdad —que un
|
||||
fichero exista, que una funcion este, que una prueba pase—. Un contador que se
|
||||
actualiza a mano miente a la semana, y ademas es justo lo que uno quiere que no
|
||||
mienta: si dice 60 % es porque tres de cinco comprobaciones pasan ahora mismo.
|
||||
|
||||
Las que cuestan tiempo (arrancar whisper, hablar con ollama) van marcadas como
|
||||
lentas y se saltan con --rapido.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||
RAIZ = os.path.dirname(AQUI)
|
||||
VENV = os.path.join(AQUI, "venv", "bin", "python")
|
||||
|
||||
|
||||
# ------------------------------------------------------------- comprobantes
|
||||
|
||||
def hay(*trozos) -> bool:
|
||||
return os.path.exists(os.path.join(AQUI, *trozos))
|
||||
|
||||
|
||||
def dentro(fichero, texto) -> bool:
|
||||
try:
|
||||
with open(os.path.join(AQUI, fichero), encoding="utf-8") as f:
|
||||
return texto in f.read()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def corre(orden, espera=90) -> bool:
|
||||
try:
|
||||
r = subprocess.run(orden, cwd=AQUI, capture_output=True, timeout=espera)
|
||||
return r.returncode == 0
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def modulo(nombre) -> bool:
|
||||
return corre([VENV if os.path.exists(VENV) else sys.executable,
|
||||
"-c", f"import {nombre}"], espera=30)
|
||||
|
||||
|
||||
def sin_comentarios(fichero, texto) -> bool:
|
||||
"""Si el texto aparece en una linea que NO es un comentario."""
|
||||
try:
|
||||
with open(os.path.join(AQUI, fichero), encoding="utf-8") as f:
|
||||
for l in f:
|
||||
limpia = l.strip()
|
||||
if limpia and not limpia.startswith(("#", "//", "*")) and texto in limpia:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def catalogo_tiene(n) -> bool:
|
||||
try:
|
||||
import json
|
||||
with open(os.path.join(AQUI, "datos", "acciones.json"), encoding="utf-8") as f:
|
||||
return len(json.load(f)["acciones"]) >= n
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- los hitos
|
||||
#
|
||||
# Cada comprobacion es (que se comprueba, como, si es lenta). El orden dentro
|
||||
# de cada hito va de lo mas basico a lo mas fino, que es como se construyo.
|
||||
|
||||
HITOS = [
|
||||
("la boca", "hablar por los altavoces", [
|
||||
("el modulo esta", lambda: hay("boca", "voz.py"), False),
|
||||
("hay tres voces o mas", lambda: dentro("boca/voz.py", '"claude"'), False),
|
||||
("cache de frases por sha1", lambda: dentro("boca/voz.py", "_clave"), False),
|
||||
("sabe si esta sonando", lambda: dentro("boca/voz.py", "def hablando"), False),
|
||||
("habla de verdad", lambda: corre(
|
||||
[VENV, "jarvis.py", "--di", "prueba"], 60), True),
|
||||
]),
|
||||
|
||||
("el oido", "escuchar y transcribir", [
|
||||
("captura con pw-record", lambda: dentro("oido/captura.py", "pw-record"), False),
|
||||
("detector de voz Silero", lambda: modulo("pysilero_vad"), False),
|
||||
("cliente de whisper-server", lambda: hay("oido", "whisper.py"), False),
|
||||
("filtro de alucinaciones", lambda: dentro("oido/whisper.py", "def es_ruido"), False),
|
||||
("guarda contra el eco", lambda: dentro("oido/captura.py", "boca.hablando"), False),
|
||||
("whisper arranca y transcribe", lambda: corre(
|
||||
[VENV, "-c",
|
||||
"import sys;sys.path.insert(0,'.');from oido.whisper import Oido;"
|
||||
"o=Oido();import sys as s;s.exit(0 if o.arranca() else 1)"], 90), True),
|
||||
]),
|
||||
|
||||
("las manos", "ejecutar las 150 acciones", [
|
||||
("el emparejador copiado", lambda: hay("manos", "acciones.py"), False),
|
||||
("las tareas en paralelo", lambda: hay("manos", "tareas.py"), False),
|
||||
("el catalogo entero", lambda: catalogo_tiene(150), False),
|
||||
("una sola puerta para ejecutar", lambda: dentro("manos/__init__.py", "class Manos"), False),
|
||||
("compara tambien por sonido", lambda: hay("manos", "fonetica.py"), False),
|
||||
("banco fijo para medir", lambda: hay("datos", "banco.json"), False),
|
||||
("las pruebas en verde", lambda: corre(
|
||||
[VENV, "pruebas/prueba_manos.py"], 120), True),
|
||||
]),
|
||||
|
||||
("el cerebro", "pensar lo que el catalogo no cubre", [
|
||||
("cliente de ollama", lambda: hay("cerebro", "ollama.py"), False),
|
||||
("dos herramientas, no una", lambda: dentro("cerebro/ollama.py", "comando_de_shell"), False),
|
||||
("no puede inventarse datos", lambda: dentro("cerebro/ollama.py", "REGLA QUE NO SE ROMPE"), False),
|
||||
("tope de vueltas", lambda: dentro("cerebro/ollama.py", "VUELTAS"), False),
|
||||
("cadena catalogo-primero", lambda: dentro("jarvis.py", "def atiende"), False),
|
||||
("ollama contesta", lambda: corre(
|
||||
["curl", "-sf", "-m", "10", "http://127.0.0.1:11434/api/tags"], 20), True),
|
||||
]),
|
||||
|
||||
("la palabra", "que le llamen JARVIS", [
|
||||
("el centinela existe", lambda: hay("oido", "despierta.py"), False),
|
||||
("se llama JARVIS", lambda: dentro("oido/despierta.py", 'NOMBRE = "jarvis"'), False),
|
||||
("aguanta como lo oye Whisper", lambda: dentro("oido/despierta.py", "PARECIDAS"), False),
|
||||
("la orden va en la misma frase", lambda: dentro("oido/despierta.py",
|
||||
"def parte_el_nombre"), False),
|
||||
("conectado al bucle de voz", lambda: dentro("jarvis.py", "centinela.filtra"), False),
|
||||
# El que de verdad importaba: sin el prompt del nombre, Whisper nunca
|
||||
# escribe JARVIS y el centinela no despierta jamas.
|
||||
("prompt del nombre para Whisper", lambda: dentro("oido/whisper.py",
|
||||
"PROMPT_NOMBRE"), False),
|
||||
("boton para dormirlo y despertarlo", lambda: dentro("cara/panel.py",
|
||||
'dato == "sueno"'), False),
|
||||
("prueba de voz de punta a punta", lambda: hay("pruebas", "prueba_voz.py"), False),
|
||||
("y la palabra va APAGADA por defecto", lambda: dentro("jarvis.py",
|
||||
"activo=a.palabra"), False),
|
||||
]),
|
||||
|
||||
("la cara", "el panel", [
|
||||
("el HUD copiado", lambda: hay("cara", "hud", "index.html"), False),
|
||||
("servidor http y websocket", lambda: hay("cara", "servidor.py"), False),
|
||||
# El WebSocket vive en puente.js, no en hud.js: hud.js no se toca a
|
||||
# proposito y el puente suplanta window.webkit por debajo.
|
||||
("puente por WebSocket", lambda: dentro("cara/hud/puente.js", "WebSocket"), False),
|
||||
("y hud.js sigue sin tocarse", lambda: dentro("cara/hud/hud.js",
|
||||
"messageHandlers"), False),
|
||||
("el panel se conecta al nucleo", lambda: dentro("cara/servidor.py", "async"), False),
|
||||
# Ojo con las comprobaciones flojas: esta buscaba la palabra "tarea" en
|
||||
# servidor.py y pasaba porque salia en un COMENTARIO, con las tarjetas
|
||||
# sin conectar. Una barra que miente es peor que no tener barra.
|
||||
("tarjetas de tareas en marcha", lambda: dentro("cara/panel.py",
|
||||
"GestorTareas"), False),
|
||||
("la consola lleva la conversacion", lambda: dentro("cara/hud/hud.js",
|
||||
"aConsolaDicho"), False),
|
||||
]),
|
||||
|
||||
("el dialogo", "que pregunte en vez de recitar", [
|
||||
("las preguntas salen del catalogo", lambda: hay("manos", "preguntar.py"), False),
|
||||
("pulsar pregunta lo que falta", lambda: dentro("cara/panel.py", "_pregunta("), False),
|
||||
("la respuesta hablada la recoge", lambda: dentro("jarvis.py", "panel.responde"), False),
|
||||
("lo destructivo confirma", lambda: dentro("cara/panel.py", "accion.confirmar"), False),
|
||||
("y el panel enseña que espera", lambda: dentro("cara/hud/hud.js", "preguntando"), False),
|
||||
("la prueba pasa", lambda: corre([VENV, "pruebas/prueba_dialogo.py"], 120), True),
|
||||
]),
|
||||
|
||||
("el candado", "que no obedezca a cualquiera", [
|
||||
("el candado en el HUD", lambda: dentro("cara/hud/index.html", "candado"), False),
|
||||
("lo atiende el nucleo", lambda: dentro("cara/panel.py", "_intenta_desbloquear"), False),
|
||||
("valida con la contrasena del sistema", lambda: dentro("cara/panel.py",
|
||||
"GestorRoot"), False),
|
||||
("con el candado puesto no ejecuta", lambda: dentro("cara/panel.py",
|
||||
"no hay ordenes"), False),
|
||||
("y la VOZ tampoco obedece", lambda: dentro("jarvis.py", "(bloqueado)"), False),
|
||||
("la prueba pasa", lambda: corre([VENV, "pruebas/prueba_candado.py"], 120), True),
|
||||
]),
|
||||
|
||||
("el saber", "conocer COFRE entero", [
|
||||
("indexador de COFRE", lambda: hay("saber", "indexa.py"), False),
|
||||
("busqueda por significado", lambda: hay("saber", "busca.py"), False),
|
||||
("indice construido", lambda: hay("datos", "saber.jsonl"), False),
|
||||
("el cerebro lo consulta antes de responder", lambda: dentro(
|
||||
"cerebro/ollama.py", "def _saber"), False),
|
||||
("filtra el codigo de terceros", lambda: dentro("saber/indexa.py", "IGNORA"), False),
|
||||
("indexa la documentacion de Linux", lambda: hay("saber", "sistema.py"), False),
|
||||
("y los servidores SSH", lambda: dentro("saber/sistema.py", "def servidores"), False),
|
||||
("el cerebro puede ejecutar como root", lambda: dentro("manos/__init__.py",
|
||||
"necesita_root"), False),
|
||||
("root ligado al candado", lambda: dentro("cara/panel.py", "root.recuerda"), False),
|
||||
]),
|
||||
|
||||
("el pipeline", "aprender del uso", [
|
||||
("diario de lo que oye y hace", lambda: hay("manos", "diario.py"), False),
|
||||
("apunta la VIA de cada frase", lambda: dentro("jarvis.py", 'anota(texto, "ruido")'), False),
|
||||
("y tambien los aciertos", lambda: dentro("jarvis.py", '"catalogo", accion.id'), False),
|
||||
("herramienta para revisarlo", lambda: hay("mejora.py"), False),
|
||||
("propone, NO commitea solo", lambda: dentro("mejora.py", "Esto NO cambia nada"), False),
|
||||
("mide contra el banco fijo", lambda: hay("datos", "banco.json"), False),
|
||||
]),
|
||||
|
||||
("paridad", "usarlo a diario", [
|
||||
("icono naranja", lambda: hay("jarvis-naranja.svg"), False),
|
||||
("icono instalado", lambda: os.path.exists(os.path.expanduser(
|
||||
"~/.local/share/icons/jarvis-nucleo.png")), False),
|
||||
("lanzador propio", lambda: hay("arranca.sh"), False),
|
||||
("ventana propia, no pestaña", lambda: hay("ventana.sh"), False),
|
||||
# Se miran las lineas de CODIGO, no el fichero entero: el comentario que
|
||||
# explica por que ya no se usa xdg-open contiene la palabra, y una
|
||||
# comprobacion ingenua se cree que sigue ahi. Es la tercera vez que la
|
||||
# barra casi miente por buscar texto suelto en vez de codigo.
|
||||
("una sola forma de abrirla", lambda: not sin_comentarios(
|
||||
"arranca.sh", "xdg-open"), False),
|
||||
("la barra de tareas la reconoce", lambda: "StartupWMClass" in open(
|
||||
os.path.expanduser("~/.local/share/applications/jarvis-nucleo.desktop")).read(), False),
|
||||
("entrada en el escritorio", lambda: os.path.exists(os.path.expanduser(
|
||||
"~/.local/share/applications/jarvis-nucleo.desktop")), False),
|
||||
("Newelle congelado y aparte", lambda: corre(
|
||||
["git", "-C", RAIZ, "rev-parse", "jarvis-newelle-final"], 15), False),
|
||||
]),
|
||||
]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ pintarlo
|
||||
|
||||
ANCHO = 34
|
||||
|
||||
|
||||
def color(t, c):
|
||||
return f"\033[{c}m{t}\033[0m" if sys.stdout.isatty() else t
|
||||
|
||||
|
||||
def barra(pct, hecho, total):
|
||||
lleno = round(ANCHO * pct / 100)
|
||||
# verde entero, amarillo a medias, gris sin empezar
|
||||
c = "32" if pct == 100 else ("33" if pct > 0 else "90")
|
||||
return color("█" * lleno, c) + color("·" * (ANCHO - lleno), "90")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("-v", "--detalle", action="store_true")
|
||||
p.add_argument("--rapido", action="store_true",
|
||||
help="sin ejecutar nada, solo mirar ficheros")
|
||||
a = p.parse_args()
|
||||
|
||||
if not os.path.exists(VENV):
|
||||
print(color(" aviso: no hay venv; algunas comprobaciones no se pueden hacer\n", "33"))
|
||||
|
||||
print()
|
||||
print(color(" JARVIS · migracion a nucleo propio", "1"))
|
||||
print()
|
||||
|
||||
hechas = totales = 0
|
||||
for i, (nombre, para_que, comprobaciones) in enumerate(HITOS, 1):
|
||||
activas = [c for c in comprobaciones if not (a.rapido and c[2])]
|
||||
pasan = []
|
||||
for texto, prueba, lenta in activas:
|
||||
try:
|
||||
ok = bool(prueba())
|
||||
except Exception:
|
||||
ok = False
|
||||
pasan.append((texto, ok, lenta))
|
||||
n = sum(1 for _, ok, _ in pasan if ok)
|
||||
pct = 100 * n / len(pasan) if pasan else 0
|
||||
hechas += n
|
||||
totales += len(pasan)
|
||||
|
||||
marca = color("✓", "32") if pct == 100 else (" " if pct == 0 else color("·", "33"))
|
||||
print(f" {marca} {i}. {nombre:12s} {barra(pct, n, len(pasan))} "
|
||||
f"{pct:3.0f}% {n}/{len(pasan)} {color(para_que, '90')}")
|
||||
|
||||
if a.detalle:
|
||||
for texto, ok, lenta in pasan:
|
||||
s = color(" ok ", "32") if ok else color(" --", "31")
|
||||
print(f" {s} {texto}" + (color(" (lenta)", "90") if lenta else ""))
|
||||
print()
|
||||
|
||||
print()
|
||||
total_pct = 100 * hechas / totales if totales else 0
|
||||
print(f" {'TOTAL':14s} {barra(total_pct, hechas, totales)} "
|
||||
f"{total_pct:3.0f}% {hechas}/{totales}")
|
||||
print()
|
||||
if a.rapido:
|
||||
print(color(" (rapido: sin las comprobaciones que ejecutan cosas)\n", "90"))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
55
nucleo/jarvis-naranja.svg
Normal file
55
nucleo/jarvis-naranja.svg
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<!-- El mismo reactor que jarvis-icon.svg, en naranja.
|
||||
La paleta no se inventa: es la del tema "ambar" del HUD (style.css),
|
||||
para que el icono y la pantalla sean el mismo color.
|
||||
|
||||
Cian = JARVIS sobre Newelle, congelado.
|
||||
Naranja = el nucleo independiente. Asi se sabe cual esta abierto. -->
|
||||
<defs>
|
||||
<radialGradient id="core" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#fff0d8"/>
|
||||
<stop offset="45%" stop-color="#ffb347"/>
|
||||
<stop offset="100%" stop-color="#c8862a"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="halo" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#ffa040" stop-opacity=".55"/>
|
||||
<stop offset="70%" stop-color="#c8862a" stop-opacity=".12"/>
|
||||
<stop offset="100%" stop-color="#3a2810" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="ring" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#f5e3c7"/>
|
||||
<stop offset="100%" stop-color="#92765a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<circle cx="128" cy="128" r="124" fill="#120c04"/>
|
||||
<circle cx="128" cy="128" r="120" fill="url(#halo)"/>
|
||||
|
||||
<!-- carcasa exterior -->
|
||||
<circle cx="128" cy="128" r="106" fill="none" stroke="url(#ring)" stroke-width="9"/>
|
||||
<circle cx="128" cy="128" r="94" fill="none" stroke="#3f2a10" stroke-width="7"/>
|
||||
|
||||
<!-- bobinas -->
|
||||
<g stroke="#efc78f" stroke-width="7" stroke-linecap="round" opacity=".9">
|
||||
<line x1="128" y1="42" x2="128" y2="70"/>
|
||||
<line x1="128" y1="186" x2="128" y2="214"/>
|
||||
<line x1="42" y1="128" x2="70" y2="128"/>
|
||||
<line x1="186" y1="128" x2="214" y2="128"/>
|
||||
<line x1="67" y1="67" x2="87" y2="87"/>
|
||||
<line x1="169" y1="169" x2="189" y2="189"/>
|
||||
<line x1="189" y1="67" x2="169" y2="87"/>
|
||||
<line x1="87" y1="169" x2="67" y2="189"/>
|
||||
</g>
|
||||
|
||||
<!-- anillo interior -->
|
||||
<circle cx="128" cy="128" r="70" fill="none" stroke="#7d541f" stroke-width="10"/>
|
||||
<circle cx="128" cy="128" r="58" fill="none" stroke="#ffa040" stroke-width="4" opacity=".85"/>
|
||||
|
||||
<!-- triangulo del nucleo -->
|
||||
<path d="M128 84 L166 150 L90 150 Z" fill="none" stroke="#ffe0b0" stroke-width="6"
|
||||
stroke-linejoin="round" opacity=".95"/>
|
||||
|
||||
<!-- nucleo -->
|
||||
<circle cx="128" cy="128" r="34" fill="url(#core)"/>
|
||||
<circle cx="128" cy="128" r="16" fill="#ffffff" opacity=".92"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
330
nucleo/jarvis.py
Executable file
330
nucleo/jarvis.py
Executable file
|
|
@ -0,0 +1,330 @@
|
|||
#!/usr/bin/env python3
|
||||
"""JARVIS. Asistente de voz local, sin framework debajo.
|
||||
|
||||
jarvis.py --di "Buenas noches, señor" prueba la boca
|
||||
jarvis.py --voces las que hay
|
||||
jarvis.py --accion "cuanto espacio queda" prueba las manos, sin voz
|
||||
|
||||
Esto sustituye a la capa que corria dentro de Newelle. Lo que se trajo tal cual
|
||||
—el emparejador, las tareas, el sudo, el HUD— esta en `manos/` y `cara/`; lo que
|
||||
habia que reescribir porque era de Newelle esta en `oido/`, `boca/` y `cerebro/`.
|
||||
|
||||
## Por que fuera del flatpak
|
||||
|
||||
No es dogma, es que el sandbox costaba mas de lo que daba: escondia `/tmp`,
|
||||
obligaba a `LD_LIBRARY_PATH` a mano para las libs de CUDA de whisper, metia un
|
||||
`flatpak-spawn --host` en cada accion, y cambiar una linea de Python pedia
|
||||
recompilar el paquete entero. Aqui un cambio es editar y reiniciar.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, AQUI)
|
||||
|
||||
from boca.voz import Boca, VOCES # noqa: E402
|
||||
from cara.panel import Panel # noqa: E402
|
||||
from cara.servidor import Cara # noqa: E402
|
||||
from cerebro.ollama import Cerebro, MODELO # noqa: E402
|
||||
from manos import Manos # noqa: E402
|
||||
from manos.diario import anota # noqa: E402
|
||||
from manos.acciones import Catalogo # noqa: E402
|
||||
from oido.captura import Captura, fuentes # noqa: E402
|
||||
from oido.despierta import Centinela, NOMBRE # noqa: E402
|
||||
from oido.whisper import Oido, prompt_dominio, es_ruido # noqa: E402
|
||||
|
||||
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
|
||||
PROPIO = os.path.expanduser("~/.config/jarvis/acciones.json")
|
||||
|
||||
|
||||
def catalogo() -> Catalogo:
|
||||
# El del usuario primero: puede redefinir cualquiera del paquete. Mismo
|
||||
# orden que tenia el panel de Newelle.
|
||||
return Catalogo([PROPIO, CATALOGO])
|
||||
|
||||
|
||||
def atiende(texto, manos: Manos, cerebro: Cerebro | None, traza=print):
|
||||
"""La cadena entera para una frase: catalogo primero, cerebro despues.
|
||||
|
||||
El orden no es un detalle de eficiencia, es lo que hace usable esto. Las 150
|
||||
acciones se emparejan sin tocar ningun modelo y contestan al instante; el
|
||||
cerebro solo ve lo que no encaja. Por eso un modelo lento se soporta: la
|
||||
mayoria de las ordenes ni pasan por el.
|
||||
"""
|
||||
t0 = time.time()
|
||||
accion, argumento = manos.busca(texto)
|
||||
if accion is not None:
|
||||
traza(f" catalogo: {accion.id}" + (f" <- {argumento}" if argumento else ""))
|
||||
dicho, _ = manos.haz(accion, argumento)
|
||||
anota(texto, "catalogo", accion.id, argumento, time.time() - t0, dicho)
|
||||
return dicho
|
||||
if cerebro is None:
|
||||
traza(" (sin cerebro conectado)")
|
||||
anota(texto, "sin-cerebro", seg=time.time() - t0)
|
||||
return ""
|
||||
traza(" al cerebro...")
|
||||
dicho = cerebro.responde(texto, al_ejecutar=lambda i, a: traza(
|
||||
f" usa {i}" + (f"({a[:52]})" if a else "")))
|
||||
# La via mas valiosa del diario: lo que el catalogo NO cubre es justo la
|
||||
# lista de acciones que faltan por escribir.
|
||||
anota(texto, "cerebro", seg=time.time() - t0, dicho=dicho)
|
||||
return dicho
|
||||
|
||||
|
||||
def manda_accion(frase: str, boca: Boca | None = None, cerebro=None) -> int:
|
||||
manos = Manos(catalogo())
|
||||
dicho = atiende(frase, manos, cerebro, traza=lambda t: print(t))
|
||||
if not dicho:
|
||||
print(f' sin accion para "{frase}"')
|
||||
return 1
|
||||
print(f" {dicho}")
|
||||
if boca:
|
||||
boca.di(dicho)
|
||||
return 0
|
||||
|
||||
|
||||
def escucha(boca: Boca, modelo="small", fuente=None, callado=False,
|
||||
cerebro=None, panel=None, centinela=None) -> int:
|
||||
"""El bucle de verdad: oir, entender, hacer, contestar.
|
||||
|
||||
Es la version desnuda de lo que hacia el panel, sin GTK y sin HUD todavia.
|
||||
Sirve para comprobar que la cadena entera funciona antes de ponerle cara.
|
||||
"""
|
||||
manos = Manos(catalogo())
|
||||
# Con el prompt del NOMBRE, no con el del catalogo entero: sin el, Whisper
|
||||
# nunca escribe "JARVIS" y el centinela no despierta jamas (ver whisper.py).
|
||||
oreja = Oido(modelo=modelo)
|
||||
if not oreja.disponible():
|
||||
print(f" no encuentro whisper o el modelo {modelo}")
|
||||
return 1
|
||||
|
||||
print(f" arrancando whisper ({modelo}, beam {oreja.beam}, sns, nombre)...")
|
||||
if not oreja.arranca():
|
||||
print(" whisper no arranco")
|
||||
return 1
|
||||
aviso = (f"esperando a que le llamen: «{NOMBRE.upper()}»"
|
||||
if centinela and centinela.activo
|
||||
else "atendiendo todo lo que oiga (--palabra para exigir el nombre)")
|
||||
print(f" escuchando por {fuente or 'la entrada por defecto'}, {aviso}.")
|
||||
print(" Ctrl+C para parar.\n")
|
||||
if panel:
|
||||
panel.estado("dormido" if centinela and centinela.activo else "escuchando")
|
||||
|
||||
# La captura consulta a la boca en cada trozo: mientras JARVIS habla, ese
|
||||
# audio no se mira siquiera. Es lo que evita que se oiga a si mismo.
|
||||
oreja_mic = Captura(boca=boca, fuente=fuente)
|
||||
try:
|
||||
for wav in oreja_mic.frases():
|
||||
try:
|
||||
texto = oreja.transcribe(wav)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(wav)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if es_ruido(texto):
|
||||
if texto:
|
||||
print(f" · ruido descartado: {texto!r}")
|
||||
anota(texto, "ruido")
|
||||
if panel:
|
||||
panel.dicho(texto, atendido=False, motivo="ruido")
|
||||
continue
|
||||
|
||||
# ¿Hay una pregunta en el aire? Entonces esto es la respuesta.
|
||||
#
|
||||
# Va antes que TODO —antes de la palabra de activacion y antes del
|
||||
# emparejador— porque si JARVIS acaba de preguntarte que fichero
|
||||
# buscas, obligarte a decir "JARVIS, informes" seria absurdo: ya
|
||||
# esta hablando contigo.
|
||||
if panel and panel.esperando():
|
||||
print(f" » {texto} (respondiendo a lo preguntado)")
|
||||
anota(texto, "respuesta")
|
||||
panel.dicho(texto)
|
||||
if panel.responde(texto):
|
||||
continue
|
||||
|
||||
# La palabra de activacion, antes de nada: sin esto obedece a
|
||||
# cualquiera que pase, y a la tele.
|
||||
if centinela:
|
||||
atender, texto_orden, recien = centinela.filtra(texto)
|
||||
if not atender:
|
||||
if recien:
|
||||
print(f" » {texto} (le han llamado)")
|
||||
if panel:
|
||||
panel.dicho(texto)
|
||||
panel.estado("escuchando") # ya despierto
|
||||
boca.di_si_libre("¿Señor?")
|
||||
else:
|
||||
print(f" · {texto} (no le han llamado)")
|
||||
anota(texto, "sin-nombre")
|
||||
# Se enseña IGUAL, marcado: que se vea que te ha oido
|
||||
# bien y que lo unico que falta es llamarle.
|
||||
if panel:
|
||||
panel.dicho(texto, atendido=False,
|
||||
motivo=f"diga «{NOMBRE.upper()}» primero")
|
||||
continue
|
||||
texto = texto_orden
|
||||
|
||||
# El candado tapa el panel, pero lo que hay que impedir es que
|
||||
# OBEDEZCA: si no, quien pase por delante da ordenes hablando, que
|
||||
# es justo lo que el candado existe para evitar.
|
||||
if panel and getattr(panel, "candado", False) and not panel.desbloqueado:
|
||||
print(f" · {texto} (bloqueado)")
|
||||
anota(texto, "bloqueado")
|
||||
panel.dicho(texto, atendido=False, motivo="hace falta la contraseña")
|
||||
continue
|
||||
|
||||
print(f" » {texto}")
|
||||
if panel:
|
||||
panel.dicho(texto)
|
||||
panel.estado("pensando")
|
||||
dicho = atiende(texto, manos, cerebro)
|
||||
if not dicho:
|
||||
if panel:
|
||||
panel.estado("escuchando")
|
||||
continue
|
||||
print(f" {dicho}")
|
||||
if panel:
|
||||
panel.respuesta(dicho)
|
||||
panel.estado("hablando")
|
||||
if not callado:
|
||||
boca.di(dicho)
|
||||
if panel:
|
||||
# Sigue despierto un rato tras atender, para poder encadenar sin
|
||||
# repetir el nombre: el rotulo tiene que decir eso y no "dormido".
|
||||
panel.estado("escuchando" if not centinela or centinela.despierto
|
||||
else "dormido")
|
||||
except KeyboardInterrupt:
|
||||
print("\n parado")
|
||||
finally:
|
||||
oreja_mic.para()
|
||||
oreja.para()
|
||||
return 0
|
||||
|
||||
|
||||
def panel(boca: Boca, cerebro=None, escuchando=False, modelo="small",
|
||||
fuente=None, abre=True, centinela=None, sin_candado=False) -> int:
|
||||
"""Levanta el panel y, si se pide, ademas escucha el microfono."""
|
||||
manos = Manos(catalogo())
|
||||
cara = Cara()
|
||||
p = Panel(cara, boca, manos, cerebro, candado=not sin_candado,
|
||||
centinela=centinela)
|
||||
cara.al_pulsar = p.pulsado
|
||||
url = cara.arranca()
|
||||
if not url:
|
||||
return 1
|
||||
p.arranca()
|
||||
print(f" panel en {url}")
|
||||
|
||||
if abre:
|
||||
# Una sola forma de abrir la ventana, en ventana.sh, para que sea igual
|
||||
# se llegue por donde se llegue. Tener aqui su propia lista de
|
||||
# navegadores fue lo que dejo que arranca.sh abriera una pestaña de
|
||||
# Firefox cuando JARVIS ya estaba en marcha.
|
||||
abridor = os.path.join(AQUI, "ventana.sh")
|
||||
if os.access(abridor, os.X_OK):
|
||||
subprocess.Popen([abridor, url], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
|
||||
if not escuchando:
|
||||
print(" sin microfono (--escucha para que ademas oiga). Ctrl+C para parar.")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\n parado")
|
||||
p.para()
|
||||
return 0
|
||||
|
||||
return escucha(boca, modelo, fuente, False, cerebro, p, centinela)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description="JARVIS")
|
||||
p.add_argument("--di", metavar="TEXTO", help="decir algo en voz alta")
|
||||
p.add_argument("--voz", default=None, help="con que voz")
|
||||
p.add_argument("--voces", action="store_true", help="listar las voces")
|
||||
p.add_argument("--accion", metavar="FRASE", help="ejecutar una orden del catalogo")
|
||||
p.add_argument("--callado", action="store_true", help="no hablar el resultado")
|
||||
p.add_argument("--escucha", action="store_true", help="escuchar el microfono")
|
||||
p.add_argument("--modelo", default="small", help="modelo de whisper")
|
||||
p.add_argument("--fuente", default=None, help="entrada de audio (ver --fuentes)")
|
||||
p.add_argument("--fuentes", action="store_true", help="listar las entradas")
|
||||
p.add_argument("--cerebro", default=MODELO,
|
||||
help="modelo de ollama; 'no' para trabajar solo con el catalogo")
|
||||
p.add_argument("--ram", action="store_true",
|
||||
help="el modelo entero en RAM, sin usar la grafica")
|
||||
p.add_argument("--panel", action="store_true", help="abrir el panel")
|
||||
p.add_argument("--sin-abrir", action="store_true",
|
||||
help="levantar el panel pero no abrir el navegador")
|
||||
p.add_argument("--palabra", action="store_true",
|
||||
help=f"exigir que le llamen «{NOMBRE}» antes de cada orden")
|
||||
p.add_argument("--sin-candado", action="store_true",
|
||||
help="no pedir la contraseña al abrir (solo para probar)")
|
||||
a = p.parse_args()
|
||||
|
||||
boca = Boca(a.voz or "davefx")
|
||||
cerebro = None
|
||||
if a.cerebro and a.cerebro != "no":
|
||||
cerebro = Cerebro(catalogo(), modelo=a.cerebro, en_ram=a.ram,
|
||||
manos=Manos(catalogo()))
|
||||
|
||||
if a.voces:
|
||||
hay = boca.disponibles()
|
||||
for nombre in VOCES:
|
||||
marca = "·" if nombre in hay else "×"
|
||||
actual = " <- puesta" if nombre == boca.voz else ""
|
||||
print(f" {marca} {nombre}{actual}")
|
||||
return 0
|
||||
|
||||
if a.di:
|
||||
if a.voz and not boca.cambia(a.voz):
|
||||
print(f" no existe la voz {a.voz}")
|
||||
return 1
|
||||
print(f" [{boca.voz}] {a.di}")
|
||||
if not boca.di(a.di):
|
||||
print(" no sono. mira que el modelo de la voz este en su sitio")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if a.fuentes:
|
||||
for f in fuentes():
|
||||
print(f" {f}")
|
||||
return 0
|
||||
|
||||
# La palabra de activacion va APAGADA por defecto.
|
||||
#
|
||||
# Decirla en cada frase es un peaje, y medido no evitaba nada: sobre 412
|
||||
# frases reales oidas —con todo el eco y el ruido ambiente dentro— solo 3
|
||||
# habrian disparado una accion, y las tres eran ordenes de verdad. Cero
|
||||
# falsos positivos del ruido, porque el emparejador ya es conservador.
|
||||
#
|
||||
# El candado cubre lo otro, que es quien puede darle ordenes.
|
||||
#
|
||||
# Se queda disponible con --palabra y en el boton del panel, por si algun
|
||||
# dia hay una tele cerca y el ruido empieza a colarse.
|
||||
centinela = Centinela(activo=a.palabra)
|
||||
|
||||
if a.panel:
|
||||
return panel(boca, cerebro, a.escucha, a.modelo, a.fuente,
|
||||
abre=not a.sin_abrir, centinela=centinela,
|
||||
sin_candado=a.sin_candado)
|
||||
|
||||
if a.escucha:
|
||||
return escucha(boca, a.modelo, a.fuente, a.callado, cerebro,
|
||||
centinela=centinela)
|
||||
|
||||
if a.accion:
|
||||
return manda_accion(a.accion, None if a.callado else boca, cerebro)
|
||||
|
||||
p.print_help()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
114
nucleo/manos/__init__.py
Normal file
114
nucleo/manos/__init__.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Lo que JARVIS puede HACER, y el unico sitio por donde pasa.
|
||||
|
||||
`Manos` es la puerta: el catalogo entra por aqui, el cerebro entra por aqui y
|
||||
el panel entrara por aqui. Tener un solo punto importa porque es donde se
|
||||
decide que se ejecuta y que no, y esa decision no puede estar repartida.
|
||||
|
||||
Las cuatro piezas de debajo vienen tal cual de la version sobre Newelle
|
||||
—`acciones.py`, `tareas.py`, `sudo_root.py`, `seleccion.py`— y se comprobo al
|
||||
copiarlas que 374 frases dan exactamente el mismo resultado en los dos sitios.
|
||||
"""
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
from .acciones import Catalogo, ejecuta, formatea
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
|
||||
PROPIO = os.path.expanduser("~/.config/jarvis/acciones.json")
|
||||
|
||||
# Lo que no se ejecuta por mucho que lo pida el modelo. No es una lista de
|
||||
# seguridad completa —eso es imposible— sino un tope contra el accidente: el
|
||||
# modelo es servicial y a veces se inventa una limpieza que nadie pidio.
|
||||
PROHIBIDO = (
|
||||
"rm -rf /", "mkfs", "dd if=", ":(){", "shutdown", "reboot", "halt",
|
||||
"> /dev/sd", "chmod -R 777 /", "chown -R", "userdel", "passwd",
|
||||
)
|
||||
|
||||
TOPE_SALIDA = 4000
|
||||
|
||||
|
||||
class Manos:
|
||||
def __init__(self, catalogo=None, al_ejecutar=None):
|
||||
self.catalogo = catalogo or Catalogo([PROPIO, CATALOGO])
|
||||
self.al_ejecutar = al_ejecutar
|
||||
# Root para la sesion: cuando el usuario desbloquea el candado con su
|
||||
# contraseña, esa misma queda aqui y el cerebro puede hacer sudo hasta
|
||||
# que caduque. Sin desbloquear, no hay root: el candado es la puerta.
|
||||
from manos.sudo_root import GestorRoot
|
||||
self.root = GestorRoot([])
|
||||
|
||||
# ------------------------------------------------------------- catalogo
|
||||
|
||||
def busca(self, texto: str):
|
||||
return self.catalogo.busca(texto)
|
||||
|
||||
def haz(self, accion, argumento="") -> tuple:
|
||||
"""Ejecuta una accion ya emparejada. Devuelve (lo que se dice, crudo)."""
|
||||
dicho, salida = ejecuta(accion, [], argumento)
|
||||
if salida:
|
||||
dicho = formatea(accion.respuesta, salida, argumento)
|
||||
return dicho, salida
|
||||
|
||||
def por_orden(self, orden: str, al_ejecutar=None) -> str:
|
||||
"""Para el cerebro: empareja contra el catalogo y ejecuta.
|
||||
|
||||
Devuelve texto para el modelo, no para el usuario: si no encaja, se le
|
||||
dice claramente para que pruebe con el shell en vez de insistir.
|
||||
"""
|
||||
if not orden.strip():
|
||||
return "no me has dicho ninguna orden"
|
||||
# El modelo a veces cuela un comando aqui de todas formas. Se detecta y
|
||||
# se le devuelve el motivo, que es mas util que un "no encontrado".
|
||||
if any(c in orden for c in "|><;&$") or orden.split()[0] in ("df", "ls", "ps", "cat"):
|
||||
return ("eso es un comando de shell, no una orden del catalogo. "
|
||||
"Usa la herramienta comando_de_shell.")
|
||||
accion, argumento = self.catalogo.busca(orden)
|
||||
if accion is None:
|
||||
return (f"'{orden}' no esta en el catalogo. Si hace falta, usa "
|
||||
"comando_de_shell.")
|
||||
if al_ejecutar or self.al_ejecutar:
|
||||
(al_ejecutar or self.al_ejecutar)(accion.id, argumento)
|
||||
dicho, salida = self.haz(accion, argumento)
|
||||
return (salida or dicho)[:TOPE_SALIDA]
|
||||
|
||||
# ---------------------------------------------------------------- shell
|
||||
|
||||
def por_shell(self, comando: str, al_ejecutar=None) -> str:
|
||||
if not comando.strip():
|
||||
return "no me has dicho ningun comando"
|
||||
bajo = comando.lower()
|
||||
for veneno in PROHIBIDO:
|
||||
if veneno in bajo:
|
||||
return (f"no ejecuto eso: contiene '{veneno}'. Si de verdad hace "
|
||||
"falta, que lo pida el usuario expresamente.")
|
||||
if al_ejecutar or self.al_ejecutar:
|
||||
(al_ejecutar or self.al_ejecutar)("shell", comando)
|
||||
|
||||
from manos.sudo_root import necesita_root
|
||||
# Los comandos con sudo van por GestorRoot, que da la contraseña de la
|
||||
# sesion por stdin. Sin ella, `bash -c "sudo ..."` se colgaria pidiendo
|
||||
# una contraseña que nadie teclea. Y si el candado no se ha abierto, no
|
||||
# hay contraseña: el root queda cerrado hasta que el usuario desbloquee.
|
||||
if necesita_root(comando):
|
||||
if not self.root.sesion_activa():
|
||||
return ("eso necesita root y el candado no esta abierto. "
|
||||
"Desbloquee con su contraseña y lo repito.")
|
||||
try:
|
||||
r = self.root.ejecuta(comando, timeout=60)
|
||||
except Exception as e:
|
||||
return f"no se pudo ejecutar como root: {e}"
|
||||
else:
|
||||
try:
|
||||
r = subprocess.run(["bash", "-c", comando], capture_output=True,
|
||||
text=True, timeout=60)
|
||||
except subprocess.TimeoutExpired:
|
||||
return "el comando tardo mas de un minuto y lo he cortado"
|
||||
except OSError as e:
|
||||
return f"no se pudo ejecutar: {e}"
|
||||
|
||||
salida = (r.stdout or r.stderr or "").strip()
|
||||
if not salida:
|
||||
return "hecho, sin salida" if r.returncode == 0 else f"fallo (codigo {r.returncode})"
|
||||
return salida[:TOPE_SALIDA]
|
||||
310
nucleo/manos/acciones.py
Normal file
310
nucleo/manos/acciones.py
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
"""Acciones rapidas: caminos predefinidos que se saltan el modelo.
|
||||
|
||||
Por que existe esto. Preguntar "cuanto espacio queda" y esperar a que un modelo
|
||||
de 4B razone, decida llamar a una herramienta, la ejecute y redacte la
|
||||
respuesta cuesta segundos. Pero esa pregunta no necesita pensarse: es un `df`.
|
||||
El catalogo empareja la frase con un comando y contesta con una plantilla, y
|
||||
eso baja de segundos a decimas.
|
||||
|
||||
No es entrenar un modelo: es una tabla. Y esa es la gracia, porque una tabla es
|
||||
predecible, se lee, se corrige y no alucina. Lo que no encaje en la tabla sigue
|
||||
yendo al modelo como siempre.
|
||||
|
||||
Formato del catalogo (JSON):
|
||||
|
||||
{
|
||||
"acciones": [
|
||||
{
|
||||
"id": "disco_libre",
|
||||
"frases": ["cuanto espacio queda", "espacio en disco"],
|
||||
"acuse": "Un momento, lo miro.",
|
||||
"comando": "df -h / | tail -1",
|
||||
"host": true,
|
||||
"respuesta": "Quedan {campo4} libres de {campo2}.",
|
||||
"confirmar": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
En `respuesta` se puede usar:
|
||||
{salida} la salida completa, recortada
|
||||
{primera} la primera linea
|
||||
{ultima} la ultima linea no vacia
|
||||
{campoN} la columna N de la ultima linea (1 en adelante)
|
||||
{lineas} cuantas lineas ha devuelto
|
||||
"""
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import unicodedata
|
||||
|
||||
UMBRAL_PARECIDO = 0.86 # por debajo empieza a confundir ordenes
|
||||
TIEMPO_LIMITE = 20 # ningun atajo deberia tardar mas
|
||||
SALIDA_MAXIMA = 600 # lo que se guarda para enseñar y para la plantilla
|
||||
|
||||
|
||||
def normaliza(texto: str) -> str:
|
||||
"""Minusculas, sin tildes, sin puntuacion y con los espacios apretados.
|
||||
|
||||
Whisper devuelve "¿Cuánto espacio queda?" y el catalogo dice "cuanto
|
||||
espacio queda". Sin normalizar no casaria ni una.
|
||||
|
||||
Lo de apretar los espacios no es cosmetico: al sustituir la puntuacion por
|
||||
un espacio, "cuanto, espacio queda" quedaba con dos seguidos y dejaba de
|
||||
coincidir con la frase del catalogo. El sintoma era un asistente que a
|
||||
veces no reacciona a una orden que ha entendido bien, segun donde el
|
||||
reconocedor decida poner una coma.
|
||||
"""
|
||||
texto = unicodedata.normalize("NFD", texto.lower())
|
||||
texto = "".join(c for c in texto if unicodedata.category(c) != "Mn")
|
||||
return " ".join(re.sub(r"[^\w\s]", " ", texto).split())
|
||||
|
||||
|
||||
# El envoltorio de cortesia con el que habla cualquiera. Nadie dice "pon la
|
||||
# pelicula" a secas: dice "necesito que pongas la pelicula" o "puedes ponerme".
|
||||
# Con eso delante, ninguna frase del catalogo encaja como subcadena y la orden
|
||||
# se pierde entera.
|
||||
#
|
||||
# Medido sobre las 462 frases que el panel anoto por no entender: de las 18 que
|
||||
# eran ordenes de verdad, la mayoria fallaba solo por esto. Cinco de ellas eran
|
||||
# el mismo intento de poner una pelicula, dicho de cinco maneras.
|
||||
CORTESIA = re.compile(
|
||||
r"^(por favor,?\s+)?"
|
||||
r"(necesito que( me)?|quiero que( me)?|me gustaria que( me)?|"
|
||||
r"puedes|podrias|me puedes|me podrias|te importa|hazme el favor de|"
|
||||
r"a ver si|va(le|)?,? |anda,? |porfa,? |quiero|necesito)\s+",
|
||||
re.I)
|
||||
|
||||
|
||||
class Accion:
|
||||
def __init__(self, datos: dict):
|
||||
self.id = datos.get("id", "")
|
||||
self.frases = [normaliza(f) for f in datos.get("frases", [])]
|
||||
self.acuse = datos.get("acuse", "Voy.")
|
||||
self.comando = datos.get("comando", "")
|
||||
self.host = bool(datos.get("host", True))
|
||||
self.respuesta = datos.get("respuesta", "{salida}")
|
||||
self.confirmar = bool(datos.get("confirmar", False))
|
||||
self.captura = bool(datos.get("captura", False))
|
||||
self.grupo = datos.get("grupo", "")
|
||||
|
||||
def coincide(self, texto: str):
|
||||
"""(longitud de la frase que encaja, lo que venia detras).
|
||||
|
||||
Se devuelve la longitud y no un si/no para poder quedarse con la
|
||||
coincidencia mas especifica: "abre la carpeta de descargas" debe ganar
|
||||
a "abre la carpeta" cuando las dos encajan.
|
||||
|
||||
El resto es el argumento: en "busca gatos en youtube" la frase es
|
||||
"busca" ... y lo demas es lo que hay que buscar. Solo se recoge si la
|
||||
accion lo pide, para que una frase suelta no arrastre basura.
|
||||
"""
|
||||
mejor, resto = 0, ""
|
||||
for f in self.frases:
|
||||
if not f or f not in texto:
|
||||
continue
|
||||
if len(f) > mejor:
|
||||
mejor = len(f)
|
||||
resto = texto.split(f, 1)[1].strip() if self.captura else ""
|
||||
return mejor, resto
|
||||
|
||||
|
||||
def parecido(a: str, b: str) -> float:
|
||||
"""Cuanto se parecen dos cadenas, de 0 a 1, por letra y por sonido.
|
||||
|
||||
Para los casi-aciertos del reconocedor: "yo tuve" por "yo tube", "casis"
|
||||
por "oasis". Se usa difflib y no una distancia de edicion propia porque
|
||||
viene en la biblioteca estandar y aqui no hay que hilar tan fino.
|
||||
|
||||
Se queda con el MAYOR de los dos parecidos, el de la letra y el del sonido.
|
||||
Muchos fallos del reconocedor no son de significado sino de ortografia
|
||||
—"Abre van Kamp" por "abre bandcamp", "poops" por "pubs"— y por escrito
|
||||
quedan lejos aunque suenen igual. Medido contra el banco: sube el acierto
|
||||
de 79,3 a 84,7 % sin que ninguna de las 50 frases de charla dispare nada.
|
||||
|
||||
Que sea el maximo y no la media importa: la media hundiria los aciertos
|
||||
normales, donde la letra ya casaba bien y el sonido no aporta.
|
||||
"""
|
||||
literal = difflib.SequenceMatcher(None, a, b).ratio()
|
||||
if literal >= 0.98:
|
||||
return literal # ya casa: no hace falta el sonido
|
||||
from .fonetica import fonetica
|
||||
return max(literal,
|
||||
difflib.SequenceMatcher(None, fonetica(a), fonetica(b)).ratio())
|
||||
|
||||
|
||||
class Catalogo:
|
||||
def __init__(self, rutas):
|
||||
self.acciones = []
|
||||
self.errores = []
|
||||
for ruta in rutas:
|
||||
if ruta and os.path.exists(ruta):
|
||||
self._carga(ruta)
|
||||
|
||||
def _carga(self, ruta):
|
||||
try:
|
||||
with open(ruta) as f:
|
||||
datos = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
self.errores.append(f"{os.path.basename(ruta)}: {e}")
|
||||
return
|
||||
|
||||
vistos = {a.id for a in self.acciones}
|
||||
for entrada in datos.get("acciones", []):
|
||||
accion = Accion(entrada)
|
||||
if not accion.id or not accion.frases:
|
||||
self.errores.append(f"{os.path.basename(ruta)}: accion sin id o sin frases")
|
||||
continue
|
||||
# el primero que se carga manda: asi el catalogo del usuario puede
|
||||
# redefinir uno del paquete sin tener que borrarlo
|
||||
if accion.id not in vistos:
|
||||
self.acciones.append(accion)
|
||||
vistos.add(accion.id)
|
||||
|
||||
def busca(self, texto: str):
|
||||
"""(accion mas especifica que encaje, argumento) o (None, "").
|
||||
|
||||
Si no encaja nada, se reintenta quitando la cortesia de delante. Se
|
||||
hace como SEGUNDA pasada y no normalizando desde el principio a
|
||||
proposito: asi lo que ya funcionaba sigue funcionando igual, y esto
|
||||
solo añade aciertos donde antes no habia ninguno.
|
||||
"""
|
||||
encontrada, argumento = self._busca_directa(normaliza(texto))
|
||||
if encontrada is not None:
|
||||
return encontrada, argumento
|
||||
sin_cortesia = CORTESIA.sub("", normaliza(texto)).strip()
|
||||
if sin_cortesia and sin_cortesia != normaliza(texto):
|
||||
return self._busca_directa(sin_cortesia)
|
||||
return None, ""
|
||||
|
||||
def _busca_directa(self, texto: str):
|
||||
mejor, puntos, argumento = None, 0, ""
|
||||
for accion in self.acciones:
|
||||
p, resto = accion.coincide(texto)
|
||||
if p > puntos:
|
||||
mejor, puntos, argumento = accion, p, resto
|
||||
|
||||
# Nada exacto: se prueba por parecido. El reconocedor se come letras
|
||||
# ("yo tuve" por "yo tube") y sin esto la orden se pierde entera. El
|
||||
# umbral es alto a proposito: mas abajo empieza a confundir ordenes
|
||||
# distintas entre si, que es peor que no reaccionar.
|
||||
if mejor is None:
|
||||
self._resto_parecido = ""
|
||||
mejor, puntos = self._por_parecido(texto)
|
||||
if mejor is not None and mejor.captura:
|
||||
argumento = self._resto_parecido
|
||||
|
||||
# una accion que captura y no recibe nada no sirve: "busca" a secas
|
||||
if mejor is not None and mejor.captura and not argumento:
|
||||
return None, ""
|
||||
return mejor, argumento
|
||||
|
||||
def _por_parecido(self, texto: str, umbral: float = UMBRAL_PARECIDO):
|
||||
"""La accion cuya frase mas se parezca, si pasa del umbral."""
|
||||
palabras = texto.split()
|
||||
mejor, mejor_p = None, umbral
|
||||
for accion in self.acciones:
|
||||
if accion.captura:
|
||||
# Con argumento no vale comparar la frase entera: "busca en
|
||||
# youtube conciertos de techno" no se parece a "busca en
|
||||
# youtube". Se compara solo el arranque y lo demas es el
|
||||
# argumento. Se exigen tres palabras para que un disparador
|
||||
# corto no se coma media conversacion.
|
||||
for frase in accion.frases:
|
||||
trozos = frase.split()
|
||||
# Dos palabras, no tres. Con tres, disparadores cortos como
|
||||
# "receta de" no entraban NUNCA en esta fase, y "receta de
|
||||
# documentos" mal oido se perdia entero. El riesgo de bajar
|
||||
# a dos lo cubre que la comparacion va anclada al principio:
|
||||
# solo encaja si el arranque suena a la frase.
|
||||
if len(trozos) < 2 or len(palabras) <= len(trozos):
|
||||
continue
|
||||
arranque = " ".join(palabras[:len(trozos)])
|
||||
p = parecido(arranque, frase)
|
||||
if p > mejor_p:
|
||||
mejor, mejor_p = accion, p
|
||||
self._resto_parecido = " ".join(palabras[len(trozos):])
|
||||
continue
|
||||
|
||||
# Frase ENTERA contra frase entera, no por ventana deslizante. La
|
||||
# ventana encontraba un sub-trozo que puntuaba alto aunque la orden
|
||||
# fuera otra: "que carne el tiempo" (mal oido de "que kernel tengo")
|
||||
# colaba en la accion del tiempo con 0.90, y se ejecutaba la accion
|
||||
# equivocada —lo peor que puede pasar en algo que lanza comandos—.
|
||||
# Una orden se dice completa; si va incrustada en una frase larga,
|
||||
# el emparejador exacto (por subcadena) ya la coge antes de llegar
|
||||
# aqui. Comparar el todo con el todo deja fuera esos falsos
|
||||
# positivos sin perder los casi-aciertos reales.
|
||||
propio = " ".join(palabras)
|
||||
for frase in accion.frases:
|
||||
if not frase:
|
||||
continue
|
||||
p = parecido(propio, frase)
|
||||
if p > mejor_p:
|
||||
mejor, mejor_p = accion, p
|
||||
return mejor, mejor_p
|
||||
|
||||
def __len__(self):
|
||||
return len(self.acciones)
|
||||
|
||||
|
||||
def ejecuta(accion: Accion, prefijo_host, argumento: str = "") -> tuple:
|
||||
"""Corre el comando y devuelve (texto para decir, salida cruda).
|
||||
|
||||
prefijo_host es lo que hay que anteponer para salir del sandbox; se pasa
|
||||
como argumento en vez de importarlo para poder probar esto sin flatpak.
|
||||
"""
|
||||
if not accion.comando:
|
||||
return accion.acuse, ""
|
||||
|
||||
# El argumento viene de un microfono, o sea de fuera: va entrecomillado con
|
||||
# shlex antes de tocar un shell. Sin esto, decir "borra punto y coma rm"
|
||||
# seria una orden ejecutable.
|
||||
comando = accion.comando.replace("{argumento}", shlex.quote(argumento))
|
||||
comando = comando.replace("{argumento_url}",
|
||||
shlex.quote(urllib.parse.quote_plus(argumento)))
|
||||
|
||||
orden = ["bash", "-lc", comando]
|
||||
if accion.host:
|
||||
orden = list(prefijo_host) + orden
|
||||
|
||||
try:
|
||||
# errors="replace": un grep o un cat que toque un binario devuelve
|
||||
# bytes que no son utf-8, y sin esto la orden entera reventaria con
|
||||
# UnicodeDecodeError. Mejor un caracter raro que un fallo.
|
||||
r = subprocess.run(orden, capture_output=True, text=True,
|
||||
errors="replace", timeout=TIEMPO_LIMITE)
|
||||
salida = (r.stdout or r.stderr or "").strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
return "La orden ha tardado demasiado y la he cancelado.", ""
|
||||
except OSError as e:
|
||||
return f"No he podido ejecutarlo: {e}", ""
|
||||
|
||||
return formatea(accion.respuesta, salida, argumento), salida[:SALIDA_MAXIMA]
|
||||
|
||||
|
||||
def formatea(plantilla: str, salida: str, argumento: str = "") -> str:
|
||||
lineas = [l for l in salida.splitlines() if l.strip()]
|
||||
ultima = lineas[-1] if lineas else ""
|
||||
campos = ultima.split()
|
||||
|
||||
valores = {
|
||||
"argumento": argumento,
|
||||
"salida": salida[:SALIDA_MAXIMA] if salida else "sin resultado",
|
||||
"primera": lineas[0] if lineas else "",
|
||||
"ultima": ultima,
|
||||
"lineas": len(lineas),
|
||||
}
|
||||
for i, campo in enumerate(campos, start=1):
|
||||
valores[f"campo{i}"] = campo
|
||||
|
||||
try:
|
||||
return plantilla.format(**valores)
|
||||
except (KeyError, IndexError, ValueError):
|
||||
# plantilla que pide un campo que no existe: mejor decir la salida
|
||||
# cruda que soltar una excepcion en mitad de una conversacion
|
||||
return valores["salida"]
|
||||
123
nucleo/manos/diario.py
Normal file
123
nucleo/manos/diario.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Lo que JARVIS oye y que hace con ello. El combustible de todo lo demas.
|
||||
|
||||
Sin esto no hay forma de mejorar nada: no se puede arreglar lo que no se mide,
|
||||
y hasta ahora el nucleo no apuntaba absolutamente nada.
|
||||
|
||||
## Por que este y no el de antes
|
||||
|
||||
El diario de la epoca de Newelle guardaba `{"texto": "..."}` y nada mas. Con eso
|
||||
no se puede hacer casi nada:
|
||||
|
||||
- no se sabe **que paso** con la frase, asi que no se distingue una orden que
|
||||
fallo de una que funciono;
|
||||
- no se sabe **cuando**, asi que no se ve si alguien repitio la misma cosa de
|
||||
tres formas seguidas —que es la señal mas valiosa que hay—;
|
||||
- y solo guardaba los FALLOS, asi que no habia con que comparar.
|
||||
|
||||
Ademas se llenaba de eco y de alucinaciones de Whisper, y la mejora nocturna
|
||||
acabo generando alias a partir del ruido. De 504 frases, 306 parecian ordenes
|
||||
sin atender y al mirarlas casi todas eran JARVIS oyendose a si mismo.
|
||||
|
||||
## Que se guarda
|
||||
|
||||
Todo lo que llega, con su destino. Una linea por frase, JSON:
|
||||
|
||||
t cuando
|
||||
oido lo que transcribio Whisper
|
||||
via catalogo | cerebro | ruido | bloqueado | respuesta
|
||||
accion a que accion fue, si fue a alguna
|
||||
arg con que argumento
|
||||
seg lo que tardo en contestar
|
||||
dicho lo que contesto
|
||||
|
||||
Guardar TAMBIEN los aciertos es lo que permite comparar: sin ellos, un dia malo
|
||||
y un dia bueno tienen el mismo aspecto.
|
||||
|
||||
## Lo que NO se guarda
|
||||
|
||||
Nada de audio. El diario es texto y se puede leer con `less`; si algun dia
|
||||
guardara los WAV, el asistente pasaria a ser una grabadora de la habitacion y
|
||||
eso es otra cosa muy distinta de lo que es.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DIARIO = os.path.join(AQUI, "datos", "diario.jsonl")
|
||||
|
||||
# Un diario que crece sin fin acaba siendo un problema en vez de una ayuda. A
|
||||
# ~150 bytes por linea, 200.000 son unos 30 MB: mas que suficiente para meses de
|
||||
# uso y poco para el disco.
|
||||
TOPE_LINEAS = 200_000
|
||||
|
||||
_cerrojo = threading.Lock()
|
||||
|
||||
|
||||
def anota(oido: str, via: str, accion: str = "", arg: str = "",
|
||||
seg: float = 0.0, dicho: str = "", ruta: str = DIARIO):
|
||||
"""Apunta una frase y su destino. No falla nunca hacia fuera.
|
||||
|
||||
Si escribir el diario diera error, lo ultimo que debe hacer es tirar al
|
||||
asistente: se pierde una linea y ya.
|
||||
"""
|
||||
if not oido:
|
||||
return
|
||||
linea = {
|
||||
"t": datetime.now().isoformat(timespec="seconds"),
|
||||
"oido": oido[:400],
|
||||
"via": via,
|
||||
}
|
||||
if accion:
|
||||
linea["accion"] = accion
|
||||
if arg:
|
||||
linea["arg"] = arg[:200]
|
||||
if seg:
|
||||
linea["seg"] = round(seg, 2)
|
||||
if dicho:
|
||||
linea["dicho"] = dicho[:300]
|
||||
|
||||
try:
|
||||
with _cerrojo:
|
||||
os.makedirs(os.path.dirname(ruta), exist_ok=True)
|
||||
with open(ruta, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(linea, ensure_ascii=False) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def lee(ruta: str = DIARIO, desde=None) -> list:
|
||||
"""Las lineas del diario, ya en diccionarios. Salta las corruptas."""
|
||||
salida = []
|
||||
try:
|
||||
with open(ruta, encoding="utf-8") as f:
|
||||
for l in f:
|
||||
try:
|
||||
d = json.loads(l)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if desde and d.get("t", "") < desde:
|
||||
continue
|
||||
salida.append(d)
|
||||
except OSError:
|
||||
pass
|
||||
return salida
|
||||
|
||||
|
||||
def recorta(ruta: str = DIARIO, tope: int = TOPE_LINEAS):
|
||||
"""Deja solo las ultimas `tope` lineas."""
|
||||
lineas = []
|
||||
try:
|
||||
with open(ruta, encoding="utf-8") as f:
|
||||
lineas = f.readlines()
|
||||
except OSError:
|
||||
return
|
||||
if len(lineas) <= tope:
|
||||
return
|
||||
try:
|
||||
with _cerrojo, open(ruta, "w", encoding="utf-8") as f:
|
||||
f.writelines(lineas[-tope:])
|
||||
except OSError:
|
||||
pass
|
||||
84
nucleo/manos/fonetica.py
Normal file
84
nucleo/manos/fonetica.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Comparar por como SUENA, no por como se escribe.
|
||||
|
||||
## Que problema resuelve
|
||||
|
||||
Midiendo donde se pierde el acierto salio esto: de 150 ordenes dichas, el 79 %
|
||||
llega a su accion, un 5 % se oye mal de verdad, y un **15 % se oye bien pero el
|
||||
emparejador no lo relaciona**. Mirando esos casos uno a uno, no son ordenes
|
||||
distintas: son la misma orden mal escrita.
|
||||
|
||||
dijo "receta de documentos" oyo "Leceta de documentos"
|
||||
dijo "como van los pubs" oyo "¿Como van los poops?"
|
||||
dijo "abre bandcamp" oyo "Abre van Kamp"
|
||||
dijo "abre el drive" oyo "Abril Drive"
|
||||
dijo "abreme oasis" oyo "Abre mi roasis"
|
||||
|
||||
Eso NO se arregla con embeddings: un modelo semantico ve "Leceta" como una
|
||||
palabra inexistente, no como "receta". Y bajar el umbral de parecido tampoco,
|
||||
porque el umbral esta alto por una razon —hay una cicatriz en el codigo de
|
||||
cuando "que carne el tiempo" se colaba en la accion del tiempo y ejecutaba lo
|
||||
que no era—.
|
||||
|
||||
Lo que sirve es comparar como suena. En castellano hay unas pocas confusiones
|
||||
que lo explican casi todo, y son las que aplica `fonetica()`.
|
||||
|
||||
## Lo que NO hace
|
||||
|
||||
No es un Soundex ni un Metaphone: esos comprimen tanto que juntan palabras que
|
||||
no se parecen, y aqui juntar de mas significa ejecutar la orden equivocada, que
|
||||
es lo peor que puede hacer esto. Se queda en las equivalencias que de verdad
|
||||
confunde el reconocedor de voz en castellano, y nada mas.
|
||||
"""
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
# El orden importa: cada regla se aplica sobre el resultado de la anterior.
|
||||
REGLAS = (
|
||||
# la hache no suena
|
||||
(r"h", ""),
|
||||
# b y v son el mismo sonido: "van Kamp" / "bandcamp"
|
||||
(r"v", "b"),
|
||||
# ge/gi suenan como jota
|
||||
(r"g([ei])", r"j\1"),
|
||||
# gue/gui: la u es muda
|
||||
(r"gu([ei])", r"g\1"),
|
||||
# que/qui igual, y qu -> k
|
||||
(r"qu([ei])", r"k\1"),
|
||||
(r"q", "k"),
|
||||
# ce/ci en seseo suenan como ese; ca/co/cu como ka
|
||||
(r"c([ei])", r"s\1"),
|
||||
(r"c", "k"),
|
||||
# z siempre ese (seseo): asi "poops"/"pubs" y "haz"/"has" caen juntos
|
||||
(r"z", "s"),
|
||||
# x entre vocales suena ks, pero al principio suele ser ese
|
||||
(r"^x", "s"),
|
||||
(r"x", "ks"),
|
||||
# ll e y son el mismo sonido para casi todo el mundo
|
||||
(r"ll", "y"),
|
||||
# w es de fuera: suena como u o como b segun la palabra; se unifica a b
|
||||
(r"w", "b"),
|
||||
# la erre doble y la simple no se distinguen al transcribir
|
||||
(r"rr", "r"),
|
||||
# la ñ se escribe de mil formas cuando el reconocedor duda
|
||||
(r"ñ", "n"),
|
||||
# las dobles no aportan: "poops" -> "pops"
|
||||
(r"(.)\1+", r"\1"),
|
||||
# las vocales finales se comen mucho; y la ese final tambien
|
||||
(r"s$", ""),
|
||||
)
|
||||
|
||||
|
||||
def fonetica(texto: str) -> str:
|
||||
"""Como suena, aproximadamente, para poder comparar dos oidos distintos."""
|
||||
t = unicodedata.normalize("NFD", (texto or "").lower())
|
||||
t = "".join(c for c in t if unicodedata.category(c) != "Mn" or c == "̃")
|
||||
t = unicodedata.normalize("NFC", t)
|
||||
t = re.sub(r"[^a-zñ\s]", " ", t)
|
||||
|
||||
palabras = []
|
||||
for p in t.split():
|
||||
for patron, cambio in REGLAS:
|
||||
p = re.sub(patron, cambio, p)
|
||||
if p:
|
||||
palabras.append(p)
|
||||
return " ".join(palabras)
|
||||
115
nucleo/manos/medidas.py
Normal file
115
nucleo/manos/medidas.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Las medidas de eficiencia de JARVIS, sacadas del diario.
|
||||
|
||||
El diario ya apunta por cada frase: cuanto tardo (`seg`), por donde fue (`via`)
|
||||
y cuando (`t`). De ahi salen las metricas de rendimiento sin instrumentar nada
|
||||
del camino caliente: esto solo LEE, y se calcula fuera del hilo que contesta.
|
||||
|
||||
## Que se mide y por que importa cada una
|
||||
|
||||
latencia p50/p95/max del tiempo de respuesta. La mediana dice como va de
|
||||
normal; el p95 dice como va cuando va mal, que es lo que se nota.
|
||||
reparto que fraccion va al CEREBRO (lento, ~2-3 s) y cual al CATALOGO
|
||||
(instantaneo, salta el modelo). Es la palanca de eficiencia mas
|
||||
grande: cuanto mas catalogo, mas rapido el conjunto.
|
||||
ruido que fraccion de lo que oye es Whisper oyendose a si mismo. No es
|
||||
velocidad, es calidad de la escucha, y envenena todo lo demas.
|
||||
volumen frases en las ultimas 24 h. Contexto para lo anterior: un p95 malo
|
||||
con 4 frases no dice nada.
|
||||
rag hit@5/hit@1 del ultimo benchmark del RAG (informe_rag.txt). Es la
|
||||
unica que no sale del diario: es una medida de test, periodica.
|
||||
|
||||
Todo sobre una VENTANA reciente (las ultimas MUESTRA frases), no sobre el diario
|
||||
entero: interesa como va AHORA, no la media historica que nunca cambia.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from manos.diario import lee
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
INFORME_RAG = os.path.join(AQUI, "datos", "informe_rag.txt")
|
||||
|
||||
MUESTRA = 600 # las ultimas N frases: "como va ahora"
|
||||
CEREBRO = "cerebro"
|
||||
|
||||
|
||||
def _percentil(valores, q):
|
||||
if not valores:
|
||||
return 0.0
|
||||
v = sorted(valores)
|
||||
k = (len(v) - 1) * q
|
||||
lo, hi = int(k), min(int(k) + 1, len(v) - 1)
|
||||
return v[lo] + (v[hi] - v[lo]) * (k - lo)
|
||||
|
||||
|
||||
def _rag():
|
||||
"""hit@5 y hit@1 del ultimo informe del RAG, si existe."""
|
||||
try:
|
||||
txt = open(INFORME_RAG, encoding="utf-8").read()
|
||||
except OSError:
|
||||
return None
|
||||
# el bloque CANDIDATO es el que se promociono; si no, el ultimo que haya
|
||||
def pct(clave):
|
||||
# " hit@5 43/45 (95%)" -> 95
|
||||
ms = re.findall(clave + r"\s+\d+/\d+\s+\((\d+)%\)", txt)
|
||||
return int(ms[-1]) if ms else None
|
||||
h5, h1 = pct("hit@5"), pct("hit@1")
|
||||
if h5 is None:
|
||||
return None
|
||||
return {"hit5": h5, "hit1": h1 or 0}
|
||||
|
||||
|
||||
def lee_medidas():
|
||||
"""El dict de medidas, con las unidades ya listas para el HUD."""
|
||||
diario = lee()[-MUESTRA:]
|
||||
if not diario:
|
||||
return {"n": 0}
|
||||
|
||||
segs = [d["seg"] for d in diario if isinstance(d.get("seg"), (int, float)) and d["seg"] > 0]
|
||||
seg_cerebro = [d["seg"] for d in diario
|
||||
if d.get("via") == CEREBRO and isinstance(d.get("seg"), (int, float))]
|
||||
seg_catalogo = [d["seg"] for d in diario
|
||||
if d.get("via") == "catalogo" and isinstance(d.get("seg"), (int, float))]
|
||||
|
||||
vias = {}
|
||||
for d in diario:
|
||||
vias[d.get("via", "?")] = vias.get(d.get("via", "?"), 0) + 1
|
||||
total = len(diario)
|
||||
|
||||
def pct_via(v):
|
||||
return round(100 * vias.get(v, 0) / total) if total else 0
|
||||
|
||||
# volumen: cuantas en las ultimas 24 h, y por hora las ultimas 12 para la traza
|
||||
ahora = datetime.now()
|
||||
hace24 = ahora - timedelta(hours=24)
|
||||
vol24 = 0
|
||||
porhora = [0] * 12
|
||||
for d in diario:
|
||||
try:
|
||||
t = datetime.fromisoformat(d.get("t", ""))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if t >= hace24:
|
||||
vol24 += 1
|
||||
h = int((ahora - t).total_seconds() // 3600)
|
||||
if 0 <= h < 12:
|
||||
porhora[11 - h] += 1
|
||||
|
||||
medidas = {
|
||||
"n": total,
|
||||
"lat_p50": round(_percentil(segs, 0.50), 2),
|
||||
"lat_p95": round(_percentil(segs, 0.95), 2),
|
||||
"lat_max": round(max(segs), 2) if segs else 0.0,
|
||||
"lat_cerebro": round(sum(seg_cerebro) / len(seg_cerebro), 2) if seg_cerebro else 0.0,
|
||||
"lat_catalogo": round(sum(seg_catalogo) / len(seg_catalogo), 2) if seg_catalogo else 0.0,
|
||||
"pct_cerebro": pct_via("cerebro"),
|
||||
"pct_catalogo": pct_via("catalogo"),
|
||||
"pct_ruido": pct_via("ruido"),
|
||||
"vol24": vol24,
|
||||
"porhora": porhora,
|
||||
}
|
||||
rag = _rag()
|
||||
if rag:
|
||||
medidas["rag"] = rag
|
||||
return medidas
|
||||
147
nucleo/manos/preguntar.py
Normal file
147
nucleo/manos/preguntar.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""Que preguntar cuando falta un dato para ejecutar una accion.
|
||||
|
||||
Pulsar una accion en el panel no puede limitarse a recitar la frase que la
|
||||
dispara —"diga «busca el fichero» y a continuacion lo que busca"—: eso es un
|
||||
manual de instrucciones, y uno pulsa justo porque no quiere leerse el manual.
|
||||
Lo natural es que pregunte lo que falta y lo haga.
|
||||
|
||||
busca el fichero -> ¿Que fichero, señor?
|
||||
manda la ventana a la pantalla -> ¿A que pantalla, señor?
|
||||
mata el proceso -> ¿Que proceso, señor?
|
||||
|
||||
## Como sale la pregunta
|
||||
|
||||
Del propio catalogo, no de una lista escrita a mano ni de un modelo. Las frases
|
||||
del catalogo son regulares —verbo + articulo + objeto— asi que basta con buscar
|
||||
el ULTIMO articulo y quedarse con lo que va detras.
|
||||
|
||||
Y se pregunta por el OBJETO, no conjugando el verbo. "¿Que fichero, señor?" es
|
||||
tan natural como "¿Que fichero busco, señor?" y no obliga a conjugar: pasar de
|
||||
"busca" a "busco", de "leeme" a "leo" o de "pon" a "pongo" pide un diccionario
|
||||
de verbos irregulares para ganar nada.
|
||||
|
||||
Si la preposicion viene delante del articulo, se conserva: sin ella, "manda la
|
||||
ventana a la pantalla" preguntaria "¿Que pantalla?" y se pierde el "a", que es
|
||||
justo lo que dice que la respuesta es un destino.
|
||||
"""
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
ARTICULOS = {"el", "la", "los", "las", "un", "una", "unos", "unas"}
|
||||
# Las contracciones ya llevan la preposicion dentro.
|
||||
CONTRACCIONES = {"al": "a", "del": "de"}
|
||||
PREPOSICIONES = {"a", "de", "en", "con", "por", "para", "sobre", "hacia", "desde"}
|
||||
|
||||
# Cuando no hay de donde sacarlo. Vale para "esta corriendo" o "tengo instalado",
|
||||
# donde el objeto no aparece en la frase.
|
||||
GENERICA = "¿El que, señor?"
|
||||
|
||||
|
||||
def _sin_tildes(t: str) -> str:
|
||||
t = unicodedata.normalize("NFD", t)
|
||||
return "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||
|
||||
|
||||
def pregunta_por(accion) -> str:
|
||||
"""La pregunta que rellena el hueco de una accion que espera argumento.
|
||||
|
||||
La clave: el argumento va SIEMPRE al final de la frase, porque asi lo
|
||||
recoge el emparejador. Asi que la pregunta se decide mirando como TERMINA.
|
||||
"""
|
||||
frases = getattr(accion, "frases", None) or []
|
||||
if not frases:
|
||||
return GENERICA
|
||||
|
||||
palabras = _sin_tildes(frases[0].lower()).split()
|
||||
if not palabras:
|
||||
return GENERICA
|
||||
ultima = palabras[-1]
|
||||
|
||||
# 1. Termina en preposicion: el hueco va justo detras.
|
||||
# "busca el precio de" -> ¿El precio de que, señor?
|
||||
# "busca imagenes de" -> ¿Imagenes de que, señor?
|
||||
if ultima in PREPOSICIONES:
|
||||
trozo = []
|
||||
# Sin la primera palabra: es el verbo, y en la pregunta sobra.
|
||||
# "busca imagenes de" tiene que dar "¿Imagenes de que?", no
|
||||
# "¿Busca imagenes de que?".
|
||||
for p in reversed(palabras[1:-1]):
|
||||
if p in ARTICULOS or p in CONTRACCIONES:
|
||||
trozo.insert(0, p)
|
||||
break
|
||||
if p in PREPOSICIONES:
|
||||
break
|
||||
trozo.insert(0, p)
|
||||
if len(trozo) >= 2:
|
||||
break
|
||||
if trozo:
|
||||
frase = " ".join(trozo).capitalize()
|
||||
return f"¿{frase} {ultima} que, señor?"
|
||||
return GENERICA
|
||||
|
||||
# 2. "busca en <sitio>": lo que falta es QUE buscar, no el sitio.
|
||||
# Sin esta regla, "busca en la wikipedia" preguntaba "¿En que
|
||||
# wikipedia?", que no tiene sentido: solo hay una.
|
||||
if "en" in palabras:
|
||||
i = palabras.index("en")
|
||||
sitio = " ".join(palabras[i:])
|
||||
verbo = palabras[0]
|
||||
if verbo.startswith("busca"):
|
||||
return f"¿Que busco {sitio}, señor?"
|
||||
return f"¿Que {sitio}, señor?"
|
||||
|
||||
# 3. Lo normal: articulo + objeto al final.
|
||||
# "busca el fichero" -> ¿Que fichero, señor?
|
||||
# "manda la ventana a la pantalla" -> ¿A que pantalla, señor?
|
||||
for i in range(len(palabras) - 1, -1, -1):
|
||||
p = palabras[i]
|
||||
objeto = palabras[i + 1] if i + 1 < len(palabras) else ""
|
||||
if not objeto:
|
||||
continue
|
||||
if p in CONTRACCIONES:
|
||||
return f"¿{CONTRACCIONES[p].capitalize()} que {objeto}, señor?"
|
||||
if p in ARTICULOS:
|
||||
anterior = palabras[i - 1] if i > 0 else ""
|
||||
if anterior in PREPOSICIONES:
|
||||
return f"¿{anterior.capitalize()} que {objeto}, señor?"
|
||||
return f"¿Que {objeto}, señor?"
|
||||
return GENERICA
|
||||
|
||||
|
||||
def confirmacion(accion) -> str:
|
||||
"""Lo que se pregunta antes de algo que no tiene vuelta atras.
|
||||
|
||||
La orden se CITA en vez de meterla en la frase. Las del catalogo estan en
|
||||
imperativo —"cierra el navegador"— y encajarlas da cosas como "Voy a cierra
|
||||
el navegador". Conjugarlas al infinitivo pediria un diccionario de verbos
|
||||
irregulares (cierra->cerrar, pon->poner, ve->ir) para ganar poco: entre
|
||||
comillas se lee bien y ademas queda claro que es literalmente la orden.
|
||||
"""
|
||||
frase = (getattr(accion, "frases", None) or [""])[0]
|
||||
return f"«{frase}». Esto no tiene vuelta atras. ¿Confirma, señor?"
|
||||
|
||||
|
||||
# Lo que cuenta como si y como no. Se comparan sin tildes y sin puntuacion,
|
||||
# porque llegan de Whisper: "sí", "Si.", "SÍ" y "sip" son lo mismo.
|
||||
SI = {"si", "sip", "claro", "vale", "venga", "adelante", "confirmo", "hazlo",
|
||||
"por supuesto", "correcto", "afirmativo", "eso es", "dale", "ok", "okey"}
|
||||
NO = {"no", "nop", "nada", "dejalo", "olvidalo", "cancela", "cancelalo",
|
||||
"para", "mejor no", "negativo", "ni hablar", "espera"}
|
||||
|
||||
|
||||
def es_si(texto: str):
|
||||
"""True si es un si, False si es un no, None si no es ni una cosa ni otra."""
|
||||
limpio = _sin_tildes(re.sub(r"[¡!¿?.,;]", "", (texto or "").lower())).strip()
|
||||
if not limpio:
|
||||
return None
|
||||
if limpio in SI:
|
||||
return True
|
||||
if limpio in NO:
|
||||
return False
|
||||
# Tambien al principio: "si, hazlo" o "no, dejalo"
|
||||
primera = limpio.split()[0]
|
||||
if primera in SI:
|
||||
return True
|
||||
if primera in NO:
|
||||
return False
|
||||
return None
|
||||
95
nucleo/manos/seleccion.py
Normal file
95
nucleo/manos/seleccion.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Elegir de una lista por voz, y buscar dentro de un fichero (Ctrl+F).
|
||||
|
||||
La capa que le faltaba a las acciones de fichero. Buscar y soltar el primer
|
||||
resultado no sirve cuando hay varios: hace falta enseñar la lista y poder decir
|
||||
"abre el tercero". Y para buscar DENTRO de un fichero como con Ctrl+F, hace
|
||||
falta recordar cual era.
|
||||
|
||||
Aqui va la logica pura —interpretar "el tercero", detectar las ordenes de
|
||||
seleccion y de busqueda interna—; el estado (la ultima lista, el ultimo
|
||||
fichero) y la ejecucion viven en el panel.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Como se dice cada posicion. Se aceptan el ordinal, el numero en palabra y la
|
||||
# cifra, porque el reconocedor devuelve cualquiera de los tres.
|
||||
ORDINALES = {
|
||||
1: ["primero", "primera", "uno", "1"],
|
||||
2: ["segundo", "segunda", "dos", "2"],
|
||||
3: ["tercero", "tercera", "tres", "3"],
|
||||
4: ["cuarto", "cuarta", "cuatro", "4"],
|
||||
5: ["quinto", "quinta", "cinco", "5"],
|
||||
6: ["sexto", "sexta", "seis", "6"],
|
||||
7: ["septimo", "septima", "siete", "7"],
|
||||
8: ["octavo", "octava", "ocho", "8"],
|
||||
9: ["noveno", "novena", "nueve", "9"],
|
||||
10: ["decimo", "decima", "diez", "10"],
|
||||
}
|
||||
_PALABRA_A_N = {p: n for n, ps in ORDINALES.items() for p in ps}
|
||||
|
||||
# "el ultimo" es comodo y no es un numero
|
||||
_ULTIMO = {"ultimo", "ultima", "final"}
|
||||
|
||||
|
||||
def posicion(texto: str):
|
||||
"""Devuelve el numero (1..N) que se ha dicho, -1 para 'el ultimo', o None.
|
||||
|
||||
Se queda con la PRIMERA posicion mencionada, para que "abre el tercero"
|
||||
de 3 y no se lie con otros numeros que puedan venir detras.
|
||||
"""
|
||||
for palabra in texto.split():
|
||||
if palabra in _ULTIMO:
|
||||
return -1
|
||||
if palabra in _PALABRA_A_N:
|
||||
return _PALABRA_A_N[palabra]
|
||||
return None
|
||||
|
||||
|
||||
# Ordenes que actuan sobre la lista ya mostrada. El verbo dice que hacer con lo
|
||||
# elegido; la posicion, cual.
|
||||
_ABRIR = re.compile(r"\b(abre|abreme|abrir)\b")
|
||||
_LEER = re.compile(r"\b(lee|leeme|leer|muestrame|ensename)\b")
|
||||
_SELECCION = re.compile(r"\b(el|la|numero|opcion|resultado)\b")
|
||||
|
||||
|
||||
def orden_de_seleccion(texto: str):
|
||||
"""(accion, posicion) si el texto elige de la lista, o None.
|
||||
|
||||
accion: "abrir" o "leer". posicion: 1..N o -1 (ultimo).
|
||||
Ejemplos: "abre el tercero", "lee el numero dos", "el ultimo", "abre el 3".
|
||||
"""
|
||||
pos = posicion(texto)
|
||||
if pos is None:
|
||||
return None
|
||||
# tiene que sonar a seleccion, no a otra cosa que lleve un numero
|
||||
if not (_SELECCION.search(texto) or _ABRIR.search(texto) or _LEER.search(texto)):
|
||||
return None
|
||||
if _LEER.search(texto):
|
||||
return ("leer", pos)
|
||||
# por defecto, elegir es abrir
|
||||
return ("abrir", pos)
|
||||
|
||||
|
||||
# Ctrl+F: buscar un termino dentro del ultimo fichero abierto.
|
||||
_DENTRO = re.compile(
|
||||
r"\b(busca|buscame|encuentra)\b.*\b(dentro|en el fichero|en el archivo|en el texto)\b")
|
||||
|
||||
|
||||
def orden_buscar_dentro(texto: str):
|
||||
"""El termino a buscar dentro del fichero, o None.
|
||||
|
||||
"busca dentro configuracion", "busca en el texto la palabra error".
|
||||
Se queda con lo que va despues de 'dentro'/'en el ...'.
|
||||
"""
|
||||
if not _DENTRO.search(texto):
|
||||
return None
|
||||
# cortar por el separador y quedarse con el resto
|
||||
for sep in ("dentro de", "dentro", "en el fichero", "en el archivo", "en el texto",
|
||||
"la palabra"):
|
||||
if sep in texto:
|
||||
resto = texto.split(sep, 1)[1].strip()
|
||||
resto = re.sub(r"^(la palabra|el termino|de|la|el|los|las)\s+",
|
||||
"", resto).strip()
|
||||
if resto:
|
||||
return resto
|
||||
return None
|
||||
134
nucleo/manos/sudo_root.py
Normal file
134
nucleo/manos/sudo_root.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""Ejecutar ordenes como root de forma controlada.
|
||||
|
||||
El problema que resuelve. Newelle convierte `sudo` en `sudo -S` (leer la
|
||||
contraseña de la entrada estandar), pero en el panel de voz no hay una entrada
|
||||
donde teclearla, asi que se queda colgado esperando una contraseña que no
|
||||
llega. Y las acciones marcadas para confirmar no tenian dialogo: solo decian
|
||||
"confirme antes" y no ejecutaban nada.
|
||||
|
||||
Como se resuelve, y las decisiones de seguridad detras:
|
||||
|
||||
- La contraseña NUNCA se escribe en disco. Vive en memoria de este proceso solo
|
||||
si el usuario activa el permiso de sesion, y se pasa a `sudo -S` por stdin.
|
||||
- El permiso de sesion CADUCA: a los 15 minutos de la ultima orden se olvida
|
||||
sola, y tambien al cerrar el panel. No es un "root para siempre".
|
||||
- CADA orden con root muestra su comando exacto en un dialogo antes de correr.
|
||||
El permiso de sesion solo ahorra re-teclear la contraseña, nunca salta el ver
|
||||
y aprobar lo que se va a ejecutar. La voz no puede autoaprobar root.
|
||||
|
||||
Alternativa descartada: apoyarse solo en el timestamp de sudo (`sudo -v`). Es
|
||||
mas limpio en teoria —lo gestiona el sistema— pero a traves de flatpak-spawn el
|
||||
tty cambia entre llamadas y el cacheo no es fiable. Mantener la contraseña en
|
||||
memoria del proceso, con caducidad y sin tocar disco, funciona siempre y el
|
||||
usuario opta a ello de forma explicita.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
# A los 15 minutos sin usarla, la contraseña en memoria se olvida. Igual que el
|
||||
# valor por defecto de sudo, para no sorprender.
|
||||
CADUCIDAD = 15 * 60
|
||||
|
||||
# Detecta una invocacion real de sudo: al principio del comando o justo tras un
|
||||
# separador (;, |, &, &&, ||, salto de linea), no la palabra "sudo" dicha como
|
||||
# argumento ("echo sudo no cuenta"). Errar aqui solo pediria una contraseña de
|
||||
# mas, no es peligroso, pero molesta.
|
||||
_SUDO = re.compile(r"(?:^|[;&|\n]|&&|\|\|)\s*sudo(?:\s|$)")
|
||||
|
||||
|
||||
def necesita_root(comando: str) -> bool:
|
||||
"""True si el comando lanza algo con sudo."""
|
||||
return bool(comando and _SUDO.search(comando))
|
||||
|
||||
|
||||
class GestorRoot:
|
||||
"""Guarda —o no— la contraseña de sesion, y ejecuta con ella.
|
||||
|
||||
Una instancia por panel. Al cerrar el panel se llama a olvida().
|
||||
"""
|
||||
|
||||
def __init__(self, prefijo_host=None):
|
||||
# prefijo para salir del sandbox; se pasa como argumento para poder
|
||||
# probar esto sin flatpak
|
||||
self._prefijo = list(prefijo_host or [])
|
||||
self._password = None
|
||||
self._ultimo_uso = 0.0
|
||||
|
||||
# -------------------------------------------------------------- sesion
|
||||
|
||||
def sesion_activa(self) -> bool:
|
||||
"""Hay contraseña recordada y aun no ha caducado."""
|
||||
if self._password is None:
|
||||
return False
|
||||
if time.monotonic() - self._ultimo_uso > CADUCIDAD:
|
||||
self.olvida()
|
||||
return False
|
||||
return True
|
||||
|
||||
def olvida(self):
|
||||
"""Borra la contraseña de memoria. Se llama al cerrar el panel y al
|
||||
caducar. Se sobreescribe antes de soltarla, por si acaso."""
|
||||
if self._password is not None:
|
||||
self._password = "\x00" * len(self._password)
|
||||
self._password = None
|
||||
self._ultimo_uso = 0.0
|
||||
|
||||
def recuerda(self, password: str):
|
||||
self._password = password
|
||||
self._ultimo_uso = time.monotonic()
|
||||
|
||||
# ------------------------------------------------------------ ejecutar
|
||||
|
||||
def valida(self, password: str) -> bool:
|
||||
"""Comprueba la contraseña con `sudo -S -v`, sin ejecutar nada mas.
|
||||
|
||||
-k borra cualquier timestamp previo para que la comprobacion sea de
|
||||
verdad; -v solo actualiza credenciales. Si la contraseña es correcta
|
||||
devuelve True; nunca lanza ningun comando del usuario.
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
self._prefijo + ["sudo", "-S", "-k", "-p", "", "-v"],
|
||||
input=password + "\n", capture_output=True, text=True,
|
||||
timeout=10)
|
||||
return r.returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
def ejecuta(self, comando: str, password: str = None, timeout: int = 30):
|
||||
"""Ejecuta el comando dando la contraseña a los sudo que lleve.
|
||||
|
||||
password: si se pasa, se usa esa vez; si no, la de la sesion. La
|
||||
contraseña se pasa por stdin a `sudo -S`. No se registra en ningun
|
||||
sitio.
|
||||
|
||||
Devuelve (salida, codigo). codigo -1 = no habia contraseña disponible.
|
||||
"""
|
||||
clave = password if password is not None else self._password
|
||||
if clave is None:
|
||||
return "No hay contraseña disponible para root.", -1
|
||||
|
||||
# que todos los sudo del comando lean de stdin sin imprimir el prompt
|
||||
preparado = re.sub(r"(^|[;&|]|\s)sudo(\s)",
|
||||
r"\1sudo -S -p '' \2", comando)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
self._prefijo + ["bash", "-lc", preparado],
|
||||
input=clave + "\n", capture_output=True, text=True,
|
||||
timeout=timeout)
|
||||
salida = (r.stdout or r.stderr or "").strip()
|
||||
# sudo escribe su prompt/errores en stderr aunque -p sea vacio;
|
||||
# se limpia lo que es ruido de sudo, no del comando
|
||||
salida = "\n".join(
|
||||
l for l in salida.splitlines()
|
||||
if l.strip() and not l.startswith(("[sudo]", "Sorry, try again")))
|
||||
self._ultimo_uso = time.monotonic()
|
||||
return salida, r.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
return "La orden con root ha tardado demasiado.", 124
|
||||
except OSError as e:
|
||||
return f"No se pudo ejecutar: {e}", 1
|
||||
finally:
|
||||
# no dejar la copia local viva mas de lo necesario
|
||||
clave = "\x00" * len(clave)
|
||||
346
nucleo/manos/tareas.py
Normal file
346
nucleo/manos/tareas.py
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
"""Tareas en paralelo: cada orden corre por su cuenta y se puede parar.
|
||||
|
||||
Hasta ahora una orden era una llamada a `ejecuta()`: un `subprocess.run` con
|
||||
veinte segundos de limite que bloqueaba el hilo hasta terminar. Servia porque
|
||||
todos los atajos eran instantaneos, pero deja fuera justo lo que uno querria
|
||||
mandar y olvidarse: un escaneo, una busqueda por todo el disco, una descarga.
|
||||
Y no habia forma de arrepentirse a mitad.
|
||||
|
||||
Aqui cada orden es una Tarea con nombre, estado y un proceso propio del que se
|
||||
guarda el grupo, para poder matarlo entero. Tres cosas que costaron decidir:
|
||||
|
||||
- **Se mata el grupo, no el proceso.** El comando real es `bash -lc "..."`, asi
|
||||
que matar el bash deja vivos a sus hijos —el `find`, el `nmap`— corriendo y
|
||||
comiendo disco con la tarjeta ya cerrada. Con `start_new_session=True` el
|
||||
proceso estrena grupo y `killpg` se los lleva a todos.
|
||||
|
||||
- **Fuera del sandbox tambien vale.** Comprobado: matar el `flatpak-spawn`
|
||||
intermediario propaga la señal al proceso del host (2 procesos vivos -> 0).
|
||||
No hace falta perseguir el PID del otro lado.
|
||||
|
||||
- **La salida se lee segun sale, no al final.** Con `run()` no hay nada que
|
||||
enseñar hasta que termina, y una tarjeta de un escaneo de tres minutos que
|
||||
esta en blanco los tres minutos no informa de nada. Se lee linea a linea y se
|
||||
guardan las ultimas, que es lo que cabe en la tarjeta.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
# Lo que se guarda de la salida para enseñar en la tarjeta y para la plantilla
|
||||
# de respuesta. Una tarjeta pequeña no da para mas, y guardar la salida entera
|
||||
# de un `find /` es meterse megas en memoria por nada.
|
||||
LINEAS_VISTA = 6
|
||||
SALIDA_MAXIMA = 600
|
||||
|
||||
# Con boton de parar, el limite deja de ser una proteccion y pasa a ser un
|
||||
# estorbo: veinte segundos matan cualquier escaneo util. Se deja un tope alto
|
||||
# como red para lo que se cuelgue de verdad y no vuelva nunca.
|
||||
LIMITE_LARGO = 900 # 15 minutos
|
||||
|
||||
# Cada cuanto se avisa al HUD mientras sale texto. Sin freno, un comando
|
||||
# hablador manda cientos de mensajes por segundo al WebView y la interfaz se
|
||||
# atasca dibujando texto que nadie llega a leer.
|
||||
CADENCIA_AVISO = 0.25
|
||||
|
||||
CORRIENDO, HECHA, PARADA, FALLO, CADUCADA = (
|
||||
"corriendo", "hecha", "parada", "fallo", "caducada")
|
||||
|
||||
|
||||
class Tarea:
|
||||
"""Una orden en marcha. Solo datos; quien la mueve es el gestor."""
|
||||
|
||||
def __init__(self, ident, titulo, accion_id, comando):
|
||||
self.id = ident
|
||||
self.titulo = titulo
|
||||
self.accion_id = accion_id
|
||||
self.comando = comando
|
||||
# La accion de origen viaja con la tarea (y no en un diccionario
|
||||
# aparte del panel) porque una tarea sin comando termina DENTRO de
|
||||
# lanza(), antes de que quien la lanzo tenga el id para apuntarla: el
|
||||
# aviso de "terminada" llegaria a un registro todavia vacio.
|
||||
self.accion = None
|
||||
self.argumento = ""
|
||||
self.estado = CORRIENDO
|
||||
self.inicio = time.monotonic()
|
||||
self.fin = None
|
||||
self.lineas = [] # las ultimas LINEAS_VISTA de salida
|
||||
self.salida = "" # la salida acumulada, recortada
|
||||
self.codigo = None
|
||||
self._proc = None
|
||||
|
||||
@property
|
||||
def segundos(self) -> float:
|
||||
return (self.fin or time.monotonic()) - self.inicio
|
||||
|
||||
@property
|
||||
def viva(self) -> bool:
|
||||
return self.estado == CORRIENDO
|
||||
|
||||
def resumen(self) -> dict:
|
||||
"""Lo que viaja al HUD. Sin objetos: esto acaba en un json.dumps."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"titulo": self.titulo,
|
||||
"accion": self.accion_id,
|
||||
"estado": self.estado,
|
||||
"segundos": round(self.segundos, 1),
|
||||
"lineas": self.lineas[-LINEAS_VISTA:],
|
||||
"codigo": self.codigo,
|
||||
}
|
||||
|
||||
|
||||
class GestorTareas:
|
||||
"""Lanza, vigila y para tareas. Un unico sitio donde vive el registro.
|
||||
|
||||
al_cambiar(tarea) se llama desde el hilo de la tarea cada vez que hay algo
|
||||
nuevo que enseñar. Quien lo reciba se encarga de saltar al hilo de la
|
||||
interfaz: aqui no se sabe nada de GTK a proposito, para poder probarlo sin
|
||||
levantar la aplicacion.
|
||||
"""
|
||||
|
||||
def __init__(self, al_cambiar=None, prefijo_host=()):
|
||||
self._al_cambiar = al_cambiar or (lambda tarea: None)
|
||||
self._prefijo_host = _con_watch_bus(prefijo_host)
|
||||
self._tareas = {}
|
||||
self._siguiente = 0
|
||||
self._cerrojo = threading.Lock()
|
||||
|
||||
# -------------------------------------------------------------- registro
|
||||
|
||||
def _nueva(self, titulo, accion_id, comando) -> Tarea:
|
||||
with self._cerrojo:
|
||||
self._siguiente += 1
|
||||
ident = f"t{self._siguiente}"
|
||||
tarea = Tarea(ident, titulo, accion_id, comando)
|
||||
self._tareas[ident] = tarea
|
||||
return tarea
|
||||
|
||||
def get(self, ident):
|
||||
with self._cerrojo:
|
||||
return self._tareas.get(ident)
|
||||
|
||||
def activas(self) -> list:
|
||||
with self._cerrojo:
|
||||
return [t for t in self._tareas.values() if t.viva]
|
||||
|
||||
def todas(self) -> list:
|
||||
with self._cerrojo:
|
||||
return list(self._tareas.values())
|
||||
|
||||
def _avisa(self, tarea):
|
||||
try:
|
||||
self._al_cambiar(tarea)
|
||||
except Exception as e: # el HUD no tumba la tarea
|
||||
print(f"JARVIS: aviso de tarea fallido: {e}")
|
||||
|
||||
# ---------------------------------------------------------------- lanzar
|
||||
|
||||
def lanza(self, accion, argumento="", titulo=None) -> Tarea:
|
||||
"""Arranca la accion en su propio proceso y devuelve la tarea ya viva."""
|
||||
comando = accion.comando or ""
|
||||
if comando:
|
||||
comando = comando.replace("{argumento}", shlex.quote(argumento))
|
||||
comando = comando.replace(
|
||||
"{argumento_url}",
|
||||
shlex.quote(urllib.parse.quote_plus(argumento)))
|
||||
|
||||
tarea = self._nueva(titulo or _titulo_de(accion, argumento),
|
||||
accion.id, comando)
|
||||
tarea.accion = accion
|
||||
tarea.argumento = argumento
|
||||
self._avisa(tarea)
|
||||
|
||||
if not comando:
|
||||
# Acciones que solo hablan (no tienen comando): nacen terminadas.
|
||||
self.termina(tarea.id, HECHA, "")
|
||||
return tarea
|
||||
|
||||
orden = ["bash", "-lc", comando]
|
||||
if accion.host:
|
||||
orden = self._prefijo_host + orden
|
||||
|
||||
threading.Thread(target=self._corre, args=(tarea, orden),
|
||||
daemon=True).start()
|
||||
return tarea
|
||||
|
||||
def adopta(self, titulo, accion_id="") -> Tarea:
|
||||
"""Una tarjeta para trabajo que se ejecuta fuera del gestor.
|
||||
|
||||
Lo que pasa por el dialogo de root corre por su camino (GestorRoot, con
|
||||
su contraseña), pero se merece tarjeta igual: si no, pedir algo con
|
||||
sudo hace desaparecer la señal de que hay algo en marcha.
|
||||
"""
|
||||
tarea = self._nueva(titulo, accion_id, "")
|
||||
self._avisa(tarea)
|
||||
return tarea
|
||||
|
||||
def _corre(self, tarea, orden):
|
||||
try:
|
||||
# start_new_session: el proceso estrena grupo, para poder matar el
|
||||
# arbol entero y no solo el bash de arriba.
|
||||
proc = subprocess.Popen(
|
||||
orden, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, errors="replace", start_new_session=True)
|
||||
except OSError as e:
|
||||
self.termina(tarea.id, FALLO, f"No he podido ejecutarlo: {e}")
|
||||
return
|
||||
|
||||
tarea._proc = proc
|
||||
limite = threading.Timer(LIMITE_LARGO, self._caduca, args=(tarea.id,))
|
||||
limite.daemon = True
|
||||
limite.start()
|
||||
|
||||
ultimo_aviso = 0.0
|
||||
acumulado = []
|
||||
try:
|
||||
for linea in proc.stdout:
|
||||
linea = linea.rstrip("\n")
|
||||
if not linea.strip():
|
||||
continue
|
||||
acumulado.append(linea)
|
||||
tarea.lineas = acumulado[-LINEAS_VISTA:]
|
||||
ahora = time.monotonic()
|
||||
if ahora - ultimo_aviso >= CADENCIA_AVISO:
|
||||
ultimo_aviso = ahora
|
||||
self._avisa(tarea)
|
||||
except Exception as e:
|
||||
print(f"JARVIS: leyendo la salida de {tarea.id}: {e}")
|
||||
finally:
|
||||
limite.cancel()
|
||||
|
||||
codigo = proc.wait()
|
||||
salida = "\n".join(acumulado).strip()
|
||||
|
||||
if tarea.estado == PARADA: # la paro alguien: no se toca
|
||||
self._avisa(tarea)
|
||||
return
|
||||
if tarea.estado == CADUCADA:
|
||||
self._avisa(tarea)
|
||||
return
|
||||
self.termina(tarea.id, HECHA if codigo == 0 else FALLO, salida, codigo)
|
||||
|
||||
# ------------------------------------------------------------- terminar
|
||||
|
||||
def termina(self, ident, estado, salida="", codigo=None):
|
||||
tarea = self.get(ident)
|
||||
if tarea is None:
|
||||
return
|
||||
tarea.estado = estado
|
||||
tarea.fin = time.monotonic()
|
||||
tarea.codigo = codigo
|
||||
if salida:
|
||||
tarea.salida = salida[:SALIDA_MAXIMA]
|
||||
if not tarea.lineas:
|
||||
tarea.lineas = salida.splitlines()[-LINEAS_VISTA:]
|
||||
else:
|
||||
tarea.salida = "\n".join(tarea.lineas)[:SALIDA_MAXIMA]
|
||||
self._avisa(tarea)
|
||||
|
||||
def para(self, ident) -> bool:
|
||||
"""Corta la tarea. Primero por las buenas, luego a la fuerza."""
|
||||
tarea = self.get(ident)
|
||||
if tarea is None or not tarea.viva:
|
||||
return False
|
||||
|
||||
tarea.estado = PARADA
|
||||
proc = tarea._proc
|
||||
if proc is not None and proc.poll() is None:
|
||||
_mata_grupo(proc)
|
||||
tarea.fin = time.monotonic()
|
||||
self._avisa(tarea)
|
||||
return True
|
||||
|
||||
def _caduca(self, ident):
|
||||
tarea = self.get(ident)
|
||||
if tarea is None or not tarea.viva:
|
||||
return
|
||||
tarea.estado = CADUCADA
|
||||
if tarea._proc is not None and tarea._proc.poll() is None:
|
||||
_mata_grupo(tarea._proc)
|
||||
tarea.fin = time.monotonic()
|
||||
self._avisa(tarea)
|
||||
|
||||
def cierra(self, ident) -> bool:
|
||||
"""Quita la tarjeta. Si seguia viva, la para antes: cerrar es cerrar."""
|
||||
tarea = self.get(ident)
|
||||
if tarea is None:
|
||||
return False
|
||||
if tarea.viva:
|
||||
self.para(ident)
|
||||
with self._cerrojo:
|
||||
self._tareas.pop(ident, None)
|
||||
return True
|
||||
|
||||
def para_todas(self):
|
||||
for tarea in self.activas():
|
||||
self.para(tarea.id)
|
||||
|
||||
|
||||
def _con_watch_bus(prefijo) -> list:
|
||||
"""Que lo lanzado fuera del sandbox no sobreviva a la aplicacion.
|
||||
|
||||
Una tarea larga corre en el host por flatpak-spawn, y ahi el proceso ya no
|
||||
es hijo de nadie que se entere de que la app se ha cerrado: un escaneo
|
||||
lanzado y olvidado seguiria comiendo maquina despues de cerrar la ventana,
|
||||
sin ventana desde la que pararlo. `--watch-bus` ata su vida a la conexion
|
||||
de bus de quien lo lanzo, que es justo lo que se quiere.
|
||||
"""
|
||||
prefijo = list(prefijo)
|
||||
if not prefijo:
|
||||
return prefijo
|
||||
if any("flatpak-spawn" in p for p in prefijo) and "--watch-bus" not in prefijo:
|
||||
# detras del propio flatpak-spawn, antes de sus argumentos
|
||||
for i, p in enumerate(prefijo):
|
||||
if "flatpak-spawn" in p:
|
||||
prefijo.insert(i + 1, "--watch-bus")
|
||||
break
|
||||
return prefijo
|
||||
|
||||
|
||||
def _mata_grupo(proc):
|
||||
"""SIGTERM al grupo, y SIGKILL a los tres segundos si sigue ahi.
|
||||
|
||||
Se da margen a proposito: un `wget` a medias cierra el fichero y un `nmap`
|
||||
escupe lo que llevaba. Matar a lo bruto de entrada deja restos.
|
||||
"""
|
||||
try:
|
||||
grupo = os.getpgid(proc.pid)
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
os.killpg(grupo, signal.SIGTERM)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
def remata():
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
os.killpg(grupo, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
t = threading.Timer(3.0, remata)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
|
||||
def _titulo_de(accion, argumento) -> str:
|
||||
"""El rotulo de la tarjeta: corto, y con el argumento si lo hay.
|
||||
|
||||
El id de la accion con guiones bajos ("buscar_fichero") es de programador;
|
||||
en la tarjeta se lee "buscar fichero", y con lo que se pidio detras, que es
|
||||
lo que distingue dos busquedas a la vez.
|
||||
"""
|
||||
base = (accion.id or "accion").replace("_", " ")
|
||||
if argumento:
|
||||
arg = argumento.strip()
|
||||
if len(arg) > 22:
|
||||
arg = arg[:21] + "…"
|
||||
return f"{base}: {arg}"
|
||||
return base
|
||||
182
nucleo/mejora.py
Executable file
182
nucleo/mejora.py
Executable file
|
|
@ -0,0 +1,182 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Que ha aprendido JARVIS del uso, y que conviene cambiar.
|
||||
|
||||
./mejora.py que hay en el diario
|
||||
./mejora.py --propone que acciones o frases faltan
|
||||
./mejora.py --mide cuanto acierta ahora mismo
|
||||
|
||||
## En que se diferencia de la mejora nocturna que se retiro
|
||||
|
||||
La de antes corria sola de madrugada, se modificaba el codigo y se commiteaba.
|
||||
Nueve horas, 39 rondas, cero commits utiles. Fallo por tres cosas, y las tres
|
||||
estan corregidas aqui:
|
||||
|
||||
1. **Minaba ruido.** Su fuente era un registro donde el 60 % era JARVIS
|
||||
oyendose a si mismo y alucinaciones de Whisper. Ahora la guarda del eco y
|
||||
el filtro de ruido limpian antes, y el diario apunta la VIA de cada frase,
|
||||
asi que el ruido esta separado y etiquetado en vez de mezclado.
|
||||
|
||||
2. **Su medida tenia mas ruido que su mejora.** Generaba audio nuevo cada
|
||||
ronda: ±1,9 puntos de ruido contra +1,45 de mejora acumulada, o sea que
|
||||
"¿esto mejora o empeora?" se decidia a cara o cruz. Ahora se mide contra
|
||||
`datos/banco.json`, que es fijo y determinista.
|
||||
|
||||
3. **Actuaba sola.** Un bucle que se reescribe y se aprueba a si mismo acaba
|
||||
pasando siempre. Esto PROPONE y para: lo que se aplica lo decide una
|
||||
persona mirandolo.
|
||||
|
||||
Y una cuarta, de fondo: aquella tocaba el codigo. Esta solo mira el catalogo,
|
||||
que es lo unico que se puede validar de verdad con el banco.
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import os
|
||||
import sys
|
||||
|
||||
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, AQUI)
|
||||
|
||||
from manos import Manos # noqa: E402
|
||||
from manos.acciones import normaliza, parecido # noqa: E402
|
||||
from manos.diario import lee # noqa: E402
|
||||
|
||||
|
||||
def color(t, c):
|
||||
return f"\033[{c}m{t}\033[0m" if sys.stdout.isatty() else t
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ mirar
|
||||
|
||||
def resumen(lineas):
|
||||
if not lineas:
|
||||
print(" el diario esta vacio: usa JARVIS un rato y vuelve")
|
||||
return 1
|
||||
|
||||
vias = collections.Counter(l.get("via", "?") for l in lineas)
|
||||
tot = sum(vias.values())
|
||||
print(f"\n {tot} frases desde {lineas[0]['t'][:10]}\n")
|
||||
print(f" {'via':14s} {'n':>5s} {'%':>7s}")
|
||||
print(" " + "-" * 32)
|
||||
ORDEN = ["catalogo", "cerebro", "respuesta", "ruido", "sin-nombre",
|
||||
"bloqueado", "sin-cerebro"]
|
||||
for v in ORDEN + [k for k in vias if k not in ORDEN]:
|
||||
if v in vias:
|
||||
print(f" {v:14s} {vias[v]:5d} {100*vias[v]/tot:6.1f} %")
|
||||
|
||||
atendidas = vias["catalogo"] + vias["cerebro"]
|
||||
if atendidas:
|
||||
print(f"\n de lo ATENDIDO ({atendidas}), el catalogo cogio "
|
||||
f"{100*vias['catalogo']/atendidas:.0f} %")
|
||||
segs = [l.get("seg", 0) for l in lineas if l.get("via") == "catalogo"]
|
||||
segc = [l.get("seg", 0) for l in lineas if l.get("via") == "cerebro"]
|
||||
if segs:
|
||||
print(f" catalogo: {sum(segs)/len(segs):.2f} s de media")
|
||||
if segc:
|
||||
print(f" cerebro: {sum(segc)/len(segc):.1f} s de media")
|
||||
ahorro = (sum(segc)/len(segc) - (sum(segs)/len(segs) if segs else 0))
|
||||
print(f"\n cada accion que se añada al catalogo ahorra ~{ahorro:.0f} s")
|
||||
|
||||
top = collections.Counter(l["accion"] for l in lineas
|
||||
if l.get("via") == "catalogo" and l.get("accion"))
|
||||
if top:
|
||||
print("\n lo que mas usas:")
|
||||
for a, n in top.most_common(8):
|
||||
print(f" {n:4d} {a}")
|
||||
return 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------- proponer
|
||||
|
||||
def propone(lineas, manos):
|
||||
"""Lo que fue al cerebro y podria ser una accion del catalogo."""
|
||||
# "sin-cerebro" cuenta igual: es lo mismo —una frase que el catalogo no
|
||||
# cubrio— y solo se diferencia en que no habia modelo conectado para
|
||||
# atenderla. El hueco del catalogo es el mismo.
|
||||
alCerebro = [l for l in lineas if l.get("via") in ("cerebro", "sin-cerebro")]
|
||||
if not alCerebro:
|
||||
print("\n nada ha ido al cerebro todavia: no hay hueco que rellenar")
|
||||
return 0
|
||||
|
||||
# Agrupar lo parecido: si has pedido lo mismo de tres formas, es UNA accion
|
||||
# que falta, no tres. Y que lo hayas repetido es justo la señal de que
|
||||
# importa.
|
||||
grupos = []
|
||||
for l in alCerebro:
|
||||
t = normaliza(l["oido"])
|
||||
if len(t.split()) < 2:
|
||||
continue
|
||||
for g in grupos:
|
||||
if parecido(t, g["patron"]) > 0.62:
|
||||
g["frases"].append(l["oido"])
|
||||
break
|
||||
else:
|
||||
grupos.append({"patron": t, "frases": [l["oido"]],
|
||||
"dicho": l.get("dicho", "")})
|
||||
|
||||
grupos.sort(key=lambda g: -len(g["frases"]))
|
||||
print(f"\n {len(alCerebro)} frases fueron al cerebro, en {len(grupos)} grupos.")
|
||||
print(" Las que mas se repiten son las acciones que faltan:\n")
|
||||
|
||||
for g in grupos[:12]:
|
||||
n = len(g["frases"])
|
||||
marca = color(f"{n:3d} veces", "33" if n > 1 else "90")
|
||||
print(f" {marca} {g['frases'][0][:64]}")
|
||||
for f in g["frases"][1:4]:
|
||||
print(f" {color('tambien: ' + f[:56], '90')}")
|
||||
if g["dicho"]:
|
||||
print(f" {color('contesto: ' + g['dicho'][:56], '90')}")
|
||||
print()
|
||||
|
||||
repetidas = [g for g in grupos if len(g["frases"]) > 1]
|
||||
print(f" {len(repetidas)} se han pedido mas de una vez: por ahi empezaria.\n")
|
||||
print(color(" Esto NO cambia nada. Para añadir una accion se edita\n"
|
||||
" datos/_generar.py —que es la FUENTE— y se regenera.\n", "90"))
|
||||
return 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ medir
|
||||
|
||||
def mide(manos):
|
||||
"""El acierto del emparejador contra el banco fijo."""
|
||||
import json
|
||||
banco = os.path.join(AQUI, "datos", "banco.json")
|
||||
try:
|
||||
with open(banco, encoding="utf-8") as f:
|
||||
casos = json.load(f)["casos"]
|
||||
except OSError:
|
||||
print(" no hay banco: pruebas/banco.py --crear")
|
||||
return 1
|
||||
bien = otra = 0
|
||||
for c in casos:
|
||||
a, _ = manos.busca(c["oido"])
|
||||
if a is None:
|
||||
continue
|
||||
if a.id == c["id"]:
|
||||
bien += 1
|
||||
else:
|
||||
otra += 1
|
||||
print(f"\n acierto {100*bien/len(casos):.1f} % sobre {len(casos)} frases")
|
||||
print(f" ordenes que acaban en la accion equivocada: {otra}")
|
||||
print(color("\n Este numero es comparable entre dias: el banco esta grabado\n"
|
||||
" una vez y no cambia. Era lo que le faltaba a la mejora\n"
|
||||
" nocturna, cuyo ruido de medida era mayor que su mejora.\n", "90"))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description="Que ha aprendido JARVIS del uso")
|
||||
p.add_argument("--propone", action="store_true")
|
||||
p.add_argument("--mide", action="store_true")
|
||||
a = p.parse_args()
|
||||
|
||||
manos = Manos()
|
||||
lineas = lee()
|
||||
if a.mide:
|
||||
return mide(manos)
|
||||
if a.propone:
|
||||
return propone(lineas, manos)
|
||||
return resumen(lineas)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
0
nucleo/oido/__init__.py
Normal file
0
nucleo/oido/__init__.py
Normal file
188
nucleo/oido/captura.py
Normal file
188
nucleo/oido/captura.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"""Escuchar el microfono y recortar lo que es habla.
|
||||
|
||||
## Por que pw-record y no pyaudio
|
||||
|
||||
Newelle usa pyaudio, que necesita portaudio compilado. En esta maquina no esta,
|
||||
asi que instalarlo pediria sudo. `pw-record` y `parec` ya vienen con PipeWire y
|
||||
sirven igual: se lanzan como subproceso y se lee su salida estandar.
|
||||
|
||||
Y hay una ventaja que no es casual: la fuente se elige con un argumento. Cuando
|
||||
se ponga el cancelador de eco de PipeWire, apuntar ahi es cambiar una cadena, no
|
||||
tocar codigo de audio.
|
||||
|
||||
## La guarda contra el eco
|
||||
|
||||
Esta es la razon de ser de este fichero. En Newelle, el 21 % largo de lo que
|
||||
JARVIS oye y no entiende es su propia voz. Tiene mecanismo para evitarlo pero
|
||||
con tres agujeros, y aqui se cierran los tres:
|
||||
|
||||
1. **Se consulta a la boca en cada trozo**, no una bandera que alguien actualiza
|
||||
desde otro hilo. Si `boca.hablando` es cierto, ese audio se tira sin mirar.
|
||||
2. **La cola de 350 ms** vive en la boca (`COLA_MS`): cuando el reproductor
|
||||
termina, el sonido sigue llegando al microfono un rato.
|
||||
3. **El prebufer se vacia** al salir de "hablando". Newelle guarda 1 s circular
|
||||
y lo antepone al detectar voz; ese segundo era justo el final de la frase de
|
||||
JARVIS, asi que se colaba aunque la guarda funcionase.
|
||||
"""
|
||||
import collections
|
||||
import os
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import wave
|
||||
|
||||
FRECUENCIA = 16000 # lo que quiere whisper.cpp
|
||||
CANALES = 1
|
||||
ANCHO = 2 # s16
|
||||
TROZO = 512 # muestras: lo que pide Silero de una vez
|
||||
|
||||
# Cuanto silencio hace falta para dar la frase por terminada. Muy corto corta a
|
||||
# quien piensa a media frase; muy largo hace que JARVIS parezca lento.
|
||||
SILENCIO_PARA_CERRAR = 0.8
|
||||
# Cuanto se guarda de ANTES de detectar voz. La primera silaba siempre llega
|
||||
# antes de que el detector se entere.
|
||||
PREBUFER_S = 0.5
|
||||
# Frases mas cortas que esto no son ordenes, son ruidos.
|
||||
MINIMO_S = 0.35
|
||||
# Y mas largas que esto es que el detector se ha quedado enganchado.
|
||||
MAXIMO_S = 25.0
|
||||
|
||||
|
||||
def _grabador(fuente=None):
|
||||
"""La orden para capturar crudo a la salida estandar."""
|
||||
if fuente:
|
||||
pw = ["pw-record", "--target", fuente]
|
||||
else:
|
||||
pw = ["pw-record"]
|
||||
return pw + ["--rate", str(FRECUENCIA), "--channels", str(CANALES),
|
||||
"--format", "s16", "-"]
|
||||
|
||||
|
||||
class Captura:
|
||||
"""Trozos de microfono -> frases sueltas en WAV."""
|
||||
|
||||
def __init__(self, boca=None, fuente=None, agresividad=0.5):
|
||||
self.boca = boca # para saber si JARVIS esta hablando
|
||||
self.fuente = fuente
|
||||
self.agresividad = agresividad
|
||||
self._proc = None
|
||||
self._vad = None
|
||||
self._energia = None
|
||||
|
||||
# ------------------------------------------------------------ el detector
|
||||
|
||||
def _detector(self):
|
||||
if self._vad is not None or self._energia is not None:
|
||||
return
|
||||
try:
|
||||
from pysilero_vad import SileroVoiceActivityDetector
|
||||
self._vad = SileroVoiceActivityDetector()
|
||||
except Exception:
|
||||
# Respaldo por energia: peor, pero mejor que no escuchar. Se avisa,
|
||||
# porque la diferencia se nota y conviene saber en cual se esta.
|
||||
print("oido: sin Silero, usando deteccion por energia")
|
||||
self._energia = True
|
||||
|
||||
def _hay_voz(self, trozo: bytes) -> bool:
|
||||
if self._vad is not None:
|
||||
try:
|
||||
return self._vad(trozo) >= self.agresividad
|
||||
except Exception:
|
||||
return False
|
||||
muestras = struct.unpack(f"{len(trozo) // 2}h", trozo)
|
||||
if not muestras:
|
||||
return False
|
||||
media = sum(m * m for m in muestras) / len(muestras)
|
||||
return (media ** 0.5) / 32768.0 > 0.015
|
||||
|
||||
# -------------------------------------------------------------- escuchar
|
||||
|
||||
def frases(self, para_cuando=None):
|
||||
"""Generador: devuelve la ruta de un WAV por cada frase oida.
|
||||
|
||||
El WAV es temporal y de quien lo recibe: se borra cuando quiera.
|
||||
"""
|
||||
self._detector()
|
||||
self._proc = subprocess.Popen(
|
||||
_grabador(self.fuente), stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
|
||||
bytes_trozo = TROZO * ANCHO
|
||||
por_segundo = FRECUENCIA / TROZO
|
||||
prebufer = collections.deque(maxlen=max(1, int(PREBUFER_S * por_segundo)))
|
||||
cerrar_tras = int(SILENCIO_PARA_CERRAR * por_segundo)
|
||||
tope = int(MAXIMO_S * por_segundo)
|
||||
|
||||
frase, callados, hablando = [], 0, False
|
||||
try:
|
||||
while True:
|
||||
if para_cuando and para_cuando():
|
||||
break
|
||||
trozo = self._proc.stdout.read(bytes_trozo)
|
||||
if not trozo or len(trozo) < bytes_trozo:
|
||||
break
|
||||
|
||||
# --- la guarda: si JARVIS habla, esto es su propia voz -------
|
||||
if self.boca is not None and self.boca.hablando:
|
||||
# Tirar tambien lo acumulado: si estaba a media frase cuando
|
||||
# JARVIS empezo a hablar, lo que quede lleva su voz encima.
|
||||
prebufer.clear()
|
||||
frase, callados, hablando = [], 0, False
|
||||
continue
|
||||
|
||||
prebufer.append(trozo)
|
||||
voz = self._hay_voz(trozo)
|
||||
|
||||
if voz and not hablando:
|
||||
hablando = True
|
||||
frase = list(prebufer) # con lo de antes de detectarla
|
||||
callados = 0
|
||||
elif hablando:
|
||||
frase.append(trozo)
|
||||
callados = 0 if voz else callados + 1
|
||||
|
||||
if hablando and (callados >= cerrar_tras or len(frase) >= tope):
|
||||
dur = len(frase) * TROZO / FRECUENCIA
|
||||
ruta = self._a_wav(frase) if dur >= MINIMO_S else None
|
||||
frase, callados, hablando = [], 0, False
|
||||
prebufer.clear()
|
||||
if ruta:
|
||||
yield ruta
|
||||
finally:
|
||||
self.para()
|
||||
|
||||
def _a_wav(self, trozos) -> str:
|
||||
fd, ruta = tempfile.mkstemp(suffix=".wav", prefix="jarvis-oido-")
|
||||
os.close(fd)
|
||||
with wave.open(ruta, "wb") as w:
|
||||
w.setnchannels(CANALES)
|
||||
w.setsampwidth(ANCHO)
|
||||
w.setframerate(FRECUENCIA)
|
||||
w.writeframes(b"".join(trozos))
|
||||
return ruta
|
||||
|
||||
def para(self):
|
||||
if not self._proc:
|
||||
return
|
||||
try:
|
||||
os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
|
||||
self._proc.wait(timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
self._proc = None
|
||||
|
||||
|
||||
def fuentes() -> list:
|
||||
"""Las entradas que hay, para poder elegir la del cancelador de eco."""
|
||||
try:
|
||||
r = subprocess.run(["pactl", "list", "short", "sources"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
salida = []
|
||||
for linea in r.stdout.splitlines():
|
||||
campos = linea.split("\t")
|
||||
if len(campos) > 1 and not campos[1].endswith(".monitor"):
|
||||
salida.append(campos[1])
|
||||
return salida
|
||||
143
nucleo/oido/despierta.py
Normal file
143
nucleo/oido/despierta.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""La palabra de activacion: "JARVIS".
|
||||
|
||||
Sin esto, el asistente obedece a cualquier cosa que se diga en la habitacion —y
|
||||
a la tele—. Con esto, solo escucha de verdad durante un rato despues de oir su
|
||||
nombre.
|
||||
|
||||
## Por que sobre el texto y no sobre el audio
|
||||
|
||||
Lo obvio seria un detector como openwakeword, que mira la forma de onda antes de
|
||||
transcribir. Aqui se hace despues, sobre lo que Whisper ya escribio, y es a
|
||||
proposito:
|
||||
|
||||
- **Ya tenemos la transcripcion.** El audio pasa por Whisper de todas formas
|
||||
porque la captura la dispara el detector de voz; no hay ahorro que ganar.
|
||||
- **Un modelo menos.** openwakeword son mas dependencias, otro modelo en
|
||||
memoria y otro afinado que mantener.
|
||||
- **Y sobre todo: Whisper oye "JARVIS" de mil maneras.** Medido en el registro
|
||||
de uso real, el nombre sale como "Jarvis", "Yarvis", "Charles", "Chavez",
|
||||
"Harvest"... Sobre el texto eso se arregla comparando como SUENA, que es lo
|
||||
que ya hace `manos/fonetica.py` para el catalogo. Sobre la onda habria que
|
||||
reentrenar.
|
||||
|
||||
Lo que se pierde: la transcripcion corre siempre, tambien cuando nadie ha
|
||||
llamado. En una maquina donde Whisper va en la grafica y tarda dos segundos, es
|
||||
un coste que ya estabamos pagando.
|
||||
|
||||
## Como se comporta
|
||||
|
||||
Se llama una vez y se queda despierto un rato, para poder encadenar ordenes sin
|
||||
repetir el nombre en cada frase. Y si la frase que trae el nombre lleva ademas
|
||||
la orden —"JARVIS, cuanto espacio queda"— se atiende esa misma, sin obligar a
|
||||
decirlo dos veces.
|
||||
"""
|
||||
import difflib
|
||||
import re
|
||||
import time
|
||||
|
||||
# Absolutos, como el resto del nucleo: jarvis.py mete su carpeta en sys.path y
|
||||
# todos los paquetes son de primer nivel. Con `..manos` esto solo se podria
|
||||
# importar desde fuera, y las pruebas lo cargan suelto.
|
||||
from manos.acciones import normaliza
|
||||
from manos.fonetica import fonetica
|
||||
|
||||
NOMBRE = "jarvis"
|
||||
|
||||
# Cuanto sigue escuchando tras oir su nombre. Corto obliga a repetirlo en cada
|
||||
# frase; largo devuelve al problema de origen, que obedezca a la tele.
|
||||
DESPIERTO_S = 25.0
|
||||
|
||||
# Lo que Whisper escribe cuando oye "JARVIS". Salen del registro real de uso,
|
||||
# no de imaginarlas.
|
||||
PARECIDAS = (
|
||||
"jarvis", "yarvis", "charvis", "jarbis", "yarbis", "harvis", "jervis",
|
||||
"jarvi", "yarvi", "javis", "jarves", "charles", "chavez", "harvest",
|
||||
"jarvix", "sharvis", "arvis",
|
||||
)
|
||||
|
||||
# Se compara palabra a palabra, asi que el umbral puede ser alto sin perder
|
||||
# aciertos: "yarvis" contra "jarvis" da 0,83 por letra y sube por sonido.
|
||||
UMBRAL = 0.80
|
||||
|
||||
_LIMPIA = re.compile(r"^[\s,.:;¡!¿?]+|[\s,.:;¡!¿?]+$")
|
||||
|
||||
# Lo que suele acompañar al nombre sin ser una orden. Si al quitar el nombre
|
||||
# solo queda esto, es que llamaron y ya: "oye jarvis" no es "haz oye".
|
||||
LLAMADAS = {"oye", "eh", "hey", "ey", "hola", "escucha", "atiende", "perdona",
|
||||
"por favor", "porfa", "venga", "a ver"}
|
||||
|
||||
|
||||
def _suena_al_nombre(palabra: str) -> bool:
|
||||
p = _LIMPIA.sub("", palabra)
|
||||
if len(p) < 4:
|
||||
return False
|
||||
if p in PARECIDAS:
|
||||
return True
|
||||
for candidata in (NOMBRE,) + PARECIDAS[:6]:
|
||||
if difflib.SequenceMatcher(None, p, candidata).ratio() >= UMBRAL:
|
||||
return True
|
||||
if difflib.SequenceMatcher(None, fonetica(p), fonetica(candidata)).ratio() >= UMBRAL:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def parte_el_nombre(texto: str):
|
||||
"""(lo llamaron, lo que queda de la frase sin el nombre).
|
||||
|
||||
Se devuelve el resto porque "JARVIS, cuanto espacio queda" tiene que
|
||||
ejecutarse en la misma frase: obligar a decir el nombre y luego la orden
|
||||
aparte es exactamente lo que hace que un asistente canse.
|
||||
"""
|
||||
palabras = normaliza(texto).split()
|
||||
if not palabras:
|
||||
return False, ""
|
||||
for i, p in enumerate(palabras):
|
||||
if _suena_al_nombre(p):
|
||||
sobra = [q for q in palabras[:i] + palabras[i + 1:]
|
||||
if q not in LLAMADAS]
|
||||
return True, " ".join(sobra).strip()
|
||||
return False, ""
|
||||
|
||||
|
||||
class Centinela:
|
||||
"""Decide si una frase hay que atenderla o dejarla pasar."""
|
||||
|
||||
def __init__(self, activo=True, ventana=DESPIERTO_S):
|
||||
self.activo = activo
|
||||
self.ventana = ventana
|
||||
self._hasta = 0.0
|
||||
|
||||
@property
|
||||
def despierto(self) -> bool:
|
||||
return not self.activo or time.monotonic() < self._hasta
|
||||
|
||||
def despierta(self):
|
||||
self._hasta = time.monotonic() + self.ventana
|
||||
|
||||
def duerme(self):
|
||||
self._hasta = 0.0
|
||||
|
||||
def filtra(self, texto: str):
|
||||
"""(hay que atenderla, texto a atender, acaba de despertar).
|
||||
|
||||
- Sin palabra de activacion configurada: se atiende todo.
|
||||
- Dormido y sin nombre: se ignora.
|
||||
- Dormido y con nombre: despierta. Si la frase traia ademas la orden,
|
||||
se devuelve; si solo traia el nombre, se contesta con un acuse.
|
||||
- Despierto: se atiende y se renueva la ventana, para poder encadenar.
|
||||
"""
|
||||
if not self.activo:
|
||||
return True, texto, False
|
||||
|
||||
llamado, resto = parte_el_nombre(texto)
|
||||
|
||||
if llamado:
|
||||
recien = not self.despierto
|
||||
self.despierta()
|
||||
return (True, resto, recien) if resto else (False, "", recien)
|
||||
|
||||
if self.despierto:
|
||||
self.despierta() # encadenar sin repetir el nombre
|
||||
return True, texto, False
|
||||
|
||||
return False, "", False
|
||||
343
nucleo/oido/whisper.py
Normal file
343
nucleo/oido/whisper.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
"""Transcribir: arrancar whisper-server y mandarle los WAV.
|
||||
|
||||
Newelle dedicaba 804 lineas a esto porque tenia que descargar modelos, elegir
|
||||
entre CLI y servidor, salir del sandbox y ofrecerlo todo en una pantalla de
|
||||
ajustes. Nosotros ya tenemos el binario compilado con CUDA y el modelo en el
|
||||
disco: queda arrancar un proceso y hacer un POST.
|
||||
|
||||
## Los cuatro flags que Newelle no pasaba
|
||||
|
||||
Esto es lo importante de este fichero, y sale de leer el codigo de upstream:
|
||||
`whispercpp_handler.py` arranca el servidor con `-m -t --host --port -l` y nada
|
||||
mas. Los defectos de `whisper-server` no son los mismos que los de
|
||||
`whisper-cli`, asi que se estaba transcribiendo peor de lo que la propia
|
||||
whisper.cpp hace por defecto:
|
||||
|
||||
--beam-size 5 --best-of 5 whisper-server trae beam-size -1, que es
|
||||
decodificacion GREEDY. El CLI usa 5. Es la causa
|
||||
mas probable de transcripciones como "esta con el
|
||||
tanutusto" en un audio limpio.
|
||||
-sns suprime los tokens que no son habla. Es
|
||||
literalmente el flag que evita los "[Musica]" y
|
||||
"(aplausos)", que son el 16,2 % de las frases que
|
||||
JARVIS no entendio.
|
||||
--prompt + --carry-initial-prompt
|
||||
vocabulario del dominio. MEDIDO Y DESCARTADO por
|
||||
defecto: ver abajo.
|
||||
|
||||
El prompt se genera DESDE el catalogo (`prompt_dominio`), no escrito a mano, o
|
||||
se queda viejo en cuanto se añada una accion.
|
||||
|
||||
## Lo que dijo la medida, que no fue lo que yo esperaba
|
||||
|
||||
40 frases del catalogo dichas por Piper con tres voces, transcritas con cada
|
||||
configuracion y contando cuantas llegan a su accion:
|
||||
|
||||
como lo hace Newelle (greedy, sin sns) 50,0 % 0,98 s
|
||||
+ beam 5 55,0 % 1,01 s
|
||||
+ beam 5 + sns 55,0 % 1,01 s
|
||||
+ beam 5 + sns + prompt 52,5 % 1,12 s
|
||||
|
||||
Tres lecturas, por orden de importancia:
|
||||
|
||||
1. **El prompt EMPEORA.** Era el riesgo anotado —Whisper empieza a "oir" las
|
||||
palabras que le has puesto donde no las hay— y se cumplio. Va apagado por
|
||||
defecto; se deja el codigo porque con un prompt mas corto podria ganar, pero
|
||||
hay que demostrarlo antes de encenderlo.
|
||||
2. **`-sns` no se puede medir con este banco.** El audio de Piper no tiene
|
||||
silencios ni musica, asi que no hay nada que suprimir. Se deja puesto porque
|
||||
no cuesta nada y ataca un problema real que solo se ve con microfono: el
|
||||
16,2 % de "[Musica]" del registro de uso.
|
||||
3. **El beam sube 5 puntos y cuesta 30 ms.** Es lo unico claramente a favor, y
|
||||
aun asi con 40 muestras esos 5 puntos son dos frases: esta en el limite del
|
||||
ruido.
|
||||
"""
|
||||
import atexit
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
BINARIO = os.path.join(RAIZ, "voz", "whisper-cuda", "whisper.cpp", "build", "bin",
|
||||
"whisper-server")
|
||||
MODELOS = os.path.expanduser(
|
||||
"~/.var/app/io.github.qwersyk.Newelle/config/models/whisper/whisper.cpp/models")
|
||||
|
||||
# Las libs de ggml estan al lado del binario y CUDA en /usr/local. Fuera del
|
||||
# flatpak esto es una linea; dentro era media pagina de flatpak-spawn.
|
||||
LIBS = "/usr/local/cuda/lib64"
|
||||
|
||||
# Un prompt largo no ayuda: Whisper empieza a "oir" esas palabras donde no las
|
||||
# hay. Se corta.
|
||||
TOPE_PROMPT = 220
|
||||
|
||||
# El prompt MINIMO, y el unico que va puesto por defecto: solo el nombre.
|
||||
#
|
||||
# Sin el, Whisper no escribe "JARVIS" nunca: se lo come y lo sustituye por la
|
||||
# palabra española que mejor le cuadre acusticamente. Medido diciendoselo con
|
||||
# Piper:
|
||||
#
|
||||
# "Jarvis, cuanto espacio queda" -> "¿Sabeis cuanto espacio queda?"
|
||||
# "Yarvis, que hora es" -> "¿Ya viste que hora es?"
|
||||
# "Jarvis pon musica" -> "Carbis Pond Musica."
|
||||
#
|
||||
# Y ninguna de esas se parece al nombre ni por letra ni por sonido, asi que el
|
||||
# centinela no despertaba JAMAS. Era la causa de que el asistente no contestara.
|
||||
#
|
||||
# Con este prompt de cinco palabras, cuatro de cuatro. Y ojo al matiz, porque
|
||||
# contradice lo de arriba solo en apariencia: lo que empeora es el prompt LARGO
|
||||
# con todo el vocabulario del catalogo. Este cuesta una frase de ochenta —90,0 a
|
||||
# 88,8 % de acierto general, dentro del ruido— y arregla el nombre entero.
|
||||
PROMPT_NOMBRE = "JARVIS. Hablando con JARVIS."
|
||||
|
||||
|
||||
def prompt_dominio(catalogo=None) -> str:
|
||||
"""Vocabulario que JARVIS espera oir, sacado del catalogo.
|
||||
|
||||
Se cogen las palabras poco comunes —las que un modelo de castellano general
|
||||
no espera— y no las frases enteras: el prompt es un sesgo, no una lista de
|
||||
ordenes, y llenarlo de "cuanto espacio queda" haria que las oyera en
|
||||
cualquier ruido.
|
||||
"""
|
||||
comunes = {
|
||||
"abre", "cierra", "busca", "pon", "quita", "sube", "baja", "dime",
|
||||
"cuanto", "cuanta", "cuantos", "que", "el", "la", "los", "las", "de",
|
||||
"del", "en", "con", "por", "para", "un", "una", "y", "o", "a", "al",
|
||||
"me", "se", "lo", "es", "esta", "hay", "mi", "tu", "su",
|
||||
}
|
||||
# Por grupos y en rueda, no por orden del catalogo. Recorriendolo en orden,
|
||||
# el tope de caracteres se lo comian entero las acciones de disco —que van
|
||||
# primeras— y no llegaba ni una palabra de firefox, ventanas o pentest.
|
||||
por_grupo, vistas = {}, set()
|
||||
for a in (catalogo.acciones if catalogo else []):
|
||||
for frase in a.frases:
|
||||
for p in frase.split():
|
||||
if len(p) < 4 or p in comunes or p in vistas:
|
||||
continue
|
||||
vistas.add(p)
|
||||
por_grupo.setdefault(a.grupo or "otras", []).append(p)
|
||||
|
||||
palabras = []
|
||||
grupos = list(por_grupo.values())
|
||||
for i in range(max((len(g) for g in grupos), default=0)):
|
||||
for g in grupos:
|
||||
if i < len(g):
|
||||
palabras.append(g[i])
|
||||
# Los nombres propios importan mas que nada y no salen del catalogo tal cual
|
||||
fijas = ["JARVIS", "firefox", "nmap", "ollama", "whisper", "terminal",
|
||||
"escritorio", "pantalla", "wifi", "bluetooth", "github", "youtube"]
|
||||
texto = ", ".join(fijas + palabras)
|
||||
return texto[:TOPE_PROMPT].rsplit(",", 1)[0]
|
||||
|
||||
|
||||
def _mata_huerfanos():
|
||||
"""whisper-server de arranques anteriores que se quedaron con la grafica.
|
||||
|
||||
Se buscan por la RUTA COMPLETA del binario, no por el nombre: `pkill -f
|
||||
whisper` casaria con el propio pkill y con cualquier cosa que lleve la
|
||||
palabra, y eso ya ha costado depuraciones. Y se leen los cmdline de /proc en
|
||||
vez de llamar a pgrep, que es lo mismo pero sin proceso de por medio.
|
||||
"""
|
||||
yo = os.getpid()
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdigit() or int(pid) == yo:
|
||||
continue
|
||||
try:
|
||||
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||
orden = f.read().split(b"\0")
|
||||
except OSError:
|
||||
continue
|
||||
if orden and orden[0].decode(errors="ignore") == BINARIO:
|
||||
try:
|
||||
os.killpg(os.getpgid(int(pid)), signal.SIGTERM)
|
||||
except (OSError, ProcessLookupError):
|
||||
pass
|
||||
|
||||
|
||||
def _puerto_libre() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
class Oido:
|
||||
"""whisper-server vivo, y un metodo para transcribir un WAV."""
|
||||
|
||||
def __init__(self, modelo="small", idioma="es", hilos=8, prompt=PROMPT_NOMBRE,
|
||||
beam=5, gpu=True):
|
||||
self.modelo = modelo
|
||||
self.idioma = idioma
|
||||
self.hilos = hilos
|
||||
self.prompt = prompt
|
||||
self.beam = beam
|
||||
self.gpu = gpu
|
||||
self._proc = None
|
||||
self._puerto = None
|
||||
|
||||
@property
|
||||
def ruta_modelo(self) -> str:
|
||||
return os.path.join(MODELOS, f"ggml-{self.modelo}.bin")
|
||||
|
||||
def disponible(self) -> bool:
|
||||
return os.path.exists(BINARIO) and os.path.exists(self.ruta_modelo)
|
||||
|
||||
# ------------------------------------------------------------- arrancar
|
||||
|
||||
def arranca(self, espera=40) -> bool:
|
||||
if self._proc and self._proc.poll() is None:
|
||||
return True
|
||||
if not self.disponible():
|
||||
return False
|
||||
|
||||
# Los huerfanos de arranques anteriores, antes de nada.
|
||||
#
|
||||
# whisper-server se lanza en su propio grupo de procesos para poder
|
||||
# matarlo entero, pero eso mismo hace que sobreviva si el nucleo muere
|
||||
# de golpe (un kill, un cuelgue, cerrar la terminal). Y cada uno se
|
||||
# queda con ~880 MiB de una tarjeta de 4 GB: al cuarto, el siguiente ya
|
||||
# no arranca y el sintoma es "whisper no arranco", que no apunta a
|
||||
# ningun sitio. Medido en esta maquina: cuatro huerfanos, 3,5 GB.
|
||||
_mata_huerfanos()
|
||||
|
||||
self._puerto = _puerto_libre()
|
||||
orden = [
|
||||
BINARIO,
|
||||
"-m", self.ruta_modelo,
|
||||
"-t", str(self.hilos),
|
||||
"--host", "127.0.0.1",
|
||||
"--port", str(self._puerto),
|
||||
"-l", self.idioma,
|
||||
# los cuatro que Newelle no pasaba
|
||||
"-sns",
|
||||
"--beam-size", str(self.beam),
|
||||
"--best-of", "5",
|
||||
]
|
||||
if self.prompt:
|
||||
orden += ["--prompt", self.prompt, "--carry-initial-prompt"]
|
||||
if not self.gpu:
|
||||
orden.append("--no-gpu")
|
||||
|
||||
entorno = {**os.environ,
|
||||
"LD_LIBRARY_PATH": LIBS + ":" + os.environ.get("LD_LIBRARY_PATH", "")}
|
||||
self._proc = subprocess.Popen(
|
||||
orden, env=entorno, cwd=os.path.dirname(BINARIO),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
# grupo propio, para poder matarlo entero como hace tareas.py
|
||||
start_new_session=True)
|
||||
# Y que se vaya con nosotros: sin esto se queda con su parte de la
|
||||
# grafica hasta el siguiente reinicio.
|
||||
atexit.register(self.para)
|
||||
|
||||
# Esperar a que escuche de verdad, no a que el proceso exista: cargar
|
||||
# el modelo en la grafica tarda, y un POST antes de tiempo da un error
|
||||
# de conexion que parece un fallo de configuracion.
|
||||
limite = time.monotonic() + espera
|
||||
while time.monotonic() < limite:
|
||||
if self._proc.poll() is not None:
|
||||
return False
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", self._puerto), 0.5):
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(0.3)
|
||||
self.para()
|
||||
return False
|
||||
|
||||
def para(self):
|
||||
if not self._proc:
|
||||
return
|
||||
try:
|
||||
os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
|
||||
self._proc.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(os.getpgid(self._proc.pid), signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
self._proc = None
|
||||
|
||||
@property
|
||||
def vivo(self) -> bool:
|
||||
return bool(self._proc and self._proc.poll() is None)
|
||||
|
||||
# ---------------------------------------------------------- transcribir
|
||||
|
||||
def transcribe(self, wav: str) -> str:
|
||||
if not self.vivo and not self.arranca():
|
||||
return ""
|
||||
try:
|
||||
with open(wav, "rb") as f:
|
||||
datos = f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
lim = "----jarvis"
|
||||
cuerpo = (
|
||||
f"--{lim}\r\n"
|
||||
f'Content-Disposition: form-data; name="file"; filename="a.wav"\r\n'
|
||||
f"Content-Type: audio/wav\r\n\r\n"
|
||||
).encode() + datos + f"\r\n--{lim}--\r\n".encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{self._puerto}/inference", data=cuerpo,
|
||||
headers={"Content-Type": f"multipart/form-data; boundary={lim}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
return json.loads(r.read().decode()).get("text", "").strip()
|
||||
except (urllib.error.URLError, json.JSONDecodeError, OSError):
|
||||
return ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------- el filtro
|
||||
|
||||
# Lo que Whisper devuelve cuando no habia nadie hablando. No es un fallo suyo:
|
||||
# se entreno con subtitulos de YouTube, donde el silencio se anota asi.
|
||||
SOLO_ETIQUETA = re.compile(r"^\s*[\[\(\*].{0,40}[\]\)\*]\s*[.!¡]*\s*$")
|
||||
MULETILLAS = (
|
||||
"suscribete", "suscríbete", "gracias por ver", "hasta la proxima",
|
||||
"hasta la próxima", "no olvides suscribirte", "dale like",
|
||||
)
|
||||
|
||||
# Anotaciones que Whisper escribe cuando no hay nadie hablando. Salen de contar
|
||||
# el registro real de JARVIS, no de imaginarlas: son las que mas se repiten
|
||||
# entre las frases cortas que no llegan a ninguna accion.
|
||||
#
|
||||
# A veces vienen entre corchetes —y entonces las coge SOLO_ETIQUETA— y a veces
|
||||
# sueltas, que es para lo que sirve esta lista.
|
||||
ANOTACIONES = {
|
||||
"musica", "música", "motor", "risas", "risa", "aplausos", "cantando",
|
||||
"ruido", "silencio", "tos", "suspiro", "aplauso", "llanto", "gritos",
|
||||
"ah", "ahh", "ahhh", "eh", "ehh", "mmm", "hmm", "uf", "ay",
|
||||
}
|
||||
|
||||
# Lo que SI se deja pasar aunque no lleve a ninguna accion: "hola", "adios",
|
||||
# "gracias", "buenas". Una persona las dice de verdad y merecen respuesta; lo
|
||||
# unico que cuesta dejarlas pasar es una consulta al modelo. Filtrar de mas
|
||||
# aqui es peor que filtrar de menos: JARVIS parecería sordo a un saludo.
|
||||
|
||||
|
||||
def es_ruido(texto: str) -> bool:
|
||||
"""Si esto es Whisper alucinando en vez de alguien hablando.
|
||||
|
||||
Se aplica ANTES del emparejador y antes de anotar el fallo. Sin esto, el
|
||||
16,2 % del registro de "no entendido" son "[Musica]" y "(aplausos)", y la
|
||||
mejora nocturna acaba generando alias a partir de ellos.
|
||||
|
||||
Conservador a proposito. Lo que se cuela solo cuesta una consulta al modelo;
|
||||
lo que se filtra de mas es una orden que JARVIS ignora, y eso se vive como
|
||||
que no funciona.
|
||||
"""
|
||||
if not texto or not texto.strip():
|
||||
return True
|
||||
if SOLO_ETIQUETA.match(texto):
|
||||
return True
|
||||
limpio = texto.lower().strip(" .,!¡?¿*-—")
|
||||
if limpio in ANOTACIONES:
|
||||
return True
|
||||
return any(m in limpio for m in MULETILLAS)
|
||||
169
nucleo/pruebas/banco.py
Executable file
169
nucleo/pruebas/banco.py
Executable file
|
|
@ -0,0 +1,169 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Banco fijo para medir el emparejador sin volver a grabar.
|
||||
|
||||
banco.py --crear [n] graba y transcribe una vez (lento)
|
||||
banco.py --mide puntua el catalogo actual (instantaneo)
|
||||
banco.py --fallos enseña que se sigue fallando
|
||||
|
||||
## Por que hace falta
|
||||
|
||||
La mejora nocturna medía generando audio nuevo cada ronda: nueve horas, 39
|
||||
rondas, +1,45 puntos de mejora contra ±1,9 puntos de ruido de medida. El ruido
|
||||
era mayor que la mejora, asi que "¿esto mejora o empeora?" se decidia a cara o
|
||||
cruz. Hoy ha vuelto a pasar: los flags de Whisper parecian dar +5 puntos con 40
|
||||
muestras y con 150 no dan nada.
|
||||
|
||||
Aqui se graba UNA vez con semilla fija y se guardan las TRANSCRIPCIONES. A
|
||||
partir de ahi, probar un cambio en el emparejador es instantaneo y determinista:
|
||||
la misma entrada siempre, asi que dos medidas son comparables de verdad.
|
||||
|
||||
## La trampa que ya costo una conclusion equivocada
|
||||
|
||||
El 24 % del catalogo son acciones que piden argumento. Diciendolas sueltas
|
||||
—"busca el texto"— el emparejador NO las dispara, y hace bien: no son una orden.
|
||||
Un banco que las dice sueltas mide 56 % donde la realidad es 79 %, y hace
|
||||
parecer que el emparejador esta roto cuando lo que esta roto es el banco.
|
||||
Por eso `--crear` les añade argumento.
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, AQUI)
|
||||
|
||||
from manos.acciones import Catalogo, normaliza # noqa: E402
|
||||
from oido.whisper import Oido, es_ruido # noqa: E402
|
||||
|
||||
RAIZ = os.path.dirname(AQUI)
|
||||
PIPER = os.path.join(RAIZ, "config", "jarvis-piper.sh")
|
||||
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
|
||||
BANCO = os.path.join(AQUI, "datos", "banco.json")
|
||||
|
||||
# Fijas a proposito: son lo que hace el banco reproducible. Cambiarlas obliga a
|
||||
# rehacerlo, y esa es justamente la señal de que ya no es comparable.
|
||||
VOCES = ["es_ES-davefx-medium", "es_MX-claude-high", "es_ES-sharvard-medium"]
|
||||
SEMILLA = 20260815
|
||||
ARGUMENTOS = ["informes", "descargas", "musica", "el proyecto", "documentos"]
|
||||
|
||||
|
||||
def crear(n: int) -> int:
|
||||
cat = Catalogo([CATALOGO])
|
||||
rnd = random.Random(SEMILLA)
|
||||
conf = [a for a in cat.acciones if a.frases]
|
||||
|
||||
casos = []
|
||||
for a in rnd.sample(conf, min(n, len(conf))):
|
||||
frase = rnd.choice(a.frases)
|
||||
if a.captura:
|
||||
frase += " " + rnd.choice(ARGUMENTOS)
|
||||
casos.append({"id": a.id, "frase": frase, "voz": rnd.choice(VOCES),
|
||||
"captura": a.captura})
|
||||
|
||||
print(f" grabando {len(casos)} frases...", flush=True)
|
||||
tmp = tempfile.mkdtemp(prefix="banco")
|
||||
for i, c in enumerate(casos):
|
||||
wav = os.path.join(tmp, f"{i:03d}.wav")
|
||||
subprocess.run([PIPER, wav, c["frase"]],
|
||||
env={**os.environ, "JARVIS_PIPER_VOZ": c["voz"],
|
||||
"JARVIS_PIPER_TONO": "1.0"},
|
||||
capture_output=True, timeout=90)
|
||||
c["wav"] = wav if os.path.exists(wav) and os.path.getsize(wav) else None
|
||||
|
||||
oreja = Oido(modelo="small", beam=5)
|
||||
if not oreja.arranca():
|
||||
print(" whisper no arranco")
|
||||
return 2
|
||||
print(" transcribiendo...", flush=True)
|
||||
for i, c in enumerate(casos):
|
||||
c["oido"] = oreja.transcribe(c["wav"]) if c["wav"] else ""
|
||||
c.pop("wav", None)
|
||||
if (i + 1) % 40 == 0:
|
||||
print(f" {i+1}/{len(casos)}", flush=True)
|
||||
oreja.para()
|
||||
subprocess.run(["rm", "-rf", tmp])
|
||||
|
||||
casos = [c for c in casos if c.get("oido")]
|
||||
os.makedirs(os.path.dirname(BANCO), exist_ok=True)
|
||||
with open(BANCO, "w", encoding="utf-8") as f:
|
||||
json.dump({"semilla": SEMILLA, "modelo": "small", "casos": casos},
|
||||
f, ensure_ascii=False, indent=1)
|
||||
print(f" guardado: {len(casos)} transcripciones en {BANCO}")
|
||||
return 0
|
||||
|
||||
|
||||
def carga():
|
||||
try:
|
||||
with open(BANCO, encoding="utf-8") as f:
|
||||
return json.load(f)["casos"]
|
||||
except (OSError, json.JSONDecodeError, KeyError):
|
||||
return []
|
||||
|
||||
|
||||
def mide(detalle=False) -> int:
|
||||
casos = carga()
|
||||
if not casos:
|
||||
print(" no hay banco: crealo con --crear")
|
||||
return 1
|
||||
cat = Catalogo([CATALOGO])
|
||||
|
||||
bien = 0
|
||||
oye_mal, no_relaciona, otra, ruido = [], [], [], []
|
||||
for c in casos:
|
||||
t = c["oido"]
|
||||
if es_ruido(t):
|
||||
ruido.append(c)
|
||||
continue
|
||||
a, _ = cat.busca(t)
|
||||
if a is not None and a.id == c["id"]:
|
||||
bien += 1
|
||||
continue
|
||||
p = difflib.SequenceMatcher(None, normaliza(t), normaliza(c["frase"])).ratio()
|
||||
if p < 0.75:
|
||||
oye_mal.append((c, t, p))
|
||||
elif a is None:
|
||||
no_relaciona.append((c, t, p))
|
||||
else:
|
||||
otra.append((c, t, a.id))
|
||||
|
||||
n = len(casos)
|
||||
print(f" {n} frases · acierto {100*bien/n:.1f} %\n")
|
||||
print(f" {'que pasa':28s} {'n':>4s} {'%':>7s}")
|
||||
print(" " + "-" * 44)
|
||||
for nombre, k in [("bien", bien), ("oye bien, NO RELACIONA", len(no_relaciona)),
|
||||
("OYE MAL la frase", len(oye_mal)),
|
||||
("oye bien, OTRA accion", len(otra)),
|
||||
("lo tira por ruido", len(ruido))]:
|
||||
print(f" {nombre:28s} {k:4d} {100*k/n:6.1f} %")
|
||||
|
||||
if detalle:
|
||||
print("\n --- oye bien pero no relaciona ---")
|
||||
for c, t, p in no_relaciona:
|
||||
print(f" {c['id']:24s} dijo: {c['frase']}")
|
||||
print(f" {'':24s} oyo: {t} ({p:.2f})")
|
||||
print("\n --- se la lleva otra accion (lo peor: ejecuta lo que no es) ---")
|
||||
for c, t, o in otra:
|
||||
print(f" {c['id']:24s} -> {o} oyo: {t}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--crear", action="store_true")
|
||||
p.add_argument("--mide", action="store_true")
|
||||
p.add_argument("--fallos", action="store_true")
|
||||
p.add_argument("n", nargs="?", type=int, default=150)
|
||||
a = p.parse_args()
|
||||
if a.crear:
|
||||
return crear(a.n)
|
||||
return mide(detalle=a.fallos)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
133
nucleo/pruebas/prueba_candado.py
Executable file
133
nucleo/pruebas/prueba_candado.py
Executable file
|
|
@ -0,0 +1,133 @@
|
|||
#!/usr/bin/env python3
|
||||
"""El candado: que tape, que rechace lo que no es, y que NO obedezca.
|
||||
|
||||
./venv/bin/python pruebas/prueba_candado.py
|
||||
|
||||
Lo que de verdad importa aqui no es que el panel se ponga negro —eso es
|
||||
cosmetica— sino que con el candado puesto **no se ejecute nada**. El panel tapado
|
||||
pero obedeciendo seria peor que no tener candado, porque da sensacion de
|
||||
seguridad sin darla.
|
||||
|
||||
No se prueba con la contraseña de verdad: no la tenemos ni hace falta. Se
|
||||
comprueba que una mala se rechaza y que mientras tanto no pasa ni una orden.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, AQUI)
|
||||
|
||||
from boca.voz import Boca # noqa: E402
|
||||
from cara.panel import Panel # noqa: E402
|
||||
from manos import Manos # noqa: E402
|
||||
|
||||
fallos = []
|
||||
|
||||
|
||||
def afirma(condicion, queja):
|
||||
print(f" {'OK ' if condicion else 'MAL'} {queja}")
|
||||
if not condicion:
|
||||
fallos.append(queja)
|
||||
|
||||
|
||||
class CaraFalsa:
|
||||
"""Se queda con lo que el panel manda, sin levantar ningun servidor."""
|
||||
|
||||
def __init__(self):
|
||||
self.mensajes = []
|
||||
self.conectados = 1
|
||||
|
||||
def manda(self, tipo, datos):
|
||||
self.mensajes.append((tipo, datos))
|
||||
|
||||
def de(self, tipo):
|
||||
return [d for t, d in self.mensajes if t == tipo]
|
||||
|
||||
|
||||
class BocaMuda(Boca):
|
||||
"""Como la de verdad pero sin sonar: la prueba no tiene que hablar."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("davefx")
|
||||
self.dicho = []
|
||||
|
||||
def di(self, texto):
|
||||
self.dicho.append(texto); return True
|
||||
|
||||
def di_si_libre(self, texto):
|
||||
self.dicho.append(texto); return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
manos = Manos()
|
||||
cara = CaraFalsa()
|
||||
boca = BocaMuda()
|
||||
p = Panel(cara, boca, manos, candado=True)
|
||||
|
||||
print("\n al abrir")
|
||||
p.arranca()
|
||||
time.sleep(0.2)
|
||||
afirma(not p.desbloqueado, "nace bloqueado")
|
||||
afirma(bool(cara.de("bloqueo")), "pide la contraseña nada mas abrir")
|
||||
|
||||
print("\n con el candado puesto NO obedece")
|
||||
antes = len(cara.mensajes)
|
||||
for orden in ("prueba", "voz:siguiente", "parar"):
|
||||
p.pulsado("accion", orden)
|
||||
time.sleep(1.0)
|
||||
afirma(not boca.dicho, f"no ha hablado ({boca.dicho})")
|
||||
# Desde que la tanda va por tareas, lo que se ve al ejecutar son mensajes
|
||||
# "tarea" (una tarjeta por orden), no "respuesta".
|
||||
hechas = [d for t, d in cara.mensajes[antes:] if t in ("tarea", "respuesta")]
|
||||
afirma(not hechas, f"no ha ejecutado nada ({len(hechas)} tareas o respuestas)")
|
||||
# Solo se miran las respuestas a lo pulsado: la telemetria sigue latiendo
|
||||
# en su hilo cada segundo y se cuela entre medias.
|
||||
contestaciones = [t for t, _ in cara.mensajes[antes:] if t != "telemetria"]
|
||||
afirma(contestaciones and all(t == "bloqueo" for t in contestaciones),
|
||||
f"a cada intento contesta pidiendo la contraseña ({contestaciones})")
|
||||
|
||||
print("\n una contraseña mala se rechaza")
|
||||
antes = len(cara.mensajes)
|
||||
p.pulsado("accion", "desbloquear:no-es-esta-contrasena-12345")
|
||||
time.sleep(0.5)
|
||||
afirma(not p.desbloqueado, "sigue bloqueado")
|
||||
ultimos = cara.de("bloqueo")[-1:] or [{}]
|
||||
afirma(ultimos[0].get("error") is True, "y lo dice como error")
|
||||
afirma(not cara.de("desbloqueado"), "no manda 'desbloqueado'")
|
||||
|
||||
print("\n una vacia ni se intenta")
|
||||
antes = len(cara.mensajes)
|
||||
p.pulsado("accion", "desbloquear:")
|
||||
afirma(len(cara.mensajes) == antes, "no molesta a sudo con una clave vacia")
|
||||
|
||||
print("\n sin candado, obedece")
|
||||
cara2, boca2 = CaraFalsa(), BocaMuda()
|
||||
p2 = Panel(cara2, boca2, manos, candado=False)
|
||||
p2.arranca()
|
||||
afirma(p2.desbloqueado, "nace desbloqueado")
|
||||
afirma(not cara2.de("bloqueo"), "no pide contraseña")
|
||||
p2.pulsado("accion", "prueba")
|
||||
time.sleep(3.0)
|
||||
tarjetas = {d["id"] for d in cara2.de("tarea")}
|
||||
afirma(bool(tarjetas), f"ejecuta de verdad ({len(tarjetas)} tarjetas)")
|
||||
afirma(any(d["estado"] == "hecha" for d in cara2.de("tarea")),
|
||||
"y las tareas llegan a terminar")
|
||||
|
||||
print("\n y se puede volver a echar")
|
||||
p2.bloquea()
|
||||
afirma(not p2.desbloqueado, "bloquea() cierra aunque arrancara sin candado")
|
||||
antes = len(cara2.mensajes)
|
||||
p2.pulsado("accion", "prueba")
|
||||
time.sleep(0.6)
|
||||
afirma(not [d for t, d in cara2.mensajes[antes:] if t in ("tarea", "respuesta")],
|
||||
"y despues de cerrar ya no obedece")
|
||||
|
||||
p.para(); p2.para()
|
||||
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
|
||||
return 1 if fallos else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
122
nucleo/pruebas/prueba_cara.py
Executable file
122
nucleo/pruebas/prueba_cara.py
Executable file
|
|
@ -0,0 +1,122 @@
|
|||
#!/usr/bin/env python3
|
||||
"""El panel: que lo que manda el nucleo sea lo que el HUD sabe leer.
|
||||
|
||||
./venv/bin/python pruebas/prueba_cara.py
|
||||
|
||||
## Por que existe esta prueba
|
||||
|
||||
Montando la telemetria, `ps` devolvia el uso de CPU como cadena y el nucleo la
|
||||
mandaba tal cual. El HUD hace `p.cpu.toFixed(0)`, y una cadena no tiene
|
||||
`toFixed`: la excepcion abortaba el manejador ENTERO y se llevaba por delante
|
||||
todo lo que se pinta despues. En pantalla se veia que CPU, RAM y grafica
|
||||
salian en "--", y el fallo no apuntaba a los procesos por ningun lado.
|
||||
|
||||
Ese es el problema de un puente sin contrato: un campo con el tipo equivocado
|
||||
apaga media pantalla y no dice donde. Aqui se fija el contrato —que campo, de
|
||||
que tipo y en que unidad— comprobando ademas que `hud.js` lo sigue usando asi,
|
||||
para que enterarse no dependa de mirar la pantalla.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, AQUI)
|
||||
|
||||
from cara.panel import Telemetria # noqa: E402
|
||||
|
||||
HUD = os.path.join(AQUI, "cara", "hud")
|
||||
|
||||
fallos = []
|
||||
|
||||
|
||||
def afirma(condicion, queja):
|
||||
print(f" {'OK ' if condicion else 'MAL'} {queja}")
|
||||
if not condicion:
|
||||
fallos.append(queja)
|
||||
|
||||
|
||||
def numero(v):
|
||||
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("\n el puente esta puesto")
|
||||
with open(os.path.join(HUD, "index.html"), encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
afirma("puente.js" in html, "index.html carga puente.js")
|
||||
# Se comparan las etiquetas <script>, no el texto suelto: el comentario que
|
||||
# hay encima menciona hud.js y una busqueda ingenua lo encuentra antes.
|
||||
scripts = re.findall(r'<script\s+src="([^"]+)"', html)
|
||||
afirma("puente.js" in scripts and "hud.js" in scripts
|
||||
and scripts.index("puente.js") < scripts.index("hud.js"),
|
||||
"y lo carga ANTES que hud.js, que pregunta por window.webkit al arrancar")
|
||||
with open(os.path.join(HUD, "puente.js"), encoding="utf-8") as f:
|
||||
puente = f.read()
|
||||
afirma("WebSocket" in puente, "el puente habla por WebSocket")
|
||||
afirma("messageHandlers" in puente,
|
||||
"suplanta window.webkit, asi hud.js no se toca")
|
||||
afirma("cola" in puente, "guarda lo pulsado mientras reconecta")
|
||||
|
||||
print("\n la telemetria: campos, tipos y unidades")
|
||||
d = Telemetria().lee()
|
||||
Telemetria().lee() # el segundo tiene ya las diferencias de cpu
|
||||
|
||||
afirma(numero(d.get("cpu")), "cpu es un numero")
|
||||
afirma(isinstance(d.get("nucleos"), list) and all(numero(x) for x in d["nucleos"]),
|
||||
f"nucleos es una lista de numeros ({len(d.get('nucleos', []))})")
|
||||
for clave in ("ram", "swap"):
|
||||
v = d.get(clave, {})
|
||||
afirma(numero(v.get("usada")) and numero(v.get("total")),
|
||||
f"{clave} lleva usada y total")
|
||||
afirma(0 < v.get("total", 0) < 1024,
|
||||
f"{clave} viene en GB, no en MB ({v.get('total', 0):.1f})")
|
||||
disco = d.get("disco", {})
|
||||
afirma(0 < disco.get("total", 0) < 200,
|
||||
f"disco viene en TB, no en GB ({disco.get('total', 0):.2f})")
|
||||
afirma(isinstance(d.get("carga"), list),
|
||||
"carga es una lista: el HUD hace carga.map()")
|
||||
afirma(d.get("uptime", 0) > 60,
|
||||
"uptime va en segundos: el HUD decide solo si pone minutos o dias")
|
||||
|
||||
if "gpu" in d:
|
||||
g = d["gpu"]
|
||||
afirma(all(numero(g.get(k)) for k in ("uso", "vram_usada", "vram_total")),
|
||||
"la grafica manda uso, vram_usada y vram_total")
|
||||
afirma(0 < g.get("vram_total", 0) < 128,
|
||||
f"la vram viene en GB ({g.get('vram_total', 0):.1f})")
|
||||
|
||||
print("\n los procesos: el campo que apago media pantalla")
|
||||
procs = d.get("procesos", [])
|
||||
afirma(bool(procs), f"llegan procesos ({len(procs)})")
|
||||
for p in procs[:3]:
|
||||
afirma(numero(p.get("cpu")) and numero(p.get("mem")),
|
||||
f"{p.get('nombre','?')}: cpu y mem son numeros, no cadenas")
|
||||
afirma(all("pid" in p for p in procs), "todos traen pid, que el HUD pone en el title")
|
||||
|
||||
print("\n y el HUD sigue esperando eso mismo")
|
||||
with open(os.path.join(HUD, "hud.js"), encoding="utf-8") as f:
|
||||
js = f.read()
|
||||
# Si alguien cambia hud.js para leer otro campo, esta prueba no lo sabria
|
||||
# mirando solo al nucleo: se comprueba en el propio JavaScript.
|
||||
for campo in ("datos.cpu", "datos.ram", "datos.swap", "datos.disco",
|
||||
"datos.nucleos", "datos.procesos"):
|
||||
afirma(campo in js, f"hud.js lee {campo}")
|
||||
afirma("p.cpu.toFixed" in js,
|
||||
"hud.js llama a toFixed sobre el cpu de cada proceso (por eso tiene que ser numero)")
|
||||
|
||||
# Todo lo que se manda tiene que poder viajar por el socket.
|
||||
print("\n serializable")
|
||||
try:
|
||||
json.dumps(d)
|
||||
afirma(True, "la telemetria entera cabe en JSON")
|
||||
except (TypeError, ValueError) as e:
|
||||
afirma(False, f"la telemetria no es JSON: {e}")
|
||||
|
||||
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
|
||||
return 1 if fallos else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
133
nucleo/pruebas/prueba_dialogo.py
Executable file
133
nucleo/pruebas/prueba_dialogo.py
Executable file
|
|
@ -0,0 +1,133 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Pulsar una accion PREGUNTA lo que falta y la hace.
|
||||
|
||||
./venv/bin/python pruebas/prueba_dialogo.py
|
||||
|
||||
Antes, pulsar en "sabe hacer" recitaba la frase que dispara la accion. Eso es un
|
||||
manual de instrucciones, y uno pulsa justo porque no quiere leerse el manual.
|
||||
|
||||
Lo que se comprueba, por orden de importancia:
|
||||
|
||||
1. que lo destructivo NO se dispare de un clic
|
||||
2. que lo destructivo CON argumento pregunte las DOS cosas
|
||||
3. que un "no" cancele de verdad
|
||||
4. que la pregunta caduque, para que una frase suelta media conversacion
|
||||
despues no se tome por la respuesta
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, AQUI)
|
||||
|
||||
from cara.panel import Panel # noqa: E402
|
||||
from manos import Manos # noqa: E402
|
||||
from manos.preguntar import pregunta_por, es_si # noqa: E402
|
||||
|
||||
fallos = []
|
||||
|
||||
|
||||
def afirma(condicion, queja):
|
||||
print(f" {'OK ' if condicion else 'MAL'} {queja}")
|
||||
if not condicion:
|
||||
fallos.append(queja)
|
||||
|
||||
|
||||
class CaraFalsa:
|
||||
conectados = 1
|
||||
def __init__(self): self.m = []
|
||||
def manda(self, t, d): self.m.append((t, d))
|
||||
|
||||
|
||||
class BocaMuda:
|
||||
voz = "davefx"
|
||||
def __init__(self): self.dicho = []
|
||||
def di(self, t): self.dicho.append(t); return True
|
||||
def di_si_libre(self, t): return self.di(t)
|
||||
def disponibles(self): return ["davefx"]
|
||||
|
||||
|
||||
def nuevo():
|
||||
cara, boca = CaraFalsa(), BocaMuda()
|
||||
return Panel(cara, boca, Manos(), candado=False), boca
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("\n las preguntas salen del catalogo")
|
||||
manos = Manos()
|
||||
conArg = [a for a in manos.catalogo.acciones if a.captura]
|
||||
genericas = [a for a in conArg if pregunta_por(a) == "¿El que, señor?"]
|
||||
afirma(len(conArg) >= 30, f"{len(conArg)} acciones piden argumento")
|
||||
afirma(len(genericas) <= 10,
|
||||
f"solo {len(genericas)} caen en la pregunta generica, de {len(conArg)}")
|
||||
afirma(all(pregunta_por(a).endswith("señor?") for a in conArg),
|
||||
"todas preguntan en el registro de la casa")
|
||||
afirma("cierra" not in [pregunta_por(a).split()[0].strip("¿").lower()
|
||||
for a in conArg],
|
||||
"y ninguna empieza por el verbo de la orden")
|
||||
|
||||
print("\n con argumento: pregunta y ejecuta con lo que le digas")
|
||||
p, boca = nuevo()
|
||||
p.pulsado("accion", "explica:buscar_fichero"); time.sleep(0.2)
|
||||
afirma(bool(p.esperando()), "se queda esperando respuesta")
|
||||
afirma(any("fichero" in d for d in boca.dicho), f"pregunta cual: {boca.dicho}")
|
||||
p.responde("informes"); time.sleep(1.5)
|
||||
tareas = [t.titulo for t in p.tareas.todas()]
|
||||
afirma(any("informes" in t for t in tareas), f"lo ejecuta con el argumento: {tareas}")
|
||||
afirma(not p.esperando(), "y deja de esperar")
|
||||
p.para()
|
||||
|
||||
print("\n lo destructivo NO se dispara de un clic")
|
||||
p, boca = nuevo()
|
||||
p.pulsado("accion", "explica:ff_firefox_cerrar"); time.sleep(0.3)
|
||||
afirma(not p.tareas.todas(), "no ha lanzado nada al pulsar")
|
||||
afirma(bool(p.esperando()), "esta esperando confirmacion")
|
||||
p.responde("no"); time.sleep(0.4)
|
||||
afirma(not p.tareas.todas(), "y con un 'no' sigue sin lanzar nada")
|
||||
p.para()
|
||||
|
||||
print("\n destructivo CON argumento: las dos preguntas")
|
||||
p, boca = nuevo()
|
||||
p.pulsado("accion", "explica:proceso_matar"); time.sleep(0.2)
|
||||
afirma(bool(p.esperando()), "pregunta el argumento")
|
||||
p.responde("firefox"); time.sleep(0.3)
|
||||
afirma(bool(p.esperando()),
|
||||
"y DESPUES pide confirmacion, no ejecuta directo")
|
||||
afirma(any("firefox" in d for d in boca.dicho),
|
||||
"la confirmacion dice sobre que va, no un cheque en blanco")
|
||||
p.responde("no"); time.sleep(0.4)
|
||||
afirma(not p.tareas.todas(), "un 'no' lo cancela")
|
||||
p.para()
|
||||
|
||||
print("\n lo que no pregunta nada, se hace")
|
||||
p, boca = nuevo()
|
||||
p.pulsado("accion", "explica:disco_libre"); time.sleep(1.5)
|
||||
afirma(bool(p.tareas.todas()), "una accion sin argumento ni riesgo se ejecuta")
|
||||
afirma(not p.esperando(), "sin preguntar nada")
|
||||
p.para()
|
||||
|
||||
print("\n la pregunta caduca")
|
||||
p, boca = nuevo()
|
||||
p.ESPERA_S = 0.5
|
||||
p.pulsado("accion", "explica:buscar_fichero"); time.sleep(0.2)
|
||||
afirma(bool(p.esperando()), "recien preguntada, espera")
|
||||
time.sleep(0.8)
|
||||
afirma(not p.esperando(),
|
||||
"pasado el plazo la olvida: una frase suelta media conversacion "
|
||||
"despues no puede tomarse por la respuesta")
|
||||
p.para()
|
||||
|
||||
print("\n el si y el no, como los dice la gente")
|
||||
for t, esperado in [("si", True), ("Sí.", True), ("vale", True),
|
||||
("claro que si", True), ("no", False), ("dejalo", False),
|
||||
("no, mejor no", False), ("informes", None),
|
||||
("el fichero de agosto", None)]:
|
||||
afirma(es_si(t) is esperado, f"{t!r} -> {es_si(t)}")
|
||||
|
||||
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
|
||||
return 1 if fallos else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
159
nucleo/pruebas/prueba_eco.py
Executable file
159
nucleo/pruebas/prueba_eco.py
Executable file
|
|
@ -0,0 +1,159 @@
|
|||
#!/usr/bin/env python3
|
||||
"""La prueba que Newelle no pasa: que JARVIS no se oiga a si mismo.
|
||||
|
||||
En Newelle, mas del 21 % de lo que oye y no entiende es su propia voz. Esta
|
||||
prueba lo comprueba de verdad, con el microfono y los altavoces, en dos partes:
|
||||
|
||||
A. Suena una frase POR FUERA de JARVIS (un reproductor a pelo, como si
|
||||
hablara una persona). Tiene que captarla. Si no, el microfono no llega a
|
||||
los altavoces y la parte B no demuestra nada.
|
||||
|
||||
B. La misma frase, pero dicha POR JARVIS. NO tiene que captarla.
|
||||
|
||||
Las dos partes juntas son la prueba: A sola no dice nada, B sola podria pasar
|
||||
simplemente porque el microfono no oye nada.
|
||||
|
||||
./venv/bin/python pruebas/prueba_eco.py
|
||||
|
||||
Necesita altavoces encendidos y a un volumen normal. Si estan en silencio, la
|
||||
parte A falla y avisa.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, AQUI)
|
||||
|
||||
from boca.voz import Boca # noqa: E402
|
||||
from oido.captura import Captura # noqa: E402
|
||||
from oido.whisper import Oido, es_ruido # noqa: E402
|
||||
|
||||
FRASE = "El sistema esta operativo y a su disposicion, señor."
|
||||
ESPERA = 14 # cuanto se escucha en cada parte
|
||||
|
||||
|
||||
def _se_parece(oido: str, dicho: str) -> bool:
|
||||
"""Si lo transcrito es la frase que se dijo, aunque venga maltratada.
|
||||
|
||||
Se compara por palabras y no por parecido de cadena: un eco recortado
|
||||
("operativo y a su disposicion") no se parece a la frase entera como
|
||||
cadena, pero comparte casi todas sus palabras, y eso es inequivoco.
|
||||
"""
|
||||
import unicodedata
|
||||
|
||||
def palabras(t):
|
||||
t = unicodedata.normalize("NFD", t.lower())
|
||||
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||
return {p.strip(".,¡!¿?") for p in t.split() if len(p) > 3}
|
||||
|
||||
a, b = palabras(oido), palabras(dicho)
|
||||
return bool(b) and len(a & b) >= 2
|
||||
|
||||
|
||||
def escucha_un_rato(captura, oreja, segundos):
|
||||
"""Devuelve lo que se haya oido en ese rato."""
|
||||
oido = []
|
||||
fin = time.monotonic() + segundos
|
||||
hilo_paro = threading.Event()
|
||||
|
||||
def bucle():
|
||||
for wav in captura.frases(para_cuando=lambda: hilo_paro.is_set()):
|
||||
try:
|
||||
t = oreja.transcribe(wav)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(wav)
|
||||
except OSError:
|
||||
pass
|
||||
if t and not es_ruido(t):
|
||||
oido.append(t)
|
||||
|
||||
h = threading.Thread(target=bucle, daemon=True)
|
||||
h.start()
|
||||
while time.monotonic() < fin:
|
||||
time.sleep(0.2)
|
||||
hilo_paro.set()
|
||||
captura.para()
|
||||
h.join(timeout=5)
|
||||
return oido
|
||||
|
||||
|
||||
def main() -> int:
|
||||
boca = Boca("davefx")
|
||||
oreja = Oido(modelo="small")
|
||||
if not oreja.disponible():
|
||||
print(" no esta whisper o el modelo; no se puede probar")
|
||||
return 2
|
||||
print(" arrancando whisper...")
|
||||
if not oreja.arranca():
|
||||
print(" whisper no arranco")
|
||||
return 2
|
||||
|
||||
# Se genera el WAV una vez para poder reproducirlo por los dos caminos con
|
||||
# exactamente el mismo audio.
|
||||
wav = boca._genera(FRASE)
|
||||
if not wav:
|
||||
print(" no se pudo generar la voz")
|
||||
return 2
|
||||
|
||||
fallos = 0
|
||||
|
||||
# --- A: suena por fuera, tiene que oirse -----------------------------
|
||||
print("\n A · suena por fuera (como si hablara alguien)")
|
||||
captura = Captura(boca=boca)
|
||||
def suelta_fuera():
|
||||
time.sleep(2.5)
|
||||
subprocess.run(["paplay", wav], capture_output=True)
|
||||
threading.Thread(target=suelta_fuera, daemon=True).start()
|
||||
oido_a = escucha_un_rato(captura, oreja, ESPERA)
|
||||
for t in oido_a:
|
||||
print(f" oido: {t!r}")
|
||||
if oido_a:
|
||||
print(" OK el microfono llega a los altavoces")
|
||||
else:
|
||||
print(" MAL no se oyo nada: ¿altavoces en silencio? sin esto la")
|
||||
print(" parte B no demuestra nada")
|
||||
fallos += 1
|
||||
|
||||
time.sleep(1.5)
|
||||
|
||||
# --- B: lo dice JARVIS, NO tiene que oirse ---------------------------
|
||||
print("\n B · lo dice JARVIS (la guarda tiene que taparlo)")
|
||||
captura = Captura(boca=boca)
|
||||
def suelta_jarvis():
|
||||
time.sleep(2.5)
|
||||
boca.di(FRASE)
|
||||
threading.Thread(target=suelta_jarvis, daemon=True).start()
|
||||
oido_b = escucha_un_rato(captura, oreja, ESPERA)
|
||||
|
||||
# Lo que se juzga es si se colo LA FRASE, no si se transcribio algo.
|
||||
#
|
||||
# Esto costo una conclusion equivocada: probando con colas de 350, 700 y
|
||||
# 1100 ms el resultado no era monotono —1100 fallaba y 700 no—, lo cual es
|
||||
# imposible si el unico factor fuera el eco. Mirando lo que se colaba, eran
|
||||
# "¡Adios!" y un parrafo inventado: Whisper ALUCINANDO sobre el silencio,
|
||||
# que es otro problema distinto y da igual lo larga que sea la guarda.
|
||||
eco = [t for t in oido_b if _se_parece(t, FRASE)]
|
||||
alucinado = [t for t in oido_b if t not in eco]
|
||||
|
||||
for t in eco:
|
||||
print(f" ECO: {t!r}")
|
||||
for t in alucinado:
|
||||
print(f" (whisper alucinando sobre el silencio: {t[:52]!r})")
|
||||
|
||||
if not eco:
|
||||
print(" OK JARVIS no se oye a si mismo")
|
||||
else:
|
||||
print(" MAL se ha oido hablar: la guarda no tapa")
|
||||
fallos += 1
|
||||
|
||||
oreja.para()
|
||||
print(f"\n {'todo en orden' if not fallos else str(fallos) + ' fallan'}")
|
||||
return 1 if fallos else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
173
nucleo/pruebas/prueba_manos.py
Executable file
173
nucleo/pruebas/prueba_manos.py
Executable file
|
|
@ -0,0 +1,173 @@
|
|||
#!/usr/bin/env python3
|
||||
"""El emparejador: que siga acertando y, sobre todo, que no se invente ordenes.
|
||||
|
||||
./venv/bin/python pruebas/prueba_manos.py
|
||||
|
||||
Porta las comprobaciones de `pruebas/08_acciones.sh` de la epoca de Newelle y
|
||||
añade las que hacen falta ahora que el emparejador compara tambien por sonido.
|
||||
|
||||
La regla que ordena este fichero: **equivocarse cuesta mas que no reaccionar.**
|
||||
Esto lanza comandos en la maquina; que no entienda una orden es molesto, que
|
||||
ejecute la que no es puede costar trabajo perdido. Por eso las comprobaciones de
|
||||
falsos positivos son las que mandan, y por eso el suelo de acierto va aparte y
|
||||
mas bajo de lo medido: para que una mejora legitima no obligue a tocar el test,
|
||||
pero una regresion de verdad lo tire.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, AQUI)
|
||||
|
||||
from manos.acciones import Catalogo # noqa: E402
|
||||
from manos.fonetica import fonetica # noqa: E402
|
||||
from oido.whisper import es_ruido # noqa: E402
|
||||
|
||||
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
|
||||
BANCO = os.path.join(AQUI, "datos", "banco.json")
|
||||
|
||||
# Medido el 15 de agosto de 2026: 84,7 %. El suelo se pone tres puntos por
|
||||
# debajo para dejar sitio al ruido de un banco de 150, donde una frase es 0,67.
|
||||
SUELO = 81.0
|
||||
|
||||
# Lo que NO puede disparar una accion jamas. Las cinco primeras vienen del test
|
||||
# 08; el resto son conversacion que empieza igual que una orden, que es
|
||||
# exactamente el riesgo de comparar por sonido.
|
||||
CHARLA = [
|
||||
"necesito un abrazo", "quiero contarte una cosa", "necesito pensar",
|
||||
"puedes explicarme como funciona el kernel", "me gustaria que fueramos amigos",
|
||||
"que tal estas", "como te encuentras hoy", "cuentame un chiste",
|
||||
"que opinas de la musica electronica", "me apetece un cafe",
|
||||
"hoy hace un dia estupendo", "estoy pensando en cambiar de trabajo",
|
||||
"quien fue alan turing", "explicame la teoria de la relatividad",
|
||||
"me gusta mucho como suena tu voz", "cuanto tiempo llevas funcionando",
|
||||
"no se que hacer este fin de semana", "que peliculas me recomiendas",
|
||||
"he tenido un dia muy largo", "sabes cantar",
|
||||
"no quiero que abras nada", "no busques nada por ahora",
|
||||
"antes buscabas mejor las cosas", "el disco duro de mi hermano se rompio",
|
||||
"la memoria me falla ultimamente", "que temperatura hace en la calle",
|
||||
"me han abierto una cuenta en el banco", "cierro los ojos y pienso",
|
||||
"mi ventana da al patio", "la pantalla del movil se ha roto",
|
||||
"ese proceso judicial fue largo", "tengo un fichero de esos antiguos",
|
||||
"el kernel de la cuestion es otro", "hablame de la red de metro",
|
||||
"pon atencion a lo que te digo", "sube el animo que no pasa nada",
|
||||
"baja la voz que estan durmiendo", "abre tu corazon",
|
||||
"escanea el documento con la impresora", "mata el tiempo como puedas",
|
||||
"el volumen de trabajo es enorme", "que hora era cuando llegaste",
|
||||
"las descargas de agua fueron fuertes", "mi carpeta de fotos favorita",
|
||||
"el brillo de sus ojos", "conecta conmigo emocionalmente",
|
||||
"quiero que sepas que me importa", "necesito que me escuches",
|
||||
"puedes decirme si estoy equivocado", "me gustaria saber tu opinion",
|
||||
]
|
||||
|
||||
# Como habla la gente de verdad. Sin quitar la cortesia no encajaba ninguna:
|
||||
# era el fallo de casi todas las ordenes reales que se perdian.
|
||||
CON_CORTESIA = [
|
||||
("Necesito que pongas la película de Iron Man.", "pelicula_abrir"),
|
||||
("¿Puedes poner la película de Iron Man?", "pelicula_abrir"),
|
||||
("Necesito que me abras una terminal.", "terminal_abrir"),
|
||||
("¿Podrías abrir una terminal?", "terminal_abrir"),
|
||||
("Me puedes decir cuánto espacio queda.", "disco_libre"),
|
||||
]
|
||||
|
||||
fallos = []
|
||||
|
||||
|
||||
def afirma(condicion, queja):
|
||||
if condicion:
|
||||
print(f" OK {queja}")
|
||||
else:
|
||||
print(f" MAL {queja}")
|
||||
fallos.append(queja)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cat = Catalogo([CATALOGO])
|
||||
|
||||
print("\n el catalogo")
|
||||
afirma(len(cat.acciones) >= 150, f"{len(cat.acciones)} acciones cargadas")
|
||||
afirma(not cat.errores, f"sin errores de carga ({len(cat.errores)})")
|
||||
ids = [a.id for a in cat.acciones]
|
||||
afirma(len(ids) == len(set(ids)), "no hay ids repetidos")
|
||||
afirma(all(a.acuse for a in cat.acciones), "todas tienen acuse")
|
||||
afirma(all("{argumento" in a.comando for a in cat.acciones if a.captura),
|
||||
"las que capturan argumento lo usan en el comando")
|
||||
afirma(all(a.confirmar for a in cat.acciones if "rm -rf" in a.comando),
|
||||
"nada que borre se ejecuta sin confirmar")
|
||||
|
||||
print("\n entiende como habla la gente")
|
||||
for frase, esperado in CON_CORTESIA:
|
||||
a, _ = cat.busca(frase)
|
||||
afirma(a is not None and a.id == esperado,
|
||||
f"'{frase[:38]}' -> {esperado}"
|
||||
+ ("" if a is None or a.id == esperado else f" (fue a {a.id})"))
|
||||
|
||||
print("\n y NO se inventa ordenes")
|
||||
coladas = [(c, cat.busca(c)[0]) for c in CHARLA]
|
||||
coladas = [(c, a.id) for c, a in coladas if a is not None]
|
||||
afirma(not coladas,
|
||||
f"ninguna de las {len(CHARLA)} frases de charla dispara nada"
|
||||
+ ("" if not coladas else f" — se colo {coladas[0][0]!r} -> {coladas[0][1]}"))
|
||||
|
||||
print("\n el argumento viene de un microfono: no puede ser una orden")
|
||||
for veneno in ["gatos; rm -rf /", 'algo " ; touch /tmp/jarvis_pwned ; "']:
|
||||
a, arg = cat.busca(f"busca en internet {veneno}")
|
||||
if a is None:
|
||||
fallos.append(f"no encaja la busqueda con {veneno!r}")
|
||||
print(f" MAL no encaja la busqueda con {veneno!r}")
|
||||
continue
|
||||
comando = a.comando.replace("{argumento_url}", shlex.quote(arg))
|
||||
comando = comando.replace("{argumento}", shlex.quote(arg))
|
||||
afirma("; rm" not in comando and "; touch" not in comando,
|
||||
f"entrecomillado: {veneno[:24]!r}")
|
||||
|
||||
print("\n el filtro de alucinaciones de Whisper")
|
||||
for t in ["[Música]", "(aplausos)", "[motor]", "¡Suscríbete!", ""]:
|
||||
afirma(es_ruido(t), f"descarta {t!r}")
|
||||
for t in ["cuanto espacio queda", "abre la terminal"]:
|
||||
afirma(not es_ruido(t), f"deja pasar {t!r}")
|
||||
|
||||
print("\n la comparacion por sonido")
|
||||
# Lo que se afirma es que ACERCA, no que iguale: `parecido()` se queda con
|
||||
# el mayor de los dos parecidos, asi que basta con que el del sonido suba.
|
||||
# Exigir igualdad seria pedirle a esto que fuera un Metaphone, y no lo es
|
||||
# a proposito: comprimir mas junta ordenes que no se parecen.
|
||||
import difflib
|
||||
def sube(a, b):
|
||||
letra = difflib.SequenceMatcher(None, a, b).ratio()
|
||||
sonido = difflib.SequenceMatcher(None, fonetica(a), fonetica(b)).ratio()
|
||||
return sonido > letra
|
||||
afirma(sube("abre van kamp", "abre bandcamp"), "'van kamp' se acerca a 'bandcamp'")
|
||||
afirma(fonetica("haz") == fonetica("has"), "'haz' suena igual que 'has'")
|
||||
afirma(fonetica("llave") == fonetica("yave"), "'yave' suena igual que 'llave'")
|
||||
afirma(fonetica("bino") == fonetica("vino"), "la b y la v son el mismo sonido")
|
||||
# Y lo que importa de verdad: que no acerque lo que no se parece.
|
||||
afirma(not sube("abre la terminal", "apaga la pantalla"),
|
||||
"y NO acerca ordenes distintas")
|
||||
|
||||
print("\n el banco fijo")
|
||||
if not os.path.exists(BANCO):
|
||||
print(" ·· no hay banco; crealo con pruebas/banco.py --crear")
|
||||
else:
|
||||
casos = json.load(open(BANCO, encoding="utf-8"))["casos"]
|
||||
bien = otra = 0
|
||||
for c in casos:
|
||||
a, _ = cat.busca(c["oido"])
|
||||
if a is None:
|
||||
continue
|
||||
if a.id == c["id"]:
|
||||
bien += 1
|
||||
else:
|
||||
otra += 1
|
||||
pct = 100 * bien / len(casos)
|
||||
afirma(pct >= SUELO, f"acierto {pct:.1f} % sobre {len(casos)} (suelo {SUELO})")
|
||||
afirma(otra == 0, f"ninguna orden acaba en la accion equivocada ({otra})")
|
||||
|
||||
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
|
||||
return 1 if fallos else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
243
nucleo/pruebas/prueba_voz.py
Executable file
243
nucleo/pruebas/prueba_voz.py
Executable file
|
|
@ -0,0 +1,243 @@
|
|||
#!/usr/bin/env python3
|
||||
"""La cadena de voz entera, con altavoces y microfono de verdad.
|
||||
|
||||
./venv/bin/python pruebas/prueba_voz.py
|
||||
|
||||
Las otras pruebas miran piezas: que el emparejador acierte, que el candado
|
||||
cierre, que el puente mande los campos que el HUD lee. Ninguna contesta a la
|
||||
pregunta que de verdad importa —**si le hablo, ¿me contesta?**— y por eso el
|
||||
fallo de que oia bien pero descartaba en silencio llego hasta el usuario.
|
||||
|
||||
Esta habla por los altavoces y escucha por el microfono, como una persona. Es
|
||||
lenta y necesita el volumen puesto, pero es la unica que prueba lo que se usa.
|
||||
|
||||
## Como funciona
|
||||
|
||||
Se genera la frase con Piper, se reproduce POR FUERA de la boca de JARVIS —con
|
||||
un reproductor a pelo, para que la guarda del eco no la tape— y se mira que la
|
||||
cadena entera reaccione. Que el microfono llega a los altavoces ya lo demuestra
|
||||
prueba_eco.py; aqui se da por hecho y se avisa si falla.
|
||||
|
||||
## Lo que se comprueba, en orden
|
||||
|
||||
1. sin llamarle se oye pero NO se atiende, y se dice por que
|
||||
2. llamandole se atiende y contesta
|
||||
3. encadenando la siguiente sin repetir el nombre
|
||||
4. como lo oye Whisper "Yarvis" y "Charles" tambien valen
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, AQUI)
|
||||
|
||||
from boca.voz import Boca # noqa: E402
|
||||
from manos import Manos # noqa: E402
|
||||
from oido.captura import Captura # noqa: E402
|
||||
from oido.despierta import Centinela, NOMBRE # noqa: E402
|
||||
from oido.whisper import Oido, es_ruido # noqa: E402
|
||||
from jarvis import atiende # noqa: E402
|
||||
|
||||
ESPERA = 16 # cuanto se escucha por cada frase dicha
|
||||
fallos = []
|
||||
ajenas = [] # lo que se oyo y no era de la prueba: ruido de la sala
|
||||
|
||||
|
||||
def _se_parece(oido: str, dicho: str) -> bool:
|
||||
"""Si lo transcrito es la frase que se reprodujo, aunque venga maltratada.
|
||||
|
||||
Hace falta porque la prueba usa el microfono de la habitacion: si hay una
|
||||
tele, una conversacion o un video sonando, el bucle oye ESO y una prueba
|
||||
ingenua lo da por bueno. Ha pasado —una pasada dio por valida "¡No te
|
||||
olvides de todo ese chico!"— y una prueba que se cree cualquier ruido no
|
||||
prueba nada.
|
||||
"""
|
||||
import unicodedata
|
||||
|
||||
def palabras(t):
|
||||
t = unicodedata.normalize("NFD", (t or "").lower())
|
||||
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||
return {p.strip(".,¡!¿?«»") for p in t.split() if len(p) > 3}
|
||||
|
||||
a, b = palabras(oido), palabras(dicho)
|
||||
if not b:
|
||||
return False
|
||||
return len(a & b) >= max(1, min(2, len(b) - 1))
|
||||
|
||||
|
||||
def afirma(condicion, queja):
|
||||
print(f" {'OK ' if condicion else 'MAL'} {queja}")
|
||||
if not condicion:
|
||||
fallos.append(queja)
|
||||
|
||||
|
||||
class Escucha:
|
||||
"""El bucle de voz de jarvis.py, reducido a lo que hace falta probar."""
|
||||
|
||||
def __init__(self, boca, oreja, centinela):
|
||||
self.boca, self.oreja, self.centinela = boca, oreja, centinela
|
||||
self.manos = Manos()
|
||||
self.oido = [] # (texto, atendido, motivo)
|
||||
self.dicho = [] # lo que ha contestado
|
||||
self._para = threading.Event()
|
||||
self._hilo = None
|
||||
|
||||
def arranca(self):
|
||||
self._hilo = threading.Thread(target=self._bucle, daemon=True)
|
||||
self._hilo.start()
|
||||
|
||||
def _bucle(self):
|
||||
cap = Captura(boca=self.boca)
|
||||
for wav in cap.frases(para_cuando=lambda: self._para.is_set()):
|
||||
try:
|
||||
texto = self.oreja.transcribe(wav)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(wav)
|
||||
except OSError:
|
||||
pass
|
||||
if es_ruido(texto):
|
||||
continue
|
||||
|
||||
atender, orden, _ = self.centinela.filtra(texto)
|
||||
if not atender:
|
||||
self.oido.append((texto, False, "no le han llamado"))
|
||||
continue
|
||||
self.oido.append((texto, True, ""))
|
||||
respuesta = atiende(orden, self.manos, None, traza=lambda t: None)
|
||||
if respuesta:
|
||||
self.dicho.append(respuesta)
|
||||
cap.para()
|
||||
|
||||
def para(self):
|
||||
self._para.set()
|
||||
if self._hilo:
|
||||
self._hilo.join(timeout=6)
|
||||
|
||||
def suelta(self, frase, espera=ESPERA):
|
||||
"""Dice la frase POR FUERA de JARVIS y espera a que reaccione.
|
||||
|
||||
Solo cuenta lo que se PARECE a lo reproducido: si hay ruido en la
|
||||
habitacion, el bucle lo oye tambien y darlo por bueno haria pasar la
|
||||
prueba sin haber probado nada.
|
||||
"""
|
||||
antes_oido, antes_dicho = len(self.oido), len(self.dicho)
|
||||
wav = self.boca._genera(frase)
|
||||
if not wav:
|
||||
return None, None
|
||||
time.sleep(0.6)
|
||||
subprocess.run(["paplay", wav], capture_output=True)
|
||||
|
||||
fin = time.monotonic() + espera
|
||||
mio = None
|
||||
while time.monotonic() < fin:
|
||||
for entrada in self.oido[antes_oido:]:
|
||||
if _se_parece(entrada[0], frase):
|
||||
mio = entrada
|
||||
break
|
||||
if mio and (not mio[1] or len(self.dicho) > antes_dicho):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
|
||||
nuevo_dicho = self.dicho[antes_dicho:]
|
||||
ajeno = [o[0] for o in self.oido[antes_oido:] if o is not mio]
|
||||
if ajeno:
|
||||
ajenas.extend(ajeno)
|
||||
print(f" (ruido de la habitacion, ignorado: {ajeno[0][:44]!r})")
|
||||
return mio, (nuevo_dicho[-1] if nuevo_dicho else None)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
boca = Boca("davefx")
|
||||
oreja = Oido(modelo="small")
|
||||
if not oreja.disponible():
|
||||
print(" no esta whisper o el modelo; no se puede probar")
|
||||
return 2
|
||||
print(" arrancando whisper...")
|
||||
if not oreja.arranca():
|
||||
print(" whisper no arranco")
|
||||
return 2
|
||||
|
||||
centinela = Centinela(activo=True)
|
||||
esc = Escucha(boca, oreja, centinela)
|
||||
esc.arranca()
|
||||
time.sleep(1.5)
|
||||
|
||||
print("\n 0 · con la palabra APAGADA (el defecto): atiende directo")
|
||||
centinela.activo = False
|
||||
oido, dicho = esc.suelta("Cuanta memoria queda.")
|
||||
if oido:
|
||||
print(f" oyo: {oido[0]!r}")
|
||||
if dicho:
|
||||
print(f" dijo: {dicho}")
|
||||
afirma(oido is not None and oido[1],
|
||||
"sin exigir el nombre, atiende lo que oiga")
|
||||
afirma(dicho is not None, "y contesta")
|
||||
centinela.activo = True
|
||||
centinela.duerme()
|
||||
|
||||
print("\n 1 · con la palabra puesta y sin llamarle: oye pero no atiende")
|
||||
oido, dicho = esc.suelta("Cuanto espacio queda en el disco.")
|
||||
if oido is None:
|
||||
print(" ·· no se oyo nada: ¿altavoces en silencio? sin esto el resto")
|
||||
print(" de la prueba no demuestra nada")
|
||||
fallos.append("el microfono no capta los altavoces")
|
||||
else:
|
||||
print(f" oyo: {oido[0]!r}")
|
||||
afirma(not oido[1], "lo oye pero no lo atiende, porque no le han llamado")
|
||||
afirma(dicho is None, "y no contesta")
|
||||
|
||||
centinela.duerme()
|
||||
print(f"\n 2 · llamandole por su nombre")
|
||||
oido, dicho = esc.suelta(f"{NOMBRE.capitalize()}, cuanto espacio queda.")
|
||||
if oido:
|
||||
print(f" oyo: {oido[0]!r}")
|
||||
if dicho:
|
||||
print(f" dijo: {dicho}")
|
||||
afirma(oido is not None and oido[1], "lo atiende")
|
||||
afirma(dicho is not None, "y contesta algo")
|
||||
afirma(dicho is not None and any(c.isdigit() for c in dicho),
|
||||
"con una cifra de verdad, no una excusa")
|
||||
|
||||
print("\n 3 · encadenando, sin repetir el nombre")
|
||||
oido, dicho = esc.suelta("Cuanta memoria queda.")
|
||||
if oido:
|
||||
print(f" oyo: {oido[0]!r}")
|
||||
afirma(oido is not None and oido[1],
|
||||
"sigue despierto y atiende sin que le vuelvan a llamar")
|
||||
|
||||
print("\n 4 · como lo oye Whisper de verdad")
|
||||
centinela.duerme()
|
||||
oido, dicho = esc.suelta("Yarvis, que hora es.")
|
||||
if oido:
|
||||
print(f" oyo: {oido[0]!r}")
|
||||
afirma(oido is not None and oido[1],
|
||||
"'Yarvis' tambien le despierta (Whisper escribe el nombre de mil formas)")
|
||||
|
||||
esc.para()
|
||||
oreja.para()
|
||||
|
||||
# Una prueba que usa el microfono de la sala no puede fallar en silencio por
|
||||
# culpa de la sala. Si ha entrado mucho ruido, el resultado no dice nada del
|
||||
# codigo y hay que decirlo, no soltar una pared de MAL.
|
||||
if fallos and len(ajenas) >= 2:
|
||||
print(f"\n ┌─ SALA RUIDOSA ─────────────────────────────────────────")
|
||||
print(f" │ Han entrado {len(ajenas)} frases que no eran de la prueba.")
|
||||
print(f" │ Con audio sonando cerca del microfono, este resultado NO")
|
||||
print(f" │ dice nada del codigo. Ejemplos de lo que se colo:")
|
||||
for a in ajenas[:3]:
|
||||
print(f" │ {a[:52]!r}")
|
||||
print(f" │ Repitelo en silencio.")
|
||||
print(f" └────────────────────────────────────────────────────────")
|
||||
print(f"\n {len(fallos)} fallan, pero no son concluyentes\n")
|
||||
return 2
|
||||
|
||||
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
|
||||
return 1 if fallos else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
175
nucleo/pruebas/repasa_acciones.py
Executable file
175
nucleo/pruebas/repasa_acciones.py
Executable file
|
|
@ -0,0 +1,175 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ejecuta TODAS las acciones del catalogo y dice cuales funcionan de verdad.
|
||||
|
||||
Por que hace falta: hasta hoy los comandos libres del modelo corrian dentro del
|
||||
sandbox, donde no hay ni el sistema ni los discos. Se arreglo (virtualization a
|
||||
false), pero las 134 acciones del catalogo nunca se habian ejecutado todas
|
||||
seguidas contra la maquina real: la suite prueba que las FRASES caen en la
|
||||
accion correcta, no que el comando de cada accion haga algo util.
|
||||
|
||||
Que se ejecuta y que no:
|
||||
|
||||
- Las destructivas (`confirmar: true`) y las que piden root NO se ejecutan.
|
||||
En uso normal pasan por un dialogo que aprueba una persona; aqui no hay
|
||||
persona, y aprobarlas solas seria justo lo que el diseño evita.
|
||||
- Las que abren ventanas (firefox, xdg-open) se ejecutan solo con --abrir,
|
||||
porque llenan la pantalla. Sin esa opcion se comprueba que el programa
|
||||
existe, que es lo que puede fallar.
|
||||
- El resto se ejecuta entero y se mira que conteste algo con sentido.
|
||||
|
||||
python repasar_acciones.py [--abrir] [--grupo sistema] [--json salida.json]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
RAIZ = os.path.dirname(AQUI) # la capa JARVIS, para los argumentos de prueba
|
||||
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
|
||||
|
||||
# Argumentos de mentira para las acciones que capturan uno. Se eligen inocuos:
|
||||
# nada que borre, mueva o mande nada fuera.
|
||||
ARGUMENTOS = {
|
||||
"buscar_fichero": "README.md",
|
||||
"buscar_carpeta": "config",
|
||||
"grep_recursivo": "JARVIS",
|
||||
"leer_fichero": "README.md", # un NOMBRE, que es lo que se dice por voz
|
||||
"abrir_carpeta": RAIZ,
|
||||
"tamano_carpeta": os.path.join(RAIZ, "config"),
|
||||
"buscar_youtube": "musica",
|
||||
"buscar_wikipedia": "madrid",
|
||||
"buscar_github": "newelle",
|
||||
"traducir": "hola",
|
||||
"buscar_web": "que hora es",
|
||||
"matar_proceso": "proceso_que_no_existe_jarvis",
|
||||
"ping": "127.0.0.1",
|
||||
}
|
||||
POR_DEFECTO = "prueba"
|
||||
|
||||
# Lo que abre o MUEVE ventanas. ventanas.sh reordena el escritorio del usuario:
|
||||
# ejecutarlo en un repaso automatico le cambia las ventanas de sitio sin avisar.
|
||||
VENTANA = re.compile(r"\b(firefox|xdg-open|gnome-terminal|nautilus|vlc|mpv|eog|"
|
||||
r"gedit|gnome-text-editor|totem|ventanas\.sh|xdotool|wmctrl)\b")
|
||||
|
||||
|
||||
def carga():
|
||||
with open(CATALOGO, encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
return d.get("acciones", d if isinstance(d, list) else [])
|
||||
|
||||
|
||||
def prefijo_host():
|
||||
"""Las acciones llevan host:true y en uso salen del sandbox. Aqui ya
|
||||
estamos fuera, asi que no hace falta prefijo."""
|
||||
return []
|
||||
|
||||
|
||||
def ejecuta(accion, argumento, tope=25):
|
||||
import shlex
|
||||
import urllib.parse
|
||||
|
||||
comando = accion.get("comando") or ""
|
||||
comando = comando.replace("{argumento}", shlex.quote(argumento))
|
||||
comando = comando.replace("{argumento_url}",
|
||||
shlex.quote(urllib.parse.quote_plus(argumento)))
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
r = subprocess.run(["bash", "-lc", comando], capture_output=True,
|
||||
text=True, errors="replace", timeout=tope)
|
||||
salida = (r.stdout or r.stderr or "").strip()
|
||||
return r.returncode, salida, time.monotonic() - t0
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1, f"(no termino en {tope}s)", time.monotonic() - t0
|
||||
except OSError as e:
|
||||
return -2, f"(no se pudo ejecutar: {e})", time.monotonic() - t0
|
||||
|
||||
|
||||
def programa_existe(comando):
|
||||
"""El primer ejecutable de la orden, para las que abren o mueven ventanas.
|
||||
|
||||
Algunos son ordenes del PATH (firefox) y otros son scripts nuestros con
|
||||
ruta completa (config/ventanas.sh): `command -v` solo sirve para los
|
||||
primeros, y con los segundos hay que mirar el fichero.
|
||||
"""
|
||||
m = VENTANA.search(comando)
|
||||
if not m:
|
||||
return None
|
||||
nombre = m.group(1)
|
||||
if nombre.endswith(".sh"):
|
||||
# sacar la ruta entera tal como aparece en el comando
|
||||
for trozo in comando.split():
|
||||
if trozo.endswith(nombre):
|
||||
ruta = os.path.expandvars(os.path.expanduser(trozo))
|
||||
return os.access(ruta, os.X_OK)
|
||||
return False
|
||||
return subprocess.run(["bash", "-lc", f"command -v {nombre}"],
|
||||
capture_output=True).returncode == 0
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--abrir", action="store_true",
|
||||
help="ejecutar tambien las que abren ventanas")
|
||||
p.add_argument("--grupo")
|
||||
p.add_argument("--json")
|
||||
p.add_argument("--tope", type=int, default=25)
|
||||
args = p.parse_args()
|
||||
|
||||
acciones = carga()
|
||||
if args.grupo:
|
||||
acciones = [a for a in acciones if a.get("grupo") == args.grupo]
|
||||
|
||||
resultados = []
|
||||
print(f" repasando {len(acciones)} acciones"
|
||||
f"{' del grupo ' + args.grupo if args.grupo else ''}\n", flush=True)
|
||||
|
||||
for a in acciones:
|
||||
ident = a.get("id", "?")
|
||||
comando = a.get("comando") or ""
|
||||
arg = ARGUMENTOS.get(ident, POR_DEFECTO if a.get("captura") else "")
|
||||
|
||||
if not comando:
|
||||
estado, detalle = "solo habla", a.get("acuse", "")[:50]
|
||||
elif a.get("confirmar") or "sudo" in comando:
|
||||
# no se aprueban solas: en uso las aprueba una persona
|
||||
estado, detalle = "saltada", "destructiva o con root"
|
||||
elif VENTANA.search(comando) and not args.abrir:
|
||||
hay = programa_existe(comando)
|
||||
estado = "programa ok" if hay else "FALTA PROGRAMA"
|
||||
detalle = VENTANA.search(comando).group(1)
|
||||
else:
|
||||
codigo, salida, tardo = ejecuta(a, arg, args.tope)
|
||||
una_linea = " ".join(salida.split())[:70]
|
||||
if codigo == 0:
|
||||
estado = "ok" if salida else "ok (sin salida)"
|
||||
else:
|
||||
estado = f"FALLO ({codigo})"
|
||||
detalle = f"{tardo:4.1f}s {una_linea}"
|
||||
|
||||
resultados.append({"id": ident, "grupo": a.get("grupo"),
|
||||
"estado": estado, "detalle": detalle})
|
||||
marca = "!" if "FALLO" in estado or "FALTA" in estado else " "
|
||||
print(f" {marca} {ident:26s} {estado:16s} {detalle}", flush=True)
|
||||
|
||||
print()
|
||||
from collections import Counter
|
||||
c = Counter(r["estado"].split(" (")[0].split(" ")[0] for r in resultados)
|
||||
for k, v in c.most_common():
|
||||
print(f" {v:3d} {k}")
|
||||
malas = [r for r in resultados if "FALLO" in r["estado"] or "FALTA" in r["estado"]]
|
||||
print(f"\n {len(resultados) - len(malas)} de {len(resultados)} responden bien")
|
||||
if malas:
|
||||
print(" fallan: " + ", ".join(r["id"] for r in malas))
|
||||
|
||||
if args.json:
|
||||
with open(args.json, "w", encoding="utf-8") as f:
|
||||
json.dump(resultados, f, ensure_ascii=False, indent=2)
|
||||
return 1 if malas else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
0
nucleo/saber/__init__.py
Normal file
0
nucleo/saber/__init__.py
Normal file
159
nucleo/saber/busca.py
Normal file
159
nucleo/saber/busca.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""Buscar en el saber de COFRE lo que hace falta para contestar una pregunta.
|
||||
|
||||
Esto es la mitad "recuperar" de RAG: dada una pregunta, devuelve los fragmentos
|
||||
de los apuntes que mas se le parecen, para pasarselos al cerebro. El cerebro
|
||||
construye la respuesta desde ESE texto, no desde lo que recuerde, que es lo que
|
||||
evita que se invente flags.
|
||||
|
||||
## Por que se carga una vez y se guarda
|
||||
|
||||
El indice son 2.866 vectores. Cargarlo y encodear la pregunta cuesta, pero solo
|
||||
la primera vez: el modelo y los vectores se quedan en memoria. Una consulta
|
||||
despues es un producto escalar contra 2.866 filas, milisegundos.
|
||||
|
||||
## Por que no una base de datos vectorial
|
||||
|
||||
FAISS, Chroma y compañia son para millones de vectores. Con 2.866, numpy y un
|
||||
producto escalar es mas rapido de arrancar, no añade una dependencia pesada, y
|
||||
el indice es un jsonl que se puede abrir y leer con los ojos.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
INDICE = os.path.join(AQUI, "datos", "saber.jsonl")
|
||||
|
||||
_modelo = None
|
||||
_entradas = None
|
||||
_matriz = None
|
||||
|
||||
|
||||
def _carga():
|
||||
global _modelo, _entradas, _matriz
|
||||
if _entradas is not None:
|
||||
return _entradas is not False
|
||||
|
||||
if not os.path.exists(INDICE):
|
||||
_entradas = False
|
||||
return False
|
||||
|
||||
import numpy as np
|
||||
from model2vec import StaticModel
|
||||
|
||||
entradas, vectores = [], []
|
||||
with open(INDICE, encoding="utf-8") as f:
|
||||
for l in f:
|
||||
try:
|
||||
d = json.loads(l)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
v = d.pop("v", None)
|
||||
if v is None:
|
||||
continue
|
||||
entradas.append(d)
|
||||
vectores.append(v)
|
||||
|
||||
_entradas = entradas
|
||||
_matriz = np.array(vectores, dtype="float32")
|
||||
# normalizar una vez: asi la similitud es un simple producto escalar
|
||||
normas = np.linalg.norm(_matriz, axis=1, keepdims=True)
|
||||
_matriz = _matriz / np.clip(normas, 1e-9, None)
|
||||
_modelo = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
|
||||
return True
|
||||
|
||||
|
||||
def _norm(t: str) -> str:
|
||||
import re
|
||||
import unicodedata
|
||||
t = unicodedata.normalize("NFD", (t or "").lower())
|
||||
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||
return re.sub(r"[^a-z0-9 ]", " ", t)
|
||||
|
||||
|
||||
# El texto normalizado de cada entrada y su nombre, cacheados para el bono.
|
||||
_texto_norm = None
|
||||
_nombre_norm = None
|
||||
|
||||
|
||||
def _bono_palabras(palabras):
|
||||
"""Un vector de bonos, uno por entrada, segun cuantas palabras compartan."""
|
||||
import numpy as np
|
||||
global _texto_norm, _nombre_norm
|
||||
if _texto_norm is None:
|
||||
_texto_norm = [set(_norm(e["texto"]).split()) for e in _entradas]
|
||||
_nombre_norm = [_norm(e.get("herramienta", "")) for e in _entradas]
|
||||
bono = np.zeros(len(_entradas), dtype="float32")
|
||||
for i, (palabras_e, nombre) in enumerate(zip(_texto_norm, _nombre_norm)):
|
||||
comunes = len(palabras & palabras_e)
|
||||
b = 0.08 * comunes # cada palabra compartida suma
|
||||
if nombre and nombre in palabras: # y nombrar la herramienta, mucho
|
||||
b += 0.5
|
||||
bono[i] = b
|
||||
return bono
|
||||
|
||||
|
||||
def busca(pregunta: str, cuantos: int = 5, perfil: str = None) -> list:
|
||||
"""Los fragmentos mas parecidos a la pregunta, mejor primero.
|
||||
|
||||
Cada uno lleva su similitud (0-1), de que herramienta y fichero viene, y el
|
||||
texto. perfil filtra por carpeta de COFRE si se quiere acotar.
|
||||
"""
|
||||
if not _carga() or not pregunta.strip():
|
||||
return []
|
||||
import numpy as np
|
||||
|
||||
q = _modelo.encode([pregunta])[0]
|
||||
q = q / max(float(np.linalg.norm(q)), 1e-9)
|
||||
sim = _matriz @ q
|
||||
|
||||
# Impulso por palabras exactas (busqueda hibrida).
|
||||
#
|
||||
# Los embeddings estaticos fallan cuando la pregunta va en castellano y el
|
||||
# texto en ingles —"cambiar permisos" no se parece a "change file mode
|
||||
# bits"—. Pero si la pregunta NOMBRA la herramienta ("como uso chmod") o
|
||||
# comparte palabras con el texto, eso es una señal fuerte que la similitud
|
||||
# semantica sola no aprovecha. Se suma un bono por cada palabra de la
|
||||
# pregunta que aparezca, y uno grande si coincide el nombre de la
|
||||
# herramienta: asi "usa nmap para..." lleva nmap al primer puesto.
|
||||
palabras = {w for w in _norm(pregunta).split() if len(w) > 3}
|
||||
if palabras:
|
||||
bono = _bono_palabras(palabras)
|
||||
sim = sim + bono
|
||||
|
||||
orden = np.argsort(-sim)[: cuantos * 6]
|
||||
salida, vistos = [], set()
|
||||
for i in orden:
|
||||
e = _entradas[i]
|
||||
if perfil and e.get("perfil") != perfil:
|
||||
continue
|
||||
# sin duplicados: OSCP_APUNTES es copia de los apuntes de primer nivel.
|
||||
# Se comparan los primeros 120 caracteres, que basta para reconocerlos.
|
||||
firma = e["texto"][:120]
|
||||
if firma in vistos:
|
||||
continue
|
||||
vistos.add(firma)
|
||||
salida.append({**e, "sim": round(float(sim[i]), 3)})
|
||||
if len(salida) >= cuantos:
|
||||
break
|
||||
return salida
|
||||
|
||||
|
||||
def contexto(pregunta: str, cuantos: int = 5, minimo: float = 0.35) -> str:
|
||||
"""Lo mismo, pero ya montado como texto para meterle al cerebro.
|
||||
|
||||
Se corta por debajo de `minimo` de similitud: pasarle al modelo fragmentos
|
||||
que no vienen a cuento es lo que le hace mezclar herramientas que no tienen
|
||||
nada que ver. Mejor darle poco y bueno que mucho y ruidoso.
|
||||
"""
|
||||
trozos = [t for t in busca(pregunta, cuantos) if t["sim"] >= minimo]
|
||||
if not trozos:
|
||||
return ""
|
||||
lineas = ["De los apuntes de COFRE del usuario:\n"]
|
||||
for t in trozos:
|
||||
fuente = t.get("fichero") or f"{t['perfil']}/{t['herramienta']}"
|
||||
lineas.append(f"--- {fuente} ---\n{t['texto']}\n")
|
||||
return "\n".join(lineas)
|
||||
|
||||
|
||||
def disponible() -> bool:
|
||||
return os.path.exists(INDICE)
|
||||
110
nucleo/saber/enriquece/README.md
Normal file
110
nucleo/saber/enriquece/README.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Enriquecer el RAG a nivel de pentesting profesional
|
||||
|
||||
El RAG del naranja ya funcionaba, pero se dejaba fuera lo mejor que hay en el
|
||||
disco. Medido antes de empezar: **de las 72 notas del `OSCP_Vault` —la
|
||||
metodologia OSCP escrita a mano, con comandos reales— había 0 en el índice.**
|
||||
`indexa.py` las descartaba por dos motivos a la vez: el nombre (`SQL
|
||||
injection.md` no lleva "notas" ni "guia") y la profundidad (viven a 5 niveles).
|
||||
|
||||
Este pipeline las rescata, multiplica su recuperabilidad con preguntas
|
||||
generadas, y **mide** que el resultado es mejor antes de tocar nada.
|
||||
|
||||
## Correrlo
|
||||
|
||||
```bash
|
||||
./correr_noche.sh # las cuatro fases, y decide si promociona
|
||||
./correr_noche.sh --sin-promo # igual, pero deja el vivo intacto
|
||||
```
|
||||
|
||||
Es reanudable: cada fase se salta si ya está hecha, así que si se corta,
|
||||
relanzarlo continúa. La fase larga (síntesis) es reanudable fragmento a
|
||||
fragmento.
|
||||
|
||||
## Las cuatro fases
|
||||
|
||||
| | Script | Qué hace | Coste |
|
||||
|---|---|---|---|
|
||||
| 1 | `cosecha.py` | rescata la metodología de COFRE que faltaba | segundos, CPU |
|
||||
| 2 | `sintetiza.py` | preguntas en castellano por cada fragmento | **horas**, modelo local |
|
||||
| 3 | `reindexa.py` | une todo y calcula vectores → `saber.jsonl.nuevo` | minutos, CPU |
|
||||
| 4 | `evalua.py` | mide recuperación vivo vs candidato | segundos |
|
||||
|
||||
### 1 · Cosecha
|
||||
|
||||
Donde `indexa.py` es prudente (solo ficheros que *parecen* documentación, 3
|
||||
niveles), esto es agresivo con el oro: las carpetas de metodología (OSCP vaults,
|
||||
apuntes, cheatsheets) enteras, sin límite de profundidad. Fuera de ahí pone un
|
||||
**tope por herramienta**: si un repo tiene más de 25 documentos, es una base de
|
||||
datos (exploitdb son 29.925 ficheros), no unos apuntes, y solo se coge su
|
||||
README. Así el oro no se ahoga en ruido.
|
||||
|
||||
### 2 · Síntesis — indexación multi-representación
|
||||
|
||||
El punto flaco de los embeddings estáticos: "cómo saco una shell reversa" no se
|
||||
parece vectorialmente a `bash -i >& /dev/tcp/...`. La solución es **embeber la
|
||||
pregunta y devolver el fragmento**. Por cada fragmento, el modelo local escribe
|
||||
las preguntas que responde; se indexa la pregunta, pero lo que se le muestra al
|
||||
cerebro es el fragmento **literal**.
|
||||
|
||||
Clave: el modelo **solo escribe preguntas, nunca reescribe comandos**. Si
|
||||
inventa una pregunta rara, esa entrada recupera peor y ya está; no corrompe una
|
||||
respuesta. Y el fragmento crudo sigue en el índice por su lado.
|
||||
|
||||
### 3 · Reindexado
|
||||
|
||||
Une el índice vivo (comandos Linux, glosario, fichas), la cosecha y la síntesis;
|
||||
deduplica por prefijo; calcula los vectores con el mismo `model2vec` de siempre.
|
||||
Escribe a `saber.jsonl.nuevo`. **No toca el vivo.** `busca.py` no cambia.
|
||||
|
||||
### 4 · Evaluación
|
||||
|
||||
`eval_set.jsonl` son ~45 preguntas de pentesting con las palabras que un
|
||||
fragmento correcto debe contener. Mide `hit@1`, `hit@5` y similitud media, del
|
||||
índice vivo contra el candidato. Determinista y reproducible, sin juez-LLM.
|
||||
|
||||
## La decisión, y por qué es segura
|
||||
|
||||
El índice vivo **solo se sustituye si la evaluación dice que el candidato es
|
||||
mejor**, y antes se guarda `saber.jsonl.antes-<fecha>`. Si empeora, se queda el
|
||||
vivo y el candidato espera revisión. Siempre reversible con un `cp`.
|
||||
|
||||
## Ficheros que genera (en `../../datos/`)
|
||||
|
||||
cosecha.jsonl la metodología rescatada, sin vectores
|
||||
sintesis.jsonl las preguntas generadas (+ .hecho, el marcador)
|
||||
saber.jsonl.nuevo el índice candidato, con vectores
|
||||
informe_rag.txt los números de la evaluación
|
||||
noche_rag.log el registro de la noche
|
||||
saber.jsonl.antes-* copia del índice anterior, si se promocionó
|
||||
|
||||
## Iteraciones posteriores a la primera noche
|
||||
|
||||
- **Troceado que no tira comandos cortos** (`cosecha.py`). El troceado por
|
||||
encabezado descartaba toda sección de menos de 60 caracteres, y eso perdía
|
||||
el oro: `## Detección time-based` + `' AND SLEEP(5)-- -` son 50. Ahora una
|
||||
sección corta se pega a la siguiente y cada fragmento lleva delante el título
|
||||
de la nota. `SLEEP` pasó de 0 a 13 apariciones en el índice; hit@1 80→82 %.
|
||||
El salto en la métrica es pequeño porque las preguntas que quedan son
|
||||
cross-lingual, y de eso no salva el troceado.
|
||||
|
||||
- **`experimento_embedder.py`** — mide, sin tocar nada, si un transformer (e5,
|
||||
bge) supera al model2vec estático. **Resultado (16 ago): NO compensa.**
|
||||
`intfloat/multilingual-e5-base` empató con el estático (hit@1 86 %, hit@5
|
||||
95 % los dos): arregla las 2 preguntas que el estático fallaba, pero rompe
|
||||
otras 2 distintas. A cambio pediría ~50-150 ms por consulta en CPU y una
|
||||
dependencia de torch en cada búsqueda. Medido antes de cambiar → **el
|
||||
estático se queda.** El trabajo de recuperación lo hace la búsqueda híbrida
|
||||
(coseno + bono por palabras), no el embedder, y por eso subir el embedder no
|
||||
mueve la aguja. Reproducible: `./experimento_embedder.py`.
|
||||
|
||||
- **Re-síntesis limpia** sobre el troceado nuevo (`SABER_BASE` en `reindexa.py`
|
||||
reconstruye desde el índice original en vez de apilar, para no inflar). Da a
|
||||
los comandos rescatados (SLEEP y demás) su gancho-pregunta en castellano, que
|
||||
es lo que de verdad ataca las consultas cross-lingual —no el embedder—.
|
||||
|
||||
## Nota sobre la tarjeta
|
||||
|
||||
La síntesis usa el modelo local por `127.0.0.1:11434`. Fija `qwen3.5:4b-jarvis`
|
||||
(el naranja, que no inventa comandos). Si dejas el TUI verde abierto, ollama
|
||||
tiene que cambiar de modelo en cada petición y la noche va más lenta: para la
|
||||
síntesis más rápida, cierra el verde.
|
||||
92
nucleo/saber/enriquece/correr_noche.sh
Executable file
92
nucleo/saber/enriquece/correr_noche.sh
Executable file
|
|
@ -0,0 +1,92 @@
|
|||
#!/usr/bin/env bash
|
||||
# El pipeline de la noche: mejora el RAG hasta nivel pentesting profesional.
|
||||
#
|
||||
# ./correr_noche.sh corre las cuatro fases y decide
|
||||
# ./correr_noche.sh --sin-promo igual, pero NO sustituye el indice vivo
|
||||
#
|
||||
# ── Que hace, en orden ─────────────────────────────────────────────────────
|
||||
#
|
||||
# 1. COSECHA rescata la metodologia de COFRE que el indexador se dejaba
|
||||
# (el OSCP_Vault entero, 0 de 72 notas estaban en el indice).
|
||||
# 2. SINTETIZA por cada fragmento, preguntas en castellano que lo encuentran.
|
||||
# Es la fase larga: horas, con el modelo local. Reanudable.
|
||||
# 3. REINDEXA une vivo + cosecha + sintesis y calcula los vectores. Escribe
|
||||
# a saber.jsonl.nuevo. NO toca el indice vivo.
|
||||
# 4. EVALUA mide recuperacion del vivo contra el candidato, con numeros.
|
||||
#
|
||||
# ── No rompe nada ──────────────────────────────────────────────────────────
|
||||
#
|
||||
# El indice vivo solo se sustituye si evalua DICE que el candidato es mejor, y
|
||||
# antes se guarda una copia con fecha. Si el candidato empeora, se queda el
|
||||
# vivo y el candidato espera revision. Reversible siempre.
|
||||
#
|
||||
# Cada fase se salta si ya esta hecha, asi que relanzarlo continua donde iba.
|
||||
set -uo pipefail
|
||||
|
||||
AQUI=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
|
||||
RAIZ=$(cd -P "$AQUI/../.." && pwd) # .../nucleo
|
||||
DATOS="$RAIZ/datos"
|
||||
PY="$RAIZ/venv/bin/python"
|
||||
[ -x "$PY" ] || PY=python3
|
||||
LOG="$DATOS/noche_rag.log"
|
||||
|
||||
# El modelo NO se fija aqui a proposito: sintetiza.py mira que tiene ollama
|
||||
# cargado y usa ESE, para no forzar cambios en la tarjeta de 4 GB. Si el verde
|
||||
# esta abierto, generara con el verde; si no, cae al naranja. Solo se fuerza si
|
||||
# exportas JARVIS_MODELO tu. Aun asi, para la noche mas rapida y limpia, lo
|
||||
# suyo es cerrar el TUI verde: con un solo modelo ollama no recarga nada.
|
||||
|
||||
PROMO=si
|
||||
[ "${1:-}" = "--sin-promo" ] && PROMO=no
|
||||
|
||||
fecha() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||
fase() { echo "" | tee -a "$LOG"; echo "[$(fecha)] === FASE $* ===" | tee -a "$LOG"; }
|
||||
|
||||
echo "[$(fecha)] arranca el pipeline de la noche (modelo $JARVIS_MODELO)" | tee -a "$LOG"
|
||||
|
||||
# ── 1. COSECHA ─────────────────────────────────────────────────────────────
|
||||
fase "1/4 COSECHA"
|
||||
if [ -s "$DATOS/cosecha.jsonl" ]; then
|
||||
echo " ya hecha ($(wc -l <"$DATOS/cosecha.jsonl") fragmentos), se salta" | tee -a "$LOG"
|
||||
else
|
||||
"$PY" "$AQUI/cosecha.py" 2>&1 | tee -a "$LOG"
|
||||
fi
|
||||
|
||||
# ── 2. SINTETIZA (la larga) ────────────────────────────────────────────────
|
||||
fase "2/4 SINTETIZA (horas; reanudable)"
|
||||
# hecha del todo = el .hecho llego al ultimo fragmento
|
||||
TOTAL=$(wc -l <"$DATOS/cosecha.jsonl")
|
||||
HECHO=$(cat "$DATOS/sintesis.jsonl.hecho" 2>/dev/null || echo -1)
|
||||
if [ "$HECHO" -ge "$((TOTAL - 1))" ] 2>/dev/null; then
|
||||
echo " ya completa ($(wc -l <"$DATOS/sintesis.jsonl" 2>/dev/null || echo 0) preguntas)" | tee -a "$LOG"
|
||||
else
|
||||
"$PY" "$AQUI/sintetiza.py" 2>&1 | tee -a "$LOG"
|
||||
fi
|
||||
|
||||
# ── 3. REINDEXA ────────────────────────────────────────────────────────────
|
||||
fase "3/4 REINDEXA"
|
||||
"$PY" "$AQUI/reindexa.py" 2>&1 | tee -a "$LOG"
|
||||
|
||||
# ── 4. EVALUA ──────────────────────────────────────────────────────────────
|
||||
fase "4/4 EVALUA"
|
||||
INFORME="$DATOS/informe_rag.txt"
|
||||
"$PY" "$AQUI/evalua.py" 2>&1 | tee "$INFORME" | tee -a "$LOG"
|
||||
VEREDICTO=$(grep -oE 'VEREDICTO (PROMOCIONAR|MANTENER)' "$INFORME" | awk '{print $2}' | tail -1)
|
||||
|
||||
# ── Decision ───────────────────────────────────────────────────────────────
|
||||
fase "DECISION"
|
||||
if [ "$PROMO" = no ]; then
|
||||
echo " --sin-promo: dejo el candidato en saber.jsonl.nuevo sin tocar el vivo" | tee -a "$LOG"
|
||||
elif [ "$VEREDICTO" = PROMOCIONAR ]; then
|
||||
COPIA="$DATOS/saber.jsonl.antes-$(date +%Y%m%d-%H%M)"
|
||||
cp "$DATOS/saber.jsonl" "$COPIA"
|
||||
mv "$DATOS/saber.jsonl.nuevo" "$DATOS/saber.jsonl"
|
||||
echo " PROMOCIONADO. El candidato es mejor y ya es el indice vivo." | tee -a "$LOG"
|
||||
echo " copia del anterior: $COPIA" | tee -a "$LOG"
|
||||
echo " para deshacer: cp '$COPIA' '$DATOS/saber.jsonl'" | tee -a "$LOG"
|
||||
else
|
||||
echo " MANTENIDO el vivo: el candidato no mejora la evaluacion." | tee -a "$LOG"
|
||||
echo " candidato en saber.jsonl.nuevo e informe en informe_rag.txt para revisar." | tee -a "$LOG"
|
||||
fi
|
||||
|
||||
echo "[$(fecha)] pipeline terminado" | tee -a "$LOG"
|
||||
282
nucleo/saber/enriquece/cosecha.py
Executable file
282
nucleo/saber/enriquece/cosecha.py
Executable file
|
|
@ -0,0 +1,282 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Cosecha la metodologia de pentesting que el indexador normal se deja fuera.
|
||||
|
||||
./cosecha.py recorre COFRE y escribe datos/cosecha.jsonl
|
||||
./cosecha.py --cuenta dice que cosecharia, sin escribir
|
||||
|
||||
## Por que existe, aparte de indexa.py
|
||||
|
||||
`indexa.py` es conservador a proposito: solo mira ficheros cuyo NOMBRE parece
|
||||
documentacion (readme, notas, guia...) y no baja mas de 3 niveles. Eso funciona
|
||||
para las herramientas, pero **tira la mejor metodologia que hay en el disco**:
|
||||
|
||||
- El `OSCP_Vault` son 72 notas OSCP escritas a mano —`SQL injection.md`,
|
||||
`Reverse shell.md`, `LFI a RCE.md`, `msfvenom.md`...— con comandos reales y
|
||||
enlaces cruzados. Ninguna entra: el nombre no lleva "notas/guia", y ademas
|
||||
viven en `OSCP_APUNTES/OSCP_Vault/02 - Explotacion web/`, a 5 niveles.
|
||||
- Igual con las cheatsheets y apuntes sueltos de AD, tunneling, privesc.
|
||||
|
||||
Medido antes de escribir esto: **0 de 72 notas del Vault estaban en el indice.**
|
||||
|
||||
Este cosechador es agresivo donde el otro es prudente: en los perfiles
|
||||
ofensivos coge TODO .md/.txt que no sea ruido evidente, a la profundidad que
|
||||
haga falta, y marca de que TEMA es por la carpeta que lo contiene (las del Vault
|
||||
van numeradas: "01 - Enumeracion", "02 - Explotacion web"...).
|
||||
|
||||
No pisa a indexa.py: escribe a un fichero aparte. La union y el deduplicado los
|
||||
hace reindexa.py.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
|
||||
def _norm(t):
|
||||
t = unicodedata.normalize("NFD", (t or "").lower())
|
||||
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||
return re.sub(r"[^a-z0-9 ]", " ", t)
|
||||
|
||||
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||
RAIZ = os.path.dirname(os.path.dirname(AQUI)) # .../nucleo
|
||||
COFRE = os.path.expanduser("~/COFRE")
|
||||
SALIDA = os.path.join(RAIZ, "datos", "cosecha.jsonl")
|
||||
|
||||
# Los perfiles ofensivos: aqui vive el saber de pentesting. En estos se cosecha
|
||||
# a lo ancho. (LINUX se queda para indexa.py, que ya lo hace bien con man pages.)
|
||||
PERFILES = ["PENTESTERS", "PHISHERS", "REVERSERS", "DEFACERS", "SNEAKERS",
|
||||
"HARD-WARERS", "PROTECTORS", "ZAPPERS", "AUTOMATAS"]
|
||||
|
||||
# Carpetas de oro: se recorren ENTERAS, sin limite de profundidad, porque su
|
||||
# valor esta justo en las notas hondas.
|
||||
ORO = re.compile(r"(OSCP_APUNTES|OSCP_Vault|TELMO_OSCP|RedTeam-Tools|"
|
||||
r"apuntes|cheat|vault|metodolog|notas)", re.I)
|
||||
|
||||
# Lo que no se mira nunca: dependencias, control de versiones, binarios.
|
||||
IGNORA = re.compile(
|
||||
r"(^|/)(node_modules|\.git|\.obsidian|vendor|dist|build|__pycache__|"
|
||||
r"\.venv|venv|site-packages|target|\.cache|\.github)(/|$)", re.I)
|
||||
|
||||
# Ficheros que son plantilla o licencia, no saber.
|
||||
RUIDO = re.compile(r"(code_of_conduct|contributing|changelog|license|copying|"
|
||||
r"pull_request|issue_template|\.min\.|package-lock)", re.I)
|
||||
|
||||
# Fuera de las carpetas de ORO, solo se coge lo que PARECE documentacion por el
|
||||
# nombre. Es la misma idea que indexa.py, un poco mas ancha (tutorial, writeup,
|
||||
# walkthrough). Sin esto entra cada .md de cada exploit.
|
||||
DOC = re.compile(r"(readme|install|usage|apuntes|notas|trucos|howto|tutorial|"
|
||||
r"walkthrough|writeup|write-up|guide|guia|cheat|manual|tips|"
|
||||
r"metodolog|documentation|docs?)", re.I)
|
||||
|
||||
# Tope de ficheros por herramienta fuera del ORO. exploitdb tiene 29.925 .md:
|
||||
# es una base de datos, no unos apuntes, y meterla entera ahoga el oro. Si una
|
||||
# herramienta pasa de esto, se la trata como repo a granel y solo se coge su
|
||||
# documentacion de primer nivel (README).
|
||||
CAP_FICHEROS = 25
|
||||
|
||||
MIN_FRAGMENTO = 60
|
||||
MAX_FRAGMENTO = 1200
|
||||
MAX_PROFUNDIDAD = 4 # niveles bajo el perfil, salvo en carpetas de ORO
|
||||
|
||||
# Tope de tamano por fichero. Una nota de metodologia son unos pocos KB; un .txt
|
||||
# de 300 MB es una wordlist (SecLists y similares llevan .txt de gigabytes) y
|
||||
# leerlo entero revienta la maquina —la swap de este equipo es de 976 MiB—. Por
|
||||
# encima de esto no es saber, es un diccionario, y no se lee.
|
||||
MAX_BYTES = 512 * 1024
|
||||
|
||||
|
||||
def _profundidad(ruta):
|
||||
return ruta.rstrip("/").count("/")
|
||||
|
||||
|
||||
def _tema(ruta):
|
||||
"""El tema, deducido de la carpeta. Las del Vault van numeradas."""
|
||||
for parte in reversed(ruta.split(os.sep)):
|
||||
# "02 - Explotacion web" -> "explotacion web"
|
||||
m = re.match(r"^\d{2}\s*[-.]\s*(.+)$", parte)
|
||||
if m:
|
||||
return m.group(1).strip().lower()
|
||||
return ""
|
||||
|
||||
|
||||
def _herramienta(ruta, perfil):
|
||||
rel = os.path.relpath(ruta, os.path.join(COFRE, perfil)).split(os.sep)
|
||||
return rel[0] if len(rel) > 1 else perfil
|
||||
|
||||
|
||||
def _prefija(titulo, seccion):
|
||||
"""Lleva el titulo de la nota delante de la seccion, salvo que ya lo tenga.
|
||||
|
||||
Sin esto, un fragmento como "## Deteccion time-based\n' AND SLEEP(5)" no
|
||||
sabe de que va —¿time-based de que?—. Con "(SQL injection) ..." delante, el
|
||||
vector y el cerebro tienen el contexto. Se evita duplicar si el titulo ya
|
||||
aparece en la cabecera de la seccion."""
|
||||
if titulo and _norm(titulo) not in _norm(seccion[:80]):
|
||||
return f"({titulo}) {seccion}"
|
||||
return seccion
|
||||
|
||||
|
||||
def trocea(texto):
|
||||
"""En fragmentos por encabezado, fusionando los cortos y con el titulo.
|
||||
|
||||
Antes se partia por encabezado y se TIRABA toda seccion de menos de 60
|
||||
caracteres. Eso perdia justo lo mejor: una seccion "## Deteccion time-based"
|
||||
con `' AND SLEEP(5)-- -` son 50 caracteres, y es oro. Ahora una seccion
|
||||
corta se PEGA a la siguiente en vez de tirarse, y cada fragmento lleva
|
||||
delante el titulo de la nota para no quedar sin contexto.
|
||||
"""
|
||||
texto = re.sub(r"\A---\n.*?\n---\n", "", texto, flags=re.S)
|
||||
m = re.search(r"^#\s+(.+)", texto, re.M)
|
||||
titulo = m.group(1).strip() if m else ""
|
||||
|
||||
crudas = [s.strip() for s in re.split(r"\n(?=#{1,3}\s)", texto) if s.strip()]
|
||||
|
||||
# fusionar hacia adelante mientras la seccion sea demasiado corta
|
||||
fundidas, i = [], 0
|
||||
while i < len(crudas):
|
||||
sec = crudas[i]
|
||||
while len(sec) < MIN_FRAGMENTO and i + 1 < len(crudas):
|
||||
i += 1
|
||||
sec += "\n\n" + crudas[i]
|
||||
fundidas.append(sec)
|
||||
i += 1
|
||||
# si la ultima quedo corta, se dobla sobre la anterior. Se saca primero y
|
||||
# se indexa despues: fundidas.pop() en el lado derecho ya habria acortado
|
||||
# la lista y fundidas[-2] se saldria de rango.
|
||||
if len(fundidas) >= 2 and len(fundidas[-1]) < MIN_FRAGMENTO:
|
||||
cola = fundidas.pop()
|
||||
fundidas[-1] += "\n\n" + cola
|
||||
|
||||
for sec in fundidas:
|
||||
if len(sec) < MIN_FRAGMENTO:
|
||||
continue # una nota entera diminuta
|
||||
if len(sec) <= MAX_FRAGMENTO:
|
||||
yield _prefija(titulo, sec)
|
||||
else:
|
||||
buf = ""
|
||||
for parrafo in re.split(r"\n\s*\n", sec):
|
||||
if len(buf) + len(parrafo) > MAX_FRAGMENTO and buf:
|
||||
yield _prefija(titulo, buf.strip())
|
||||
buf = ""
|
||||
buf += parrafo + "\n\n"
|
||||
if len(buf.strip()) >= MIN_FRAGMENTO:
|
||||
yield _prefija(titulo, buf.strip())
|
||||
|
||||
|
||||
def _candidato(ruta, fich, en_oro):
|
||||
"""Un fichero vale si es texto, no es ruido, no es enorme, y —fuera del
|
||||
oro— su nombre parece documentacion."""
|
||||
if not fich.lower().endswith((".md", ".txt")):
|
||||
return False
|
||||
if RUIDO.search(fich):
|
||||
return False
|
||||
if not en_oro and not DOC.search(fich):
|
||||
return False
|
||||
try:
|
||||
return os.path.getsize(ruta) <= MAX_BYTES
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def ficheros(perfil):
|
||||
"""Los ficheros a cosechar de un perfil, con el tope por herramienta.
|
||||
|
||||
Se recorre cada herramienta (subcarpeta de primer nivel) por separado para
|
||||
poder contar sus ficheros: si pasa del cap y no es oro, es un repo a granel
|
||||
(exploitdb) y solo se coge su README, no sus 30.000 entradas.
|
||||
"""
|
||||
base = os.path.join(COFRE, perfil)
|
||||
if not os.path.isdir(base):
|
||||
return
|
||||
|
||||
# sueltos en la raiz del perfil (apuntes-ad-pentest.md, trucos_trampas.md)
|
||||
for fich in sorted(os.listdir(base)):
|
||||
ruta = os.path.join(base, fich)
|
||||
if os.path.isfile(ruta) and _candidato(ruta, fich, en_oro=True):
|
||||
yield ruta, True
|
||||
|
||||
for herr in sorted(os.listdir(base)):
|
||||
raiz_h = os.path.join(base, herr)
|
||||
if not os.path.isdir(raiz_h) or herr.startswith("."):
|
||||
continue
|
||||
|
||||
candidatos = []
|
||||
for raiz, dirs, fichs in os.walk(raiz_h):
|
||||
if IGNORA.search(raiz):
|
||||
dirs[:] = []
|
||||
continue
|
||||
en_oro = bool(ORO.search(raiz))
|
||||
if not en_oro and _profundidad(raiz) - _profundidad(raiz_h) > MAX_PROFUNDIDAD:
|
||||
dirs[:] = []
|
||||
continue
|
||||
for fich in sorted(fichs):
|
||||
ruta = os.path.join(raiz, fich)
|
||||
if _candidato(ruta, fich, en_oro):
|
||||
candidatos.append((ruta, en_oro, _profundidad(ruta)))
|
||||
|
||||
del_oro = [c for c in candidatos if c[1]]
|
||||
resto = [c for c in candidatos if not c[1]]
|
||||
|
||||
# el oro entra siempre y entero
|
||||
for ruta, en_oro, _ in del_oro:
|
||||
yield ruta, True
|
||||
|
||||
# el resto, con tope: si es un repo a granel, solo lo mas alto
|
||||
if len(resto) > CAP_FICHEROS:
|
||||
resto.sort(key=lambda c: c[2]) # los menos hondos primero
|
||||
resto = resto[:CAP_FICHEROS]
|
||||
for ruta, en_oro, _ in resto:
|
||||
yield ruta, False
|
||||
|
||||
|
||||
def construye(solo_cuenta=False):
|
||||
salida = []
|
||||
for perfil in PERFILES:
|
||||
n_doc = n_frag = 0
|
||||
for ruta, en_oro in ficheros(perfil):
|
||||
try:
|
||||
with open(ruta, encoding="utf-8", errors="ignore") as f:
|
||||
texto = f.read()
|
||||
except OSError:
|
||||
continue
|
||||
if len(texto.strip()) < MIN_FRAGMENTO:
|
||||
continue
|
||||
tema = _tema(ruta)
|
||||
herr = _herramienta(ruta, perfil)
|
||||
rel = os.path.relpath(ruta, COFRE)
|
||||
hubo = False
|
||||
for trozo in trocea(texto):
|
||||
salida.append({
|
||||
"tipo": "metodologia" if en_oro else "apunte",
|
||||
"herramienta": herr, "perfil": perfil,
|
||||
"fichero": rel, "tema": tema, "texto": trozo,
|
||||
})
|
||||
n_frag += 1
|
||||
hubo = True
|
||||
n_doc += hubo
|
||||
print(f" {perfil:14s} {n_doc:5d} docs {n_frag:6d} fragmentos", flush=True)
|
||||
|
||||
oro = sum(1 for e in salida if e["tipo"] == "metodologia")
|
||||
print(f"\n total: {len(salida)} fragmentos ({oro} de metodologia)", flush=True)
|
||||
if solo_cuenta:
|
||||
return 0
|
||||
|
||||
os.makedirs(os.path.dirname(SALIDA), exist_ok=True)
|
||||
with open(SALIDA, "w", encoding="utf-8") as f:
|
||||
for e in salida:
|
||||
f.write(json.dumps(e, ensure_ascii=False) + "\n")
|
||||
print(f" escrito: {SALIDA}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--cuenta", action="store_true")
|
||||
a = p.parse_args()
|
||||
return construye(solo_cuenta=a.cuenta)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
45
nucleo/saber/enriquece/eval_set.jsonl
Normal file
45
nucleo/saber/enriquece/eval_set.jsonl
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{"pregunta": "como saco una shell reversa desde bash", "espera": ["bash -i", "/dev/tcp", "reverse", "nc "]}
|
||||
{"pregunta": "generar un payload de reverse shell con msfvenom", "espera": ["msfvenom", "-p ", "lhost", "payload"]}
|
||||
{"pregunta": "estabilizar una shell tty para que funcione bien", "espera": ["python", "pty", "stty", "script /dev/null"]}
|
||||
{"pregunta": "bypass de login por inyeccion sql", "espera": ["or '1'='1", "or 1=1", "-- -", "union select"]}
|
||||
{"pregunta": "detectar sql injection a ciegas basada en tiempo", "espera": ["sleep", "waitfor", "time-based", "benchmark"]}
|
||||
{"pregunta": "de una sqli a ejecucion de comandos", "espera": ["xp_cmdshell", "load_file", "into outfile", "rce"]}
|
||||
{"pregunta": "explotar un local file inclusion para conseguir rce", "espera": ["lfi", "php://", "log poisoning", "/proc/self/environ", "data://"]}
|
||||
{"pregunta": "probar server side template injection", "espera": ["ssti", "{{7*7", "jinja", "${", "twig"]}
|
||||
{"pregunta": "payload basico de xss para robar cookie", "espera": ["<script", "document.cookie", "onerror", "alert("]}
|
||||
{"pregunta": "inyeccion de comandos en un parametro web", "espera": ["command injection", ";", "| ", "$(", "`", "&&"]}
|
||||
{"pregunta": "saltarme un filtro de subida de ficheros", "espera": ["file upload", "magic bytes", ".phtml", "content-type", "double extension"]}
|
||||
{"pregunta": "descubrir directorios y ficheros ocultos en una web", "espera": ["gobuster", "ffuf", "feroxbuster", "dirb", "wordlist"]}
|
||||
{"pregunta": "path traversal para leer etc passwd", "espera": ["../", "directory traversal", "/etc/passwd", "%2e"]}
|
||||
{"pregunta": "enumerar un servidor smb", "espera": ["smbclient", "enum4linux", "smbmap", "crackmapexec", "139", "445"]}
|
||||
{"pregunta": "escaneo de puertos y servicios con nmap", "espera": ["nmap", "-sv", "-sc", "-p-", "-a "]}
|
||||
{"pregunta": "como paso de usuario normal a root en linux", "espera": ["privesc", "sudo -l", "suid", "linpeas", "gtfobins"]}
|
||||
{"pregunta": "escalada de privilegios en windows", "espera": ["winpeas", "privesc", "seimpersonate", "potato", "token"]}
|
||||
{"pregunta": "ataque dcsync para sacar hashes del dominio", "espera": ["dcsync", "secretsdump", "mimikatz", "lsadump"]}
|
||||
{"pregunta": "pass the hash para autenticarme sin la contrasena", "espera": ["pass-the-hash", "pass the hash", "-hashes", "ntlm", "pth"]}
|
||||
{"pregunta": "kerberoasting para sacar tickets de servicio", "espera": ["kerberoast", "getuserspns", "spn", "ticket"]}
|
||||
{"pregunta": "usar bloodhound para mapear el active directory", "espera": ["bloodhound", "sharphound", "neo4j", "collector"]}
|
||||
{"pregunta": "dumpear credenciales de memoria con mimikatz", "espera": ["mimikatz", "sekurlsa", "logonpasswords", "lsass"]}
|
||||
{"pregunta": "usar impacket para ejecutar comandos remotos", "espera": ["impacket", "psexec", "wmiexec", "smbexec"]}
|
||||
{"pregunta": "crackear un hash con john the ripper", "espera": ["john", "--wordlist", "rockyou", "--format"]}
|
||||
{"pregunta": "crackear hashes con hashcat", "espera": ["hashcat", "-m ", "rockyou", "-a "]}
|
||||
{"pregunta": "ataque de fuerza bruta a un login ssh", "espera": ["hydra", "medusa", "-l ", "-p ", "ssh"]}
|
||||
{"pregunta": "transferir un fichero a la maquina victima", "espera": ["scp", "http.server", "certutil", "wget", "curl", "impacket-smbserver"]}
|
||||
{"pregunta": "montar un tunel para pivotar en la red interna", "espera": ["chisel", "ligolo", "ssh -l", "proxychains", "socks"]}
|
||||
{"pregunta": "pivoting con ssh port forwarding", "espera": ["ssh -l", "ssh -r", "-d ", "port forward", "dynamic"]}
|
||||
{"pregunta": "usar metasploit para explotar un servicio", "espera": ["metasploit", "msfconsole", "use exploit", "set rhost"]}
|
||||
{"pregunta": "escanear vulnerabilidades web con nuclei", "espera": ["nuclei", "-t ", "template", "-u "]}
|
||||
{"pregunta": "escanear un wordpress en busca de fallos", "espera": ["wpscan", "--enumerate", "--url", "plugin"]}
|
||||
{"pregunta": "buscar exploits publicos de un servicio", "espera": ["searchsploit", "exploit-db", "exploitdb"]}
|
||||
{"pregunta": "evadir un antivirus o edr al ejecutar payload", "espera": ["av evasion", "amsi", "bypass", "obfusc", "edr"]}
|
||||
{"pregunta": "hacer un buffer overflow clasico", "espera": ["buffer overflow", "eip", "pattern_create", "badchars", "jmp esp"]}
|
||||
{"pregunta": "filtrado de salida y como saltarmelo", "espera": ["egress", "filtering", "puerto", "443", "outbound"]}
|
||||
{"pregunta": "enumerar usuarios y recursos de un active directory", "espera": ["enum", "ldap", "rpcclient", "net user", "adperti"]}
|
||||
{"pregunta": "atacar autenticacion con credenciales por defecto", "espera": ["authentication", "default", "brute", "credential", "login"]}
|
||||
{"pregunta": "descubrir contenido con fuzzing de parametros", "espera": ["ffuf", "fuzz", "content discovery", "wordlist"]}
|
||||
{"pregunta": "usar burp repeater e intruder para probar peticiones", "espera": ["burp", "repeater", "intruder", "proxy"]}
|
||||
{"pregunta": "escaneo de red para descubrir hosts vivos", "espera": ["nmap", "-sn", "ping sweep", "netdiscover", "arp"]}
|
||||
{"pregunta": "conseguir persistencia tras comprometer una maquina", "espera": ["persistenc", "cron", "scheduled task", "backdoor", "registry"]}
|
||||
{"pregunta": "como escaneo redes wifi y capturo handshakes", "espera": ["airodump", "aircrack", "handshake", "wpa", "airgeddon"]}
|
||||
{"pregunta": "usar crackmapexec para barrer una red windows", "espera": ["crackmapexec", "cme ", "smb", "--shares", "spray"]}
|
||||
{"pregunta": "leer un hash ntlm y reutilizarlo por la red", "espera": ["ntlm", "hash", "relay", "responder", "ntlmrelayx"]}
|
||||
173
nucleo/saber/enriquece/evalua.py
Executable file
173
nucleo/saber/enriquece/evalua.py
Executable file
|
|
@ -0,0 +1,173 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mide si el indice candidato recupera mejor que el vivo. Con numeros.
|
||||
|
||||
./evalua.py compara vivo contra saber.jsonl.nuevo
|
||||
./evalua.py A.jsonl B.jsonl compara dos indices cualesquiera
|
||||
|
||||
## Que mide
|
||||
|
||||
Un conjunto de preguntas de pentesting (eval_set.jsonl), cada una con las
|
||||
palabras que un fragmento CORRECTO tiene que contener (un comando, una
|
||||
herramienta, un patron). Para cada indice se busca la pregunta y se mira si
|
||||
alguno de los 5 primeros resultados contiene lo esperado.
|
||||
|
||||
hit@1 la pregunta se resuelve con el PRIMER resultado
|
||||
hit@5 se resuelve con alguno de los cinco primeros
|
||||
sim similitud media del mejor resultado
|
||||
|
||||
La busqueda replica la de busca.py (coseno + bono por palabras) para que la
|
||||
comparacion sea justa: lo unico que cambia entre las dos columnas es el indice.
|
||||
|
||||
## Por que asi y no "le pregunte al modelo si estaba bien"
|
||||
|
||||
Un juez-LLM sobre una tarjeta de 4 GB es lento y ruidoso. Esto es determinista,
|
||||
reproducible y honesto: la palabra esperada esta o no esta en el texto
|
||||
recuperado. Es una cota inferior de la calidad —un fragmento puede ser util sin
|
||||
contener el literal— pero sirve para COMPARAR dos indices, que es lo que decide
|
||||
si se promociona el candidato.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||
RAIZ = os.path.dirname(os.path.dirname(AQUI))
|
||||
DATOS = os.path.join(RAIZ, "datos")
|
||||
VIVO = os.path.join(DATOS, "saber.jsonl")
|
||||
NUEVO = os.path.join(DATOS, "saber.jsonl.nuevo")
|
||||
EVAL = os.path.join(AQUI, "eval_set.jsonl")
|
||||
MODELO = "minishlab/potion-multilingual-128M"
|
||||
|
||||
_modelo = None
|
||||
|
||||
|
||||
def _norm(t):
|
||||
t = unicodedata.normalize("NFD", (t or "").lower())
|
||||
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||
return re.sub(r"[^a-z0-9 ]", " ", t)
|
||||
|
||||
|
||||
def _carga_modelo():
|
||||
global _modelo
|
||||
if _modelo is None:
|
||||
from model2vec import StaticModel
|
||||
_modelo = StaticModel.from_pretrained(MODELO)
|
||||
return _modelo
|
||||
|
||||
|
||||
def _carga_indice(ruta):
|
||||
import numpy as np
|
||||
entradas, vs = [], []
|
||||
with open(ruta, encoding="utf-8") as f:
|
||||
for l in f:
|
||||
try:
|
||||
d = json.loads(l)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
v = d.pop("v", None)
|
||||
if v is None:
|
||||
continue
|
||||
entradas.append(d)
|
||||
vs.append(v)
|
||||
M = np.array(vs, dtype="float32")
|
||||
M /= np.clip(np.linalg.norm(M, axis=1, keepdims=True), 1e-9, None)
|
||||
txt = [set(_norm(e["texto"]).split()) for e in entradas]
|
||||
nom = [_norm(e.get("herramienta", "")) for e in entradas]
|
||||
return entradas, M, txt, nom
|
||||
|
||||
|
||||
def _busca(indice, pregunta, cuantos=5):
|
||||
import numpy as np
|
||||
entradas, M, txt, nom = indice
|
||||
q = _carga_modelo().encode([pregunta])[0]
|
||||
q = q / max(float(np.linalg.norm(q)), 1e-9)
|
||||
sim = M @ q
|
||||
palabras = {w for w in _norm(pregunta).split() if len(w) > 3}
|
||||
if palabras:
|
||||
bono = np.zeros(len(entradas), dtype="float32")
|
||||
for i in range(len(entradas)):
|
||||
b = 0.08 * len(palabras & txt[i])
|
||||
if nom[i] and nom[i] in palabras:
|
||||
b += 0.5
|
||||
bono[i] = b
|
||||
sim = sim + bono
|
||||
orden = np.argsort(-sim)[: cuantos * 4]
|
||||
salida, vistos = [], set()
|
||||
for i in orden:
|
||||
firma = entradas[i]["texto"][:120]
|
||||
if firma in vistos:
|
||||
continue
|
||||
vistos.add(firma)
|
||||
salida.append((float(sim[i]), entradas[i]["texto"]))
|
||||
if len(salida) >= cuantos:
|
||||
break
|
||||
return salida
|
||||
|
||||
|
||||
def evalua(ruta):
|
||||
indice = _carga_indice(ruta)
|
||||
casos = [json.loads(l) for l in open(EVAL, encoding="utf-8") if l.strip()]
|
||||
hit1 = hit5 = 0
|
||||
simtop = 0.0
|
||||
fallos = []
|
||||
for c in casos:
|
||||
esperado = [_norm(k).strip() for k in c["espera"]]
|
||||
res = _busca(indice, c["pregunta"])
|
||||
simtop += res[0][0] if res else 0.0
|
||||
textos = [_norm(t) for _, t in res]
|
||||
acierta = lambda t: any(k and k in t for k in esperado)
|
||||
if res and acierta(textos[0]):
|
||||
hit1 += 1
|
||||
if any(acierta(t) for t in textos):
|
||||
hit5 += 1
|
||||
else:
|
||||
fallos.append(c["pregunta"])
|
||||
n = len(casos)
|
||||
return {"n": n, "hit1": hit1, "hit5": hit5,
|
||||
"sim": simtop / n if n else 0.0, "fallos": fallos}
|
||||
|
||||
|
||||
def _pinta(nombre, r):
|
||||
print(f" {nombre}")
|
||||
print(f" hit@1 {r['hit1']:3d}/{r['n']} ({100*r['hit1']//r['n']}%)")
|
||||
print(f" hit@5 {r['hit5']:3d}/{r['n']} ({100*r['hit5']//r['n']}%)")
|
||||
print(f" sim {r['sim']:.3f}")
|
||||
|
||||
|
||||
def main():
|
||||
a = sys.argv[1] if len(sys.argv) > 1 else VIVO
|
||||
b = sys.argv[2] if len(sys.argv) > 2 else NUEVO
|
||||
if not os.path.exists(b):
|
||||
print(f" no existe el candidato: {b}")
|
||||
return 1
|
||||
|
||||
print("Evaluando el indice VIVO...", flush=True)
|
||||
rv = evalua(a)
|
||||
print("Evaluando el indice CANDIDATO...", flush=True)
|
||||
rn = evalua(b)
|
||||
|
||||
print("\n" + "=" * 44)
|
||||
_pinta("VIVO " + os.path.basename(a), rv)
|
||||
print()
|
||||
_pinta("CANDIDATO " + os.path.basename(b), rn)
|
||||
print("=" * 44)
|
||||
|
||||
mejora5 = rn["hit5"] - rv["hit5"]
|
||||
mejora1 = rn["hit1"] - rv["hit1"]
|
||||
print(f"\n hit@5: {mejora5:+d} hit@1: {mejora1:+d}")
|
||||
|
||||
# el veredicto que lee correr_noche.sh
|
||||
promociona = rn["hit5"] >= rv["hit5"] and rn["hit1"] >= rv["hit1"] - 1
|
||||
print(f" VEREDICTO {'PROMOCIONAR' if promociona else 'MANTENER'}")
|
||||
|
||||
if rn["fallos"]:
|
||||
print(f"\n el candidato aun no resuelve {len(rn['fallos'])}:")
|
||||
for q in rn["fallos"][:12]:
|
||||
print(f" · {q}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
142
nucleo/saber/enriquece/experimento_embedder.py
Normal file
142
nucleo/saber/enriquece/experimento_embedder.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Experimento: mide si un embedder transformer supera al model2vec estatico.
|
||||
|
||||
./experimento_embedder.py prueba intfloat/multilingual-e5-base
|
||||
./experimento_embedder.py BAAI/bge-m3 prueba otro modelo
|
||||
|
||||
## Por que este experimento
|
||||
|
||||
model2vec (potion-multilingual-128M) es un embedder ESTATICO: rapidisimo (un
|
||||
vector por token, precalculado, sin red neuronal en la consulta) pero flojo en
|
||||
lo semantico. Su punto ciego se ve en las dos preguntas que el RAG no resuelve:
|
||||
|
||||
"detectar sql injection a ciegas basada en tiempo" -> no encuentra SLEEP
|
||||
"descubrir directorios ocultos en una web" -> no encuentra gobuster
|
||||
|
||||
Las dos son cross-lingual: la pregunta va en castellano coloquial y el comando
|
||||
en ingles tecnico ("time-based", "content discovery"). Un transformer de verdad
|
||||
—e5, bge— entiende eso; el estatico no.
|
||||
|
||||
Este experimento NO toca nada: coge una MUESTRA del indice vivo (para que quepa
|
||||
en memoria y sea rapido), la re-embebe con el transformer, y corre la misma
|
||||
evaluacion. Si gana claro, merece la pena cambiar busca.py; si no, no.
|
||||
|
||||
## El coste que habria que pagar si se adopta
|
||||
|
||||
El estatico encodea la consulta en microsegundos. Un transformer en CPU tarda
|
||||
~50-150 ms por consulta. Para un asistente de voz local es asumible, pero no es
|
||||
gratis, y por eso se mide antes de decidir.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||
RAIZ = os.path.dirname(os.path.dirname(AQUI))
|
||||
VIVO = os.path.join(RAIZ, "datos", "saber.jsonl")
|
||||
EVAL = os.path.join(AQUI, "eval_set.jsonl")
|
||||
MODELO_TF = sys.argv[1] if len(sys.argv) > 1 else "intfloat/multilingual-e5-base"
|
||||
|
||||
# Muestra: todo lo que un caso de eval podria querer + relleno, para no cargar
|
||||
# 14.000 vectores de transformer en RAM. Se cogen todas las entradas de
|
||||
# metodologia y pregunta (el oro) mas una parte del resto.
|
||||
MAX_ENTRADAS = 6000
|
||||
|
||||
|
||||
def _norm(t):
|
||||
t = unicodedata.normalize("NFD", (t or "").lower())
|
||||
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||
return re.sub(r"[^a-z0-9 ]", " ", t)
|
||||
|
||||
|
||||
def _muestra():
|
||||
oro, resto = [], []
|
||||
with open(VIVO, encoding="utf-8") as f:
|
||||
for l in f:
|
||||
try:
|
||||
d = json.loads(l)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
(oro if d.get("tipo") in ("metodologia", "pregunta") else resto).append(d)
|
||||
ent = oro + resto[: max(0, MAX_ENTRADAS - len(oro))]
|
||||
return ent
|
||||
|
||||
|
||||
def _eval(nombre, encode_corpus, encode_query, entradas):
|
||||
import numpy as np
|
||||
textos = [(e.get("indexar") or e["texto"]) for e in entradas]
|
||||
M = encode_corpus(textos)
|
||||
M = M / np.clip(np.linalg.norm(M, axis=1, keepdims=True), 1e-9, None)
|
||||
palabras_e = [set(_norm(e["texto"]).split()) for e in entradas]
|
||||
nom = [_norm(e.get("herramienta", "")) for e in entradas]
|
||||
|
||||
casos = [json.loads(l) for l in open(EVAL, encoding="utf-8") if l.strip()]
|
||||
hit1 = hit5 = 0
|
||||
fallos = []
|
||||
for c in casos:
|
||||
q = encode_query([c["pregunta"]])[0]
|
||||
q = q / max(float(np.linalg.norm(q)), 1e-9)
|
||||
sim = M @ q
|
||||
pal = {w for w in _norm(c["pregunta"]).split() if len(w) > 3}
|
||||
if pal:
|
||||
bono = np.array([0.08 * len(pal & palabras_e[i]) +
|
||||
(0.5 if nom[i] and nom[i] in pal else 0.0)
|
||||
for i in range(len(entradas))], dtype="float32")
|
||||
sim = sim + bono
|
||||
orden = np.argsort(-sim)[:5]
|
||||
esperado = [_norm(k).strip() for k in c["espera"]]
|
||||
tops = [_norm(entradas[i]["texto"]) for i in orden]
|
||||
ok = lambda t: any(k and k in t for k in esperado)
|
||||
if tops and ok(tops[0]):
|
||||
hit1 += 1
|
||||
if any(ok(t) for t in tops):
|
||||
hit5 += 1
|
||||
else:
|
||||
fallos.append(c["pregunta"])
|
||||
n = len(casos)
|
||||
print(f"\n {nombre}")
|
||||
print(f" hit@1 {hit1:3d}/{n} ({100*hit1//n}%)")
|
||||
print(f" hit@5 {hit5:3d}/{n} ({100*hit5//n}%)")
|
||||
if fallos:
|
||||
print(f" no resuelve {len(fallos)}: " + "; ".join(fallos[:6]))
|
||||
return hit1, hit5
|
||||
|
||||
|
||||
def main():
|
||||
entradas = _muestra()
|
||||
print(f"Muestra: {len(entradas)} entradas del indice vivo")
|
||||
|
||||
# 1) model2vec estatico, la referencia
|
||||
from model2vec import StaticModel
|
||||
est = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
|
||||
enc_est = lambda xs: est.encode(xs)
|
||||
h1e, h5e = _eval("model2vec (estatico, el actual)", enc_est, enc_est, entradas)
|
||||
|
||||
# 2) el transformer a prueba
|
||||
print(f"\n cargando {MODELO_TF} (puede descargar ~1-2 GB la 1a vez)...", flush=True)
|
||||
from sentence_transformers import SentenceTransformer
|
||||
tf = SentenceTransformer(MODELO_TF)
|
||||
# los modelos e5 piden prefijos "query:" y "passage:"
|
||||
es_e5 = "e5" in MODELO_TF.lower()
|
||||
enc_doc = lambda xs: tf.encode([("passage: " + x) if es_e5 else x for x in xs],
|
||||
show_progress_bar=False, batch_size=32)
|
||||
enc_q = lambda xs: tf.encode([("query: " + x) if es_e5 else x for x in xs],
|
||||
show_progress_bar=False, batch_size=32)
|
||||
h1t, h5t = _eval(f"{MODELO_TF} (transformer)", enc_doc, enc_q, entradas)
|
||||
|
||||
print("\n" + "=" * 44)
|
||||
print(f" hit@1 estatico {h1e} -> transformer {h1t} ({h1t-h1e:+d})")
|
||||
print(f" hit@5 estatico {h5e} -> transformer {h5t} ({h5t-h5e:+d})")
|
||||
print("=" * 44)
|
||||
if h5t > h5e or (h5t == h5e and h1t > h1e):
|
||||
print(" El transformer gana. Merece plantear el cambio en busca.py")
|
||||
print(" (coste: ~50-150 ms por consulta en CPU, hoy es instantaneo).")
|
||||
else:
|
||||
print(" El transformer NO compensa aqui. El estatico se queda.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
113
nucleo/saber/enriquece/reindexa.py
Executable file
113
nucleo/saber/enriquece/reindexa.py
Executable file
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Construye el indice candidato uniendo lo viejo, lo cosechado y lo sintetizado.
|
||||
|
||||
./reindexa.py escribe datos/saber.jsonl.nuevo (NO toca el vivo)
|
||||
|
||||
## Que une
|
||||
|
||||
1. El indice vivo (datos/saber.jsonl): comandos de Linux, glosario, fichas,
|
||||
servidores... todo lo que cosecha no produce. Se conserva entero.
|
||||
2. cosecha.jsonl: la metodologia de pentesting que faltaba.
|
||||
3. sintesis.jsonl: preguntas en castellano que apuntan a esos fragmentos.
|
||||
|
||||
## Indexacion multi-representacion
|
||||
|
||||
Cada entrada se EMBEBE por su campo `indexar` si lo tiene (las preguntas
|
||||
sintetizadas), y si no por `texto` (todo lo demas). Asi una entrada de pregunta
|
||||
se compara con la consulta del usuario pregunta-contra-pregunta, pero lo que se
|
||||
le muestra al cerebro sigue siendo el fragmento literal de `texto`. busca.py no
|
||||
cambia: solo lee `texto` y `v`.
|
||||
|
||||
## No destruye nada
|
||||
|
||||
Escribe a `saber.jsonl.nuevo`. Quien decide si sustituye al vivo es evalua.py +
|
||||
correr_noche.sh, y solo si mide que es mejor, guardando antes una copia.
|
||||
|
||||
## Deduplicado
|
||||
|
||||
Por los primeros 120 caracteres del texto normalizado (para las entradas
|
||||
normales) o de la pregunta (para las sintetizadas). OSCP_APUNTES duplica apuntes
|
||||
de primer nivel; esto los colapsa.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||
RAIZ = os.path.dirname(os.path.dirname(AQUI))
|
||||
DATOS = os.path.join(RAIZ, "datos")
|
||||
# La base son las entradas que cosecha NO produce (comandos Linux, glosario,
|
||||
# fichas). Por defecto el indice vivo, pero se puede fijar con SABER_BASE al
|
||||
# indice ORIGINAL para una reconstruccion limpia que no apile sintesis sobre
|
||||
# sintesis y no infle el indice a cada pasada.
|
||||
VIVO = os.environ.get("SABER_BASE") or os.path.join(DATOS, "saber.jsonl")
|
||||
COSECHA = os.path.join(DATOS, "cosecha.jsonl")
|
||||
SINTESIS = os.path.join(DATOS, "sintesis.jsonl")
|
||||
NUEVO = os.path.join(DATOS, "saber.jsonl.nuevo")
|
||||
|
||||
MODELO = "minishlab/potion-multilingual-128M"
|
||||
|
||||
|
||||
def _norm(t):
|
||||
t = unicodedata.normalize("NFD", (t or "").lower())
|
||||
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", t)).strip()
|
||||
|
||||
|
||||
def _lee(ruta, quita_v=False):
|
||||
if not os.path.exists(ruta):
|
||||
return
|
||||
with open(ruta, encoding="utf-8") as f:
|
||||
for l in f:
|
||||
try:
|
||||
d = json.loads(l)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if quita_v:
|
||||
d.pop("v", None)
|
||||
yield d
|
||||
|
||||
|
||||
def main():
|
||||
entradas, vistos = [], set()
|
||||
fuentes = [("vivo", VIVO, True), ("cosecha", COSECHA, False),
|
||||
("sintesis", SINTESIS, False)]
|
||||
cuenta = {}
|
||||
for nombre, ruta, quita in fuentes:
|
||||
n = 0
|
||||
for d in _lee(ruta, quita_v=quita):
|
||||
clave_txt = d.get("indexar") or d.get("texto", "")
|
||||
firma = _norm(clave_txt)[:120]
|
||||
if not firma or firma in vistos:
|
||||
continue
|
||||
vistos.add(firma)
|
||||
entradas.append(d)
|
||||
n += 1
|
||||
cuenta[nombre] = n
|
||||
print(f" {nombre:9s} {n:6d} entradas nuevas", flush=True)
|
||||
|
||||
print(f"\n total tras deduplicar: {len(entradas)}", flush=True)
|
||||
|
||||
print(" cargando el modelo de embeddings...", flush=True)
|
||||
from model2vec import StaticModel
|
||||
modelo = StaticModel.from_pretrained(MODELO)
|
||||
|
||||
# se embebe `indexar` si existe (la pregunta), si no `texto`
|
||||
a_embeber = [(e.get("indexar") or e["texto"]) for e in entradas]
|
||||
print(f" calculando {len(a_embeber)} vectores...", flush=True)
|
||||
vectores = modelo.encode(a_embeber, show_progress_bar=False)
|
||||
|
||||
with open(NUEVO, "w", encoding="utf-8") as f:
|
||||
for e, v in zip(entradas, vectores):
|
||||
e["v"] = [round(float(x), 5) for x in v]
|
||||
f.write(json.dumps(e, ensure_ascii=False) + "\n")
|
||||
print(f" escrito: {NUEVO} ({os.path.getsize(NUEVO)//1024} KB)", flush=True)
|
||||
print(f" RESUMEN vivo={cuenta['vivo']} cosecha={cuenta['cosecha']} "
|
||||
f"sintesis={cuenta['sintesis']} total={len(entradas)}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
207
nucleo/saber/enriquece/sintetiza.py
Executable file
207
nucleo/saber/enriquece/sintetiza.py
Executable file
|
|
@ -0,0 +1,207 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Genera preguntas en castellano para cada fragmento cosechado.
|
||||
|
||||
./sintetiza.py procesa datos/cosecha.jsonl (reanudable, horas)
|
||||
./sintetiza.py --muestra 5 ensena 5 y para
|
||||
|
||||
## Que problema resuelve, y por que no corrompe comandos
|
||||
|
||||
El indice usa embeddings estaticos. Su punto flaco conocido: una pregunta
|
||||
coloquial en castellano —"como saco una shell reversa"— no se parece
|
||||
vectorialmente a un fragmento tecnico en ingles con `bash -i >& /dev/tcp/...`.
|
||||
La busqueda hibrida por palabras ayuda, pero solo si coinciden literales.
|
||||
|
||||
La solucion es **indexacion multi-representacion**: por cada fragmento se generan
|
||||
las preguntas que ese fragmento responde, y se EMBEBE LA PREGUNTA apuntando al
|
||||
fragmento. Asi la consulta del usuario se compara contra otras preguntas, que es
|
||||
comparar peras con peras.
|
||||
|
||||
Clave para no meter la pata: la respuesta que se le muestra al cerebro es el
|
||||
**fragmento original, literal** (campo `texto`). El modelo local solo escribe la
|
||||
PREGUNTA (campo `indexar`). Si inventa una pregunta rara, lo peor que pasa es que
|
||||
esa entrada no recupere bien; **nunca corrompe un comando**, porque no reescribe
|
||||
la respuesta. Y el fragmento crudo sigue en el indice por su lado (lo pone
|
||||
cosecha), asi que la cobertura solo puede subir.
|
||||
|
||||
## El modelo
|
||||
|
||||
Habla con el ollama local. Usa el modelo que ya este CARGADO (mira /api/ps) para
|
||||
no forzar a la tarjeta de 4 GB a cambiar de modelo con el carril verde abierto;
|
||||
si no hay ninguno, carga qwen3.5:4b-jarvis. think:false —qwen3.5 razona por
|
||||
defecto y tardaria 20x—.
|
||||
|
||||
## Reanudable
|
||||
|
||||
Un trabajo de horas no puede perder todo si se corta. Se apunta en un .hecho por
|
||||
que linea de cosecha.jsonl iba, y al arrancar salta hasta ahi. La metodologia
|
||||
(el oro) se procesa PRIMERO, para que si no termina la noche, lo valioso este.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||
RAIZ = os.path.dirname(os.path.dirname(AQUI))
|
||||
COSECHA = os.path.join(RAIZ, "datos", "cosecha.jsonl")
|
||||
SALIDA = os.path.join(RAIZ, "datos", "sintesis.jsonl")
|
||||
MARCA = SALIDA + ".hecho"
|
||||
ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434")
|
||||
|
||||
INSTRUCCION = """Eres un generador de preguntas para un buscador de apuntes de
|
||||
pentesting. Te doy un fragmento de apuntes tecnicos. Devuelve de UNA a TRES
|
||||
preguntas, en castellano, que una persona haria por voz y que ese fragmento
|
||||
responde.
|
||||
|
||||
Reglas:
|
||||
- Preguntas naturales y variadas, como las diria alguien: "como...", "que
|
||||
comando...", "cual es la forma de...".
|
||||
- NO respondas, NO copies comandos: solo las preguntas.
|
||||
- Si el fragmento es ruido (un titulo suelto, una lista de enlaces), devuelve
|
||||
una lista vacia.
|
||||
|
||||
Responde SOLO con JSON: {"preguntas": ["...", "..."]}"""
|
||||
|
||||
|
||||
FIJO = os.environ.get("JARVIS_MODELO") # si se fija a mano, manda
|
||||
RESPALDO = "qwen3.5:4b-jarvis"
|
||||
|
||||
|
||||
def _modelo_cargado():
|
||||
"""El modelo que ollama ya tiene en memoria, para no forzar un cambio."""
|
||||
try:
|
||||
with urllib.request.urlopen(ENDPOINT + "/api/ps", timeout=5) as r:
|
||||
m = json.load(r).get("models", [])
|
||||
if m:
|
||||
return m[0]["name"]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _modelo():
|
||||
"""Que modelo usar AHORA. Se re-mira cada tanto en vez de fijarlo al
|
||||
arrancar: en una tarjeta de 4 GB solo cabe uno, asi que si el carril verde
|
||||
tiene el suyo cargado, generamos con ESE (una pregunta no necesita el ajuste
|
||||
anti-alucinacion, solo la respuesta lo necesitaria y la respuesta es el
|
||||
fragmento literal). Asi no se fuerza a ollama a cambiar de modelo en cada
|
||||
peticion, que en esta maquina cuesta 2-3 s de recarga.
|
||||
"""
|
||||
return FIJO or _modelo_cargado() or RESPALDO
|
||||
|
||||
|
||||
MODELO = _modelo()
|
||||
|
||||
|
||||
def _pide(fragmento, modelo):
|
||||
cuerpo = json.dumps({
|
||||
"model": modelo,
|
||||
"messages": [{"role": "system", "content": INSTRUCCION},
|
||||
{"role": "user", "content": fragmento[:1400]}],
|
||||
"stream": False, "think": False,
|
||||
"options": {"temperature": 0.7, "num_predict": 200},
|
||||
"format": "json",
|
||||
}).encode()
|
||||
req = urllib.request.Request(ENDPOINT + "/api/chat", data=cuerpo,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
contenido = json.loads(r.read())["message"]["content"]
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
d = json.loads(contenido)
|
||||
except json.JSONDecodeError:
|
||||
m = re.search(r"\[.*\]", contenido, re.S)
|
||||
if not m:
|
||||
return []
|
||||
try:
|
||||
d = {"preguntas": json.loads(m.group(0))}
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
preguntas = d.get("preguntas") if isinstance(d, dict) else d
|
||||
if not isinstance(preguntas, list):
|
||||
return []
|
||||
fuera = []
|
||||
for p in preguntas:
|
||||
if isinstance(p, str) and 8 <= len(p.strip()) <= 200:
|
||||
fuera.append(p.strip())
|
||||
return fuera[:3]
|
||||
|
||||
|
||||
def fragmentos():
|
||||
"""Los fragmentos de cosecha, el ORO (metodologia) primero."""
|
||||
todos = []
|
||||
with open(COSECHA, encoding="utf-8") as f:
|
||||
for l in f:
|
||||
try:
|
||||
todos.append(json.loads(l))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
todos.sort(key=lambda d: 0 if d.get("tipo") == "metodologia" else 1)
|
||||
return todos
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--muestra", type=int, default=0)
|
||||
a = p.parse_args()
|
||||
|
||||
if not os.path.exists(COSECHA):
|
||||
print(" no hay cosecha.jsonl: corre cosecha.py primero")
|
||||
return 1
|
||||
|
||||
frags = fragmentos()
|
||||
print(f" {len(frags)} fragmentos · modelo {MODELO}", flush=True)
|
||||
|
||||
if a.muestra:
|
||||
for fr in frags[:a.muestra]:
|
||||
print(f"\n ── {fr.get('fichero')} ──")
|
||||
for q in _pide(fr["texto"], MODELO):
|
||||
print(f" P: {q}")
|
||||
return 0
|
||||
|
||||
desde = 0
|
||||
if os.path.exists(MARCA) and os.path.exists(SALIDA):
|
||||
try:
|
||||
desde = int(open(MARCA).read().strip()) + 1
|
||||
except (ValueError, OSError):
|
||||
desde = 0
|
||||
modo = "a" if desde else "w"
|
||||
hechas = sum(1 for _ in open(SALIDA)) if desde and os.path.exists(SALIDA) else 0
|
||||
if desde:
|
||||
print(f" reanudando desde el fragmento {desde} ({hechas} preguntas)", flush=True)
|
||||
|
||||
total = len(frags)
|
||||
modelo = MODELO
|
||||
with open(SALIDA, modo, encoding="utf-8", buffering=1) as out:
|
||||
for i in range(desde, total):
|
||||
# re-mirar que modelo esta cargado cada 25: si el verde se abre o se
|
||||
# cierra durante la noche, seguimos al que este en la tarjeta.
|
||||
if i % 25 == 0:
|
||||
modelo = _modelo()
|
||||
fr = frags[i]
|
||||
for q in _pide(fr["texto"], modelo):
|
||||
out.write(json.dumps({
|
||||
"tipo": "pregunta",
|
||||
"herramienta": fr.get("herramienta"),
|
||||
"perfil": fr.get("perfil"),
|
||||
"fichero": fr.get("fichero"),
|
||||
"tema": fr.get("tema", ""),
|
||||
"texto": fr["texto"], # la respuesta: el fragmento LITERAL
|
||||
"indexar": q, # lo que se embebe: la pregunta
|
||||
}, ensure_ascii=False) + "\n")
|
||||
hechas += 1
|
||||
with open(MARCA, "w") as m:
|
||||
m.write(str(i))
|
||||
if (i + 1) % 25 == 0:
|
||||
print(f" PROGRESO sintetiza hecho={i+1} total={total} "
|
||||
f"preguntas={hechas}", flush=True)
|
||||
print(f"\n hecho: {hechas} preguntas en {SALIDA}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
94
nucleo/saber/glosario.py
Normal file
94
nucleo/saber/glosario.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Tareas comunes de Linux en castellano, mapeadas al comando.
|
||||
|
||||
El puente que las man pages no dan: estan en ingles, y "cambiar permisos" no se
|
||||
parece a "change file mode bits". Aqui viven las tareas del dia a dia dichas
|
||||
como las diria el usuario, con el comando y una linea de como se usa. Es
|
||||
curado a mano a proposito: son las 50 cosas que de verdad se piden, y una
|
||||
traduccion buena vale mas que mil man pages mal recuperadas.
|
||||
|
||||
Se anaden al indice como entradas normales, asi que se buscan igual que todo lo
|
||||
demas y ademas se llevan el impulso por palabras exactas.
|
||||
"""
|
||||
|
||||
# (lo que se pide en castellano, el comando, como se usa)
|
||||
TAREAS = [
|
||||
("cambiar los permisos de un fichero o carpeta", "chmod",
|
||||
"chmod 755 fichero (rwx dueño, rx resto). chmod +x da permiso de ejecucion."),
|
||||
("cambiar el dueño de un fichero", "chown",
|
||||
"chown usuario:grupo fichero. Con -R para una carpeta entera."),
|
||||
("comprimir una carpeta en un archivo", "tar",
|
||||
"tar czf archivo.tar.gz carpeta/ comprime. tar xzf archivo.tar.gz extrae."),
|
||||
("descomprimir un zip", "unzip", "unzip archivo.zip. Con -d carpeta para donde."),
|
||||
("buscar texto dentro de ficheros", "grep",
|
||||
"grep -r 'texto' carpeta/ busca en todo. grep -i ignora mayusculas."),
|
||||
("buscar ficheros por nombre", "find",
|
||||
"find /ruta -name '*.txt' busca por nombre. -type f solo ficheros."),
|
||||
("ver los puertos abiertos en escucha", "ss",
|
||||
"ss -tlnp lista los puertos TCP en escucha con su proceso."),
|
||||
("ver que procesos consumen mas", "top",
|
||||
"top o htop en tiempo real. ps aux --sort=-%cpu para una foto."),
|
||||
("matar un proceso", "kill",
|
||||
"kill PID lo termina. kill -9 PID a la fuerza. pkill nombre por nombre."),
|
||||
("ver el espacio libre en disco", "df", "df -h muestra el espacio por particion."),
|
||||
("ver cuanto ocupa una carpeta", "du", "du -sh carpeta/ el tamaño total."),
|
||||
("ver la memoria libre", "free", "free -h muestra RAM y swap."),
|
||||
("descargar un fichero de internet", "wget",
|
||||
"wget URL descarga. curl -O URL tambien. curl -L sigue redirecciones."),
|
||||
("copiar ficheros a un servidor", "scp",
|
||||
"scp fichero usuario@servidor:/ruta lo sube. scp -r para carpetas."),
|
||||
("sincronizar carpetas", "rsync",
|
||||
"rsync -av origen/ destino/ copia solo lo cambiado. -z comprime en red."),
|
||||
("editar un fichero de texto", "nano",
|
||||
"nano fichero (facil) o vim fichero. Ctrl+O guarda y Ctrl+X sale en nano."),
|
||||
("ver el contenido de un fichero", "cat",
|
||||
"cat fichero. less fichero para leer largo. head/tail para principio/final."),
|
||||
("ver los ultimos cambios de un fichero de log", "tail",
|
||||
"tail -f log sigue en vivo. tail -n 50 las ultimas 50 lineas."),
|
||||
("ver los servicios del sistema", "systemctl",
|
||||
"systemctl status servicio. start/stop/restart para manejarlo."),
|
||||
("ver los mensajes del sistema", "journalctl",
|
||||
"journalctl -xe lo reciente. journalctl -u servicio de un servicio."),
|
||||
("montar un disco o usb", "mount",
|
||||
"mount /dev/sdX1 /mnt monta. lsblk lista los discos. umount desmonta."),
|
||||
("ver la configuracion de red", "ip",
|
||||
"ip a las interfaces y sus IP. ip r la tabla de rutas."),
|
||||
("escanear una red o unos puertos", "nmap",
|
||||
"nmap -sV objetivo detecta servicios. nmap -p- todos los puertos."),
|
||||
("ver el uso de red en vivo", "iftop", "iftop o nload muestran el trafico en vivo."),
|
||||
("cambiar de directorio y listar", "ls",
|
||||
"ls -la lista con detalles y ocultos. cd carpeta entra. pwd dice donde estas."),
|
||||
("crear una carpeta", "mkdir", "mkdir carpeta. mkdir -p a/b/c crea la ruta entera."),
|
||||
("mover o renombrar", "mv", "mv origen destino mueve o renombra."),
|
||||
("copiar ficheros", "cp", "cp origen destino. cp -r para carpetas."),
|
||||
("borrar ficheros", "rm",
|
||||
"rm fichero. rm -r carpeta. CUIDADO: no hay papelera, es definitivo."),
|
||||
("dar permisos de administrador a un comando", "sudo",
|
||||
"sudo comando lo ejecuta como root. Pide tu contraseña."),
|
||||
("ver el historial de comandos", "history", "history lista lo tecleado. Ctrl+R busca."),
|
||||
("programar una tarea periodica", "cron",
|
||||
"crontab -e edita tus tareas. Formato: min hora dia mes diasemana comando."),
|
||||
("ver la temperatura y el hardware", "sensors",
|
||||
"sensors muestra temperaturas. lscpu la CPU, lspci los dispositivos."),
|
||||
("gestionar paquetes en Debian", "apt",
|
||||
"apt install paquete, apt update actualiza la lista, apt upgrade el sistema."),
|
||||
("ver o cambiar variables de entorno", "export",
|
||||
"export VAR=valor la pone. env las lista. echo $VAR la muestra."),
|
||||
("comprobar si un comando esta instalado", "which",
|
||||
"which comando dice donde esta, o nada si no esta. type comando tambien."),
|
||||
("ver diferencias entre dos ficheros", "diff",
|
||||
"diff a b muestra las diferencias. diff -u formato de parche."),
|
||||
("contar lineas palabras o bytes", "wc",
|
||||
"wc -l lineas, wc -w palabras. cat fichero | wc -l cuenta lineas."),
|
||||
("conectarse a una base de datos", "psql",
|
||||
"psql -U usuario base para Postgres. mysql -u usuario -p para MySQL."),
|
||||
("ver quien esta conectado", "who", "who los usuarios. w con lo que hacen."),
|
||||
]
|
||||
|
||||
|
||||
def entradas():
|
||||
for pide, cmd, como in TAREAS:
|
||||
yield {
|
||||
"tipo": "tarea", "herramienta": cmd, "perfil": "LINUX",
|
||||
"fichero": f"tarea comun: {cmd}",
|
||||
"texto": f"Para {pide}: usa {cmd}. {como}",
|
||||
}
|
||||
232
nucleo/saber/indexa.py
Executable file
232
nucleo/saber/indexa.py
Executable file
|
|
@ -0,0 +1,232 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Convierte COFRE en algo que JARVIS puede consultar antes de contestar.
|
||||
|
||||
./indexa.py reconstruye el indice
|
||||
./indexa.py --cuenta solo dice que encontraria, sin indexar
|
||||
|
||||
## El problema que resuelve
|
||||
|
||||
COFRE tiene 35.000 documentos y 30 GB. Indexarlo entero seria a la vez inutil y
|
||||
lentisimo: el 95 % es codigo fuente de terceros —node_modules, .git, dist— que
|
||||
no dice nada de COMO USAR una herramienta. Una busqueda sobre eso devuelve
|
||||
ruido con total confianza, que es peor que no tener nada.
|
||||
|
||||
Lo que de verdad vale es el SABER, y son tres cosas:
|
||||
|
||||
1. Los apuntes propios del usuario: 26.000 palabras de flujos de trabajo
|
||||
escritos probandolos. Es lo mas valioso porque es correcto y es suyo.
|
||||
2. Los READMEs de primer nivel de cada herramienta: el "para que sirve y como
|
||||
se llama", una vez por herramienta.
|
||||
3. Una ficha por herramienta: nombre, perfil, ruta, una linea. Asi JARVIS
|
||||
sabe QUE tiene aunque no haya un README.
|
||||
|
||||
## Como se trocea
|
||||
|
||||
En fragmentos por seccion (los encabezados markdown) y no en trozos ciegos de N
|
||||
caracteres: un apunte partido por la mitad de una frase recupera peor que uno
|
||||
partido por donde el autor ya separo las ideas. Cada fragmento recuerda de que
|
||||
fichero y que herramienta viene, para poder citar la fuente.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
COFRE = os.path.expanduser("~/COFRE")
|
||||
INDICE = os.path.join(AQUI, "datos", "saber.jsonl")
|
||||
|
||||
# Los perfiles que se indexan. Cada uno es una carpeta de ~/COFRE.
|
||||
PERFILES = ["PENTESTERS", "PHISHERS", "REVERSERS", "PROTECTORS", "HARD-WARERS",
|
||||
"AUTOMATAS", "DEFACERS", "SNEAKERS", "ZAPPERS"]
|
||||
|
||||
# Lo que NO se mira: dependencias, control de versiones, builds. Aqui esta la
|
||||
# diferencia entre un indice util y 30 GB de ruido.
|
||||
IGNORA = re.compile(
|
||||
r"(^|/)(node_modules|\.git|vendor|dist|build|__pycache__|\.venv|venv|"
|
||||
r"site-packages|target|\.cache|test|tests|fixtures|examples?|locale|"
|
||||
r"i18n|translations|\.github)(/|$)", re.I)
|
||||
|
||||
# Documentos que son saber, no plantillas de proyecto ni codigo de conducta.
|
||||
DOC_UTIL = re.compile(r"(readme|install|usage|apuntes|notas|trucos|howto|"
|
||||
r"documentation|guide|guia|cheat|manual)", re.I)
|
||||
DOC_RUIDO = re.compile(r"(code_of_conduct|contributing|changelog|license|"
|
||||
r"copying|pull_request|issue_template|security\.md|"
|
||||
r"hall_of_fame|redistributed|historical)", re.I)
|
||||
|
||||
MIN_FRAGMENTO = 80 # menos de esto no es un parrafo, es un titulo suelto
|
||||
MAX_FRAGMENTO = 1200 # mas de esto recupera con demasiado ruido alrededor
|
||||
|
||||
|
||||
def _profundidad(ruta):
|
||||
return ruta.rstrip("/").count("/")
|
||||
|
||||
|
||||
def herramientas(perfil):
|
||||
"""Cada subcarpeta de primer nivel es una herramienta. Una ficha por cada."""
|
||||
base = os.path.join(COFRE, perfil)
|
||||
if not os.path.isdir(base):
|
||||
return
|
||||
for nombre in sorted(os.listdir(base)):
|
||||
ruta = os.path.join(base, nombre)
|
||||
if not os.path.isdir(ruta) or nombre.startswith("."):
|
||||
continue
|
||||
yield {"herramienta": nombre, "perfil": perfil, "ruta": ruta}
|
||||
|
||||
|
||||
def _resumen_readme(ruta_herramienta):
|
||||
"""El primer parrafo con sustancia de su README, si tiene."""
|
||||
for nombre in ("README.md", "README", "README.txt", "readme.md"):
|
||||
p = os.path.join(ruta_herramienta, nombre)
|
||||
if not os.path.exists(p):
|
||||
continue
|
||||
try:
|
||||
with open(p, encoding="utf-8", errors="ignore") as f:
|
||||
texto = f.read(4000)
|
||||
except OSError:
|
||||
continue
|
||||
# saltar titulos, insignias y lineas vacias hasta el primer parrafo real
|
||||
for parrafo in re.split(r"\n\s*\n", texto):
|
||||
limpio = re.sub(r"[#>*`\[\]!]|\(https?://[^)]+\)|<[^>]+>", "", parrafo).strip()
|
||||
limpio = re.sub(r"\s+", " ", limpio)
|
||||
if len(limpio) >= 60 and not limpio.lower().startswith(("http", "===")):
|
||||
return limpio[:400]
|
||||
return ""
|
||||
|
||||
|
||||
def documentos_utiles(perfil):
|
||||
"""Los .md/.txt que son saber, no codigo ni plantillas."""
|
||||
base = os.path.join(COFRE, perfil)
|
||||
for raiz, dirs, ficheros in os.walk(base):
|
||||
if IGNORA.search(raiz):
|
||||
dirs[:] = []
|
||||
continue
|
||||
# no bajar mas de 3 niveles: el saber vive arriba, el codigo abajo
|
||||
if _profundidad(raiz) - _profundidad(base) > 3:
|
||||
dirs[:] = []
|
||||
continue
|
||||
for fich in ficheros:
|
||||
if not fich.lower().endswith((".md", ".txt")):
|
||||
continue
|
||||
if DOC_RUIDO.search(fich):
|
||||
continue
|
||||
# los apuntes propios entran siempre; de terceros, solo los utiles
|
||||
propio = re.search(r"apuntes|trucos|notas|cheat|guia", fich, re.I)
|
||||
if not propio and not DOC_UTIL.search(fich):
|
||||
continue
|
||||
yield os.path.join(raiz, fich)
|
||||
|
||||
|
||||
def trocea(ruta):
|
||||
"""Un fichero markdown en fragmentos por seccion."""
|
||||
try:
|
||||
with open(ruta, encoding="utf-8", errors="ignore") as f:
|
||||
texto = f.read()
|
||||
except OSError:
|
||||
return
|
||||
# partir por encabezados, conservando el titulo con su seccion
|
||||
partes = re.split(r"\n(?=#{1,3}\s)", texto)
|
||||
for parte in partes:
|
||||
limpio = parte.strip()
|
||||
if len(limpio) < MIN_FRAGMENTO:
|
||||
continue
|
||||
# si una seccion es enorme, partirla por parrafos sin pasar del tope
|
||||
if len(limpio) <= MAX_FRAGMENTO:
|
||||
yield limpio
|
||||
else:
|
||||
buffer = ""
|
||||
for parrafo in re.split(r"\n\s*\n", limpio):
|
||||
if len(buffer) + len(parrafo) > MAX_FRAGMENTO and buffer:
|
||||
yield buffer.strip()
|
||||
buffer = ""
|
||||
buffer += parrafo + "\n\n"
|
||||
if len(buffer.strip()) >= MIN_FRAGMENTO:
|
||||
yield buffer.strip()
|
||||
|
||||
|
||||
def construye(solo_cuenta=False, solo_cofre=False):
|
||||
fichas, fragmentos = [], []
|
||||
|
||||
for perfil in PERFILES:
|
||||
n_herr = 0
|
||||
for h in herramientas(perfil):
|
||||
resumen = _resumen_readme(h["ruta"])
|
||||
texto = f"{h['herramienta']} ({perfil}). {resumen}".strip()
|
||||
fichas.append({
|
||||
"tipo": "ficha", "herramienta": h["herramienta"],
|
||||
"perfil": perfil, "ruta": h["ruta"], "texto": texto,
|
||||
})
|
||||
n_herr += 1
|
||||
|
||||
n_frag = 0
|
||||
for doc in documentos_utiles(perfil):
|
||||
herr = _herramienta_de(doc, perfil)
|
||||
for trozo in trocea(doc):
|
||||
fragmentos.append({
|
||||
"tipo": "apunte", "herramienta": herr, "perfil": perfil,
|
||||
"fichero": os.path.relpath(doc, COFRE), "texto": trozo,
|
||||
})
|
||||
n_frag += 1
|
||||
print(f" {perfil:14s} {n_herr:4d} herramientas {n_frag:5d} fragmentos de apuntes")
|
||||
|
||||
# El conocimiento de la propia maquina: comandos de Linux, servidores SSH,
|
||||
# extensiones. Es lo que convierte "sabe de mis herramientas" en "sabe hacer
|
||||
# cualquier cosa en Linux". Va salvo que se pida solo COFRE.
|
||||
sistema = []
|
||||
if not solo_cofre:
|
||||
try:
|
||||
from saber import sistema as sis
|
||||
except ImportError:
|
||||
import sistema as sis
|
||||
print(" extrayendo la documentacion del sistema (man pages, ssh, firefox)...",
|
||||
flush=True)
|
||||
n_cmd = n_srv = n_nav = 0
|
||||
for e in sis.todo():
|
||||
sistema.append(e)
|
||||
n_cmd += e["tipo"] == "comando"
|
||||
n_srv += e["tipo"] == "servidor"
|
||||
n_nav += e["tipo"] == "navegador"
|
||||
if len(sistema) % 500 == 0:
|
||||
print(f" {len(sistema)} entradas de sistema...", flush=True)
|
||||
print(f" LINUX {n_cmd:4d} comandos {n_srv} servidores {n_nav} navegador")
|
||||
|
||||
todo = fichas + fragmentos + sistema
|
||||
print(f"\n total: {len(fichas)} fichas + {len(fragmentos)} apuntes + "
|
||||
f"{len(sistema)} sistema = {len(todo)} entradas")
|
||||
if solo_cuenta:
|
||||
return 0
|
||||
|
||||
print(" cargando el modelo de embeddings...", flush=True)
|
||||
from model2vec import StaticModel
|
||||
modelo = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
|
||||
|
||||
print(" calculando vectores...", flush=True)
|
||||
vectores = modelo.encode([e["texto"] for e in todo], show_progress_bar=False)
|
||||
|
||||
os.makedirs(os.path.dirname(INDICE), exist_ok=True)
|
||||
with open(INDICE, "w", encoding="utf-8") as f:
|
||||
for entrada, vec in zip(todo, vectores):
|
||||
entrada["v"] = [round(float(x), 5) for x in vec]
|
||||
f.write(json.dumps(entrada, ensure_ascii=False) + "\n")
|
||||
print(f" indice guardado: {INDICE} ({os.path.getsize(INDICE)//1024} KB)")
|
||||
return 0
|
||||
|
||||
|
||||
def _herramienta_de(ruta, perfil):
|
||||
partes = os.path.relpath(ruta, os.path.join(COFRE, perfil)).split(os.sep)
|
||||
return partes[0] if len(partes) > 1 else perfil
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--cuenta", action="store_true",
|
||||
help="di que encontrarias, sin indexar")
|
||||
p.add_argument("--solo-cofre", action="store_true",
|
||||
help="indexar solo COFRE, sin la documentacion del sistema")
|
||||
a = p.parse_args()
|
||||
return construye(solo_cuenta=a.cuenta, solo_cofre=a.solo_cofre)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
183
nucleo/saber/sistema.py
Normal file
183
nucleo/saber/sistema.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"""Indexar el conocimiento de la propia maquina: comandos, servidores, extensiones.
|
||||
|
||||
El RAG de COFRE (indexa.py) sabe de las herramientas del usuario. Esto añade lo
|
||||
otro que pidio: que sepa hacer CUALQUIER cosa en GNU/Linux. La fuente ya esta en
|
||||
el disco —9.541 man pages, 3.000 comandos— y es la mejor que hay: la
|
||||
documentacion oficial de cada herramienta, escrita por quien la hizo.
|
||||
|
||||
## Que se extrae de cada man page, y por que no entera
|
||||
|
||||
Una man page entera son cientos de lineas: todas las opciones, los ejemplos, las
|
||||
notas, los bugs. Meterla entera hace el indice enorme y recupera peor —hay tanto
|
||||
texto que la parte relevante se diluye—. Se coge lo que contesta "¿que hace y
|
||||
como se llama?":
|
||||
|
||||
NAME una linea: que es
|
||||
SYNOPSIS como se invoca, con sus opciones
|
||||
DESCRIPTION los primeros parrafos, donde esta lo esencial
|
||||
|
||||
Con eso, preguntar "como listo los puertos abiertos" recupera la man de ss o
|
||||
netstat con su sintaxis, y el cerebro construye el comando desde ahi.
|
||||
|
||||
## Solo los comandos que EXISTEN en esta maquina
|
||||
|
||||
No las 9.541 man pages: muchas son de librerias de C, formatos de fichero y cosas
|
||||
que no se invocan desde la terminal. Se indexan las de los comandos que estan de
|
||||
verdad en el PATH (secciones 1 y 8), que es lo que el usuario puede pedir que se
|
||||
ejecute.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
def _limpio(texto: str) -> str:
|
||||
# las man pages traen backspaces para negrita (c\bco\bom\bmo); se quitan
|
||||
texto = re.sub(r".\x08", "", texto)
|
||||
texto = re.sub(r"\x1b\[[0-9;]*m", "", texto) # colores ANSI
|
||||
return re.sub(r"[ \t]+", " ", texto)
|
||||
|
||||
|
||||
def _comandos_del_path() -> list:
|
||||
vistos = set()
|
||||
for carpeta in os.environ.get("PATH", "").split(":"):
|
||||
try:
|
||||
for nombre in os.listdir(carpeta):
|
||||
ruta = os.path.join(carpeta, nombre)
|
||||
if nombre not in vistos and os.access(ruta, os.X_OK) \
|
||||
and not os.path.isdir(ruta):
|
||||
vistos.add(nombre)
|
||||
except OSError:
|
||||
continue
|
||||
return sorted(vistos)
|
||||
|
||||
|
||||
def _man(comando: str) -> str:
|
||||
"""NAME + SYNOPSIS + arranque de DESCRIPTION de un comando, o ''."""
|
||||
try:
|
||||
r = subprocess.run(["man", comando], capture_output=True, text=True,
|
||||
timeout=8, env={**os.environ, "MANWIDTH": "80",
|
||||
"MAN_KEEP_FORMATTING": ""})
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
if r.returncode != 0 or not r.stdout:
|
||||
return ""
|
||||
texto = _limpio(r.stdout)
|
||||
|
||||
trozos = []
|
||||
seccion = None
|
||||
buffer = []
|
||||
# se cortan las secciones por sus titulos en mayuscula al margen izquierdo
|
||||
for linea in texto.splitlines():
|
||||
cabecera = re.match(r"^([A-Z][A-Z ]{2,20})$", linea.strip())
|
||||
if cabecera:
|
||||
if seccion in ("NAME", "SYNOPSIS", "DESCRIPTION") and buffer:
|
||||
trozos.append((seccion, "\n".join(buffer).strip()))
|
||||
seccion = cabecera.group(1).strip()
|
||||
buffer = []
|
||||
if seccion in ("OPTIONS", "SEE ALSO", "AUTHOR", "COPYRIGHT",
|
||||
"REPORTING BUGS", "EXAMPLES"):
|
||||
seccion = None # dejar de acumular
|
||||
elif seccion:
|
||||
buffer.append(linea)
|
||||
if seccion in ("NAME", "SYNOPSIS", "DESCRIPTION") and buffer:
|
||||
trozos.append((seccion, "\n".join(buffer).strip()))
|
||||
|
||||
partes = []
|
||||
for sec, cont in trozos:
|
||||
if sec == "DESCRIPTION":
|
||||
cont = cont[:600] # solo el arranque de la descripcion
|
||||
partes.append(cont)
|
||||
return "\n".join(partes).strip()
|
||||
|
||||
|
||||
def comandos():
|
||||
"""Una entrada por comando del PATH que tenga man page."""
|
||||
for cmd in _comandos_del_path():
|
||||
texto = _man(cmd)
|
||||
if len(texto) < 40:
|
||||
continue
|
||||
yield {
|
||||
"tipo": "comando", "herramienta": cmd, "perfil": "LINUX",
|
||||
"fichero": f"man {cmd}", "texto": texto[:1400],
|
||||
}
|
||||
|
||||
|
||||
def servidores():
|
||||
"""Una ficha por host del ssh config, para que sepa a que puede conectarse."""
|
||||
ruta = os.path.expanduser("~/.ssh/config")
|
||||
if not os.path.exists(ruta):
|
||||
return
|
||||
host = None
|
||||
datos = {}
|
||||
|
||||
def suelta():
|
||||
if host and "*" not in host:
|
||||
hn = datos.get("hostname", host)
|
||||
usuario = datos.get("user", "")
|
||||
desc = (f"Servidor SSH «{host}»: se conecta con «ssh {host}». "
|
||||
f"Direccion {hn}." + (f" Usuario {usuario}." if usuario else "")
|
||||
+ " Para ejecutar algo alli: ssh " + host + " '<comando>'.")
|
||||
return {"tipo": "servidor", "herramienta": host, "perfil": "SSH",
|
||||
"fichero": "~/.ssh/config", "texto": desc}
|
||||
return None
|
||||
|
||||
for linea in open(ruta, encoding="utf-8", errors="ignore"):
|
||||
m = re.match(r"^\s*(\w+)\s+(.+?)\s*$", linea)
|
||||
if not m:
|
||||
continue
|
||||
clave, valor = m.group(1).lower(), m.group(2)
|
||||
if clave == "host":
|
||||
hecho = suelta()
|
||||
if hecho:
|
||||
yield hecho
|
||||
host, datos = valor.split()[0], {}
|
||||
elif host:
|
||||
datos[clave] = valor
|
||||
hecho = suelta()
|
||||
if hecho:
|
||||
yield hecho
|
||||
|
||||
|
||||
def extensiones_firefox():
|
||||
"""Que extensiones tiene puestas, para que sepa con que cuenta en el navegador.
|
||||
|
||||
Aviso honesto de alcance: JARVIS puede ABRIR firefox y llevarlo a una URL,
|
||||
pero NO puede pulsar los botones de una extension —eso vive dentro del
|
||||
navegador, fuera del alcance de un comando de shell—. Lo que si puede es
|
||||
saber que tienes puestas y abrir lo que cada una gestiona por URL.
|
||||
"""
|
||||
import glob
|
||||
import json as _json
|
||||
base = os.path.expanduser("~/.mozilla/firefox")
|
||||
perfiles = glob.glob(os.path.join(base, "*", "extensions.json"))
|
||||
nombres = []
|
||||
for p in perfiles:
|
||||
try:
|
||||
datos = _json.load(open(p, encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for addon in datos.get("addons", []):
|
||||
if not addon.get("active") or addon.get("type") != "extension":
|
||||
continue
|
||||
nombre = ((addon.get("defaultLocale") or {}).get("name")
|
||||
or addon.get("id", ""))
|
||||
if nombre and nombre not in nombres:
|
||||
nombres.append(nombre)
|
||||
if nombres:
|
||||
yield {
|
||||
"tipo": "navegador", "herramienta": "firefox", "perfil": "NAVEGADOR",
|
||||
"fichero": "extensiones de firefox",
|
||||
"texto": ("Extensiones instaladas en Firefox: " + ", ".join(nombres)
|
||||
+ ". JARVIS puede abrir Firefox y llevarlo a una URL; los "
|
||||
"botones de cada extension se pulsan dentro del navegador."),
|
||||
}
|
||||
|
||||
|
||||
def todo():
|
||||
"""Todas las entradas de sistema, para que indexa.py las sume a las de COFRE."""
|
||||
from saber import glosario
|
||||
yield from glosario.entradas() # el glosario en castellano, primero
|
||||
yield from servidores()
|
||||
yield from extensiones_firefox()
|
||||
yield from comandos()
|
||||
54
nucleo/ventana.sh
Executable file
54
nucleo/ventana.sh
Executable file
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env bash
|
||||
# Abre el panel en SU PROPIA VENTANA, no en una pestaña del navegador.
|
||||
#
|
||||
# ventana.sh [url]
|
||||
#
|
||||
# Existe porque habia dos sitios abriendo el panel de dos maneras distintas:
|
||||
# jarvis.py lo abria con `chromium --app=`, que sale como una aplicacion, y
|
||||
# arranca.sh —cuando JARVIS ya estaba en marcha— tiraba de `xdg-open`, que abre
|
||||
# el navegador POR DEFECTO en una pestaña mas. Segun si ya estaba abierto o no,
|
||||
# el mismo icono daba una cosa u otra.
|
||||
#
|
||||
# ## Que hace que parezca una aplicacion y no una web
|
||||
#
|
||||
# --app=URL sin barra de direcciones, sin pestañas, sin marcadores
|
||||
# --user-data-dir perfil propio: no se mezcla con la navegacion del
|
||||
# usuario, no hereda sus extensiones y no arrastra su
|
||||
# sesion. Ademas asi la ventana es un proceso aparte y
|
||||
# cerrarla no toca al navegador de verdad.
|
||||
# --class para que el gestor de ventanas la trate como una
|
||||
# aplicacion propia: entrada suya en la barra de tareas y
|
||||
# su icono, no el del navegador. Va acompañado del
|
||||
# StartupWMClass del .desktop.
|
||||
#
|
||||
# Firefox queda como ultimo recurso: su `--kiosk` no deja ni cerrar la ventana
|
||||
# comodamente y no tiene un equivalente limpio a --app. Si es lo unico que hay,
|
||||
# se abre en ventana nueva y se avisa.
|
||||
set -uo pipefail
|
||||
|
||||
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
|
||||
URL=${1:-http://127.0.0.1:${JARVIS_PUERTO:-8790}/}
|
||||
PERFIL=${XDG_CACHE_HOME:-$HOME/.cache}/jarvis-panel
|
||||
CLASE=jarvis-nucleo
|
||||
|
||||
for navegador in chromium chromium-browser google-chrome brave-browser; do
|
||||
command -v "$navegador" >/dev/null || continue
|
||||
exec "$navegador" \
|
||||
--app="$URL" \
|
||||
--user-data-dir="$PERFIL" \
|
||||
--class="$CLASE" \
|
||||
--window-size=1500,940 \
|
||||
--no-first-run \
|
||||
--no-default-browser-check \
|
||||
--disable-features=TranslateUI \
|
||||
>/dev/null 2>&1
|
||||
done
|
||||
|
||||
if command -v firefox >/dev/null || command -v firefox-esr >/dev/null; then
|
||||
FF=$(command -v firefox || command -v firefox-esr)
|
||||
echo "sin chromium: el panel se abre en una ventana de Firefox, con su barra" >&2
|
||||
exec "$FF" --new-window "$URL" >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
echo "no hay ningun navegador con el que abrir el panel: $URL" >&2
|
||||
exit 1
|
||||
Loading…
Add table
Add a link
Reference in a new issue