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.
123 lines
3.8 KiB
Python
123 lines
3.8 KiB
Python
"""Lo que JARVIS oye y que hace con ello. El combustible de todo lo demas.
|
|
|
|
Sin esto no hay forma de mejorar nada: no se puede arreglar lo que no se mide,
|
|
y hasta ahora el nucleo no apuntaba absolutamente nada.
|
|
|
|
## Por que este y no el de antes
|
|
|
|
El diario de la epoca de Newelle guardaba `{"texto": "..."}` y nada mas. Con eso
|
|
no se puede hacer casi nada:
|
|
|
|
- no se sabe **que paso** con la frase, asi que no se distingue una orden que
|
|
fallo de una que funciono;
|
|
- no se sabe **cuando**, asi que no se ve si alguien repitio la misma cosa de
|
|
tres formas seguidas —que es la señal mas valiosa que hay—;
|
|
- y solo guardaba los FALLOS, asi que no habia con que comparar.
|
|
|
|
Ademas se llenaba de eco y de alucinaciones de Whisper, y la mejora nocturna
|
|
acabo generando alias a partir del ruido. De 504 frases, 306 parecian ordenes
|
|
sin atender y al mirarlas casi todas eran JARVIS oyendose a si mismo.
|
|
|
|
## Que se guarda
|
|
|
|
Todo lo que llega, con su destino. Una linea por frase, JSON:
|
|
|
|
t cuando
|
|
oido lo que transcribio Whisper
|
|
via catalogo | cerebro | ruido | bloqueado | respuesta
|
|
accion a que accion fue, si fue a alguna
|
|
arg con que argumento
|
|
seg lo que tardo en contestar
|
|
dicho lo que contesto
|
|
|
|
Guardar TAMBIEN los aciertos es lo que permite comparar: sin ellos, un dia malo
|
|
y un dia bueno tienen el mismo aspecto.
|
|
|
|
## Lo que NO se guarda
|
|
|
|
Nada de audio. El diario es texto y se puede leer con `less`; si algun dia
|
|
guardara los WAV, el asistente pasaria a ser una grabadora de la habitacion y
|
|
eso es otra cosa muy distinta de lo que es.
|
|
"""
|
|
import json
|
|
import os
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
|
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
DIARIO = os.path.join(AQUI, "datos", "diario.jsonl")
|
|
|
|
# Un diario que crece sin fin acaba siendo un problema en vez de una ayuda. A
|
|
# ~150 bytes por linea, 200.000 son unos 30 MB: mas que suficiente para meses de
|
|
# uso y poco para el disco.
|
|
TOPE_LINEAS = 200_000
|
|
|
|
_cerrojo = threading.Lock()
|
|
|
|
|
|
def anota(oido: str, via: str, accion: str = "", arg: str = "",
|
|
seg: float = 0.0, dicho: str = "", ruta: str = DIARIO):
|
|
"""Apunta una frase y su destino. No falla nunca hacia fuera.
|
|
|
|
Si escribir el diario diera error, lo ultimo que debe hacer es tirar al
|
|
asistente: se pierde una linea y ya.
|
|
"""
|
|
if not oido:
|
|
return
|
|
linea = {
|
|
"t": datetime.now().isoformat(timespec="seconds"),
|
|
"oido": oido[:400],
|
|
"via": via,
|
|
}
|
|
if accion:
|
|
linea["accion"] = accion
|
|
if arg:
|
|
linea["arg"] = arg[:200]
|
|
if seg:
|
|
linea["seg"] = round(seg, 2)
|
|
if dicho:
|
|
linea["dicho"] = dicho[:300]
|
|
|
|
try:
|
|
with _cerrojo:
|
|
os.makedirs(os.path.dirname(ruta), exist_ok=True)
|
|
with open(ruta, "a", encoding="utf-8") as f:
|
|
f.write(json.dumps(linea, ensure_ascii=False) + "\n")
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def lee(ruta: str = DIARIO, desde=None) -> list:
|
|
"""Las lineas del diario, ya en diccionarios. Salta las corruptas."""
|
|
salida = []
|
|
try:
|
|
with open(ruta, encoding="utf-8") as f:
|
|
for l in f:
|
|
try:
|
|
d = json.loads(l)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if desde and d.get("t", "") < desde:
|
|
continue
|
|
salida.append(d)
|
|
except OSError:
|
|
pass
|
|
return salida
|
|
|
|
|
|
def recorta(ruta: str = DIARIO, tope: int = TOPE_LINEAS):
|
|
"""Deja solo las ultimas `tope` lineas."""
|
|
lineas = []
|
|
try:
|
|
with open(ruta, encoding="utf-8") as f:
|
|
lineas = f.readlines()
|
|
except OSError:
|
|
return
|
|
if len(lineas) <= tope:
|
|
return
|
|
try:
|
|
with _cerrojo, open(ruta, "w", encoding="utf-8") as f:
|
|
f.writelines(lineas[-tope:])
|
|
except OSError:
|
|
pass
|