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.
147 lines
5.8 KiB
Python
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
|