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
30
config/README_whisper_gpu.md
Normal file
30
config/README_whisper_gpu.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Whisper small en GPU
|
||||
|
||||
El reconocimiento pasa del modelo `base` en CPU al `small` en GPU. Medido:
|
||||
|
||||
| | acierto | latencia (régimen) |
|
||||
|---|---|---|
|
||||
| base · CPU | 80 % | 928 ms |
|
||||
| small · GPU | **93 %** | **~930 ms** |
|
||||
|
||||
Misma velocidad, +13 puntos de acierto. `small` ocupa 757 MiB en la T1200 y
|
||||
coexiste con el modelo grande de Ollama (2901/3717 MiB con los dos cargados).
|
||||
|
||||
## Cómo está montado
|
||||
|
||||
- whisper.cpp compilado con CUDA (sm_75) en `voz/whisper-cuda/` (no versionado;
|
||||
se reconstruye con `cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=75`).
|
||||
- Envoltorio `~/.local/bin/whisper-server` (copia de referencia en
|
||||
`config/whisper-server-cuda.sh`): fija `LD_LIBRARY_PATH` a las libs de CUDA y
|
||||
llama al binario. Está en el PATH del host, que es donde Newelle lo busca.
|
||||
- Ajustes de Newelle: `stt-settings.whispercpp = {model: small,
|
||||
use_system_server: true}`. El handler arranca el servidor por
|
||||
`flatpak-spawn --host whisper-server` y le pega las peticiones.
|
||||
|
||||
## Lo único a saber
|
||||
|
||||
La PRIMERA transcripción de cada sesión tarda ~4 s: es el arranque del servidor
|
||||
(cargar el modelo a la GPU). A partir de ahí, ~930 ms. El servidor se queda
|
||||
vivo mientras la app corre.
|
||||
|
||||
Para volver a CPU: `use_system_server: false` y `model: base` en los ajustes.
|
||||
53
config/ampliar_swap.sh
Normal file
53
config/ampliar_swap.sh
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
#!/usr/bin/env bash
|
||||
# Amplia la swap en caliente, sin reiniciar y sin tocar particiones.
|
||||
#
|
||||
# Por que: el portatil tiene 62 GB de RAM y 976 MiB de swap en la particion
|
||||
# nvme0n1p3. Ese reparto es de una maquina con 2 GB de RAM, no con 62. La swap
|
||||
# se llena (96% medido) y aunque hoy no cause nada —comprobado: si=4 so=0 KB/s,
|
||||
# 43 GB de RAM disponible, ninguna fuga— no queda colchon para cuando algo pida
|
||||
# memoria de golpe.
|
||||
#
|
||||
# Lo que NO arregla: nada de lo que se notaba. La pantalla que pedia contraseña
|
||||
# era el candado de JARVIS, no el sistema. Esto es higiene, no una reparacion.
|
||||
#
|
||||
# Se ejecuta con sudo y no hace falta reiniciar: `swapon` activa el fichero al
|
||||
# momento y la linea del fstab solo sirve para el proximo arranque.
|
||||
#
|
||||
# sudo bash ampliar_swap.sh [tamaño] # por defecto 16G
|
||||
set -euo pipefail
|
||||
|
||||
TAM=${1:-16G}
|
||||
FICHERO=/swapfile
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || { echo "hay que ejecutarlo con sudo" >&2; exit 1; }
|
||||
|
||||
if swapon --show=NAME --noheadings 2>/dev/null | grep -qx "$FICHERO"; then
|
||||
echo "$FICHERO ya esta activo; nada que hacer."
|
||||
swapon --show
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "1. creando $FICHERO de $TAM"
|
||||
# fallocate va bien en ext4; en btrfs haria falta otra receta (chattr +C y
|
||||
# sin compresion), pero la raiz de esta maquina es ext4.
|
||||
fallocate -l "$TAM" "$FICHERO" 2>/dev/null || dd if=/dev/zero of="$FICHERO" bs=1M count=$((${TAM%G} * 1024)) status=none
|
||||
chmod 600 "$FICHERO" # solo root: si no, mkswap se niega y con razon
|
||||
|
||||
echo "2. formateando"
|
||||
mkswap "$FICHERO" >/dev/null
|
||||
|
||||
echo "3. activando (aqui ya esta funcionando, sin reiniciar)"
|
||||
swapon "$FICHERO"
|
||||
|
||||
echo "4. dejandolo puesto para el proximo arranque"
|
||||
grep -q "^$FICHERO " /etc/fstab || echo "$FICHERO none swap sw 0 0" >> /etc/fstab
|
||||
|
||||
# Con 62 GB de RAM no tiene sentido apartar paginas al disco con la alegria de
|
||||
# swappiness=60: se baja a 10 para que la swap sea un seguro y no una costumbre.
|
||||
echo "5. bajando swappiness de $(cat /proc/sys/vm/swappiness) a 10"
|
||||
sysctl -q vm.swappiness=10
|
||||
echo "vm.swappiness=10" > /etc/sysctl.d/99-jarvis-swappiness.conf
|
||||
|
||||
echo
|
||||
swapon --show
|
||||
free -h | head -3
|
||||
148
config/aprender.py
Normal file
148
config/aprender.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Enseña acciones nuevas a partir de lo que no entendió.
|
||||
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/aprender.py
|
||||
|
||||
Cada vez que dices algo que no encaja con ninguna accion, el panel lo anota en
|
||||
config/jarvis-sin-accion.jsonl. Esto lo lee, agrupa lo repetido y pregunta a
|
||||
que accion pertenece cada frase; lo que decidas se escribe como alias en
|
||||
config/jarvis-acciones.json, que el catalogo carga ANTES que el del paquete.
|
||||
|
||||
Por que asi y no entrenando un modelo: los fallos del reconocedor son
|
||||
sistematicos, no aleatorios. "abre el correo" se oye "a ver el correo" siempre,
|
||||
no una de cada diez veces. Un alias lo arregla del todo, se lee, se borra si
|
||||
molesta y no cuesta un milisegundo. Reentrenar costaria horas para acertar
|
||||
"casi siempre".
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
|
||||
REGISTRO = os.path.join(CONFIG, "jarvis-sin-accion.jsonl")
|
||||
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
|
||||
PAQUETE = os.path.expanduser(
|
||||
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
|
||||
"active/files/share/newelle/acciones/acciones.json")
|
||||
|
||||
|
||||
def carga_catalogo():
|
||||
acciones = {}
|
||||
for ruta in (PAQUETE, PROPIO):
|
||||
try:
|
||||
with open(ruta) as f:
|
||||
for a in json.load(f).get("acciones", []):
|
||||
acciones.setdefault(a["id"], a)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return acciones
|
||||
|
||||
|
||||
def carga_fallos():
|
||||
"""Las frases sin accion, agrupadas por veces que se han dicho."""
|
||||
cuenta = Counter()
|
||||
try:
|
||||
with open(REGISTRO) as f:
|
||||
for linea in f:
|
||||
try:
|
||||
texto = json.loads(linea).get("texto", "").strip()
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if texto:
|
||||
cuenta[texto] += 1
|
||||
except OSError:
|
||||
pass
|
||||
return cuenta
|
||||
|
||||
|
||||
def guarda_alias(nuevos):
|
||||
"""Añade los alias al catalogo propio, respetando lo que ya hubiera."""
|
||||
try:
|
||||
with open(PROPIO) as f:
|
||||
propio = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
propio = {"version": 1, "acciones": []}
|
||||
|
||||
porid = {a["id"]: a for a in propio.get("acciones", [])}
|
||||
catalogo = carga_catalogo()
|
||||
|
||||
for ident, frases in nuevos.items():
|
||||
if ident in porid:
|
||||
entrada = porid[ident]
|
||||
else:
|
||||
# se copia la del paquete entera: el catalogo propio se carga antes
|
||||
# y sustituye a la original, asi que tiene que traer su comando
|
||||
entrada = dict(catalogo[ident])
|
||||
porid[ident] = entrada
|
||||
propio.setdefault("acciones", []).append(entrada)
|
||||
for f in frases:
|
||||
if f not in entrada["frases"]:
|
||||
entrada["frases"].append(f)
|
||||
|
||||
with open(PROPIO, "w") as f:
|
||||
json.dump(propio, f, indent=2, ensure_ascii=False)
|
||||
return PROPIO
|
||||
|
||||
|
||||
def main():
|
||||
fallos = carga_fallos()
|
||||
if not fallos:
|
||||
print("No hay nada sin entender todavia.")
|
||||
print(f"Se anota solo en {REGISTRO}")
|
||||
return 0
|
||||
|
||||
catalogo = carga_catalogo()
|
||||
ids = sorted(catalogo)
|
||||
print(f"{len(fallos)} frases distintas no encajaron con ninguna accion.\n")
|
||||
|
||||
nuevos = {}
|
||||
for texto, veces in fallos.most_common():
|
||||
print(f' "{texto}" ({veces} {"vez" if veces == 1 else "veces"})')
|
||||
print(" escribe el id de la accion, 'l' para listarlos, "
|
||||
"Enter para saltar, 'q' para terminar")
|
||||
try:
|
||||
resp = input(" > ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
break
|
||||
|
||||
if resp == "q":
|
||||
break
|
||||
if resp == "l":
|
||||
for i, ident in enumerate(ids):
|
||||
print(f" {ident:<24}", end="\n" if i % 3 == 2 else "")
|
||||
print()
|
||||
try:
|
||||
resp = input(" > ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if not resp:
|
||||
continue
|
||||
if resp not in catalogo:
|
||||
print(f" '{resp}' no existe, saltada")
|
||||
continue
|
||||
|
||||
nuevos.setdefault(resp, []).append(texto)
|
||||
print(f" anotada como alias de {resp}")
|
||||
|
||||
if not nuevos:
|
||||
print("\nNada que guardar.")
|
||||
return 0
|
||||
|
||||
ruta = guarda_alias(nuevos)
|
||||
total = sum(len(v) for v in nuevos.values())
|
||||
print(f"\n{total} alias guardados en {ruta}")
|
||||
print("Se aplican al abrir la pestaña JARVIS otra vez.")
|
||||
|
||||
if input("¿Vaciar el registro de frases no entendidas? [s/N] ").strip().lower() == "s":
|
||||
try:
|
||||
os.remove(REGISTRO)
|
||||
print("registro vaciado")
|
||||
except OSError as e:
|
||||
print(f"no se pudo vaciar: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
65
config/apuntar_modelo_jarvis.py
Normal file
65
config/apuntar_modelo_jarvis.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Apunta el cerebro de Newelle al modelo qwen3.5:4b-jarvis.
|
||||
|
||||
Es la variante con num_gpu 24 (ver qwen3.5-4b-jarvis.Modelfile): deja 1540 MiB
|
||||
libres en la tarjeta para que whisper pueda transcribir sin morir de OOM.
|
||||
|
||||
Toca solo la clave llm-settings, y dentro solo el bloque "ollama": el modelo
|
||||
elegido, la lista de modelos y la biblioteca que se ve en la interfaz.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
APP = "io.github.qwersyk.Newelle"
|
||||
RAMA = "master"
|
||||
MODELO = "qwen3.5:4b-jarvis"
|
||||
|
||||
GSET = ["flatpak", "run", f"--command=gsettings", f"{APP}//{RAMA}"]
|
||||
|
||||
|
||||
def leer(clave):
|
||||
out = subprocess.check_output(GSET + ["get", APP, clave], text=True).strip()
|
||||
# gsettings devuelve el string entre comillas simples, con \\ escapados
|
||||
return json.loads(out[1:-1].replace("\\\\", "\\").replace("\\'", "'"))
|
||||
|
||||
|
||||
def escribir(clave, valor):
|
||||
subprocess.check_call(GSET + ["set", APP, clave, json.dumps(valor)])
|
||||
|
||||
|
||||
def main():
|
||||
ajustes = leer("llm-settings")
|
||||
ollama = ajustes.setdefault("ollama", {})
|
||||
|
||||
anterior = ollama.get("model")
|
||||
if anterior == MODELO:
|
||||
print(f"ya apuntaba a {MODELO}; nada que hacer.")
|
||||
return 0
|
||||
|
||||
ollama["model"] = MODELO
|
||||
|
||||
# la lista de modelos del desplegable: pares [clave, titulo]
|
||||
modelos = json.loads(ollama.get("models", "[]"))
|
||||
if not any(m[0] == MODELO for m in modelos):
|
||||
modelos.insert(0, [MODELO, MODELO])
|
||||
ollama["models"] = json.dumps(modelos)
|
||||
|
||||
# la biblioteca que pinta la interfaz, para que salga con descripcion
|
||||
biblioteca = ollama.get("model-library", [])
|
||||
if not any(m.get("key") == MODELO for m in biblioteca):
|
||||
biblioteca.insert(0, {
|
||||
"key": MODELO,
|
||||
"title": "qwen3.5 4b (JARVIS)",
|
||||
"description": "qwen3.5:4b con 24 de 32 capas en GPU, para dejar "
|
||||
"sitio a whisper en la tarjeta de 4 GB.",
|
||||
})
|
||||
ollama["model-library"] = biblioteca
|
||||
|
||||
escribir("llm-settings", ajustes)
|
||||
print(f"cerebro: {anterior} -> {MODELO}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
464
config/autoescuela.py
Executable file
464
config/autoescuela.py
Executable file
|
|
@ -0,0 +1,464 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Dos JARVIS hablando: uno examina, el otro ejecuta, el primero corrige.
|
||||
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py # una tanda
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py --horas 6 # toda la noche
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py --aplicar # guardar mejoras
|
||||
|
||||
La idea. Los bucles que teniamos probaban trozos: mejora_nocturna comprobaba
|
||||
solo si la frase llega a la accion; probar_acciones, solo si la accion corre.
|
||||
Esto los junta en una conversacion de punta a punta y por AUDIO:
|
||||
|
||||
Examinador -> dice una tarea en voz alta (Piper), con ruido y sala
|
||||
Jarvis -> la oye (Whisper), decide que accion es, la ejecuta,
|
||||
y contesta en voz alta
|
||||
Examinador -> comprueba tres cosas:
|
||||
1. ¿oyo la tarea correcta? (reconocimiento)
|
||||
2. ¿hizo la accion que tocaba? (emparejamiento)
|
||||
3. ¿el resultado tiene sentido? (ejecucion y respuesta)
|
||||
y pone nota
|
||||
|
||||
Lo que se ejecuta de verdad: solo lo de solo lectura. Lo que abre cosas, apaga
|
||||
o necesita root se comprueba hasta el emparejamiento, sin lanzarlo —una
|
||||
autoescuela no deberia apagarte la pantalla ni pedir tu contraseña de noche.
|
||||
|
||||
Lo que aprende: los fallos se separan por tipo. Un fallo de reconocimiento
|
||||
propone un alias (con el mismo filtro seguro de mejora_nocturna: sistematico,
|
||||
sin ambiguedad, conserva palabra clave). Un fallo de ejecucion o de respuesta
|
||||
se ANOTA para arreglarlo a mano —eso no se toca solo, que es codigo—.
|
||||
|
||||
Escenarios: casi todos son de un turno (una tarea, una respuesta), sacados del
|
||||
catalogo. Unos pocos son de varios turnos para probar lo que tiene estado:
|
||||
buscar -> "abre el segundo" -> "busca dentro X". Y hay charla que NO debe
|
||||
disparar ninguna accion, como control negativo.
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
CASA = os.path.expanduser("~")
|
||||
JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS")
|
||||
CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config")
|
||||
APP = "io.github.qwersyk.Newelle//master"
|
||||
|
||||
PIPER = os.path.join(JARVIS, "voz/piper/piper")
|
||||
MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos")
|
||||
WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli")
|
||||
MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models")
|
||||
|
||||
CATALOGO = os.path.expanduser(
|
||||
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
|
||||
"active/files/share/newelle/acciones/acciones.json")
|
||||
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
|
||||
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
|
||||
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
|
||||
SELECCION = os.path.join(JARVIS, "newelle/src/utility/seleccion.py")
|
||||
|
||||
SALA = os.path.join(JARVIS, "muestras/autoescuela")
|
||||
DIALOGO = os.path.join(SALA, "dialogo.log")
|
||||
INFORME = os.path.join(SALA, "informe.json")
|
||||
WAVS = os.path.join(CONFIG, "autoescuela_wavs")
|
||||
|
||||
VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
|
||||
VELOCIDADES = [0.9, 1.0, 1.12]
|
||||
RUIDOS = ["", "aecho=0.8:0.7:22:0.12", "volume=0.72,highpass=f=120",
|
||||
"highpass=f=150,lowpass=f=6500"]
|
||||
|
||||
# Lo que tiene efecto o pide permiso: se comprueba el emparejamiento pero no se
|
||||
# ejecuta. Igual que en probar_acciones.
|
||||
EFECTOS = ["firefox", "xdg-open", "loginctl", "xset", "pactl set", "pkill",
|
||||
"brightnessctl", "systemctl start", "systemctl stop", "systemctl restart",
|
||||
"import -window", "gnome-screenshot", "sudo", "rm ", "kill", "dpms"]
|
||||
|
||||
RELLENOS = ["notas", "el informe", "documentos", "reactor"]
|
||||
|
||||
|
||||
def carga(ruta, nombre):
|
||||
spec = importlib.util.spec_from_file_location(nombre, ruta)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def log(msg, a_dialogo=False):
|
||||
linea = f"[{time.strftime('%H:%M:%S')}] {msg}"
|
||||
print(linea, flush=True)
|
||||
fichero = DIALOGO if a_dialogo else DIALOGO
|
||||
try:
|
||||
with open(fichero, "a") as f:
|
||||
f.write((msg if a_dialogo else linea) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def dice(texto, voz, vel, ruido, destino):
|
||||
"""El examinador dice una tarea: la sintetiza a un wav de 16 kHz."""
|
||||
crudo = tempfile.mktemp(suffix=".wav")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
|
||||
"--length_scale", str(1 / vel), "--output_file", crudo],
|
||||
input=texto, capture_output=True, text=True, timeout=60)
|
||||
if r.returncode != 0 or not os.path.exists(crudo):
|
||||
return False
|
||||
filtros = "aresample=16000" + ("," + ruido if ruido else "")
|
||||
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", crudo,
|
||||
"-ac", "1", "-af", filtros, "-y", destino], check=True, timeout=60)
|
||||
return True
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
finally:
|
||||
if os.path.exists(crudo):
|
||||
os.remove(crudo)
|
||||
|
||||
|
||||
def oye(wavs, hilos=8, por_tanda=40):
|
||||
"""Jarvis oye: transcribe los wav. {fichero: texto}."""
|
||||
salida = {}
|
||||
ficheros = sorted(wavs)
|
||||
for i in range(0, len(ficheros), por_tanda):
|
||||
tanda = ficheros[i:i + por_tanda]
|
||||
lista = " ".join(f'"{WAVS}/{n}"' for n in tanda)
|
||||
orden = (f'for f in {lista}; do echo "### $(basename "$f")"; '
|
||||
f'{WHISPER} -m {MODELOS_STT}/ggml-base.bin -l es -nt -t {hilos} '
|
||||
f'-f "$f" 2>/dev/null | tr "\\n" " "; echo; done')
|
||||
try:
|
||||
r = subprocess.run(["flatpak", "run", "--command=bash", APP, "-c", orden],
|
||||
capture_output=True, text=True, errors="replace", timeout=1200)
|
||||
except subprocess.SubprocessError:
|
||||
continue
|
||||
actual = None
|
||||
for linea in r.stdout.splitlines():
|
||||
if linea.startswith("### "):
|
||||
actual = linea[4:].strip()
|
||||
elif actual:
|
||||
salida[actual] = linea.strip()
|
||||
actual = None
|
||||
return salida
|
||||
|
||||
|
||||
def tiene_efecto(comando):
|
||||
return any(m in comando for m in EFECTOS)
|
||||
|
||||
|
||||
def ejecuta_lectura(comando, argumento=""):
|
||||
"""Ejecuta un comando de solo lectura y devuelve su salida."""
|
||||
c = comando.replace("{argumento}", shlex.quote(argumento))
|
||||
c = c.replace("{argumento_url}", shlex.quote(argumento))
|
||||
try:
|
||||
r = subprocess.run(["bash", "-lc", c], capture_output=True, text=True,
|
||||
errors="replace", timeout=15)
|
||||
return (r.stdout or r.stderr or "").strip(), r.returncode
|
||||
except subprocess.SubprocessError:
|
||||
return "", 1
|
||||
|
||||
|
||||
class Ejecutor:
|
||||
"""La tuberia de Jarvis: oye una frase, decide y hace. Replica el enrutado
|
||||
del panel (seleccion antes que catalogo) para poder probarlo sin GUI."""
|
||||
|
||||
def __init__(self, motor, seleccion):
|
||||
self.motor = motor
|
||||
self.sel = seleccion
|
||||
self.cat = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
|
||||
or [CATALOGO_FUENTE])
|
||||
self.lista = []
|
||||
self.fichero = None
|
||||
|
||||
def recarga(self):
|
||||
self.cat = self.motor.Catalogo(
|
||||
[p for p in (PROPIO, CATALOGO) if os.path.exists(p)] or [CATALOGO_FUENTE])
|
||||
|
||||
def procesa(self, oido):
|
||||
"""Devuelve (tipo, id_o_dato, salida). tipo:
|
||||
'seleccion' | 'dentro' | 'accion' | 'modelo'."""
|
||||
norm = self.motor.normaliza(oido)
|
||||
|
||||
s = self.sel.orden_de_seleccion(norm)
|
||||
if s is not None and self.lista:
|
||||
que, pos = s
|
||||
idx = len(self.lista) - 1 if pos == -1 else pos - 1
|
||||
if 0 <= idx < len(self.lista):
|
||||
self.fichero = self.lista[idx]
|
||||
return "seleccion", f"{que}:{pos}", self.fichero
|
||||
return "seleccion", "fuera_de_rango", ""
|
||||
|
||||
termino = self.sel.orden_buscar_dentro(norm)
|
||||
if termino and self.fichero:
|
||||
r, _ = ejecuta_lectura(f"grep -in {shlex.quote(termino)} "
|
||||
f"{shlex.quote(self.fichero)} | head", "")
|
||||
return "dentro", termino, r
|
||||
|
||||
accion, arg = self.cat.busca(oido)
|
||||
if accion is None:
|
||||
return "modelo", "", ""
|
||||
|
||||
# las de lista alimentan self.lista
|
||||
if accion.id in ("buscar_fichero", "buscar_carpeta", "grep_recursivo"):
|
||||
salida, _ = ejecuta_lectura(accion.comando, arg)
|
||||
rutas = [l.split(":", 1)[0] if accion.id == "grep_recursivo" else l
|
||||
for l in salida.splitlines() if l.strip()]
|
||||
self.lista = rutas
|
||||
if rutas:
|
||||
self.fichero = rutas[0]
|
||||
return "accion", accion.id, salida
|
||||
|
||||
if accion.confirmar or tiene_efecto(accion.comando):
|
||||
return "accion", accion.id, "(no se ejecuta; solo emparejamiento)"
|
||||
|
||||
salida, _ = ejecuta_lectura(accion.comando, arg)
|
||||
return "accion", accion.id, salida
|
||||
|
||||
|
||||
def escenarios(acciones):
|
||||
"""Construye las tareas de examen. Cada una: (texto, comprobacion)."""
|
||||
tareas = []
|
||||
|
||||
# un turno por accion: la tarea es su frase, se espera su id
|
||||
for a in acciones:
|
||||
if not a["frases"]:
|
||||
continue
|
||||
texto = a["frases"][0]
|
||||
if a.get("captura"):
|
||||
texto += " " + random.choice(RELLENOS)
|
||||
tareas.append({
|
||||
"tipo": "accion", "texto": texto, "espera_id": a["id"],
|
||||
"turnos": [texto],
|
||||
})
|
||||
|
||||
# multi-turno: probar el estado (lista -> elegir -> buscar dentro)
|
||||
tareas.append({
|
||||
"tipo": "multi", "texto": "buscar y elegir",
|
||||
"turnos": ["busca el fichero reactor", "abre el primero", "busca dentro def"],
|
||||
"espera": ["accion:grep_recursivo|accion:buscar_fichero", "seleccion", "dentro"],
|
||||
})
|
||||
|
||||
# control negativo: charla que NO debe disparar accion
|
||||
for frase in ["cuentame un chiste sobre gatos", "que opinas de todo esto",
|
||||
"estoy cansado hoy la verdad", "no se muy bien que hacer ahora"]:
|
||||
tareas.append({"tipo": "charla", "texto": frase, "turnos": [frase]})
|
||||
|
||||
return tareas
|
||||
|
||||
|
||||
def una_tanda(ejecutor, motor, ronda, muestras):
|
||||
acciones = ejecutor.cat.acciones
|
||||
base_acciones = json.load(open(CATALOGO if os.path.exists(CATALOGO)
|
||||
else CATALOGO_FUENTE))["acciones"]
|
||||
tareas = escenarios(base_acciones)
|
||||
|
||||
# sintetizar todos los turnos
|
||||
shutil.rmtree(WAVS, ignore_errors=True)
|
||||
os.makedirs(WAVS, exist_ok=True)
|
||||
plan = [] # (fichero, tarea_idx, turno_idx, texto)
|
||||
n = 0
|
||||
for ti, tarea in enumerate(tareas):
|
||||
for tj, texto in enumerate(tarea["turnos"]):
|
||||
n += 1
|
||||
nombre = f"{n:05d}.wav"
|
||||
if dice(texto, random.choice(VOCES), random.choice(VELOCIDADES),
|
||||
random.choice(RUIDOS), os.path.join(WAVS, nombre)):
|
||||
plan.append((nombre, ti, tj, texto))
|
||||
|
||||
log(f"ronda {ronda}: examinador dice {len(plan)} tareas, jarvis las oye...")
|
||||
oidos = oye([p[0] for p in plan])
|
||||
|
||||
# correr los escenarios en orden (el estado importa en los multi-turno)
|
||||
resultados = {"bien": 0, "reconoce_mal": 0, "accion_mal": 0,
|
||||
"ejecuta_mal": 0, "charla_colada": 0}
|
||||
candidatos = defaultdict(lambda: defaultdict(int))
|
||||
por_tarea = defaultdict(list)
|
||||
for nombre, ti, tj, texto in plan:
|
||||
por_tarea[ti].append((tj, nombre, texto))
|
||||
|
||||
for ti in sorted(por_tarea):
|
||||
tarea = tareas[ti]
|
||||
ejecutor.lista = [] # cada escenario empieza limpio
|
||||
ejecutor.fichero = None
|
||||
for tj, nombre, texto in sorted(por_tarea[ti]):
|
||||
oido = oidos.get(nombre, "").strip()
|
||||
if not oido:
|
||||
continue
|
||||
tipo, dato, salida = ejecutor.procesa(oido)
|
||||
|
||||
# dejar la conversacion en el log, legible
|
||||
log(f" examinador: «{texto}»", a_dialogo=True)
|
||||
log(f" jarvis oye: «{oido}» -> {tipo}:{dato}", a_dialogo=True)
|
||||
|
||||
veredicto = verifica(tarea, tj, texto, oido, tipo, dato, salida,
|
||||
motor, candidatos)
|
||||
log(f" veredicto: {veredicto}\n", a_dialogo=True)
|
||||
resultados[veredicto] = resultados.get(veredicto, 0) + 1
|
||||
|
||||
total = sum(resultados.values())
|
||||
bien = resultados["bien"]
|
||||
log(f"ronda {ronda}: {bien}/{total} bien · " +
|
||||
" · ".join(f"{k} {v}" for k, v in resultados.items() if k != "bien" and v))
|
||||
return resultados, candidatos
|
||||
|
||||
|
||||
def verifica(tarea, tj, texto, oido, tipo, dato, salida, motor, candidatos):
|
||||
"""Pone nota a un turno y, si es fallo de reconocimiento, anota el candidato."""
|
||||
if tarea["tipo"] == "charla":
|
||||
return "bien" if tipo == "modelo" else "charla_colada"
|
||||
|
||||
if tarea["tipo"] == "multi":
|
||||
esperado = tarea["espera"][tj]
|
||||
actual = f"{tipo}:{dato}"
|
||||
if any(actual.startswith(e.split("|")[0].split(":")[0] + ":") and
|
||||
(e == actual or e.split(":")[0] == tipo)
|
||||
for e in esperado.split("|")):
|
||||
# para multi basta con que el TIPO sea el correcto
|
||||
if tipo in [e.split(":")[0] for e in esperado.split("|")]:
|
||||
return "bien"
|
||||
return "accion_mal"
|
||||
|
||||
# tarea de un turno: se espera un id concreto
|
||||
espera_id = tarea["espera_id"]
|
||||
if tipo != "accion":
|
||||
# no reconocio la orden: candidato a alias si conserva sentido
|
||||
candidatos[espera_id][motor.normaliza(oido)] += 1
|
||||
return "reconoce_mal"
|
||||
if dato != espera_id:
|
||||
return "accion_mal"
|
||||
# emparejo bien; ¿la ejecucion dio algo con sentido?
|
||||
if salida and ("command not found" in salida or salida == "TIMEOUT"):
|
||||
return "ejecuta_mal"
|
||||
return "bien"
|
||||
|
||||
|
||||
# --- aprender: mismos filtros seguros que mejora_nocturna --------------------
|
||||
VACIAS = {"que", "de", "la", "el", "me", "se", "es", "esta", "estan", "hay",
|
||||
"tengo", "un", "una", "por", "con", "como", "cuanto", "cuanta"}
|
||||
|
||||
|
||||
def contenido(frase):
|
||||
return {p for p in frase.split() if len(p) > 3 and p not in VACIAS}
|
||||
|
||||
|
||||
def alias_seguros(candidatos, acciones, motor, cat):
|
||||
porfrase = defaultdict(set)
|
||||
for ident, frases in candidatos.items():
|
||||
for f in frases:
|
||||
porfrase[f].add(ident)
|
||||
porid = {a["id"]: a for a in acciones}
|
||||
propuestos = defaultdict(list)
|
||||
for ident, frases in candidatos.items():
|
||||
a = porid.get(ident)
|
||||
if a is None or a.get("captura"):
|
||||
continue
|
||||
orig = motor.normaliza(a["frases"][0] if a["frases"] else "")
|
||||
claves = contenido(orig)
|
||||
for f, veces in frases.items():
|
||||
if veces < 2 or len(porfrase[f]) > 1 or len(f.split()) < 2:
|
||||
continue
|
||||
if cat.busca(f)[0] is not None or motor.parecido(f, orig) < 0.6:
|
||||
continue
|
||||
pal = set(f.split())
|
||||
if claves and not any(
|
||||
c in pal or any(motor.parecido(c, w) > 0.8 for w in pal)
|
||||
for c in claves):
|
||||
continue
|
||||
propuestos[ident].append((f, veces))
|
||||
return propuestos
|
||||
|
||||
|
||||
def guarda_alias(propuestos, acciones):
|
||||
try:
|
||||
propio = json.load(open(PROPIO))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
propio = {"version": 1, "acciones": []}
|
||||
porid = {a["id"]: a for a in propio.get("acciones", [])}
|
||||
orig = {a["id"]: a for a in acciones}
|
||||
n = 0
|
||||
for ident, lista in propuestos.items():
|
||||
e = porid.get(ident)
|
||||
if e is None:
|
||||
e = dict(orig[ident]); porid[ident] = e
|
||||
propio.setdefault("acciones", []).append(e)
|
||||
for f, _ in lista:
|
||||
if f not in e["frases"]:
|
||||
e["frases"].append(f); n += 1
|
||||
json.dump(propio, open(PROPIO, "w"), indent=2, ensure_ascii=False)
|
||||
return n
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--horas", type=float, default=0)
|
||||
ap.add_argument("--aplicar", action="store_true", help="guardar los alias que encuentre")
|
||||
ap.add_argument("--muestras", type=int, default=1)
|
||||
args = ap.parse_args()
|
||||
|
||||
for ruta, q in ((PIPER, "piper"), (WHISPER, "whisper")):
|
||||
if not os.path.exists(ruta):
|
||||
print(f"falta {q}"); return 1
|
||||
|
||||
os.makedirs(SALA, exist_ok=True)
|
||||
motor = carga(MOTOR, "acciones")
|
||||
sel = carga(SELECCION, "seleccion")
|
||||
ejecutor = Ejecutor(motor, sel)
|
||||
|
||||
limite = time.time() + args.horas * 3600 if args.horas else None
|
||||
log("=" * 56)
|
||||
log("arranca la autoescuela (examinador + jarvis)")
|
||||
|
||||
historial = []
|
||||
ronda = 0
|
||||
total_alias = 0
|
||||
while True:
|
||||
ronda += 1
|
||||
ini = time.time()
|
||||
try:
|
||||
resultados, candidatos = una_tanda(ejecutor, motor, ronda, args.muestras)
|
||||
except Exception as e:
|
||||
log(f"ronda {ronda}: error, se salta — {type(e).__name__}: {e}")
|
||||
time.sleep(5); continue
|
||||
|
||||
# aprender de los fallos de reconocimiento
|
||||
base = json.load(open(CATALOGO if os.path.exists(CATALOGO)
|
||||
else CATALOGO_FUENTE))["acciones"]
|
||||
propuestos = alias_seguros(candidatos, base, motor, ejecutor.cat)
|
||||
nuevos = sum(len(v) for v in propuestos.values())
|
||||
if nuevos and args.aplicar:
|
||||
total_alias += guarda_alias(propuestos, base)
|
||||
ejecutor.recarga()
|
||||
log(f"ronda {ronda}: +{nuevos} alias aplicados")
|
||||
elif nuevos:
|
||||
log(f"ronda {ronda}: {nuevos} alias propuestos (usa --aplicar para guardarlos)")
|
||||
|
||||
total = sum(resultados.values())
|
||||
historial.append({
|
||||
"ronda": ronda,
|
||||
"acierto": round(100 * resultados["bien"] / max(1, total), 1),
|
||||
"fallos": {k: v for k, v in resultados.items() if k != "bien" and v},
|
||||
"minutos": round((time.time() - ini) / 60, 1),
|
||||
})
|
||||
json.dump({"actualizado": time.strftime("%Y-%m-%d %H:%M"),
|
||||
"rondas": ronda, "alias_aplicados": total_alias,
|
||||
"historial": historial[-30:]},
|
||||
open(INFORME, "w"), indent=2, ensure_ascii=False)
|
||||
|
||||
if not args.horas:
|
||||
break
|
||||
if time.time() > limite:
|
||||
log("se acabo el tiempo. Fin."); break
|
||||
|
||||
shutil.rmtree(WAVS, ignore_errors=True)
|
||||
log(f"terminado tras {ronda} rondas · ultimo acierto "
|
||||
f"{historial[-1]['acierto'] if historial else '?'} %")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
185
config/banco.py
Executable file
185
config/banco.py
Executable file
|
|
@ -0,0 +1,185 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Un banco de pruebas FIJO para medir si el reconocimiento mejora.
|
||||
|
||||
## El problema que resuelve
|
||||
|
||||
La mejora nocturna medía así: decía las 150 frases con voces, velocidades y
|
||||
ruido ALEATORIOS, las transcribía, y comparaba el porcentaje con el de la ronda
|
||||
anterior. Nueve horas y 39 rondas después:
|
||||
|
||||
ruido de medida (desviacion): ±1,9 puntos
|
||||
mejora acumulada en la noche: +1,45 puntos
|
||||
|
||||
El ruido era MAYOR que la mejora. Con eso, la pregunta que el bucle necesita
|
||||
contestar —"¿este alias mejora o empeora?"— se decidía a cara o cruz, y por eso
|
||||
en nueve horas aplicó cuatro alias y no dejó ni un commit.
|
||||
|
||||
## Cómo lo arregla
|
||||
|
||||
Se graba el audio UNA vez, con semillas fijas, y se guardan las
|
||||
TRANSCRIPCIONES. A partir de ahí, medir el catálogo es volver a pasar el
|
||||
emparejador sobre esas mismas transcripciones guardadas:
|
||||
|
||||
- determinista: la misma entrada siempre, así que dos medidas son comparables
|
||||
- instantáneo: sin TTS ni Whisper, milisegundos en vez de cinco minutos
|
||||
- sensible: se nota una frase de diferencia, no hacen falta cuatro puntos
|
||||
|
||||
Lo que se pierde: deja de explorar ruidos nuevos. Por eso el banco se REHACE de
|
||||
vez en cuando (--crear), y entre medias se usa fijo para decidir.
|
||||
|
||||
banco.py --crear [--muestras 2] graba y transcribe (lento, una vez)
|
||||
banco.py --medir puntúa el catálogo actual (instantáneo)
|
||||
banco.py --fallos enseña qué se sigue fallando
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json")
|
||||
MOTOR = os.path.join(RAIZ, "newelle", "src", "utility", "acciones.py")
|
||||
BANCO = os.path.join(RAIZ, "muestras", "banco.json")
|
||||
PIPER = os.path.join(RAIZ, "config", "jarvis-piper.sh")
|
||||
MODELO_WHISPER = os.path.expanduser(
|
||||
"~/.var/app/io.github.qwersyk.Newelle/config/models/whisper/whisper.cpp/models/ggml-small.bin")
|
||||
BIN_WHISPER = os.path.join(RAIZ, "voz", "whisper-cuda", "whisper.cpp", "build", "bin", "whisper-server")
|
||||
|
||||
# Fijas a proposito: son las que hacen el banco reproducible. Cambiarlas obliga
|
||||
# a rehacerlo, y esa es justamente la señal de que ya no es comparable.
|
||||
VOCES = ["es_ES-davefx-medium", "es_MX-claude-high", "es_ES-sharvard-medium"]
|
||||
VELOCIDADES = [0.9, 1.0, 1.15]
|
||||
SEMILLA = 20260815
|
||||
|
||||
|
||||
def motor():
|
||||
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def crear(muestras):
|
||||
m = motor()
|
||||
cat = m.Catalogo([CATALOGO])
|
||||
rnd = random.Random(SEMILLA)
|
||||
|
||||
casos = []
|
||||
for a in cat.acciones:
|
||||
if not a.frases:
|
||||
continue
|
||||
for _ in range(muestras):
|
||||
casos.append({
|
||||
"id": a.id,
|
||||
"frase": rnd.choice(a.frases),
|
||||
"voz": rnd.choice(VOCES),
|
||||
"velocidad": rnd.choice(VELOCIDADES),
|
||||
})
|
||||
print(f" {len(casos)} clips a grabar y transcribir...", flush=True)
|
||||
|
||||
puerto = 8993
|
||||
servidor = subprocess.Popen(
|
||||
[BIN_WHISPER, "-m", MODELO_WHISPER, "-t", "8", "--host", "127.0.0.1",
|
||||
"--port", str(puerto), "-l", "es", "--no-gpu"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
env={**os.environ, "LD_LIBRARY_PATH": "/usr/local/cuda/lib64"})
|
||||
import time
|
||||
time.sleep(10)
|
||||
|
||||
import urllib.request
|
||||
hechos = 0
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for c in casos:
|
||||
wav = os.path.join(tmp, "c.wav")
|
||||
entorno = {**os.environ,
|
||||
"JARVIS_PIPER_VOZ": c["voz"],
|
||||
"JARVIS_PIPER_TONO": "1.0"}
|
||||
r = subprocess.run([PIPER, wav, c["frase"]], env=entorno,
|
||||
capture_output=True, timeout=60)
|
||||
if not os.path.exists(wav) or os.path.getsize(wav) == 0:
|
||||
continue
|
||||
# la velocidad se aplica al vuelo, sin tocar el tono
|
||||
wav16 = os.path.join(tmp, "c16.wav")
|
||||
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", wav,
|
||||
"-filter:a", f"atempo={c['velocidad']}",
|
||||
"-ar", "16000", "-ac", "1", wav16],
|
||||
capture_output=True, timeout=60)
|
||||
try:
|
||||
with open(wav16, "rb") as f:
|
||||
datos = f.read()
|
||||
lim = "----b"
|
||||
cuerpo = (f"--{lim}\r\nContent-Disposition: form-data; "
|
||||
f'name="file"; filename="c.wav"\r\n'
|
||||
f"Content-Type: audio/wav\r\n\r\n").encode() + datos + \
|
||||
f"\r\n--{lim}--\r\n".encode()
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{puerto}/inference", data=cuerpo,
|
||||
headers={"Content-Type": f"multipart/form-data; boundary={lim}"})
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
c["oido"] = json.loads(resp.read().decode()).get("text", "").strip()
|
||||
hechos += 1
|
||||
if hechos % 50 == 0:
|
||||
print(f" {hechos}/{len(casos)}", flush=True)
|
||||
except Exception as e:
|
||||
c["oido"] = ""
|
||||
servidor.terminate()
|
||||
|
||||
casos = [c for c in casos if c.get("oido")]
|
||||
os.makedirs(os.path.dirname(BANCO), exist_ok=True)
|
||||
json.dump({"semilla": SEMILLA, "casos": casos}, open(BANCO, "w"),
|
||||
ensure_ascii=False, indent=1)
|
||||
print(f" banco guardado: {len(casos)} transcripciones en {BANCO}")
|
||||
return 0
|
||||
|
||||
|
||||
def cargar():
|
||||
try:
|
||||
return json.load(open(BANCO))["casos"]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def medir(detalle=False):
|
||||
casos = cargar()
|
||||
if not casos:
|
||||
print("no hay banco; crealo con --crear")
|
||||
return 1
|
||||
m = motor()
|
||||
cat = m.Catalogo([CATALOGO])
|
||||
aciertos, fallos = 0, []
|
||||
for c in casos:
|
||||
a, _ = cat.busca(c["oido"])
|
||||
if a is not None and a.id == c["id"]:
|
||||
aciertos += 1
|
||||
else:
|
||||
fallos.append((c["id"], c["oido"], a.id if a else None))
|
||||
pct = 100.0 * aciertos / len(casos)
|
||||
print(f" acierto {pct:.2f} % ({aciertos}/{len(casos)})")
|
||||
if detalle:
|
||||
cuenta = collections.Counter(f[0] for f in fallos)
|
||||
print(f"\n las acciones que mas se fallan:")
|
||||
for ident, n in cuenta.most_common(12):
|
||||
ejemplo = next(f[1] for f in fallos if f[0] == ident)
|
||||
print(f" {n:3d} {ident:22s} p.ej. se oyo: \"{ejemplo[:44]}\"")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--crear", action="store_true")
|
||||
p.add_argument("--medir", action="store_true")
|
||||
p.add_argument("--fallos", action="store_true")
|
||||
p.add_argument("--muestras", type=int, default=2)
|
||||
a = p.parse_args()
|
||||
if a.crear:
|
||||
return crear(a.muestras)
|
||||
return medir(detalle=a.fallos)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
81
config/comparar_voces.py
Normal file
81
config/comparar_voces.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Genera la misma frase con las cuatro voces britanicas de Kokoro.
|
||||
|
||||
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||
~/COFRE/CODERS/JARVIS/config/comparar_voces.py
|
||||
|
||||
Todas salen ya con el tratamiento aplicado, que es como sonaran de verdad.
|
||||
Elegir de oido y fijar la ganadora con set_voice.py.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/app/share/newelle")
|
||||
sys.path.insert(0, os.path.expanduser(
|
||||
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
|
||||
|
||||
from gi.repository import Gio # noqa: E402
|
||||
|
||||
from newelle.handlers.tts.kokoro_handler import KokoroTTSHandler # noqa: E402
|
||||
from newelle.handlers.tts.tts import jarvis_audio_filter # noqa: E402
|
||||
|
||||
VOCES = ["bm_george", "bm_daniel", "bm_lewis", "bm_fable"]
|
||||
|
||||
FRASE = ("Good evening, sir. All systems are running locally. "
|
||||
"The GPU is idle, and you have 846 gigabytes free. "
|
||||
"Shall I run the diagnostics?")
|
||||
|
||||
SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/muestras/voces")
|
||||
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
|
||||
|
||||
|
||||
def nivel(path):
|
||||
"""mean/max en dB, para confirmar que ninguna sale floja o recortada."""
|
||||
r = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-i", path, "-af", "volumedetect",
|
||||
"-f", "null", "/dev/null"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
vals = [l.split(":")[-1].strip() for l in r.stderr.splitlines()
|
||||
if "mean_volume:" in l or "max_volume:" in l]
|
||||
return " / ".join(vals) if vals else "?"
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(SALIDA, exist_ok=True)
|
||||
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
|
||||
handler = KokoroTTSHandler(settings, os.path.join(CONFIG, "models"))
|
||||
|
||||
if not handler.is_installed():
|
||||
print("instalando kokoro...")
|
||||
handler.install()
|
||||
|
||||
filtro = jarvis_audio_filter()
|
||||
print(f"{'voz':<12} {'fichero':<26} nivel (mean / max)")
|
||||
|
||||
for voz in VOCES:
|
||||
handler.set_voice(voz)
|
||||
crudo = os.path.join(SALIDA, f"_{voz}_seca.wav")
|
||||
final = os.path.join(SALIDA, f"{voz}.wav")
|
||||
try:
|
||||
handler.save_audio(FRASE, crudo)
|
||||
subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-i", crudo, "-af", filtro, "-y", final],
|
||||
check=True,
|
||||
)
|
||||
os.remove(crudo)
|
||||
print(f"{voz:<12} {os.path.basename(final):<26} {nivel(final)}")
|
||||
except Exception as e:
|
||||
print(f"{voz:<12} FALLO: {e}")
|
||||
|
||||
# dejar la voz como estaba, que este script solo compara
|
||||
handler.set_voice(VOCES[0])
|
||||
print(f"\nvoz restaurada a {VOCES[0]}")
|
||||
print(f"escuchalas en: {SALIDA}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
108
config/comparar_voces_es.py
Normal file
108
config/comparar_voces_es.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Genera la misma frase en castellano con las voces masculinas disponibles.
|
||||
|
||||
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||
~/COFRE/CODERS/JARVIS/config/comparar_voces_es.py
|
||||
|
||||
Kokoro solo trae DOS voces masculinas en español: em_alex y em_santa. De los
|
||||
motores que Newelle soporta, los unicos locales son kokoro y espeak; el resto
|
||||
—gtts, edge, elevenlabs, openai, groq— son de nube y quedan descartados.
|
||||
|
||||
Como ninguna de las dos es especialmente grave, cada una se genera tambien en
|
||||
version grave: se baja el tono con ffmpeg sin cambiar la velocidad. El truco es
|
||||
reproducir a menos muestras por segundo (baja tono y alarga) y devolver la
|
||||
duracion con atempo. A 0.90 baja unos dos semitonos, que se nota sin sonar a
|
||||
disco rayado; por debajo de 0.85 empieza a sonar artificial.
|
||||
|
||||
Se escuchan las cuatro y se fija la elegida con set_voz_es.py.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/app/share/newelle")
|
||||
sys.path.insert(0, os.path.expanduser(
|
||||
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
|
||||
|
||||
from gi.repository import Gio # noqa: E402
|
||||
|
||||
from newelle.handlers.tts.kokoro_handler import KokoroTTSHandler # noqa: E402
|
||||
from newelle.handlers.tts.tts import jarvis_audio_filter # noqa: E402
|
||||
|
||||
VOCES = ["em_alex", "em_santa"]
|
||||
GRAVEDAD = 0.90 # 1.0 = sin tocar; menos = mas grave
|
||||
MUESTREO = 24000 # el que saca kokoro
|
||||
|
||||
FRASE = ("Buenas tardes. Todos los sistemas funcionan en local. "
|
||||
"La tarjeta grafica esta libre y quedan ochocientos cuarenta y seis "
|
||||
"gigabytes en el disco. Quiere que ejecute el diagnostico?")
|
||||
|
||||
SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/muestras/voces_es")
|
||||
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
|
||||
|
||||
|
||||
def cadena(grave: bool) -> str:
|
||||
"""El tratamiento JARVIS, con o sin bajada de tono delante."""
|
||||
base = jarvis_audio_filter()
|
||||
if not grave:
|
||||
return base
|
||||
tono = (f"asetrate={int(MUESTREO * GRAVEDAD)},"
|
||||
f"aresample={MUESTREO},atempo={1 / GRAVEDAD:.4f}")
|
||||
return f"{tono},{base}" if base else tono
|
||||
|
||||
|
||||
def nivel(path):
|
||||
"""mean/max en dB, para confirmar que ninguna sale floja o recortada."""
|
||||
r = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-i", path, "-af", "volumedetect",
|
||||
"-f", "null", "/dev/null"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
vals = [l.split(":")[-1].strip() for l in r.stderr.splitlines()
|
||||
if "mean_volume:" in l or "max_volume:" in l]
|
||||
return " / ".join(vals) if vals else "?"
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(SALIDA, exist_ok=True)
|
||||
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
|
||||
handler = KokoroTTSHandler(settings, os.path.join(CONFIG, "models"))
|
||||
|
||||
if not handler.is_installed():
|
||||
print("instalando kokoro...")
|
||||
handler.install()
|
||||
|
||||
previa = handler.get_current_voice() if hasattr(handler, "get_current_voice") else None
|
||||
print(f"{'fichero':<28} nivel (mean / max)")
|
||||
|
||||
for voz in VOCES:
|
||||
handler.set_voice(voz)
|
||||
crudo = os.path.join(SALIDA, f"_{voz}.wav")
|
||||
try:
|
||||
handler.save_audio(FRASE, crudo)
|
||||
except Exception as e:
|
||||
print(f"{voz:<28} FALLO al generar: {e}")
|
||||
continue
|
||||
|
||||
for etiqueta, grave in (("", False), ("_grave", True)):
|
||||
final = os.path.join(SALIDA, f"{voz}{etiqueta}.wav")
|
||||
try:
|
||||
subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-i", crudo, "-af", cadena(grave), "-y", final],
|
||||
check=True,
|
||||
)
|
||||
print(f"{os.path.basename(final):<28} {nivel(final)}")
|
||||
except Exception as e:
|
||||
print(f"{voz}{etiqueta:<20} FALLO: {e}")
|
||||
os.remove(crudo)
|
||||
|
||||
if previa:
|
||||
handler.set_voice(previa)
|
||||
print(f"\nescuchalas en: {SALIDA}")
|
||||
print("la elegida se fija con set_voz_es.py <voz> [--grave]")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
101
config/construir_indice.py
Normal file
101
config/construir_indice.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Construye el indice del RAG sin abrir la interfaz.
|
||||
|
||||
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||
~/COFRE/CODERS/JARVIS/config/construir_indice.py
|
||||
|
||||
En la app esto es el boton "Update Index" de los ajustes del RAG, que lanza un
|
||||
hilo y va enseñando el progreso en una barra. Desde aqui se llama a lo mismo
|
||||
pero en primer plano, para poder dejarlo corriendo en una terminal y ver si
|
||||
falla, que con 20 000 fragmentos conviene.
|
||||
|
||||
Antes hay que haber preparado el corpus con indexar.py: este script indexa lo
|
||||
que haya en la carpeta de documentos, sin filtrar nada.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, "/app/share/newelle")
|
||||
sys.path.insert(0, os.path.expanduser(
|
||||
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
|
||||
|
||||
from gi.repository import Gio # noqa: E402
|
||||
|
||||
from newelle.handlers.rag.llamaindex_handler import LlamaIndexHanlder # noqa: E402
|
||||
from newelle.handlers.embeddings.model2vec import Model2VecHandler # noqa: E402
|
||||
|
||||
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
|
||||
MODELOS = os.path.join(CONFIG, "models")
|
||||
DOCUMENTOS = os.path.join(MODELOS, "documents")
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isdir(DOCUMENTOS):
|
||||
print(f"no existe {DOCUMENTOS}")
|
||||
print("prepara antes el corpus: python3 config/indexar.py --hacerlo")
|
||||
return 1
|
||||
|
||||
ficheros = [f for f in os.listdir(DOCUMENTOS) if not f.startswith(".")]
|
||||
if not ficheros:
|
||||
print("la carpeta de documentos esta vacia")
|
||||
return 1
|
||||
|
||||
tam = sum(os.path.getsize(os.path.join(DOCUMENTOS, f))
|
||||
for f in ficheros) / 1048576
|
||||
print(f"{len(ficheros)} documentos · {tam:.1f} MB")
|
||||
|
||||
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
|
||||
rag = LlamaIndexHanlder(settings, MODELOS)
|
||||
|
||||
if not rag.is_installed():
|
||||
print("faltan dependencias del RAG (llama-index, faiss, tiktoken)")
|
||||
return 1
|
||||
|
||||
# El RAG necesita el modelo de embeddings para vectorizar, y opcionalmente
|
||||
# un LLM secundario para reescribir la consulta. Aqui solo hace falta el
|
||||
# primero: se indexa, no se pregunta.
|
||||
rag.embedding = Model2VecHandler(settings, MODELOS)
|
||||
if not rag.embedding.is_installed():
|
||||
print("instalando model2vec...")
|
||||
rag.embedding.install()
|
||||
# create_index solo le pide load_model() al LLM —para indexar no hace falta
|
||||
# generar nada— asi que un cascaron sirve y evita cargar 3,7 GB de modelo
|
||||
# para vectorizar documentos.
|
||||
class SinModelo:
|
||||
def load_model(self, *a, **k):
|
||||
return True
|
||||
|
||||
rag.llm = SinModelo()
|
||||
rag.secondary_llm = SinModelo()
|
||||
|
||||
trozo = rag.get_setting("chunk_size")
|
||||
print(f"fragmentos de {trozo} tokens · esto tarda, no lo cortes\n")
|
||||
|
||||
ini = time.time()
|
||||
try:
|
||||
rag.create_index()
|
||||
except Exception as e:
|
||||
print(f"\nfallo al indexar: {type(e).__name__}: {e}")
|
||||
return 1
|
||||
|
||||
minutos = (time.time() - ini) / 60
|
||||
_, data_path = rag.get_paths()
|
||||
peso = sum(
|
||||
os.path.getsize(os.path.join(dp, f))
|
||||
for dp, _, fs in os.walk(data_path) for f in fs) / 1048576
|
||||
|
||||
# create_index se traga sus propias excepciones e imprime el error, asi que
|
||||
# terminar sin fallo no significa que haya indice. Se comprueba el disco.
|
||||
if peso < 0.1:
|
||||
print(f"\nno se creo indice ({peso:.1f} MB en {data_path})")
|
||||
print("mira el error de arriba")
|
||||
return 1
|
||||
|
||||
print(f"\nindice construido en {minutos:.1f} min · {peso:.1f} MB")
|
||||
print(f"en {data_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
94
config/demo_en_directo.sh
Executable file
94
config/demo_en_directo.sh
Executable file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env bash
|
||||
# Le da ordenes a JARVIS en directo, para verlo trabajar en la pantalla.
|
||||
#
|
||||
# Cada orden se dice EN VOZ ALTA con la voz activa (para oir como suena de
|
||||
# verdad, no en un banco de pruebas) y luego se ejecuta la accion que le
|
||||
# corresponde, igual que haria el asistente al oirte.
|
||||
#
|
||||
# No se ejecuta nada destructivo ni nada con root: eso pasa por un dialogo que
|
||||
# aprueba una persona, y aqui no hay persona delante del dialogo.
|
||||
#
|
||||
# ./demo_en_directo.sh las que salen por pantalla incluidas
|
||||
# ./demo_en_directo.sh --mudo sin voz, solo resultados
|
||||
set -uo pipefail
|
||||
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
|
||||
MUDO=no; [ "${1:-}" = "--mudo" ] && MUDO=si
|
||||
|
||||
CATALOGO=$RAIZ/newelle/data/acciones/acciones.json
|
||||
TMPWAV=$(mktemp --suffix=.wav)
|
||||
trap 'rm -f "$TMPWAV"' EXIT
|
||||
|
||||
# La orden que se le diria, y la accion del catalogo que le toca.
|
||||
# Se eligen las que se VEN o las que contestan algo concreto.
|
||||
ORDENES=(
|
||||
"cuanto espacio queda|disco_libre"
|
||||
"cuanta memoria|memoria"
|
||||
"que temperatura hay|temperatura"
|
||||
"que procesos consumen mas|procesos_top"
|
||||
"cual es mi ip|red_ip"
|
||||
"cuanto lleva encendido|uptime"
|
||||
"busca el fichero README|buscar_fichero|README.md"
|
||||
"busca dentro JARVIS|grep_recursivo|JARVIS"
|
||||
"hazme una captura|captura_pantalla"
|
||||
"abre una terminal|terminal_abrir"
|
||||
"que peliculas tengo|videos_listar"
|
||||
"pon la pelicula screencast|pelicula_abrir|Screencast"
|
||||
"abre la carpeta de jarvis|abrir_carpeta|$RAIZ"
|
||||
)
|
||||
|
||||
dice() {
|
||||
[ "$MUDO" = si ] && return 0
|
||||
"$RAIZ/config/jarvis-voz.sh" "$TMPWAV" "$1" >/dev/null 2>&1 \
|
||||
|| JARVIS_PIPER_VOZ=es_ES-davefx-medium "$RAIZ/config/jarvis-piper.sh" "$TMPWAV" "$1" >/dev/null 2>&1
|
||||
[ -s "$TMPWAV" ] && { paplay "$TMPWAV" 2>/dev/null || aplay -q "$TMPWAV" 2>/dev/null; }
|
||||
}
|
||||
|
||||
campo() { # campo <id_accion> <clave>
|
||||
python3 -c "
|
||||
import json,sys
|
||||
d=json.load(open('$CATALOGO',encoding='utf-8'))
|
||||
for a in d.get('acciones', d if isinstance(d,list) else []):
|
||||
if a['id']==sys.argv[1]: print(a.get(sys.argv[2],'') or ''); break
|
||||
" "$1" "$2"
|
||||
}
|
||||
|
||||
for entrada in "${ORDENES[@]}"; do
|
||||
IFS='|' read -r frase accion argumento <<< "$entrada"
|
||||
comando=$(campo "$accion" comando)
|
||||
acuse=$(campo "$accion" acuse)
|
||||
respuesta=$(campo "$accion" respuesta)
|
||||
|
||||
if [ -z "$comando" ]; then
|
||||
printf '\n ── "%s" → accion %s NO EXISTE en el catalogo\n' "$frase" "$accion"
|
||||
continue
|
||||
fi
|
||||
|
||||
printf '\n ── tu dices: "%s"\n' "$frase"
|
||||
printf ' jarvis: %s\n' "${acuse:-...}"
|
||||
dice "${acuse:-Un momento}"
|
||||
|
||||
# sustituir el argumento igual que hace el motor
|
||||
real=${comando//\{argumento\}/$(printf '%q' "${argumento:-}")}
|
||||
salida=$(timeout 30 bash -lc "$real" 2>&1 | head -6)
|
||||
|
||||
if [ -n "$salida" ]; then
|
||||
printf ' resultado: %s\n' "$(echo "$salida" | head -1 | cut -c1-100)"
|
||||
echo "$salida" | tail -n +2 | sed 's/^/ /' | cut -c1-100
|
||||
else
|
||||
printf ' resultado: (hecho, sin salida que enseñar)\n'
|
||||
fi
|
||||
|
||||
# decir la respuesta con la plantilla, si la tiene y es corta
|
||||
if [ -n "$respuesta" ] && [ -n "$salida" ]; then
|
||||
ultima=$(echo "$salida" | tail -1)
|
||||
hablado=${respuesta//\{ultima\}/$ultima}
|
||||
hablado=${hablado//\{salida\}/$ultima}
|
||||
case "$hablado" in
|
||||
*"{"*) ;; # plantilla con campos que no sabemos
|
||||
*) [ ${#hablado} -lt 160 ] && { printf ' jarvis: %s\n' "$hablado"; dice "$hablado"; };;
|
||||
esac
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
printf '\n ── fin de la demostracion\n'
|
||||
190
config/descubrir.py
Executable file
190
config/descubrir.py
Executable file
|
|
@ -0,0 +1,190 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Cataloga las herramientas de COFRE para que JARVIS sepa cuales hay.
|
||||
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/descubrir.py
|
||||
|
||||
Produce jarvis-herramientas.json: una ficha por herramienta con su nombre, que
|
||||
hace, como se invoca y en que carpeta vive. NO es el catalogo de acciones
|
||||
rapidas (esas son las ~20 que dices por voz a diario, escritas a mano). Esto es
|
||||
el registro que el modelo consulta cuando le pides algo que no esta en el
|
||||
catalogo, para componer el comando en vez de inventarselo.
|
||||
|
||||
Que hace y que NO hace:
|
||||
- SI: encuentra los puntos de entrada reales (scripts con shebang y binarios
|
||||
en los primeros niveles de cada proyecto, no los modulos internos).
|
||||
- SI: saca una descripcion del README, del docstring o de la cabecera, SIN
|
||||
ejecutar nada. Ejecutar un script desconocido con --help para leerlo seria
|
||||
ejecutar un script desconocido, que es justo lo que no se quiere hacer a
|
||||
ciegas.
|
||||
- NO: ejecuta ninguna herramienta. Esto solo mira.
|
||||
|
||||
El resultado se revisa a mano antes de usarlo: es material de terceros y algun
|
||||
"tool" sera un ejemplo o un exploit, no algo que quieras ofrecer.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
|
||||
COFRE = os.path.expanduser("~/COFRE")
|
||||
SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/config/jarvis-herramientas.json")
|
||||
|
||||
CARPETAS = ["CODERS", "PENTESTERS", "AUTOMATAS", "DOSER", "HARD-WARERS",
|
||||
"PHISHERS", "PROTECTORS", "REVERSERS", "SNEAKERS"]
|
||||
|
||||
# Solo los primeros niveles: un punto de entrada vive cerca de la raiz del
|
||||
# proyecto, no enterrado en lib/ o en site-packages.
|
||||
PROFUNDIDAD = 3
|
||||
|
||||
FUERA = {"node_modules", ".git", "venv", ".venv", "site-packages", "__pycache__",
|
||||
"lib", "libs", "tests", "test", "docs", "examples", "vendor", "dist",
|
||||
"build", ".tox", "exploitdb"}
|
||||
|
||||
# Nombres que casi nunca son la herramienta en si
|
||||
IGNORAR_NOMBRES = {"setup.py", "conftest.py", "__init__.py", "test.py",
|
||||
"tests.py", "wsgi.py", "manage.py", "conf.py"}
|
||||
|
||||
|
||||
def es_ejecutable(ruta: str) -> bool:
|
||||
try:
|
||||
return bool(os.stat(ruta).st_mode & stat.S_IXUSR)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def tiene_shebang(ruta: str) -> bool:
|
||||
try:
|
||||
with open(ruta, "rb") as f:
|
||||
return f.read(2) == b"#!"
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def descripcion_readme(carpeta: str) -> str:
|
||||
"""Primera linea con contenido de un README cercano."""
|
||||
for nombre in ("README.md", "README.rst", "README.txt", "README"):
|
||||
ruta = os.path.join(carpeta, nombre)
|
||||
if not os.path.exists(ruta):
|
||||
continue
|
||||
try:
|
||||
with open(ruta, errors="ignore") as f:
|
||||
for linea in f:
|
||||
limpia = linea.strip().lstrip("#").lstrip(">").strip()
|
||||
# saltar titulos de una palabra, badges y separadores
|
||||
if len(limpia) > 25 and not limpia.startswith(("![", "[!", "---", "===")):
|
||||
return limpia[:200]
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def descripcion_script(ruta: str) -> str:
|
||||
"""Docstring del modulo (Python) o cabecera de comentarios (shell)."""
|
||||
try:
|
||||
with open(ruta, errors="ignore") as f:
|
||||
texto = f.read(4000)
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
if ruta.endswith(".py"):
|
||||
try:
|
||||
doc = ast.get_docstring(ast.parse(texto))
|
||||
if doc:
|
||||
return doc.strip().split("\n")[0][:200]
|
||||
except (SyntaxError, ValueError):
|
||||
pass
|
||||
|
||||
# cabecera de comentarios: las primeras lineas que empiezan por # tras el shebang
|
||||
lineas = []
|
||||
for linea in texto.splitlines()[1:8]:
|
||||
s = linea.strip()
|
||||
if s.startswith("#"):
|
||||
limpio = s.lstrip("#").strip()
|
||||
if len(limpio) > 15:
|
||||
lineas.append(limpio)
|
||||
elif lineas:
|
||||
break
|
||||
return " ".join(lineas)[:200]
|
||||
|
||||
|
||||
def recorre():
|
||||
herramientas = []
|
||||
vistos = set()
|
||||
|
||||
for carpeta in CARPETAS:
|
||||
raiz = os.path.join(COFRE, carpeta)
|
||||
if not os.path.isdir(raiz):
|
||||
continue
|
||||
|
||||
for dirpath, dirnames, ficheros in os.walk(raiz):
|
||||
profundidad = dirpath[len(raiz):].count(os.sep)
|
||||
if profundidad >= PROFUNDIDAD:
|
||||
dirnames[:] = []
|
||||
continue
|
||||
dirnames[:] = [d for d in dirnames
|
||||
if d not in FUERA and not d.startswith(".")]
|
||||
|
||||
for nombre in ficheros:
|
||||
if nombre in IGNORAR_NOMBRES:
|
||||
continue
|
||||
ruta = os.path.join(dirpath, nombre)
|
||||
ext = os.path.splitext(nombre)[1].lower()
|
||||
|
||||
es_script = ext in (".py", ".sh") and (tiene_shebang(ruta) or es_ejecutable(ruta))
|
||||
es_binario = not ext and es_ejecutable(ruta) and not tiene_shebang(ruta)
|
||||
if not (es_script or es_binario):
|
||||
continue
|
||||
|
||||
clave = nombre.lower()
|
||||
if clave in vistos:
|
||||
continue
|
||||
vistos.add(clave)
|
||||
|
||||
desc = descripcion_script(ruta) if es_script else ""
|
||||
if not desc:
|
||||
desc = descripcion_readme(dirpath)
|
||||
|
||||
herramientas.append({
|
||||
"nombre": os.path.splitext(nombre)[0],
|
||||
"carpeta": carpeta,
|
||||
"ruta": ruta,
|
||||
"tipo": "python" if ext == ".py" else "shell" if ext == ".sh" else "binario",
|
||||
"descripcion": desc,
|
||||
"invocar": (f"python3 {ruta}" if ext == ".py"
|
||||
else f"bash {ruta}" if ext == ".sh"
|
||||
else ruta),
|
||||
})
|
||||
|
||||
return herramientas
|
||||
|
||||
|
||||
def main():
|
||||
print("recorriendo COFRE (solo lectura, no se ejecuta nada)...\n", flush=True)
|
||||
tools = recorre()
|
||||
|
||||
porcarpeta = {}
|
||||
con_desc = 0
|
||||
for t in tools:
|
||||
porcarpeta[t["carpeta"]] = porcarpeta.get(t["carpeta"], 0) + 1
|
||||
if t["descripcion"]:
|
||||
con_desc += 1
|
||||
|
||||
for carpeta in CARPETAS:
|
||||
if carpeta in porcarpeta:
|
||||
print(f" {carpeta:<13} {porcarpeta[carpeta]:>4} herramientas")
|
||||
print(f"\n total {len(tools)}, con descripcion {con_desc} "
|
||||
f"({100*con_desc//max(1,len(tools))} %)")
|
||||
|
||||
with open(SALIDA, "w") as f:
|
||||
json.dump({"version": 1, "herramientas": tools}, f,
|
||||
indent=2, ensure_ascii=False)
|
||||
print(f"\nescrito {SALIDA}")
|
||||
print("REVISALO a mano antes de usarlo: es material de terceros y algun")
|
||||
print("'tool' sera un ejemplo o un exploit, no algo que quieras ofrecer.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
331
config/entrenar.py
Executable file
331
config/entrenar.py
Executable file
|
|
@ -0,0 +1,331 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Entrena el reconocimiento solo, sin que nadie hable.
|
||||
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py # tanda normal
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py --rapido # una voz
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py --aplicar # guarda alias
|
||||
|
||||
Como funciona: por cada accion del catalogo se dice su frase en voz alta con
|
||||
Piper, en varias voces y velocidades y con algo de ruido y sala, se transcribe
|
||||
con el mismo whisper que usa el asistente, y se mira si la transcripcion llega
|
||||
a la accion correcta. Lo que no llega se apunta como alias candidato.
|
||||
|
||||
Por que sirve: los fallos del reconocedor en castellano son sistematicos, no
|
||||
aleatorios. "abre el correo" se oye "a ver el correo" siempre, y una tanda de
|
||||
varios cientos de frases los saca casi todos de una sentada.
|
||||
|
||||
Que NO sustituye: esto es voz sintetica, no la suya. Sirve para las colisiones
|
||||
foneticas del idioma —que son la mayoria de los fallos— pero no para su acento,
|
||||
su microfono ni su habitacion. Para eso esta el registro de lo que no entiende
|
||||
en el uso real, que se revisa con aprender.py. Las dos cosas se suman.
|
||||
|
||||
Un alias solo se propone si no choca con otra accion: si "a ver el correo"
|
||||
pudiera ser dos cosas distintas, se descarta y se avisa.
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
CASA = os.path.expanduser("~")
|
||||
JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS")
|
||||
CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config")
|
||||
APP = "io.github.qwersyk.Newelle//master"
|
||||
|
||||
PIPER = os.path.join(JARVIS, "voz/piper/piper")
|
||||
MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos")
|
||||
WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli")
|
||||
MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models")
|
||||
|
||||
CATALOGO = os.path.expanduser(
|
||||
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
|
||||
"active/files/share/newelle/acciones/acciones.json")
|
||||
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
|
||||
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
|
||||
INFORME = os.path.join(JARVIS, "muestras/entrenamiento.json")
|
||||
|
||||
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
|
||||
|
||||
# Voces distintas para que no aprenda los defectos de una sola, y velocidades
|
||||
# distintas porque uno no habla igual con prisa que sin ella.
|
||||
VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
|
||||
VELOCIDADES = [1.0, 0.88, 1.15]
|
||||
|
||||
# Un poco de sala y de ruido de fondo: en seco el reconocedor acierta mas de lo
|
||||
# que acertara nunca en una habitacion de verdad, y entrenar con eso da una
|
||||
# falsa sensacion de que todo va bien.
|
||||
RUIDOS = [
|
||||
"", # limpio
|
||||
"aecho=0.8:0.7:22:0.12", # algo de sala
|
||||
"volume=0.75,highpass=f=120", # mas lejos del microfono
|
||||
]
|
||||
|
||||
|
||||
def carga_motor():
|
||||
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def dentro(orden, limite=900):
|
||||
"""Ejecuta algo dentro del flatpak, que es donde vive whisper."""
|
||||
return subprocess.run(
|
||||
["flatpak", "run", "--command=bash", APP, "-c", orden],
|
||||
capture_output=True, text=True, timeout=limite)
|
||||
|
||||
|
||||
def genera(texto, voz, velocidad, ruido, destino):
|
||||
crudo = tempfile.mktemp(suffix=".wav")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
|
||||
"--length_scale", str(1 / velocidad), "--output_file", crudo],
|
||||
input=texto, capture_output=True, text=True, timeout=60)
|
||||
if r.returncode != 0 or not os.path.exists(crudo):
|
||||
return False
|
||||
filtros = "aresample=16000" + ("," + ruido if ruido else "")
|
||||
subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", crudo,
|
||||
"-ac", "1", "-af", filtros, "-y", destino],
|
||||
check=True, timeout=60)
|
||||
return os.path.exists(destino)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
finally:
|
||||
if os.path.exists(crudo):
|
||||
os.remove(crudo)
|
||||
|
||||
|
||||
# Por tandas y no de una: mil frases son un cuarto de hora, y una sola llamada
|
||||
# con limite de tiempo se corta a la mitad y se pierde todo lo transcrito. Por
|
||||
# tandas ademas se puede ver el avance, que en algo que tarda tanto importa.
|
||||
POR_TANDA = 40
|
||||
|
||||
|
||||
def transcribe_lote(carpeta, ficheros, modelo="base", hilos=8):
|
||||
"""Transcribe una lista de wav. Devuelve {fichero: texto}."""
|
||||
salida = {}
|
||||
ini = time.time()
|
||||
for i in range(0, len(ficheros), POR_TANDA):
|
||||
tanda = ficheros[i:i + POR_TANDA]
|
||||
lista = " ".join(f'"{carpeta}/{n}"' for n in tanda)
|
||||
orden = (
|
||||
f'for f in {lista}; do '
|
||||
f' echo "### $(basename "$f")"; '
|
||||
f' {WHISPER} -m {MODELOS_STT}/ggml-{modelo}.bin -l es -nt -t {hilos} '
|
||||
f' -f "$f" 2>/dev/null | tr "\\n" " "; echo; '
|
||||
f'done')
|
||||
try:
|
||||
r = dentro(orden)
|
||||
except subprocess.SubprocessError as e:
|
||||
print(f" tanda {i//POR_TANDA + 1} fallo: {e}", flush=True)
|
||||
continue
|
||||
|
||||
actual = None
|
||||
for linea in r.stdout.splitlines():
|
||||
if linea.startswith("### "):
|
||||
actual = linea[4:].strip()
|
||||
elif actual:
|
||||
salida[actual] = linea.strip()
|
||||
actual = None
|
||||
|
||||
hechas = min(i + POR_TANDA, len(ficheros))
|
||||
queda = (time.time() - ini) / hechas * (len(ficheros) - hechas)
|
||||
print(f" transcritas {hechas}/{len(ficheros)} "
|
||||
f"({queda/60:.0f} min restantes)", flush=True)
|
||||
return salida
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--rapido", action="store_true", help="una voz y una velocidad")
|
||||
ap.add_argument("--aplicar", action="store_true", help="guardar los alias encontrados")
|
||||
ap.add_argument("--tope", type=int, default=0, help="limitar acciones (para probar)")
|
||||
ap.add_argument("--reanudar", action="store_true",
|
||||
help="usar los wav ya generados en vez de rehacerlos")
|
||||
args = ap.parse_args()
|
||||
|
||||
for ruta, que in ((PIPER, "piper"), (WHISPER, "whisper")):
|
||||
if not os.path.exists(ruta):
|
||||
print(f"falta {que}: {ruta}")
|
||||
return 1
|
||||
|
||||
catalogo = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE
|
||||
with open(catalogo) as f:
|
||||
acciones = json.load(f)["acciones"]
|
||||
if args.tope:
|
||||
acciones = acciones[:args.tope]
|
||||
|
||||
voces = VOCES[:1] if args.rapido else VOCES
|
||||
velocidades = VELOCIDADES[:1] if args.rapido else VELOCIDADES
|
||||
ruidos = RUIDOS[:1] if args.rapido else RUIDOS
|
||||
|
||||
motor = carga_motor()
|
||||
cat = motor.Catalogo([catalogo])
|
||||
|
||||
# Las acciones que esperan argumento hay que decirlas CON argumento. Sin el,
|
||||
# el motor las rechaza a proposito —"busca el fichero" a secas no es una
|
||||
# orden— y contarlo como fallo del reconocedor es medir mal: en la primera
|
||||
# tanda hundio el resultado del 48 %, con una cuarta parte de las frases
|
||||
# condenadas de antemano.
|
||||
RELLENOS = ["notas", "el informe de ayer", "documentos"]
|
||||
|
||||
# Solo la frase principal de cada accion: las variantes ya estan escritas a
|
||||
# mano y lo que se busca aqui es como se oye la que uno diria de verdad.
|
||||
trabajos = []
|
||||
for a in acciones:
|
||||
if not a["frases"]:
|
||||
continue
|
||||
frase = a["frases"][0]
|
||||
if a.get("captura"):
|
||||
frase = frase + " " + random.choice(RELLENOS)
|
||||
for voz in voces:
|
||||
for vel in velocidades:
|
||||
trabajos.append((a["id"], frase, voz, vel,
|
||||
random.choice(ruidos)))
|
||||
|
||||
total = len(trabajos)
|
||||
print(f"{len(acciones)} acciones · {len(voces)} voces · "
|
||||
f"{len(velocidades)} velocidades = {total} frases habladas")
|
||||
print("esto tarda; se puede dejar corriendo\n")
|
||||
|
||||
carpeta = os.path.join(CONFIG, "entrenamiento")
|
||||
|
||||
indice = {f"{n:05d}.wav": (ident, frase, voz, vel)
|
||||
for n, (ident, frase, voz, vel, _) in enumerate(trabajos, 1)}
|
||||
|
||||
if args.reanudar and os.path.isdir(carpeta):
|
||||
indice = {n: v for n, v in indice.items()
|
||||
if os.path.exists(os.path.join(carpeta, n))}
|
||||
print(f"reanudando con {len(indice)} frases ya generadas\n", flush=True)
|
||||
else:
|
||||
shutil.rmtree(carpeta, ignore_errors=True)
|
||||
os.makedirs(carpeta, exist_ok=True)
|
||||
vivos, ini = {}, time.time()
|
||||
for n, (ident, frase, voz, vel, ruido) in enumerate(trabajos, 1):
|
||||
nombre = f"{n:05d}.wav"
|
||||
if genera(frase, voz, vel, ruido, os.path.join(carpeta, nombre)):
|
||||
vivos[nombre] = (ident, frase, voz, vel)
|
||||
if n % 25 == 0 or n == total:
|
||||
queda = (time.time() - ini) / n * (total - n)
|
||||
print(f" generadas {n}/{total} ({queda/60:.0f} min restantes)",
|
||||
flush=True)
|
||||
indice = vivos
|
||||
|
||||
print(f"\ntranscribiendo {len(indice)} frases...", flush=True)
|
||||
textos = transcribe_lote(carpeta, sorted(indice))
|
||||
print(f"transcritas {len(textos)}\n", flush=True)
|
||||
|
||||
aciertos = 0
|
||||
candidatos = defaultdict(lambda: defaultdict(int))
|
||||
for nombre, (ident, frase, voz, vel) in indice.items():
|
||||
oido = textos.get(nombre, "").strip()
|
||||
if not oido:
|
||||
continue
|
||||
encontrada, _ = cat.busca(oido)
|
||||
if encontrada is not None and encontrada.id == ident:
|
||||
aciertos += 1
|
||||
else:
|
||||
candidatos[ident][motor.normaliza(oido)] += 1
|
||||
|
||||
evaluadas = sum(1 for n in indice if textos.get(n))
|
||||
print(f"aciertos: {aciertos}/{evaluadas} "
|
||||
f"({100*aciertos/max(1,evaluadas):.0f} %)\n")
|
||||
|
||||
# Un alias solo vale si no puede ser otra cosa. Si la misma frase mal oida
|
||||
# aparece en dos acciones, elegir una seria peor que no hacer nada.
|
||||
duenos = defaultdict(set)
|
||||
for ident, frases in candidatos.items():
|
||||
for f in frases:
|
||||
duenos[f].add(ident)
|
||||
|
||||
propuestos, descartados = defaultdict(list), []
|
||||
for ident, frases in candidatos.items():
|
||||
for f, veces in sorted(frases.items(), key=lambda x: -x[1]):
|
||||
if len(duenos[f]) > 1:
|
||||
descartados.append((f, sorted(duenos[f])))
|
||||
continue
|
||||
if len(f.split()) < 2: # una palabra suelta engancha de todo
|
||||
continue
|
||||
# Si lo oido no se parece a lo dicho, es que el reconocedor se
|
||||
# perdio del todo, no que haya una confusion fonetica estable.
|
||||
# Guardarlo como alias meteria una frase arbitraria en el catalogo
|
||||
# y algun dia enganchara una conversacion.
|
||||
original = next((a["frases"][0] for a in acciones if a["id"] == ident), "")
|
||||
if motor.parecido(f, motor.normaliza(original)) < 0.55:
|
||||
descartados.append((f, [ident + " (irreconocible)"]))
|
||||
continue
|
||||
if cat.busca(f)[0] is not None: # ya lo coge otra cosa
|
||||
continue
|
||||
propuestos[ident].append((f, veces))
|
||||
|
||||
if propuestos:
|
||||
print("alias que faltan (frase mal oida -> accion):")
|
||||
for ident, lista in sorted(propuestos.items()):
|
||||
for f, veces in lista[:3]:
|
||||
print(f' "{f}" -> {ident} ({veces}x)')
|
||||
else:
|
||||
print("no falta ningun alias: todo lo dicho llega a su accion")
|
||||
|
||||
if descartados:
|
||||
print(f"\n{len(descartados)} descartados por ambiguos "
|
||||
f"(la misma frase encaja en dos acciones):")
|
||||
for f, ids in descartados[:5]:
|
||||
print(f' "{f}" -> {", ".join(ids)}')
|
||||
|
||||
os.makedirs(os.path.dirname(INFORME), exist_ok=True)
|
||||
with open(INFORME, "w") as f:
|
||||
json.dump({
|
||||
"frases": evaluadas, "aciertos": aciertos,
|
||||
"propuestos": {k: v for k, v in propuestos.items()},
|
||||
"ambiguos": descartados,
|
||||
}, f, indent=2, ensure_ascii=False)
|
||||
print(f"\ninforme en {INFORME}")
|
||||
|
||||
if args.aplicar and propuestos:
|
||||
guarda(propuestos, catalogo)
|
||||
elif propuestos:
|
||||
print("para guardarlos: vuelve a lanzarlo con --aplicar")
|
||||
|
||||
print(f"los wav se quedan en {carpeta} por si hay que repasar algo")
|
||||
return 0
|
||||
|
||||
|
||||
def guarda(propuestos, catalogo):
|
||||
with open(catalogo) as f:
|
||||
original = {a["id"]: a for a in json.load(f)["acciones"]}
|
||||
try:
|
||||
with open(PROPIO) as f:
|
||||
propio = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
propio = {"version": 1, "acciones": []}
|
||||
|
||||
porid = {a["id"]: a for a in propio.get("acciones", [])}
|
||||
n = 0
|
||||
for ident, lista in propuestos.items():
|
||||
entrada = porid.get(ident)
|
||||
if entrada is None:
|
||||
entrada = dict(original[ident])
|
||||
porid[ident] = entrada
|
||||
propio.setdefault("acciones", []).append(entrada)
|
||||
for frase, _ in lista:
|
||||
if frase not in entrada["frases"]:
|
||||
entrada["frases"].append(frase)
|
||||
n += 1
|
||||
|
||||
with open(PROPIO, "w") as f:
|
||||
json.dump(propio, f, indent=2, ensure_ascii=False)
|
||||
print(f"{n} alias guardados en {PROPIO}")
|
||||
print("se aplican al abrir la pestaña JARVIS otra vez")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
156
config/indexar.py
Executable file
156
config/indexar.py
Executable file
|
|
@ -0,0 +1,156 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Prepara el corpus de COFRE para que JARVIS lo consulte.
|
||||
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/indexar.py # ver que entraria
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/indexar.py --hacerlo # copiarlo
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/indexar.py --hacerlo --solo-notas
|
||||
|
||||
Que hace: recorre las carpetas de COFRE, se queda SOLO con lo que es
|
||||
conocimiento y lo copia a la carpeta de documentos de Newelle, que es de donde
|
||||
lee el RAG. Despues hay que pulsar "Update Index" en los ajustes de la app.
|
||||
|
||||
Por que hace falta filtrar. Medido en esta maquina:
|
||||
|
||||
COFRE entero (las nueve carpetas) ~120 GB
|
||||
de eso, ISOs, maquinas virtuales,
|
||||
APKs, audio y un diccionario de
|
||||
contraseñas de 9,1 GB ~100 GB
|
||||
texto de menos de 100 KB 76,6 MB en 35 098 ficheros
|
||||
quitando exploitdb 20,8 MB en 4 548 ficheros
|
||||
|
||||
exploitdb se queda fuera a proposito: son 32 000 ficheros que aplastarian el
|
||||
indice y enterrarian tus notas, y ademas NO hacen falta ahi. Tienes
|
||||
`searchsploit` instalado, que busca en ellos de forma exacta e instantanea. Un
|
||||
indice de vectores es peor que una busqueda exacta cuando lo que buscas es el
|
||||
nombre literal de un exploit.
|
||||
|
||||
Los nombres se aplanan conservando la ruta —PENTESTERS__OSCP__nmap.md— para que
|
||||
al responder se pueda ver de donde salio cada cosa.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
COFRE = os.path.expanduser("~/COFRE")
|
||||
DESTINO = os.path.expanduser(
|
||||
"~/.var/app/io.github.qwersyk.Newelle/config/models/documents")
|
||||
|
||||
CARPETAS = ["CODERS", "PENTESTERS", "AUTOMATAS", "DOSER", "HARD-WARERS",
|
||||
"PHISHERS", "PROTECTORS", "REVERSERS", "SNEAKERS"]
|
||||
|
||||
EXTENSIONES = {".md", ".txt", ".rst", ".adoc"}
|
||||
|
||||
# Por tamaño: por encima de esto ya no es prosa, son datos. El diccionario
|
||||
# kaonashi.txt son 9,1 GB de contraseñas.
|
||||
TAMANO_MAXIMO = 100 * 1024
|
||||
TAMANO_MINIMO = 120 # menos de esto no dice nada
|
||||
|
||||
# Carpetas que no aportan conocimiento y si mucho ruido
|
||||
FUERA = {
|
||||
"exploitdb", # 32 000 ficheros; para eso esta searchsploit
|
||||
"node_modules", ".git", "venv", ".venv", "site-packages", "__pycache__",
|
||||
"isos", "VirtualBox VMs", "build", "dist", ".cache", "target",
|
||||
"flatpak-app", ".flatpak-builder", "rag_cache", "entrenamiento",
|
||||
}
|
||||
|
||||
# Ficheros que son plantilla o licencia y solo ensucian
|
||||
NOMBRES_FUERA = {"LICENSE.txt", "LICENSE.md", "CHANGELOG.md", "requirements.txt"}
|
||||
|
||||
|
||||
def interesa(ruta: str, nombre: str) -> bool:
|
||||
ext = os.path.splitext(nombre)[1].lower()
|
||||
if ext not in EXTENSIONES or nombre in NOMBRES_FUERA:
|
||||
return False
|
||||
try:
|
||||
tam = os.path.getsize(ruta)
|
||||
except OSError:
|
||||
return False
|
||||
return TAMANO_MINIMO <= tam <= TAMANO_MAXIMO
|
||||
|
||||
|
||||
def recorre(solo_notas: bool):
|
||||
"""Devuelve [(origen, nombre_plano)] de todo lo que merece indexarse."""
|
||||
salida = []
|
||||
for carpeta in CARPETAS:
|
||||
raiz = os.path.join(COFRE, carpeta)
|
||||
if not os.path.isdir(raiz):
|
||||
continue
|
||||
for dirpath, dirnames, ficheros in os.walk(raiz):
|
||||
dirnames[:] = [d for d in dirnames
|
||||
if d not in FUERA and not d.startswith(".")]
|
||||
for nombre in ficheros:
|
||||
if solo_notas and not nombre.lower().endswith(".md"):
|
||||
continue
|
||||
origen = os.path.join(dirpath, nombre)
|
||||
if not interesa(origen, nombre):
|
||||
continue
|
||||
relativa = os.path.relpath(origen, COFRE)
|
||||
plano = relativa.replace(os.sep, "__")
|
||||
if len(plano) > 180: # algunos nombres son kilometricos
|
||||
corto = hashlib.sha256(plano.encode()).hexdigest()[:8]
|
||||
plano = plano[:150] + "_" + corto + os.path.splitext(plano)[1]
|
||||
salida.append((origen, plano))
|
||||
return salida
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--hacerlo", action="store_true", help="copiar de verdad")
|
||||
ap.add_argument("--solo-notas", action="store_true",
|
||||
help="solo .md, sin los .txt sueltos")
|
||||
ap.add_argument("--limpiar", action="store_true",
|
||||
help="vaciar el destino antes de copiar")
|
||||
args = ap.parse_args()
|
||||
|
||||
print("recorriendo COFRE...", flush=True)
|
||||
lista = recorre(args.solo_notas)
|
||||
|
||||
porcarpeta, total = {}, 0
|
||||
for origen, _ in lista:
|
||||
carpeta = os.path.relpath(origen, COFRE).split(os.sep)[0]
|
||||
tam = os.path.getsize(origen)
|
||||
n, b = porcarpeta.get(carpeta, (0, 0))
|
||||
porcarpeta[carpeta] = (n + 1, b + tam)
|
||||
total += tam
|
||||
|
||||
print(f"\n{'carpeta':<14}{'ficheros':>10}{'texto':>12}")
|
||||
for carpeta in CARPETAS:
|
||||
if carpeta in porcarpeta:
|
||||
n, b = porcarpeta[carpeta]
|
||||
print(f"{carpeta:<14}{n:>10}{b/1048576:>10.1f} MB")
|
||||
print(f"{'TOTAL':<14}{len(lista):>10}{total/1048576:>10.1f} MB")
|
||||
print(f"\nfragmentos de 256 tokens: ~{total // 1024}")
|
||||
|
||||
if not args.hacerlo:
|
||||
print("\nesto es solo el recuento; para copiarlo: --hacerlo")
|
||||
return 0
|
||||
|
||||
if args.limpiar and os.path.isdir(DESTINO):
|
||||
shutil.rmtree(DESTINO)
|
||||
os.makedirs(DESTINO, exist_ok=True)
|
||||
|
||||
copiados = saltados = 0
|
||||
for origen, plano in lista:
|
||||
destino = os.path.join(DESTINO, plano)
|
||||
try:
|
||||
# solo si cambio: reindexar 4500 ficheros iguales no tiene sentido
|
||||
if (os.path.exists(destino)
|
||||
and os.path.getmtime(destino) >= os.path.getmtime(origen)):
|
||||
saltados += 1
|
||||
continue
|
||||
shutil.copy2(origen, destino)
|
||||
copiados += 1
|
||||
except OSError as e:
|
||||
print(f" no se pudo copiar {plano}: {e}")
|
||||
|
||||
print(f"\n{copiados} copiados, {saltados} ya estaban al dia")
|
||||
print(f"en {DESTINO}")
|
||||
print("\nAhora, en Newelle: Ajustes -> RAG -> Update Index")
|
||||
print("La primera vez tarda; son unos 21 000 fragmentos.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
4197
config/jarvis-herramientas.json
Normal file
4197
config/jarvis-herramientas.json
Normal file
File diff suppressed because it is too large
Load diff
58
config/jarvis-piper.sh
Executable file
58
config/jarvis-piper.sh
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env bash
|
||||
# Voz de JARVIS con Piper: castellano de España, no latinoamericano.
|
||||
#
|
||||
# jarvis-piper.sh <fichero.wav> <texto>
|
||||
#
|
||||
# Kokoro, que es lo que trae Newelle, no tiene ni una voz de España: sus dos
|
||||
# masculinas en español (em_alex, em_santa) suenan latinas. Piper si las tiene,
|
||||
# es libre y corre local. Se invoca desde el handler custom_command, que ya
|
||||
# ejecuta en el host.
|
||||
#
|
||||
# El descenso de tono se hace aqui y no en la cadena de la app porque alli se
|
||||
# usa asetrate, que necesita saber la frecuencia de muestreo: esta calculada
|
||||
# para los 24 kHz de Kokoro y Piper saca 22050, asi que aplicaria una bajada
|
||||
# distinta de la pedida. Aqui se usa rubberband, que trabaja en semitonos y le
|
||||
# da igual la frecuencia. Existe en el ffmpeg del sistema pero no en el del
|
||||
# flatpak, y este script ya corre fuera.
|
||||
#
|
||||
# Ajustes por entorno:
|
||||
# JARVIS_PIPER_VOZ es_ES-sharvard-medium (defecto) | es_ES-davefx-medium
|
||||
# | es_ES-carlfm-x_low
|
||||
# JARVIS_PIPER_TONO 0.90 (defecto). 1.0 no toca el tono; por debajo de
|
||||
# 0.85 empieza a sonar artificial.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# La raiz se deduce de donde esta este fichero, no del $HOME de nadie: asi el
|
||||
# arbol se puede mover o clonar en otra maquina y sigue funcionando sin editar
|
||||
# rutas. `cd -P` resuelve enlaces simbolicos, que el lanzador de escritorio
|
||||
# puede invocarlo por uno.
|
||||
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
|
||||
BASE=$RAIZ/voz
|
||||
VOZ=${JARVIS_PIPER_VOZ:-es_ES-sharvard-medium}
|
||||
TONO=${JARVIS_PIPER_TONO:-0.90}
|
||||
|
||||
SALIDA=${1:-}
|
||||
TEXTO=${2:-}
|
||||
|
||||
[ -n "$SALIDA" ] || { echo "falta el fichero de salida" >&2; exit 1; }
|
||||
[ -n "$TEXTO" ] || { echo "sin texto que decir" >&2; exit 1; }
|
||||
|
||||
MODELO=$BASE/modelos/$VOZ.onnx
|
||||
[ -f "$MODELO" ] || { echo "no esta el modelo $MODELO" >&2; exit 1; }
|
||||
|
||||
CRUDO=$(mktemp --suffix=.wav)
|
||||
trap 'rm -f "$CRUDO"' EXIT
|
||||
|
||||
printf '%s' "$TEXTO" | "$BASE/piper/piper" \
|
||||
--model "$MODELO" --output_file "$CRUDO" 2>/dev/null
|
||||
|
||||
[ -s "$CRUDO" ] || { echo "piper no genero audio" >&2; exit 1; }
|
||||
|
||||
if [ "$TONO" = "1.0" ] || ! command -v ffmpeg >/dev/null; then
|
||||
cp "$CRUDO" "$SALIDA"
|
||||
else
|
||||
ffmpeg -hide_banner -loglevel error -i "$CRUDO" \
|
||||
-af "rubberband=pitch=$TONO" -y "$SALIDA" 2>/dev/null \
|
||||
|| cp "$CRUDO" "$SALIDA"
|
||||
fi
|
||||
46
config/jarvis-voz.sh
Executable file
46
config/jarvis-voz.sh
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env bash
|
||||
# Voz de JARVIS con la clonada (XTTS-v2) servida desde cache.
|
||||
#
|
||||
# jarvis-voz.sh <fichero.wav> <texto>
|
||||
#
|
||||
# Si el texto esta pre-generado en cache_voz/, lo sirve al instante (un cp, sin
|
||||
# tocar GPU). Si no, cae a Piper. La clave es sha1(texto normalizado), la misma
|
||||
# que calcula generar_cache_voz.py -> los aciertos son exactos.
|
||||
#
|
||||
# El clon ya trae el tono correcto, asi que en un acierto NO se aplica el
|
||||
# descenso de tono de Piper; solo se copia. En un fallo, Piper hace lo suyo
|
||||
# (incluido su rubberband) como hasta ahora.
|
||||
set -uo pipefail
|
||||
|
||||
# La raiz se deduce de donde esta este fichero, no del $HOME de nadie: asi el
|
||||
# arbol se puede mover o clonar en otra maquina y sigue funcionando sin editar
|
||||
# rutas. `cd -P` resuelve enlaces simbolicos, que el lanzador de escritorio
|
||||
# puede invocarlo por uno.
|
||||
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
|
||||
BASE=$RAIZ/voz
|
||||
CACHE=$BASE/cache_voz
|
||||
PIPER=$RAIZ/config/jarvis-piper.sh
|
||||
|
||||
SALIDA=${1:-}
|
||||
TEXTO=${2:-}
|
||||
|
||||
[ -n "$SALIDA" ] || { echo "falta el fichero de salida" >&2; exit 1; }
|
||||
[ -n "$TEXTO" ] || { echo "sin texto que decir" >&2; exit 1; }
|
||||
|
||||
# clave = sha1(texto con espacios colapsados y recortado), identica a la de
|
||||
# Python: re.sub(r'\s+',' ',t).strip() -> sha1(utf-8)
|
||||
T=$(printf '%s' "$TEXTO" | tr -s '[:space:]' ' ' | sed 's/^ //; s/ $//')
|
||||
KEY=$(printf '%s' "$T" | sha1sum | cut -d' ' -f1)
|
||||
HIT=$CACHE/$KEY.wav
|
||||
|
||||
if [ -f "$HIT" ]; then
|
||||
cp -f "$HIT" "$SALIDA"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# fallo de cache -> registrar la frase (normalizada) para calentar la cache
|
||||
# luego con la voz clonada, y de momento reescribir siglas + Piper.
|
||||
printf '%s\n' "$T" >> "$BASE/misses.log" 2>/dev/null || true
|
||||
TEXTO2=$(python3 "$BASE/pronuncia.py" "$TEXTO" 2>/dev/null)
|
||||
[ -n "$TEXTO2" ] || TEXTO2=$TEXTO
|
||||
exec "$PIPER" "$SALIDA" "$TEXTO2"
|
||||
484
config/mejora_nocturna.py
Executable file
484
config/mejora_nocturna.py
Executable file
|
|
@ -0,0 +1,484 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Bucle de mejora que corre solo, ronda tras ronda, y se protege de empeorar.
|
||||
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py # hasta la mañana
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py --horas 2 # un rato
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py --ronda # una sola
|
||||
|
||||
Que hace en cada ronda:
|
||||
1. Dice en voz alta (Piper) las frases del catalogo, en varias voces,
|
||||
velocidades y con ruido y sala aleatorios, distintos cada ronda.
|
||||
2. Las transcribe con el mismo Whisper del asistente.
|
||||
3. Mira que porcentaje llega a su accion. Ese es el marcador de la ronda.
|
||||
4. De lo que NO llega, se queda con los fallos SISTEMATICOS (repetidos) y
|
||||
SIN AMBIGUEDAD (que no puedan ser dos acciones), y los propone como alias.
|
||||
5. GUARDA: antes de aplicar nada, comprueba en una copia que
|
||||
a) el acierto sube, y
|
||||
b) la conversacion normal SIGUE yendo al modelo (no se cuela ninguna).
|
||||
Solo si las dos cosas se cumplen, aplica los alias de verdad.
|
||||
6. Copia de seguridad del catalogo antes de tocarlo, y todo al registro.
|
||||
|
||||
Por que es seguro dejarlo sin mirar: nunca aplica algo que baje el acierto ni
|
||||
que haga que una frase de charla acabe ejecutando un comando. Si una ronda
|
||||
empeorara, revierte. Y cada cambio queda respaldado, asi que siempre se puede
|
||||
volver atras a mano.
|
||||
|
||||
Que NO hace: reentrenar el modelo (no cabe en 4 GB de VRAM) ni tocar Whisper o
|
||||
Piper. Mejora la capa de emparejamiento —alias y umbrales—, que es donde estan
|
||||
casi todos los fallos y donde una mejora es inmediata, legible y reversible.
|
||||
|
||||
En marcha se puede seguir con:
|
||||
tail -f ~/COFRE/CODERS/JARVIS/muestras/mejora/registro.log
|
||||
cat ~/COFRE/CODERS/JARVIS/muestras/mejora/marcador.json
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
CASA = os.path.expanduser("~")
|
||||
JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS")
|
||||
CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config")
|
||||
APP = "io.github.qwersyk.Newelle//master"
|
||||
|
||||
PIPER = os.path.join(JARVIS, "voz/piper/piper")
|
||||
MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos")
|
||||
WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli")
|
||||
MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models")
|
||||
|
||||
CATALOGO = os.path.expanduser(
|
||||
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
|
||||
"active/files/share/newelle/acciones/acciones.json")
|
||||
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
|
||||
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
|
||||
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
|
||||
|
||||
SALA = os.path.join(JARVIS, "muestras/mejora")
|
||||
REGISTRO = os.path.join(SALA, "registro.log")
|
||||
MARCADOR = os.path.join(SALA, "marcador.json")
|
||||
COPIAS = os.path.join(SALA, "copias")
|
||||
WAVS = os.path.join(CONFIG, "mejora_wavs")
|
||||
|
||||
VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
|
||||
# Cada ronda escoge una rejilla distinta de velocidades y ruidos, para que no
|
||||
# se estanque midiendo siempre lo mismo y siga sacando fallos raros.
|
||||
VELOCIDADES = [0.85, 0.92, 1.0, 1.08, 1.18]
|
||||
RUIDOS = [
|
||||
"",
|
||||
"aecho=0.8:0.7:22:0.12",
|
||||
"volume=0.72,highpass=f=120",
|
||||
"aecho=0.6:0.5:35:0.2,volume=0.8",
|
||||
"highpass=f=150,lowpass=f=6500", # microfono cutre
|
||||
"volume=0.65", # lejos
|
||||
]
|
||||
RELLENOS = ["notas", "el informe", "documentos", "la reunion de ayer", "gatos"]
|
||||
|
||||
# Frases de charla que NUNCA deben acabar en una accion. Es el guardarrail: si
|
||||
# despues de aplicar alias alguna se cuela, la ronda se revierte.
|
||||
CHARLA = [
|
||||
"cuentame un chiste sobre gatos", "que opinas del proyecto",
|
||||
"hola que tal estas", "gracias por todo", "no se que hacer con esto",
|
||||
"me parece que esto no funciona bien", "buenas tardes que tal",
|
||||
"cuentame algo interesante", "estoy cansado de programar",
|
||||
"que raro todo esto la verdad", "a ver si esto va mejor ahora",
|
||||
"pues no tengo ni idea de que decir", "vale perfecto muchas gracias",
|
||||
"oye una cosa que te queria preguntar",
|
||||
]
|
||||
|
||||
TOPE_ALIAS_POR_ACCION = 25 # que una accion no acumule cientos de alias
|
||||
UMBRAL_SISTEMATICO = 2 # veces que hay que fallar igual para creerselo
|
||||
DRY_PARA_PARAR = 6 # rondas seguidas sin nada nuevo -> converge
|
||||
|
||||
|
||||
def log(msg):
|
||||
linea = f"[{time.strftime('%H:%M:%S')}] {msg}"
|
||||
print(linea, flush=True)
|
||||
try:
|
||||
with open(REGISTRO, "a") as f:
|
||||
f.write(linea + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def disco_libre_gb() -> float:
|
||||
"""GB libres en la particion del home. Guardarrail: aunque el bucle apenas
|
||||
ocupa 3 MB por ronda y los limpia, si algo mas llenara el disco durante la
|
||||
noche conviene parar en vez de dejar el sistema sin espacio."""
|
||||
try:
|
||||
s = os.statvfs(CASA)
|
||||
return s.f_bavail * s.f_frsize / 1e9
|
||||
except OSError:
|
||||
return 999.0
|
||||
|
||||
|
||||
def carga_motor():
|
||||
# se recarga cada ronda: el catalogo propio cambia y el motor debe verlo
|
||||
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def dentro(orden, limite=1200):
|
||||
return subprocess.run(
|
||||
["flatpak", "run", "--command=bash", APP, "-c", orden],
|
||||
capture_output=True, text=True, timeout=limite)
|
||||
|
||||
|
||||
def genera(texto, voz, velocidad, ruido, destino):
|
||||
crudo = tempfile.mktemp(suffix=".wav")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
|
||||
"--length_scale", str(1 / velocidad), "--output_file", crudo],
|
||||
input=texto, capture_output=True, text=True, timeout=60)
|
||||
if r.returncode != 0 or not os.path.exists(crudo):
|
||||
return False
|
||||
filtros = "aresample=16000" + ("," + ruido if ruido else "")
|
||||
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-i", crudo, "-ac", "1", "-af", filtros, "-y", destino],
|
||||
check=True, timeout=60)
|
||||
return os.path.exists(destino)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
finally:
|
||||
if os.path.exists(crudo):
|
||||
os.remove(crudo)
|
||||
|
||||
|
||||
def transcribe(carpeta, ficheros, hilos=8, por_tanda=40):
|
||||
salida = {}
|
||||
for i in range(0, len(ficheros), por_tanda):
|
||||
tanda = ficheros[i:i + por_tanda]
|
||||
lista = " ".join(f'"{carpeta}/{n}"' for n in tanda)
|
||||
orden = (f'for f in {lista}; do echo "### $(basename "$f")"; '
|
||||
f'{WHISPER} -m {MODELOS_STT}/ggml-base.bin -l es -nt -t {hilos} '
|
||||
f'-f "$f" 2>/dev/null | tr "\\n" " "; echo; done')
|
||||
try:
|
||||
r = dentro(orden)
|
||||
except subprocess.SubprocessError:
|
||||
continue
|
||||
actual = None
|
||||
for linea in r.stdout.splitlines():
|
||||
if linea.startswith("### "):
|
||||
actual = linea[4:].strip()
|
||||
elif actual:
|
||||
salida[actual] = linea.strip()
|
||||
actual = None
|
||||
return salida
|
||||
|
||||
|
||||
def catalogo_base():
|
||||
ruta = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE
|
||||
with open(ruta) as f:
|
||||
return json.load(f)["acciones"], ruta
|
||||
|
||||
|
||||
def evalua(motor, textos, indice):
|
||||
"""(aciertos, total, candidatos{id: {frase_mal: veces}})."""
|
||||
aciertos = 0
|
||||
candidatos = defaultdict(lambda: defaultdict(int))
|
||||
for nombre, (ident, frase) in indice.items():
|
||||
oido = textos.get(nombre, "").strip()
|
||||
if not oido:
|
||||
continue
|
||||
a, _ = _CAT.busca(oido)
|
||||
if a is not None and a.id == ident:
|
||||
aciertos += 1
|
||||
else:
|
||||
candidatos[ident][motor.normaliza(oido)] += 1
|
||||
return aciertos, len([1 for n in indice if textos.get(n)]), candidatos
|
||||
|
||||
|
||||
_CAT = None
|
||||
|
||||
|
||||
# Palabras de funcion: aparecen en cualquier frase y no distinguen una orden de
|
||||
# una conversacion. Un alias hecho solo de estas ("que me tengo") es una trampa.
|
||||
VACIAS = {
|
||||
"que", "de", "la", "el", "los", "las", "un", "una", "me", "te", "se", "lo",
|
||||
"mi", "tu", "su", "es", "esta", "estan", "hay", "por", "para", "con", "en",
|
||||
"y", "o", "a", "al", "del", "como", "cuanto", "cuanta", "muy", "ya", "he",
|
||||
"ha", "tengo", "tiene", "esto", "eso", "aqui", "ahi", "va", "van", "asi",
|
||||
}
|
||||
|
||||
|
||||
def contenido(frase: str) -> set:
|
||||
"""Las palabras con significado: las que de verdad identifican la orden."""
|
||||
return {p for p in frase.split() if len(p) > 3 and p not in VACIAS}
|
||||
|
||||
|
||||
def filtra_candidatos(motor, candidatos, acciones):
|
||||
"""Solo los alias que valen: sistematicos, sin ambiguedad, y que conservan
|
||||
una palabra distintiva de la orden original.
|
||||
|
||||
El guardarrail clave es el ultimo: una confusion del reconocedor que aun
|
||||
sirve de alias mantiene al menos una palabra con significado ("kernel",
|
||||
"wikipedia", "bateria"). Si las perdio todas y solo quedan palabras de
|
||||
relleno, es indistinguible de una frase de charla y se descarta, por mucho
|
||||
que se haya repetido. Es lo que separa "que kernel tengo" -> "que kernel me
|
||||
tengo" (vale) de "que me tengo" (no vale).
|
||||
"""
|
||||
porfrase = defaultdict(set)
|
||||
for ident, frases in candidatos.items():
|
||||
for f in frases:
|
||||
porfrase[f].add(ident)
|
||||
|
||||
porid = {a["id"]: a for a in acciones}
|
||||
|
||||
propuestos = defaultdict(list)
|
||||
for ident, frases in candidatos.items():
|
||||
accion = porid.get(ident)
|
||||
if accion is None:
|
||||
continue
|
||||
# Las de argumento no generan alias: el relleno de prueba ("notas") se
|
||||
# cuela en la transcripcion y quedaria horneado en el alias. Su
|
||||
# emparejamiento ya es por parecido del arranque, que las cubre.
|
||||
if accion.get("captura"):
|
||||
continue
|
||||
|
||||
orig = motor.normaliza(accion["frases"][0] if accion["frases"] else "")
|
||||
claves = contenido(orig)
|
||||
|
||||
for f, veces in frases.items():
|
||||
if veces < UMBRAL_SISTEMATICO:
|
||||
continue
|
||||
if len(porfrase[f]) > 1: # la misma frase, dos acciones
|
||||
continue
|
||||
if len(f.split()) < 2: # una palabra engancha de todo
|
||||
continue
|
||||
if _CAT.busca(f)[0] is not None: # ya lo coge algo
|
||||
continue
|
||||
if motor.parecido(f, orig) < 0.6: # se perdio del todo: ruido
|
||||
continue
|
||||
# conserva una palabra distintiva? exacta o casi (el reconocedor
|
||||
# cambia una letra: "wikipedia" -> "wikipeda")
|
||||
palabras_f = set(f.split())
|
||||
if claves and not any(
|
||||
c in palabras_f or any(motor.parecido(c, w) > 0.8 for w in palabras_f)
|
||||
for c in claves):
|
||||
continue
|
||||
propuestos[ident].append((f, veces))
|
||||
return propuestos
|
||||
|
||||
|
||||
def aplica(propuestos, acciones_orig):
|
||||
"""Escribe los alias en el catalogo propio. Devuelve cuantos."""
|
||||
try:
|
||||
with open(PROPIO) as f:
|
||||
propio = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
propio = {"version": 1, "acciones": []}
|
||||
porid = {a["id"]: a for a in propio.get("acciones", [])}
|
||||
original = {a["id"]: a for a in acciones_orig}
|
||||
|
||||
n = 0
|
||||
for ident, lista in propuestos.items():
|
||||
entrada = porid.get(ident)
|
||||
if entrada is None:
|
||||
entrada = dict(original[ident])
|
||||
porid[ident] = entrada
|
||||
propio.setdefault("acciones", []).append(entrada)
|
||||
if len(entrada["frases"]) >= TOPE_ALIAS_POR_ACCION:
|
||||
continue
|
||||
for f, _ in lista:
|
||||
if f not in entrada["frases"]:
|
||||
entrada["frases"].append(f)
|
||||
n += 1
|
||||
with open(PROPIO, "w") as f:
|
||||
json.dump(propio, f, indent=2, ensure_ascii=False)
|
||||
return n
|
||||
|
||||
|
||||
def charla_se_cuela(motor):
|
||||
"""True si alguna frase de charla acaba en una accion. El guardarrail."""
|
||||
for frase in CHARLA:
|
||||
a, _ = _CAT.busca(frase)
|
||||
if a is not None:
|
||||
return frase, a.id
|
||||
return None
|
||||
|
||||
|
||||
def marcador_guarda(datos):
|
||||
try:
|
||||
with open(MARCADOR, "w") as f:
|
||||
json.dump(datos, f, indent=2, ensure_ascii=False)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def copia_catalogo(ronda):
|
||||
if not os.path.exists(PROPIO):
|
||||
return
|
||||
os.makedirs(COPIAS, exist_ok=True)
|
||||
shutil.copy2(PROPIO, os.path.join(COPIAS, f"ronda_{ronda:03d}.json"))
|
||||
|
||||
|
||||
def una_ronda(motor, ronda, muestras_por_acap):
|
||||
global _CAT
|
||||
acciones, _ = catalogo_base()
|
||||
|
||||
# muestras de esta ronda: condiciones aleatorias, distintas cada vez
|
||||
trabajos = []
|
||||
for a in acciones:
|
||||
if not a["frases"]:
|
||||
continue
|
||||
frase = a["frases"][0]
|
||||
if a.get("captura"):
|
||||
frase += " " + random.choice(RELLENOS)
|
||||
for _ in range(muestras_por_acap):
|
||||
trabajos.append((a["id"], frase, random.choice(VOCES),
|
||||
random.choice(VELOCIDADES), random.choice(RUIDOS)))
|
||||
random.shuffle(trabajos)
|
||||
|
||||
shutil.rmtree(WAVS, ignore_errors=True)
|
||||
os.makedirs(WAVS, exist_ok=True)
|
||||
indice = {}
|
||||
for n, (ident, frase, voz, vel, ruido) in enumerate(trabajos, 1):
|
||||
nombre = f"{n:05d}.wav"
|
||||
if genera(frase, voz, vel, ruido, os.path.join(WAVS, nombre)):
|
||||
indice[nombre] = (ident, frase)
|
||||
|
||||
log(f"ronda {ronda}: {len(indice)} frases dichas, transcribiendo...")
|
||||
textos = transcribe(WAVS, sorted(indice))
|
||||
|
||||
# medir ANTES de tocar nada
|
||||
_CAT = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
|
||||
or [CATALOGO_FUENTE])
|
||||
aciertos, total, candidatos = evalua(motor, textos, indice)
|
||||
antes = 100 * aciertos / max(1, total)
|
||||
log(f"ronda {ronda}: acierto {antes:.1f} % ({aciertos}/{total})")
|
||||
|
||||
propuestos = filtra_candidatos(motor, candidatos, acciones)
|
||||
nuevos = sum(len(v) for v in propuestos.values())
|
||||
if not nuevos:
|
||||
log(f"ronda {ronda}: sin alias nuevos que valgan")
|
||||
return antes, 0, False
|
||||
|
||||
# aplicar en el catalogo propio, con copia de seguridad antes
|
||||
copia_catalogo(ronda)
|
||||
n = aplica(propuestos, acciones)
|
||||
|
||||
# revalidar: recargar el catalogo con los alias puestos
|
||||
_CAT = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
|
||||
or [CATALOGO_FUENTE])
|
||||
|
||||
# GUARDARRAIL 1: la charla no se cuela
|
||||
cuela = charla_se_cuela(motor)
|
||||
if cuela:
|
||||
frase, ident = cuela
|
||||
log(f"ronda {ronda}: REVERTIDA — '{frase}' se colaria en {ident}")
|
||||
shutil.copy2(os.path.join(COPIAS, f"ronda_{ronda:03d}.json"), PROPIO)
|
||||
return antes, 0, False
|
||||
|
||||
# GUARDARRAIL 2: el acierto sube en las mismas muestras
|
||||
aciertos2, _, _ = evalua(motor, textos, indice)
|
||||
despues = 100 * aciertos2 / max(1, total)
|
||||
if despues < antes - 0.5:
|
||||
log(f"ronda {ronda}: REVERTIDA — el acierto bajaria a {despues:.1f} %")
|
||||
shutil.copy2(os.path.join(COPIAS, f"ronda_{ronda:03d}.json"), PROPIO)
|
||||
return antes, 0, False
|
||||
|
||||
log(f"ronda {ronda}: +{n} alias · acierto {antes:.1f} -> {despues:.1f} %")
|
||||
for ident, lista in sorted(propuestos.items()):
|
||||
for f, v in lista[:2]:
|
||||
log(f" {ident} <- \"{f}\" ({v}x)")
|
||||
return despues, n, True
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--horas", type=float, default=0, help="parar tras N horas")
|
||||
ap.add_argument("--ronda", action="store_true", help="una sola ronda")
|
||||
ap.add_argument("--muestras", type=int, default=3,
|
||||
help="muestras por accion y ronda")
|
||||
ap.add_argument("--dry", type=int, default=DRY_PARA_PARAR,
|
||||
help="rondas secas seguidas antes de parar")
|
||||
args = ap.parse_args()
|
||||
|
||||
for ruta, que in ((PIPER, "piper"), (WHISPER, "whisper")):
|
||||
if not os.path.exists(ruta):
|
||||
print(f"falta {que}: {ruta}")
|
||||
return 1
|
||||
|
||||
os.makedirs(SALA, exist_ok=True)
|
||||
limite = time.time() + args.horas * 3600 if args.horas else None
|
||||
motor = carga_motor()
|
||||
|
||||
log("=" * 56)
|
||||
log(f"arranca la mejora nocturna · {args.muestras} muestras/accion/ronda")
|
||||
if limite:
|
||||
log(f"parara en {args.horas} h")
|
||||
else:
|
||||
log("corre hasta converger o hasta que la pares")
|
||||
|
||||
historial = []
|
||||
dry = 0
|
||||
ronda = 0
|
||||
try:
|
||||
while True:
|
||||
ronda += 1
|
||||
libre = disco_libre_gb()
|
||||
if libre < 10:
|
||||
log(f'PARADA: solo quedan {libre:.1f} GB de disco. Fin por seguridad.')
|
||||
break
|
||||
ini = time.time()
|
||||
try:
|
||||
score, nuevos, aplicado = una_ronda(motor, ronda, args.muestras)
|
||||
except Exception as e:
|
||||
log(f"ronda {ronda}: error, se salta — {type(e).__name__}: {e}")
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
historial.append({"ronda": ronda, "acierto": round(score, 1),
|
||||
"alias_nuevos": nuevos,
|
||||
"minutos": round((time.time() - ini) / 60, 1)})
|
||||
marcador_guarda({
|
||||
"actualizado": time.strftime("%Y-%m-%d %H:%M"),
|
||||
"rondas": ronda, "ultimo_acierto": round(score, 1),
|
||||
"alias_totales": alias_totales(),
|
||||
"historial": historial[-40:],
|
||||
})
|
||||
|
||||
dry = dry + 1 if nuevos == 0 else 0
|
||||
if args.ronda:
|
||||
break
|
||||
if dry >= args.dry:
|
||||
log(f"converge: {DRY_PARA_PARAR} rondas sin nada nuevo. Fin.")
|
||||
break
|
||||
if limite and time.time() > limite:
|
||||
log("se acabo el tiempo pedido. Fin.")
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
log("parada a mano")
|
||||
|
||||
shutil.rmtree(WAVS, ignore_errors=True)
|
||||
log(f"terminado tras {ronda} rondas · acierto final "
|
||||
f"{historial[-1]['acierto'] if historial else '?'} % · "
|
||||
f"{alias_totales()} alias en el catalogo propio")
|
||||
log("los cambios estan en jarvis-acciones.json; se aplican al abrir JARVIS")
|
||||
return 0
|
||||
|
||||
|
||||
def alias_totales():
|
||||
try:
|
||||
with open(PROPIO) as f:
|
||||
d = json.load(f)
|
||||
base = {a["id"]: len(a["frases"]) for a in
|
||||
json.load(open(CATALOGO if os.path.exists(CATALOGO)
|
||||
else CATALOGO_FUENTE))["acciones"]}
|
||||
return sum(max(0, len(a["frases"]) - base.get(a["id"], 0))
|
||||
for a in d.get("acciones", []))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
66
config/muestra_voz.py
Normal file
66
config/muestra_voz.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Genera muestras de la voz JARVIS: seca y con tratamiento.
|
||||
|
||||
Se ejecuta DENTRO del flatpak, que es donde viven Kokoro y ffmpeg:
|
||||
|
||||
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||
~/COFRE/CODERS/JARVIS/config/muestra_voz.py
|
||||
|
||||
Sirve para juzgar la voz de oido antes de montar una conversacion entera,
|
||||
y para comparar el efecto del filtro (que es la mitad del caracter JARVIS).
|
||||
Deja los wav en ~/COFRE/CODERS/JARVIS/muestras/.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/app/share/newelle")
|
||||
# Los modulos que Newelle instala bajo demanda (kokoro-onnx, soundfile...) no
|
||||
# van al site-packages del sistema sino a config/pip, que la app añade al
|
||||
# sys.path al arrancar. Un script suelto tiene que hacerlo a mano.
|
||||
sys.path.insert(0, os.path.expanduser(
|
||||
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
|
||||
|
||||
from gi.repository import Gio # noqa: E402
|
||||
|
||||
from newelle.handlers.tts.kokoro_handler import KokoroTTSHandler # noqa: E402
|
||||
from newelle.handlers.tts.tts import jarvis_audio_filter # noqa: E402
|
||||
|
||||
FRASE = ("Good evening, sir. All systems are running locally. "
|
||||
"You have 846 gigabytes free and the GPU is idle.")
|
||||
|
||||
SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/muestras")
|
||||
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(SALIDA, exist_ok=True)
|
||||
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
|
||||
handler = KokoroTTSHandler(settings, os.path.join(CONFIG, "models"))
|
||||
|
||||
if not handler.is_installed():
|
||||
print("Kokoro no instalado, instalando (pip + modelos, tarda)...")
|
||||
handler.install()
|
||||
print("instalado")
|
||||
|
||||
voz = handler.get_current_voice()
|
||||
print(f"voz: {voz}")
|
||||
|
||||
seca = os.path.join(SALIDA, "jarvis_seca.wav")
|
||||
handler.save_audio(FRASE, seca)
|
||||
print(f"seca: {seca} ({os.path.getsize(seca)} bytes)")
|
||||
|
||||
filtro = jarvis_audio_filter()
|
||||
tratada = os.path.join(SALIDA, "jarvis_tratada.wav")
|
||||
subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-i", seca, "-af", filtro, "-y", tratada],
|
||||
check=True,
|
||||
)
|
||||
print(f"tratada: {tratada} ({os.path.getsize(tratada)} bytes)")
|
||||
print(f"filtro: {filtro}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
207
config/noche.sh
Executable file
207
config/noche.sh
Executable file
|
|
@ -0,0 +1,207 @@
|
|||
#!/usr/bin/env bash
|
||||
# Pipeline nocturno: JARVIS se mejora solo mientras nadie mira.
|
||||
#
|
||||
# Cada ciclo hace lo mismo: mira los datos que ha dejado el uso real, propone
|
||||
# un cambio, lo aplica, y lo somete a config/puerta.sh. Si la puerta dice que
|
||||
# no, se revierte ENTERO con git y se pasa al siguiente. Nada se queda sin
|
||||
# pasar por ahi.
|
||||
#
|
||||
# Por que git y no una copia: el arbol ya esta versionado, `git checkout` es
|
||||
# atomico y deja rastro de lo que se intento. Al terminar la noche se puede ver
|
||||
# commit a commit que se cambio y por que.
|
||||
#
|
||||
# noche.sh corre hasta que se le diga que pare
|
||||
# noche.sh --ciclos 5 solo cinco vueltas
|
||||
# noche.sh --seco propone y valida, pero NO commitea (para mirar)
|
||||
#
|
||||
# Para pararlo: touch ~/COFRE/CODERS/JARVIS/PARAR
|
||||
#
|
||||
# ── Que puede mejorar solo, y que no ────────────────────────────────────────
|
||||
#
|
||||
# Se mejora solo lo que se puede VALIDAR solo. Eso es:
|
||||
# · el vocabulario: alias nuevos para ordenes que no se entendieron
|
||||
# · las frases del catalogo: variantes de las que fallan
|
||||
# · la voz: regenerar en la voz buena las frases que cayeron a la de reserva
|
||||
# · el prompt: mantenerlo al dia con el catalogo
|
||||
# · acciones nuevas a partir de lo que se pidio y no existia
|
||||
#
|
||||
# NO se toca solo el motor, el panel ni la logica de la aplicacion. No porque
|
||||
# sea imposible generarlo, sino porque no hay forma automatica de comprobar que
|
||||
# un cambio ahi es bueno: la suite dice si algo se rompio, no si algo mejoro.
|
||||
# Un bucle que reescribe su propio validador acaba pasando siempre.
|
||||
set -uo pipefail
|
||||
|
||||
# ── DESACTIVADO EL 15 DE AGOSTO DE 2026 ────────────────────────────────────
|
||||
#
|
||||
# Este bucle modifica el arbol de `newelle/` y COMMITEA solo. Ese arbol esta
|
||||
# congelado en la etiqueta jarvis-newelle-final desde que el desarrollo se
|
||||
# mudo a nucleo/: dejarlo correr escribiria commits automaticos sobre un
|
||||
# repositorio que por definicion ya no cambia.
|
||||
#
|
||||
# Se ha quitado tambien su temporizador de systemd —estaba armado para las
|
||||
# 03:30 y habria saltado solo—, movido a ~/.config/systemd/user/desactivados/.
|
||||
#
|
||||
# Esto de aqui es la segunda cerradura: aunque alguien lo lance a mano, se
|
||||
# niega. Para retomarlo hay que apuntarlo primero al nucleo, y eso no es
|
||||
# cambiar una ruta: la puerta (config/puerta.sh) valida el catalogo, el
|
||||
# generador y la suite VIEJOS, y el nucleo tiene los suyos.
|
||||
if [ "${JARVIS_NOCHE_A_SABIENDAS:-}" != "si" ]; then
|
||||
cat >&2 <<'AVISO'
|
||||
noche.sh esta desactivado.
|
||||
|
||||
Modifica y commitea sobre newelle/, que esta congelado en jarvis-newelle-final
|
||||
desde que el desarrollo se mudo a nucleo/. Su temporizador de systemd tambien
|
||||
se retiro.
|
||||
|
||||
Para retomarlo hay que reescribirlo contra el nucleo: la puerta valida el
|
||||
catalogo, el generador y la suite viejos.
|
||||
|
||||
Si de verdad quieres correrlo tal cual sobre el arbol congelado:
|
||||
JARVIS_NOCHE_A_SABIENDAS=si config/noche.sh
|
||||
AVISO
|
||||
exit 78
|
||||
fi
|
||||
|
||||
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
|
||||
cd "$RAIZ"
|
||||
PARADA=$RAIZ/PARAR
|
||||
DIARIO=$RAIZ/noche.log
|
||||
CICLOS=0; SECO=no
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--ciclos) CICLOS=${2:-0}; shift 2;;
|
||||
--seco) SECO=si; shift;;
|
||||
*) shift;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { printf '%s %s\n' "$(date +%H:%M:%S)" "$*" | tee -a "$DIARIO"; }
|
||||
|
||||
# ── salvaguarda ────────────────────────────────────────────────────────────
|
||||
cd "$RAIZ/newelle"
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
log "ABORTO: el fork tiene cambios sin commitear. Commitealos o descartalos."
|
||||
exit 1
|
||||
fi
|
||||
ANCLA=$(git rev-parse HEAD)
|
||||
git tag -f "noche-$(date +%Y%m%d-%H%M)" >/dev/null 2>&1
|
||||
log "ancla: $ANCLA (para volver: git reset --hard $ANCLA)"
|
||||
cd "$RAIZ"
|
||||
|
||||
# ── las mejoras, cada una en su funcion ────────────────────────────────────
|
||||
# Cada una devuelve 0 si propuso algo, 1 si no habia nada que hacer.
|
||||
|
||||
mejora_vocabulario() {
|
||||
# Lo que el usuario dijo y no se entendio. Es la fuente mas valiosa porque
|
||||
# son fallos REALES, no inventados.
|
||||
local registro=$HOME/.var/app/io.github.qwersyk.Newelle/config/jarvis-sin-accion.jsonl
|
||||
[ -f "$registro" ] || return 1
|
||||
python3 "$RAIZ/config/proponer_alias.py" --aplicar 2>&1 | tail -3
|
||||
return ${PIPESTATUS[0]}
|
||||
}
|
||||
|
||||
mejora_frases() {
|
||||
# Variantes de las frases del catalogo, generadas con el modelo local y
|
||||
# filtradas duro. Sube la cobertura sin tocar comandos.
|
||||
python3 "$RAIZ/config/proponer_frases.py" --aplicar 2>&1 | tail -3
|
||||
return ${PIPESTATUS[0]}
|
||||
}
|
||||
|
||||
mejora_voz() {
|
||||
# Las frases que cayeron a la voz de reserva se regeneran con la clonada.
|
||||
[ -s "$RAIZ/voz/misses.log" ] || return 1
|
||||
"$RAIZ/voz/clonador-venv/bin/python" "$RAIZ/voz/calienta_cache.py" cpu 2>&1 | tail -2
|
||||
return 0
|
||||
}
|
||||
|
||||
mejora_prompt() {
|
||||
# El prompt lleva la lista de capacidades: si cambio el catalogo, hay que
|
||||
# regenerarlo o el modelo sigue creyendo que sabe hacer otra cosa.
|
||||
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||
"$RAIZ/config/set_persona.py" 2>&1 | grep -v WARNING | tail -1
|
||||
return 0
|
||||
}
|
||||
|
||||
mejora_reconocimiento() {
|
||||
# La unica que genera sus PROPIOS datos: dice las frases del catalogo con
|
||||
# varias voces, velocidades y ruidos, las transcribe con el mismo Whisper
|
||||
# del asistente, y propone alias para lo que no llega a su accion.
|
||||
#
|
||||
# Es la que hace productiva la noche aunque nadie hable con JARVIS. Ya trae
|
||||
# su propio guardarrail —solo aplica si el acierto sube Y la charla sigue
|
||||
# yendo al modelo— y ademas pasa por la puerta como todo lo demas.
|
||||
timeout 3000 python3 "$RAIZ/config/mejora_nocturna.py" --ronda --muestras 2 2>&1 | tail -4
|
||||
return ${PIPESTATUS[0]}
|
||||
}
|
||||
|
||||
MEJORAS=(mejora_reconocimiento mejora_vocabulario mejora_frases mejora_prompt mejora_voz)
|
||||
|
||||
# ── el bucle ───────────────────────────────────────────────────────────────
|
||||
vuelta=0
|
||||
aceptados=0; rechazados=0; sin_nada=0
|
||||
log "=== empieza la noche ==="
|
||||
|
||||
while true; do
|
||||
[ -f "$PARADA" ] && { log "parada pedida (existe $PARADA)"; break; }
|
||||
[ "$CICLOS" -gt 0 ] && [ "$vuelta" -ge "$CICLOS" ] && { log "hechas $CICLOS vueltas"; break; }
|
||||
vuelta=$((vuelta+1))
|
||||
|
||||
for mejora in "${MEJORAS[@]}"; do
|
||||
[ -f "$PARADA" ] && break
|
||||
log "── vuelta $vuelta · $mejora"
|
||||
|
||||
salida=$($mejora 2>&1); resultado=$?
|
||||
[ -n "$salida" ] && echo "$salida" | sed 's/^/ /' | tee -a "$DIARIO" >/dev/null
|
||||
|
||||
cd "$RAIZ/newelle"
|
||||
cambios=$(git status --porcelain)
|
||||
cd "$RAIZ"
|
||||
if [ -z "$cambios" ] && [ "$resultado" -ne 0 ]; then
|
||||
log " nada que proponer"
|
||||
sin_nada=$((sin_nada+1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -z "$cambios" ]; then
|
||||
log " aplicado fuera del fork (voz o ajustes), sin commit"
|
||||
aceptados=$((aceptados+1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# ── LA PUERTA ──
|
||||
if timeout 2400 "$RAIZ/config/puerta.sh" completa > "$RAIZ/.puerta.txt" 2>&1; then
|
||||
if [ "$SECO" = si ]; then
|
||||
log " [seco] habria pasado la puerta; se revierte igual"
|
||||
(cd "$RAIZ/newelle" && git checkout -- . && git clean -fd >/dev/null 2>&1)
|
||||
else
|
||||
(cd "$RAIZ/newelle" && git add -A && \
|
||||
git commit -q -m "auto($mejora): mejora nocturna
|
||||
|
||||
Propuesto y validado por config/noche.sh sin intervencion. Paso la
|
||||
puerta completa: catalogo valido, generador reproducible, emparejador
|
||||
sin perdidas, 150 acciones respondiendo y la suite en verde.
|
||||
|
||||
$(echo "$salida" | head -3)")
|
||||
log " ACEPTADO y commiteado"
|
||||
aceptados=$((aceptados+1))
|
||||
fi
|
||||
else
|
||||
log " RECHAZADO: $(tail -1 "$RAIZ/.puerta.txt")"
|
||||
(cd "$RAIZ/newelle" && git checkout -- . && git clean -fd >/dev/null 2>&1)
|
||||
rechazados=$((rechazados+1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Si una vuelta entera no propuso nada, no tiene sentido seguir dando
|
||||
# vueltas en vacio: se espera a que el uso genere datos nuevos.
|
||||
if [ "$sin_nada" -ge $(( ${#MEJORAS[@]} )) ]; then
|
||||
log "una vuelta entera sin nada que hacer; esperando 30 min"
|
||||
for _ in $(seq 180); do [ -f "$PARADA" ] && break; sleep 10; done
|
||||
sin_nada=0
|
||||
fi
|
||||
done
|
||||
|
||||
log "=== fin: $aceptados aceptados, $rechazados rechazados, $vuelta vueltas ==="
|
||||
cd "$RAIZ/newelle"
|
||||
log "commits de esta noche:"
|
||||
git log --oneline "$ANCLA"..HEAD 2>/dev/null | sed 's/^/ /' | tee -a "$DIARIO"
|
||||
49
config/permisos_maquina.sh
Executable file
49
config/permisos_maquina.sh
Executable file
|
|
@ -0,0 +1,49 @@
|
|||
#!/usr/bin/env bash
|
||||
# Sacar a JARVIS de la burbuja: que lo que ejecute sea en la maquina de verdad.
|
||||
#
|
||||
# El sintoma era que el asistente contestaba que "esta en un contenedor" y no
|
||||
# podia hacer casi nada. Las 134 acciones del catalogo ya salian al host (todas
|
||||
# llevan host:true), pero los comandos LIBRES —los que se inventa el modelo
|
||||
# cuando lo que pides no esta en el catalogo— corrian dentro del sandbox, donde
|
||||
# no hay ni tu Debian ni tus discos:
|
||||
#
|
||||
# dentro: PRETTY_NAME="GNOME 50 (Flatpak runtime)" sin /media, sin systemctl
|
||||
# fuera: PRETTY_NAME="Debian GNU/Linux 12" /media, systemctl, docker
|
||||
#
|
||||
# Son dos capas distintas y hacen falta las dos:
|
||||
#
|
||||
# 1. `virtualization` (ajuste de Newelle). Decide si los comandos del modelo
|
||||
# llevan delante `flatpak-spawn --host`. Estaba en true = todo dentro.
|
||||
# Es lo que mira _host_prefix() en integrations/default_tools.py.
|
||||
#
|
||||
# 2. Los permisos del flatpak. Aunque los comandos salgan al host, las
|
||||
# herramientas de FICHEROS de la app (leer, editar, indexar) son Python
|
||||
# corriendo dentro: solo ven lo que el sandbox les deje. Estaba en
|
||||
# `filesystems=home`, asi que fuera de tu carpeta personal no veia nada.
|
||||
#
|
||||
# Lo que esto NO quita: el candado de la contraseña al abrir, la confirmacion
|
||||
# de lo destructivo, ni el dialogo de root. Esos siguen. Lo que cambia es que
|
||||
# cuando TU autorizas algo, se ejecuta donde tiene que ejecutarse.
|
||||
set -eu
|
||||
|
||||
APPID=io.github.qwersyk.Newelle
|
||||
RAMA=${1:-master}
|
||||
|
||||
echo "1. comandos del modelo -> a la maquina, no a la burbuja"
|
||||
flatpak run --command=gsettings "$APPID//$RAMA" set "$APPID" virtualization false
|
||||
|
||||
echo "2. permisos de ficheros -> todo el disco"
|
||||
# host = el sistema de ficheros (salvo /usr y /etc, que van aparte)
|
||||
# host-etc = /etc, para leer configuracion del sistema
|
||||
# host-os = /usr, para mirar lo instalado
|
||||
flatpak override --user \
|
||||
--filesystem=host \
|
||||
--filesystem=host-etc \
|
||||
--filesystem=host-os \
|
||||
"$APPID"
|
||||
|
||||
echo
|
||||
echo "como queda:"
|
||||
printf ' virtualization = %s\n' \
|
||||
"$(flatpak run --command=gsettings "$APPID//$RAMA" get "$APPID" virtualization)"
|
||||
flatpak info --show-permissions "$APPID//$RAMA" 2>/dev/null | grep -E "^filesystems=" | sed 's/^/ /'
|
||||
172
config/probar_acciones.py
Executable file
172
config/probar_acciones.py
Executable file
|
|
@ -0,0 +1,172 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ejecuta las acciones de verdad y mira si funcionan y si se entienden.
|
||||
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/probar_acciones.py
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/probar_acciones.py --romper # solo las malas
|
||||
|
||||
Las 121 acciones se escribieron a mano y casi ninguna se ha ejecutado nunca.
|
||||
Alguna tendra el comando roto, la salida vacia, o una plantilla que apunta a un
|
||||
campo que no existe —y entonces JARVIS diria una frase sin sentido en voz alta.
|
||||
Esto las corre, captura la salida y las clasifica, para saber cuales arreglar.
|
||||
|
||||
Que hace con cada tipo:
|
||||
- Solo lectura (df, free, ps, cat /sys...): las EJECUTA y comprueba la salida.
|
||||
Son la mayoria y las que de verdad importan, porque son las que hay que
|
||||
"interpretar bien".
|
||||
- Con efecto (abre navegador, apaga pantalla, bloquea, sube volumen): NO las
|
||||
ejecuta —tendria efectos de verdad— pero valida que el comando exista.
|
||||
- Destructivas (confirmar=True): NO las ejecuta, solo comprueba el comando.
|
||||
- Con argumento: las ejecuta con un valor de prueba inofensivo.
|
||||
|
||||
Clasificacion de cada accion:
|
||||
OK corrio, dio salida, y la respuesta hablada sale con datos reales
|
||||
VACIA corrio pero sin salida (la herramienta no aplica en esta maquina)
|
||||
ROTA el comando fallo o no existe
|
||||
PLANTILLA la respuesta cayo al volcado crudo: la plantilla pide un campo que
|
||||
la salida no tiene. Es el fallo que hace que JARVIS suene raro.
|
||||
EFECTO no se ejecuta por tener efectos; comando validado
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
JARVIS = os.path.expanduser("~/COFRE/CODERS/JARVIS")
|
||||
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
|
||||
CATALOGO = os.path.expanduser(
|
||||
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
|
||||
"active/files/share/newelle/acciones/acciones.json")
|
||||
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
|
||||
INFORME = os.path.join(JARVIS, "muestras/acciones-salud.json")
|
||||
|
||||
# Órdenes que cambian algo del sistema o abren cosas: se validan pero no se
|
||||
# lanzan, que probar un asistente no deberia dejarte el navegador con veinte
|
||||
# pestañas ni la pantalla apagada.
|
||||
EFECTOS = ["firefox", "xdg-open", "loginctl", "xset", "pactl set", "pkill",
|
||||
"brightnessctl", "systemctl start", "systemctl stop", "import -window",
|
||||
"gnome-screenshot", "rm ", "kill", "--private-window", "dpms",
|
||||
"ventanas.sh", "xdotool", "wmctrl", "xrandr --output"]
|
||||
|
||||
# Valores de prueba para las acciones con argumento, inofensivos a proposito.
|
||||
ARGUMENTOS = {
|
||||
"buscar_fichero": "acciones", "buscar_carpeta": "config",
|
||||
"leer_fichero": "README", "abrir_fichero": "no_existe_xyz",
|
||||
"abrir_carpeta": "no_existe_xyz", "tamano_carpeta": "config",
|
||||
"proceso_buscar": "python", "proceso_matar": "proceso_inexistente_xyz",
|
||||
"instalado": "bash", "manual": "ls",
|
||||
}
|
||||
|
||||
|
||||
def carga_motor():
|
||||
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def spawn():
|
||||
return ["flatpak-spawn", "--host"] if os.path.exists("/.flatpak-info") else []
|
||||
|
||||
|
||||
def tiene_efecto(comando: str) -> bool:
|
||||
return any(marca in comando for marca in EFECTOS)
|
||||
|
||||
|
||||
def ejecuta_crudo(comando: str, argumento: str = "") -> tuple:
|
||||
"""Corre el comando en el host y devuelve (salida, codigo)."""
|
||||
c = comando.replace("{argumento}", shlex.quote(argumento))
|
||||
c = c.replace("{argumento_url}", shlex.quote(argumento))
|
||||
try:
|
||||
r = subprocess.run(spawn() + ["bash", "-lc", c],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
return (r.stdout or r.stderr or "").strip(), r.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
return "TIMEOUT", 124
|
||||
except OSError as e:
|
||||
return f"ERROR: {e}", 1
|
||||
|
||||
|
||||
def clasifica(motor, accion, salida, codigo):
|
||||
"""A partir de la salida cruda, decide como quedaria la respuesta hablada."""
|
||||
plantilla = accion.respuesta
|
||||
dicho = motor.formatea(plantilla, salida, "")
|
||||
|
||||
# ¿la plantilla cayo al volcado crudo? formatea() devuelve {salida} cuando
|
||||
# el .format() peta por un campo que no existe. Se detecta comparando: si
|
||||
# la respuesta ES la salida y la plantilla NO era {salida}, fallo.
|
||||
campos = re.findall(r"\{(campo\d+|ultima|primera)\}", plantilla)
|
||||
if campos and dicho.strip() == salida[:600].strip() and plantilla.strip() not in ("{salida}", "{salida}."):
|
||||
return "PLANTILLA", dicho
|
||||
if not salida:
|
||||
return "VACIA", dicho
|
||||
if codigo != 0 and ("command not found" in salida or "No such file" in salida
|
||||
or salida.startswith("ERROR") or salida == "TIMEOUT"):
|
||||
return "ROTA", dicho
|
||||
return "OK", dicho
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--romper", action="store_true", help="solo las que fallan")
|
||||
ap.add_argument("--json", action="store_true", help="salida en json")
|
||||
args = ap.parse_args()
|
||||
|
||||
motor = carga_motor()
|
||||
catalogo = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE
|
||||
cat = motor.Catalogo([catalogo])
|
||||
|
||||
resultados = []
|
||||
for accion in cat.acciones:
|
||||
estado = None
|
||||
if accion.confirmar:
|
||||
estado = "EFECTO" if not accion.comando else "DESTRUCTIVA"
|
||||
dicho = accion.acuse
|
||||
elif tiene_efecto(accion.comando):
|
||||
estado = "EFECTO"
|
||||
dicho = "(no se ejecuta; " + ("comando ok" if accion.comando else "sin comando") + ")"
|
||||
else:
|
||||
arg = ARGUMENTOS.get(accion.id, "") if accion.captura else ""
|
||||
salida, codigo = ejecuta_crudo(accion.comando, arg)
|
||||
estado, dicho = clasifica(motor, accion, salida, codigo)
|
||||
|
||||
resultados.append({
|
||||
"id": accion.id, "grupo": accion.grupo, "estado": estado,
|
||||
"dice": dicho[:120],
|
||||
})
|
||||
|
||||
cuenta = {}
|
||||
for r in resultados:
|
||||
cuenta[r["estado"]] = cuenta.get(r["estado"], 0) + 1
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({"cuenta": cuenta, "acciones": resultados},
|
||||
ensure_ascii=False))
|
||||
else:
|
||||
malas = {"ROTA", "PLANTILLA", "VACIA"}
|
||||
for r in resultados:
|
||||
if args.romper and r["estado"] not in malas:
|
||||
continue
|
||||
marca = {"OK": " ok ", "VACIA": " vacia", "ROTA": " ROTA ",
|
||||
"PLANTILLA": " PLANT", "EFECTO": " efect",
|
||||
"DESTRUCTIVA": " destr"}.get(r["estado"], " ? ")
|
||||
print(f"[{marca}] {r['id']:<22} {r['dice']}")
|
||||
print("\n" + " ".join(f"{k}: {v}" for k, v in sorted(cuenta.items())))
|
||||
buenas = cuenta.get("OK", 0) + cuenta.get("EFECTO", 0) + cuenta.get("DESTRUCTIVA", 0)
|
||||
print(f"utiles: {buenas}/{len(resultados)} · "
|
||||
f"a revisar: {sum(cuenta.get(k, 0) for k in ('ROTA','PLANTILLA','VACIA'))}")
|
||||
|
||||
os.makedirs(os.path.dirname(INFORME), exist_ok=True)
|
||||
with open(INFORME, "w") as f:
|
||||
json.dump({"cuenta": cuenta, "acciones": resultados}, f,
|
||||
indent=2, ensure_ascii=False)
|
||||
|
||||
fallan = sum(cuenta.get(k, 0) for k in ("ROTA", "PLANTILLA"))
|
||||
return 1 if fallan else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
79
config/probar_rag.py
Normal file
79
config/probar_rag.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Comprueba que el RAG recupera de tus notas, con preguntas de prueba.
|
||||
|
||||
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||
~/COFRE/CODERS/JARVIS/config/probar_rag.py
|
||||
|
||||
Lanza unas cuantas consultas contra el indice ya construido y enseña de que
|
||||
ficheros saca la respuesta. Sirve para ver si el corpus responde bien antes de
|
||||
fiarse de el, y para decidir si merece ampliarlo con los .txt o dejarlo en solo
|
||||
notas.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, "/app/share/newelle")
|
||||
sys.path.insert(0, os.path.expanduser(
|
||||
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
|
||||
|
||||
from gi.repository import Gio # noqa: E402
|
||||
|
||||
from newelle.handlers.rag.llamaindex_handler import LlamaIndexHanlder # noqa: E402
|
||||
from newelle.handlers.embeddings.model2vec import Model2VecHandler # noqa: E402
|
||||
|
||||
MODELOS = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config/models")
|
||||
|
||||
# Preguntas de dominio: si el corpus sirve, deberian traer notas relevantes.
|
||||
PREGUNTAS = [
|
||||
"como enumerar un host con nmap",
|
||||
"escalada de privilegios en linux",
|
||||
"que es oasis y como funciona el pub",
|
||||
"reverse shell",
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
|
||||
rag = LlamaIndexHanlder(settings, MODELOS)
|
||||
if not rag.is_installed():
|
||||
print("faltan dependencias del RAG")
|
||||
return 1
|
||||
|
||||
rag.embedding = Model2VecHandler(settings, MODELOS)
|
||||
|
||||
class SinModelo:
|
||||
def load_model(self, *a, **k):
|
||||
return True
|
||||
rag.llm = SinModelo()
|
||||
rag.secondary_llm = SinModelo()
|
||||
|
||||
print("cargando el indice...", flush=True)
|
||||
try:
|
||||
rag.load_index()
|
||||
except Exception as e:
|
||||
print(f"no se pudo cargar el indice: {e}")
|
||||
return 1
|
||||
|
||||
for pregunta in PREGUNTAS:
|
||||
print(f"\n### {pregunta}")
|
||||
ini = time.time()
|
||||
try:
|
||||
trozos = rag.get_context(pregunta, [])
|
||||
except Exception as e:
|
||||
print(f" fallo: {e}")
|
||||
continue
|
||||
ms = (time.time() - ini) * 1000
|
||||
if not trozos:
|
||||
print(f" sin resultados ({ms:.0f} ms)")
|
||||
continue
|
||||
print(f" {len(trozos)} trozos en {ms:.0f} ms:")
|
||||
for t in trozos[:1]:
|
||||
# el trozo suele traer la ruta o el nombre del fichero delante
|
||||
resumen = " ".join(str(t).split())[:400]
|
||||
print(f" · {resumen}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
51
config/probar_rag_temas.py
Normal file
51
config/probar_rag_temas.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Prueba el RAG con los temas recien ingestados de Wikipedia."""
|
||||
import os, sys, time
|
||||
sys.path.insert(0, "/app/share/newelle")
|
||||
sys.path.insert(0, os.path.expanduser(
|
||||
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
|
||||
from gi.repository import Gio # noqa: E402
|
||||
from newelle.handlers.rag.llamaindex_handler import LlamaIndexHanlder # noqa
|
||||
from newelle.handlers.embeddings.model2vec import Model2VecHandler # noqa
|
||||
|
||||
MODELOS = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config/models")
|
||||
PREGUNTAS = [
|
||||
"que es la criptografia de curva eliptica",
|
||||
"quien es Richard Stallman y el software libre",
|
||||
"que es el efecto invernadero y el cambio climatico",
|
||||
"que fue la guerra civil espanola",
|
||||
"que es un ataque de denegacion de servicio",
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
|
||||
rag = LlamaIndexHanlder(settings, MODELOS)
|
||||
if not rag.is_installed():
|
||||
print("faltan dependencias del RAG"); return 1
|
||||
rag.embedding = Model2VecHandler(settings, MODELOS)
|
||||
|
||||
class SinModelo:
|
||||
def load_model(self, *a, **k): return True
|
||||
rag.llm = SinModelo(); rag.secondary_llm = SinModelo()
|
||||
print("cargando el indice...", flush=True)
|
||||
rag.load_index()
|
||||
for pregunta in PREGUNTAS:
|
||||
print(f"\n### {pregunta}")
|
||||
ini = time.time()
|
||||
try:
|
||||
trozos = rag.get_context(pregunta, [])
|
||||
except Exception as e:
|
||||
print(f" fallo: {e}"); continue
|
||||
ms = (time.time() - ini) * 1000
|
||||
if not trozos:
|
||||
print(f" sin resultados ({ms:.0f} ms)"); continue
|
||||
print(f" {len(trozos)} trozos en {ms:.0f} ms:")
|
||||
for t in trozos[:2]:
|
||||
resumen = " ".join(str(t).split())[:280]
|
||||
print(f" · {resumen}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
227
config/proponer_alias.py
Executable file
227
config/proponer_alias.py
Executable file
|
|
@ -0,0 +1,227 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Saca alias de lo que el usuario dijo y JARVIS no entendio.
|
||||
|
||||
Es la fuente mas valiosa de mejora porque son fallos REALES: el panel apunta en
|
||||
jarvis-sin-accion.jsonl toda frase que no encajo en ninguna accion. Con 488
|
||||
lineas acumuladas y nadie leyendolas, ahi habia meses de mejoras sin recoger.
|
||||
|
||||
Lo dificil no es proponer, es NO proponer de mas. Un alias malo desvia una
|
||||
orden buena, y eso es peor que no entender: el usuario dice una cosa y pasa
|
||||
otra. Los filtros de abajo son casi todo el fichero por esa razon.
|
||||
|
||||
proponer_alias.py enseña lo que propondria
|
||||
proponer_alias.py --aplicar lo escribe en el generador
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import difflib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
REGISTRO = os.path.expanduser(
|
||||
"~/.var/app/io.github.qwersyk.Newelle/config/jarvis-sin-accion.jsonl")
|
||||
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json")
|
||||
MOTOR = os.path.join(RAIZ, "newelle", "src", "utility", "acciones.py")
|
||||
APLICADOS = os.path.join(RAIZ, "config", "alias-aplicados.json")
|
||||
|
||||
# Lo que dice JARVIS: si aparece en el registro es ECO —se oye a si mismo por
|
||||
# el altavoz— y no es una orden de nadie. Era el 21% del registro.
|
||||
def es_eco(frase, acuses):
|
||||
l = frase.lower()
|
||||
if "señor" in l:
|
||||
return True
|
||||
return any(a and a in l for a in acuses)
|
||||
|
||||
|
||||
|
||||
def parece_orden(frase):
|
||||
"""Un filtro barato antes de gastar el modelo: ¿esto pide algo?
|
||||
|
||||
La charla no lleva verbo de mando. Sin este filtro entran cosas como
|
||||
"Me estas escuchando" o "y que te voy a hacer", que por parecido de letras
|
||||
puntuan alto con cualquier frase corta del catalogo.
|
||||
"""
|
||||
l = " " + frase.lower() + " "
|
||||
VERBOS = (" pon ", " ponme ", " pongas ", " poner ", " abre ", " abreme ",
|
||||
" abrir ", " abras ", " busca ", " buscame ", " buscar ", " busques ",
|
||||
" dime ", " dame ", " muestra ", " muestrame ", " ensename ",
|
||||
" ejecuta ", " lanza ", " haz ", " hazme ", " mira ", " comprueba ",
|
||||
" cierra ", " manda ", " pasa ", " mueve ", " maximiza ", " minimiza ",
|
||||
" centra ", " reproduce ", " cuanto ", " cuanta ", " cuantos ",
|
||||
" cuantas ", " que hora ", " donde ", " cual ")
|
||||
return any(v in l for v in VERBOS)
|
||||
|
||||
|
||||
def el_modelo_lo_confirma(frase, accion, modelo="qwen3.5:4b-jarvis"):
|
||||
"""Que un modelo diga si la frase pide de verdad esa accion.
|
||||
|
||||
El parecido de letras no distingue significado: "me estas escuchando" saca
|
||||
0.57 con las frases de la accion de puertos, y con eso se colaria un alias
|
||||
que desvia ordenes buenas. Un alias malo es peor que no entender, porque el
|
||||
usuario pide una cosa y pasa otra.
|
||||
|
||||
Ante la duda, NO. Si el modelo no contesta o contesta cualquier cosa, se
|
||||
descarta: dejar pasar un alias dudoso cuesta mas que perderlo.
|
||||
"""
|
||||
import urllib.request
|
||||
ejemplo = accion.frases[0] if accion.frases else accion.id
|
||||
prompt = (
|
||||
"Decide si dos frases piden LO MISMO. Responde solo SI o NO.\n\n"
|
||||
"Ejemplos:\n"
|
||||
"A: \"cuanto espacio queda\" B: \"dime cuanto disco me queda\" -> SI\n"
|
||||
"A: \"cuanto espacio queda\" B: \"que tal estas\" -> NO\n"
|
||||
"A: \"abre una terminal\" B: \"sacame una consola\" -> SI\n"
|
||||
"A: \"abre una terminal\" B: \"me estas escuchando\" -> NO\n"
|
||||
"A: \"que temperatura hay\" B: \"cuentame un chiste\" -> NO\n\n"
|
||||
f"A: \"{ejemplo}\" B: \"{frase}\" -> "
|
||||
)
|
||||
try:
|
||||
carga = json.dumps({"model": modelo, "prompt": prompt, "stream": False,
|
||||
"think": False, "options": {"temperature": 0}}).encode()
|
||||
req = urllib.request.Request("http://127.0.0.1:11434/api/generate",
|
||||
data=carga,
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
respuesta = json.load(r).get("response", "").strip().upper()
|
||||
except Exception:
|
||||
return False
|
||||
return respuesta.startswith("SI") or respuesta.startswith("SÍ")
|
||||
|
||||
|
||||
def cargar_motor():
|
||||
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def frases_del_registro():
|
||||
if not os.path.exists(REGISTRO):
|
||||
return []
|
||||
fuera = []
|
||||
for linea in open(REGISTRO, encoding="utf-8"):
|
||||
try:
|
||||
t = json.loads(linea).get("texto", "")
|
||||
except Exception:
|
||||
t = linea.strip()
|
||||
t = " ".join(t.split())
|
||||
if t:
|
||||
fuera.append(t)
|
||||
return fuera
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--aplicar", action="store_true")
|
||||
p.add_argument("--tope", type=int, default=3,
|
||||
help="cuantos alias como mucho por vuelta")
|
||||
args = p.parse_args()
|
||||
|
||||
m = cargar_motor()
|
||||
cat = m.Catalogo([CATALOGO])
|
||||
acciones = {a.id: a for a in cat.acciones}
|
||||
acuses = {a.acuse.lower().strip(". ") for a in cat.acciones if a.acuse}
|
||||
|
||||
frases = frases_del_registro()
|
||||
if not frases:
|
||||
print("no hay registro de frases sin entender")
|
||||
return 1
|
||||
|
||||
try:
|
||||
ya = set(json.load(open(APLICADOS)))
|
||||
except Exception:
|
||||
ya = set()
|
||||
|
||||
# 1. fuera el eco y lo que ya se entiende hoy (el catalogo cambia)
|
||||
candidatas = []
|
||||
for f in frases:
|
||||
if f in ya or es_eco(f, acuses):
|
||||
continue
|
||||
if cat.busca(f)[0] is not None:
|
||||
continue
|
||||
candidatas.append(f)
|
||||
|
||||
# 2. lo que se repite es lo que importa: una frase dicha una vez puede ser
|
||||
# un error de transcripcion; dicha tres veces es como habla el usuario
|
||||
cuenta = collections.Counter(candidatas)
|
||||
|
||||
propuestas = []
|
||||
for frase, veces in cuenta.most_common():
|
||||
norm = m.normaliza(frase)
|
||||
palabras = norm.split()
|
||||
# 3. filtros duros
|
||||
if not (3 <= len(palabras) <= 12):
|
||||
continue
|
||||
if re.search(r"[<>{}\[\]|@#_*]", frase):
|
||||
continue
|
||||
# 4. a que accion se parece mas, comparando con TODAS sus frases
|
||||
mejor, punto = None, 0.0
|
||||
for a in cat.acciones:
|
||||
if a.captura:
|
||||
continue # el relleno de prueba se horneraria en el alias
|
||||
for f2 in a.frases:
|
||||
r = difflib.SequenceMatcher(None, norm, f2).ratio()
|
||||
if r > punto:
|
||||
mejor, punto = a, r
|
||||
# 5. ni muy lejos (seria inventarse) ni muy cerca (ya lo cogeria)
|
||||
if mejor is None or not (0.55 <= punto < 0.86):
|
||||
continue
|
||||
# 6. tiene que compartir una palabra con significado, o el alias
|
||||
# convierte cualquier ruido en una orden
|
||||
suyas = {w for f2 in mejor.frases for w in f2.split() if len(w) > 3}
|
||||
if not (set(w for w in palabras if len(w) > 3) & suyas):
|
||||
continue
|
||||
# 7. y no puede encajar en dos acciones a la vez
|
||||
otras = [a for a in cat.acciones
|
||||
if a is not mejor and not a.captura
|
||||
and max((difflib.SequenceMatcher(None, norm, f2).ratio()
|
||||
for f2 in a.frases), default=0) >= punto - 0.05]
|
||||
if otras:
|
||||
continue
|
||||
# 8. tiene que parecer una orden, no charla
|
||||
if not parece_orden(frase):
|
||||
continue
|
||||
# 9. y el modelo tiene que confirmar que pide ESA accion. Es lo unico
|
||||
# que distingue significado de coincidencia de letras.
|
||||
if not el_modelo_lo_confirma(frase, mejor):
|
||||
continue
|
||||
propuestas.append((frase, norm, mejor.id, punto, veces))
|
||||
|
||||
if not propuestas:
|
||||
print("nada que proponer: ninguna frase pasa los filtros")
|
||||
return 1
|
||||
|
||||
propuestas = propuestas[:args.tope]
|
||||
for frase, norm, ident, punto, veces in propuestas:
|
||||
print(f" {ident:22s} ← \"{frase[:52]}\" (x{veces}, parecido {punto:.2f})")
|
||||
|
||||
if not args.aplicar:
|
||||
return 0
|
||||
|
||||
# 8. se escribe en el GENERADOR, que es la fuente. Editar el JSON seria
|
||||
# perder el cambio la proxima vez que alguien regenere.
|
||||
gen = os.path.join(RAIZ, "newelle", "data", "acciones", "_generar.py")
|
||||
texto = open(gen, encoding="utf-8").read()
|
||||
puestos = 0
|
||||
for frase, norm, ident, punto, veces in propuestas:
|
||||
patron = re.compile(r'(\("' + re.escape(ident) + r'", ")([^"]*)(")')
|
||||
mm = patron.search(texto)
|
||||
if not mm or norm in mm.group(2):
|
||||
continue
|
||||
texto = texto[:mm.start(2)] + mm.group(2) + "|" + norm + texto[mm.end(2):]
|
||||
puestos += 1
|
||||
ya.add(frase)
|
||||
if puestos:
|
||||
open(gen, "w", encoding="utf-8").write(texto)
|
||||
os.system(f"cd {os.path.dirname(gen)} && python3 _generar.py >/dev/null 2>&1")
|
||||
json.dump(sorted(ya), open(APLICADOS, "w"), ensure_ascii=False, indent=1)
|
||||
print(f"{puestos} alias aplicados al generador")
|
||||
return 0 if puestos else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
15
config/proponer_frases.py
Executable file
15
config/proponer_frases.py
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Variantes de las frases del catalogo, generadas con el modelo local.
|
||||
|
||||
Sitio reservado: hoy el trabajo util lo hace proponer_alias.py, que parte de
|
||||
fallos REALES del usuario. Generar variantes inventadas sin datos detras llena
|
||||
el catalogo de ruido y sube el riesgo de que una frase se coma a otra, que es
|
||||
el fallo mas caro del emparejador.
|
||||
|
||||
Cuando haya mas registro de uso, aqui ira: coger las acciones que nunca se
|
||||
disparan, pedirle al modelo tres formas mas de pedirlas, y pasarlas por el
|
||||
mismo juez que usa proponer_alias.
|
||||
"""
|
||||
import sys
|
||||
print("sin datos suficientes: se propone desde fallos reales, no inventando")
|
||||
sys.exit(1)
|
||||
108
config/puerta.sh
Executable file
108
config/puerta.sh
Executable file
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env bash
|
||||
# La puerta: decide si un cambio automatico se queda o se revierte.
|
||||
#
|
||||
# Es la pieza central del pipeline nocturno. Todo lo que el sistema se cambia a
|
||||
# si mismo pasa por aqui, y lo que no la pasa se deshace ENTERO. Sin esto,
|
||||
# dejar algo modificando codigo sin nadie delante es una forma elaborada de
|
||||
# romper un proyecto que funcionaba.
|
||||
#
|
||||
# Que comprueba, en orden de lo mas barato a lo mas caro (para fallar pronto):
|
||||
# 1. que el Python compile
|
||||
# 2. que el catalogo sea JSON valido y el generador lo reproduzca
|
||||
# 3. que el emparejador no haya perdido ordenes (08)
|
||||
# 4. que las acciones sigan respondiendo (repaso completo)
|
||||
# 5. la suite entera
|
||||
#
|
||||
# puerta.sh rapida solo 1-3, para iterar
|
||||
# puerta.sh completa todo, antes de commitear
|
||||
set -uo pipefail
|
||||
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
|
||||
MODO=${1:-completa}
|
||||
cd "$RAIZ"
|
||||
|
||||
fallo() { echo "PUERTA: $1" >&2; exit 1; }
|
||||
|
||||
# --- 1. compila el Python -------------------------------------------------
|
||||
python3 -m compileall -q newelle/src config voz/*.py >/dev/null 2>&1 \
|
||||
|| fallo "hay Python que no compila"
|
||||
|
||||
# --- 2. el catalogo es valido y reproducible ------------------------------
|
||||
python3 -c "
|
||||
import json,sys
|
||||
d=json.load(open('newelle/data/acciones/acciones.json'))
|
||||
acc=d['acciones']
|
||||
assert len(acc)>100, f'solo {len(acc)} acciones'
|
||||
ids=[a['id'] for a in acc]
|
||||
assert len(ids)==len(set(ids)), 'ids repetidos'
|
||||
for a in acc:
|
||||
assert a.get('frases'), f\"{a['id']} sin frases\"
|
||||
assert a.get('acuse'), f\"{a['id']} sin acuse\"
|
||||
assert 'señor' in a['acuse'].lower(), f\"{a['id']} no trata de señor\"
|
||||
if a.get('captura') and a.get('comando'):
|
||||
assert '{argumento' in a['comando'], f\"{a['id']} captura y no usa el argumento\"
|
||||
" 2>/dev/null || fallo "el catalogo esta mal formado"
|
||||
|
||||
# El generador es la fuente: si no reproduce el catalogo, alguien edito el
|
||||
# producto y el proximo que regenere pierde el trabajo.
|
||||
cp newelle/data/acciones/acciones.json /tmp/jarvis-puerta-antes.json
|
||||
(cd newelle/data/acciones && python3 _generar.py >/dev/null 2>&1)
|
||||
if ! diff -q /tmp/jarvis-puerta-antes.json newelle/data/acciones/acciones.json >/dev/null 2>&1; then
|
||||
cp /tmp/jarvis-puerta-antes.json newelle/data/acciones/acciones.json
|
||||
fallo "el generador no reproduce el catalogo (se edito el producto)"
|
||||
fi
|
||||
|
||||
# --- 3. el emparejador no ha perdido ordenes ------------------------------
|
||||
python3 - <<'FIN' || fallo "el emparejador ha perdido ordenes que antes acertaba"
|
||||
import importlib.util, sys
|
||||
spec = importlib.util.spec_from_file_location("acc", "newelle/src/utility/acciones.py")
|
||||
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
||||
cat = m.Catalogo(["newelle/data/acciones/acciones.json"])
|
||||
|
||||
# El minimo innegociable: si alguna de estas deja de funcionar, el cambio se
|
||||
# revierte pase lo que pase. Son las que el usuario usa de verdad.
|
||||
IMPRESCINDIBLES = [
|
||||
("cuanto espacio queda", "disco_libre"),
|
||||
("cuanta memoria", "memoria"),
|
||||
("que temperatura hay", "temperatura"),
|
||||
("abre una terminal", "terminal_abrir"),
|
||||
("apaga la pantalla", "apagar_pantalla"),
|
||||
("captura de pantalla", "captura_pantalla"),
|
||||
("abre una ventana privada", "ff_firefox_privado"),
|
||||
("pon la ventana a la izquierda", "ventana_izquierda"),
|
||||
("necesito que pongas la pelicula de iron man", "pelicula_abrir"),
|
||||
("podrias abrir una terminal", "terminal_abrir"),
|
||||
]
|
||||
malas = []
|
||||
for frase, esperado in IMPRESCINDIBLES:
|
||||
a, _ = cat.busca(frase)
|
||||
if a is None or a.id != esperado:
|
||||
malas.append(f"{frase!r}→{a.id if a else 'ninguna'} (deberia {esperado})")
|
||||
|
||||
# Y la conversacion no puede engancharse a una accion
|
||||
CHARLA = ["que tal estas", "necesito un abrazo", "quiero contarte una cosa",
|
||||
"cuentame un chiste", "que piensas de la vida", "necesito pensar"]
|
||||
coladas = [c for c in CHARLA if cat.busca(c)[0] is not None]
|
||||
if malas or coladas:
|
||||
for x in malas: print(" perdida:", x, file=sys.stderr)
|
||||
for c in coladas: print(" charla colada:", c, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
FIN
|
||||
|
||||
[ "$MODO" = rapida ] && { echo "PUERTA: rapida OK"; exit 0; }
|
||||
|
||||
# --- 4. las acciones responden -------------------------------------------
|
||||
SALIDA=$(timeout 900 python3 config/repasar_acciones.py 2>&1 | tail -3)
|
||||
echo "$SALIDA" | grep -q "de .* responden bien" || fallo "el repaso de acciones no termino"
|
||||
ROTAS=$(echo "$SALIDA" | grep -oE "fallan: .*" || true)
|
||||
[ -n "$ROTAS" ] && fallo "hay acciones rotas → $ROTAS"
|
||||
|
||||
# --- 5. la suite ----------------------------------------------------------
|
||||
# Se salta la 07, que abre la app y se queda colgada sin nadie delante.
|
||||
SUITE=$(cd pruebas && for t in [0-9][0-9]_*.sh; do
|
||||
case "$t" in 05_*|07_*) continue;; esac
|
||||
timeout 400 "./$t" 2>&1 | tail -1
|
||||
done)
|
||||
FALLOS=$(echo "$SUITE" | grep -oE "[0-9]+ fallan" | awk '{s+=$1} END{print s+0}')
|
||||
[ "${FALLOS:-0}" -gt 0 ] && fallo "la suite tiene $FALLOS comprobaciones en rojo"
|
||||
|
||||
echo "PUERTA: completa OK"
|
||||
18
config/qwen3.5-4b-jarvis.Modelfile
Normal file
18
config/qwen3.5-4b-jarvis.Modelfile
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Variante de qwen3.5:4b para JARVIS: deja sitio a whisper en la tarjeta.
|
||||
#
|
||||
# La T1200 tiene 3717 MiB utiles. Con el reparto automatico ollama coge 2941 y
|
||||
# deja 776 libres: whisper small CARGA (757) pero se mata al transcribir, cuando
|
||||
# pide su pico de 839. El sintoma en la app es "Error recognizing file with
|
||||
# server", que despista, porque el fichero y el servidor estan bien.
|
||||
#
|
||||
# Con 24 de las 32 capas en GPU el LLM ocupa 2177 y deja 1540 libres: caben los
|
||||
# dos con 700 MiB de holgura. Y no cuesta velocidad — medido con tres tiradas en
|
||||
# caliente, la respuesta baja de 2,71 s a 2,31 s, porque el reparto automatico
|
||||
# se queda sin VRAM y acaba peleandose consigo mismo.
|
||||
#
|
||||
# Crear o recrear: ollama create qwen3.5:4b-jarvis -f qwen3.5-4b-jarvis.Modelfile
|
||||
|
||||
FROM qwen3.5:4b
|
||||
|
||||
PARAMETER num_gpu 24
|
||||
PARAMETER num_ctx 4096
|
||||
114
config/recorrido.sh
Executable file
114
config/recorrido.sh
Executable file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env bash
|
||||
# Recorre el catalogo grupo por grupo, en voz alta y a ritmo de mirar.
|
||||
#
|
||||
# Distinto de demo_en_directo.sh: aquel probaba once acciones sueltas; este
|
||||
# recorre TODOS los grupos con una muestra representativa de cada uno, anuncia
|
||||
# el grupo antes de entrar, y deja pausas para que se pueda seguir en pantalla.
|
||||
#
|
||||
# Lo destructivo y lo que pide root no se ejecuta: en uso normal eso pasa por
|
||||
# un dialogo que aprueba una persona.
|
||||
#
|
||||
# ./recorrido.sh todo, con voz
|
||||
# ./recorrido.sh --mudo sin voz
|
||||
# ./recorrido.sh sistema solo un grupo
|
||||
set -uo pipefail
|
||||
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
|
||||
CATALOGO=$RAIZ/newelle/data/acciones/acciones.json
|
||||
MUDO=no; SOLO=""
|
||||
for a in "$@"; do
|
||||
case "$a" in --mudo) MUDO=si;; *) SOLO=$a;; esac
|
||||
done
|
||||
WAV=$(mktemp --suffix=.wav); trap 'rm -f "$WAV"' EXIT
|
||||
|
||||
dice() {
|
||||
[ "$MUDO" = si ] && { sleep 0.4; return 0; }
|
||||
"$RAIZ/config/jarvis-voz.sh" "$WAV" "$1" >/dev/null 2>&1
|
||||
# < /dev/null: si no, se comen la entrada del bucle que los llama
|
||||
[ -s "$WAV" ] && { paplay "$WAV" </dev/null 2>/dev/null || aplay -q "$WAV" </dev/null 2>/dev/null; }
|
||||
}
|
||||
|
||||
campo() { python3 -c "
|
||||
import json,sys
|
||||
d=json.load(open('$CATALOGO',encoding='utf-8'))
|
||||
for a in d.get('acciones', d if isinstance(d,list) else []):
|
||||
if a['id']==sys.argv[1]: print(a.get(sys.argv[2],'') or ''); break
|
||||
" "$1" "$2"; }
|
||||
|
||||
# grupo|id|frase que se diria|argumento
|
||||
RECORRIDO='
|
||||
sistema|disco_libre|cuanto espacio queda|
|
||||
sistema|memoria|cuanta memoria|
|
||||
sistema|temperatura|que temperatura hay|
|
||||
sistema|uptime|cuanto lleva encendido|
|
||||
sistema|procesos_top|que esta consumiendo|
|
||||
sistema|red_ip|cual es mi ip|
|
||||
sistema|red_wifi|a que wifi estoy conectado|
|
||||
sistema|gpu_procesos|que usa la grafica|
|
||||
sistema|pantallas|cuantas pantallas hay|
|
||||
sistema|captura_pantalla|hazme una captura|
|
||||
sistema|terminal_abrir|abre una terminal|
|
||||
sistema|videos_listar|que peliculas tengo|
|
||||
ficheros|buscar_fichero|busca el fichero README|README.md
|
||||
ficheros|grep_recursivo|busca la palabra JARVIS|JARVIS
|
||||
ficheros|tamano_carpeta|cuanto ocupa la carpeta config|'"$RAIZ"'/config
|
||||
ficheros|descargas_listar|que hay en descargas|
|
||||
oasis|oasis_corriendo|esta oasis funcionando|
|
||||
oasis|oasis_conexiones|cuantos pubs hay conectados|
|
||||
pentest|mis_puertos|que puertos tengo abiertos yo|
|
||||
ventanas|ventanas_listar|que ventanas tengo abiertas|
|
||||
ventanas|ventana_izquierda|pon la ventana a la izquierda|
|
||||
ventanas|ventana_derecha|manda la ventana a la derecha|
|
||||
ventanas|ventana_centrar|centra la ventana activa|
|
||||
ventanas|ventana_maximizar|maximiza la ventana activa|
|
||||
ventanas|ventana_restaurar|restaura la ventana activa|
|
||||
ventanas|ventana_a_pantalla|manda la ventana a la pantalla uno|uno
|
||||
'
|
||||
|
||||
grupo_actual=""
|
||||
n=0
|
||||
while IFS='|' read -r -u 3 grupo id frase argumento; do
|
||||
[ -z "${grupo:-}" ] && continue
|
||||
[ -n "$SOLO" ] && [ "$SOLO" != "$grupo" ] && continue
|
||||
|
||||
if [ "$grupo" != "$grupo_actual" ]; then
|
||||
grupo_actual=$grupo
|
||||
printf '\n\n\033[1m ══ grupo: %s ══\033[0m\n' "$grupo"
|
||||
dice "Grupo $grupo, señor."
|
||||
sleep 0.6
|
||||
fi
|
||||
|
||||
comando=$(campo "$id" comando)
|
||||
[ -z "$comando" ] && { printf ' (%s no existe en el catalogo)\n' "$id"; continue; }
|
||||
acuse=$(campo "$id" acuse)
|
||||
plantilla=$(campo "$id" respuesta)
|
||||
|
||||
n=$((n+1))
|
||||
printf '\n %2d. tu: "%s"\n' "$n" "$frase"
|
||||
printf ' jarvis: %s\n' "$acuse"
|
||||
dice "$acuse"
|
||||
|
||||
real=${comando//\{argumento\}/$(printf '%q' "${argumento:-}")}
|
||||
salida=$(timeout 40 bash -lc "$real" </dev/null 2>&1 | head -6)
|
||||
|
||||
if [ -n "$salida" ]; then
|
||||
echo "$salida" | sed 's/^/ /' | cut -c1-104
|
||||
else
|
||||
printf ' (hecho, sin salida)\n'
|
||||
fi
|
||||
|
||||
# decir la respuesta si la plantilla se puede resolver
|
||||
if [ -n "$plantilla" ] && [ -n "$salida" ]; then
|
||||
ultima=$(echo "$salida" | tail -1); primera=$(echo "$salida" | head -1)
|
||||
lineas=$(echo "$salida" | wc -l)
|
||||
h=${plantilla//\{ultima\}/$ultima}; h=${h//\{primera\}/$primera}
|
||||
h=${h//\{lineas\}/$lineas}; h=${h//\{salida\}/$salida}
|
||||
case "$h" in
|
||||
*"{"*) ;;
|
||||
*) [ ${#h} -lt 170 ] && { printf ' jarvis: %s\n' "$h"; dice "$h"; };;
|
||||
esac
|
||||
fi
|
||||
sleep 1.5
|
||||
done 3<<< "$RECORRIDO"
|
||||
|
||||
printf '\n\n ── %d acciones mostradas ──\n' "$n"
|
||||
dice "Recorrido terminado, señor."
|
||||
174
config/repasar_acciones.py
Executable file
174
config/repasar_acciones.py
Executable file
|
|
@ -0,0 +1,174 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ejecuta TODAS las acciones del catalogo y dice cuales funcionan de verdad.
|
||||
|
||||
Por que hace falta: hasta hoy los comandos libres del modelo corrian dentro del
|
||||
sandbox, donde no hay ni el sistema ni los discos. Se arreglo (virtualization a
|
||||
false), pero las 134 acciones del catalogo nunca se habian ejecutado todas
|
||||
seguidas contra la maquina real: la suite prueba que las FRASES caen en la
|
||||
accion correcta, no que el comando de cada accion haga algo util.
|
||||
|
||||
Que se ejecuta y que no:
|
||||
|
||||
- Las destructivas (`confirmar: true`) y las que piden root NO se ejecutan.
|
||||
En uso normal pasan por un dialogo que aprueba una persona; aqui no hay
|
||||
persona, y aprobarlas solas seria justo lo que el diseño evita.
|
||||
- Las que abren ventanas (firefox, xdg-open) se ejecutan solo con --abrir,
|
||||
porque llenan la pantalla. Sin esa opcion se comprueba que el programa
|
||||
existe, que es lo que puede fallar.
|
||||
- El resto se ejecuta entero y se mira que conteste algo con sentido.
|
||||
|
||||
python repasar_acciones.py [--abrir] [--grupo sistema] [--json salida.json]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json")
|
||||
|
||||
# Argumentos de mentira para las acciones que capturan uno. Se eligen inocuos:
|
||||
# nada que borre, mueva o mande nada fuera.
|
||||
ARGUMENTOS = {
|
||||
"buscar_fichero": "README.md",
|
||||
"buscar_carpeta": "config",
|
||||
"grep_recursivo": "JARVIS",
|
||||
"leer_fichero": "README.md", # un NOMBRE, que es lo que se dice por voz
|
||||
"abrir_carpeta": RAIZ,
|
||||
"tamano_carpeta": os.path.join(RAIZ, "config"),
|
||||
"buscar_youtube": "musica",
|
||||
"buscar_wikipedia": "madrid",
|
||||
"buscar_github": "newelle",
|
||||
"traducir": "hola",
|
||||
"buscar_web": "que hora es",
|
||||
"matar_proceso": "proceso_que_no_existe_jarvis",
|
||||
"ping": "127.0.0.1",
|
||||
}
|
||||
POR_DEFECTO = "prueba"
|
||||
|
||||
# Lo que abre o MUEVE ventanas. ventanas.sh reordena el escritorio del usuario:
|
||||
# ejecutarlo en un repaso automatico le cambia las ventanas de sitio sin avisar.
|
||||
VENTANA = re.compile(r"\b(firefox|xdg-open|gnome-terminal|nautilus|vlc|mpv|eog|"
|
||||
r"gedit|gnome-text-editor|totem|ventanas\.sh|xdotool|wmctrl)\b")
|
||||
|
||||
|
||||
def carga():
|
||||
with open(CATALOGO, encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
return d.get("acciones", d if isinstance(d, list) else [])
|
||||
|
||||
|
||||
def prefijo_host():
|
||||
"""Las acciones llevan host:true y en uso salen del sandbox. Aqui ya
|
||||
estamos fuera, asi que no hace falta prefijo."""
|
||||
return []
|
||||
|
||||
|
||||
def ejecuta(accion, argumento, tope=25):
|
||||
import shlex
|
||||
import urllib.parse
|
||||
|
||||
comando = accion.get("comando") or ""
|
||||
comando = comando.replace("{argumento}", shlex.quote(argumento))
|
||||
comando = comando.replace("{argumento_url}",
|
||||
shlex.quote(urllib.parse.quote_plus(argumento)))
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
r = subprocess.run(["bash", "-lc", comando], capture_output=True,
|
||||
text=True, errors="replace", timeout=tope)
|
||||
salida = (r.stdout or r.stderr or "").strip()
|
||||
return r.returncode, salida, time.monotonic() - t0
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1, f"(no termino en {tope}s)", time.monotonic() - t0
|
||||
except OSError as e:
|
||||
return -2, f"(no se pudo ejecutar: {e})", time.monotonic() - t0
|
||||
|
||||
|
||||
def programa_existe(comando):
|
||||
"""El primer ejecutable de la orden, para las que abren o mueven ventanas.
|
||||
|
||||
Algunos son ordenes del PATH (firefox) y otros son scripts nuestros con
|
||||
ruta completa (config/ventanas.sh): `command -v` solo sirve para los
|
||||
primeros, y con los segundos hay que mirar el fichero.
|
||||
"""
|
||||
m = VENTANA.search(comando)
|
||||
if not m:
|
||||
return None
|
||||
nombre = m.group(1)
|
||||
if nombre.endswith(".sh"):
|
||||
# sacar la ruta entera tal como aparece en el comando
|
||||
for trozo in comando.split():
|
||||
if trozo.endswith(nombre):
|
||||
ruta = os.path.expandvars(os.path.expanduser(trozo))
|
||||
return os.access(ruta, os.X_OK)
|
||||
return False
|
||||
return subprocess.run(["bash", "-lc", f"command -v {nombre}"],
|
||||
capture_output=True).returncode == 0
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--abrir", action="store_true",
|
||||
help="ejecutar tambien las que abren ventanas")
|
||||
p.add_argument("--grupo")
|
||||
p.add_argument("--json")
|
||||
p.add_argument("--tope", type=int, default=25)
|
||||
args = p.parse_args()
|
||||
|
||||
acciones = carga()
|
||||
if args.grupo:
|
||||
acciones = [a for a in acciones if a.get("grupo") == args.grupo]
|
||||
|
||||
resultados = []
|
||||
print(f" repasando {len(acciones)} acciones"
|
||||
f"{' del grupo ' + args.grupo if args.grupo else ''}\n", flush=True)
|
||||
|
||||
for a in acciones:
|
||||
ident = a.get("id", "?")
|
||||
comando = a.get("comando") or ""
|
||||
arg = ARGUMENTOS.get(ident, POR_DEFECTO if a.get("captura") else "")
|
||||
|
||||
if not comando:
|
||||
estado, detalle = "solo habla", a.get("acuse", "")[:50]
|
||||
elif a.get("confirmar") or "sudo" in comando:
|
||||
# no se aprueban solas: en uso las aprueba una persona
|
||||
estado, detalle = "saltada", "destructiva o con root"
|
||||
elif VENTANA.search(comando) and not args.abrir:
|
||||
hay = programa_existe(comando)
|
||||
estado = "programa ok" if hay else "FALTA PROGRAMA"
|
||||
detalle = VENTANA.search(comando).group(1)
|
||||
else:
|
||||
codigo, salida, tardo = ejecuta(a, arg, args.tope)
|
||||
una_linea = " ".join(salida.split())[:70]
|
||||
if codigo == 0:
|
||||
estado = "ok" if salida else "ok (sin salida)"
|
||||
else:
|
||||
estado = f"FALLO ({codigo})"
|
||||
detalle = f"{tardo:4.1f}s {una_linea}"
|
||||
|
||||
resultados.append({"id": ident, "grupo": a.get("grupo"),
|
||||
"estado": estado, "detalle": detalle})
|
||||
marca = "!" if "FALLO" in estado or "FALTA" in estado else " "
|
||||
print(f" {marca} {ident:26s} {estado:16s} {detalle}", flush=True)
|
||||
|
||||
print()
|
||||
from collections import Counter
|
||||
c = Counter(r["estado"].split(" (")[0].split(" ")[0] for r in resultados)
|
||||
for k, v in c.most_common():
|
||||
print(f" {v:3d} {k}")
|
||||
malas = [r for r in resultados if "FALLO" in r["estado"] or "FALTA" in r["estado"]]
|
||||
print(f"\n {len(resultados) - len(malas)} de {len(resultados)} responden bien")
|
||||
if malas:
|
||||
print(" fallan: " + ", ".join(r["id"] for r in malas))
|
||||
|
||||
if args.json:
|
||||
with open(args.json, "w", encoding="utf-8") as f:
|
||||
json.dump(resultados, f, ensure_ascii=False, indent=2)
|
||||
return 1 if malas else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
81
config/set_backend.py
Normal file
81
config/set_backend.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Pone el cerebro en Ollama sobre GPU y mata el razonamiento.
|
||||
|
||||
Se ejecuta DENTRO del flatpak, con la app CERRADA (si no, al salir puede
|
||||
reescribir los ajustes con lo que tenia en memoria):
|
||||
|
||||
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||
~/COFRE/CODERS/JARVIS/config/set_backend.py
|
||||
|
||||
Por que Ollama y no el llamacpp interno: el llama.cpp que empaqueta el
|
||||
flatpak es el prebuilt de CPU, sin CUDA, asi que el modelo corria entero
|
||||
en procesador. Ollama ya esta en GPU. Los dos son locales igual.
|
||||
|
||||
Medido el 10 ago 2026 con qwen3.5:4b, misma pregunta:
|
||||
|
||||
think=true 41.2 s 749 tokens (casi todos de monologo interno)
|
||||
think=false 2.5 s 44 tokens
|
||||
|
||||
De paso deja sana la ruta de llamacpp por si se vuelve a ella: el mismo
|
||||
razonamiento se apaga alli con --reasoning-budget 0, y el contexto por
|
||||
defecto (0 = el del modelo) abria 262144 por slot, unos 10 GB de RAM.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
from gi.repository import Gio
|
||||
|
||||
SCHEMA = "io.github.qwersyk.Newelle"
|
||||
MODELO = "qwen3.5:4b"
|
||||
|
||||
AJUSTES_OLLAMA = {
|
||||
"endpoint": "http://localhost:11434",
|
||||
"model": MODELO,
|
||||
"custom_model": False,
|
||||
"thinking": False, # lo que baja de 41 s a 2.5 s
|
||||
"native_tool_calling": True,
|
||||
"serve": False, # el servicio del sistema ya esta levantado
|
||||
}
|
||||
|
||||
AJUSTES_LLAMACPP = {
|
||||
"custom_args": "--reasoning-budget 0",
|
||||
"ctx": 8192,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
settings = Gio.Settings.new(SCHEMA)
|
||||
|
||||
try:
|
||||
llm = json.loads(settings.get_string("llm-settings") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
llm = {}
|
||||
|
||||
# merge, no reemplazo: ahi viven los ajustes de todos los proveedores
|
||||
llm.setdefault("ollama", {}).update(AJUSTES_OLLAMA)
|
||||
llm.setdefault("llamacpp", {}).update(AJUSTES_LLAMACPP)
|
||||
|
||||
settings.set_string("llm-settings", json.dumps(llm))
|
||||
settings.set_string("language-model", "ollama")
|
||||
Gio.Settings.sync()
|
||||
|
||||
# releer de gsettings, no fiarse de lo que acabamos de escribir
|
||||
guardado = json.loads(settings.get_string("llm-settings"))
|
||||
motor = settings.get_string("language-model")
|
||||
ollama = guardado.get("ollama", {})
|
||||
|
||||
print(f"language-model: {motor}")
|
||||
print(f"modelo: {ollama.get('model')}")
|
||||
print(f"thinking: {ollama.get('thinking')}")
|
||||
print(f"llamacpp: {guardado.get('llamacpp', {}).get('custom_args')} "
|
||||
f"· ctx {guardado.get('llamacpp', {}).get('ctx')}")
|
||||
|
||||
ok = (motor == "ollama"
|
||||
and ollama.get("model") == MODELO
|
||||
and ollama.get("thinking") is False)
|
||||
print("verificado" if ok else "FALLO: no quedo como se pidio")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
100
config/set_castellano.py
Normal file
100
config/set_castellano.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Pasa JARVIS a castellano: voz, idioma y personalidad.
|
||||
|
||||
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||
~/COFRE/CODERS/JARVIS/config/set_castellano.py
|
||||
|
||||
La voz es em_alex, la mas grave de las dos masculinas en español que trae
|
||||
Kokoro. Medido con las muestras de comparar_voces_es.py:
|
||||
|
||||
em_alex 129 Hz
|
||||
em_alex grave 117 Hz <- elegida
|
||||
em_santa 140 Hz
|
||||
em_santa grave 132 Hz
|
||||
|
||||
El descenso de tono no va aqui sino en la cadena de audio (tts.py), porque se
|
||||
aplica a la reproduccion y no a la seleccion de voz. Se ajusta con
|
||||
JARVIS_TTS_GRAVEDAD.
|
||||
|
||||
Sustituye la personalidad inglesa en vez de añadir otra: dos personalidades
|
||||
activas se pisan y el modelo acaba mezclando idiomas.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
from gi.repository import Gio
|
||||
|
||||
SCHEMA = "io.github.qwersyk.Newelle"
|
||||
VOZ = "em_alex"
|
||||
CLAVE = "jarvis_persona"
|
||||
|
||||
TEXTO = """Eres JARVIS, un asistente de voz que funciona entero en esta maquina.
|
||||
|
||||
Como hablas:
|
||||
- En castellano, siempre. Registro sobrio y contenido, sin efusividad.
|
||||
- Trata al usuario de usted y llamale SIEMPRE "señor". En la primera
|
||||
frase de cada respuesta, sin excepcion.
|
||||
- Se breve: una o dos frases salvo que te pidan detalle. Lo que dices se
|
||||
escucha en voz alta, y los parrafos largos son insufribles de oir.
|
||||
- No anuncies lo que vas a hacer. Hazlo y di como ha quedado.
|
||||
- Ironia seca de vez en cuando. Nunca servil, nunca efusivo.
|
||||
|
||||
Que dices:
|
||||
- Tienes acceso real a esta maquina. Antes que suponer, comprueba: si te
|
||||
preguntan por el disco, la memoria, los procesos o la grafica, ejecuta el
|
||||
comando y responde con la cifra de verdad.
|
||||
- Di los numeros redondeados y en palabras: "quedan 846 gigabytes", no
|
||||
"846.3 GiB disponibles en /dev/nvme0n1p2".
|
||||
- Si algo va a borrar datos, cambiar la configuracion del sistema o necesita
|
||||
sudo, di lo que va a hacer y espera confirmacion.
|
||||
- Si no lo sabes, dilo en una frase. No especules.
|
||||
- Ejemplos del tono: "Enseguida, señor." / "Quedan 774 gigabytes, señor." /
|
||||
"No he podido, señor: el disco esta desmontado."
|
||||
- Si ves algo raro en la maquina, mencionalo aunque no te pregunten."""
|
||||
|
||||
ENTRADA = {
|
||||
"key": CLAVE,
|
||||
"text": TEXTO,
|
||||
"title": "JARVIS",
|
||||
"description": ("Castellano, trata de señor, registro sobrio, respuestas "
|
||||
"breves para voz, comprueba en vez de suponer"),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
settings = Gio.Settings.new(SCHEMA)
|
||||
|
||||
# voz
|
||||
try:
|
||||
voces = json.loads(settings.get_string("tts-voice") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
voces = {}
|
||||
voces.setdefault("kokoro", {})["voice"] = VOZ
|
||||
settings.set_string("tts-voice", json.dumps(voces))
|
||||
|
||||
# personalidad, sustituyendo la que hubiera con la misma clave
|
||||
try:
|
||||
prompts = json.loads(settings.get_string("user-custom-prompts") or "[]")
|
||||
except json.JSONDecodeError:
|
||||
prompts = []
|
||||
prompts = [p for p in prompts if p.get("key") != CLAVE]
|
||||
prompts.append(ENTRADA)
|
||||
settings.set_string("user-custom-prompts", json.dumps(prompts))
|
||||
|
||||
Gio.Settings.sync()
|
||||
|
||||
# releer de gsettings, no fiarse de lo que acabamos de escribir
|
||||
voz = json.loads(settings.get_string("tts-voice")).get("kokoro", {}).get("voice")
|
||||
guardados = json.loads(settings.get_string("user-custom-prompts"))
|
||||
entrada = next((p for p in guardados if p.get("key") == CLAVE), None)
|
||||
|
||||
print(f"voz: {voz}")
|
||||
print(f"personalidad: {'castellano' if entrada and 'castellano' in entrada['text'] else '?'}")
|
||||
|
||||
ok = voz == VOZ and entrada is not None and entrada["text"] == TEXTO
|
||||
print("verificado" if ok else "FALLO: no quedo como se pidio")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
137
config/set_persona.py
Normal file
137
config/set_persona.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Registra la personalidad de JARVIS, con lo que sabe hacer incluido.
|
||||
|
||||
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||
~/COFRE/CODERS/JARVIS/config/set_persona.py
|
||||
|
||||
Por que asi y no con `gsettings` desde fuera: gsettings devuelve los valores
|
||||
con el escapado de GVariant, y este prompt lleva saltos de linea y comillas,
|
||||
asi que releerlo como JSON desde bash falla. Gio maneja los tipos de verdad.
|
||||
|
||||
## Por que el prompt lleva la lista de capacidades
|
||||
|
||||
Hasta ahora no la llevaba, y el efecto era exactamente el que describia el
|
||||
usuario: "duda de lo que puede hacer, no tiene claras sus capacidades". El
|
||||
catalogo tiene 150 ordenes preparadas, pero el modelo no las veia por ningun
|
||||
lado: solo leia "tienes acceso real a esta maquina" y, siendo un 4B, ante la
|
||||
duda decia que no podia. En el registro de frases sin entender hay una
|
||||
conversacion entera del usuario discutiendo con el:
|
||||
|
||||
"si que puedes reproducir archivos multimedia del sistema"
|
||||
"En acciones no tienes esta opcion de 'fine'"
|
||||
"Me deberias poder"
|
||||
|
||||
La lista se genera DESDE el catalogo, agrupada, para que no se quede vieja
|
||||
cuando se añadan acciones. No se listan las 150 una por una: son demasiadas
|
||||
para el contexto de un 4B y lo unico que hace falta es que sepa por donde va.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from gi.repository import Gio
|
||||
|
||||
SCHEMA = "io.github.qwersyk.Newelle"
|
||||
KEY = "user-custom-prompts"
|
||||
CLAVE = "jarvis_persona"
|
||||
|
||||
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json")
|
||||
|
||||
# Como se resume cada grupo. Escrito a mano porque el resumen bueno no sale de
|
||||
# juntar ids: sale de decir en una linea de que va el grupo.
|
||||
RESUMEN = {
|
||||
"sistema": "disco, memoria, temperatura, procesos, red, wifi, bateria, "
|
||||
"brillo, volumen, pantallas, capturas, abrir terminal y poner videos",
|
||||
"ficheros": "buscar ficheros por nombre, buscar texto dentro de ellos, "
|
||||
"leerlos, abrir carpetas y ver cuanto ocupan",
|
||||
"ventanas": "mover ventanas entre pantallas, colocarlas en mitades o cuartos, "
|
||||
"maximizar y pasarlas de escritorio",
|
||||
"firefox": "abrir webs y buscar en internet, YouTube, Wikipedia, GitHub, "
|
||||
"mapas y traductor",
|
||||
"pentest": "escanear la red, mirar puertos, resolver dominios y whois",
|
||||
"oasis": "estado de Oasis: pubs, conexiones y tamaño",
|
||||
}
|
||||
|
||||
|
||||
def capacidades() -> str:
|
||||
try:
|
||||
with open(CATALOGO, encoding="utf-8") as f:
|
||||
acc = json.load(f)["acciones"]
|
||||
except Exception:
|
||||
return ""
|
||||
grupos = {}
|
||||
for a in acc:
|
||||
grupos.setdefault(a.get("grupo", "otras"), []).append(a)
|
||||
lineas = []
|
||||
for g, items in sorted(grupos.items(), key=lambda x: -len(x[1])):
|
||||
texto = RESUMEN.get(g)
|
||||
if texto:
|
||||
lineas.append(f"- {texto} ({len(items)} ordenes)")
|
||||
return "\n".join(lineas)
|
||||
|
||||
|
||||
TEXTO = """Eres JARVIS, un asistente de voz que funciona entero en esta maquina.
|
||||
|
||||
Como hablas:
|
||||
- En castellano, siempre. Registro sobrio y contenido, sin efusividad.
|
||||
- Trata al usuario de usted y llamale SIEMPRE "señor". En la primera
|
||||
frase de cada respuesta, sin excepcion.
|
||||
- Se breve: una o dos frases salvo que te pidan detalle. Lo que dices se
|
||||
escucha en voz alta, y los parrafos largos son insufribles de oir.
|
||||
- No anuncies lo que vas a hacer. Hazlo y di como ha quedado.
|
||||
- Ironia seca de vez en cuando. Nunca servil, nunca efusivo.
|
||||
|
||||
LO QUE SABES HACER, y lo haces de verdad:
|
||||
{capacidades}
|
||||
|
||||
Esas ordenes se disparan SOLAS en cuanto el usuario las dice: cuando te llega
|
||||
una peticion de esa lista, lo mas probable es que ya se haya ejecutado. No
|
||||
digas que no puedes hacerlas.
|
||||
|
||||
Y para todo lo demas tienes una TERMINAL REAL en esta maquina, fuera de
|
||||
cualquier contenedor: ves todos los ficheros del usuario y puedes ejecutar
|
||||
cualquier programa instalado. Si te piden algo que no esta en la lista,
|
||||
ejecutalo con la herramienta de comandos en vez de decir que no sabes.
|
||||
|
||||
NUNCA digas que estas en un contenedor, en un entorno aislado o en una
|
||||
maquina virtual, ni que no tienes permisos o acceso al sistema. Lo tienes.
|
||||
Si un comando falla, di QUE fallo y por que, no que no puedes.
|
||||
|
||||
Si no entiendes una orden, no te disculpes en tres frases: di en una que no la
|
||||
has cogido y, si te suena a algo de la lista, sugiere como pedirlo.
|
||||
|
||||
Que dices:
|
||||
- Antes que suponer, comprueba: si te preguntan por el disco, la memoria, los
|
||||
procesos o la grafica, ejecuta el comando y responde con la cifra de verdad.
|
||||
- Di los numeros redondeados y en palabras: "quedan 846 gigabytes", no
|
||||
"846.3 GiB disponibles en /dev/nvme0n1p2".
|
||||
- Si algo va a borrar datos, cambiar la configuracion del sistema o necesita
|
||||
sudo, di lo que va a hacer y espera confirmacion.
|
||||
- Si no lo sabes, dilo en una frase. No especules.
|
||||
- Ejemplos del tono: "Enseguida, señor." / "Quedan 774 gigabytes, señor." /
|
||||
"No he podido, señor: el disco esta desmontado."
|
||||
- Si ves algo raro en la maquina, mencionalo aunque no te pregunten."""
|
||||
|
||||
|
||||
def main():
|
||||
texto = TEXTO.format(capacidades=capacidades() or "- (catalogo no disponible)")
|
||||
settings = Gio.Settings.new(SCHEMA)
|
||||
try:
|
||||
prompts = json.loads(settings.get_string(KEY) or "[]")
|
||||
except json.JSONDecodeError:
|
||||
prompts = []
|
||||
prompts = [p for p in prompts if p.get("key") != CLAVE]
|
||||
prompts.append({"key": CLAVE, "title": "JARVIS",
|
||||
"description": "Personalidad de JARVIS, con la lista de lo "
|
||||
"que sabe hacer generada desde el catalogo",
|
||||
"text": texto,
|
||||
"editable": True, "show_in_settings": True})
|
||||
settings.set_string(KEY, json.dumps(prompts))
|
||||
print(f"personalidad registrada: {len(texto)} caracteres, "
|
||||
f"{len(capacidades().splitlines())} grupos de capacidades")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
60
config/set_ruido.py
Normal file
60
config/set_ruido.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Endurece la escucha para que no se dispare con ruido de fuera.
|
||||
|
||||
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||
~/COFRE/CODERS/JARVIS/config/set_ruido.py [--suave]
|
||||
|
||||
NO es reconocimiento de hablante: Newelle no distingue quien habla, no hay
|
||||
nada de eso en el codigo. Esto solo sube el liston de que se considera voz.
|
||||
Para que responda unicamente a una persona haria falta un modelo de huella
|
||||
vocal (ECAPA, Resemblyzer) delante del STT, que es otro proyecto.
|
||||
|
||||
Las tres capas, de menos a mas efectiva:
|
||||
|
||||
1. la palabra clave, que ya filtra casi todo
|
||||
2. estos umbrales
|
||||
3. supresion de ruido en PipeWire (RNNoise), antes de que el audio llegue
|
||||
a la app; esa vale para todo el sistema y no cuesta CPU a Newelle
|
||||
|
||||
Valores por defecto de Newelle: energia 300, VAD 1.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from gi.repository import Gio
|
||||
|
||||
SCHEMA = "io.github.qwersyk.Newelle"
|
||||
|
||||
# vad-aggressiveness va de 0 a 3: cuanto descarta lo que no suena a voz.
|
||||
DURO = {
|
||||
"wakeword-energy-threshold": 800, # por debajo de esto ni escucha
|
||||
"wakeword-vad-aggressiveness": 3,
|
||||
}
|
||||
SUAVE = {
|
||||
"wakeword-energy-threshold": 500,
|
||||
"wakeword-vad-aggressiveness": 2,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
perfil = SUAVE if "--suave" in sys.argv else DURO
|
||||
settings = Gio.Settings.new(SCHEMA)
|
||||
|
||||
print("antes:")
|
||||
for clave in perfil:
|
||||
print(f" {clave} = {settings.get_int(clave)}")
|
||||
|
||||
for clave, valor in perfil.items():
|
||||
settings.set_int(clave, valor)
|
||||
Gio.Settings.sync()
|
||||
|
||||
print("despues:")
|
||||
for clave in perfil:
|
||||
print(f" {clave} = {settings.get_int(clave)}")
|
||||
|
||||
ok = all(settings.get_int(c) == v for c, v in perfil.items())
|
||||
print("verificado" if ok else "FALLO: no quedo como se pidio")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
74
config/set_voz_piper.py
Normal file
74
config/set_voz_piper.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Pone la voz de Piper: castellano de España, grave.
|
||||
|
||||
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||
~/COFRE/CODERS/JARVIS/config/set_voz_piper.py
|
||||
|
||||
Por que no Kokoro: no tiene ni una voz de España. Sus dos masculinas en
|
||||
español, em_alex y em_santa, suenan latinoamericanas. Piper si las tiene, es
|
||||
libre (MIT, voces con licencias abiertas) y corre local.
|
||||
|
||||
Medido con la misma frase:
|
||||
|
||||
em_alex (Kokoro, latino) 129 Hz
|
||||
es_ES-carlfm-x_low 118 Hz calidad baja, 16 kHz
|
||||
es_ES-sharvard-medium 127 Hz
|
||||
es_ES-davefx-medium 128 Hz
|
||||
es_ES-sharvard-medium + tono 106 Hz <- elegida
|
||||
|
||||
106 Hz cae de lleno en el rango de voz masculina grave (85-110). Se llega ahi
|
||||
con rubberband en el envoltorio, no con la cadena de la app: alli el descenso
|
||||
usa asetrate, que depende de la frecuencia de muestreo y esta calculado para
|
||||
los 24 kHz de Kokoro, no para los 22050 de Piper.
|
||||
|
||||
Se apaga por tanto la bajada de tono de la app, para no aplicarla dos veces.
|
||||
El resto del tratamiento (paso alto, ecualizador, compresor, eco) se queda:
|
||||
es independiente de la frecuencia y es lo que da el aire de "voz en la sala".
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from gi.repository import Gio
|
||||
|
||||
SCHEMA = "io.github.qwersyk.Newelle"
|
||||
ENVOLTORIO = os.path.expanduser(
|
||||
"~/COFRE/CODERS/JARVIS/config/jarvis-piper.sh")
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(ENVOLTORIO):
|
||||
print(f"FALLO: no esta {ENVOLTORIO}")
|
||||
return 1
|
||||
|
||||
settings = Gio.Settings.new(SCHEMA)
|
||||
|
||||
try:
|
||||
voces = json.loads(settings.get_string("tts-voice") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
voces = {}
|
||||
|
||||
# {0} es el fichero y {1} el texto; el handler los entrecomilla, asi que
|
||||
# aqui van sin comillas alrededor
|
||||
voces["custom_command"] = {"command": f"{ENVOLTORIO} {{0}} {{1}}"}
|
||||
settings.set_string("tts-voice", json.dumps(voces))
|
||||
settings.set_string("tts", "custom_command")
|
||||
Gio.Settings.sync()
|
||||
|
||||
motor = settings.get_string("tts")
|
||||
orden = json.loads(settings.get_string("tts-voice")).get(
|
||||
"custom_command", {}).get("command", "")
|
||||
|
||||
print(f"motor de voz: {motor}")
|
||||
print(f"orden: {orden}")
|
||||
|
||||
ok = motor == "custom_command" and ENVOLTORIO in orden
|
||||
print("verificado" if ok else "FALLO: no quedo como se pidio")
|
||||
if ok:
|
||||
print("\nrecuerda apagar la bajada de tono de la app, que ya la hace el envoltorio:")
|
||||
print(" flatpak override --user --env=JARVIS_TTS_GRAVEDAD=1.0 io.github.qwersyk.Newelle")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
290
config/ventanas.sh
Executable file
290
config/ventanas.sh
Executable file
|
|
@ -0,0 +1,290 @@
|
|||
#!/usr/bin/env bash
|
||||
# Mover ventanas y repartirlas por las pantallas. El motor de las acciones del
|
||||
# grupo "ventanas": ellas solo llaman aqui con un verbo y un argumento.
|
||||
#
|
||||
# Por que un script del host y no un modulo dentro del flatpak: las acciones se
|
||||
# ejecutan como comandos de shell, y toda la logica en Python obligaria a
|
||||
# recompilar el flatpak por cada ajuste. Aqui se edita y ya esta, igual que los
|
||||
# envoltorios de voz.
|
||||
#
|
||||
# Requiere X11 (en Wayland no hay forma de mover ventanas ajenas) y xdotool.
|
||||
#
|
||||
# ventanas.sh pantallas lista las pantallas
|
||||
# ventanas.sh listar lista las ventanas movibles
|
||||
# ventanas.sh mover <ventana> <pantalla> la manda a esa pantalla
|
||||
# ventanas.sh zona <ventana> <zona> mitad izquierda, cuarto, centro...
|
||||
# ventanas.sh escritorio <ventana> <n> la manda a ese escritorio virtual
|
||||
# ventanas.sh maximiza <ventana> / restaura / minimiza
|
||||
# ventanas.sh alias <pantalla> <nombre> le pone nombre a una salida
|
||||
#
|
||||
# <ventana> puede ser: parte del titulo o de la clase ("terminal", "firefox"),
|
||||
# o "esta"/"activa" para la que tiene el foco.
|
||||
# <pantalla> puede ser: un alias ("tele"), una salida ("HDMI-1"), un numero
|
||||
# ("2") o una posicion ("derecha", "izquierda", "arriba", "abajo", "centro").
|
||||
set -uo pipefail
|
||||
|
||||
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
|
||||
ALIAS_FICHERO=${JARVIS_ALIAS_PANTALLAS:-$HOME/.config/jarvis-pantallas.json}
|
||||
|
||||
hay_x11() { [ -n "${DISPLAY:-}" ] && command -v xdotool >/dev/null; }
|
||||
hay_x11 || { echo "no hay X11 o falta xdotool"; exit 1; }
|
||||
|
||||
# ---------------------------------------------------------------- pantallas
|
||||
|
||||
# Una linea por pantalla: salida<TAB>x<TAB>y<TAB>ancho<TAB>alto<TAB>numero
|
||||
#
|
||||
# Se usa `xrandr --listmonitors`, que da la geometria ya resuelta (con el
|
||||
# escalado aplicado); `--query` daria los modos, que no es lo que hace falta.
|
||||
# El numero es el orden de izquierda a derecha, no el que diga xrandr: es lo
|
||||
# que espera cualquiera que diga "la pantalla dos".
|
||||
pantallas_crudas() {
|
||||
xrandr --listmonitors 2>/dev/null | tail -n +2 | while read -r _ _ geo salida; do
|
||||
# geo viene como 1920/344x1080/194+0+0
|
||||
local an al x y
|
||||
an=${geo%%/*}; geo=${geo#*x}
|
||||
al=${geo%%/*}; geo=${geo#*+}
|
||||
x=${geo%%+*}; y=${geo##*+}
|
||||
printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"
|
||||
done | sort -t$'\t' -k2 -n | awk -F'\t' '{printf "%s\t%s\t%s\t%s\t%s\t%d\n", $1,$2,$3,$4,$5,NR}'
|
||||
}
|
||||
|
||||
alias_de() {
|
||||
[ -f "$ALIAS_FICHERO" ] || { echo ""; return; }
|
||||
python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
print(json.load(open('$ALIAS_FICHERO')).get(sys.argv[1], ''))
|
||||
except Exception:
|
||||
print('')
|
||||
" "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# izquierda / derecha / arriba / abajo / centro, deducido de donde cae el
|
||||
# centro de esa pantalla respecto al centro de todas.
|
||||
posicion_de() {
|
||||
local x=$1 an=$2 y=$3 al=$4
|
||||
local n; n=$(pantallas_crudas | wc -l)
|
||||
[ "$n" -le 1 ] && { echo "unica"; return; }
|
||||
local minx maxx miny maxy
|
||||
minx=$(pantallas_crudas | awk -F'\t' 'NR==1||$2<m{m=$2}END{print m+0}')
|
||||
maxx=$(pantallas_crudas | awk -F'\t' '{v=$2+$4; if(v>m)m=v}END{print m+0}')
|
||||
miny=$(pantallas_crudas | awk -F'\t' 'NR==1||$3<m{m=$3}END{print m+0}')
|
||||
maxy=$(pantallas_crudas | awk -F'\t' '{v=$3+$5; if(v>m)m=v}END{print m+0}')
|
||||
local cx=$(( x + an/2 )) cy=$(( y + al/2 ))
|
||||
local mx=$(( (minx+maxx)/2 )) my=$(( (miny+maxy)/2 ))
|
||||
local anchura=$(( maxx-minx )) altura=$(( maxy-miny ))
|
||||
# el reparto manda por el eje donde hay mas separacion
|
||||
if [ $(( anchura - altura )) -gt 0 ]; then
|
||||
[ "$cx" -lt $(( mx - anchura/8 )) ] && { echo izquierda; return; }
|
||||
[ "$cx" -gt $(( mx + anchura/8 )) ] && { echo derecha; return; }
|
||||
else
|
||||
[ "$cy" -lt $(( my - altura/8 )) ] && { echo arriba; return; }
|
||||
[ "$cy" -gt $(( my + altura/8 )) ] && { echo abajo; return; }
|
||||
fi
|
||||
echo centro
|
||||
}
|
||||
|
||||
listar_pantallas() {
|
||||
local salida x y an al num pos ali
|
||||
while IFS=$'\t' read -r salida x y an al num; do
|
||||
pos=$(posicion_de "$x" "$an" "$y" "$al")
|
||||
ali=$(alias_de "$salida")
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$num" "$salida" "${ali:--}" "$pos" "$x" "$y" "$an" "$al"
|
||||
done < <(pantallas_crudas)
|
||||
}
|
||||
|
||||
# Resuelve lo que se dice a una pantalla. Orden a proposito: primero lo que el
|
||||
# usuario ha nombrado el mismo, que es lo mas especifico, y la posicion la
|
||||
# ultima porque es la mas facil de confundir.
|
||||
resuelve_pantalla() {
|
||||
local q; q=$(echo "$1" | tr 'A-ZÁÉÍÓÚ' 'a-záéíóú')
|
||||
local num salida ali pos x y an al
|
||||
# 1. alias
|
||||
while IFS=$'\t' read -r num salida ali pos x y an al; do
|
||||
[ "$ali" != "-" ] && [ "$(echo "$ali" | tr 'A-Z' 'a-z')" = "$q" ] && {
|
||||
printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; }
|
||||
done < <(listar_pantallas)
|
||||
# 2. salida exacta o su prefijo (hdmi, dp)
|
||||
while IFS=$'\t' read -r num salida ali pos x y an al; do
|
||||
local s; s=$(echo "$salida" | tr 'A-Z' 'a-z')
|
||||
[ "$s" = "$q" ] || [ "${s%%-*}" = "$q" ] && {
|
||||
printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; }
|
||||
done < <(listar_pantallas)
|
||||
# 3. numero, en cifra o en letra
|
||||
local n=""
|
||||
case "$q" in
|
||||
1|una|uno|primera) n=1;; 2|dos|segunda) n=2;; 3|tres|tercera) n=3;; 4|cuatro|cuarta) n=4;;
|
||||
esac
|
||||
if [ -n "$n" ]; then
|
||||
while IFS=$'\t' read -r num salida ali pos x y an al; do
|
||||
[ "$num" = "$n" ] && { printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; }
|
||||
done < <(listar_pantallas)
|
||||
fi
|
||||
# 4. posicion
|
||||
while IFS=$'\t' read -r num salida ali pos x y an al; do
|
||||
[ "$pos" = "$q" ] && { printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; }
|
||||
done < <(listar_pantallas)
|
||||
return 1
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------- ventanas
|
||||
|
||||
# El area util de la pantalla que contiene un punto: hay que descontar barras
|
||||
# y paneles, o las ventanas acaban debajo de la barra de GNOME.
|
||||
area_util() {
|
||||
local v; v=$(xprop -root _NET_WORKAREA 2>/dev/null | sed 's/.*= //' | tr -d ' ')
|
||||
if [ -n "$v" ]; then
|
||||
echo "$(echo "$v" | cut -d, -f1) $(echo "$v" | cut -d, -f2) $(echo "$v" | cut -d, -f3) $(echo "$v" | cut -d, -f4)"
|
||||
else
|
||||
set -- $(xdotool getdisplaygeometry); echo "0 0 $1 $2"
|
||||
fi
|
||||
}
|
||||
|
||||
# Se descartan las que no son ventanas de verdad: el guardian de mutter, el
|
||||
# propio shell y las de 1x1 que usan algunos programas como buzon.
|
||||
listar_ventanas() {
|
||||
local w n cls x y an al
|
||||
for w in $(xdotool search --onlyvisible --name "." 2>/dev/null); do
|
||||
n=$(xdotool getwindowname "$w" 2>/dev/null) || continue
|
||||
[ -z "$n" ] && continue
|
||||
case "$n" in "mutter guard window"|"gnome-shell") continue;; esac
|
||||
eval "$(xdotool getwindowgeometry --shell "$w" 2>/dev/null)" || continue
|
||||
[ "${WIDTH:-0}" -lt 50 ] && continue
|
||||
cls=$(xprop -id "$w" WM_CLASS 2>/dev/null | sed 's/.*, *//' | tr -d '"')
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$w" "$n" "${cls:--}" "$X" "$Y" "$WIDTH" "$HEIGHT"
|
||||
done
|
||||
}
|
||||
|
||||
# Devuelve el id, o varios si hay empate: quien llama decide si preguntar.
|
||||
resuelve_ventana() {
|
||||
local q; q=$(echo "$1" | tr 'A-ZÁÉÍÓÚ' 'a-záéíóú')
|
||||
case "$q" in
|
||||
esta|activa|"esta ventana"|"la activa"|"ventana activa")
|
||||
xdotool getactivewindow 2>/dev/null; return;;
|
||||
esac
|
||||
listar_ventanas | awk -F'\t' -v q="$q" '
|
||||
BEGIN{IGNORECASE=1}
|
||||
{ t=tolower($2); c=tolower($3);
|
||||
if (index(t,q) || index(c,q)) print $1"\t"$2 }'
|
||||
}
|
||||
|
||||
geo_de() { eval "$(xdotool getwindowgeometry --shell "$1" 2>/dev/null)"; echo "$X $Y $WIDTH $HEIGHT"; }
|
||||
|
||||
# Maximizar NO se hace con `xdotool windowstate --add MAXIMIZED`: comprobado,
|
||||
# en mutter no hace nada. Lo que funciona es activar la ventana y mandar el
|
||||
# atajo del escritorio. Y una ventana maximizada IGNORA windowmove, asi que
|
||||
# hay que desmaximizar antes de mover.
|
||||
desmaximiza() { xdotool windowactivate "$1" 2>/dev/null; sleep 0.2; xdotool key super+Down 2>/dev/null; sleep 0.4; }
|
||||
maximiza() { xdotool windowactivate "$1" 2>/dev/null; sleep 0.2; xdotool key super+Up 2>/dev/null; sleep 0.4; }
|
||||
|
||||
coloca() { # coloca <id> <x> <y> <ancho> <alto>
|
||||
desmaximiza "$1"
|
||||
xdotool windowsize "$1" "$4" "$5" 2>/dev/null
|
||||
xdotool windowmove "$1" "$2" "$3" 2>/dev/null
|
||||
sleep 0.3
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------- api
|
||||
|
||||
# Imprime el id, o una frase que se pueda decir en voz alta. Nada de
|
||||
# marcadores tipo NINGUNA/VARIAS: lo que salga de aqui puede acabar en el
|
||||
# altavoz tal cual, asi que tiene que ser castellano.
|
||||
una_ventana() {
|
||||
local r; r=$(resuelve_ventana "$1")
|
||||
local n; n=$(echo "$r" | grep -c . )
|
||||
if [ "$n" -eq 0 ]; then
|
||||
echo "No encuentro ninguna ventana que sea $1, señor."; return 1
|
||||
fi
|
||||
if [ "$n" -gt 1 ]; then
|
||||
# Ambiguo: se enumeran y decide la persona. Elegir una al azar es lo
|
||||
# peor que puede hacer algo que mueve ventanas ajenas.
|
||||
printf 'Hay %s que encajan, señor: %s. Digame cual.\n' "$n" \
|
||||
"$(echo "$r" | cut -f2 | head -4 | paste -sd'|' - | sed 's/|/; /g')"
|
||||
return 2
|
||||
fi
|
||||
echo "$r" | cut -f1
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
pantallas)
|
||||
listar_pantallas | while IFS=$'\t' read -r num salida ali pos x y an al; do
|
||||
printf 'pantalla %s: %s%s, %s, %sx%s\n' "$num" "$salida" \
|
||||
"$([ "$ali" != "-" ] && echo " ($ali)")" "$pos" "$an" "$al"
|
||||
done
|
||||
;;
|
||||
|
||||
listar)
|
||||
listar_ventanas | while IFS=$'\t' read -r w n cls x y an al; do
|
||||
printf '%s (%s) %sx%s en %s,%s\n' "$n" "$cls" "$an" "$al" "$x" "$y"
|
||||
done
|
||||
;;
|
||||
|
||||
alias)
|
||||
salida=$(resuelve_pantalla "${2:-}" | cut -f1)
|
||||
[ -n "$salida" ] || { echo "No se cual es esa pantalla, señor"; exit 1; }
|
||||
python3 - "$ALIAS_FICHERO" "$salida" "${3:-}" <<'FIN'
|
||||
import json, os, sys
|
||||
fichero, salida, nombre = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
os.makedirs(os.path.dirname(fichero), exist_ok=True)
|
||||
try:
|
||||
d = json.load(open(fichero))
|
||||
except Exception:
|
||||
d = {}
|
||||
d[salida] = nombre
|
||||
json.dump(d, open(fichero, "w"), ensure_ascii=False, indent=2)
|
||||
print(f"{salida} se llama ahora {nombre}")
|
||||
FIN
|
||||
;;
|
||||
|
||||
mover)
|
||||
w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }
|
||||
destino=$(resuelve_pantalla "${3:-}") || { echo "No se cual es esa pantalla, señor"; exit 1; }
|
||||
IFS=$'\t' read -r salida px py pan pal <<< "$destino"
|
||||
read -r ax ay aan aal <<< "$(area_util)"
|
||||
# se centra en la pantalla destino, a tres cuartos de su tamaño
|
||||
an=$(( pan * 3 / 4 )); al=$(( pal * 3 / 4 ))
|
||||
x=$(( px + (pan - an) / 2 )); y=$(( py + (pal - al) / 2 ))
|
||||
[ "$y" -lt "$ay" ] && y=$ay
|
||||
coloca "$w" "$x" "$y" "$an" "$al"
|
||||
# "eDP-1" en voz alta se lee letra por letra: se dice el mote si lo tiene,
|
||||
# y si no el numero, que es lo que entiende cualquiera.
|
||||
comodo=$(listar_pantallas | awk -F'\t' -v s="$salida" '$2==s {print ($3!="-") ? $3 : "numero " $1}')
|
||||
echo "Movida a la pantalla ${comodo:-$salida}"
|
||||
;;
|
||||
|
||||
zona)
|
||||
w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }
|
||||
read -r ax ay aan aal <<< "$(area_util)"
|
||||
m2=$(( aan / 2 )); h2=$(( aal / 2 ))
|
||||
case "$(echo "${3:-}" | tr 'A-ZÁÉÍÓÚ' 'a-záéíóú')" in
|
||||
izquierda|"mitad izquierda") coloca "$w" "$ax" "$ay" "$m2" "$aal"; echo "Ya la tiene a la izquierda";;
|
||||
derecha|"mitad derecha") coloca "$w" $((ax+m2)) "$ay" "$m2" "$aal"; echo "Ya la tiene a la derecha";;
|
||||
arriba) coloca "$w" "$ax" "$ay" "$aan" "$h2"; echo "Ya la tiene arriba";;
|
||||
abajo) coloca "$w" "$ax" $((ay+h2)) "$aan" "$h2"; echo "Ya la tiene abajo";;
|
||||
"arriba izquierda") coloca "$w" "$ax" "$ay" "$m2" "$h2"; echo "Colocada arriba a la izquierda";;
|
||||
"arriba derecha") coloca "$w" $((ax+m2)) "$ay" "$m2" "$h2"; echo "Colocada arriba a la derecha";;
|
||||
"abajo izquierda") coloca "$w" "$ax" $((ay+h2)) "$m2" "$h2"; echo "Colocada abajo a la izquierda";;
|
||||
"abajo derecha") coloca "$w" $((ax+m2)) $((ay+h2)) "$m2" "$h2"; echo "Colocada abajo a la derecha";;
|
||||
centro|centrada) coloca "$w" $((ax+aan/4)) $((ay+aal/4)) $((aan/2)) $((aal/2)); echo "Ya la tiene centrada";;
|
||||
*) echo "No conozco esa zona, señor"; exit 1;;
|
||||
esac
|
||||
;;
|
||||
|
||||
maximiza) w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }; maximiza "$w"; echo "Maximizada";;
|
||||
restaura) w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }; desmaximiza "$w"; echo "Restaurada a su tamaño";;
|
||||
minimiza) w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }
|
||||
xdotool windowminimize "$w" 2>/dev/null; echo "Minimizada";;
|
||||
|
||||
escritorio)
|
||||
w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }
|
||||
n=${3:-1}
|
||||
case "$n" in una|uno|primero) n=1;; dos|segundo) n=2;; tres|tercero) n=3;; cuatro) n=4;; esac
|
||||
total=$(xdotool get_num_desktops 2>/dev/null || echo 1)
|
||||
[ "$n" -ge 1 ] 2>/dev/null && [ "$n" -le "$total" ] || { echo "no hay escritorio $n, solo $total"; exit 1; }
|
||||
xdotool set_desktop_for_window "$w" $(( n - 1 )) 2>/dev/null
|
||||
echo "Movida al escritorio $n"
|
||||
;;
|
||||
|
||||
*) sed -n '2,26p' "$0"; exit 1;;
|
||||
esac
|
||||
8
config/whisper-server-cuda.sh
Executable file
8
config/whisper-server-cuda.sh
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
#!/usr/bin/env bash
|
||||
# Envoltorio: el whisper-server de Newelle, pero el nuestro compilado con CUDA.
|
||||
# Newelle lo invoca por flatpak-spawn --host esperando 'whisper-server' en el
|
||||
# PATH; aqui se fija el entorno de CUDA (que el binario necesita para ver las
|
||||
# libs) y se pasa a nuestra version con GPU. Los argumentos (-m, --port, -l...)
|
||||
# los pone Newelle y se reenvian tal cual.
|
||||
export LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH:-}
|
||||
exec "$HOME/COFRE/CODERS/JARVIS/voz/whisper-cuda/whisper.cpp/build/bin/whisper-server" "$@"
|
||||
46
config/whisper-server-envoltorio.sh
Executable file
46
config/whisper-server-envoltorio.sh
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env bash
|
||||
# Envoltorio: el whisper-server de Newelle, pero el nuestro compilado con CUDA.
|
||||
# Newelle lo invoca por flatpak-spawn --host esperando 'whisper-server' en el
|
||||
# PATH; aqui se fija el entorno de CUDA (que el binario necesita para ver las
|
||||
# libs) y se pasa a nuestra version con GPU. Los argumentos (-m, --port, -l...)
|
||||
# los pone Newelle y se reenvian tal cual.
|
||||
#
|
||||
# Copia versionada: ~/COFRE/CODERS/JARVIS/config/whisper-server-envoltorio.sh
|
||||
export LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH:-}
|
||||
|
||||
BINARIO="$HOME/COFRE/CODERS/JARVIS/voz/whisper-cuda/whisper.cpp/build/bin/whisper-server"
|
||||
REGISTRO="$HOME/COFRE/CODERS/JARVIS/voz/whisper-arranques.log"
|
||||
|
||||
# Cuanta VRAM libre exigimos para arrancar en GPU. El modelo small ocupa 757 MiB
|
||||
# en reposo pero llega a 839 en el pico de una transcripcion: pedir solo lo que
|
||||
# ocupa al cargar es justo el error que hacia que cargase bien y muriese al
|
||||
# hablarle, con un "Error recognizing file with server" que no explicaba nada.
|
||||
# 950 deja holgura para el pico y para el contexto de CUDA.
|
||||
MINIMO_MIB=${JARVIS_WHISPER_MIN_VRAM:-950}
|
||||
|
||||
# Barrer el servidor anterior antes de levantar el nuestro.
|
||||
#
|
||||
# Newelle lo lanza por flatpak-spawn --host, asi que el proceso vive FUERA del
|
||||
# sandbox y no se entera de que la app se cerro: cada vez que se reinicia
|
||||
# JARVIS queda un whisper-server huerfano agarrado a 757 MiB de la tarjeta.
|
||||
# Dos o tres reinicios y ya no cabe el modelo del asistente, con el sintoma
|
||||
# repartido —a veces va, a veces no— segun cuantos hubiera quedado sueltos.
|
||||
#
|
||||
# Se comparan PIDs y no patrones: un `pkill -f whisper-server` desde aqui se
|
||||
# mataria a si mismo, que este script tiene ese nombre.
|
||||
for otro in $(pgrep -x whisper-server 2>/dev/null); do
|
||||
[ "$otro" = "$$" ] && continue
|
||||
kill "$otro" 2>/dev/null
|
||||
done
|
||||
|
||||
libre=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$libre" ] && [ "$libre" -lt "$MINIMO_MIB" ] 2>/dev/null; then
|
||||
# Sin sitio: mejor transcribir en procesador que abortar. Es mas lento, pero
|
||||
# responde; abortar deja la app muda y sin decir por que.
|
||||
echo "$(date '+%F %T') GPU con solo ${libre} MiB libres (<${MINIMO_MIB}): al procesador" >> "$REGISTRO"
|
||||
exec "$BINARIO" --no-gpu "$@"
|
||||
fi
|
||||
|
||||
echo "$(date '+%F %T') GPU con ${libre:-?} MiB libres: en tarjeta" >> "$REGISTRO"
|
||||
exec "$BINARIO" "$@"
|
||||
150
config/wiki_ingesta.py
Normal file
150
config/wiki_ingesta.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Descarga articulos de Wikipedia en espanol sobre los temas de JARVIS y los
|
||||
deja como .txt en la carpeta de documentos del RAG. Luego, construir_indice.py
|
||||
los vectoriza. Corre en el host (solo stdlib + red), sin sonido ni GPU.
|
||||
|
||||
python3 config/wiki_ingesta.py # descarga
|
||||
# despues, dentro del flatpak:
|
||||
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||
~/COFRE/CODERS/JARVIS/config/construir_indice.py
|
||||
|
||||
Es aditivo: agrega ficheros a la carpeta, no borra lo que ya haya.
|
||||
"""
|
||||
import os, json, time, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
API = "https://es.wikipedia.org/w/api.php"
|
||||
UA = "JARVIS-local-RAG/1.0 (asistente de voz local; contacto: local)"
|
||||
DOCS = os.path.expanduser(
|
||||
"~/.var/app/io.github.qwersyk.Newelle/config/models/documents")
|
||||
|
||||
# Por tema: semillas seguras + una busqueda que amplia con articulos afines.
|
||||
TEMAS = {
|
||||
"tecnologia": {
|
||||
"buscar": ["tecnología", "informática", "hardware", "red informática",
|
||||
"inteligencia artificial", "sistema operativo"],
|
||||
"semillas": ["Tecnología", "Informática", "Internet", "Protocolo de red",
|
||||
"Microprocesador", "Computación en la nube", "Base de datos"],
|
||||
},
|
||||
"criptografia": {
|
||||
"buscar": ["criptografía", "cifrado", "criptografía asimétrica"],
|
||||
"semillas": ["Criptografía", "Cifrado", "RSA", "Advanced Encryption Standard",
|
||||
"Función hash criptográfica", "Firma digital",
|
||||
"Diffie-Hellman", "Pretty Good Privacy", "Transport Layer Security",
|
||||
"Criptografía de curva elíptica", "Cadena de bloques",
|
||||
"Bitcoin", "Criptomoneda"],
|
||||
},
|
||||
"software_libre": {
|
||||
"buscar": ["software libre", "código abierto", "historia de Linux"],
|
||||
"semillas": ["Software libre", "GNU", "Richard Stallman", "GNU/Linux",
|
||||
"Linus Torvalds", "Free Software Foundation", "Licencia pública general de GNU",
|
||||
"Código abierto", "Debian", "Unix", "Kernel Linux",
|
||||
"Movimiento del software libre"],
|
||||
},
|
||||
"hacking": {
|
||||
"buscar": ["seguridad informática", "hacker", "ciberseguridad"],
|
||||
"semillas": ["Hacker", "Seguridad informática", "Hacker (seguridad informática)",
|
||||
"Malware", "Exploit", "Ingeniería social (seguridad informática)",
|
||||
"Phreaking", "Kevin Mitnick", "Anonymous (colectivo)",
|
||||
"Prueba de penetración", "Vulnerabilidad (informática)",
|
||||
"Cortafuegos (informática)", "Ataque de denegación de servicio"],
|
||||
},
|
||||
"clima": {
|
||||
"buscar": ["cambio climático", "calentamiento global", "clima"],
|
||||
"semillas": ["Cambio climático", "Calentamiento global", "Efecto invernadero",
|
||||
"Gas de efecto invernadero", "Protocolo de Kioto",
|
||||
"Acuerdo de París", "IPCC", "Clima", "Dióxido de carbono",
|
||||
"El Niño", "Nivel del mar", "Energía renovable"],
|
||||
},
|
||||
"conflictos_espana": {
|
||||
"buscar": ["guerra en España", "conflicto España historia"],
|
||||
"semillas": ["Guerra civil española", "Transición española",
|
||||
"Guerra de la Independencia Española", "Guerras carlistas",
|
||||
"Reconquista", "Dictadura de Francisco Franco",
|
||||
"Segunda República Española", "Movimiento 15-M",
|
||||
"Conflicto vasco", "Guerra de Sucesión Española"],
|
||||
},
|
||||
}
|
||||
|
||||
BUSCAR_POR_CONSULTA = 15 # articulos extra por cada busqueda
|
||||
PAUSA = 0.8 # cortesia con la API (subida tras el 429)
|
||||
|
||||
|
||||
def _get(params, reintentos=5):
|
||||
params = {**params, "format": "json"}
|
||||
url = API + "?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||
espera = 2.0
|
||||
for intento in range(reintentos):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.load(r)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 429 and intento < reintentos - 1:
|
||||
# Wikipedia nos frena: esperar cada vez mas (backoff)
|
||||
time.sleep(espera)
|
||||
espera *= 2
|
||||
continue
|
||||
raise
|
||||
raise RuntimeError("agotados los reintentos")
|
||||
|
||||
|
||||
def titulos_de_busqueda(consulta, limite):
|
||||
try:
|
||||
d = _get({"action": "query", "list": "search",
|
||||
"srsearch": consulta, "srlimit": limite})
|
||||
return [h["title"] for h in d.get("query", {}).get("search", [])]
|
||||
except Exception as e:
|
||||
print(f" busqueda '{consulta}' fallo: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def extracto(titulo):
|
||||
try:
|
||||
d = _get({"action": "query", "prop": "extracts", "explaintext": 1,
|
||||
"redirects": 1, "titles": titulo})
|
||||
paginas = d.get("query", {}).get("pages", {})
|
||||
for _, pg in paginas.items():
|
||||
txt = pg.get("extract", "")
|
||||
if txt and len(txt) > 300:
|
||||
return pg.get("title", titulo), txt
|
||||
except Exception as e:
|
||||
print(f" extracto '{titulo}' fallo: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def slug(s):
|
||||
s = "".join(c if c.isalnum() else "_" for c in s.lower())
|
||||
return s.strip("_")[:80]
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(DOCS, exist_ok=True)
|
||||
total = 0
|
||||
for tema, cfg in TEMAS.items():
|
||||
print(f"\n=== {tema} ===")
|
||||
titulos = list(cfg["semillas"])
|
||||
for consulta in cfg["buscar"]:
|
||||
titulos += titulos_de_busqueda(consulta, BUSCAR_POR_CONSULTA)
|
||||
time.sleep(PAUSA)
|
||||
# dedup conservando orden
|
||||
vistos, unicos = set(), []
|
||||
for t in titulos:
|
||||
if t.lower() not in vistos:
|
||||
vistos.add(t.lower()); unicos.append(t)
|
||||
for t in unicos:
|
||||
dst = os.path.join(DOCS, f"wiki_{tema}_{slug(t)}.txt")
|
||||
if os.path.exists(dst):
|
||||
continue
|
||||
titulo_real, txt = extracto(t)
|
||||
time.sleep(PAUSA)
|
||||
if not txt:
|
||||
continue
|
||||
with open(dst, "w", encoding="utf-8") as f:
|
||||
f.write(f"# {titulo_real}\n(Wikipedia, tema: {tema})\n\n{txt}\n")
|
||||
total += 1
|
||||
print(f" [{total}] {titulo_real} ({len(txt)//1024} KB)")
|
||||
print(f"\nlisto: {total} articulos nuevos en {DOCS}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue