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
147
nucleo/manos/preguntar.py
Normal file
147
nucleo/manos/preguntar.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""Que preguntar cuando falta un dato para ejecutar una accion.
|
||||
|
||||
Pulsar una accion en el panel no puede limitarse a recitar la frase que la
|
||||
dispara —"diga «busca el fichero» y a continuacion lo que busca"—: eso es un
|
||||
manual de instrucciones, y uno pulsa justo porque no quiere leerse el manual.
|
||||
Lo natural es que pregunte lo que falta y lo haga.
|
||||
|
||||
busca el fichero -> ¿Que fichero, señor?
|
||||
manda la ventana a la pantalla -> ¿A que pantalla, señor?
|
||||
mata el proceso -> ¿Que proceso, señor?
|
||||
|
||||
## Como sale la pregunta
|
||||
|
||||
Del propio catalogo, no de una lista escrita a mano ni de un modelo. Las frases
|
||||
del catalogo son regulares —verbo + articulo + objeto— asi que basta con buscar
|
||||
el ULTIMO articulo y quedarse con lo que va detras.
|
||||
|
||||
Y se pregunta por el OBJETO, no conjugando el verbo. "¿Que fichero, señor?" es
|
||||
tan natural como "¿Que fichero busco, señor?" y no obliga a conjugar: pasar de
|
||||
"busca" a "busco", de "leeme" a "leo" o de "pon" a "pongo" pide un diccionario
|
||||
de verbos irregulares para ganar nada.
|
||||
|
||||
Si la preposicion viene delante del articulo, se conserva: sin ella, "manda la
|
||||
ventana a la pantalla" preguntaria "¿Que pantalla?" y se pierde el "a", que es
|
||||
justo lo que dice que la respuesta es un destino.
|
||||
"""
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
ARTICULOS = {"el", "la", "los", "las", "un", "una", "unos", "unas"}
|
||||
# Las contracciones ya llevan la preposicion dentro.
|
||||
CONTRACCIONES = {"al": "a", "del": "de"}
|
||||
PREPOSICIONES = {"a", "de", "en", "con", "por", "para", "sobre", "hacia", "desde"}
|
||||
|
||||
# Cuando no hay de donde sacarlo. Vale para "esta corriendo" o "tengo instalado",
|
||||
# donde el objeto no aparece en la frase.
|
||||
GENERICA = "¿El que, señor?"
|
||||
|
||||
|
||||
def _sin_tildes(t: str) -> str:
|
||||
t = unicodedata.normalize("NFD", t)
|
||||
return "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||
|
||||
|
||||
def pregunta_por(accion) -> str:
|
||||
"""La pregunta que rellena el hueco de una accion que espera argumento.
|
||||
|
||||
La clave: el argumento va SIEMPRE al final de la frase, porque asi lo
|
||||
recoge el emparejador. Asi que la pregunta se decide mirando como TERMINA.
|
||||
"""
|
||||
frases = getattr(accion, "frases", None) or []
|
||||
if not frases:
|
||||
return GENERICA
|
||||
|
||||
palabras = _sin_tildes(frases[0].lower()).split()
|
||||
if not palabras:
|
||||
return GENERICA
|
||||
ultima = palabras[-1]
|
||||
|
||||
# 1. Termina en preposicion: el hueco va justo detras.
|
||||
# "busca el precio de" -> ¿El precio de que, señor?
|
||||
# "busca imagenes de" -> ¿Imagenes de que, señor?
|
||||
if ultima in PREPOSICIONES:
|
||||
trozo = []
|
||||
# Sin la primera palabra: es el verbo, y en la pregunta sobra.
|
||||
# "busca imagenes de" tiene que dar "¿Imagenes de que?", no
|
||||
# "¿Busca imagenes de que?".
|
||||
for p in reversed(palabras[1:-1]):
|
||||
if p in ARTICULOS or p in CONTRACCIONES:
|
||||
trozo.insert(0, p)
|
||||
break
|
||||
if p in PREPOSICIONES:
|
||||
break
|
||||
trozo.insert(0, p)
|
||||
if len(trozo) >= 2:
|
||||
break
|
||||
if trozo:
|
||||
frase = " ".join(trozo).capitalize()
|
||||
return f"¿{frase} {ultima} que, señor?"
|
||||
return GENERICA
|
||||
|
||||
# 2. "busca en <sitio>": lo que falta es QUE buscar, no el sitio.
|
||||
# Sin esta regla, "busca en la wikipedia" preguntaba "¿En que
|
||||
# wikipedia?", que no tiene sentido: solo hay una.
|
||||
if "en" in palabras:
|
||||
i = palabras.index("en")
|
||||
sitio = " ".join(palabras[i:])
|
||||
verbo = palabras[0]
|
||||
if verbo.startswith("busca"):
|
||||
return f"¿Que busco {sitio}, señor?"
|
||||
return f"¿Que {sitio}, señor?"
|
||||
|
||||
# 3. Lo normal: articulo + objeto al final.
|
||||
# "busca el fichero" -> ¿Que fichero, señor?
|
||||
# "manda la ventana a la pantalla" -> ¿A que pantalla, señor?
|
||||
for i in range(len(palabras) - 1, -1, -1):
|
||||
p = palabras[i]
|
||||
objeto = palabras[i + 1] if i + 1 < len(palabras) else ""
|
||||
if not objeto:
|
||||
continue
|
||||
if p in CONTRACCIONES:
|
||||
return f"¿{CONTRACCIONES[p].capitalize()} que {objeto}, señor?"
|
||||
if p in ARTICULOS:
|
||||
anterior = palabras[i - 1] if i > 0 else ""
|
||||
if anterior in PREPOSICIONES:
|
||||
return f"¿{anterior.capitalize()} que {objeto}, señor?"
|
||||
return f"¿Que {objeto}, señor?"
|
||||
return GENERICA
|
||||
|
||||
|
||||
def confirmacion(accion) -> str:
|
||||
"""Lo que se pregunta antes de algo que no tiene vuelta atras.
|
||||
|
||||
La orden se CITA en vez de meterla en la frase. Las del catalogo estan en
|
||||
imperativo —"cierra el navegador"— y encajarlas da cosas como "Voy a cierra
|
||||
el navegador". Conjugarlas al infinitivo pediria un diccionario de verbos
|
||||
irregulares (cierra->cerrar, pon->poner, ve->ir) para ganar poco: entre
|
||||
comillas se lee bien y ademas queda claro que es literalmente la orden.
|
||||
"""
|
||||
frase = (getattr(accion, "frases", None) or [""])[0]
|
||||
return f"«{frase}». Esto no tiene vuelta atras. ¿Confirma, señor?"
|
||||
|
||||
|
||||
# Lo que cuenta como si y como no. Se comparan sin tildes y sin puntuacion,
|
||||
# porque llegan de Whisper: "sí", "Si.", "SÍ" y "sip" son lo mismo.
|
||||
SI = {"si", "sip", "claro", "vale", "venga", "adelante", "confirmo", "hazlo",
|
||||
"por supuesto", "correcto", "afirmativo", "eso es", "dale", "ok", "okey"}
|
||||
NO = {"no", "nop", "nada", "dejalo", "olvidalo", "cancela", "cancelalo",
|
||||
"para", "mejor no", "negativo", "ni hablar", "espera"}
|
||||
|
||||
|
||||
def es_si(texto: str):
|
||||
"""True si es un si, False si es un no, None si no es ni una cosa ni otra."""
|
||||
limpio = _sin_tildes(re.sub(r"[¡!¿?.,;]", "", (texto or "").lower())).strip()
|
||||
if not limpio:
|
||||
return None
|
||||
if limpio in SI:
|
||||
return True
|
||||
if limpio in NO:
|
||||
return False
|
||||
# Tambien al principio: "si, hazlo" o "no, dejalo"
|
||||
primera = limpio.split()[0]
|
||||
if primera in SI:
|
||||
return True
|
||||
if primera in NO:
|
||||
return False
|
||||
return None
|
||||
Loading…
Add table
Add a link
Reference in a new issue