JARVIS/nucleo/manos/preguntar.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

147 lines
5.8 KiB
Python

"""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