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