JARVIS/nucleo/estado.py
sito 8e4bc8ad94 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.
2026-08-16 15:34:37 +02:00

290 lines
14 KiB
Python
Executable file

#!/usr/bin/env python3
"""Por donde va la migracion, en la terminal.
./estado.py las barras
./estado.py -v y que falta exactamente en cada hito
./estado.py --rapido sin ejecutar nada, solo mirar ficheros
El porcentaje NO se escribe a mano: sale de comprobaciones de verdad —que un
fichero exista, que una funcion este, que una prueba pase—. Un contador que se
actualiza a mano miente a la semana, y ademas es justo lo que uno quiere que no
mienta: si dice 60 % es porque tres de cinco comprobaciones pasan ahora mismo.
Las que cuestan tiempo (arrancar whisper, hablar con ollama) van marcadas como
lentas y se saltan con --rapido.
"""
import argparse
import os
import subprocess
import sys
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(AQUI)
VENV = os.path.join(AQUI, "venv", "bin", "python")
# ------------------------------------------------------------- comprobantes
def hay(*trozos) -> bool:
return os.path.exists(os.path.join(AQUI, *trozos))
def dentro(fichero, texto) -> bool:
try:
with open(os.path.join(AQUI, fichero), encoding="utf-8") as f:
return texto in f.read()
except OSError:
return False
def corre(orden, espera=90) -> bool:
try:
r = subprocess.run(orden, cwd=AQUI, capture_output=True, timeout=espera)
return r.returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
def modulo(nombre) -> bool:
return corre([VENV if os.path.exists(VENV) else sys.executable,
"-c", f"import {nombre}"], espera=30)
def sin_comentarios(fichero, texto) -> bool:
"""Si el texto aparece en una linea que NO es un comentario."""
try:
with open(os.path.join(AQUI, fichero), encoding="utf-8") as f:
for l in f:
limpia = l.strip()
if limpia and not limpia.startswith(("#", "//", "*")) and texto in limpia:
return True
except OSError:
pass
return False
def catalogo_tiene(n) -> bool:
try:
import json
with open(os.path.join(AQUI, "datos", "acciones.json"), encoding="utf-8") as f:
return len(json.load(f)["acciones"]) >= n
except Exception:
return False
# ---------------------------------------------------------------- los hitos
#
# Cada comprobacion es (que se comprueba, como, si es lenta). El orden dentro
# de cada hito va de lo mas basico a lo mas fino, que es como se construyo.
HITOS = [
("la boca", "hablar por los altavoces", [
("el modulo esta", lambda: hay("boca", "voz.py"), False),
("hay tres voces o mas", lambda: dentro("boca/voz.py", '"claude"'), False),
("cache de frases por sha1", lambda: dentro("boca/voz.py", "_clave"), False),
("sabe si esta sonando", lambda: dentro("boca/voz.py", "def hablando"), False),
("habla de verdad", lambda: corre(
[VENV, "jarvis.py", "--di", "prueba"], 60), True),
]),
("el oido", "escuchar y transcribir", [
("captura con pw-record", lambda: dentro("oido/captura.py", "pw-record"), False),
("detector de voz Silero", lambda: modulo("pysilero_vad"), False),
("cliente de whisper-server", lambda: hay("oido", "whisper.py"), False),
("filtro de alucinaciones", lambda: dentro("oido/whisper.py", "def es_ruido"), False),
("guarda contra el eco", lambda: dentro("oido/captura.py", "boca.hablando"), False),
("whisper arranca y transcribe", lambda: corre(
[VENV, "-c",
"import sys;sys.path.insert(0,'.');from oido.whisper import Oido;"
"o=Oido();import sys as s;s.exit(0 if o.arranca() else 1)"], 90), True),
]),
("las manos", "ejecutar las 150 acciones", [
("el emparejador copiado", lambda: hay("manos", "acciones.py"), False),
("las tareas en paralelo", lambda: hay("manos", "tareas.py"), False),
("el catalogo entero", lambda: catalogo_tiene(150), False),
("una sola puerta para ejecutar", lambda: dentro("manos/__init__.py", "class Manos"), False),
("compara tambien por sonido", lambda: hay("manos", "fonetica.py"), False),
("banco fijo para medir", lambda: hay("datos", "banco.json"), False),
("las pruebas en verde", lambda: corre(
[VENV, "pruebas/prueba_manos.py"], 120), True),
]),
("el cerebro", "pensar lo que el catalogo no cubre", [
("cliente de ollama", lambda: hay("cerebro", "ollama.py"), False),
("dos herramientas, no una", lambda: dentro("cerebro/ollama.py", "comando_de_shell"), False),
("no puede inventarse datos", lambda: dentro("cerebro/ollama.py", "REGLA QUE NO SE ROMPE"), False),
("tope de vueltas", lambda: dentro("cerebro/ollama.py", "VUELTAS"), False),
("cadena catalogo-primero", lambda: dentro("jarvis.py", "def atiende"), False),
("ollama contesta", lambda: corre(
["curl", "-sf", "-m", "10", "http://127.0.0.1:11434/api/tags"], 20), True),
]),
("la palabra", "que le llamen JARVIS", [
("el centinela existe", lambda: hay("oido", "despierta.py"), False),
("se llama JARVIS", lambda: dentro("oido/despierta.py", 'NOMBRE = "jarvis"'), False),
("aguanta como lo oye Whisper", lambda: dentro("oido/despierta.py", "PARECIDAS"), False),
("la orden va en la misma frase", lambda: dentro("oido/despierta.py",
"def parte_el_nombre"), False),
("conectado al bucle de voz", lambda: dentro("jarvis.py", "centinela.filtra"), False),
# El que de verdad importaba: sin el prompt del nombre, Whisper nunca
# escribe JARVIS y el centinela no despierta jamas.
("prompt del nombre para Whisper", lambda: dentro("oido/whisper.py",
"PROMPT_NOMBRE"), False),
("boton para dormirlo y despertarlo", lambda: dentro("cara/panel.py",
'dato == "sueno"'), False),
("prueba de voz de punta a punta", lambda: hay("pruebas", "prueba_voz.py"), False),
("y la palabra va APAGADA por defecto", lambda: dentro("jarvis.py",
"activo=a.palabra"), False),
]),
("la cara", "el panel", [
("el HUD copiado", lambda: hay("cara", "hud", "index.html"), False),
("servidor http y websocket", lambda: hay("cara", "servidor.py"), False),
# El WebSocket vive en puente.js, no en hud.js: hud.js no se toca a
# proposito y el puente suplanta window.webkit por debajo.
("puente por WebSocket", lambda: dentro("cara/hud/puente.js", "WebSocket"), False),
("y hud.js sigue sin tocarse", lambda: dentro("cara/hud/hud.js",
"messageHandlers"), False),
("el panel se conecta al nucleo", lambda: dentro("cara/servidor.py", "async"), False),
# Ojo con las comprobaciones flojas: esta buscaba la palabra "tarea" en
# servidor.py y pasaba porque salia en un COMENTARIO, con las tarjetas
# sin conectar. Una barra que miente es peor que no tener barra.
("tarjetas de tareas en marcha", lambda: dentro("cara/panel.py",
"GestorTareas"), False),
("la consola lleva la conversacion", lambda: dentro("cara/hud/hud.js",
"aConsolaDicho"), False),
]),
("el dialogo", "que pregunte en vez de recitar", [
("las preguntas salen del catalogo", lambda: hay("manos", "preguntar.py"), False),
("pulsar pregunta lo que falta", lambda: dentro("cara/panel.py", "_pregunta("), False),
("la respuesta hablada la recoge", lambda: dentro("jarvis.py", "panel.responde"), False),
("lo destructivo confirma", lambda: dentro("cara/panel.py", "accion.confirmar"), False),
("y el panel enseña que espera", lambda: dentro("cara/hud/hud.js", "preguntando"), False),
("la prueba pasa", lambda: corre([VENV, "pruebas/prueba_dialogo.py"], 120), True),
]),
("el candado", "que no obedezca a cualquiera", [
("el candado en el HUD", lambda: dentro("cara/hud/index.html", "candado"), False),
("lo atiende el nucleo", lambda: dentro("cara/panel.py", "_intenta_desbloquear"), False),
("valida con la contrasena del sistema", lambda: dentro("cara/panel.py",
"GestorRoot"), False),
("con el candado puesto no ejecuta", lambda: dentro("cara/panel.py",
"no hay ordenes"), False),
("y la VOZ tampoco obedece", lambda: dentro("jarvis.py", "(bloqueado)"), False),
("la prueba pasa", lambda: corre([VENV, "pruebas/prueba_candado.py"], 120), True),
]),
("el saber", "conocer COFRE entero", [
("indexador de COFRE", lambda: hay("saber", "indexa.py"), False),
("busqueda por significado", lambda: hay("saber", "busca.py"), False),
("indice construido", lambda: hay("datos", "saber.jsonl"), False),
("el cerebro lo consulta antes de responder", lambda: dentro(
"cerebro/ollama.py", "def _saber"), False),
("filtra el codigo de terceros", lambda: dentro("saber/indexa.py", "IGNORA"), False),
("indexa la documentacion de Linux", lambda: hay("saber", "sistema.py"), False),
("y los servidores SSH", lambda: dentro("saber/sistema.py", "def servidores"), False),
("el cerebro puede ejecutar como root", lambda: dentro("manos/__init__.py",
"necesita_root"), False),
("root ligado al candado", lambda: dentro("cara/panel.py", "root.recuerda"), False),
]),
("el pipeline", "aprender del uso", [
("diario de lo que oye y hace", lambda: hay("manos", "diario.py"), False),
("apunta la VIA de cada frase", lambda: dentro("jarvis.py", 'anota(texto, "ruido")'), False),
("y tambien los aciertos", lambda: dentro("jarvis.py", '"catalogo", accion.id'), False),
("herramienta para revisarlo", lambda: hay("mejora.py"), False),
("propone, NO commitea solo", lambda: dentro("mejora.py", "Esto NO cambia nada"), False),
("mide contra el banco fijo", lambda: hay("datos", "banco.json"), False),
]),
("paridad", "usarlo a diario", [
("icono naranja", lambda: hay("jarvis-naranja.svg"), False),
("icono instalado", lambda: os.path.exists(os.path.expanduser(
"~/.local/share/icons/jarvis-nucleo.png")), False),
("lanzador propio", lambda: hay("arranca.sh"), False),
("ventana propia, no pestaña", lambda: hay("ventana.sh"), False),
# Se miran las lineas de CODIGO, no el fichero entero: el comentario que
# explica por que ya no se usa xdg-open contiene la palabra, y una
# comprobacion ingenua se cree que sigue ahi. Es la tercera vez que la
# barra casi miente por buscar texto suelto en vez de codigo.
("una sola forma de abrirla", lambda: not sin_comentarios(
"arranca.sh", "xdg-open"), False),
("la barra de tareas la reconoce", lambda: "StartupWMClass" in open(
os.path.expanduser("~/.local/share/applications/jarvis-nucleo.desktop")).read(), False),
("entrada en el escritorio", lambda: os.path.exists(os.path.expanduser(
"~/.local/share/applications/jarvis-nucleo.desktop")), False),
("Newelle congelado y aparte", lambda: corre(
["git", "-C", RAIZ, "rev-parse", "jarvis-newelle-final"], 15), False),
]),
]
# ------------------------------------------------------------------ pintarlo
ANCHO = 34
def color(t, c):
return f"\033[{c}m{t}\033[0m" if sys.stdout.isatty() else t
def barra(pct, hecho, total):
lleno = round(ANCHO * pct / 100)
# verde entero, amarillo a medias, gris sin empezar
c = "32" if pct == 100 else ("33" if pct > 0 else "90")
return color("" * lleno, c) + color("·" * (ANCHO - lleno), "90")
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("-v", "--detalle", action="store_true")
p.add_argument("--rapido", action="store_true",
help="sin ejecutar nada, solo mirar ficheros")
a = p.parse_args()
if not os.path.exists(VENV):
print(color(" aviso: no hay venv; algunas comprobaciones no se pueden hacer\n", "33"))
print()
print(color(" JARVIS · migracion a nucleo propio", "1"))
print()
hechas = totales = 0
for i, (nombre, para_que, comprobaciones) in enumerate(HITOS, 1):
activas = [c for c in comprobaciones if not (a.rapido and c[2])]
pasan = []
for texto, prueba, lenta in activas:
try:
ok = bool(prueba())
except Exception:
ok = False
pasan.append((texto, ok, lenta))
n = sum(1 for _, ok, _ in pasan if ok)
pct = 100 * n / len(pasan) if pasan else 0
hechas += n
totales += len(pasan)
marca = color("", "32") if pct == 100 else (" " if pct == 0 else color("·", "33"))
print(f" {marca} {i}. {nombre:12s} {barra(pct, n, len(pasan))} "
f"{pct:3.0f}% {n}/{len(pasan)} {color(para_que, '90')}")
if a.detalle:
for texto, ok, lenta in pasan:
s = color(" ok ", "32") if ok else color(" --", "31")
print(f" {s} {texto}" + (color(" (lenta)", "90") if lenta else ""))
print()
print()
total_pct = 100 * hechas / totales if totales else 0
print(f" {'TOTAL':14s} {barra(total_pct, hechas, totales)} "
f"{total_pct:3.0f}% {hechas}/{totales}")
print()
if a.rapido:
print(color(" (rapido: sin las comprobaciones que ejecutan cosas)\n", "90"))
return 0
if __name__ == "__main__":
sys.exit(main())