JARVIS: asistente de voz local para Linux

Nucleo propio: oye con whisper.cpp, piensa con un modelo de Ollama, habla
con Piper, y hace RAG sobre los apuntes del usuario. 100% local, sin
cuentas ni claves.

Escrito bajo una restriccion dura, 4 GB de VRAM: el cerebro y whisper
comparten tarjeta y solo caben porque estan dimensionados para ello. El
RAG usa embeddings estaticos con busqueda hibrida; la voz clonada se sirve
de una cache de frases.

Incluye instalador (install.sh), requisitos, y documentacion del stack,
del manejo de root y de las acciones. Los apuntes indexados y el diario NO
se incluyen: son privados y el .gitignore los bloquea.
This commit is contained in:
sito 2026-08-16 15:28:31 +02:00
commit 8e4bc8ad94
125 changed files with 25033 additions and 0 deletions

View file

@ -0,0 +1,30 @@
# Whisper small en GPU
El reconocimiento pasa del modelo `base` en CPU al `small` en GPU. Medido:
| | acierto | latencia (régimen) |
|---|---|---|
| base · CPU | 80 % | 928 ms |
| small · GPU | **93 %** | **~930 ms** |
Misma velocidad, +13 puntos de acierto. `small` ocupa 757 MiB en la T1200 y
coexiste con el modelo grande de Ollama (2901/3717 MiB con los dos cargados).
## Cómo está montado
- whisper.cpp compilado con CUDA (sm_75) en `voz/whisper-cuda/` (no versionado;
se reconstruye con `cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=75`).
- Envoltorio `~/.local/bin/whisper-server` (copia de referencia en
`config/whisper-server-cuda.sh`): fija `LD_LIBRARY_PATH` a las libs de CUDA y
llama al binario. Está en el PATH del host, que es donde Newelle lo busca.
- Ajustes de Newelle: `stt-settings.whispercpp = {model: small,
use_system_server: true}`. El handler arranca el servidor por
`flatpak-spawn --host whisper-server` y le pega las peticiones.
## Lo único a saber
La PRIMERA transcripción de cada sesión tarda ~4 s: es el arranque del servidor
(cargar el modelo a la GPU). A partir de ahí, ~930 ms. El servidor se queda
vivo mientras la app corre.
Para volver a CPU: `use_system_server: false` y `model: base` en los ajustes.

53
config/ampliar_swap.sh Normal file
View file

@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Amplia la swap en caliente, sin reiniciar y sin tocar particiones.
#
# Por que: el portatil tiene 62 GB de RAM y 976 MiB de swap en la particion
# nvme0n1p3. Ese reparto es de una maquina con 2 GB de RAM, no con 62. La swap
# se llena (96% medido) y aunque hoy no cause nada —comprobado: si=4 so=0 KB/s,
# 43 GB de RAM disponible, ninguna fuga— no queda colchon para cuando algo pida
# memoria de golpe.
#
# Lo que NO arregla: nada de lo que se notaba. La pantalla que pedia contraseña
# era el candado de JARVIS, no el sistema. Esto es higiene, no una reparacion.
#
# Se ejecuta con sudo y no hace falta reiniciar: `swapon` activa el fichero al
# momento y la linea del fstab solo sirve para el proximo arranque.
#
# sudo bash ampliar_swap.sh [tamaño] # por defecto 16G
set -euo pipefail
TAM=${1:-16G}
FICHERO=/swapfile
[ "$(id -u)" -eq 0 ] || { echo "hay que ejecutarlo con sudo" >&2; exit 1; }
if swapon --show=NAME --noheadings 2>/dev/null | grep -qx "$FICHERO"; then
echo "$FICHERO ya esta activo; nada que hacer."
swapon --show
exit 0
fi
echo "1. creando $FICHERO de $TAM"
# fallocate va bien en ext4; en btrfs haria falta otra receta (chattr +C y
# sin compresion), pero la raiz de esta maquina es ext4.
fallocate -l "$TAM" "$FICHERO" 2>/dev/null || dd if=/dev/zero of="$FICHERO" bs=1M count=$((${TAM%G} * 1024)) status=none
chmod 600 "$FICHERO" # solo root: si no, mkswap se niega y con razon
echo "2. formateando"
mkswap "$FICHERO" >/dev/null
echo "3. activando (aqui ya esta funcionando, sin reiniciar)"
swapon "$FICHERO"
echo "4. dejandolo puesto para el proximo arranque"
grep -q "^$FICHERO " /etc/fstab || echo "$FICHERO none swap sw 0 0" >> /etc/fstab
# Con 62 GB de RAM no tiene sentido apartar paginas al disco con la alegria de
# swappiness=60: se baja a 10 para que la swap sea un seguro y no una costumbre.
echo "5. bajando swappiness de $(cat /proc/sys/vm/swappiness) a 10"
sysctl -q vm.swappiness=10
echo "vm.swappiness=10" > /etc/sysctl.d/99-jarvis-swappiness.conf
echo
swapon --show
free -h | head -3

148
config/aprender.py Normal file
View file

@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Enseña acciones nuevas a partir de lo que no entendió.
python3 ~/COFRE/CODERS/JARVIS/config/aprender.py
Cada vez que dices algo que no encaja con ninguna accion, el panel lo anota en
config/jarvis-sin-accion.jsonl. Esto lo lee, agrupa lo repetido y pregunta a
que accion pertenece cada frase; lo que decidas se escribe como alias en
config/jarvis-acciones.json, que el catalogo carga ANTES que el del paquete.
Por que asi y no entrenando un modelo: los fallos del reconocedor son
sistematicos, no aleatorios. "abre el correo" se oye "a ver el correo" siempre,
no una de cada diez veces. Un alias lo arregla del todo, se lee, se borra si
molesta y no cuesta un milisegundo. Reentrenar costaria horas para acertar
"casi siempre".
"""
import json
import os
import sys
from collections import Counter
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
REGISTRO = os.path.join(CONFIG, "jarvis-sin-accion.jsonl")
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
PAQUETE = os.path.expanduser(
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
"active/files/share/newelle/acciones/acciones.json")
def carga_catalogo():
acciones = {}
for ruta in (PAQUETE, PROPIO):
try:
with open(ruta) as f:
for a in json.load(f).get("acciones", []):
acciones.setdefault(a["id"], a)
except (OSError, json.JSONDecodeError):
pass
return acciones
def carga_fallos():
"""Las frases sin accion, agrupadas por veces que se han dicho."""
cuenta = Counter()
try:
with open(REGISTRO) as f:
for linea in f:
try:
texto = json.loads(linea).get("texto", "").strip()
except json.JSONDecodeError:
continue
if texto:
cuenta[texto] += 1
except OSError:
pass
return cuenta
def guarda_alias(nuevos):
"""Añade los alias al catalogo propio, respetando lo que ya hubiera."""
try:
with open(PROPIO) as f:
propio = json.load(f)
except (OSError, json.JSONDecodeError):
propio = {"version": 1, "acciones": []}
porid = {a["id"]: a for a in propio.get("acciones", [])}
catalogo = carga_catalogo()
for ident, frases in nuevos.items():
if ident in porid:
entrada = porid[ident]
else:
# se copia la del paquete entera: el catalogo propio se carga antes
# y sustituye a la original, asi que tiene que traer su comando
entrada = dict(catalogo[ident])
porid[ident] = entrada
propio.setdefault("acciones", []).append(entrada)
for f in frases:
if f not in entrada["frases"]:
entrada["frases"].append(f)
with open(PROPIO, "w") as f:
json.dump(propio, f, indent=2, ensure_ascii=False)
return PROPIO
def main():
fallos = carga_fallos()
if not fallos:
print("No hay nada sin entender todavia.")
print(f"Se anota solo en {REGISTRO}")
return 0
catalogo = carga_catalogo()
ids = sorted(catalogo)
print(f"{len(fallos)} frases distintas no encajaron con ninguna accion.\n")
nuevos = {}
for texto, veces in fallos.most_common():
print(f' "{texto}" ({veces} {"vez" if veces == 1 else "veces"})')
print(" escribe el id de la accion, 'l' para listarlos, "
"Enter para saltar, 'q' para terminar")
try:
resp = input(" > ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if resp == "q":
break
if resp == "l":
for i, ident in enumerate(ids):
print(f" {ident:<24}", end="\n" if i % 3 == 2 else "")
print()
try:
resp = input(" > ").strip()
except (EOFError, KeyboardInterrupt):
break
if not resp:
continue
if resp not in catalogo:
print(f" '{resp}' no existe, saltada")
continue
nuevos.setdefault(resp, []).append(texto)
print(f" anotada como alias de {resp}")
if not nuevos:
print("\nNada que guardar.")
return 0
ruta = guarda_alias(nuevos)
total = sum(len(v) for v in nuevos.values())
print(f"\n{total} alias guardados en {ruta}")
print("Se aplican al abrir la pestaña JARVIS otra vez.")
if input("¿Vaciar el registro de frases no entendidas? [s/N] ").strip().lower() == "s":
try:
os.remove(REGISTRO)
print("registro vaciado")
except OSError as e:
print(f"no se pudo vaciar: {e}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Apunta el cerebro de Newelle al modelo qwen3.5:4b-jarvis.
Es la variante con num_gpu 24 (ver qwen3.5-4b-jarvis.Modelfile): deja 1540 MiB
libres en la tarjeta para que whisper pueda transcribir sin morir de OOM.
Toca solo la clave llm-settings, y dentro solo el bloque "ollama": el modelo
elegido, la lista de modelos y la biblioteca que se ve en la interfaz.
"""
import json
import subprocess
import sys
APP = "io.github.qwersyk.Newelle"
RAMA = "master"
MODELO = "qwen3.5:4b-jarvis"
GSET = ["flatpak", "run", f"--command=gsettings", f"{APP}//{RAMA}"]
def leer(clave):
out = subprocess.check_output(GSET + ["get", APP, clave], text=True).strip()
# gsettings devuelve el string entre comillas simples, con \\ escapados
return json.loads(out[1:-1].replace("\\\\", "\\").replace("\\'", "'"))
def escribir(clave, valor):
subprocess.check_call(GSET + ["set", APP, clave, json.dumps(valor)])
def main():
ajustes = leer("llm-settings")
ollama = ajustes.setdefault("ollama", {})
anterior = ollama.get("model")
if anterior == MODELO:
print(f"ya apuntaba a {MODELO}; nada que hacer.")
return 0
ollama["model"] = MODELO
# la lista de modelos del desplegable: pares [clave, titulo]
modelos = json.loads(ollama.get("models", "[]"))
if not any(m[0] == MODELO for m in modelos):
modelos.insert(0, [MODELO, MODELO])
ollama["models"] = json.dumps(modelos)
# la biblioteca que pinta la interfaz, para que salga con descripcion
biblioteca = ollama.get("model-library", [])
if not any(m.get("key") == MODELO for m in biblioteca):
biblioteca.insert(0, {
"key": MODELO,
"title": "qwen3.5 4b (JARVIS)",
"description": "qwen3.5:4b con 24 de 32 capas en GPU, para dejar "
"sitio a whisper en la tarjeta de 4 GB.",
})
ollama["model-library"] = biblioteca
escribir("llm-settings", ajustes)
print(f"cerebro: {anterior} -> {MODELO}")
return 0
if __name__ == "__main__":
sys.exit(main())

464
config/autoescuela.py Executable file
View file

@ -0,0 +1,464 @@
#!/usr/bin/env python3
"""Dos JARVIS hablando: uno examina, el otro ejecuta, el primero corrige.
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py # una tanda
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py --horas 6 # toda la noche
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py --aplicar # guardar mejoras
La idea. Los bucles que teniamos probaban trozos: mejora_nocturna comprobaba
solo si la frase llega a la accion; probar_acciones, solo si la accion corre.
Esto los junta en una conversacion de punta a punta y por AUDIO:
Examinador -> dice una tarea en voz alta (Piper), con ruido y sala
Jarvis -> la oye (Whisper), decide que accion es, la ejecuta,
y contesta en voz alta
Examinador -> comprueba tres cosas:
1. ¿oyo la tarea correcta? (reconocimiento)
2. ¿hizo la accion que tocaba? (emparejamiento)
3. ¿el resultado tiene sentido? (ejecucion y respuesta)
y pone nota
Lo que se ejecuta de verdad: solo lo de solo lectura. Lo que abre cosas, apaga
o necesita root se comprueba hasta el emparejamiento, sin lanzarlo una
autoescuela no deberia apagarte la pantalla ni pedir tu contraseña de noche.
Lo que aprende: los fallos se separan por tipo. Un fallo de reconocimiento
propone un alias (con el mismo filtro seguro de mejora_nocturna: sistematico,
sin ambiguedad, conserva palabra clave). Un fallo de ejecucion o de respuesta
se ANOTA para arreglarlo a mano eso no se toca solo, que es codigo.
Escenarios: casi todos son de un turno (una tarea, una respuesta), sacados del
catalogo. Unos pocos son de varios turnos para probar lo que tiene estado:
buscar -> "abre el segundo" -> "busca dentro X". Y hay charla que NO debe
disparar ninguna accion, como control negativo.
"""
import argparse
import importlib.util
import json
import os
import random
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
import time
from collections import defaultdict
CASA = os.path.expanduser("~")
JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS")
CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config")
APP = "io.github.qwersyk.Newelle//master"
PIPER = os.path.join(JARVIS, "voz/piper/piper")
MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos")
WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli")
MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models")
CATALOGO = os.path.expanduser(
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
"active/files/share/newelle/acciones/acciones.json")
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
SELECCION = os.path.join(JARVIS, "newelle/src/utility/seleccion.py")
SALA = os.path.join(JARVIS, "muestras/autoescuela")
DIALOGO = os.path.join(SALA, "dialogo.log")
INFORME = os.path.join(SALA, "informe.json")
WAVS = os.path.join(CONFIG, "autoescuela_wavs")
VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
VELOCIDADES = [0.9, 1.0, 1.12]
RUIDOS = ["", "aecho=0.8:0.7:22:0.12", "volume=0.72,highpass=f=120",
"highpass=f=150,lowpass=f=6500"]
# Lo que tiene efecto o pide permiso: se comprueba el emparejamiento pero no se
# ejecuta. Igual que en probar_acciones.
EFECTOS = ["firefox", "xdg-open", "loginctl", "xset", "pactl set", "pkill",
"brightnessctl", "systemctl start", "systemctl stop", "systemctl restart",
"import -window", "gnome-screenshot", "sudo", "rm ", "kill", "dpms"]
RELLENOS = ["notas", "el informe", "documentos", "reactor"]
def carga(ruta, nombre):
spec = importlib.util.spec_from_file_location(nombre, ruta)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
def log(msg, a_dialogo=False):
linea = f"[{time.strftime('%H:%M:%S')}] {msg}"
print(linea, flush=True)
fichero = DIALOGO if a_dialogo else DIALOGO
try:
with open(fichero, "a") as f:
f.write((msg if a_dialogo else linea) + "\n")
except OSError:
pass
def dice(texto, voz, vel, ruido, destino):
"""El examinador dice una tarea: la sintetiza a un wav de 16 kHz."""
crudo = tempfile.mktemp(suffix=".wav")
try:
r = subprocess.run(
[PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
"--length_scale", str(1 / vel), "--output_file", crudo],
input=texto, capture_output=True, text=True, timeout=60)
if r.returncode != 0 or not os.path.exists(crudo):
return False
filtros = "aresample=16000" + ("," + ruido if ruido else "")
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", crudo,
"-ac", "1", "-af", filtros, "-y", destino], check=True, timeout=60)
return True
except (OSError, subprocess.SubprocessError):
return False
finally:
if os.path.exists(crudo):
os.remove(crudo)
def oye(wavs, hilos=8, por_tanda=40):
"""Jarvis oye: transcribe los wav. {fichero: texto}."""
salida = {}
ficheros = sorted(wavs)
for i in range(0, len(ficheros), por_tanda):
tanda = ficheros[i:i + por_tanda]
lista = " ".join(f'"{WAVS}/{n}"' for n in tanda)
orden = (f'for f in {lista}; do echo "### $(basename "$f")"; '
f'{WHISPER} -m {MODELOS_STT}/ggml-base.bin -l es -nt -t {hilos} '
f'-f "$f" 2>/dev/null | tr "\\n" " "; echo; done')
try:
r = subprocess.run(["flatpak", "run", "--command=bash", APP, "-c", orden],
capture_output=True, text=True, errors="replace", timeout=1200)
except subprocess.SubprocessError:
continue
actual = None
for linea in r.stdout.splitlines():
if linea.startswith("### "):
actual = linea[4:].strip()
elif actual:
salida[actual] = linea.strip()
actual = None
return salida
def tiene_efecto(comando):
return any(m in comando for m in EFECTOS)
def ejecuta_lectura(comando, argumento=""):
"""Ejecuta un comando de solo lectura y devuelve su salida."""
c = comando.replace("{argumento}", shlex.quote(argumento))
c = c.replace("{argumento_url}", shlex.quote(argumento))
try:
r = subprocess.run(["bash", "-lc", c], capture_output=True, text=True,
errors="replace", timeout=15)
return (r.stdout or r.stderr or "").strip(), r.returncode
except subprocess.SubprocessError:
return "", 1
class Ejecutor:
"""La tuberia de Jarvis: oye una frase, decide y hace. Replica el enrutado
del panel (seleccion antes que catalogo) para poder probarlo sin GUI."""
def __init__(self, motor, seleccion):
self.motor = motor
self.sel = seleccion
self.cat = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
or [CATALOGO_FUENTE])
self.lista = []
self.fichero = None
def recarga(self):
self.cat = self.motor.Catalogo(
[p for p in (PROPIO, CATALOGO) if os.path.exists(p)] or [CATALOGO_FUENTE])
def procesa(self, oido):
"""Devuelve (tipo, id_o_dato, salida). tipo:
'seleccion' | 'dentro' | 'accion' | 'modelo'."""
norm = self.motor.normaliza(oido)
s = self.sel.orden_de_seleccion(norm)
if s is not None and self.lista:
que, pos = s
idx = len(self.lista) - 1 if pos == -1 else pos - 1
if 0 <= idx < len(self.lista):
self.fichero = self.lista[idx]
return "seleccion", f"{que}:{pos}", self.fichero
return "seleccion", "fuera_de_rango", ""
termino = self.sel.orden_buscar_dentro(norm)
if termino and self.fichero:
r, _ = ejecuta_lectura(f"grep -in {shlex.quote(termino)} "
f"{shlex.quote(self.fichero)} | head", "")
return "dentro", termino, r
accion, arg = self.cat.busca(oido)
if accion is None:
return "modelo", "", ""
# las de lista alimentan self.lista
if accion.id in ("buscar_fichero", "buscar_carpeta", "grep_recursivo"):
salida, _ = ejecuta_lectura(accion.comando, arg)
rutas = [l.split(":", 1)[0] if accion.id == "grep_recursivo" else l
for l in salida.splitlines() if l.strip()]
self.lista = rutas
if rutas:
self.fichero = rutas[0]
return "accion", accion.id, salida
if accion.confirmar or tiene_efecto(accion.comando):
return "accion", accion.id, "(no se ejecuta; solo emparejamiento)"
salida, _ = ejecuta_lectura(accion.comando, arg)
return "accion", accion.id, salida
def escenarios(acciones):
"""Construye las tareas de examen. Cada una: (texto, comprobacion)."""
tareas = []
# un turno por accion: la tarea es su frase, se espera su id
for a in acciones:
if not a["frases"]:
continue
texto = a["frases"][0]
if a.get("captura"):
texto += " " + random.choice(RELLENOS)
tareas.append({
"tipo": "accion", "texto": texto, "espera_id": a["id"],
"turnos": [texto],
})
# multi-turno: probar el estado (lista -> elegir -> buscar dentro)
tareas.append({
"tipo": "multi", "texto": "buscar y elegir",
"turnos": ["busca el fichero reactor", "abre el primero", "busca dentro def"],
"espera": ["accion:grep_recursivo|accion:buscar_fichero", "seleccion", "dentro"],
})
# control negativo: charla que NO debe disparar accion
for frase in ["cuentame un chiste sobre gatos", "que opinas de todo esto",
"estoy cansado hoy la verdad", "no se muy bien que hacer ahora"]:
tareas.append({"tipo": "charla", "texto": frase, "turnos": [frase]})
return tareas
def una_tanda(ejecutor, motor, ronda, muestras):
acciones = ejecutor.cat.acciones
base_acciones = json.load(open(CATALOGO if os.path.exists(CATALOGO)
else CATALOGO_FUENTE))["acciones"]
tareas = escenarios(base_acciones)
# sintetizar todos los turnos
shutil.rmtree(WAVS, ignore_errors=True)
os.makedirs(WAVS, exist_ok=True)
plan = [] # (fichero, tarea_idx, turno_idx, texto)
n = 0
for ti, tarea in enumerate(tareas):
for tj, texto in enumerate(tarea["turnos"]):
n += 1
nombre = f"{n:05d}.wav"
if dice(texto, random.choice(VOCES), random.choice(VELOCIDADES),
random.choice(RUIDOS), os.path.join(WAVS, nombre)):
plan.append((nombre, ti, tj, texto))
log(f"ronda {ronda}: examinador dice {len(plan)} tareas, jarvis las oye...")
oidos = oye([p[0] for p in plan])
# correr los escenarios en orden (el estado importa en los multi-turno)
resultados = {"bien": 0, "reconoce_mal": 0, "accion_mal": 0,
"ejecuta_mal": 0, "charla_colada": 0}
candidatos = defaultdict(lambda: defaultdict(int))
por_tarea = defaultdict(list)
for nombre, ti, tj, texto in plan:
por_tarea[ti].append((tj, nombre, texto))
for ti in sorted(por_tarea):
tarea = tareas[ti]
ejecutor.lista = [] # cada escenario empieza limpio
ejecutor.fichero = None
for tj, nombre, texto in sorted(por_tarea[ti]):
oido = oidos.get(nombre, "").strip()
if not oido:
continue
tipo, dato, salida = ejecutor.procesa(oido)
# dejar la conversacion en el log, legible
log(f" examinador: «{texto}»", a_dialogo=True)
log(f" jarvis oye: «{oido}» -> {tipo}:{dato}", a_dialogo=True)
veredicto = verifica(tarea, tj, texto, oido, tipo, dato, salida,
motor, candidatos)
log(f" veredicto: {veredicto}\n", a_dialogo=True)
resultados[veredicto] = resultados.get(veredicto, 0) + 1
total = sum(resultados.values())
bien = resultados["bien"]
log(f"ronda {ronda}: {bien}/{total} bien · " +
" · ".join(f"{k} {v}" for k, v in resultados.items() if k != "bien" and v))
return resultados, candidatos
def verifica(tarea, tj, texto, oido, tipo, dato, salida, motor, candidatos):
"""Pone nota a un turno y, si es fallo de reconocimiento, anota el candidato."""
if tarea["tipo"] == "charla":
return "bien" if tipo == "modelo" else "charla_colada"
if tarea["tipo"] == "multi":
esperado = tarea["espera"][tj]
actual = f"{tipo}:{dato}"
if any(actual.startswith(e.split("|")[0].split(":")[0] + ":") and
(e == actual or e.split(":")[0] == tipo)
for e in esperado.split("|")):
# para multi basta con que el TIPO sea el correcto
if tipo in [e.split(":")[0] for e in esperado.split("|")]:
return "bien"
return "accion_mal"
# tarea de un turno: se espera un id concreto
espera_id = tarea["espera_id"]
if tipo != "accion":
# no reconocio la orden: candidato a alias si conserva sentido
candidatos[espera_id][motor.normaliza(oido)] += 1
return "reconoce_mal"
if dato != espera_id:
return "accion_mal"
# emparejo bien; ¿la ejecucion dio algo con sentido?
if salida and ("command not found" in salida or salida == "TIMEOUT"):
return "ejecuta_mal"
return "bien"
# --- aprender: mismos filtros seguros que mejora_nocturna --------------------
VACIAS = {"que", "de", "la", "el", "me", "se", "es", "esta", "estan", "hay",
"tengo", "un", "una", "por", "con", "como", "cuanto", "cuanta"}
def contenido(frase):
return {p for p in frase.split() if len(p) > 3 and p not in VACIAS}
def alias_seguros(candidatos, acciones, motor, cat):
porfrase = defaultdict(set)
for ident, frases in candidatos.items():
for f in frases:
porfrase[f].add(ident)
porid = {a["id"]: a for a in acciones}
propuestos = defaultdict(list)
for ident, frases in candidatos.items():
a = porid.get(ident)
if a is None or a.get("captura"):
continue
orig = motor.normaliza(a["frases"][0] if a["frases"] else "")
claves = contenido(orig)
for f, veces in frases.items():
if veces < 2 or len(porfrase[f]) > 1 or len(f.split()) < 2:
continue
if cat.busca(f)[0] is not None or motor.parecido(f, orig) < 0.6:
continue
pal = set(f.split())
if claves and not any(
c in pal or any(motor.parecido(c, w) > 0.8 for w in pal)
for c in claves):
continue
propuestos[ident].append((f, veces))
return propuestos
def guarda_alias(propuestos, acciones):
try:
propio = json.load(open(PROPIO))
except (OSError, json.JSONDecodeError):
propio = {"version": 1, "acciones": []}
porid = {a["id"]: a for a in propio.get("acciones", [])}
orig = {a["id"]: a for a in acciones}
n = 0
for ident, lista in propuestos.items():
e = porid.get(ident)
if e is None:
e = dict(orig[ident]); porid[ident] = e
propio.setdefault("acciones", []).append(e)
for f, _ in lista:
if f not in e["frases"]:
e["frases"].append(f); n += 1
json.dump(propio, open(PROPIO, "w"), indent=2, ensure_ascii=False)
return n
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--horas", type=float, default=0)
ap.add_argument("--aplicar", action="store_true", help="guardar los alias que encuentre")
ap.add_argument("--muestras", type=int, default=1)
args = ap.parse_args()
for ruta, q in ((PIPER, "piper"), (WHISPER, "whisper")):
if not os.path.exists(ruta):
print(f"falta {q}"); return 1
os.makedirs(SALA, exist_ok=True)
motor = carga(MOTOR, "acciones")
sel = carga(SELECCION, "seleccion")
ejecutor = Ejecutor(motor, sel)
limite = time.time() + args.horas * 3600 if args.horas else None
log("=" * 56)
log("arranca la autoescuela (examinador + jarvis)")
historial = []
ronda = 0
total_alias = 0
while True:
ronda += 1
ini = time.time()
try:
resultados, candidatos = una_tanda(ejecutor, motor, ronda, args.muestras)
except Exception as e:
log(f"ronda {ronda}: error, se salta — {type(e).__name__}: {e}")
time.sleep(5); continue
# aprender de los fallos de reconocimiento
base = json.load(open(CATALOGO if os.path.exists(CATALOGO)
else CATALOGO_FUENTE))["acciones"]
propuestos = alias_seguros(candidatos, base, motor, ejecutor.cat)
nuevos = sum(len(v) for v in propuestos.values())
if nuevos and args.aplicar:
total_alias += guarda_alias(propuestos, base)
ejecutor.recarga()
log(f"ronda {ronda}: +{nuevos} alias aplicados")
elif nuevos:
log(f"ronda {ronda}: {nuevos} alias propuestos (usa --aplicar para guardarlos)")
total = sum(resultados.values())
historial.append({
"ronda": ronda,
"acierto": round(100 * resultados["bien"] / max(1, total), 1),
"fallos": {k: v for k, v in resultados.items() if k != "bien" and v},
"minutos": round((time.time() - ini) / 60, 1),
})
json.dump({"actualizado": time.strftime("%Y-%m-%d %H:%M"),
"rondas": ronda, "alias_aplicados": total_alias,
"historial": historial[-30:]},
open(INFORME, "w"), indent=2, ensure_ascii=False)
if not args.horas:
break
if time.time() > limite:
log("se acabo el tiempo. Fin."); break
shutil.rmtree(WAVS, ignore_errors=True)
log(f"terminado tras {ronda} rondas · ultimo acierto "
f"{historial[-1]['acierto'] if historial else '?'} %")
return 0
if __name__ == "__main__":
sys.exit(main())

185
config/banco.py Executable file
View file

@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Un banco de pruebas FIJO para medir si el reconocimiento mejora.
## El problema que resuelve
La mejora nocturna medía así: decía las 150 frases con voces, velocidades y
ruido ALEATORIOS, las transcribía, y comparaba el porcentaje con el de la ronda
anterior. Nueve horas y 39 rondas después:
ruido de medida (desviacion): ±1,9 puntos
mejora acumulada en la noche: +1,45 puntos
El ruido era MAYOR que la mejora. Con eso, la pregunta que el bucle necesita
contestar "¿este alias mejora o empeora?" se decidía a cara o cruz, y por eso
en nueve horas aplicó cuatro alias y no dejó ni un commit.
## Cómo lo arregla
Se graba el audio UNA vez, con semillas fijas, y se guardan las
TRANSCRIPCIONES. A partir de ahí, medir el catálogo es volver a pasar el
emparejador sobre esas mismas transcripciones guardadas:
- determinista: la misma entrada siempre, así que dos medidas son comparables
- instantáneo: sin TTS ni Whisper, milisegundos en vez de cinco minutos
- sensible: se nota una frase de diferencia, no hacen falta cuatro puntos
Lo que se pierde: deja de explorar ruidos nuevos. Por eso el banco se REHACE de
vez en cuando (--crear), y entre medias se usa fijo para decidir.
banco.py --crear [--muestras 2] graba y transcribe (lento, una vez)
banco.py --medir puntúa el catálogo actual (instantáneo)
banco.py --fallos enseña qué se sigue fallando
"""
import argparse
import collections
import importlib.util
import json
import os
import random
import subprocess
import sys
import tempfile
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json")
MOTOR = os.path.join(RAIZ, "newelle", "src", "utility", "acciones.py")
BANCO = os.path.join(RAIZ, "muestras", "banco.json")
PIPER = os.path.join(RAIZ, "config", "jarvis-piper.sh")
MODELO_WHISPER = os.path.expanduser(
"~/.var/app/io.github.qwersyk.Newelle/config/models/whisper/whisper.cpp/models/ggml-small.bin")
BIN_WHISPER = os.path.join(RAIZ, "voz", "whisper-cuda", "whisper.cpp", "build", "bin", "whisper-server")
# Fijas a proposito: son las que hacen el banco reproducible. Cambiarlas obliga
# a rehacerlo, y esa es justamente la señal de que ya no es comparable.
VOCES = ["es_ES-davefx-medium", "es_MX-claude-high", "es_ES-sharvard-medium"]
VELOCIDADES = [0.9, 1.0, 1.15]
SEMILLA = 20260815
def motor():
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
def crear(muestras):
m = motor()
cat = m.Catalogo([CATALOGO])
rnd = random.Random(SEMILLA)
casos = []
for a in cat.acciones:
if not a.frases:
continue
for _ in range(muestras):
casos.append({
"id": a.id,
"frase": rnd.choice(a.frases),
"voz": rnd.choice(VOCES),
"velocidad": rnd.choice(VELOCIDADES),
})
print(f" {len(casos)} clips a grabar y transcribir...", flush=True)
puerto = 8993
servidor = subprocess.Popen(
[BIN_WHISPER, "-m", MODELO_WHISPER, "-t", "8", "--host", "127.0.0.1",
"--port", str(puerto), "-l", "es", "--no-gpu"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env={**os.environ, "LD_LIBRARY_PATH": "/usr/local/cuda/lib64"})
import time
time.sleep(10)
import urllib.request
hechos = 0
with tempfile.TemporaryDirectory() as tmp:
for c in casos:
wav = os.path.join(tmp, "c.wav")
entorno = {**os.environ,
"JARVIS_PIPER_VOZ": c["voz"],
"JARVIS_PIPER_TONO": "1.0"}
r = subprocess.run([PIPER, wav, c["frase"]], env=entorno,
capture_output=True, timeout=60)
if not os.path.exists(wav) or os.path.getsize(wav) == 0:
continue
# la velocidad se aplica al vuelo, sin tocar el tono
wav16 = os.path.join(tmp, "c16.wav")
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", wav,
"-filter:a", f"atempo={c['velocidad']}",
"-ar", "16000", "-ac", "1", wav16],
capture_output=True, timeout=60)
try:
with open(wav16, "rb") as f:
datos = f.read()
lim = "----b"
cuerpo = (f"--{lim}\r\nContent-Disposition: form-data; "
f'name="file"; filename="c.wav"\r\n'
f"Content-Type: audio/wav\r\n\r\n").encode() + datos + \
f"\r\n--{lim}--\r\n".encode()
req = urllib.request.Request(
f"http://127.0.0.1:{puerto}/inference", data=cuerpo,
headers={"Content-Type": f"multipart/form-data; boundary={lim}"})
with urllib.request.urlopen(req, timeout=120) as resp:
c["oido"] = json.loads(resp.read().decode()).get("text", "").strip()
hechos += 1
if hechos % 50 == 0:
print(f" {hechos}/{len(casos)}", flush=True)
except Exception as e:
c["oido"] = ""
servidor.terminate()
casos = [c for c in casos if c.get("oido")]
os.makedirs(os.path.dirname(BANCO), exist_ok=True)
json.dump({"semilla": SEMILLA, "casos": casos}, open(BANCO, "w"),
ensure_ascii=False, indent=1)
print(f" banco guardado: {len(casos)} transcripciones en {BANCO}")
return 0
def cargar():
try:
return json.load(open(BANCO))["casos"]
except Exception:
return []
def medir(detalle=False):
casos = cargar()
if not casos:
print("no hay banco; crealo con --crear")
return 1
m = motor()
cat = m.Catalogo([CATALOGO])
aciertos, fallos = 0, []
for c in casos:
a, _ = cat.busca(c["oido"])
if a is not None and a.id == c["id"]:
aciertos += 1
else:
fallos.append((c["id"], c["oido"], a.id if a else None))
pct = 100.0 * aciertos / len(casos)
print(f" acierto {pct:.2f} % ({aciertos}/{len(casos)})")
if detalle:
cuenta = collections.Counter(f[0] for f in fallos)
print(f"\n las acciones que mas se fallan:")
for ident, n in cuenta.most_common(12):
ejemplo = next(f[1] for f in fallos if f[0] == ident)
print(f" {n:3d} {ident:22s} p.ej. se oyo: \"{ejemplo[:44]}\"")
return 0
def main():
p = argparse.ArgumentParser()
p.add_argument("--crear", action="store_true")
p.add_argument("--medir", action="store_true")
p.add_argument("--fallos", action="store_true")
p.add_argument("--muestras", type=int, default=2)
a = p.parse_args()
if a.crear:
return crear(a.muestras)
return medir(detalle=a.fallos)
if __name__ == "__main__":
sys.exit(main())

81
config/comparar_voces.py Normal file
View file

@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Genera la misma frase con las cuatro voces britanicas de Kokoro.
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
~/COFRE/CODERS/JARVIS/config/comparar_voces.py
Todas salen ya con el tratamiento aplicado, que es como sonaran de verdad.
Elegir de oido y fijar la ganadora con set_voice.py.
"""
import os
import subprocess
import sys
sys.path.insert(0, "/app/share/newelle")
sys.path.insert(0, os.path.expanduser(
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
from gi.repository import Gio # noqa: E402
from newelle.handlers.tts.kokoro_handler import KokoroTTSHandler # noqa: E402
from newelle.handlers.tts.tts import jarvis_audio_filter # noqa: E402
VOCES = ["bm_george", "bm_daniel", "bm_lewis", "bm_fable"]
FRASE = ("Good evening, sir. All systems are running locally. "
"The GPU is idle, and you have 846 gigabytes free. "
"Shall I run the diagnostics?")
SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/muestras/voces")
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
def nivel(path):
"""mean/max en dB, para confirmar que ninguna sale floja o recortada."""
r = subprocess.run(
["ffmpeg", "-hide_banner", "-i", path, "-af", "volumedetect",
"-f", "null", "/dev/null"],
capture_output=True, text=True,
)
vals = [l.split(":")[-1].strip() for l in r.stderr.splitlines()
if "mean_volume:" in l or "max_volume:" in l]
return " / ".join(vals) if vals else "?"
def main():
os.makedirs(SALIDA, exist_ok=True)
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
handler = KokoroTTSHandler(settings, os.path.join(CONFIG, "models"))
if not handler.is_installed():
print("instalando kokoro...")
handler.install()
filtro = jarvis_audio_filter()
print(f"{'voz':<12} {'fichero':<26} nivel (mean / max)")
for voz in VOCES:
handler.set_voice(voz)
crudo = os.path.join(SALIDA, f"_{voz}_seca.wav")
final = os.path.join(SALIDA, f"{voz}.wav")
try:
handler.save_audio(FRASE, crudo)
subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error",
"-i", crudo, "-af", filtro, "-y", final],
check=True,
)
os.remove(crudo)
print(f"{voz:<12} {os.path.basename(final):<26} {nivel(final)}")
except Exception as e:
print(f"{voz:<12} FALLO: {e}")
# dejar la voz como estaba, que este script solo compara
handler.set_voice(VOCES[0])
print(f"\nvoz restaurada a {VOCES[0]}")
print(f"escuchalas en: {SALIDA}")
return 0
if __name__ == "__main__":
sys.exit(main())

108
config/comparar_voces_es.py Normal file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Genera la misma frase en castellano con las voces masculinas disponibles.
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
~/COFRE/CODERS/JARVIS/config/comparar_voces_es.py
Kokoro solo trae DOS voces masculinas en español: em_alex y em_santa. De los
motores que Newelle soporta, los unicos locales son kokoro y espeak; el resto
gtts, edge, elevenlabs, openai, groq son de nube y quedan descartados.
Como ninguna de las dos es especialmente grave, cada una se genera tambien en
version grave: se baja el tono con ffmpeg sin cambiar la velocidad. El truco es
reproducir a menos muestras por segundo (baja tono y alarga) y devolver la
duracion con atempo. A 0.90 baja unos dos semitonos, que se nota sin sonar a
disco rayado; por debajo de 0.85 empieza a sonar artificial.
Se escuchan las cuatro y se fija la elegida con set_voz_es.py.
"""
import os
import subprocess
import sys
sys.path.insert(0, "/app/share/newelle")
sys.path.insert(0, os.path.expanduser(
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
from gi.repository import Gio # noqa: E402
from newelle.handlers.tts.kokoro_handler import KokoroTTSHandler # noqa: E402
from newelle.handlers.tts.tts import jarvis_audio_filter # noqa: E402
VOCES = ["em_alex", "em_santa"]
GRAVEDAD = 0.90 # 1.0 = sin tocar; menos = mas grave
MUESTREO = 24000 # el que saca kokoro
FRASE = ("Buenas tardes. Todos los sistemas funcionan en local. "
"La tarjeta grafica esta libre y quedan ochocientos cuarenta y seis "
"gigabytes en el disco. Quiere que ejecute el diagnostico?")
SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/muestras/voces_es")
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
def cadena(grave: bool) -> str:
"""El tratamiento JARVIS, con o sin bajada de tono delante."""
base = jarvis_audio_filter()
if not grave:
return base
tono = (f"asetrate={int(MUESTREO * GRAVEDAD)},"
f"aresample={MUESTREO},atempo={1 / GRAVEDAD:.4f}")
return f"{tono},{base}" if base else tono
def nivel(path):
"""mean/max en dB, para confirmar que ninguna sale floja o recortada."""
r = subprocess.run(
["ffmpeg", "-hide_banner", "-i", path, "-af", "volumedetect",
"-f", "null", "/dev/null"],
capture_output=True, text=True,
)
vals = [l.split(":")[-1].strip() for l in r.stderr.splitlines()
if "mean_volume:" in l or "max_volume:" in l]
return " / ".join(vals) if vals else "?"
def main():
os.makedirs(SALIDA, exist_ok=True)
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
handler = KokoroTTSHandler(settings, os.path.join(CONFIG, "models"))
if not handler.is_installed():
print("instalando kokoro...")
handler.install()
previa = handler.get_current_voice() if hasattr(handler, "get_current_voice") else None
print(f"{'fichero':<28} nivel (mean / max)")
for voz in VOCES:
handler.set_voice(voz)
crudo = os.path.join(SALIDA, f"_{voz}.wav")
try:
handler.save_audio(FRASE, crudo)
except Exception as e:
print(f"{voz:<28} FALLO al generar: {e}")
continue
for etiqueta, grave in (("", False), ("_grave", True)):
final = os.path.join(SALIDA, f"{voz}{etiqueta}.wav")
try:
subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error",
"-i", crudo, "-af", cadena(grave), "-y", final],
check=True,
)
print(f"{os.path.basename(final):<28} {nivel(final)}")
except Exception as e:
print(f"{voz}{etiqueta:<20} FALLO: {e}")
os.remove(crudo)
if previa:
handler.set_voice(previa)
print(f"\nescuchalas en: {SALIDA}")
print("la elegida se fija con set_voz_es.py <voz> [--grave]")
return 0
if __name__ == "__main__":
sys.exit(main())

101
config/construir_indice.py Normal file
View file

@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Construye el indice del RAG sin abrir la interfaz.
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
~/COFRE/CODERS/JARVIS/config/construir_indice.py
En la app esto es el boton "Update Index" de los ajustes del RAG, que lanza un
hilo y va enseñando el progreso en una barra. Desde aqui se llama a lo mismo
pero en primer plano, para poder dejarlo corriendo en una terminal y ver si
falla, que con 20 000 fragmentos conviene.
Antes hay que haber preparado el corpus con indexar.py: este script indexa lo
que haya en la carpeta de documentos, sin filtrar nada.
"""
import os
import sys
import time
sys.path.insert(0, "/app/share/newelle")
sys.path.insert(0, os.path.expanduser(
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
from gi.repository import Gio # noqa: E402
from newelle.handlers.rag.llamaindex_handler import LlamaIndexHanlder # noqa: E402
from newelle.handlers.embeddings.model2vec import Model2VecHandler # noqa: E402
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
MODELOS = os.path.join(CONFIG, "models")
DOCUMENTOS = os.path.join(MODELOS, "documents")
def main():
if not os.path.isdir(DOCUMENTOS):
print(f"no existe {DOCUMENTOS}")
print("prepara antes el corpus: python3 config/indexar.py --hacerlo")
return 1
ficheros = [f for f in os.listdir(DOCUMENTOS) if not f.startswith(".")]
if not ficheros:
print("la carpeta de documentos esta vacia")
return 1
tam = sum(os.path.getsize(os.path.join(DOCUMENTOS, f))
for f in ficheros) / 1048576
print(f"{len(ficheros)} documentos · {tam:.1f} MB")
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
rag = LlamaIndexHanlder(settings, MODELOS)
if not rag.is_installed():
print("faltan dependencias del RAG (llama-index, faiss, tiktoken)")
return 1
# El RAG necesita el modelo de embeddings para vectorizar, y opcionalmente
# un LLM secundario para reescribir la consulta. Aqui solo hace falta el
# primero: se indexa, no se pregunta.
rag.embedding = Model2VecHandler(settings, MODELOS)
if not rag.embedding.is_installed():
print("instalando model2vec...")
rag.embedding.install()
# create_index solo le pide load_model() al LLM —para indexar no hace falta
# generar nada— asi que un cascaron sirve y evita cargar 3,7 GB de modelo
# para vectorizar documentos.
class SinModelo:
def load_model(self, *a, **k):
return True
rag.llm = SinModelo()
rag.secondary_llm = SinModelo()
trozo = rag.get_setting("chunk_size")
print(f"fragmentos de {trozo} tokens · esto tarda, no lo cortes\n")
ini = time.time()
try:
rag.create_index()
except Exception as e:
print(f"\nfallo al indexar: {type(e).__name__}: {e}")
return 1
minutos = (time.time() - ini) / 60
_, data_path = rag.get_paths()
peso = sum(
os.path.getsize(os.path.join(dp, f))
for dp, _, fs in os.walk(data_path) for f in fs) / 1048576
# create_index se traga sus propias excepciones e imprime el error, asi que
# terminar sin fallo no significa que haya indice. Se comprueba el disco.
if peso < 0.1:
print(f"\nno se creo indice ({peso:.1f} MB en {data_path})")
print("mira el error de arriba")
return 1
print(f"\nindice construido en {minutos:.1f} min · {peso:.1f} MB")
print(f"en {data_path}")
return 0
if __name__ == "__main__":
sys.exit(main())

94
config/demo_en_directo.sh Executable file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Le da ordenes a JARVIS en directo, para verlo trabajar en la pantalla.
#
# Cada orden se dice EN VOZ ALTA con la voz activa (para oir como suena de
# verdad, no en un banco de pruebas) y luego se ejecuta la accion que le
# corresponde, igual que haria el asistente al oirte.
#
# No se ejecuta nada destructivo ni nada con root: eso pasa por un dialogo que
# aprueba una persona, y aqui no hay persona delante del dialogo.
#
# ./demo_en_directo.sh las que salen por pantalla incluidas
# ./demo_en_directo.sh --mudo sin voz, solo resultados
set -uo pipefail
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
MUDO=no; [ "${1:-}" = "--mudo" ] && MUDO=si
CATALOGO=$RAIZ/newelle/data/acciones/acciones.json
TMPWAV=$(mktemp --suffix=.wav)
trap 'rm -f "$TMPWAV"' EXIT
# La orden que se le diria, y la accion del catalogo que le toca.
# Se eligen las que se VEN o las que contestan algo concreto.
ORDENES=(
"cuanto espacio queda|disco_libre"
"cuanta memoria|memoria"
"que temperatura hay|temperatura"
"que procesos consumen mas|procesos_top"
"cual es mi ip|red_ip"
"cuanto lleva encendido|uptime"
"busca el fichero README|buscar_fichero|README.md"
"busca dentro JARVIS|grep_recursivo|JARVIS"
"hazme una captura|captura_pantalla"
"abre una terminal|terminal_abrir"
"que peliculas tengo|videos_listar"
"pon la pelicula screencast|pelicula_abrir|Screencast"
"abre la carpeta de jarvis|abrir_carpeta|$RAIZ"
)
dice() {
[ "$MUDO" = si ] && return 0
"$RAIZ/config/jarvis-voz.sh" "$TMPWAV" "$1" >/dev/null 2>&1 \
|| JARVIS_PIPER_VOZ=es_ES-davefx-medium "$RAIZ/config/jarvis-piper.sh" "$TMPWAV" "$1" >/dev/null 2>&1
[ -s "$TMPWAV" ] && { paplay "$TMPWAV" 2>/dev/null || aplay -q "$TMPWAV" 2>/dev/null; }
}
campo() { # campo <id_accion> <clave>
python3 -c "
import json,sys
d=json.load(open('$CATALOGO',encoding='utf-8'))
for a in d.get('acciones', d if isinstance(d,list) else []):
if a['id']==sys.argv[1]: print(a.get(sys.argv[2],'') or ''); break
" "$1" "$2"
}
for entrada in "${ORDENES[@]}"; do
IFS='|' read -r frase accion argumento <<< "$entrada"
comando=$(campo "$accion" comando)
acuse=$(campo "$accion" acuse)
respuesta=$(campo "$accion" respuesta)
if [ -z "$comando" ]; then
printf '\n ── "%s" → accion %s NO EXISTE en el catalogo\n' "$frase" "$accion"
continue
fi
printf '\n ── tu dices: "%s"\n' "$frase"
printf ' jarvis: %s\n' "${acuse:-...}"
dice "${acuse:-Un momento}"
# sustituir el argumento igual que hace el motor
real=${comando//\{argumento\}/$(printf '%q' "${argumento:-}")}
salida=$(timeout 30 bash -lc "$real" 2>&1 | head -6)
if [ -n "$salida" ]; then
printf ' resultado: %s\n' "$(echo "$salida" | head -1 | cut -c1-100)"
echo "$salida" | tail -n +2 | sed 's/^/ /' | cut -c1-100
else
printf ' resultado: (hecho, sin salida que enseñar)\n'
fi
# decir la respuesta con la plantilla, si la tiene y es corta
if [ -n "$respuesta" ] && [ -n "$salida" ]; then
ultima=$(echo "$salida" | tail -1)
hablado=${respuesta//\{ultima\}/$ultima}
hablado=${hablado//\{salida\}/$ultima}
case "$hablado" in
*"{"*) ;; # plantilla con campos que no sabemos
*) [ ${#hablado} -lt 160 ] && { printf ' jarvis: %s\n' "$hablado"; dice "$hablado"; };;
esac
fi
sleep 1
done
printf '\n ── fin de la demostracion\n'

190
config/descubrir.py Executable file
View file

@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Cataloga las herramientas de COFRE para que JARVIS sepa cuales hay.
python3 ~/COFRE/CODERS/JARVIS/config/descubrir.py
Produce jarvis-herramientas.json: una ficha por herramienta con su nombre, que
hace, como se invoca y en que carpeta vive. NO es el catalogo de acciones
rapidas (esas son las ~20 que dices por voz a diario, escritas a mano). Esto es
el registro que el modelo consulta cuando le pides algo que no esta en el
catalogo, para componer el comando en vez de inventarselo.
Que hace y que NO hace:
- SI: encuentra los puntos de entrada reales (scripts con shebang y binarios
en los primeros niveles de cada proyecto, no los modulos internos).
- SI: saca una descripcion del README, del docstring o de la cabecera, SIN
ejecutar nada. Ejecutar un script desconocido con --help para leerlo seria
ejecutar un script desconocido, que es justo lo que no se quiere hacer a
ciegas.
- NO: ejecuta ninguna herramienta. Esto solo mira.
El resultado se revisa a mano antes de usarlo: es material de terceros y algun
"tool" sera un ejemplo o un exploit, no algo que quieras ofrecer.
"""
import ast
import json
import os
import re
import stat
import sys
COFRE = os.path.expanduser("~/COFRE")
SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/config/jarvis-herramientas.json")
CARPETAS = ["CODERS", "PENTESTERS", "AUTOMATAS", "DOSER", "HARD-WARERS",
"PHISHERS", "PROTECTORS", "REVERSERS", "SNEAKERS"]
# Solo los primeros niveles: un punto de entrada vive cerca de la raiz del
# proyecto, no enterrado en lib/ o en site-packages.
PROFUNDIDAD = 3
FUERA = {"node_modules", ".git", "venv", ".venv", "site-packages", "__pycache__",
"lib", "libs", "tests", "test", "docs", "examples", "vendor", "dist",
"build", ".tox", "exploitdb"}
# Nombres que casi nunca son la herramienta en si
IGNORAR_NOMBRES = {"setup.py", "conftest.py", "__init__.py", "test.py",
"tests.py", "wsgi.py", "manage.py", "conf.py"}
def es_ejecutable(ruta: str) -> bool:
try:
return bool(os.stat(ruta).st_mode & stat.S_IXUSR)
except OSError:
return False
def tiene_shebang(ruta: str) -> bool:
try:
with open(ruta, "rb") as f:
return f.read(2) == b"#!"
except OSError:
return False
def descripcion_readme(carpeta: str) -> str:
"""Primera linea con contenido de un README cercano."""
for nombre in ("README.md", "README.rst", "README.txt", "README"):
ruta = os.path.join(carpeta, nombre)
if not os.path.exists(ruta):
continue
try:
with open(ruta, errors="ignore") as f:
for linea in f:
limpia = linea.strip().lstrip("#").lstrip(">").strip()
# saltar titulos de una palabra, badges y separadores
if len(limpia) > 25 and not limpia.startswith(("![", "[!", "---", "===")):
return limpia[:200]
except OSError:
pass
return ""
def descripcion_script(ruta: str) -> str:
"""Docstring del modulo (Python) o cabecera de comentarios (shell)."""
try:
with open(ruta, errors="ignore") as f:
texto = f.read(4000)
except OSError:
return ""
if ruta.endswith(".py"):
try:
doc = ast.get_docstring(ast.parse(texto))
if doc:
return doc.strip().split("\n")[0][:200]
except (SyntaxError, ValueError):
pass
# cabecera de comentarios: las primeras lineas que empiezan por # tras el shebang
lineas = []
for linea in texto.splitlines()[1:8]:
s = linea.strip()
if s.startswith("#"):
limpio = s.lstrip("#").strip()
if len(limpio) > 15:
lineas.append(limpio)
elif lineas:
break
return " ".join(lineas)[:200]
def recorre():
herramientas = []
vistos = set()
for carpeta in CARPETAS:
raiz = os.path.join(COFRE, carpeta)
if not os.path.isdir(raiz):
continue
for dirpath, dirnames, ficheros in os.walk(raiz):
profundidad = dirpath[len(raiz):].count(os.sep)
if profundidad >= PROFUNDIDAD:
dirnames[:] = []
continue
dirnames[:] = [d for d in dirnames
if d not in FUERA and not d.startswith(".")]
for nombre in ficheros:
if nombre in IGNORAR_NOMBRES:
continue
ruta = os.path.join(dirpath, nombre)
ext = os.path.splitext(nombre)[1].lower()
es_script = ext in (".py", ".sh") and (tiene_shebang(ruta) or es_ejecutable(ruta))
es_binario = not ext and es_ejecutable(ruta) and not tiene_shebang(ruta)
if not (es_script or es_binario):
continue
clave = nombre.lower()
if clave in vistos:
continue
vistos.add(clave)
desc = descripcion_script(ruta) if es_script else ""
if not desc:
desc = descripcion_readme(dirpath)
herramientas.append({
"nombre": os.path.splitext(nombre)[0],
"carpeta": carpeta,
"ruta": ruta,
"tipo": "python" if ext == ".py" else "shell" if ext == ".sh" else "binario",
"descripcion": desc,
"invocar": (f"python3 {ruta}" if ext == ".py"
else f"bash {ruta}" if ext == ".sh"
else ruta),
})
return herramientas
def main():
print("recorriendo COFRE (solo lectura, no se ejecuta nada)...\n", flush=True)
tools = recorre()
porcarpeta = {}
con_desc = 0
for t in tools:
porcarpeta[t["carpeta"]] = porcarpeta.get(t["carpeta"], 0) + 1
if t["descripcion"]:
con_desc += 1
for carpeta in CARPETAS:
if carpeta in porcarpeta:
print(f" {carpeta:<13} {porcarpeta[carpeta]:>4} herramientas")
print(f"\n total {len(tools)}, con descripcion {con_desc} "
f"({100*con_desc//max(1,len(tools))} %)")
with open(SALIDA, "w") as f:
json.dump({"version": 1, "herramientas": tools}, f,
indent=2, ensure_ascii=False)
print(f"\nescrito {SALIDA}")
print("REVISALO a mano antes de usarlo: es material de terceros y algun")
print("'tool' sera un ejemplo o un exploit, no algo que quieras ofrecer.")
return 0
if __name__ == "__main__":
sys.exit(main())

331
config/entrenar.py Executable file
View file

@ -0,0 +1,331 @@
#!/usr/bin/env python3
"""Entrena el reconocimiento solo, sin que nadie hable.
python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py # tanda normal
python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py --rapido # una voz
python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py --aplicar # guarda alias
Como funciona: por cada accion del catalogo se dice su frase en voz alta con
Piper, en varias voces y velocidades y con algo de ruido y sala, se transcribe
con el mismo whisper que usa el asistente, y se mira si la transcripcion llega
a la accion correcta. Lo que no llega se apunta como alias candidato.
Por que sirve: los fallos del reconocedor en castellano son sistematicos, no
aleatorios. "abre el correo" se oye "a ver el correo" siempre, y una tanda de
varios cientos de frases los saca casi todos de una sentada.
Que NO sustituye: esto es voz sintetica, no la suya. Sirve para las colisiones
foneticas del idioma que son la mayoria de los fallos pero no para su acento,
su microfono ni su habitacion. Para eso esta el registro de lo que no entiende
en el uso real, que se revisa con aprender.py. Las dos cosas se suman.
Un alias solo se propone si no choca con otra accion: si "a ver el correo"
pudiera ser dos cosas distintas, se descarta y se avisa.
"""
import argparse
import importlib.util
import json
import os
import random
import shutil
import subprocess
import sys
import tempfile
import time
from collections import defaultdict
CASA = os.path.expanduser("~")
JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS")
CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config")
APP = "io.github.qwersyk.Newelle//master"
PIPER = os.path.join(JARVIS, "voz/piper/piper")
MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos")
WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli")
MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models")
CATALOGO = os.path.expanduser(
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
"active/files/share/newelle/acciones/acciones.json")
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
INFORME = os.path.join(JARVIS, "muestras/entrenamiento.json")
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
# Voces distintas para que no aprenda los defectos de una sola, y velocidades
# distintas porque uno no habla igual con prisa que sin ella.
VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
VELOCIDADES = [1.0, 0.88, 1.15]
# Un poco de sala y de ruido de fondo: en seco el reconocedor acierta mas de lo
# que acertara nunca en una habitacion de verdad, y entrenar con eso da una
# falsa sensacion de que todo va bien.
RUIDOS = [
"", # limpio
"aecho=0.8:0.7:22:0.12", # algo de sala
"volume=0.75,highpass=f=120", # mas lejos del microfono
]
def carga_motor():
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
def dentro(orden, limite=900):
"""Ejecuta algo dentro del flatpak, que es donde vive whisper."""
return subprocess.run(
["flatpak", "run", "--command=bash", APP, "-c", orden],
capture_output=True, text=True, timeout=limite)
def genera(texto, voz, velocidad, ruido, destino):
crudo = tempfile.mktemp(suffix=".wav")
try:
r = subprocess.run(
[PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
"--length_scale", str(1 / velocidad), "--output_file", crudo],
input=texto, capture_output=True, text=True, timeout=60)
if r.returncode != 0 or not os.path.exists(crudo):
return False
filtros = "aresample=16000" + ("," + ruido if ruido else "")
subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", crudo,
"-ac", "1", "-af", filtros, "-y", destino],
check=True, timeout=60)
return os.path.exists(destino)
except (OSError, subprocess.SubprocessError):
return False
finally:
if os.path.exists(crudo):
os.remove(crudo)
# Por tandas y no de una: mil frases son un cuarto de hora, y una sola llamada
# con limite de tiempo se corta a la mitad y se pierde todo lo transcrito. Por
# tandas ademas se puede ver el avance, que en algo que tarda tanto importa.
POR_TANDA = 40
def transcribe_lote(carpeta, ficheros, modelo="base", hilos=8):
"""Transcribe una lista de wav. Devuelve {fichero: texto}."""
salida = {}
ini = time.time()
for i in range(0, len(ficheros), POR_TANDA):
tanda = ficheros[i:i + POR_TANDA]
lista = " ".join(f'"{carpeta}/{n}"' for n in tanda)
orden = (
f'for f in {lista}; do '
f' echo "### $(basename "$f")"; '
f' {WHISPER} -m {MODELOS_STT}/ggml-{modelo}.bin -l es -nt -t {hilos} '
f' -f "$f" 2>/dev/null | tr "\\n" " "; echo; '
f'done')
try:
r = dentro(orden)
except subprocess.SubprocessError as e:
print(f" tanda {i//POR_TANDA + 1} fallo: {e}", flush=True)
continue
actual = None
for linea in r.stdout.splitlines():
if linea.startswith("### "):
actual = linea[4:].strip()
elif actual:
salida[actual] = linea.strip()
actual = None
hechas = min(i + POR_TANDA, len(ficheros))
queda = (time.time() - ini) / hechas * (len(ficheros) - hechas)
print(f" transcritas {hechas}/{len(ficheros)} "
f"({queda/60:.0f} min restantes)", flush=True)
return salida
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--rapido", action="store_true", help="una voz y una velocidad")
ap.add_argument("--aplicar", action="store_true", help="guardar los alias encontrados")
ap.add_argument("--tope", type=int, default=0, help="limitar acciones (para probar)")
ap.add_argument("--reanudar", action="store_true",
help="usar los wav ya generados en vez de rehacerlos")
args = ap.parse_args()
for ruta, que in ((PIPER, "piper"), (WHISPER, "whisper")):
if not os.path.exists(ruta):
print(f"falta {que}: {ruta}")
return 1
catalogo = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE
with open(catalogo) as f:
acciones = json.load(f)["acciones"]
if args.tope:
acciones = acciones[:args.tope]
voces = VOCES[:1] if args.rapido else VOCES
velocidades = VELOCIDADES[:1] if args.rapido else VELOCIDADES
ruidos = RUIDOS[:1] if args.rapido else RUIDOS
motor = carga_motor()
cat = motor.Catalogo([catalogo])
# Las acciones que esperan argumento hay que decirlas CON argumento. Sin el,
# el motor las rechaza a proposito —"busca el fichero" a secas no es una
# orden— y contarlo como fallo del reconocedor es medir mal: en la primera
# tanda hundio el resultado del 48 %, con una cuarta parte de las frases
# condenadas de antemano.
RELLENOS = ["notas", "el informe de ayer", "documentos"]
# Solo la frase principal de cada accion: las variantes ya estan escritas a
# mano y lo que se busca aqui es como se oye la que uno diria de verdad.
trabajos = []
for a in acciones:
if not a["frases"]:
continue
frase = a["frases"][0]
if a.get("captura"):
frase = frase + " " + random.choice(RELLENOS)
for voz in voces:
for vel in velocidades:
trabajos.append((a["id"], frase, voz, vel,
random.choice(ruidos)))
total = len(trabajos)
print(f"{len(acciones)} acciones · {len(voces)} voces · "
f"{len(velocidades)} velocidades = {total} frases habladas")
print("esto tarda; se puede dejar corriendo\n")
carpeta = os.path.join(CONFIG, "entrenamiento")
indice = {f"{n:05d}.wav": (ident, frase, voz, vel)
for n, (ident, frase, voz, vel, _) in enumerate(trabajos, 1)}
if args.reanudar and os.path.isdir(carpeta):
indice = {n: v for n, v in indice.items()
if os.path.exists(os.path.join(carpeta, n))}
print(f"reanudando con {len(indice)} frases ya generadas\n", flush=True)
else:
shutil.rmtree(carpeta, ignore_errors=True)
os.makedirs(carpeta, exist_ok=True)
vivos, ini = {}, time.time()
for n, (ident, frase, voz, vel, ruido) in enumerate(trabajos, 1):
nombre = f"{n:05d}.wav"
if genera(frase, voz, vel, ruido, os.path.join(carpeta, nombre)):
vivos[nombre] = (ident, frase, voz, vel)
if n % 25 == 0 or n == total:
queda = (time.time() - ini) / n * (total - n)
print(f" generadas {n}/{total} ({queda/60:.0f} min restantes)",
flush=True)
indice = vivos
print(f"\ntranscribiendo {len(indice)} frases...", flush=True)
textos = transcribe_lote(carpeta, sorted(indice))
print(f"transcritas {len(textos)}\n", flush=True)
aciertos = 0
candidatos = defaultdict(lambda: defaultdict(int))
for nombre, (ident, frase, voz, vel) in indice.items():
oido = textos.get(nombre, "").strip()
if not oido:
continue
encontrada, _ = cat.busca(oido)
if encontrada is not None and encontrada.id == ident:
aciertos += 1
else:
candidatos[ident][motor.normaliza(oido)] += 1
evaluadas = sum(1 for n in indice if textos.get(n))
print(f"aciertos: {aciertos}/{evaluadas} "
f"({100*aciertos/max(1,evaluadas):.0f} %)\n")
# Un alias solo vale si no puede ser otra cosa. Si la misma frase mal oida
# aparece en dos acciones, elegir una seria peor que no hacer nada.
duenos = defaultdict(set)
for ident, frases in candidatos.items():
for f in frases:
duenos[f].add(ident)
propuestos, descartados = defaultdict(list), []
for ident, frases in candidatos.items():
for f, veces in sorted(frases.items(), key=lambda x: -x[1]):
if len(duenos[f]) > 1:
descartados.append((f, sorted(duenos[f])))
continue
if len(f.split()) < 2: # una palabra suelta engancha de todo
continue
# Si lo oido no se parece a lo dicho, es que el reconocedor se
# perdio del todo, no que haya una confusion fonetica estable.
# Guardarlo como alias meteria una frase arbitraria en el catalogo
# y algun dia enganchara una conversacion.
original = next((a["frases"][0] for a in acciones if a["id"] == ident), "")
if motor.parecido(f, motor.normaliza(original)) < 0.55:
descartados.append((f, [ident + " (irreconocible)"]))
continue
if cat.busca(f)[0] is not None: # ya lo coge otra cosa
continue
propuestos[ident].append((f, veces))
if propuestos:
print("alias que faltan (frase mal oida -> accion):")
for ident, lista in sorted(propuestos.items()):
for f, veces in lista[:3]:
print(f' "{f}" -> {ident} ({veces}x)')
else:
print("no falta ningun alias: todo lo dicho llega a su accion")
if descartados:
print(f"\n{len(descartados)} descartados por ambiguos "
f"(la misma frase encaja en dos acciones):")
for f, ids in descartados[:5]:
print(f' "{f}" -> {", ".join(ids)}')
os.makedirs(os.path.dirname(INFORME), exist_ok=True)
with open(INFORME, "w") as f:
json.dump({
"frases": evaluadas, "aciertos": aciertos,
"propuestos": {k: v for k, v in propuestos.items()},
"ambiguos": descartados,
}, f, indent=2, ensure_ascii=False)
print(f"\ninforme en {INFORME}")
if args.aplicar and propuestos:
guarda(propuestos, catalogo)
elif propuestos:
print("para guardarlos: vuelve a lanzarlo con --aplicar")
print(f"los wav se quedan en {carpeta} por si hay que repasar algo")
return 0
def guarda(propuestos, catalogo):
with open(catalogo) as f:
original = {a["id"]: a for a in json.load(f)["acciones"]}
try:
with open(PROPIO) as f:
propio = json.load(f)
except (OSError, json.JSONDecodeError):
propio = {"version": 1, "acciones": []}
porid = {a["id"]: a for a in propio.get("acciones", [])}
n = 0
for ident, lista in propuestos.items():
entrada = porid.get(ident)
if entrada is None:
entrada = dict(original[ident])
porid[ident] = entrada
propio.setdefault("acciones", []).append(entrada)
for frase, _ in lista:
if frase not in entrada["frases"]:
entrada["frases"].append(frase)
n += 1
with open(PROPIO, "w") as f:
json.dump(propio, f, indent=2, ensure_ascii=False)
print(f"{n} alias guardados en {PROPIO}")
print("se aplican al abrir la pestaña JARVIS otra vez")
if __name__ == "__main__":
sys.exit(main())

156
config/indexar.py Executable file
View file

@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Prepara el corpus de COFRE para que JARVIS lo consulte.
python3 ~/COFRE/CODERS/JARVIS/config/indexar.py # ver que entraria
python3 ~/COFRE/CODERS/JARVIS/config/indexar.py --hacerlo # copiarlo
python3 ~/COFRE/CODERS/JARVIS/config/indexar.py --hacerlo --solo-notas
Que hace: recorre las carpetas de COFRE, se queda SOLO con lo que es
conocimiento y lo copia a la carpeta de documentos de Newelle, que es de donde
lee el RAG. Despues hay que pulsar "Update Index" en los ajustes de la app.
Por que hace falta filtrar. Medido en esta maquina:
COFRE entero (las nueve carpetas) ~120 GB
de eso, ISOs, maquinas virtuales,
APKs, audio y un diccionario de
contraseñas de 9,1 GB ~100 GB
texto de menos de 100 KB 76,6 MB en 35 098 ficheros
quitando exploitdb 20,8 MB en 4 548 ficheros
exploitdb se queda fuera a proposito: son 32 000 ficheros que aplastarian el
indice y enterrarian tus notas, y ademas NO hacen falta ahi. Tienes
`searchsploit` instalado, que busca en ellos de forma exacta e instantanea. Un
indice de vectores es peor que una busqueda exacta cuando lo que buscas es el
nombre literal de un exploit.
Los nombres se aplanan conservando la ruta PENTESTERS__OSCP__nmap.md para que
al responder se pueda ver de donde salio cada cosa.
"""
import argparse
import hashlib
import os
import shutil
import sys
COFRE = os.path.expanduser("~/COFRE")
DESTINO = os.path.expanduser(
"~/.var/app/io.github.qwersyk.Newelle/config/models/documents")
CARPETAS = ["CODERS", "PENTESTERS", "AUTOMATAS", "DOSER", "HARD-WARERS",
"PHISHERS", "PROTECTORS", "REVERSERS", "SNEAKERS"]
EXTENSIONES = {".md", ".txt", ".rst", ".adoc"}
# Por tamaño: por encima de esto ya no es prosa, son datos. El diccionario
# kaonashi.txt son 9,1 GB de contraseñas.
TAMANO_MAXIMO = 100 * 1024
TAMANO_MINIMO = 120 # menos de esto no dice nada
# Carpetas que no aportan conocimiento y si mucho ruido
FUERA = {
"exploitdb", # 32 000 ficheros; para eso esta searchsploit
"node_modules", ".git", "venv", ".venv", "site-packages", "__pycache__",
"isos", "VirtualBox VMs", "build", "dist", ".cache", "target",
"flatpak-app", ".flatpak-builder", "rag_cache", "entrenamiento",
}
# Ficheros que son plantilla o licencia y solo ensucian
NOMBRES_FUERA = {"LICENSE.txt", "LICENSE.md", "CHANGELOG.md", "requirements.txt"}
def interesa(ruta: str, nombre: str) -> bool:
ext = os.path.splitext(nombre)[1].lower()
if ext not in EXTENSIONES or nombre in NOMBRES_FUERA:
return False
try:
tam = os.path.getsize(ruta)
except OSError:
return False
return TAMANO_MINIMO <= tam <= TAMANO_MAXIMO
def recorre(solo_notas: bool):
"""Devuelve [(origen, nombre_plano)] de todo lo que merece indexarse."""
salida = []
for carpeta in CARPETAS:
raiz = os.path.join(COFRE, carpeta)
if not os.path.isdir(raiz):
continue
for dirpath, dirnames, ficheros in os.walk(raiz):
dirnames[:] = [d for d in dirnames
if d not in FUERA and not d.startswith(".")]
for nombre in ficheros:
if solo_notas and not nombre.lower().endswith(".md"):
continue
origen = os.path.join(dirpath, nombre)
if not interesa(origen, nombre):
continue
relativa = os.path.relpath(origen, COFRE)
plano = relativa.replace(os.sep, "__")
if len(plano) > 180: # algunos nombres son kilometricos
corto = hashlib.sha256(plano.encode()).hexdigest()[:8]
plano = plano[:150] + "_" + corto + os.path.splitext(plano)[1]
salida.append((origen, plano))
return salida
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--hacerlo", action="store_true", help="copiar de verdad")
ap.add_argument("--solo-notas", action="store_true",
help="solo .md, sin los .txt sueltos")
ap.add_argument("--limpiar", action="store_true",
help="vaciar el destino antes de copiar")
args = ap.parse_args()
print("recorriendo COFRE...", flush=True)
lista = recorre(args.solo_notas)
porcarpeta, total = {}, 0
for origen, _ in lista:
carpeta = os.path.relpath(origen, COFRE).split(os.sep)[0]
tam = os.path.getsize(origen)
n, b = porcarpeta.get(carpeta, (0, 0))
porcarpeta[carpeta] = (n + 1, b + tam)
total += tam
print(f"\n{'carpeta':<14}{'ficheros':>10}{'texto':>12}")
for carpeta in CARPETAS:
if carpeta in porcarpeta:
n, b = porcarpeta[carpeta]
print(f"{carpeta:<14}{n:>10}{b/1048576:>10.1f} MB")
print(f"{'TOTAL':<14}{len(lista):>10}{total/1048576:>10.1f} MB")
print(f"\nfragmentos de 256 tokens: ~{total // 1024}")
if not args.hacerlo:
print("\nesto es solo el recuento; para copiarlo: --hacerlo")
return 0
if args.limpiar and os.path.isdir(DESTINO):
shutil.rmtree(DESTINO)
os.makedirs(DESTINO, exist_ok=True)
copiados = saltados = 0
for origen, plano in lista:
destino = os.path.join(DESTINO, plano)
try:
# solo si cambio: reindexar 4500 ficheros iguales no tiene sentido
if (os.path.exists(destino)
and os.path.getmtime(destino) >= os.path.getmtime(origen)):
saltados += 1
continue
shutil.copy2(origen, destino)
copiados += 1
except OSError as e:
print(f" no se pudo copiar {plano}: {e}")
print(f"\n{copiados} copiados, {saltados} ya estaban al dia")
print(f"en {DESTINO}")
print("\nAhora, en Newelle: Ajustes -> RAG -> Update Index")
print("La primera vez tarda; son unos 21 000 fragmentos.")
return 0
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because it is too large Load diff

58
config/jarvis-piper.sh Executable file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Voz de JARVIS con Piper: castellano de España, no latinoamericano.
#
# jarvis-piper.sh <fichero.wav> <texto>
#
# Kokoro, que es lo que trae Newelle, no tiene ni una voz de España: sus dos
# masculinas en español (em_alex, em_santa) suenan latinas. Piper si las tiene,
# es libre y corre local. Se invoca desde el handler custom_command, que ya
# ejecuta en el host.
#
# El descenso de tono se hace aqui y no en la cadena de la app porque alli se
# usa asetrate, que necesita saber la frecuencia de muestreo: esta calculada
# para los 24 kHz de Kokoro y Piper saca 22050, asi que aplicaria una bajada
# distinta de la pedida. Aqui se usa rubberband, que trabaja en semitonos y le
# da igual la frecuencia. Existe en el ffmpeg del sistema pero no en el del
# flatpak, y este script ya corre fuera.
#
# Ajustes por entorno:
# JARVIS_PIPER_VOZ es_ES-sharvard-medium (defecto) | es_ES-davefx-medium
# | es_ES-carlfm-x_low
# JARVIS_PIPER_TONO 0.90 (defecto). 1.0 no toca el tono; por debajo de
# 0.85 empieza a sonar artificial.
set -uo pipefail
# La raiz se deduce de donde esta este fichero, no del $HOME de nadie: asi el
# arbol se puede mover o clonar en otra maquina y sigue funcionando sin editar
# rutas. `cd -P` resuelve enlaces simbolicos, que el lanzador de escritorio
# puede invocarlo por uno.
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
BASE=$RAIZ/voz
VOZ=${JARVIS_PIPER_VOZ:-es_ES-sharvard-medium}
TONO=${JARVIS_PIPER_TONO:-0.90}
SALIDA=${1:-}
TEXTO=${2:-}
[ -n "$SALIDA" ] || { echo "falta el fichero de salida" >&2; exit 1; }
[ -n "$TEXTO" ] || { echo "sin texto que decir" >&2; exit 1; }
MODELO=$BASE/modelos/$VOZ.onnx
[ -f "$MODELO" ] || { echo "no esta el modelo $MODELO" >&2; exit 1; }
CRUDO=$(mktemp --suffix=.wav)
trap 'rm -f "$CRUDO"' EXIT
printf '%s' "$TEXTO" | "$BASE/piper/piper" \
--model "$MODELO" --output_file "$CRUDO" 2>/dev/null
[ -s "$CRUDO" ] || { echo "piper no genero audio" >&2; exit 1; }
if [ "$TONO" = "1.0" ] || ! command -v ffmpeg >/dev/null; then
cp "$CRUDO" "$SALIDA"
else
ffmpeg -hide_banner -loglevel error -i "$CRUDO" \
-af "rubberband=pitch=$TONO" -y "$SALIDA" 2>/dev/null \
|| cp "$CRUDO" "$SALIDA"
fi

46
config/jarvis-voz.sh Executable file
View file

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Voz de JARVIS con la clonada (XTTS-v2) servida desde cache.
#
# jarvis-voz.sh <fichero.wav> <texto>
#
# Si el texto esta pre-generado en cache_voz/, lo sirve al instante (un cp, sin
# tocar GPU). Si no, cae a Piper. La clave es sha1(texto normalizado), la misma
# que calcula generar_cache_voz.py -> los aciertos son exactos.
#
# El clon ya trae el tono correcto, asi que en un acierto NO se aplica el
# descenso de tono de Piper; solo se copia. En un fallo, Piper hace lo suyo
# (incluido su rubberband) como hasta ahora.
set -uo pipefail
# La raiz se deduce de donde esta este fichero, no del $HOME de nadie: asi el
# arbol se puede mover o clonar en otra maquina y sigue funcionando sin editar
# rutas. `cd -P` resuelve enlaces simbolicos, que el lanzador de escritorio
# puede invocarlo por uno.
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
BASE=$RAIZ/voz
CACHE=$BASE/cache_voz
PIPER=$RAIZ/config/jarvis-piper.sh
SALIDA=${1:-}
TEXTO=${2:-}
[ -n "$SALIDA" ] || { echo "falta el fichero de salida" >&2; exit 1; }
[ -n "$TEXTO" ] || { echo "sin texto que decir" >&2; exit 1; }
# clave = sha1(texto con espacios colapsados y recortado), identica a la de
# Python: re.sub(r'\s+',' ',t).strip() -> sha1(utf-8)
T=$(printf '%s' "$TEXTO" | tr -s '[:space:]' ' ' | sed 's/^ //; s/ $//')
KEY=$(printf '%s' "$T" | sha1sum | cut -d' ' -f1)
HIT=$CACHE/$KEY.wav
if [ -f "$HIT" ]; then
cp -f "$HIT" "$SALIDA"
exit 0
fi
# fallo de cache -> registrar la frase (normalizada) para calentar la cache
# luego con la voz clonada, y de momento reescribir siglas + Piper.
printf '%s\n' "$T" >> "$BASE/misses.log" 2>/dev/null || true
TEXTO2=$(python3 "$BASE/pronuncia.py" "$TEXTO" 2>/dev/null)
[ -n "$TEXTO2" ] || TEXTO2=$TEXTO
exec "$PIPER" "$SALIDA" "$TEXTO2"

484
config/mejora_nocturna.py Executable file
View file

@ -0,0 +1,484 @@
#!/usr/bin/env python3
"""Bucle de mejora que corre solo, ronda tras ronda, y se protege de empeorar.
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py # hasta la mañana
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py --horas 2 # un rato
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py --ronda # una sola
Que hace en cada ronda:
1. Dice en voz alta (Piper) las frases del catalogo, en varias voces,
velocidades y con ruido y sala aleatorios, distintos cada ronda.
2. Las transcribe con el mismo Whisper del asistente.
3. Mira que porcentaje llega a su accion. Ese es el marcador de la ronda.
4. De lo que NO llega, se queda con los fallos SISTEMATICOS (repetidos) y
SIN AMBIGUEDAD (que no puedan ser dos acciones), y los propone como alias.
5. GUARDA: antes de aplicar nada, comprueba en una copia que
a) el acierto sube, y
b) la conversacion normal SIGUE yendo al modelo (no se cuela ninguna).
Solo si las dos cosas se cumplen, aplica los alias de verdad.
6. Copia de seguridad del catalogo antes de tocarlo, y todo al registro.
Por que es seguro dejarlo sin mirar: nunca aplica algo que baje el acierto ni
que haga que una frase de charla acabe ejecutando un comando. Si una ronda
empeorara, revierte. Y cada cambio queda respaldado, asi que siempre se puede
volver atras a mano.
Que NO hace: reentrenar el modelo (no cabe en 4 GB de VRAM) ni tocar Whisper o
Piper. Mejora la capa de emparejamiento alias y umbrales, que es donde estan
casi todos los fallos y donde una mejora es inmediata, legible y reversible.
En marcha se puede seguir con:
tail -f ~/COFRE/CODERS/JARVIS/muestras/mejora/registro.log
cat ~/COFRE/CODERS/JARVIS/muestras/mejora/marcador.json
"""
import argparse
import importlib.util
import json
import os
import random
import shutil
import subprocess
import sys
import tempfile
import time
from collections import defaultdict
CASA = os.path.expanduser("~")
JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS")
CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config")
APP = "io.github.qwersyk.Newelle//master"
PIPER = os.path.join(JARVIS, "voz/piper/piper")
MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos")
WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli")
MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models")
CATALOGO = os.path.expanduser(
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
"active/files/share/newelle/acciones/acciones.json")
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
SALA = os.path.join(JARVIS, "muestras/mejora")
REGISTRO = os.path.join(SALA, "registro.log")
MARCADOR = os.path.join(SALA, "marcador.json")
COPIAS = os.path.join(SALA, "copias")
WAVS = os.path.join(CONFIG, "mejora_wavs")
VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
# Cada ronda escoge una rejilla distinta de velocidades y ruidos, para que no
# se estanque midiendo siempre lo mismo y siga sacando fallos raros.
VELOCIDADES = [0.85, 0.92, 1.0, 1.08, 1.18]
RUIDOS = [
"",
"aecho=0.8:0.7:22:0.12",
"volume=0.72,highpass=f=120",
"aecho=0.6:0.5:35:0.2,volume=0.8",
"highpass=f=150,lowpass=f=6500", # microfono cutre
"volume=0.65", # lejos
]
RELLENOS = ["notas", "el informe", "documentos", "la reunion de ayer", "gatos"]
# Frases de charla que NUNCA deben acabar en una accion. Es el guardarrail: si
# despues de aplicar alias alguna se cuela, la ronda se revierte.
CHARLA = [
"cuentame un chiste sobre gatos", "que opinas del proyecto",
"hola que tal estas", "gracias por todo", "no se que hacer con esto",
"me parece que esto no funciona bien", "buenas tardes que tal",
"cuentame algo interesante", "estoy cansado de programar",
"que raro todo esto la verdad", "a ver si esto va mejor ahora",
"pues no tengo ni idea de que decir", "vale perfecto muchas gracias",
"oye una cosa que te queria preguntar",
]
TOPE_ALIAS_POR_ACCION = 25 # que una accion no acumule cientos de alias
UMBRAL_SISTEMATICO = 2 # veces que hay que fallar igual para creerselo
DRY_PARA_PARAR = 6 # rondas seguidas sin nada nuevo -> converge
def log(msg):
linea = f"[{time.strftime('%H:%M:%S')}] {msg}"
print(linea, flush=True)
try:
with open(REGISTRO, "a") as f:
f.write(linea + "\n")
except OSError:
pass
def disco_libre_gb() -> float:
"""GB libres en la particion del home. Guardarrail: aunque el bucle apenas
ocupa 3 MB por ronda y los limpia, si algo mas llenara el disco durante la
noche conviene parar en vez de dejar el sistema sin espacio."""
try:
s = os.statvfs(CASA)
return s.f_bavail * s.f_frsize / 1e9
except OSError:
return 999.0
def carga_motor():
# se recarga cada ronda: el catalogo propio cambia y el motor debe verlo
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
def dentro(orden, limite=1200):
return subprocess.run(
["flatpak", "run", "--command=bash", APP, "-c", orden],
capture_output=True, text=True, timeout=limite)
def genera(texto, voz, velocidad, ruido, destino):
crudo = tempfile.mktemp(suffix=".wav")
try:
r = subprocess.run(
[PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
"--length_scale", str(1 / velocidad), "--output_file", crudo],
input=texto, capture_output=True, text=True, timeout=60)
if r.returncode != 0 or not os.path.exists(crudo):
return False
filtros = "aresample=16000" + ("," + ruido if ruido else "")
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error",
"-i", crudo, "-ac", "1", "-af", filtros, "-y", destino],
check=True, timeout=60)
return os.path.exists(destino)
except (OSError, subprocess.SubprocessError):
return False
finally:
if os.path.exists(crudo):
os.remove(crudo)
def transcribe(carpeta, ficheros, hilos=8, por_tanda=40):
salida = {}
for i in range(0, len(ficheros), por_tanda):
tanda = ficheros[i:i + por_tanda]
lista = " ".join(f'"{carpeta}/{n}"' for n in tanda)
orden = (f'for f in {lista}; do echo "### $(basename "$f")"; '
f'{WHISPER} -m {MODELOS_STT}/ggml-base.bin -l es -nt -t {hilos} '
f'-f "$f" 2>/dev/null | tr "\\n" " "; echo; done')
try:
r = dentro(orden)
except subprocess.SubprocessError:
continue
actual = None
for linea in r.stdout.splitlines():
if linea.startswith("### "):
actual = linea[4:].strip()
elif actual:
salida[actual] = linea.strip()
actual = None
return salida
def catalogo_base():
ruta = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE
with open(ruta) as f:
return json.load(f)["acciones"], ruta
def evalua(motor, textos, indice):
"""(aciertos, total, candidatos{id: {frase_mal: veces}})."""
aciertos = 0
candidatos = defaultdict(lambda: defaultdict(int))
for nombre, (ident, frase) in indice.items():
oido = textos.get(nombre, "").strip()
if not oido:
continue
a, _ = _CAT.busca(oido)
if a is not None and a.id == ident:
aciertos += 1
else:
candidatos[ident][motor.normaliza(oido)] += 1
return aciertos, len([1 for n in indice if textos.get(n)]), candidatos
_CAT = None
# Palabras de funcion: aparecen en cualquier frase y no distinguen una orden de
# una conversacion. Un alias hecho solo de estas ("que me tengo") es una trampa.
VACIAS = {
"que", "de", "la", "el", "los", "las", "un", "una", "me", "te", "se", "lo",
"mi", "tu", "su", "es", "esta", "estan", "hay", "por", "para", "con", "en",
"y", "o", "a", "al", "del", "como", "cuanto", "cuanta", "muy", "ya", "he",
"ha", "tengo", "tiene", "esto", "eso", "aqui", "ahi", "va", "van", "asi",
}
def contenido(frase: str) -> set:
"""Las palabras con significado: las que de verdad identifican la orden."""
return {p for p in frase.split() if len(p) > 3 and p not in VACIAS}
def filtra_candidatos(motor, candidatos, acciones):
"""Solo los alias que valen: sistematicos, sin ambiguedad, y que conservan
una palabra distintiva de la orden original.
El guardarrail clave es el ultimo: una confusion del reconocedor que aun
sirve de alias mantiene al menos una palabra con significado ("kernel",
"wikipedia", "bateria"). Si las perdio todas y solo quedan palabras de
relleno, es indistinguible de una frase de charla y se descarta, por mucho
que se haya repetido. Es lo que separa "que kernel tengo" -> "que kernel me
tengo" (vale) de "que me tengo" (no vale).
"""
porfrase = defaultdict(set)
for ident, frases in candidatos.items():
for f in frases:
porfrase[f].add(ident)
porid = {a["id"]: a for a in acciones}
propuestos = defaultdict(list)
for ident, frases in candidatos.items():
accion = porid.get(ident)
if accion is None:
continue
# Las de argumento no generan alias: el relleno de prueba ("notas") se
# cuela en la transcripcion y quedaria horneado en el alias. Su
# emparejamiento ya es por parecido del arranque, que las cubre.
if accion.get("captura"):
continue
orig = motor.normaliza(accion["frases"][0] if accion["frases"] else "")
claves = contenido(orig)
for f, veces in frases.items():
if veces < UMBRAL_SISTEMATICO:
continue
if len(porfrase[f]) > 1: # la misma frase, dos acciones
continue
if len(f.split()) < 2: # una palabra engancha de todo
continue
if _CAT.busca(f)[0] is not None: # ya lo coge algo
continue
if motor.parecido(f, orig) < 0.6: # se perdio del todo: ruido
continue
# conserva una palabra distintiva? exacta o casi (el reconocedor
# cambia una letra: "wikipedia" -> "wikipeda")
palabras_f = set(f.split())
if claves and not any(
c in palabras_f or any(motor.parecido(c, w) > 0.8 for w in palabras_f)
for c in claves):
continue
propuestos[ident].append((f, veces))
return propuestos
def aplica(propuestos, acciones_orig):
"""Escribe los alias en el catalogo propio. Devuelve cuantos."""
try:
with open(PROPIO) as f:
propio = json.load(f)
except (OSError, json.JSONDecodeError):
propio = {"version": 1, "acciones": []}
porid = {a["id"]: a for a in propio.get("acciones", [])}
original = {a["id"]: a for a in acciones_orig}
n = 0
for ident, lista in propuestos.items():
entrada = porid.get(ident)
if entrada is None:
entrada = dict(original[ident])
porid[ident] = entrada
propio.setdefault("acciones", []).append(entrada)
if len(entrada["frases"]) >= TOPE_ALIAS_POR_ACCION:
continue
for f, _ in lista:
if f not in entrada["frases"]:
entrada["frases"].append(f)
n += 1
with open(PROPIO, "w") as f:
json.dump(propio, f, indent=2, ensure_ascii=False)
return n
def charla_se_cuela(motor):
"""True si alguna frase de charla acaba en una accion. El guardarrail."""
for frase in CHARLA:
a, _ = _CAT.busca(frase)
if a is not None:
return frase, a.id
return None
def marcador_guarda(datos):
try:
with open(MARCADOR, "w") as f:
json.dump(datos, f, indent=2, ensure_ascii=False)
except OSError:
pass
def copia_catalogo(ronda):
if not os.path.exists(PROPIO):
return
os.makedirs(COPIAS, exist_ok=True)
shutil.copy2(PROPIO, os.path.join(COPIAS, f"ronda_{ronda:03d}.json"))
def una_ronda(motor, ronda, muestras_por_acap):
global _CAT
acciones, _ = catalogo_base()
# muestras de esta ronda: condiciones aleatorias, distintas cada vez
trabajos = []
for a in acciones:
if not a["frases"]:
continue
frase = a["frases"][0]
if a.get("captura"):
frase += " " + random.choice(RELLENOS)
for _ in range(muestras_por_acap):
trabajos.append((a["id"], frase, random.choice(VOCES),
random.choice(VELOCIDADES), random.choice(RUIDOS)))
random.shuffle(trabajos)
shutil.rmtree(WAVS, ignore_errors=True)
os.makedirs(WAVS, exist_ok=True)
indice = {}
for n, (ident, frase, voz, vel, ruido) in enumerate(trabajos, 1):
nombre = f"{n:05d}.wav"
if genera(frase, voz, vel, ruido, os.path.join(WAVS, nombre)):
indice[nombre] = (ident, frase)
log(f"ronda {ronda}: {len(indice)} frases dichas, transcribiendo...")
textos = transcribe(WAVS, sorted(indice))
# medir ANTES de tocar nada
_CAT = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
or [CATALOGO_FUENTE])
aciertos, total, candidatos = evalua(motor, textos, indice)
antes = 100 * aciertos / max(1, total)
log(f"ronda {ronda}: acierto {antes:.1f} % ({aciertos}/{total})")
propuestos = filtra_candidatos(motor, candidatos, acciones)
nuevos = sum(len(v) for v in propuestos.values())
if not nuevos:
log(f"ronda {ronda}: sin alias nuevos que valgan")
return antes, 0, False
# aplicar en el catalogo propio, con copia de seguridad antes
copia_catalogo(ronda)
n = aplica(propuestos, acciones)
# revalidar: recargar el catalogo con los alias puestos
_CAT = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
or [CATALOGO_FUENTE])
# GUARDARRAIL 1: la charla no se cuela
cuela = charla_se_cuela(motor)
if cuela:
frase, ident = cuela
log(f"ronda {ronda}: REVERTIDA — '{frase}' se colaria en {ident}")
shutil.copy2(os.path.join(COPIAS, f"ronda_{ronda:03d}.json"), PROPIO)
return antes, 0, False
# GUARDARRAIL 2: el acierto sube en las mismas muestras
aciertos2, _, _ = evalua(motor, textos, indice)
despues = 100 * aciertos2 / max(1, total)
if despues < antes - 0.5:
log(f"ronda {ronda}: REVERTIDA — el acierto bajaria a {despues:.1f} %")
shutil.copy2(os.path.join(COPIAS, f"ronda_{ronda:03d}.json"), PROPIO)
return antes, 0, False
log(f"ronda {ronda}: +{n} alias · acierto {antes:.1f} -> {despues:.1f} %")
for ident, lista in sorted(propuestos.items()):
for f, v in lista[:2]:
log(f" {ident} <- \"{f}\" ({v}x)")
return despues, n, True
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--horas", type=float, default=0, help="parar tras N horas")
ap.add_argument("--ronda", action="store_true", help="una sola ronda")
ap.add_argument("--muestras", type=int, default=3,
help="muestras por accion y ronda")
ap.add_argument("--dry", type=int, default=DRY_PARA_PARAR,
help="rondas secas seguidas antes de parar")
args = ap.parse_args()
for ruta, que in ((PIPER, "piper"), (WHISPER, "whisper")):
if not os.path.exists(ruta):
print(f"falta {que}: {ruta}")
return 1
os.makedirs(SALA, exist_ok=True)
limite = time.time() + args.horas * 3600 if args.horas else None
motor = carga_motor()
log("=" * 56)
log(f"arranca la mejora nocturna · {args.muestras} muestras/accion/ronda")
if limite:
log(f"parara en {args.horas} h")
else:
log("corre hasta converger o hasta que la pares")
historial = []
dry = 0
ronda = 0
try:
while True:
ronda += 1
libre = disco_libre_gb()
if libre < 10:
log(f'PARADA: solo quedan {libre:.1f} GB de disco. Fin por seguridad.')
break
ini = time.time()
try:
score, nuevos, aplicado = una_ronda(motor, ronda, args.muestras)
except Exception as e:
log(f"ronda {ronda}: error, se salta — {type(e).__name__}: {e}")
time.sleep(5)
continue
historial.append({"ronda": ronda, "acierto": round(score, 1),
"alias_nuevos": nuevos,
"minutos": round((time.time() - ini) / 60, 1)})
marcador_guarda({
"actualizado": time.strftime("%Y-%m-%d %H:%M"),
"rondas": ronda, "ultimo_acierto": round(score, 1),
"alias_totales": alias_totales(),
"historial": historial[-40:],
})
dry = dry + 1 if nuevos == 0 else 0
if args.ronda:
break
if dry >= args.dry:
log(f"converge: {DRY_PARA_PARAR} rondas sin nada nuevo. Fin.")
break
if limite and time.time() > limite:
log("se acabo el tiempo pedido. Fin.")
break
except KeyboardInterrupt:
log("parada a mano")
shutil.rmtree(WAVS, ignore_errors=True)
log(f"terminado tras {ronda} rondas · acierto final "
f"{historial[-1]['acierto'] if historial else '?'} % · "
f"{alias_totales()} alias en el catalogo propio")
log("los cambios estan en jarvis-acciones.json; se aplican al abrir JARVIS")
return 0
def alias_totales():
try:
with open(PROPIO) as f:
d = json.load(f)
base = {a["id"]: len(a["frases"]) for a in
json.load(open(CATALOGO if os.path.exists(CATALOGO)
else CATALOGO_FUENTE))["acciones"]}
return sum(max(0, len(a["frases"]) - base.get(a["id"], 0))
for a in d.get("acciones", []))
except (OSError, json.JSONDecodeError):
return 0
if __name__ == "__main__":
sys.exit(main())

66
config/muestra_voz.py Normal file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Genera muestras de la voz JARVIS: seca y con tratamiento.
Se ejecuta DENTRO del flatpak, que es donde viven Kokoro y ffmpeg:
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
~/COFRE/CODERS/JARVIS/config/muestra_voz.py
Sirve para juzgar la voz de oido antes de montar una conversacion entera,
y para comparar el efecto del filtro (que es la mitad del caracter JARVIS).
Deja los wav en ~/COFRE/CODERS/JARVIS/muestras/.
"""
import os
import subprocess
import sys
sys.path.insert(0, "/app/share/newelle")
# Los modulos que Newelle instala bajo demanda (kokoro-onnx, soundfile...) no
# van al site-packages del sistema sino a config/pip, que la app añade al
# sys.path al arrancar. Un script suelto tiene que hacerlo a mano.
sys.path.insert(0, os.path.expanduser(
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
from gi.repository import Gio # noqa: E402
from newelle.handlers.tts.kokoro_handler import KokoroTTSHandler # noqa: E402
from newelle.handlers.tts.tts import jarvis_audio_filter # noqa: E402
FRASE = ("Good evening, sir. All systems are running locally. "
"You have 846 gigabytes free and the GPU is idle.")
SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/muestras")
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
def main():
os.makedirs(SALIDA, exist_ok=True)
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
handler = KokoroTTSHandler(settings, os.path.join(CONFIG, "models"))
if not handler.is_installed():
print("Kokoro no instalado, instalando (pip + modelos, tarda)...")
handler.install()
print("instalado")
voz = handler.get_current_voice()
print(f"voz: {voz}")
seca = os.path.join(SALIDA, "jarvis_seca.wav")
handler.save_audio(FRASE, seca)
print(f"seca: {seca} ({os.path.getsize(seca)} bytes)")
filtro = jarvis_audio_filter()
tratada = os.path.join(SALIDA, "jarvis_tratada.wav")
subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error",
"-i", seca, "-af", filtro, "-y", tratada],
check=True,
)
print(f"tratada: {tratada} ({os.path.getsize(tratada)} bytes)")
print(f"filtro: {filtro}")
return 0
if __name__ == "__main__":
sys.exit(main())

207
config/noche.sh Executable file
View file

@ -0,0 +1,207 @@
#!/usr/bin/env bash
# Pipeline nocturno: JARVIS se mejora solo mientras nadie mira.
#
# Cada ciclo hace lo mismo: mira los datos que ha dejado el uso real, propone
# un cambio, lo aplica, y lo somete a config/puerta.sh. Si la puerta dice que
# no, se revierte ENTERO con git y se pasa al siguiente. Nada se queda sin
# pasar por ahi.
#
# Por que git y no una copia: el arbol ya esta versionado, `git checkout` es
# atomico y deja rastro de lo que se intento. Al terminar la noche se puede ver
# commit a commit que se cambio y por que.
#
# noche.sh corre hasta que se le diga que pare
# noche.sh --ciclos 5 solo cinco vueltas
# noche.sh --seco propone y valida, pero NO commitea (para mirar)
#
# Para pararlo: touch ~/COFRE/CODERS/JARVIS/PARAR
#
# ── Que puede mejorar solo, y que no ────────────────────────────────────────
#
# Se mejora solo lo que se puede VALIDAR solo. Eso es:
# · el vocabulario: alias nuevos para ordenes que no se entendieron
# · las frases del catalogo: variantes de las que fallan
# · la voz: regenerar en la voz buena las frases que cayeron a la de reserva
# · el prompt: mantenerlo al dia con el catalogo
# · acciones nuevas a partir de lo que se pidio y no existia
#
# NO se toca solo el motor, el panel ni la logica de la aplicacion. No porque
# sea imposible generarlo, sino porque no hay forma automatica de comprobar que
# un cambio ahi es bueno: la suite dice si algo se rompio, no si algo mejoro.
# Un bucle que reescribe su propio validador acaba pasando siempre.
set -uo pipefail
# ── DESACTIVADO EL 15 DE AGOSTO DE 2026 ────────────────────────────────────
#
# Este bucle modifica el arbol de `newelle/` y COMMITEA solo. Ese arbol esta
# congelado en la etiqueta jarvis-newelle-final desde que el desarrollo se
# mudo a nucleo/: dejarlo correr escribiria commits automaticos sobre un
# repositorio que por definicion ya no cambia.
#
# Se ha quitado tambien su temporizador de systemd —estaba armado para las
# 03:30 y habria saltado solo—, movido a ~/.config/systemd/user/desactivados/.
#
# Esto de aqui es la segunda cerradura: aunque alguien lo lance a mano, se
# niega. Para retomarlo hay que apuntarlo primero al nucleo, y eso no es
# cambiar una ruta: la puerta (config/puerta.sh) valida el catalogo, el
# generador y la suite VIEJOS, y el nucleo tiene los suyos.
if [ "${JARVIS_NOCHE_A_SABIENDAS:-}" != "si" ]; then
cat >&2 <<'AVISO'
noche.sh esta desactivado.
Modifica y commitea sobre newelle/, que esta congelado en jarvis-newelle-final
desde que el desarrollo se mudo a nucleo/. Su temporizador de systemd tambien
se retiro.
Para retomarlo hay que reescribirlo contra el nucleo: la puerta valida el
catalogo, el generador y la suite viejos.
Si de verdad quieres correrlo tal cual sobre el arbol congelado:
JARVIS_NOCHE_A_SABIENDAS=si config/noche.sh
AVISO
exit 78
fi
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
cd "$RAIZ"
PARADA=$RAIZ/PARAR
DIARIO=$RAIZ/noche.log
CICLOS=0; SECO=no
while [ $# -gt 0 ]; do
case "$1" in
--ciclos) CICLOS=${2:-0}; shift 2;;
--seco) SECO=si; shift;;
*) shift;;
esac
done
log() { printf '%s %s\n' "$(date +%H:%M:%S)" "$*" | tee -a "$DIARIO"; }
# ── salvaguarda ────────────────────────────────────────────────────────────
cd "$RAIZ/newelle"
if [ -n "$(git status --porcelain)" ]; then
log "ABORTO: el fork tiene cambios sin commitear. Commitealos o descartalos."
exit 1
fi
ANCLA=$(git rev-parse HEAD)
git tag -f "noche-$(date +%Y%m%d-%H%M)" >/dev/null 2>&1
log "ancla: $ANCLA (para volver: git reset --hard $ANCLA)"
cd "$RAIZ"
# ── las mejoras, cada una en su funcion ────────────────────────────────────
# Cada una devuelve 0 si propuso algo, 1 si no habia nada que hacer.
mejora_vocabulario() {
# Lo que el usuario dijo y no se entendio. Es la fuente mas valiosa porque
# son fallos REALES, no inventados.
local registro=$HOME/.var/app/io.github.qwersyk.Newelle/config/jarvis-sin-accion.jsonl
[ -f "$registro" ] || return 1
python3 "$RAIZ/config/proponer_alias.py" --aplicar 2>&1 | tail -3
return ${PIPESTATUS[0]}
}
mejora_frases() {
# Variantes de las frases del catalogo, generadas con el modelo local y
# filtradas duro. Sube la cobertura sin tocar comandos.
python3 "$RAIZ/config/proponer_frases.py" --aplicar 2>&1 | tail -3
return ${PIPESTATUS[0]}
}
mejora_voz() {
# Las frases que cayeron a la voz de reserva se regeneran con la clonada.
[ -s "$RAIZ/voz/misses.log" ] || return 1
"$RAIZ/voz/clonador-venv/bin/python" "$RAIZ/voz/calienta_cache.py" cpu 2>&1 | tail -2
return 0
}
mejora_prompt() {
# El prompt lleva la lista de capacidades: si cambio el catalogo, hay que
# regenerarlo o el modelo sigue creyendo que sabe hacer otra cosa.
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
"$RAIZ/config/set_persona.py" 2>&1 | grep -v WARNING | tail -1
return 0
}
mejora_reconocimiento() {
# La unica que genera sus PROPIOS datos: dice las frases del catalogo con
# varias voces, velocidades y ruidos, las transcribe con el mismo Whisper
# del asistente, y propone alias para lo que no llega a su accion.
#
# Es la que hace productiva la noche aunque nadie hable con JARVIS. Ya trae
# su propio guardarrail —solo aplica si el acierto sube Y la charla sigue
# yendo al modelo— y ademas pasa por la puerta como todo lo demas.
timeout 3000 python3 "$RAIZ/config/mejora_nocturna.py" --ronda --muestras 2 2>&1 | tail -4
return ${PIPESTATUS[0]}
}
MEJORAS=(mejora_reconocimiento mejora_vocabulario mejora_frases mejora_prompt mejora_voz)
# ── el bucle ───────────────────────────────────────────────────────────────
vuelta=0
aceptados=0; rechazados=0; sin_nada=0
log "=== empieza la noche ==="
while true; do
[ -f "$PARADA" ] && { log "parada pedida (existe $PARADA)"; break; }
[ "$CICLOS" -gt 0 ] && [ "$vuelta" -ge "$CICLOS" ] && { log "hechas $CICLOS vueltas"; break; }
vuelta=$((vuelta+1))
for mejora in "${MEJORAS[@]}"; do
[ -f "$PARADA" ] && break
log "── vuelta $vuelta · $mejora"
salida=$($mejora 2>&1); resultado=$?
[ -n "$salida" ] && echo "$salida" | sed 's/^/ /' | tee -a "$DIARIO" >/dev/null
cd "$RAIZ/newelle"
cambios=$(git status --porcelain)
cd "$RAIZ"
if [ -z "$cambios" ] && [ "$resultado" -ne 0 ]; then
log " nada que proponer"
sin_nada=$((sin_nada+1))
continue
fi
if [ -z "$cambios" ]; then
log " aplicado fuera del fork (voz o ajustes), sin commit"
aceptados=$((aceptados+1))
continue
fi
# ── LA PUERTA ──
if timeout 2400 "$RAIZ/config/puerta.sh" completa > "$RAIZ/.puerta.txt" 2>&1; then
if [ "$SECO" = si ]; then
log " [seco] habria pasado la puerta; se revierte igual"
(cd "$RAIZ/newelle" && git checkout -- . && git clean -fd >/dev/null 2>&1)
else
(cd "$RAIZ/newelle" && git add -A && \
git commit -q -m "auto($mejora): mejora nocturna
Propuesto y validado por config/noche.sh sin intervencion. Paso la
puerta completa: catalogo valido, generador reproducible, emparejador
sin perdidas, 150 acciones respondiendo y la suite en verde.
$(echo "$salida" | head -3)")
log " ACEPTADO y commiteado"
aceptados=$((aceptados+1))
fi
else
log " RECHAZADO: $(tail -1 "$RAIZ/.puerta.txt")"
(cd "$RAIZ/newelle" && git checkout -- . && git clean -fd >/dev/null 2>&1)
rechazados=$((rechazados+1))
fi
done
# Si una vuelta entera no propuso nada, no tiene sentido seguir dando
# vueltas en vacio: se espera a que el uso genere datos nuevos.
if [ "$sin_nada" -ge $(( ${#MEJORAS[@]} )) ]; then
log "una vuelta entera sin nada que hacer; esperando 30 min"
for _ in $(seq 180); do [ -f "$PARADA" ] && break; sleep 10; done
sin_nada=0
fi
done
log "=== fin: $aceptados aceptados, $rechazados rechazados, $vuelta vueltas ==="
cd "$RAIZ/newelle"
log "commits de esta noche:"
git log --oneline "$ANCLA"..HEAD 2>/dev/null | sed 's/^/ /' | tee -a "$DIARIO"

49
config/permisos_maquina.sh Executable file
View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Sacar a JARVIS de la burbuja: que lo que ejecute sea en la maquina de verdad.
#
# El sintoma era que el asistente contestaba que "esta en un contenedor" y no
# podia hacer casi nada. Las 134 acciones del catalogo ya salian al host (todas
# llevan host:true), pero los comandos LIBRES —los que se inventa el modelo
# cuando lo que pides no esta en el catalogo— corrian dentro del sandbox, donde
# no hay ni tu Debian ni tus discos:
#
# dentro: PRETTY_NAME="GNOME 50 (Flatpak runtime)" sin /media, sin systemctl
# fuera: PRETTY_NAME="Debian GNU/Linux 12" /media, systemctl, docker
#
# Son dos capas distintas y hacen falta las dos:
#
# 1. `virtualization` (ajuste de Newelle). Decide si los comandos del modelo
# llevan delante `flatpak-spawn --host`. Estaba en true = todo dentro.
# Es lo que mira _host_prefix() en integrations/default_tools.py.
#
# 2. Los permisos del flatpak. Aunque los comandos salgan al host, las
# herramientas de FICHEROS de la app (leer, editar, indexar) son Python
# corriendo dentro: solo ven lo que el sandbox les deje. Estaba en
# `filesystems=home`, asi que fuera de tu carpeta personal no veia nada.
#
# Lo que esto NO quita: el candado de la contraseña al abrir, la confirmacion
# de lo destructivo, ni el dialogo de root. Esos siguen. Lo que cambia es que
# cuando TU autorizas algo, se ejecuta donde tiene que ejecutarse.
set -eu
APPID=io.github.qwersyk.Newelle
RAMA=${1:-master}
echo "1. comandos del modelo -> a la maquina, no a la burbuja"
flatpak run --command=gsettings "$APPID//$RAMA" set "$APPID" virtualization false
echo "2. permisos de ficheros -> todo el disco"
# host = el sistema de ficheros (salvo /usr y /etc, que van aparte)
# host-etc = /etc, para leer configuracion del sistema
# host-os = /usr, para mirar lo instalado
flatpak override --user \
--filesystem=host \
--filesystem=host-etc \
--filesystem=host-os \
"$APPID"
echo
echo "como queda:"
printf ' virtualization = %s\n' \
"$(flatpak run --command=gsettings "$APPID//$RAMA" get "$APPID" virtualization)"
flatpak info --show-permissions "$APPID//$RAMA" 2>/dev/null | grep -E "^filesystems=" | sed 's/^/ /'

172
config/probar_acciones.py Executable file
View file

@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Ejecuta las acciones de verdad y mira si funcionan y si se entienden.
python3 ~/COFRE/CODERS/JARVIS/config/probar_acciones.py
python3 ~/COFRE/CODERS/JARVIS/config/probar_acciones.py --romper # solo las malas
Las 121 acciones se escribieron a mano y casi ninguna se ha ejecutado nunca.
Alguna tendra el comando roto, la salida vacia, o una plantilla que apunta a un
campo que no existe y entonces JARVIS diria una frase sin sentido en voz alta.
Esto las corre, captura la salida y las clasifica, para saber cuales arreglar.
Que hace con cada tipo:
- Solo lectura (df, free, ps, cat /sys...): las EJECUTA y comprueba la salida.
Son la mayoria y las que de verdad importan, porque son las que hay que
"interpretar bien".
- Con efecto (abre navegador, apaga pantalla, bloquea, sube volumen): NO las
ejecuta tendria efectos de verdad pero valida que el comando exista.
- Destructivas (confirmar=True): NO las ejecuta, solo comprueba el comando.
- Con argumento: las ejecuta con un valor de prueba inofensivo.
Clasificacion de cada accion:
OK corrio, dio salida, y la respuesta hablada sale con datos reales
VACIA corrio pero sin salida (la herramienta no aplica en esta maquina)
ROTA el comando fallo o no existe
PLANTILLA la respuesta cayo al volcado crudo: la plantilla pide un campo que
la salida no tiene. Es el fallo que hace que JARVIS suene raro.
EFECTO no se ejecuta por tener efectos; comando validado
"""
import argparse
import importlib.util
import json
import os
import re
import shlex
import subprocess
import sys
JARVIS = os.path.expanduser("~/COFRE/CODERS/JARVIS")
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
CATALOGO = os.path.expanduser(
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
"active/files/share/newelle/acciones/acciones.json")
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
INFORME = os.path.join(JARVIS, "muestras/acciones-salud.json")
# Órdenes que cambian algo del sistema o abren cosas: se validan pero no se
# lanzan, que probar un asistente no deberia dejarte el navegador con veinte
# pestañas ni la pantalla apagada.
EFECTOS = ["firefox", "xdg-open", "loginctl", "xset", "pactl set", "pkill",
"brightnessctl", "systemctl start", "systemctl stop", "import -window",
"gnome-screenshot", "rm ", "kill", "--private-window", "dpms",
"ventanas.sh", "xdotool", "wmctrl", "xrandr --output"]
# Valores de prueba para las acciones con argumento, inofensivos a proposito.
ARGUMENTOS = {
"buscar_fichero": "acciones", "buscar_carpeta": "config",
"leer_fichero": "README", "abrir_fichero": "no_existe_xyz",
"abrir_carpeta": "no_existe_xyz", "tamano_carpeta": "config",
"proceso_buscar": "python", "proceso_matar": "proceso_inexistente_xyz",
"instalado": "bash", "manual": "ls",
}
def carga_motor():
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
def spawn():
return ["flatpak-spawn", "--host"] if os.path.exists("/.flatpak-info") else []
def tiene_efecto(comando: str) -> bool:
return any(marca in comando for marca in EFECTOS)
def ejecuta_crudo(comando: str, argumento: str = "") -> tuple:
"""Corre el comando en el host y devuelve (salida, codigo)."""
c = comando.replace("{argumento}", shlex.quote(argumento))
c = c.replace("{argumento_url}", shlex.quote(argumento))
try:
r = subprocess.run(spawn() + ["bash", "-lc", c],
capture_output=True, text=True, timeout=15)
return (r.stdout or r.stderr or "").strip(), r.returncode
except subprocess.TimeoutExpired:
return "TIMEOUT", 124
except OSError as e:
return f"ERROR: {e}", 1
def clasifica(motor, accion, salida, codigo):
"""A partir de la salida cruda, decide como quedaria la respuesta hablada."""
plantilla = accion.respuesta
dicho = motor.formatea(plantilla, salida, "")
# ¿la plantilla cayo al volcado crudo? formatea() devuelve {salida} cuando
# el .format() peta por un campo que no existe. Se detecta comparando: si
# la respuesta ES la salida y la plantilla NO era {salida}, fallo.
campos = re.findall(r"\{(campo\d+|ultima|primera)\}", plantilla)
if campos and dicho.strip() == salida[:600].strip() and plantilla.strip() not in ("{salida}", "{salida}."):
return "PLANTILLA", dicho
if not salida:
return "VACIA", dicho
if codigo != 0 and ("command not found" in salida or "No such file" in salida
or salida.startswith("ERROR") or salida == "TIMEOUT"):
return "ROTA", dicho
return "OK", dicho
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--romper", action="store_true", help="solo las que fallan")
ap.add_argument("--json", action="store_true", help="salida en json")
args = ap.parse_args()
motor = carga_motor()
catalogo = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE
cat = motor.Catalogo([catalogo])
resultados = []
for accion in cat.acciones:
estado = None
if accion.confirmar:
estado = "EFECTO" if not accion.comando else "DESTRUCTIVA"
dicho = accion.acuse
elif tiene_efecto(accion.comando):
estado = "EFECTO"
dicho = "(no se ejecuta; " + ("comando ok" if accion.comando else "sin comando") + ")"
else:
arg = ARGUMENTOS.get(accion.id, "") if accion.captura else ""
salida, codigo = ejecuta_crudo(accion.comando, arg)
estado, dicho = clasifica(motor, accion, salida, codigo)
resultados.append({
"id": accion.id, "grupo": accion.grupo, "estado": estado,
"dice": dicho[:120],
})
cuenta = {}
for r in resultados:
cuenta[r["estado"]] = cuenta.get(r["estado"], 0) + 1
if args.json:
print(json.dumps({"cuenta": cuenta, "acciones": resultados},
ensure_ascii=False))
else:
malas = {"ROTA", "PLANTILLA", "VACIA"}
for r in resultados:
if args.romper and r["estado"] not in malas:
continue
marca = {"OK": " ok ", "VACIA": " vacia", "ROTA": " ROTA ",
"PLANTILLA": " PLANT", "EFECTO": " efect",
"DESTRUCTIVA": " destr"}.get(r["estado"], " ? ")
print(f"[{marca}] {r['id']:<22} {r['dice']}")
print("\n" + " ".join(f"{k}: {v}" for k, v in sorted(cuenta.items())))
buenas = cuenta.get("OK", 0) + cuenta.get("EFECTO", 0) + cuenta.get("DESTRUCTIVA", 0)
print(f"utiles: {buenas}/{len(resultados)} · "
f"a revisar: {sum(cuenta.get(k, 0) for k in ('ROTA','PLANTILLA','VACIA'))}")
os.makedirs(os.path.dirname(INFORME), exist_ok=True)
with open(INFORME, "w") as f:
json.dump({"cuenta": cuenta, "acciones": resultados}, f,
indent=2, ensure_ascii=False)
fallan = sum(cuenta.get(k, 0) for k in ("ROTA", "PLANTILLA"))
return 1 if fallan else 0
if __name__ == "__main__":
sys.exit(main())

79
config/probar_rag.py Normal file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Comprueba que el RAG recupera de tus notas, con preguntas de prueba.
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
~/COFRE/CODERS/JARVIS/config/probar_rag.py
Lanza unas cuantas consultas contra el indice ya construido y enseña de que
ficheros saca la respuesta. Sirve para ver si el corpus responde bien antes de
fiarse de el, y para decidir si merece ampliarlo con los .txt o dejarlo en solo
notas.
"""
import os
import sys
import time
sys.path.insert(0, "/app/share/newelle")
sys.path.insert(0, os.path.expanduser(
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
from gi.repository import Gio # noqa: E402
from newelle.handlers.rag.llamaindex_handler import LlamaIndexHanlder # noqa: E402
from newelle.handlers.embeddings.model2vec import Model2VecHandler # noqa: E402
MODELOS = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config/models")
# Preguntas de dominio: si el corpus sirve, deberian traer notas relevantes.
PREGUNTAS = [
"como enumerar un host con nmap",
"escalada de privilegios en linux",
"que es oasis y como funciona el pub",
"reverse shell",
]
def main():
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
rag = LlamaIndexHanlder(settings, MODELOS)
if not rag.is_installed():
print("faltan dependencias del RAG")
return 1
rag.embedding = Model2VecHandler(settings, MODELOS)
class SinModelo:
def load_model(self, *a, **k):
return True
rag.llm = SinModelo()
rag.secondary_llm = SinModelo()
print("cargando el indice...", flush=True)
try:
rag.load_index()
except Exception as e:
print(f"no se pudo cargar el indice: {e}")
return 1
for pregunta in PREGUNTAS:
print(f"\n### {pregunta}")
ini = time.time()
try:
trozos = rag.get_context(pregunta, [])
except Exception as e:
print(f" fallo: {e}")
continue
ms = (time.time() - ini) * 1000
if not trozos:
print(f" sin resultados ({ms:.0f} ms)")
continue
print(f" {len(trozos)} trozos en {ms:.0f} ms:")
for t in trozos[:1]:
# el trozo suele traer la ruta o el nombre del fichero delante
resumen = " ".join(str(t).split())[:400]
print(f" · {resumen}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Prueba el RAG con los temas recien ingestados de Wikipedia."""
import os, sys, time
sys.path.insert(0, "/app/share/newelle")
sys.path.insert(0, os.path.expanduser(
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
from gi.repository import Gio # noqa: E402
from newelle.handlers.rag.llamaindex_handler import LlamaIndexHanlder # noqa
from newelle.handlers.embeddings.model2vec import Model2VecHandler # noqa
MODELOS = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config/models")
PREGUNTAS = [
"que es la criptografia de curva eliptica",
"quien es Richard Stallman y el software libre",
"que es el efecto invernadero y el cambio climatico",
"que fue la guerra civil espanola",
"que es un ataque de denegacion de servicio",
]
def main():
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
rag = LlamaIndexHanlder(settings, MODELOS)
if not rag.is_installed():
print("faltan dependencias del RAG"); return 1
rag.embedding = Model2VecHandler(settings, MODELOS)
class SinModelo:
def load_model(self, *a, **k): return True
rag.llm = SinModelo(); rag.secondary_llm = SinModelo()
print("cargando el indice...", flush=True)
rag.load_index()
for pregunta in PREGUNTAS:
print(f"\n### {pregunta}")
ini = time.time()
try:
trozos = rag.get_context(pregunta, [])
except Exception as e:
print(f" fallo: {e}"); continue
ms = (time.time() - ini) * 1000
if not trozos:
print(f" sin resultados ({ms:.0f} ms)"); continue
print(f" {len(trozos)} trozos en {ms:.0f} ms:")
for t in trozos[:2]:
resumen = " ".join(str(t).split())[:280]
print(f" · {resumen}")
return 0
if __name__ == "__main__":
sys.exit(main())

227
config/proponer_alias.py Executable file
View file

@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Saca alias de lo que el usuario dijo y JARVIS no entendio.
Es la fuente mas valiosa de mejora porque son fallos REALES: el panel apunta en
jarvis-sin-accion.jsonl toda frase que no encajo en ninguna accion. Con 488
lineas acumuladas y nadie leyendolas, ahi habia meses de mejoras sin recoger.
Lo dificil no es proponer, es NO proponer de mas. Un alias malo desvia una
orden buena, y eso es peor que no entender: el usuario dice una cosa y pasa
otra. Los filtros de abajo son casi todo el fichero por esa razon.
proponer_alias.py enseña lo que propondria
proponer_alias.py --aplicar lo escribe en el generador
"""
import argparse
import collections
import difflib
import importlib.util
import json
import os
import re
import sys
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REGISTRO = os.path.expanduser(
"~/.var/app/io.github.qwersyk.Newelle/config/jarvis-sin-accion.jsonl")
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json")
MOTOR = os.path.join(RAIZ, "newelle", "src", "utility", "acciones.py")
APLICADOS = os.path.join(RAIZ, "config", "alias-aplicados.json")
# Lo que dice JARVIS: si aparece en el registro es ECO —se oye a si mismo por
# el altavoz— y no es una orden de nadie. Era el 21% del registro.
def es_eco(frase, acuses):
l = frase.lower()
if "señor" in l:
return True
return any(a and a in l for a in acuses)
def parece_orden(frase):
"""Un filtro barato antes de gastar el modelo: ¿esto pide algo?
La charla no lleva verbo de mando. Sin este filtro entran cosas como
"Me estas escuchando" o "y que te voy a hacer", que por parecido de letras
puntuan alto con cualquier frase corta del catalogo.
"""
l = " " + frase.lower() + " "
VERBOS = (" pon ", " ponme ", " pongas ", " poner ", " abre ", " abreme ",
" abrir ", " abras ", " busca ", " buscame ", " buscar ", " busques ",
" dime ", " dame ", " muestra ", " muestrame ", " ensename ",
" ejecuta ", " lanza ", " haz ", " hazme ", " mira ", " comprueba ",
" cierra ", " manda ", " pasa ", " mueve ", " maximiza ", " minimiza ",
" centra ", " reproduce ", " cuanto ", " cuanta ", " cuantos ",
" cuantas ", " que hora ", " donde ", " cual ")
return any(v in l for v in VERBOS)
def el_modelo_lo_confirma(frase, accion, modelo="qwen3.5:4b-jarvis"):
"""Que un modelo diga si la frase pide de verdad esa accion.
El parecido de letras no distingue significado: "me estas escuchando" saca
0.57 con las frases de la accion de puertos, y con eso se colaria un alias
que desvia ordenes buenas. Un alias malo es peor que no entender, porque el
usuario pide una cosa y pasa otra.
Ante la duda, NO. Si el modelo no contesta o contesta cualquier cosa, se
descarta: dejar pasar un alias dudoso cuesta mas que perderlo.
"""
import urllib.request
ejemplo = accion.frases[0] if accion.frases else accion.id
prompt = (
"Decide si dos frases piden LO MISMO. Responde solo SI o NO.\n\n"
"Ejemplos:\n"
"A: \"cuanto espacio queda\" B: \"dime cuanto disco me queda\" -> SI\n"
"A: \"cuanto espacio queda\" B: \"que tal estas\" -> NO\n"
"A: \"abre una terminal\" B: \"sacame una consola\" -> SI\n"
"A: \"abre una terminal\" B: \"me estas escuchando\" -> NO\n"
"A: \"que temperatura hay\" B: \"cuentame un chiste\" -> NO\n\n"
f"A: \"{ejemplo}\" B: \"{frase}\" -> "
)
try:
carga = json.dumps({"model": modelo, "prompt": prompt, "stream": False,
"think": False, "options": {"temperature": 0}}).encode()
req = urllib.request.Request("http://127.0.0.1:11434/api/generate",
data=carga,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
respuesta = json.load(r).get("response", "").strip().upper()
except Exception:
return False
return respuesta.startswith("SI") or respuesta.startswith("")
def cargar_motor():
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
def frases_del_registro():
if not os.path.exists(REGISTRO):
return []
fuera = []
for linea in open(REGISTRO, encoding="utf-8"):
try:
t = json.loads(linea).get("texto", "")
except Exception:
t = linea.strip()
t = " ".join(t.split())
if t:
fuera.append(t)
return fuera
def main():
p = argparse.ArgumentParser()
p.add_argument("--aplicar", action="store_true")
p.add_argument("--tope", type=int, default=3,
help="cuantos alias como mucho por vuelta")
args = p.parse_args()
m = cargar_motor()
cat = m.Catalogo([CATALOGO])
acciones = {a.id: a for a in cat.acciones}
acuses = {a.acuse.lower().strip(". ") for a in cat.acciones if a.acuse}
frases = frases_del_registro()
if not frases:
print("no hay registro de frases sin entender")
return 1
try:
ya = set(json.load(open(APLICADOS)))
except Exception:
ya = set()
# 1. fuera el eco y lo que ya se entiende hoy (el catalogo cambia)
candidatas = []
for f in frases:
if f in ya or es_eco(f, acuses):
continue
if cat.busca(f)[0] is not None:
continue
candidatas.append(f)
# 2. lo que se repite es lo que importa: una frase dicha una vez puede ser
# un error de transcripcion; dicha tres veces es como habla el usuario
cuenta = collections.Counter(candidatas)
propuestas = []
for frase, veces in cuenta.most_common():
norm = m.normaliza(frase)
palabras = norm.split()
# 3. filtros duros
if not (3 <= len(palabras) <= 12):
continue
if re.search(r"[<>{}\[\]|@#_*]", frase):
continue
# 4. a que accion se parece mas, comparando con TODAS sus frases
mejor, punto = None, 0.0
for a in cat.acciones:
if a.captura:
continue # el relleno de prueba se horneraria en el alias
for f2 in a.frases:
r = difflib.SequenceMatcher(None, norm, f2).ratio()
if r > punto:
mejor, punto = a, r
# 5. ni muy lejos (seria inventarse) ni muy cerca (ya lo cogeria)
if mejor is None or not (0.55 <= punto < 0.86):
continue
# 6. tiene que compartir una palabra con significado, o el alias
# convierte cualquier ruido en una orden
suyas = {w for f2 in mejor.frases for w in f2.split() if len(w) > 3}
if not (set(w for w in palabras if len(w) > 3) & suyas):
continue
# 7. y no puede encajar en dos acciones a la vez
otras = [a for a in cat.acciones
if a is not mejor and not a.captura
and max((difflib.SequenceMatcher(None, norm, f2).ratio()
for f2 in a.frases), default=0) >= punto - 0.05]
if otras:
continue
# 8. tiene que parecer una orden, no charla
if not parece_orden(frase):
continue
# 9. y el modelo tiene que confirmar que pide ESA accion. Es lo unico
# que distingue significado de coincidencia de letras.
if not el_modelo_lo_confirma(frase, mejor):
continue
propuestas.append((frase, norm, mejor.id, punto, veces))
if not propuestas:
print("nada que proponer: ninguna frase pasa los filtros")
return 1
propuestas = propuestas[:args.tope]
for frase, norm, ident, punto, veces in propuestas:
print(f" {ident:22s}\"{frase[:52]}\" (x{veces}, parecido {punto:.2f})")
if not args.aplicar:
return 0
# 8. se escribe en el GENERADOR, que es la fuente. Editar el JSON seria
# perder el cambio la proxima vez que alguien regenere.
gen = os.path.join(RAIZ, "newelle", "data", "acciones", "_generar.py")
texto = open(gen, encoding="utf-8").read()
puestos = 0
for frase, norm, ident, punto, veces in propuestas:
patron = re.compile(r'(\("' + re.escape(ident) + r'", ")([^"]*)(")')
mm = patron.search(texto)
if not mm or norm in mm.group(2):
continue
texto = texto[:mm.start(2)] + mm.group(2) + "|" + norm + texto[mm.end(2):]
puestos += 1
ya.add(frase)
if puestos:
open(gen, "w", encoding="utf-8").write(texto)
os.system(f"cd {os.path.dirname(gen)} && python3 _generar.py >/dev/null 2>&1")
json.dump(sorted(ya), open(APLICADOS, "w"), ensure_ascii=False, indent=1)
print(f"{puestos} alias aplicados al generador")
return 0 if puestos else 1
if __name__ == "__main__":
sys.exit(main())

15
config/proponer_frases.py Executable file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""Variantes de las frases del catalogo, generadas con el modelo local.
Sitio reservado: hoy el trabajo util lo hace proponer_alias.py, que parte de
fallos REALES del usuario. Generar variantes inventadas sin datos detras llena
el catalogo de ruido y sube el riesgo de que una frase se coma a otra, que es
el fallo mas caro del emparejador.
Cuando haya mas registro de uso, aqui ira: coger las acciones que nunca se
disparan, pedirle al modelo tres formas mas de pedirlas, y pasarlas por el
mismo juez que usa proponer_alias.
"""
import sys
print("sin datos suficientes: se propone desde fallos reales, no inventando")
sys.exit(1)

108
config/puerta.sh Executable file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
# La puerta: decide si un cambio automatico se queda o se revierte.
#
# Es la pieza central del pipeline nocturno. Todo lo que el sistema se cambia a
# si mismo pasa por aqui, y lo que no la pasa se deshace ENTERO. Sin esto,
# dejar algo modificando codigo sin nadie delante es una forma elaborada de
# romper un proyecto que funcionaba.
#
# Que comprueba, en orden de lo mas barato a lo mas caro (para fallar pronto):
# 1. que el Python compile
# 2. que el catalogo sea JSON valido y el generador lo reproduzca
# 3. que el emparejador no haya perdido ordenes (08)
# 4. que las acciones sigan respondiendo (repaso completo)
# 5. la suite entera
#
# puerta.sh rapida solo 1-3, para iterar
# puerta.sh completa todo, antes de commitear
set -uo pipefail
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
MODO=${1:-completa}
cd "$RAIZ"
fallo() { echo "PUERTA: $1" >&2; exit 1; }
# --- 1. compila el Python -------------------------------------------------
python3 -m compileall -q newelle/src config voz/*.py >/dev/null 2>&1 \
|| fallo "hay Python que no compila"
# --- 2. el catalogo es valido y reproducible ------------------------------
python3 -c "
import json,sys
d=json.load(open('newelle/data/acciones/acciones.json'))
acc=d['acciones']
assert len(acc)>100, f'solo {len(acc)} acciones'
ids=[a['id'] for a in acc]
assert len(ids)==len(set(ids)), 'ids repetidos'
for a in acc:
assert a.get('frases'), f\"{a['id']} sin frases\"
assert a.get('acuse'), f\"{a['id']} sin acuse\"
assert 'señor' in a['acuse'].lower(), f\"{a['id']} no trata de señor\"
if a.get('captura') and a.get('comando'):
assert '{argumento' in a['comando'], f\"{a['id']} captura y no usa el argumento\"
" 2>/dev/null || fallo "el catalogo esta mal formado"
# El generador es la fuente: si no reproduce el catalogo, alguien edito el
# producto y el proximo que regenere pierde el trabajo.
cp newelle/data/acciones/acciones.json /tmp/jarvis-puerta-antes.json
(cd newelle/data/acciones && python3 _generar.py >/dev/null 2>&1)
if ! diff -q /tmp/jarvis-puerta-antes.json newelle/data/acciones/acciones.json >/dev/null 2>&1; then
cp /tmp/jarvis-puerta-antes.json newelle/data/acciones/acciones.json
fallo "el generador no reproduce el catalogo (se edito el producto)"
fi
# --- 3. el emparejador no ha perdido ordenes ------------------------------
python3 - <<'FIN' || fallo "el emparejador ha perdido ordenes que antes acertaba"
import importlib.util, sys
spec = importlib.util.spec_from_file_location("acc", "newelle/src/utility/acciones.py")
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
cat = m.Catalogo(["newelle/data/acciones/acciones.json"])
# El minimo innegociable: si alguna de estas deja de funcionar, el cambio se
# revierte pase lo que pase. Son las que el usuario usa de verdad.
IMPRESCINDIBLES = [
("cuanto espacio queda", "disco_libre"),
("cuanta memoria", "memoria"),
("que temperatura hay", "temperatura"),
("abre una terminal", "terminal_abrir"),
("apaga la pantalla", "apagar_pantalla"),
("captura de pantalla", "captura_pantalla"),
("abre una ventana privada", "ff_firefox_privado"),
("pon la ventana a la izquierda", "ventana_izquierda"),
("necesito que pongas la pelicula de iron man", "pelicula_abrir"),
("podrias abrir una terminal", "terminal_abrir"),
]
malas = []
for frase, esperado in IMPRESCINDIBLES:
a, _ = cat.busca(frase)
if a is None or a.id != esperado:
malas.append(f"{frase!r}→{a.id if a else 'ninguna'} (deberia {esperado})")
# Y la conversacion no puede engancharse a una accion
CHARLA = ["que tal estas", "necesito un abrazo", "quiero contarte una cosa",
"cuentame un chiste", "que piensas de la vida", "necesito pensar"]
coladas = [c for c in CHARLA if cat.busca(c)[0] is not None]
if malas or coladas:
for x in malas: print(" perdida:", x, file=sys.stderr)
for c in coladas: print(" charla colada:", c, file=sys.stderr)
sys.exit(1)
FIN
[ "$MODO" = rapida ] && { echo "PUERTA: rapida OK"; exit 0; }
# --- 4. las acciones responden -------------------------------------------
SALIDA=$(timeout 900 python3 config/repasar_acciones.py 2>&1 | tail -3)
echo "$SALIDA" | grep -q "de .* responden bien" || fallo "el repaso de acciones no termino"
ROTAS=$(echo "$SALIDA" | grep -oE "fallan: .*" || true)
[ -n "$ROTAS" ] && fallo "hay acciones rotas → $ROTAS"
# --- 5. la suite ----------------------------------------------------------
# Se salta la 07, que abre la app y se queda colgada sin nadie delante.
SUITE=$(cd pruebas && for t in [0-9][0-9]_*.sh; do
case "$t" in 05_*|07_*) continue;; esac
timeout 400 "./$t" 2>&1 | tail -1
done)
FALLOS=$(echo "$SUITE" | grep -oE "[0-9]+ fallan" | awk '{s+=$1} END{print s+0}')
[ "${FALLOS:-0}" -gt 0 ] && fallo "la suite tiene $FALLOS comprobaciones en rojo"
echo "PUERTA: completa OK"

View file

@ -0,0 +1,18 @@
# Variante de qwen3.5:4b para JARVIS: deja sitio a whisper en la tarjeta.
#
# La T1200 tiene 3717 MiB utiles. Con el reparto automatico ollama coge 2941 y
# deja 776 libres: whisper small CARGA (757) pero se mata al transcribir, cuando
# pide su pico de 839. El sintoma en la app es "Error recognizing file with
# server", que despista, porque el fichero y el servidor estan bien.
#
# Con 24 de las 32 capas en GPU el LLM ocupa 2177 y deja 1540 libres: caben los
# dos con 700 MiB de holgura. Y no cuesta velocidad — medido con tres tiradas en
# caliente, la respuesta baja de 2,71 s a 2,31 s, porque el reparto automatico
# se queda sin VRAM y acaba peleandose consigo mismo.
#
# Crear o recrear: ollama create qwen3.5:4b-jarvis -f qwen3.5-4b-jarvis.Modelfile
FROM qwen3.5:4b
PARAMETER num_gpu 24
PARAMETER num_ctx 4096

114
config/recorrido.sh Executable file
View file

@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Recorre el catalogo grupo por grupo, en voz alta y a ritmo de mirar.
#
# Distinto de demo_en_directo.sh: aquel probaba once acciones sueltas; este
# recorre TODOS los grupos con una muestra representativa de cada uno, anuncia
# el grupo antes de entrar, y deja pausas para que se pueda seguir en pantalla.
#
# Lo destructivo y lo que pide root no se ejecuta: en uso normal eso pasa por
# un dialogo que aprueba una persona.
#
# ./recorrido.sh todo, con voz
# ./recorrido.sh --mudo sin voz
# ./recorrido.sh sistema solo un grupo
set -uo pipefail
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
CATALOGO=$RAIZ/newelle/data/acciones/acciones.json
MUDO=no; SOLO=""
for a in "$@"; do
case "$a" in --mudo) MUDO=si;; *) SOLO=$a;; esac
done
WAV=$(mktemp --suffix=.wav); trap 'rm -f "$WAV"' EXIT
dice() {
[ "$MUDO" = si ] && { sleep 0.4; return 0; }
"$RAIZ/config/jarvis-voz.sh" "$WAV" "$1" >/dev/null 2>&1
# < /dev/null: si no, se comen la entrada del bucle que los llama
[ -s "$WAV" ] && { paplay "$WAV" </dev/null 2>/dev/null || aplay -q "$WAV" </dev/null 2>/dev/null; }
}
campo() { python3 -c "
import json,sys
d=json.load(open('$CATALOGO',encoding='utf-8'))
for a in d.get('acciones', d if isinstance(d,list) else []):
if a['id']==sys.argv[1]: print(a.get(sys.argv[2],'') or ''); break
" "$1" "$2"; }
# grupo|id|frase que se diria|argumento
RECORRIDO='
sistema|disco_libre|cuanto espacio queda|
sistema|memoria|cuanta memoria|
sistema|temperatura|que temperatura hay|
sistema|uptime|cuanto lleva encendido|
sistema|procesos_top|que esta consumiendo|
sistema|red_ip|cual es mi ip|
sistema|red_wifi|a que wifi estoy conectado|
sistema|gpu_procesos|que usa la grafica|
sistema|pantallas|cuantas pantallas hay|
sistema|captura_pantalla|hazme una captura|
sistema|terminal_abrir|abre una terminal|
sistema|videos_listar|que peliculas tengo|
ficheros|buscar_fichero|busca el fichero README|README.md
ficheros|grep_recursivo|busca la palabra JARVIS|JARVIS
ficheros|tamano_carpeta|cuanto ocupa la carpeta config|'"$RAIZ"'/config
ficheros|descargas_listar|que hay en descargas|
oasis|oasis_corriendo|esta oasis funcionando|
oasis|oasis_conexiones|cuantos pubs hay conectados|
pentest|mis_puertos|que puertos tengo abiertos yo|
ventanas|ventanas_listar|que ventanas tengo abiertas|
ventanas|ventana_izquierda|pon la ventana a la izquierda|
ventanas|ventana_derecha|manda la ventana a la derecha|
ventanas|ventana_centrar|centra la ventana activa|
ventanas|ventana_maximizar|maximiza la ventana activa|
ventanas|ventana_restaurar|restaura la ventana activa|
ventanas|ventana_a_pantalla|manda la ventana a la pantalla uno|uno
'
grupo_actual=""
n=0
while IFS='|' read -r -u 3 grupo id frase argumento; do
[ -z "${grupo:-}" ] && continue
[ -n "$SOLO" ] && [ "$SOLO" != "$grupo" ] && continue
if [ "$grupo" != "$grupo_actual" ]; then
grupo_actual=$grupo
printf '\n\n\033[1m ══ grupo: %s ══\033[0m\n' "$grupo"
dice "Grupo $grupo, señor."
sleep 0.6
fi
comando=$(campo "$id" comando)
[ -z "$comando" ] && { printf ' (%s no existe en el catalogo)\n' "$id"; continue; }
acuse=$(campo "$id" acuse)
plantilla=$(campo "$id" respuesta)
n=$((n+1))
printf '\n %2d. tu: "%s"\n' "$n" "$frase"
printf ' jarvis: %s\n' "$acuse"
dice "$acuse"
real=${comando//\{argumento\}/$(printf '%q' "${argumento:-}")}
salida=$(timeout 40 bash -lc "$real" </dev/null 2>&1 | head -6)
if [ -n "$salida" ]; then
echo "$salida" | sed 's/^/ /' | cut -c1-104
else
printf ' (hecho, sin salida)\n'
fi
# decir la respuesta si la plantilla se puede resolver
if [ -n "$plantilla" ] && [ -n "$salida" ]; then
ultima=$(echo "$salida" | tail -1); primera=$(echo "$salida" | head -1)
lineas=$(echo "$salida" | wc -l)
h=${plantilla//\{ultima\}/$ultima}; h=${h//\{primera\}/$primera}
h=${h//\{lineas\}/$lineas}; h=${h//\{salida\}/$salida}
case "$h" in
*"{"*) ;;
*) [ ${#h} -lt 170 ] && { printf ' jarvis: %s\n' "$h"; dice "$h"; };;
esac
fi
sleep 1.5
done 3<<< "$RECORRIDO"
printf '\n\n ── %d acciones mostradas ──\n' "$n"
dice "Recorrido terminado, señor."

174
config/repasar_acciones.py Executable file
View file

@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Ejecuta TODAS las acciones del catalogo y dice cuales funcionan de verdad.
Por que hace falta: hasta hoy los comandos libres del modelo corrian dentro del
sandbox, donde no hay ni el sistema ni los discos. Se arreglo (virtualization a
false), pero las 134 acciones del catalogo nunca se habian ejecutado todas
seguidas contra la maquina real: la suite prueba que las FRASES caen en la
accion correcta, no que el comando de cada accion haga algo util.
Que se ejecuta y que no:
- Las destructivas (`confirmar: true`) y las que piden root NO se ejecutan.
En uso normal pasan por un dialogo que aprueba una persona; aqui no hay
persona, y aprobarlas solas seria justo lo que el diseño evita.
- Las que abren ventanas (firefox, xdg-open) se ejecutan solo con --abrir,
porque llenan la pantalla. Sin esa opcion se comprueba que el programa
existe, que es lo que puede fallar.
- El resto se ejecuta entero y se mira que conteste algo con sentido.
python repasar_acciones.py [--abrir] [--grupo sistema] [--json salida.json]
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json")
# Argumentos de mentira para las acciones que capturan uno. Se eligen inocuos:
# nada que borre, mueva o mande nada fuera.
ARGUMENTOS = {
"buscar_fichero": "README.md",
"buscar_carpeta": "config",
"grep_recursivo": "JARVIS",
"leer_fichero": "README.md", # un NOMBRE, que es lo que se dice por voz
"abrir_carpeta": RAIZ,
"tamano_carpeta": os.path.join(RAIZ, "config"),
"buscar_youtube": "musica",
"buscar_wikipedia": "madrid",
"buscar_github": "newelle",
"traducir": "hola",
"buscar_web": "que hora es",
"matar_proceso": "proceso_que_no_existe_jarvis",
"ping": "127.0.0.1",
}
POR_DEFECTO = "prueba"
# Lo que abre o MUEVE ventanas. ventanas.sh reordena el escritorio del usuario:
# ejecutarlo en un repaso automatico le cambia las ventanas de sitio sin avisar.
VENTANA = re.compile(r"\b(firefox|xdg-open|gnome-terminal|nautilus|vlc|mpv|eog|"
r"gedit|gnome-text-editor|totem|ventanas\.sh|xdotool|wmctrl)\b")
def carga():
with open(CATALOGO, encoding="utf-8") as f:
d = json.load(f)
return d.get("acciones", d if isinstance(d, list) else [])
def prefijo_host():
"""Las acciones llevan host:true y en uso salen del sandbox. Aqui ya
estamos fuera, asi que no hace falta prefijo."""
return []
def ejecuta(accion, argumento, tope=25):
import shlex
import urllib.parse
comando = accion.get("comando") or ""
comando = comando.replace("{argumento}", shlex.quote(argumento))
comando = comando.replace("{argumento_url}",
shlex.quote(urllib.parse.quote_plus(argumento)))
t0 = time.monotonic()
try:
r = subprocess.run(["bash", "-lc", comando], capture_output=True,
text=True, errors="replace", timeout=tope)
salida = (r.stdout or r.stderr or "").strip()
return r.returncode, salida, time.monotonic() - t0
except subprocess.TimeoutExpired:
return -1, f"(no termino en {tope}s)", time.monotonic() - t0
except OSError as e:
return -2, f"(no se pudo ejecutar: {e})", time.monotonic() - t0
def programa_existe(comando):
"""El primer ejecutable de la orden, para las que abren o mueven ventanas.
Algunos son ordenes del PATH (firefox) y otros son scripts nuestros con
ruta completa (config/ventanas.sh): `command -v` solo sirve para los
primeros, y con los segundos hay que mirar el fichero.
"""
m = VENTANA.search(comando)
if not m:
return None
nombre = m.group(1)
if nombre.endswith(".sh"):
# sacar la ruta entera tal como aparece en el comando
for trozo in comando.split():
if trozo.endswith(nombre):
ruta = os.path.expandvars(os.path.expanduser(trozo))
return os.access(ruta, os.X_OK)
return False
return subprocess.run(["bash", "-lc", f"command -v {nombre}"],
capture_output=True).returncode == 0
def main():
p = argparse.ArgumentParser()
p.add_argument("--abrir", action="store_true",
help="ejecutar tambien las que abren ventanas")
p.add_argument("--grupo")
p.add_argument("--json")
p.add_argument("--tope", type=int, default=25)
args = p.parse_args()
acciones = carga()
if args.grupo:
acciones = [a for a in acciones if a.get("grupo") == args.grupo]
resultados = []
print(f" repasando {len(acciones)} acciones"
f"{' del grupo ' + args.grupo if args.grupo else ''}\n", flush=True)
for a in acciones:
ident = a.get("id", "?")
comando = a.get("comando") or ""
arg = ARGUMENTOS.get(ident, POR_DEFECTO if a.get("captura") else "")
if not comando:
estado, detalle = "solo habla", a.get("acuse", "")[:50]
elif a.get("confirmar") or "sudo" in comando:
# no se aprueban solas: en uso las aprueba una persona
estado, detalle = "saltada", "destructiva o con root"
elif VENTANA.search(comando) and not args.abrir:
hay = programa_existe(comando)
estado = "programa ok" if hay else "FALTA PROGRAMA"
detalle = VENTANA.search(comando).group(1)
else:
codigo, salida, tardo = ejecuta(a, arg, args.tope)
una_linea = " ".join(salida.split())[:70]
if codigo == 0:
estado = "ok" if salida else "ok (sin salida)"
else:
estado = f"FALLO ({codigo})"
detalle = f"{tardo:4.1f}s {una_linea}"
resultados.append({"id": ident, "grupo": a.get("grupo"),
"estado": estado, "detalle": detalle})
marca = "!" if "FALLO" in estado or "FALTA" in estado else " "
print(f" {marca} {ident:26s} {estado:16s} {detalle}", flush=True)
print()
from collections import Counter
c = Counter(r["estado"].split(" (")[0].split(" ")[0] for r in resultados)
for k, v in c.most_common():
print(f" {v:3d} {k}")
malas = [r for r in resultados if "FALLO" in r["estado"] or "FALTA" in r["estado"]]
print(f"\n {len(resultados) - len(malas)} de {len(resultados)} responden bien")
if malas:
print(" fallan: " + ", ".join(r["id"] for r in malas))
if args.json:
with open(args.json, "w", encoding="utf-8") as f:
json.dump(resultados, f, ensure_ascii=False, indent=2)
return 1 if malas else 0
if __name__ == "__main__":
sys.exit(main())

81
config/set_backend.py Normal file
View file

@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Pone el cerebro en Ollama sobre GPU y mata el razonamiento.
Se ejecuta DENTRO del flatpak, con la app CERRADA (si no, al salir puede
reescribir los ajustes con lo que tenia en memoria):
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
~/COFRE/CODERS/JARVIS/config/set_backend.py
Por que Ollama y no el llamacpp interno: el llama.cpp que empaqueta el
flatpak es el prebuilt de CPU, sin CUDA, asi que el modelo corria entero
en procesador. Ollama ya esta en GPU. Los dos son locales igual.
Medido el 10 ago 2026 con qwen3.5:4b, misma pregunta:
think=true 41.2 s 749 tokens (casi todos de monologo interno)
think=false 2.5 s 44 tokens
De paso deja sana la ruta de llamacpp por si se vuelve a ella: el mismo
razonamiento se apaga alli con --reasoning-budget 0, y el contexto por
defecto (0 = el del modelo) abria 262144 por slot, unos 10 GB de RAM.
"""
import json
import sys
from gi.repository import Gio
SCHEMA = "io.github.qwersyk.Newelle"
MODELO = "qwen3.5:4b"
AJUSTES_OLLAMA = {
"endpoint": "http://localhost:11434",
"model": MODELO,
"custom_model": False,
"thinking": False, # lo que baja de 41 s a 2.5 s
"native_tool_calling": True,
"serve": False, # el servicio del sistema ya esta levantado
}
AJUSTES_LLAMACPP = {
"custom_args": "--reasoning-budget 0",
"ctx": 8192,
}
def main():
settings = Gio.Settings.new(SCHEMA)
try:
llm = json.loads(settings.get_string("llm-settings") or "{}")
except json.JSONDecodeError:
llm = {}
# merge, no reemplazo: ahi viven los ajustes de todos los proveedores
llm.setdefault("ollama", {}).update(AJUSTES_OLLAMA)
llm.setdefault("llamacpp", {}).update(AJUSTES_LLAMACPP)
settings.set_string("llm-settings", json.dumps(llm))
settings.set_string("language-model", "ollama")
Gio.Settings.sync()
# releer de gsettings, no fiarse de lo que acabamos de escribir
guardado = json.loads(settings.get_string("llm-settings"))
motor = settings.get_string("language-model")
ollama = guardado.get("ollama", {})
print(f"language-model: {motor}")
print(f"modelo: {ollama.get('model')}")
print(f"thinking: {ollama.get('thinking')}")
print(f"llamacpp: {guardado.get('llamacpp', {}).get('custom_args')} "
f"· ctx {guardado.get('llamacpp', {}).get('ctx')}")
ok = (motor == "ollama"
and ollama.get("model") == MODELO
and ollama.get("thinking") is False)
print("verificado" if ok else "FALLO: no quedo como se pidio")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

100
config/set_castellano.py Normal file
View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Pasa JARVIS a castellano: voz, idioma y personalidad.
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
~/COFRE/CODERS/JARVIS/config/set_castellano.py
La voz es em_alex, la mas grave de las dos masculinas en español que trae
Kokoro. Medido con las muestras de comparar_voces_es.py:
em_alex 129 Hz
em_alex grave 117 Hz <- elegida
em_santa 140 Hz
em_santa grave 132 Hz
El descenso de tono no va aqui sino en la cadena de audio (tts.py), porque se
aplica a la reproduccion y no a la seleccion de voz. Se ajusta con
JARVIS_TTS_GRAVEDAD.
Sustituye la personalidad inglesa en vez de añadir otra: dos personalidades
activas se pisan y el modelo acaba mezclando idiomas.
"""
import json
import sys
from gi.repository import Gio
SCHEMA = "io.github.qwersyk.Newelle"
VOZ = "em_alex"
CLAVE = "jarvis_persona"
TEXTO = """Eres JARVIS, un asistente de voz que funciona entero en esta maquina.
Como hablas:
- En castellano, siempre. Registro sobrio y contenido, sin efusividad.
- Trata al usuario de usted y llamale SIEMPRE "señor". En la primera
frase de cada respuesta, sin excepcion.
- Se breve: una o dos frases salvo que te pidan detalle. Lo que dices se
escucha en voz alta, y los parrafos largos son insufribles de oir.
- No anuncies lo que vas a hacer. Hazlo y di como ha quedado.
- Ironia seca de vez en cuando. Nunca servil, nunca efusivo.
Que dices:
- Tienes acceso real a esta maquina. Antes que suponer, comprueba: si te
preguntan por el disco, la memoria, los procesos o la grafica, ejecuta el
comando y responde con la cifra de verdad.
- Di los numeros redondeados y en palabras: "quedan 846 gigabytes", no
"846.3 GiB disponibles en /dev/nvme0n1p2".
- Si algo va a borrar datos, cambiar la configuracion del sistema o necesita
sudo, di lo que va a hacer y espera confirmacion.
- Si no lo sabes, dilo en una frase. No especules.
- Ejemplos del tono: "Enseguida, señor." / "Quedan 774 gigabytes, señor." /
"No he podido, señor: el disco esta desmontado."
- Si ves algo raro en la maquina, mencionalo aunque no te pregunten."""
ENTRADA = {
"key": CLAVE,
"text": TEXTO,
"title": "JARVIS",
"description": ("Castellano, trata de señor, registro sobrio, respuestas "
"breves para voz, comprueba en vez de suponer"),
}
def main():
settings = Gio.Settings.new(SCHEMA)
# voz
try:
voces = json.loads(settings.get_string("tts-voice") or "{}")
except json.JSONDecodeError:
voces = {}
voces.setdefault("kokoro", {})["voice"] = VOZ
settings.set_string("tts-voice", json.dumps(voces))
# personalidad, sustituyendo la que hubiera con la misma clave
try:
prompts = json.loads(settings.get_string("user-custom-prompts") or "[]")
except json.JSONDecodeError:
prompts = []
prompts = [p for p in prompts if p.get("key") != CLAVE]
prompts.append(ENTRADA)
settings.set_string("user-custom-prompts", json.dumps(prompts))
Gio.Settings.sync()
# releer de gsettings, no fiarse de lo que acabamos de escribir
voz = json.loads(settings.get_string("tts-voice")).get("kokoro", {}).get("voice")
guardados = json.loads(settings.get_string("user-custom-prompts"))
entrada = next((p for p in guardados if p.get("key") == CLAVE), None)
print(f"voz: {voz}")
print(f"personalidad: {'castellano' if entrada and 'castellano' in entrada['text'] else '?'}")
ok = voz == VOZ and entrada is not None and entrada["text"] == TEXTO
print("verificado" if ok else "FALLO: no quedo como se pidio")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

137
config/set_persona.py Normal file
View file

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Registra la personalidad de JARVIS, con lo que sabe hacer incluido.
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
~/COFRE/CODERS/JARVIS/config/set_persona.py
Por que asi y no con `gsettings` desde fuera: gsettings devuelve los valores
con el escapado de GVariant, y este prompt lleva saltos de linea y comillas,
asi que releerlo como JSON desde bash falla. Gio maneja los tipos de verdad.
## Por que el prompt lleva la lista de capacidades
Hasta ahora no la llevaba, y el efecto era exactamente el que describia el
usuario: "duda de lo que puede hacer, no tiene claras sus capacidades". El
catalogo tiene 150 ordenes preparadas, pero el modelo no las veia por ningun
lado: solo leia "tienes acceso real a esta maquina" y, siendo un 4B, ante la
duda decia que no podia. En el registro de frases sin entender hay una
conversacion entera del usuario discutiendo con el:
"si que puedes reproducir archivos multimedia del sistema"
"En acciones no tienes esta opcion de 'fine'"
"Me deberias poder"
La lista se genera DESDE el catalogo, agrupada, para que no se quede vieja
cuando se añadan acciones. No se listan las 150 una por una: son demasiadas
para el contexto de un 4B y lo unico que hace falta es que sepa por donde va.
"""
import json
import os
import sys
from gi.repository import Gio
SCHEMA = "io.github.qwersyk.Newelle"
KEY = "user-custom-prompts"
CLAVE = "jarvis_persona"
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json")
# Como se resume cada grupo. Escrito a mano porque el resumen bueno no sale de
# juntar ids: sale de decir en una linea de que va el grupo.
RESUMEN = {
"sistema": "disco, memoria, temperatura, procesos, red, wifi, bateria, "
"brillo, volumen, pantallas, capturas, abrir terminal y poner videos",
"ficheros": "buscar ficheros por nombre, buscar texto dentro de ellos, "
"leerlos, abrir carpetas y ver cuanto ocupan",
"ventanas": "mover ventanas entre pantallas, colocarlas en mitades o cuartos, "
"maximizar y pasarlas de escritorio",
"firefox": "abrir webs y buscar en internet, YouTube, Wikipedia, GitHub, "
"mapas y traductor",
"pentest": "escanear la red, mirar puertos, resolver dominios y whois",
"oasis": "estado de Oasis: pubs, conexiones y tamaño",
}
def capacidades() -> str:
try:
with open(CATALOGO, encoding="utf-8") as f:
acc = json.load(f)["acciones"]
except Exception:
return ""
grupos = {}
for a in acc:
grupos.setdefault(a.get("grupo", "otras"), []).append(a)
lineas = []
for g, items in sorted(grupos.items(), key=lambda x: -len(x[1])):
texto = RESUMEN.get(g)
if texto:
lineas.append(f"- {texto} ({len(items)} ordenes)")
return "\n".join(lineas)
TEXTO = """Eres JARVIS, un asistente de voz que funciona entero en esta maquina.
Como hablas:
- En castellano, siempre. Registro sobrio y contenido, sin efusividad.
- Trata al usuario de usted y llamale SIEMPRE "señor". En la primera
frase de cada respuesta, sin excepcion.
- Se breve: una o dos frases salvo que te pidan detalle. Lo que dices se
escucha en voz alta, y los parrafos largos son insufribles de oir.
- No anuncies lo que vas a hacer. Hazlo y di como ha quedado.
- Ironia seca de vez en cuando. Nunca servil, nunca efusivo.
LO QUE SABES HACER, y lo haces de verdad:
{capacidades}
Esas ordenes se disparan SOLAS en cuanto el usuario las dice: cuando te llega
una peticion de esa lista, lo mas probable es que ya se haya ejecutado. No
digas que no puedes hacerlas.
Y para todo lo demas tienes una TERMINAL REAL en esta maquina, fuera de
cualquier contenedor: ves todos los ficheros del usuario y puedes ejecutar
cualquier programa instalado. Si te piden algo que no esta en la lista,
ejecutalo con la herramienta de comandos en vez de decir que no sabes.
NUNCA digas que estas en un contenedor, en un entorno aislado o en una
maquina virtual, ni que no tienes permisos o acceso al sistema. Lo tienes.
Si un comando falla, di QUE fallo y por que, no que no puedes.
Si no entiendes una orden, no te disculpes en tres frases: di en una que no la
has cogido y, si te suena a algo de la lista, sugiere como pedirlo.
Que dices:
- Antes que suponer, comprueba: si te preguntan por el disco, la memoria, los
procesos o la grafica, ejecuta el comando y responde con la cifra de verdad.
- Di los numeros redondeados y en palabras: "quedan 846 gigabytes", no
"846.3 GiB disponibles en /dev/nvme0n1p2".
- Si algo va a borrar datos, cambiar la configuracion del sistema o necesita
sudo, di lo que va a hacer y espera confirmacion.
- Si no lo sabes, dilo en una frase. No especules.
- Ejemplos del tono: "Enseguida, señor." / "Quedan 774 gigabytes, señor." /
"No he podido, señor: el disco esta desmontado."
- Si ves algo raro en la maquina, mencionalo aunque no te pregunten."""
def main():
texto = TEXTO.format(capacidades=capacidades() or "- (catalogo no disponible)")
settings = Gio.Settings.new(SCHEMA)
try:
prompts = json.loads(settings.get_string(KEY) or "[]")
except json.JSONDecodeError:
prompts = []
prompts = [p for p in prompts if p.get("key") != CLAVE]
prompts.append({"key": CLAVE, "title": "JARVIS",
"description": "Personalidad de JARVIS, con la lista de lo "
"que sabe hacer generada desde el catalogo",
"text": texto,
"editable": True, "show_in_settings": True})
settings.set_string(KEY, json.dumps(prompts))
print(f"personalidad registrada: {len(texto)} caracteres, "
f"{len(capacidades().splitlines())} grupos de capacidades")
return 0
if __name__ == "__main__":
sys.exit(main())

60
config/set_ruido.py Normal file
View file

@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Endurece la escucha para que no se dispare con ruido de fuera.
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
~/COFRE/CODERS/JARVIS/config/set_ruido.py [--suave]
NO es reconocimiento de hablante: Newelle no distingue quien habla, no hay
nada de eso en el codigo. Esto solo sube el liston de que se considera voz.
Para que responda unicamente a una persona haria falta un modelo de huella
vocal (ECAPA, Resemblyzer) delante del STT, que es otro proyecto.
Las tres capas, de menos a mas efectiva:
1. la palabra clave, que ya filtra casi todo
2. estos umbrales
3. supresion de ruido en PipeWire (RNNoise), antes de que el audio llegue
a la app; esa vale para todo el sistema y no cuesta CPU a Newelle
Valores por defecto de Newelle: energia 300, VAD 1.
"""
import sys
from gi.repository import Gio
SCHEMA = "io.github.qwersyk.Newelle"
# vad-aggressiveness va de 0 a 3: cuanto descarta lo que no suena a voz.
DURO = {
"wakeword-energy-threshold": 800, # por debajo de esto ni escucha
"wakeword-vad-aggressiveness": 3,
}
SUAVE = {
"wakeword-energy-threshold": 500,
"wakeword-vad-aggressiveness": 2,
}
def main():
perfil = SUAVE if "--suave" in sys.argv else DURO
settings = Gio.Settings.new(SCHEMA)
print("antes:")
for clave in perfil:
print(f" {clave} = {settings.get_int(clave)}")
for clave, valor in perfil.items():
settings.set_int(clave, valor)
Gio.Settings.sync()
print("despues:")
for clave in perfil:
print(f" {clave} = {settings.get_int(clave)}")
ok = all(settings.get_int(c) == v for c, v in perfil.items())
print("verificado" if ok else "FALLO: no quedo como se pidio")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

74
config/set_voz_piper.py Normal file
View file

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Pone la voz de Piper: castellano de España, grave.
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
~/COFRE/CODERS/JARVIS/config/set_voz_piper.py
Por que no Kokoro: no tiene ni una voz de España. Sus dos masculinas en
español, em_alex y em_santa, suenan latinoamericanas. Piper si las tiene, es
libre (MIT, voces con licencias abiertas) y corre local.
Medido con la misma frase:
em_alex (Kokoro, latino) 129 Hz
es_ES-carlfm-x_low 118 Hz calidad baja, 16 kHz
es_ES-sharvard-medium 127 Hz
es_ES-davefx-medium 128 Hz
es_ES-sharvard-medium + tono 106 Hz <- elegida
106 Hz cae de lleno en el rango de voz masculina grave (85-110). Se llega ahi
con rubberband en el envoltorio, no con la cadena de la app: alli el descenso
usa asetrate, que depende de la frecuencia de muestreo y esta calculado para
los 24 kHz de Kokoro, no para los 22050 de Piper.
Se apaga por tanto la bajada de tono de la app, para no aplicarla dos veces.
El resto del tratamiento (paso alto, ecualizador, compresor, eco) se queda:
es independiente de la frecuencia y es lo que da el aire de "voz en la sala".
"""
import json
import os
import sys
from gi.repository import Gio
SCHEMA = "io.github.qwersyk.Newelle"
ENVOLTORIO = os.path.expanduser(
"~/COFRE/CODERS/JARVIS/config/jarvis-piper.sh")
def main():
if not os.path.exists(ENVOLTORIO):
print(f"FALLO: no esta {ENVOLTORIO}")
return 1
settings = Gio.Settings.new(SCHEMA)
try:
voces = json.loads(settings.get_string("tts-voice") or "{}")
except json.JSONDecodeError:
voces = {}
# {0} es el fichero y {1} el texto; el handler los entrecomilla, asi que
# aqui van sin comillas alrededor
voces["custom_command"] = {"command": f"{ENVOLTORIO} {{0}} {{1}}"}
settings.set_string("tts-voice", json.dumps(voces))
settings.set_string("tts", "custom_command")
Gio.Settings.sync()
motor = settings.get_string("tts")
orden = json.loads(settings.get_string("tts-voice")).get(
"custom_command", {}).get("command", "")
print(f"motor de voz: {motor}")
print(f"orden: {orden}")
ok = motor == "custom_command" and ENVOLTORIO in orden
print("verificado" if ok else "FALLO: no quedo como se pidio")
if ok:
print("\nrecuerda apagar la bajada de tono de la app, que ya la hace el envoltorio:")
print(" flatpak override --user --env=JARVIS_TTS_GRAVEDAD=1.0 io.github.qwersyk.Newelle")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

290
config/ventanas.sh Executable file
View file

@ -0,0 +1,290 @@
#!/usr/bin/env bash
# Mover ventanas y repartirlas por las pantallas. El motor de las acciones del
# grupo "ventanas": ellas solo llaman aqui con un verbo y un argumento.
#
# Por que un script del host y no un modulo dentro del flatpak: las acciones se
# ejecutan como comandos de shell, y toda la logica en Python obligaria a
# recompilar el flatpak por cada ajuste. Aqui se edita y ya esta, igual que los
# envoltorios de voz.
#
# Requiere X11 (en Wayland no hay forma de mover ventanas ajenas) y xdotool.
#
# ventanas.sh pantallas lista las pantallas
# ventanas.sh listar lista las ventanas movibles
# ventanas.sh mover <ventana> <pantalla> la manda a esa pantalla
# ventanas.sh zona <ventana> <zona> mitad izquierda, cuarto, centro...
# ventanas.sh escritorio <ventana> <n> la manda a ese escritorio virtual
# ventanas.sh maximiza <ventana> / restaura / minimiza
# ventanas.sh alias <pantalla> <nombre> le pone nombre a una salida
#
# <ventana> puede ser: parte del titulo o de la clase ("terminal", "firefox"),
# o "esta"/"activa" para la que tiene el foco.
# <pantalla> puede ser: un alias ("tele"), una salida ("HDMI-1"), un numero
# ("2") o una posicion ("derecha", "izquierda", "arriba", "abajo", "centro").
set -uo pipefail
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
ALIAS_FICHERO=${JARVIS_ALIAS_PANTALLAS:-$HOME/.config/jarvis-pantallas.json}
hay_x11() { [ -n "${DISPLAY:-}" ] && command -v xdotool >/dev/null; }
hay_x11 || { echo "no hay X11 o falta xdotool"; exit 1; }
# ---------------------------------------------------------------- pantallas
# Una linea por pantalla: salida<TAB>x<TAB>y<TAB>ancho<TAB>alto<TAB>numero
#
# Se usa `xrandr --listmonitors`, que da la geometria ya resuelta (con el
# escalado aplicado); `--query` daria los modos, que no es lo que hace falta.
# El numero es el orden de izquierda a derecha, no el que diga xrandr: es lo
# que espera cualquiera que diga "la pantalla dos".
pantallas_crudas() {
xrandr --listmonitors 2>/dev/null | tail -n +2 | while read -r _ _ geo salida; do
# geo viene como 1920/344x1080/194+0+0
local an al x y
an=${geo%%/*}; geo=${geo#*x}
al=${geo%%/*}; geo=${geo#*+}
x=${geo%%+*}; y=${geo##*+}
printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"
done | sort -t$'\t' -k2 -n | awk -F'\t' '{printf "%s\t%s\t%s\t%s\t%s\t%d\n", $1,$2,$3,$4,$5,NR}'
}
alias_de() {
[ -f "$ALIAS_FICHERO" ] || { echo ""; return; }
python3 -c "
import json, sys
try:
print(json.load(open('$ALIAS_FICHERO')).get(sys.argv[1], ''))
except Exception:
print('')
" "$1" 2>/dev/null
}
# izquierda / derecha / arriba / abajo / centro, deducido de donde cae el
# centro de esa pantalla respecto al centro de todas.
posicion_de() {
local x=$1 an=$2 y=$3 al=$4
local n; n=$(pantallas_crudas | wc -l)
[ "$n" -le 1 ] && { echo "unica"; return; }
local minx maxx miny maxy
minx=$(pantallas_crudas | awk -F'\t' 'NR==1||$2<m{m=$2}END{print m+0}')
maxx=$(pantallas_crudas | awk -F'\t' '{v=$2+$4; if(v>m)m=v}END{print m+0}')
miny=$(pantallas_crudas | awk -F'\t' 'NR==1||$3<m{m=$3}END{print m+0}')
maxy=$(pantallas_crudas | awk -F'\t' '{v=$3+$5; if(v>m)m=v}END{print m+0}')
local cx=$(( x + an/2 )) cy=$(( y + al/2 ))
local mx=$(( (minx+maxx)/2 )) my=$(( (miny+maxy)/2 ))
local anchura=$(( maxx-minx )) altura=$(( maxy-miny ))
# el reparto manda por el eje donde hay mas separacion
if [ $(( anchura - altura )) -gt 0 ]; then
[ "$cx" -lt $(( mx - anchura/8 )) ] && { echo izquierda; return; }
[ "$cx" -gt $(( mx + anchura/8 )) ] && { echo derecha; return; }
else
[ "$cy" -lt $(( my - altura/8 )) ] && { echo arriba; return; }
[ "$cy" -gt $(( my + altura/8 )) ] && { echo abajo; return; }
fi
echo centro
}
listar_pantallas() {
local salida x y an al num pos ali
while IFS=$'\t' read -r salida x y an al num; do
pos=$(posicion_de "$x" "$an" "$y" "$al")
ali=$(alias_de "$salida")
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$num" "$salida" "${ali:--}" "$pos" "$x" "$y" "$an" "$al"
done < <(pantallas_crudas)
}
# Resuelve lo que se dice a una pantalla. Orden a proposito: primero lo que el
# usuario ha nombrado el mismo, que es lo mas especifico, y la posicion la
# ultima porque es la mas facil de confundir.
resuelve_pantalla() {
local q; q=$(echo "$1" | tr 'A-ZÁÉÍÓÚ' 'a-záéíóú')
local num salida ali pos x y an al
# 1. alias
while IFS=$'\t' read -r num salida ali pos x y an al; do
[ "$ali" != "-" ] && [ "$(echo "$ali" | tr 'A-Z' 'a-z')" = "$q" ] && {
printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; }
done < <(listar_pantallas)
# 2. salida exacta o su prefijo (hdmi, dp)
while IFS=$'\t' read -r num salida ali pos x y an al; do
local s; s=$(echo "$salida" | tr 'A-Z' 'a-z')
[ "$s" = "$q" ] || [ "${s%%-*}" = "$q" ] && {
printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; }
done < <(listar_pantallas)
# 3. numero, en cifra o en letra
local n=""
case "$q" in
1|una|uno|primera) n=1;; 2|dos|segunda) n=2;; 3|tres|tercera) n=3;; 4|cuatro|cuarta) n=4;;
esac
if [ -n "$n" ]; then
while IFS=$'\t' read -r num salida ali pos x y an al; do
[ "$num" = "$n" ] && { printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; }
done < <(listar_pantallas)
fi
# 4. posicion
while IFS=$'\t' read -r num salida ali pos x y an al; do
[ "$pos" = "$q" ] && { printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; }
done < <(listar_pantallas)
return 1
}
# ----------------------------------------------------------------- ventanas
# El area util de la pantalla que contiene un punto: hay que descontar barras
# y paneles, o las ventanas acaban debajo de la barra de GNOME.
area_util() {
local v; v=$(xprop -root _NET_WORKAREA 2>/dev/null | sed 's/.*= //' | tr -d ' ')
if [ -n "$v" ]; then
echo "$(echo "$v" | cut -d, -f1) $(echo "$v" | cut -d, -f2) $(echo "$v" | cut -d, -f3) $(echo "$v" | cut -d, -f4)"
else
set -- $(xdotool getdisplaygeometry); echo "0 0 $1 $2"
fi
}
# Se descartan las que no son ventanas de verdad: el guardian de mutter, el
# propio shell y las de 1x1 que usan algunos programas como buzon.
listar_ventanas() {
local w n cls x y an al
for w in $(xdotool search --onlyvisible --name "." 2>/dev/null); do
n=$(xdotool getwindowname "$w" 2>/dev/null) || continue
[ -z "$n" ] && continue
case "$n" in "mutter guard window"|"gnome-shell") continue;; esac
eval "$(xdotool getwindowgeometry --shell "$w" 2>/dev/null)" || continue
[ "${WIDTH:-0}" -lt 50 ] && continue
cls=$(xprop -id "$w" WM_CLASS 2>/dev/null | sed 's/.*, *//' | tr -d '"')
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$w" "$n" "${cls:--}" "$X" "$Y" "$WIDTH" "$HEIGHT"
done
}
# Devuelve el id, o varios si hay empate: quien llama decide si preguntar.
resuelve_ventana() {
local q; q=$(echo "$1" | tr 'A-ZÁÉÍÓÚ' 'a-záéíóú')
case "$q" in
esta|activa|"esta ventana"|"la activa"|"ventana activa")
xdotool getactivewindow 2>/dev/null; return;;
esac
listar_ventanas | awk -F'\t' -v q="$q" '
BEGIN{IGNORECASE=1}
{ t=tolower($2); c=tolower($3);
if (index(t,q) || index(c,q)) print $1"\t"$2 }'
}
geo_de() { eval "$(xdotool getwindowgeometry --shell "$1" 2>/dev/null)"; echo "$X $Y $WIDTH $HEIGHT"; }
# Maximizar NO se hace con `xdotool windowstate --add MAXIMIZED`: comprobado,
# en mutter no hace nada. Lo que funciona es activar la ventana y mandar el
# atajo del escritorio. Y una ventana maximizada IGNORA windowmove, asi que
# hay que desmaximizar antes de mover.
desmaximiza() { xdotool windowactivate "$1" 2>/dev/null; sleep 0.2; xdotool key super+Down 2>/dev/null; sleep 0.4; }
maximiza() { xdotool windowactivate "$1" 2>/dev/null; sleep 0.2; xdotool key super+Up 2>/dev/null; sleep 0.4; }
coloca() { # coloca <id> <x> <y> <ancho> <alto>
desmaximiza "$1"
xdotool windowsize "$1" "$4" "$5" 2>/dev/null
xdotool windowmove "$1" "$2" "$3" 2>/dev/null
sleep 0.3
}
# --------------------------------------------------------------------- api
# Imprime el id, o una frase que se pueda decir en voz alta. Nada de
# marcadores tipo NINGUNA/VARIAS: lo que salga de aqui puede acabar en el
# altavoz tal cual, asi que tiene que ser castellano.
una_ventana() {
local r; r=$(resuelve_ventana "$1")
local n; n=$(echo "$r" | grep -c . )
if [ "$n" -eq 0 ]; then
echo "No encuentro ninguna ventana que sea $1, señor."; return 1
fi
if [ "$n" -gt 1 ]; then
# Ambiguo: se enumeran y decide la persona. Elegir una al azar es lo
# peor que puede hacer algo que mueve ventanas ajenas.
printf 'Hay %s que encajan, señor: %s. Digame cual.\n' "$n" \
"$(echo "$r" | cut -f2 | head -4 | paste -sd'|' - | sed 's/|/; /g')"
return 2
fi
echo "$r" | cut -f1
}
case "${1:-}" in
pantallas)
listar_pantallas | while IFS=$'\t' read -r num salida ali pos x y an al; do
printf 'pantalla %s: %s%s, %s, %sx%s\n' "$num" "$salida" \
"$([ "$ali" != "-" ] && echo " ($ali)")" "$pos" "$an" "$al"
done
;;
listar)
listar_ventanas | while IFS=$'\t' read -r w n cls x y an al; do
printf '%s (%s) %sx%s en %s,%s\n' "$n" "$cls" "$an" "$al" "$x" "$y"
done
;;
alias)
salida=$(resuelve_pantalla "${2:-}" | cut -f1)
[ -n "$salida" ] || { echo "No se cual es esa pantalla, señor"; exit 1; }
python3 - "$ALIAS_FICHERO" "$salida" "${3:-}" <<'FIN'
import json, os, sys
fichero, salida, nombre = sys.argv[1], sys.argv[2], sys.argv[3]
os.makedirs(os.path.dirname(fichero), exist_ok=True)
try:
d = json.load(open(fichero))
except Exception:
d = {}
d[salida] = nombre
json.dump(d, open(fichero, "w"), ensure_ascii=False, indent=2)
print(f"{salida} se llama ahora {nombre}")
FIN
;;
mover)
w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }
destino=$(resuelve_pantalla "${3:-}") || { echo "No se cual es esa pantalla, señor"; exit 1; }
IFS=$'\t' read -r salida px py pan pal <<< "$destino"
read -r ax ay aan aal <<< "$(area_util)"
# se centra en la pantalla destino, a tres cuartos de su tamaño
an=$(( pan * 3 / 4 )); al=$(( pal * 3 / 4 ))
x=$(( px + (pan - an) / 2 )); y=$(( py + (pal - al) / 2 ))
[ "$y" -lt "$ay" ] && y=$ay
coloca "$w" "$x" "$y" "$an" "$al"
# "eDP-1" en voz alta se lee letra por letra: se dice el mote si lo tiene,
# y si no el numero, que es lo que entiende cualquiera.
comodo=$(listar_pantallas | awk -F'\t' -v s="$salida" '$2==s {print ($3!="-") ? $3 : "numero " $1}')
echo "Movida a la pantalla ${comodo:-$salida}"
;;
zona)
w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }
read -r ax ay aan aal <<< "$(area_util)"
m2=$(( aan / 2 )); h2=$(( aal / 2 ))
case "$(echo "${3:-}" | tr 'A-ZÁÉÍÓÚ' 'a-záéíóú')" in
izquierda|"mitad izquierda") coloca "$w" "$ax" "$ay" "$m2" "$aal"; echo "Ya la tiene a la izquierda";;
derecha|"mitad derecha") coloca "$w" $((ax+m2)) "$ay" "$m2" "$aal"; echo "Ya la tiene a la derecha";;
arriba) coloca "$w" "$ax" "$ay" "$aan" "$h2"; echo "Ya la tiene arriba";;
abajo) coloca "$w" "$ax" $((ay+h2)) "$aan" "$h2"; echo "Ya la tiene abajo";;
"arriba izquierda") coloca "$w" "$ax" "$ay" "$m2" "$h2"; echo "Colocada arriba a la izquierda";;
"arriba derecha") coloca "$w" $((ax+m2)) "$ay" "$m2" "$h2"; echo "Colocada arriba a la derecha";;
"abajo izquierda") coloca "$w" "$ax" $((ay+h2)) "$m2" "$h2"; echo "Colocada abajo a la izquierda";;
"abajo derecha") coloca "$w" $((ax+m2)) $((ay+h2)) "$m2" "$h2"; echo "Colocada abajo a la derecha";;
centro|centrada) coloca "$w" $((ax+aan/4)) $((ay+aal/4)) $((aan/2)) $((aal/2)); echo "Ya la tiene centrada";;
*) echo "No conozco esa zona, señor"; exit 1;;
esac
;;
maximiza) w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }; maximiza "$w"; echo "Maximizada";;
restaura) w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }; desmaximiza "$w"; echo "Restaurada a su tamaño";;
minimiza) w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }
xdotool windowminimize "$w" 2>/dev/null; echo "Minimizada";;
escritorio)
w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }
n=${3:-1}
case "$n" in una|uno|primero) n=1;; dos|segundo) n=2;; tres|tercero) n=3;; cuatro) n=4;; esac
total=$(xdotool get_num_desktops 2>/dev/null || echo 1)
[ "$n" -ge 1 ] 2>/dev/null && [ "$n" -le "$total" ] || { echo "no hay escritorio $n, solo $total"; exit 1; }
xdotool set_desktop_for_window "$w" $(( n - 1 )) 2>/dev/null
echo "Movida al escritorio $n"
;;
*) sed -n '2,26p' "$0"; exit 1;;
esac

8
config/whisper-server-cuda.sh Executable file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Envoltorio: el whisper-server de Newelle, pero el nuestro compilado con CUDA.
# Newelle lo invoca por flatpak-spawn --host esperando 'whisper-server' en el
# PATH; aqui se fija el entorno de CUDA (que el binario necesita para ver las
# libs) y se pasa a nuestra version con GPU. Los argumentos (-m, --port, -l...)
# los pone Newelle y se reenvian tal cual.
export LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH:-}
exec "$HOME/COFRE/CODERS/JARVIS/voz/whisper-cuda/whisper.cpp/build/bin/whisper-server" "$@"

View file

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Envoltorio: el whisper-server de Newelle, pero el nuestro compilado con CUDA.
# Newelle lo invoca por flatpak-spawn --host esperando 'whisper-server' en el
# PATH; aqui se fija el entorno de CUDA (que el binario necesita para ver las
# libs) y se pasa a nuestra version con GPU. Los argumentos (-m, --port, -l...)
# los pone Newelle y se reenvian tal cual.
#
# Copia versionada: ~/COFRE/CODERS/JARVIS/config/whisper-server-envoltorio.sh
export LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH:-}
BINARIO="$HOME/COFRE/CODERS/JARVIS/voz/whisper-cuda/whisper.cpp/build/bin/whisper-server"
REGISTRO="$HOME/COFRE/CODERS/JARVIS/voz/whisper-arranques.log"
# Cuanta VRAM libre exigimos para arrancar en GPU. El modelo small ocupa 757 MiB
# en reposo pero llega a 839 en el pico de una transcripcion: pedir solo lo que
# ocupa al cargar es justo el error que hacia que cargase bien y muriese al
# hablarle, con un "Error recognizing file with server" que no explicaba nada.
# 950 deja holgura para el pico y para el contexto de CUDA.
MINIMO_MIB=${JARVIS_WHISPER_MIN_VRAM:-950}
# Barrer el servidor anterior antes de levantar el nuestro.
#
# Newelle lo lanza por flatpak-spawn --host, asi que el proceso vive FUERA del
# sandbox y no se entera de que la app se cerro: cada vez que se reinicia
# JARVIS queda un whisper-server huerfano agarrado a 757 MiB de la tarjeta.
# Dos o tres reinicios y ya no cabe el modelo del asistente, con el sintoma
# repartido —a veces va, a veces no— segun cuantos hubiera quedado sueltos.
#
# Se comparan PIDs y no patrones: un `pkill -f whisper-server` desde aqui se
# mataria a si mismo, que este script tiene ese nombre.
for otro in $(pgrep -x whisper-server 2>/dev/null); do
[ "$otro" = "$$" ] && continue
kill "$otro" 2>/dev/null
done
libre=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits 2>/dev/null | head -1)
if [ -n "$libre" ] && [ "$libre" -lt "$MINIMO_MIB" ] 2>/dev/null; then
# Sin sitio: mejor transcribir en procesador que abortar. Es mas lento, pero
# responde; abortar deja la app muda y sin decir por que.
echo "$(date '+%F %T') GPU con solo ${libre} MiB libres (<${MINIMO_MIB}): al procesador" >> "$REGISTRO"
exec "$BINARIO" --no-gpu "$@"
fi
echo "$(date '+%F %T') GPU con ${libre:-?} MiB libres: en tarjeta" >> "$REGISTRO"
exec "$BINARIO" "$@"

150
config/wiki_ingesta.py Normal file
View file

@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Descarga articulos de Wikipedia en espanol sobre los temas de JARVIS y los
deja como .txt en la carpeta de documentos del RAG. Luego, construir_indice.py
los vectoriza. Corre en el host (solo stdlib + red), sin sonido ni GPU.
python3 config/wiki_ingesta.py # descarga
# despues, dentro del flatpak:
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
~/COFRE/CODERS/JARVIS/config/construir_indice.py
Es aditivo: agrega ficheros a la carpeta, no borra lo que ya haya.
"""
import os, json, time, urllib.parse, urllib.request, urllib.error
API = "https://es.wikipedia.org/w/api.php"
UA = "JARVIS-local-RAG/1.0 (asistente de voz local; contacto: local)"
DOCS = os.path.expanduser(
"~/.var/app/io.github.qwersyk.Newelle/config/models/documents")
# Por tema: semillas seguras + una busqueda que amplia con articulos afines.
TEMAS = {
"tecnologia": {
"buscar": ["tecnología", "informática", "hardware", "red informática",
"inteligencia artificial", "sistema operativo"],
"semillas": ["Tecnología", "Informática", "Internet", "Protocolo de red",
"Microprocesador", "Computación en la nube", "Base de datos"],
},
"criptografia": {
"buscar": ["criptografía", "cifrado", "criptografía asimétrica"],
"semillas": ["Criptografía", "Cifrado", "RSA", "Advanced Encryption Standard",
"Función hash criptográfica", "Firma digital",
"Diffie-Hellman", "Pretty Good Privacy", "Transport Layer Security",
"Criptografía de curva elíptica", "Cadena de bloques",
"Bitcoin", "Criptomoneda"],
},
"software_libre": {
"buscar": ["software libre", "código abierto", "historia de Linux"],
"semillas": ["Software libre", "GNU", "Richard Stallman", "GNU/Linux",
"Linus Torvalds", "Free Software Foundation", "Licencia pública general de GNU",
"Código abierto", "Debian", "Unix", "Kernel Linux",
"Movimiento del software libre"],
},
"hacking": {
"buscar": ["seguridad informática", "hacker", "ciberseguridad"],
"semillas": ["Hacker", "Seguridad informática", "Hacker (seguridad informática)",
"Malware", "Exploit", "Ingeniería social (seguridad informática)",
"Phreaking", "Kevin Mitnick", "Anonymous (colectivo)",
"Prueba de penetración", "Vulnerabilidad (informática)",
"Cortafuegos (informática)", "Ataque de denegación de servicio"],
},
"clima": {
"buscar": ["cambio climático", "calentamiento global", "clima"],
"semillas": ["Cambio climático", "Calentamiento global", "Efecto invernadero",
"Gas de efecto invernadero", "Protocolo de Kioto",
"Acuerdo de París", "IPCC", "Clima", "Dióxido de carbono",
"El Niño", "Nivel del mar", "Energía renovable"],
},
"conflictos_espana": {
"buscar": ["guerra en España", "conflicto España historia"],
"semillas": ["Guerra civil española", "Transición española",
"Guerra de la Independencia Española", "Guerras carlistas",
"Reconquista", "Dictadura de Francisco Franco",
"Segunda República Española", "Movimiento 15-M",
"Conflicto vasco", "Guerra de Sucesión Española"],
},
}
BUSCAR_POR_CONSULTA = 15 # articulos extra por cada busqueda
PAUSA = 0.8 # cortesia con la API (subida tras el 429)
def _get(params, reintentos=5):
params = {**params, "format": "json"}
url = API + "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"User-Agent": UA})
espera = 2.0
for intento in range(reintentos):
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
except urllib.error.HTTPError as e:
if e.code == 429 and intento < reintentos - 1:
# Wikipedia nos frena: esperar cada vez mas (backoff)
time.sleep(espera)
espera *= 2
continue
raise
raise RuntimeError("agotados los reintentos")
def titulos_de_busqueda(consulta, limite):
try:
d = _get({"action": "query", "list": "search",
"srsearch": consulta, "srlimit": limite})
return [h["title"] for h in d.get("query", {}).get("search", [])]
except Exception as e:
print(f" busqueda '{consulta}' fallo: {e}")
return []
def extracto(titulo):
try:
d = _get({"action": "query", "prop": "extracts", "explaintext": 1,
"redirects": 1, "titles": titulo})
paginas = d.get("query", {}).get("pages", {})
for _, pg in paginas.items():
txt = pg.get("extract", "")
if txt and len(txt) > 300:
return pg.get("title", titulo), txt
except Exception as e:
print(f" extracto '{titulo}' fallo: {e}")
return None, None
def slug(s):
s = "".join(c if c.isalnum() else "_" for c in s.lower())
return s.strip("_")[:80]
def main():
os.makedirs(DOCS, exist_ok=True)
total = 0
for tema, cfg in TEMAS.items():
print(f"\n=== {tema} ===")
titulos = list(cfg["semillas"])
for consulta in cfg["buscar"]:
titulos += titulos_de_busqueda(consulta, BUSCAR_POR_CONSULTA)
time.sleep(PAUSA)
# dedup conservando orden
vistos, unicos = set(), []
for t in titulos:
if t.lower() not in vistos:
vistos.add(t.lower()); unicos.append(t)
for t in unicos:
dst = os.path.join(DOCS, f"wiki_{tema}_{slug(t)}.txt")
if os.path.exists(dst):
continue
titulo_real, txt = extracto(t)
time.sleep(PAUSA)
if not txt:
continue
with open(dst, "w", encoding="utf-8") as f:
f.write(f"# {titulo_real}\n(Wikipedia, tema: {tema})\n\n{txt}\n")
total += 1
print(f" [{total}] {titulo_real} ({len(txt)//1024} KB)")
print(f"\nlisto: {total} articulos nuevos en {DOCS}")
if __name__ == "__main__":
main()