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
114
nucleo/manos/__init__.py
Normal file
114
nucleo/manos/__init__.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Lo que JARVIS puede HACER, y el unico sitio por donde pasa.
|
||||
|
||||
`Manos` es la puerta: el catalogo entra por aqui, el cerebro entra por aqui y
|
||||
el panel entrara por aqui. Tener un solo punto importa porque es donde se
|
||||
decide que se ejecuta y que no, y esa decision no puede estar repartida.
|
||||
|
||||
Las cuatro piezas de debajo vienen tal cual de la version sobre Newelle
|
||||
—`acciones.py`, `tareas.py`, `sudo_root.py`, `seleccion.py`— y se comprobo al
|
||||
copiarlas que 374 frases dan exactamente el mismo resultado en los dos sitios.
|
||||
"""
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
from .acciones import Catalogo, ejecuta, formatea
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
|
||||
PROPIO = os.path.expanduser("~/.config/jarvis/acciones.json")
|
||||
|
||||
# Lo que no se ejecuta por mucho que lo pida el modelo. No es una lista de
|
||||
# seguridad completa —eso es imposible— sino un tope contra el accidente: el
|
||||
# modelo es servicial y a veces se inventa una limpieza que nadie pidio.
|
||||
PROHIBIDO = (
|
||||
"rm -rf /", "mkfs", "dd if=", ":(){", "shutdown", "reboot", "halt",
|
||||
"> /dev/sd", "chmod -R 777 /", "chown -R", "userdel", "passwd",
|
||||
)
|
||||
|
||||
TOPE_SALIDA = 4000
|
||||
|
||||
|
||||
class Manos:
|
||||
def __init__(self, catalogo=None, al_ejecutar=None):
|
||||
self.catalogo = catalogo or Catalogo([PROPIO, CATALOGO])
|
||||
self.al_ejecutar = al_ejecutar
|
||||
# Root para la sesion: cuando el usuario desbloquea el candado con su
|
||||
# contraseña, esa misma queda aqui y el cerebro puede hacer sudo hasta
|
||||
# que caduque. Sin desbloquear, no hay root: el candado es la puerta.
|
||||
from manos.sudo_root import GestorRoot
|
||||
self.root = GestorRoot([])
|
||||
|
||||
# ------------------------------------------------------------- catalogo
|
||||
|
||||
def busca(self, texto: str):
|
||||
return self.catalogo.busca(texto)
|
||||
|
||||
def haz(self, accion, argumento="") -> tuple:
|
||||
"""Ejecuta una accion ya emparejada. Devuelve (lo que se dice, crudo)."""
|
||||
dicho, salida = ejecuta(accion, [], argumento)
|
||||
if salida:
|
||||
dicho = formatea(accion.respuesta, salida, argumento)
|
||||
return dicho, salida
|
||||
|
||||
def por_orden(self, orden: str, al_ejecutar=None) -> str:
|
||||
"""Para el cerebro: empareja contra el catalogo y ejecuta.
|
||||
|
||||
Devuelve texto para el modelo, no para el usuario: si no encaja, se le
|
||||
dice claramente para que pruebe con el shell en vez de insistir.
|
||||
"""
|
||||
if not orden.strip():
|
||||
return "no me has dicho ninguna orden"
|
||||
# El modelo a veces cuela un comando aqui de todas formas. Se detecta y
|
||||
# se le devuelve el motivo, que es mas util que un "no encontrado".
|
||||
if any(c in orden for c in "|><;&$") or orden.split()[0] in ("df", "ls", "ps", "cat"):
|
||||
return ("eso es un comando de shell, no una orden del catalogo. "
|
||||
"Usa la herramienta comando_de_shell.")
|
||||
accion, argumento = self.catalogo.busca(orden)
|
||||
if accion is None:
|
||||
return (f"'{orden}' no esta en el catalogo. Si hace falta, usa "
|
||||
"comando_de_shell.")
|
||||
if al_ejecutar or self.al_ejecutar:
|
||||
(al_ejecutar or self.al_ejecutar)(accion.id, argumento)
|
||||
dicho, salida = self.haz(accion, argumento)
|
||||
return (salida or dicho)[:TOPE_SALIDA]
|
||||
|
||||
# ---------------------------------------------------------------- shell
|
||||
|
||||
def por_shell(self, comando: str, al_ejecutar=None) -> str:
|
||||
if not comando.strip():
|
||||
return "no me has dicho ningun comando"
|
||||
bajo = comando.lower()
|
||||
for veneno in PROHIBIDO:
|
||||
if veneno in bajo:
|
||||
return (f"no ejecuto eso: contiene '{veneno}'. Si de verdad hace "
|
||||
"falta, que lo pida el usuario expresamente.")
|
||||
if al_ejecutar or self.al_ejecutar:
|
||||
(al_ejecutar or self.al_ejecutar)("shell", comando)
|
||||
|
||||
from manos.sudo_root import necesita_root
|
||||
# Los comandos con sudo van por GestorRoot, que da la contraseña de la
|
||||
# sesion por stdin. Sin ella, `bash -c "sudo ..."` se colgaria pidiendo
|
||||
# una contraseña que nadie teclea. Y si el candado no se ha abierto, no
|
||||
# hay contraseña: el root queda cerrado hasta que el usuario desbloquee.
|
||||
if necesita_root(comando):
|
||||
if not self.root.sesion_activa():
|
||||
return ("eso necesita root y el candado no esta abierto. "
|
||||
"Desbloquee con su contraseña y lo repito.")
|
||||
try:
|
||||
r = self.root.ejecuta(comando, timeout=60)
|
||||
except Exception as e:
|
||||
return f"no se pudo ejecutar como root: {e}"
|
||||
else:
|
||||
try:
|
||||
r = subprocess.run(["bash", "-c", comando], capture_output=True,
|
||||
text=True, timeout=60)
|
||||
except subprocess.TimeoutExpired:
|
||||
return "el comando tardo mas de un minuto y lo he cortado"
|
||||
except OSError as e:
|
||||
return f"no se pudo ejecutar: {e}"
|
||||
|
||||
salida = (r.stdout or r.stderr or "").strip()
|
||||
if not salida:
|
||||
return "hecho, sin salida" if r.returncode == 0 else f"fallo (codigo {r.returncode})"
|
||||
return salida[:TOPE_SALIDA]
|
||||
310
nucleo/manos/acciones.py
Normal file
310
nucleo/manos/acciones.py
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
"""Acciones rapidas: caminos predefinidos que se saltan el modelo.
|
||||
|
||||
Por que existe esto. Preguntar "cuanto espacio queda" y esperar a que un modelo
|
||||
de 4B razone, decida llamar a una herramienta, la ejecute y redacte la
|
||||
respuesta cuesta segundos. Pero esa pregunta no necesita pensarse: es un `df`.
|
||||
El catalogo empareja la frase con un comando y contesta con una plantilla, y
|
||||
eso baja de segundos a decimas.
|
||||
|
||||
No es entrenar un modelo: es una tabla. Y esa es la gracia, porque una tabla es
|
||||
predecible, se lee, se corrige y no alucina. Lo que no encaje en la tabla sigue
|
||||
yendo al modelo como siempre.
|
||||
|
||||
Formato del catalogo (JSON):
|
||||
|
||||
{
|
||||
"acciones": [
|
||||
{
|
||||
"id": "disco_libre",
|
||||
"frases": ["cuanto espacio queda", "espacio en disco"],
|
||||
"acuse": "Un momento, lo miro.",
|
||||
"comando": "df -h / | tail -1",
|
||||
"host": true,
|
||||
"respuesta": "Quedan {campo4} libres de {campo2}.",
|
||||
"confirmar": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
En `respuesta` se puede usar:
|
||||
{salida} la salida completa, recortada
|
||||
{primera} la primera linea
|
||||
{ultima} la ultima linea no vacia
|
||||
{campoN} la columna N de la ultima linea (1 en adelante)
|
||||
{lineas} cuantas lineas ha devuelto
|
||||
"""
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import unicodedata
|
||||
|
||||
UMBRAL_PARECIDO = 0.86 # por debajo empieza a confundir ordenes
|
||||
TIEMPO_LIMITE = 20 # ningun atajo deberia tardar mas
|
||||
SALIDA_MAXIMA = 600 # lo que se guarda para enseñar y para la plantilla
|
||||
|
||||
|
||||
def normaliza(texto: str) -> str:
|
||||
"""Minusculas, sin tildes, sin puntuacion y con los espacios apretados.
|
||||
|
||||
Whisper devuelve "¿Cuánto espacio queda?" y el catalogo dice "cuanto
|
||||
espacio queda". Sin normalizar no casaria ni una.
|
||||
|
||||
Lo de apretar los espacios no es cosmetico: al sustituir la puntuacion por
|
||||
un espacio, "cuanto, espacio queda" quedaba con dos seguidos y dejaba de
|
||||
coincidir con la frase del catalogo. El sintoma era un asistente que a
|
||||
veces no reacciona a una orden que ha entendido bien, segun donde el
|
||||
reconocedor decida poner una coma.
|
||||
"""
|
||||
texto = unicodedata.normalize("NFD", texto.lower())
|
||||
texto = "".join(c for c in texto if unicodedata.category(c) != "Mn")
|
||||
return " ".join(re.sub(r"[^\w\s]", " ", texto).split())
|
||||
|
||||
|
||||
# El envoltorio de cortesia con el que habla cualquiera. Nadie dice "pon la
|
||||
# pelicula" a secas: dice "necesito que pongas la pelicula" o "puedes ponerme".
|
||||
# Con eso delante, ninguna frase del catalogo encaja como subcadena y la orden
|
||||
# se pierde entera.
|
||||
#
|
||||
# Medido sobre las 462 frases que el panel anoto por no entender: de las 18 que
|
||||
# eran ordenes de verdad, la mayoria fallaba solo por esto. Cinco de ellas eran
|
||||
# el mismo intento de poner una pelicula, dicho de cinco maneras.
|
||||
CORTESIA = re.compile(
|
||||
r"^(por favor,?\s+)?"
|
||||
r"(necesito que( me)?|quiero que( me)?|me gustaria que( me)?|"
|
||||
r"puedes|podrias|me puedes|me podrias|te importa|hazme el favor de|"
|
||||
r"a ver si|va(le|)?,? |anda,? |porfa,? |quiero|necesito)\s+",
|
||||
re.I)
|
||||
|
||||
|
||||
class Accion:
|
||||
def __init__(self, datos: dict):
|
||||
self.id = datos.get("id", "")
|
||||
self.frases = [normaliza(f) for f in datos.get("frases", [])]
|
||||
self.acuse = datos.get("acuse", "Voy.")
|
||||
self.comando = datos.get("comando", "")
|
||||
self.host = bool(datos.get("host", True))
|
||||
self.respuesta = datos.get("respuesta", "{salida}")
|
||||
self.confirmar = bool(datos.get("confirmar", False))
|
||||
self.captura = bool(datos.get("captura", False))
|
||||
self.grupo = datos.get("grupo", "")
|
||||
|
||||
def coincide(self, texto: str):
|
||||
"""(longitud de la frase que encaja, lo que venia detras).
|
||||
|
||||
Se devuelve la longitud y no un si/no para poder quedarse con la
|
||||
coincidencia mas especifica: "abre la carpeta de descargas" debe ganar
|
||||
a "abre la carpeta" cuando las dos encajan.
|
||||
|
||||
El resto es el argumento: en "busca gatos en youtube" la frase es
|
||||
"busca" ... y lo demas es lo que hay que buscar. Solo se recoge si la
|
||||
accion lo pide, para que una frase suelta no arrastre basura.
|
||||
"""
|
||||
mejor, resto = 0, ""
|
||||
for f in self.frases:
|
||||
if not f or f not in texto:
|
||||
continue
|
||||
if len(f) > mejor:
|
||||
mejor = len(f)
|
||||
resto = texto.split(f, 1)[1].strip() if self.captura else ""
|
||||
return mejor, resto
|
||||
|
||||
|
||||
def parecido(a: str, b: str) -> float:
|
||||
"""Cuanto se parecen dos cadenas, de 0 a 1, por letra y por sonido.
|
||||
|
||||
Para los casi-aciertos del reconocedor: "yo tuve" por "yo tube", "casis"
|
||||
por "oasis". Se usa difflib y no una distancia de edicion propia porque
|
||||
viene en la biblioteca estandar y aqui no hay que hilar tan fino.
|
||||
|
||||
Se queda con el MAYOR de los dos parecidos, el de la letra y el del sonido.
|
||||
Muchos fallos del reconocedor no son de significado sino de ortografia
|
||||
—"Abre van Kamp" por "abre bandcamp", "poops" por "pubs"— y por escrito
|
||||
quedan lejos aunque suenen igual. Medido contra el banco: sube el acierto
|
||||
de 79,3 a 84,7 % sin que ninguna de las 50 frases de charla dispare nada.
|
||||
|
||||
Que sea el maximo y no la media importa: la media hundiria los aciertos
|
||||
normales, donde la letra ya casaba bien y el sonido no aporta.
|
||||
"""
|
||||
literal = difflib.SequenceMatcher(None, a, b).ratio()
|
||||
if literal >= 0.98:
|
||||
return literal # ya casa: no hace falta el sonido
|
||||
from .fonetica import fonetica
|
||||
return max(literal,
|
||||
difflib.SequenceMatcher(None, fonetica(a), fonetica(b)).ratio())
|
||||
|
||||
|
||||
class Catalogo:
|
||||
def __init__(self, rutas):
|
||||
self.acciones = []
|
||||
self.errores = []
|
||||
for ruta in rutas:
|
||||
if ruta and os.path.exists(ruta):
|
||||
self._carga(ruta)
|
||||
|
||||
def _carga(self, ruta):
|
||||
try:
|
||||
with open(ruta) as f:
|
||||
datos = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
self.errores.append(f"{os.path.basename(ruta)}: {e}")
|
||||
return
|
||||
|
||||
vistos = {a.id for a in self.acciones}
|
||||
for entrada in datos.get("acciones", []):
|
||||
accion = Accion(entrada)
|
||||
if not accion.id or not accion.frases:
|
||||
self.errores.append(f"{os.path.basename(ruta)}: accion sin id o sin frases")
|
||||
continue
|
||||
# el primero que se carga manda: asi el catalogo del usuario puede
|
||||
# redefinir uno del paquete sin tener que borrarlo
|
||||
if accion.id not in vistos:
|
||||
self.acciones.append(accion)
|
||||
vistos.add(accion.id)
|
||||
|
||||
def busca(self, texto: str):
|
||||
"""(accion mas especifica que encaje, argumento) o (None, "").
|
||||
|
||||
Si no encaja nada, se reintenta quitando la cortesia de delante. Se
|
||||
hace como SEGUNDA pasada y no normalizando desde el principio a
|
||||
proposito: asi lo que ya funcionaba sigue funcionando igual, y esto
|
||||
solo añade aciertos donde antes no habia ninguno.
|
||||
"""
|
||||
encontrada, argumento = self._busca_directa(normaliza(texto))
|
||||
if encontrada is not None:
|
||||
return encontrada, argumento
|
||||
sin_cortesia = CORTESIA.sub("", normaliza(texto)).strip()
|
||||
if sin_cortesia and sin_cortesia != normaliza(texto):
|
||||
return self._busca_directa(sin_cortesia)
|
||||
return None, ""
|
||||
|
||||
def _busca_directa(self, texto: str):
|
||||
mejor, puntos, argumento = None, 0, ""
|
||||
for accion in self.acciones:
|
||||
p, resto = accion.coincide(texto)
|
||||
if p > puntos:
|
||||
mejor, puntos, argumento = accion, p, resto
|
||||
|
||||
# Nada exacto: se prueba por parecido. El reconocedor se come letras
|
||||
# ("yo tuve" por "yo tube") y sin esto la orden se pierde entera. El
|
||||
# umbral es alto a proposito: mas abajo empieza a confundir ordenes
|
||||
# distintas entre si, que es peor que no reaccionar.
|
||||
if mejor is None:
|
||||
self._resto_parecido = ""
|
||||
mejor, puntos = self._por_parecido(texto)
|
||||
if mejor is not None and mejor.captura:
|
||||
argumento = self._resto_parecido
|
||||
|
||||
# una accion que captura y no recibe nada no sirve: "busca" a secas
|
||||
if mejor is not None and mejor.captura and not argumento:
|
||||
return None, ""
|
||||
return mejor, argumento
|
||||
|
||||
def _por_parecido(self, texto: str, umbral: float = UMBRAL_PARECIDO):
|
||||
"""La accion cuya frase mas se parezca, si pasa del umbral."""
|
||||
palabras = texto.split()
|
||||
mejor, mejor_p = None, umbral
|
||||
for accion in self.acciones:
|
||||
if accion.captura:
|
||||
# Con argumento no vale comparar la frase entera: "busca en
|
||||
# youtube conciertos de techno" no se parece a "busca en
|
||||
# youtube". Se compara solo el arranque y lo demas es el
|
||||
# argumento. Se exigen tres palabras para que un disparador
|
||||
# corto no se coma media conversacion.
|
||||
for frase in accion.frases:
|
||||
trozos = frase.split()
|
||||
# Dos palabras, no tres. Con tres, disparadores cortos como
|
||||
# "receta de" no entraban NUNCA en esta fase, y "receta de
|
||||
# documentos" mal oido se perdia entero. El riesgo de bajar
|
||||
# a dos lo cubre que la comparacion va anclada al principio:
|
||||
# solo encaja si el arranque suena a la frase.
|
||||
if len(trozos) < 2 or len(palabras) <= len(trozos):
|
||||
continue
|
||||
arranque = " ".join(palabras[:len(trozos)])
|
||||
p = parecido(arranque, frase)
|
||||
if p > mejor_p:
|
||||
mejor, mejor_p = accion, p
|
||||
self._resto_parecido = " ".join(palabras[len(trozos):])
|
||||
continue
|
||||
|
||||
# Frase ENTERA contra frase entera, no por ventana deslizante. La
|
||||
# ventana encontraba un sub-trozo que puntuaba alto aunque la orden
|
||||
# fuera otra: "que carne el tiempo" (mal oido de "que kernel tengo")
|
||||
# colaba en la accion del tiempo con 0.90, y se ejecutaba la accion
|
||||
# equivocada —lo peor que puede pasar en algo que lanza comandos—.
|
||||
# Una orden se dice completa; si va incrustada en una frase larga,
|
||||
# el emparejador exacto (por subcadena) ya la coge antes de llegar
|
||||
# aqui. Comparar el todo con el todo deja fuera esos falsos
|
||||
# positivos sin perder los casi-aciertos reales.
|
||||
propio = " ".join(palabras)
|
||||
for frase in accion.frases:
|
||||
if not frase:
|
||||
continue
|
||||
p = parecido(propio, frase)
|
||||
if p > mejor_p:
|
||||
mejor, mejor_p = accion, p
|
||||
return mejor, mejor_p
|
||||
|
||||
def __len__(self):
|
||||
return len(self.acciones)
|
||||
|
||||
|
||||
def ejecuta(accion: Accion, prefijo_host, argumento: str = "") -> tuple:
|
||||
"""Corre el comando y devuelve (texto para decir, salida cruda).
|
||||
|
||||
prefijo_host es lo que hay que anteponer para salir del sandbox; se pasa
|
||||
como argumento en vez de importarlo para poder probar esto sin flatpak.
|
||||
"""
|
||||
if not accion.comando:
|
||||
return accion.acuse, ""
|
||||
|
||||
# El argumento viene de un microfono, o sea de fuera: va entrecomillado con
|
||||
# shlex antes de tocar un shell. Sin esto, decir "borra punto y coma rm"
|
||||
# seria una orden ejecutable.
|
||||
comando = accion.comando.replace("{argumento}", shlex.quote(argumento))
|
||||
comando = comando.replace("{argumento_url}",
|
||||
shlex.quote(urllib.parse.quote_plus(argumento)))
|
||||
|
||||
orden = ["bash", "-lc", comando]
|
||||
if accion.host:
|
||||
orden = list(prefijo_host) + orden
|
||||
|
||||
try:
|
||||
# errors="replace": un grep o un cat que toque un binario devuelve
|
||||
# bytes que no son utf-8, y sin esto la orden entera reventaria con
|
||||
# UnicodeDecodeError. Mejor un caracter raro que un fallo.
|
||||
r = subprocess.run(orden, capture_output=True, text=True,
|
||||
errors="replace", timeout=TIEMPO_LIMITE)
|
||||
salida = (r.stdout or r.stderr or "").strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
return "La orden ha tardado demasiado y la he cancelado.", ""
|
||||
except OSError as e:
|
||||
return f"No he podido ejecutarlo: {e}", ""
|
||||
|
||||
return formatea(accion.respuesta, salida, argumento), salida[:SALIDA_MAXIMA]
|
||||
|
||||
|
||||
def formatea(plantilla: str, salida: str, argumento: str = "") -> str:
|
||||
lineas = [l for l in salida.splitlines() if l.strip()]
|
||||
ultima = lineas[-1] if lineas else ""
|
||||
campos = ultima.split()
|
||||
|
||||
valores = {
|
||||
"argumento": argumento,
|
||||
"salida": salida[:SALIDA_MAXIMA] if salida else "sin resultado",
|
||||
"primera": lineas[0] if lineas else "",
|
||||
"ultima": ultima,
|
||||
"lineas": len(lineas),
|
||||
}
|
||||
for i, campo in enumerate(campos, start=1):
|
||||
valores[f"campo{i}"] = campo
|
||||
|
||||
try:
|
||||
return plantilla.format(**valores)
|
||||
except (KeyError, IndexError, ValueError):
|
||||
# plantilla que pide un campo que no existe: mejor decir la salida
|
||||
# cruda que soltar una excepcion en mitad de una conversacion
|
||||
return valores["salida"]
|
||||
123
nucleo/manos/diario.py
Normal file
123
nucleo/manos/diario.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""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
|
||||
84
nucleo/manos/fonetica.py
Normal file
84
nucleo/manos/fonetica.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Comparar por como SUENA, no por como se escribe.
|
||||
|
||||
## Que problema resuelve
|
||||
|
||||
Midiendo donde se pierde el acierto salio esto: de 150 ordenes dichas, el 79 %
|
||||
llega a su accion, un 5 % se oye mal de verdad, y un **15 % se oye bien pero el
|
||||
emparejador no lo relaciona**. Mirando esos casos uno a uno, no son ordenes
|
||||
distintas: son la misma orden mal escrita.
|
||||
|
||||
dijo "receta de documentos" oyo "Leceta de documentos"
|
||||
dijo "como van los pubs" oyo "¿Como van los poops?"
|
||||
dijo "abre bandcamp" oyo "Abre van Kamp"
|
||||
dijo "abre el drive" oyo "Abril Drive"
|
||||
dijo "abreme oasis" oyo "Abre mi roasis"
|
||||
|
||||
Eso NO se arregla con embeddings: un modelo semantico ve "Leceta" como una
|
||||
palabra inexistente, no como "receta". Y bajar el umbral de parecido tampoco,
|
||||
porque el umbral esta alto por una razon —hay una cicatriz en el codigo de
|
||||
cuando "que carne el tiempo" se colaba en la accion del tiempo y ejecutaba lo
|
||||
que no era—.
|
||||
|
||||
Lo que sirve es comparar como suena. En castellano hay unas pocas confusiones
|
||||
que lo explican casi todo, y son las que aplica `fonetica()`.
|
||||
|
||||
## Lo que NO hace
|
||||
|
||||
No es un Soundex ni un Metaphone: esos comprimen tanto que juntan palabras que
|
||||
no se parecen, y aqui juntar de mas significa ejecutar la orden equivocada, que
|
||||
es lo peor que puede hacer esto. Se queda en las equivalencias que de verdad
|
||||
confunde el reconocedor de voz en castellano, y nada mas.
|
||||
"""
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
# El orden importa: cada regla se aplica sobre el resultado de la anterior.
|
||||
REGLAS = (
|
||||
# la hache no suena
|
||||
(r"h", ""),
|
||||
# b y v son el mismo sonido: "van Kamp" / "bandcamp"
|
||||
(r"v", "b"),
|
||||
# ge/gi suenan como jota
|
||||
(r"g([ei])", r"j\1"),
|
||||
# gue/gui: la u es muda
|
||||
(r"gu([ei])", r"g\1"),
|
||||
# que/qui igual, y qu -> k
|
||||
(r"qu([ei])", r"k\1"),
|
||||
(r"q", "k"),
|
||||
# ce/ci en seseo suenan como ese; ca/co/cu como ka
|
||||
(r"c([ei])", r"s\1"),
|
||||
(r"c", "k"),
|
||||
# z siempre ese (seseo): asi "poops"/"pubs" y "haz"/"has" caen juntos
|
||||
(r"z", "s"),
|
||||
# x entre vocales suena ks, pero al principio suele ser ese
|
||||
(r"^x", "s"),
|
||||
(r"x", "ks"),
|
||||
# ll e y son el mismo sonido para casi todo el mundo
|
||||
(r"ll", "y"),
|
||||
# w es de fuera: suena como u o como b segun la palabra; se unifica a b
|
||||
(r"w", "b"),
|
||||
# la erre doble y la simple no se distinguen al transcribir
|
||||
(r"rr", "r"),
|
||||
# la ñ se escribe de mil formas cuando el reconocedor duda
|
||||
(r"ñ", "n"),
|
||||
# las dobles no aportan: "poops" -> "pops"
|
||||
(r"(.)\1+", r"\1"),
|
||||
# las vocales finales se comen mucho; y la ese final tambien
|
||||
(r"s$", ""),
|
||||
)
|
||||
|
||||
|
||||
def fonetica(texto: str) -> str:
|
||||
"""Como suena, aproximadamente, para poder comparar dos oidos distintos."""
|
||||
t = unicodedata.normalize("NFD", (texto or "").lower())
|
||||
t = "".join(c for c in t if unicodedata.category(c) != "Mn" or c == "̃")
|
||||
t = unicodedata.normalize("NFC", t)
|
||||
t = re.sub(r"[^a-zñ\s]", " ", t)
|
||||
|
||||
palabras = []
|
||||
for p in t.split():
|
||||
for patron, cambio in REGLAS:
|
||||
p = re.sub(patron, cambio, p)
|
||||
if p:
|
||||
palabras.append(p)
|
||||
return " ".join(palabras)
|
||||
115
nucleo/manos/medidas.py
Normal file
115
nucleo/manos/medidas.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Las medidas de eficiencia de JARVIS, sacadas del diario.
|
||||
|
||||
El diario ya apunta por cada frase: cuanto tardo (`seg`), por donde fue (`via`)
|
||||
y cuando (`t`). De ahi salen las metricas de rendimiento sin instrumentar nada
|
||||
del camino caliente: esto solo LEE, y se calcula fuera del hilo que contesta.
|
||||
|
||||
## Que se mide y por que importa cada una
|
||||
|
||||
latencia p50/p95/max del tiempo de respuesta. La mediana dice como va de
|
||||
normal; el p95 dice como va cuando va mal, que es lo que se nota.
|
||||
reparto que fraccion va al CEREBRO (lento, ~2-3 s) y cual al CATALOGO
|
||||
(instantaneo, salta el modelo). Es la palanca de eficiencia mas
|
||||
grande: cuanto mas catalogo, mas rapido el conjunto.
|
||||
ruido que fraccion de lo que oye es Whisper oyendose a si mismo. No es
|
||||
velocidad, es calidad de la escucha, y envenena todo lo demas.
|
||||
volumen frases en las ultimas 24 h. Contexto para lo anterior: un p95 malo
|
||||
con 4 frases no dice nada.
|
||||
rag hit@5/hit@1 del ultimo benchmark del RAG (informe_rag.txt). Es la
|
||||
unica que no sale del diario: es una medida de test, periodica.
|
||||
|
||||
Todo sobre una VENTANA reciente (las ultimas MUESTRA frases), no sobre el diario
|
||||
entero: interesa como va AHORA, no la media historica que nunca cambia.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from manos.diario import lee
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
INFORME_RAG = os.path.join(AQUI, "datos", "informe_rag.txt")
|
||||
|
||||
MUESTRA = 600 # las ultimas N frases: "como va ahora"
|
||||
CEREBRO = "cerebro"
|
||||
|
||||
|
||||
def _percentil(valores, q):
|
||||
if not valores:
|
||||
return 0.0
|
||||
v = sorted(valores)
|
||||
k = (len(v) - 1) * q
|
||||
lo, hi = int(k), min(int(k) + 1, len(v) - 1)
|
||||
return v[lo] + (v[hi] - v[lo]) * (k - lo)
|
||||
|
||||
|
||||
def _rag():
|
||||
"""hit@5 y hit@1 del ultimo informe del RAG, si existe."""
|
||||
try:
|
||||
txt = open(INFORME_RAG, encoding="utf-8").read()
|
||||
except OSError:
|
||||
return None
|
||||
# el bloque CANDIDATO es el que se promociono; si no, el ultimo que haya
|
||||
def pct(clave):
|
||||
# " hit@5 43/45 (95%)" -> 95
|
||||
ms = re.findall(clave + r"\s+\d+/\d+\s+\((\d+)%\)", txt)
|
||||
return int(ms[-1]) if ms else None
|
||||
h5, h1 = pct("hit@5"), pct("hit@1")
|
||||
if h5 is None:
|
||||
return None
|
||||
return {"hit5": h5, "hit1": h1 or 0}
|
||||
|
||||
|
||||
def lee_medidas():
|
||||
"""El dict de medidas, con las unidades ya listas para el HUD."""
|
||||
diario = lee()[-MUESTRA:]
|
||||
if not diario:
|
||||
return {"n": 0}
|
||||
|
||||
segs = [d["seg"] for d in diario if isinstance(d.get("seg"), (int, float)) and d["seg"] > 0]
|
||||
seg_cerebro = [d["seg"] for d in diario
|
||||
if d.get("via") == CEREBRO and isinstance(d.get("seg"), (int, float))]
|
||||
seg_catalogo = [d["seg"] for d in diario
|
||||
if d.get("via") == "catalogo" and isinstance(d.get("seg"), (int, float))]
|
||||
|
||||
vias = {}
|
||||
for d in diario:
|
||||
vias[d.get("via", "?")] = vias.get(d.get("via", "?"), 0) + 1
|
||||
total = len(diario)
|
||||
|
||||
def pct_via(v):
|
||||
return round(100 * vias.get(v, 0) / total) if total else 0
|
||||
|
||||
# volumen: cuantas en las ultimas 24 h, y por hora las ultimas 12 para la traza
|
||||
ahora = datetime.now()
|
||||
hace24 = ahora - timedelta(hours=24)
|
||||
vol24 = 0
|
||||
porhora = [0] * 12
|
||||
for d in diario:
|
||||
try:
|
||||
t = datetime.fromisoformat(d.get("t", ""))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if t >= hace24:
|
||||
vol24 += 1
|
||||
h = int((ahora - t).total_seconds() // 3600)
|
||||
if 0 <= h < 12:
|
||||
porhora[11 - h] += 1
|
||||
|
||||
medidas = {
|
||||
"n": total,
|
||||
"lat_p50": round(_percentil(segs, 0.50), 2),
|
||||
"lat_p95": round(_percentil(segs, 0.95), 2),
|
||||
"lat_max": round(max(segs), 2) if segs else 0.0,
|
||||
"lat_cerebro": round(sum(seg_cerebro) / len(seg_cerebro), 2) if seg_cerebro else 0.0,
|
||||
"lat_catalogo": round(sum(seg_catalogo) / len(seg_catalogo), 2) if seg_catalogo else 0.0,
|
||||
"pct_cerebro": pct_via("cerebro"),
|
||||
"pct_catalogo": pct_via("catalogo"),
|
||||
"pct_ruido": pct_via("ruido"),
|
||||
"vol24": vol24,
|
||||
"porhora": porhora,
|
||||
}
|
||||
rag = _rag()
|
||||
if rag:
|
||||
medidas["rag"] = rag
|
||||
return medidas
|
||||
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
|
||||
95
nucleo/manos/seleccion.py
Normal file
95
nucleo/manos/seleccion.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Elegir de una lista por voz, y buscar dentro de un fichero (Ctrl+F).
|
||||
|
||||
La capa que le faltaba a las acciones de fichero. Buscar y soltar el primer
|
||||
resultado no sirve cuando hay varios: hace falta enseñar la lista y poder decir
|
||||
"abre el tercero". Y para buscar DENTRO de un fichero como con Ctrl+F, hace
|
||||
falta recordar cual era.
|
||||
|
||||
Aqui va la logica pura —interpretar "el tercero", detectar las ordenes de
|
||||
seleccion y de busqueda interna—; el estado (la ultima lista, el ultimo
|
||||
fichero) y la ejecucion viven en el panel.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Como se dice cada posicion. Se aceptan el ordinal, el numero en palabra y la
|
||||
# cifra, porque el reconocedor devuelve cualquiera de los tres.
|
||||
ORDINALES = {
|
||||
1: ["primero", "primera", "uno", "1"],
|
||||
2: ["segundo", "segunda", "dos", "2"],
|
||||
3: ["tercero", "tercera", "tres", "3"],
|
||||
4: ["cuarto", "cuarta", "cuatro", "4"],
|
||||
5: ["quinto", "quinta", "cinco", "5"],
|
||||
6: ["sexto", "sexta", "seis", "6"],
|
||||
7: ["septimo", "septima", "siete", "7"],
|
||||
8: ["octavo", "octava", "ocho", "8"],
|
||||
9: ["noveno", "novena", "nueve", "9"],
|
||||
10: ["decimo", "decima", "diez", "10"],
|
||||
}
|
||||
_PALABRA_A_N = {p: n for n, ps in ORDINALES.items() for p in ps}
|
||||
|
||||
# "el ultimo" es comodo y no es un numero
|
||||
_ULTIMO = {"ultimo", "ultima", "final"}
|
||||
|
||||
|
||||
def posicion(texto: str):
|
||||
"""Devuelve el numero (1..N) que se ha dicho, -1 para 'el ultimo', o None.
|
||||
|
||||
Se queda con la PRIMERA posicion mencionada, para que "abre el tercero"
|
||||
de 3 y no se lie con otros numeros que puedan venir detras.
|
||||
"""
|
||||
for palabra in texto.split():
|
||||
if palabra in _ULTIMO:
|
||||
return -1
|
||||
if palabra in _PALABRA_A_N:
|
||||
return _PALABRA_A_N[palabra]
|
||||
return None
|
||||
|
||||
|
||||
# Ordenes que actuan sobre la lista ya mostrada. El verbo dice que hacer con lo
|
||||
# elegido; la posicion, cual.
|
||||
_ABRIR = re.compile(r"\b(abre|abreme|abrir)\b")
|
||||
_LEER = re.compile(r"\b(lee|leeme|leer|muestrame|ensename)\b")
|
||||
_SELECCION = re.compile(r"\b(el|la|numero|opcion|resultado)\b")
|
||||
|
||||
|
||||
def orden_de_seleccion(texto: str):
|
||||
"""(accion, posicion) si el texto elige de la lista, o None.
|
||||
|
||||
accion: "abrir" o "leer". posicion: 1..N o -1 (ultimo).
|
||||
Ejemplos: "abre el tercero", "lee el numero dos", "el ultimo", "abre el 3".
|
||||
"""
|
||||
pos = posicion(texto)
|
||||
if pos is None:
|
||||
return None
|
||||
# tiene que sonar a seleccion, no a otra cosa que lleve un numero
|
||||
if not (_SELECCION.search(texto) or _ABRIR.search(texto) or _LEER.search(texto)):
|
||||
return None
|
||||
if _LEER.search(texto):
|
||||
return ("leer", pos)
|
||||
# por defecto, elegir es abrir
|
||||
return ("abrir", pos)
|
||||
|
||||
|
||||
# Ctrl+F: buscar un termino dentro del ultimo fichero abierto.
|
||||
_DENTRO = re.compile(
|
||||
r"\b(busca|buscame|encuentra)\b.*\b(dentro|en el fichero|en el archivo|en el texto)\b")
|
||||
|
||||
|
||||
def orden_buscar_dentro(texto: str):
|
||||
"""El termino a buscar dentro del fichero, o None.
|
||||
|
||||
"busca dentro configuracion", "busca en el texto la palabra error".
|
||||
Se queda con lo que va despues de 'dentro'/'en el ...'.
|
||||
"""
|
||||
if not _DENTRO.search(texto):
|
||||
return None
|
||||
# cortar por el separador y quedarse con el resto
|
||||
for sep in ("dentro de", "dentro", "en el fichero", "en el archivo", "en el texto",
|
||||
"la palabra"):
|
||||
if sep in texto:
|
||||
resto = texto.split(sep, 1)[1].strip()
|
||||
resto = re.sub(r"^(la palabra|el termino|de|la|el|los|las)\s+",
|
||||
"", resto).strip()
|
||||
if resto:
|
||||
return resto
|
||||
return None
|
||||
134
nucleo/manos/sudo_root.py
Normal file
134
nucleo/manos/sudo_root.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""Ejecutar ordenes como root de forma controlada.
|
||||
|
||||
El problema que resuelve. Newelle convierte `sudo` en `sudo -S` (leer la
|
||||
contraseña de la entrada estandar), pero en el panel de voz no hay una entrada
|
||||
donde teclearla, asi que se queda colgado esperando una contraseña que no
|
||||
llega. Y las acciones marcadas para confirmar no tenian dialogo: solo decian
|
||||
"confirme antes" y no ejecutaban nada.
|
||||
|
||||
Como se resuelve, y las decisiones de seguridad detras:
|
||||
|
||||
- La contraseña NUNCA se escribe en disco. Vive en memoria de este proceso solo
|
||||
si el usuario activa el permiso de sesion, y se pasa a `sudo -S` por stdin.
|
||||
- El permiso de sesion CADUCA: a los 15 minutos de la ultima orden se olvida
|
||||
sola, y tambien al cerrar el panel. No es un "root para siempre".
|
||||
- CADA orden con root muestra su comando exacto en un dialogo antes de correr.
|
||||
El permiso de sesion solo ahorra re-teclear la contraseña, nunca salta el ver
|
||||
y aprobar lo que se va a ejecutar. La voz no puede autoaprobar root.
|
||||
|
||||
Alternativa descartada: apoyarse solo en el timestamp de sudo (`sudo -v`). Es
|
||||
mas limpio en teoria —lo gestiona el sistema— pero a traves de flatpak-spawn el
|
||||
tty cambia entre llamadas y el cacheo no es fiable. Mantener la contraseña en
|
||||
memoria del proceso, con caducidad y sin tocar disco, funciona siempre y el
|
||||
usuario opta a ello de forma explicita.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
# A los 15 minutos sin usarla, la contraseña en memoria se olvida. Igual que el
|
||||
# valor por defecto de sudo, para no sorprender.
|
||||
CADUCIDAD = 15 * 60
|
||||
|
||||
# Detecta una invocacion real de sudo: al principio del comando o justo tras un
|
||||
# separador (;, |, &, &&, ||, salto de linea), no la palabra "sudo" dicha como
|
||||
# argumento ("echo sudo no cuenta"). Errar aqui solo pediria una contraseña de
|
||||
# mas, no es peligroso, pero molesta.
|
||||
_SUDO = re.compile(r"(?:^|[;&|\n]|&&|\|\|)\s*sudo(?:\s|$)")
|
||||
|
||||
|
||||
def necesita_root(comando: str) -> bool:
|
||||
"""True si el comando lanza algo con sudo."""
|
||||
return bool(comando and _SUDO.search(comando))
|
||||
|
||||
|
||||
class GestorRoot:
|
||||
"""Guarda —o no— la contraseña de sesion, y ejecuta con ella.
|
||||
|
||||
Una instancia por panel. Al cerrar el panel se llama a olvida().
|
||||
"""
|
||||
|
||||
def __init__(self, prefijo_host=None):
|
||||
# prefijo para salir del sandbox; se pasa como argumento para poder
|
||||
# probar esto sin flatpak
|
||||
self._prefijo = list(prefijo_host or [])
|
||||
self._password = None
|
||||
self._ultimo_uso = 0.0
|
||||
|
||||
# -------------------------------------------------------------- sesion
|
||||
|
||||
def sesion_activa(self) -> bool:
|
||||
"""Hay contraseña recordada y aun no ha caducado."""
|
||||
if self._password is None:
|
||||
return False
|
||||
if time.monotonic() - self._ultimo_uso > CADUCIDAD:
|
||||
self.olvida()
|
||||
return False
|
||||
return True
|
||||
|
||||
def olvida(self):
|
||||
"""Borra la contraseña de memoria. Se llama al cerrar el panel y al
|
||||
caducar. Se sobreescribe antes de soltarla, por si acaso."""
|
||||
if self._password is not None:
|
||||
self._password = "\x00" * len(self._password)
|
||||
self._password = None
|
||||
self._ultimo_uso = 0.0
|
||||
|
||||
def recuerda(self, password: str):
|
||||
self._password = password
|
||||
self._ultimo_uso = time.monotonic()
|
||||
|
||||
# ------------------------------------------------------------ ejecutar
|
||||
|
||||
def valida(self, password: str) -> bool:
|
||||
"""Comprueba la contraseña con `sudo -S -v`, sin ejecutar nada mas.
|
||||
|
||||
-k borra cualquier timestamp previo para que la comprobacion sea de
|
||||
verdad; -v solo actualiza credenciales. Si la contraseña es correcta
|
||||
devuelve True; nunca lanza ningun comando del usuario.
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
self._prefijo + ["sudo", "-S", "-k", "-p", "", "-v"],
|
||||
input=password + "\n", capture_output=True, text=True,
|
||||
timeout=10)
|
||||
return r.returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
def ejecuta(self, comando: str, password: str = None, timeout: int = 30):
|
||||
"""Ejecuta el comando dando la contraseña a los sudo que lleve.
|
||||
|
||||
password: si se pasa, se usa esa vez; si no, la de la sesion. La
|
||||
contraseña se pasa por stdin a `sudo -S`. No se registra en ningun
|
||||
sitio.
|
||||
|
||||
Devuelve (salida, codigo). codigo -1 = no habia contraseña disponible.
|
||||
"""
|
||||
clave = password if password is not None else self._password
|
||||
if clave is None:
|
||||
return "No hay contraseña disponible para root.", -1
|
||||
|
||||
# que todos los sudo del comando lean de stdin sin imprimir el prompt
|
||||
preparado = re.sub(r"(^|[;&|]|\s)sudo(\s)",
|
||||
r"\1sudo -S -p '' \2", comando)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
self._prefijo + ["bash", "-lc", preparado],
|
||||
input=clave + "\n", capture_output=True, text=True,
|
||||
timeout=timeout)
|
||||
salida = (r.stdout or r.stderr or "").strip()
|
||||
# sudo escribe su prompt/errores en stderr aunque -p sea vacio;
|
||||
# se limpia lo que es ruido de sudo, no del comando
|
||||
salida = "\n".join(
|
||||
l for l in salida.splitlines()
|
||||
if l.strip() and not l.startswith(("[sudo]", "Sorry, try again")))
|
||||
self._ultimo_uso = time.monotonic()
|
||||
return salida, r.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
return "La orden con root ha tardado demasiado.", 124
|
||||
except OSError as e:
|
||||
return f"No se pudo ejecutar: {e}", 1
|
||||
finally:
|
||||
# no dejar la copia local viva mas de lo necesario
|
||||
clave = "\x00" * len(clave)
|
||||
346
nucleo/manos/tareas.py
Normal file
346
nucleo/manos/tareas.py
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
"""Tareas en paralelo: cada orden corre por su cuenta y se puede parar.
|
||||
|
||||
Hasta ahora una orden era una llamada a `ejecuta()`: un `subprocess.run` con
|
||||
veinte segundos de limite que bloqueaba el hilo hasta terminar. Servia porque
|
||||
todos los atajos eran instantaneos, pero deja fuera justo lo que uno querria
|
||||
mandar y olvidarse: un escaneo, una busqueda por todo el disco, una descarga.
|
||||
Y no habia forma de arrepentirse a mitad.
|
||||
|
||||
Aqui cada orden es una Tarea con nombre, estado y un proceso propio del que se
|
||||
guarda el grupo, para poder matarlo entero. Tres cosas que costaron decidir:
|
||||
|
||||
- **Se mata el grupo, no el proceso.** El comando real es `bash -lc "..."`, asi
|
||||
que matar el bash deja vivos a sus hijos —el `find`, el `nmap`— corriendo y
|
||||
comiendo disco con la tarjeta ya cerrada. Con `start_new_session=True` el
|
||||
proceso estrena grupo y `killpg` se los lleva a todos.
|
||||
|
||||
- **Fuera del sandbox tambien vale.** Comprobado: matar el `flatpak-spawn`
|
||||
intermediario propaga la señal al proceso del host (2 procesos vivos -> 0).
|
||||
No hace falta perseguir el PID del otro lado.
|
||||
|
||||
- **La salida se lee segun sale, no al final.** Con `run()` no hay nada que
|
||||
enseñar hasta que termina, y una tarjeta de un escaneo de tres minutos que
|
||||
esta en blanco los tres minutos no informa de nada. Se lee linea a linea y se
|
||||
guardan las ultimas, que es lo que cabe en la tarjeta.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
# Lo que se guarda de la salida para enseñar en la tarjeta y para la plantilla
|
||||
# de respuesta. Una tarjeta pequeña no da para mas, y guardar la salida entera
|
||||
# de un `find /` es meterse megas en memoria por nada.
|
||||
LINEAS_VISTA = 6
|
||||
SALIDA_MAXIMA = 600
|
||||
|
||||
# Con boton de parar, el limite deja de ser una proteccion y pasa a ser un
|
||||
# estorbo: veinte segundos matan cualquier escaneo util. Se deja un tope alto
|
||||
# como red para lo que se cuelgue de verdad y no vuelva nunca.
|
||||
LIMITE_LARGO = 900 # 15 minutos
|
||||
|
||||
# Cada cuanto se avisa al HUD mientras sale texto. Sin freno, un comando
|
||||
# hablador manda cientos de mensajes por segundo al WebView y la interfaz se
|
||||
# atasca dibujando texto que nadie llega a leer.
|
||||
CADENCIA_AVISO = 0.25
|
||||
|
||||
CORRIENDO, HECHA, PARADA, FALLO, CADUCADA = (
|
||||
"corriendo", "hecha", "parada", "fallo", "caducada")
|
||||
|
||||
|
||||
class Tarea:
|
||||
"""Una orden en marcha. Solo datos; quien la mueve es el gestor."""
|
||||
|
||||
def __init__(self, ident, titulo, accion_id, comando):
|
||||
self.id = ident
|
||||
self.titulo = titulo
|
||||
self.accion_id = accion_id
|
||||
self.comando = comando
|
||||
# La accion de origen viaja con la tarea (y no en un diccionario
|
||||
# aparte del panel) porque una tarea sin comando termina DENTRO de
|
||||
# lanza(), antes de que quien la lanzo tenga el id para apuntarla: el
|
||||
# aviso de "terminada" llegaria a un registro todavia vacio.
|
||||
self.accion = None
|
||||
self.argumento = ""
|
||||
self.estado = CORRIENDO
|
||||
self.inicio = time.monotonic()
|
||||
self.fin = None
|
||||
self.lineas = [] # las ultimas LINEAS_VISTA de salida
|
||||
self.salida = "" # la salida acumulada, recortada
|
||||
self.codigo = None
|
||||
self._proc = None
|
||||
|
||||
@property
|
||||
def segundos(self) -> float:
|
||||
return (self.fin or time.monotonic()) - self.inicio
|
||||
|
||||
@property
|
||||
def viva(self) -> bool:
|
||||
return self.estado == CORRIENDO
|
||||
|
||||
def resumen(self) -> dict:
|
||||
"""Lo que viaja al HUD. Sin objetos: esto acaba en un json.dumps."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"titulo": self.titulo,
|
||||
"accion": self.accion_id,
|
||||
"estado": self.estado,
|
||||
"segundos": round(self.segundos, 1),
|
||||
"lineas": self.lineas[-LINEAS_VISTA:],
|
||||
"codigo": self.codigo,
|
||||
}
|
||||
|
||||
|
||||
class GestorTareas:
|
||||
"""Lanza, vigila y para tareas. Un unico sitio donde vive el registro.
|
||||
|
||||
al_cambiar(tarea) se llama desde el hilo de la tarea cada vez que hay algo
|
||||
nuevo que enseñar. Quien lo reciba se encarga de saltar al hilo de la
|
||||
interfaz: aqui no se sabe nada de GTK a proposito, para poder probarlo sin
|
||||
levantar la aplicacion.
|
||||
"""
|
||||
|
||||
def __init__(self, al_cambiar=None, prefijo_host=()):
|
||||
self._al_cambiar = al_cambiar or (lambda tarea: None)
|
||||
self._prefijo_host = _con_watch_bus(prefijo_host)
|
||||
self._tareas = {}
|
||||
self._siguiente = 0
|
||||
self._cerrojo = threading.Lock()
|
||||
|
||||
# -------------------------------------------------------------- registro
|
||||
|
||||
def _nueva(self, titulo, accion_id, comando) -> Tarea:
|
||||
with self._cerrojo:
|
||||
self._siguiente += 1
|
||||
ident = f"t{self._siguiente}"
|
||||
tarea = Tarea(ident, titulo, accion_id, comando)
|
||||
self._tareas[ident] = tarea
|
||||
return tarea
|
||||
|
||||
def get(self, ident):
|
||||
with self._cerrojo:
|
||||
return self._tareas.get(ident)
|
||||
|
||||
def activas(self) -> list:
|
||||
with self._cerrojo:
|
||||
return [t for t in self._tareas.values() if t.viva]
|
||||
|
||||
def todas(self) -> list:
|
||||
with self._cerrojo:
|
||||
return list(self._tareas.values())
|
||||
|
||||
def _avisa(self, tarea):
|
||||
try:
|
||||
self._al_cambiar(tarea)
|
||||
except Exception as e: # el HUD no tumba la tarea
|
||||
print(f"JARVIS: aviso de tarea fallido: {e}")
|
||||
|
||||
# ---------------------------------------------------------------- lanzar
|
||||
|
||||
def lanza(self, accion, argumento="", titulo=None) -> Tarea:
|
||||
"""Arranca la accion en su propio proceso y devuelve la tarea ya viva."""
|
||||
comando = accion.comando or ""
|
||||
if comando:
|
||||
comando = comando.replace("{argumento}", shlex.quote(argumento))
|
||||
comando = comando.replace(
|
||||
"{argumento_url}",
|
||||
shlex.quote(urllib.parse.quote_plus(argumento)))
|
||||
|
||||
tarea = self._nueva(titulo or _titulo_de(accion, argumento),
|
||||
accion.id, comando)
|
||||
tarea.accion = accion
|
||||
tarea.argumento = argumento
|
||||
self._avisa(tarea)
|
||||
|
||||
if not comando:
|
||||
# Acciones que solo hablan (no tienen comando): nacen terminadas.
|
||||
self.termina(tarea.id, HECHA, "")
|
||||
return tarea
|
||||
|
||||
orden = ["bash", "-lc", comando]
|
||||
if accion.host:
|
||||
orden = self._prefijo_host + orden
|
||||
|
||||
threading.Thread(target=self._corre, args=(tarea, orden),
|
||||
daemon=True).start()
|
||||
return tarea
|
||||
|
||||
def adopta(self, titulo, accion_id="") -> Tarea:
|
||||
"""Una tarjeta para trabajo que se ejecuta fuera del gestor.
|
||||
|
||||
Lo que pasa por el dialogo de root corre por su camino (GestorRoot, con
|
||||
su contraseña), pero se merece tarjeta igual: si no, pedir algo con
|
||||
sudo hace desaparecer la señal de que hay algo en marcha.
|
||||
"""
|
||||
tarea = self._nueva(titulo, accion_id, "")
|
||||
self._avisa(tarea)
|
||||
return tarea
|
||||
|
||||
def _corre(self, tarea, orden):
|
||||
try:
|
||||
# start_new_session: el proceso estrena grupo, para poder matar el
|
||||
# arbol entero y no solo el bash de arriba.
|
||||
proc = subprocess.Popen(
|
||||
orden, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, errors="replace", start_new_session=True)
|
||||
except OSError as e:
|
||||
self.termina(tarea.id, FALLO, f"No he podido ejecutarlo: {e}")
|
||||
return
|
||||
|
||||
tarea._proc = proc
|
||||
limite = threading.Timer(LIMITE_LARGO, self._caduca, args=(tarea.id,))
|
||||
limite.daemon = True
|
||||
limite.start()
|
||||
|
||||
ultimo_aviso = 0.0
|
||||
acumulado = []
|
||||
try:
|
||||
for linea in proc.stdout:
|
||||
linea = linea.rstrip("\n")
|
||||
if not linea.strip():
|
||||
continue
|
||||
acumulado.append(linea)
|
||||
tarea.lineas = acumulado[-LINEAS_VISTA:]
|
||||
ahora = time.monotonic()
|
||||
if ahora - ultimo_aviso >= CADENCIA_AVISO:
|
||||
ultimo_aviso = ahora
|
||||
self._avisa(tarea)
|
||||
except Exception as e:
|
||||
print(f"JARVIS: leyendo la salida de {tarea.id}: {e}")
|
||||
finally:
|
||||
limite.cancel()
|
||||
|
||||
codigo = proc.wait()
|
||||
salida = "\n".join(acumulado).strip()
|
||||
|
||||
if tarea.estado == PARADA: # la paro alguien: no se toca
|
||||
self._avisa(tarea)
|
||||
return
|
||||
if tarea.estado == CADUCADA:
|
||||
self._avisa(tarea)
|
||||
return
|
||||
self.termina(tarea.id, HECHA if codigo == 0 else FALLO, salida, codigo)
|
||||
|
||||
# ------------------------------------------------------------- terminar
|
||||
|
||||
def termina(self, ident, estado, salida="", codigo=None):
|
||||
tarea = self.get(ident)
|
||||
if tarea is None:
|
||||
return
|
||||
tarea.estado = estado
|
||||
tarea.fin = time.monotonic()
|
||||
tarea.codigo = codigo
|
||||
if salida:
|
||||
tarea.salida = salida[:SALIDA_MAXIMA]
|
||||
if not tarea.lineas:
|
||||
tarea.lineas = salida.splitlines()[-LINEAS_VISTA:]
|
||||
else:
|
||||
tarea.salida = "\n".join(tarea.lineas)[:SALIDA_MAXIMA]
|
||||
self._avisa(tarea)
|
||||
|
||||
def para(self, ident) -> bool:
|
||||
"""Corta la tarea. Primero por las buenas, luego a la fuerza."""
|
||||
tarea = self.get(ident)
|
||||
if tarea is None or not tarea.viva:
|
||||
return False
|
||||
|
||||
tarea.estado = PARADA
|
||||
proc = tarea._proc
|
||||
if proc is not None and proc.poll() is None:
|
||||
_mata_grupo(proc)
|
||||
tarea.fin = time.monotonic()
|
||||
self._avisa(tarea)
|
||||
return True
|
||||
|
||||
def _caduca(self, ident):
|
||||
tarea = self.get(ident)
|
||||
if tarea is None or not tarea.viva:
|
||||
return
|
||||
tarea.estado = CADUCADA
|
||||
if tarea._proc is not None and tarea._proc.poll() is None:
|
||||
_mata_grupo(tarea._proc)
|
||||
tarea.fin = time.monotonic()
|
||||
self._avisa(tarea)
|
||||
|
||||
def cierra(self, ident) -> bool:
|
||||
"""Quita la tarjeta. Si seguia viva, la para antes: cerrar es cerrar."""
|
||||
tarea = self.get(ident)
|
||||
if tarea is None:
|
||||
return False
|
||||
if tarea.viva:
|
||||
self.para(ident)
|
||||
with self._cerrojo:
|
||||
self._tareas.pop(ident, None)
|
||||
return True
|
||||
|
||||
def para_todas(self):
|
||||
for tarea in self.activas():
|
||||
self.para(tarea.id)
|
||||
|
||||
|
||||
def _con_watch_bus(prefijo) -> list:
|
||||
"""Que lo lanzado fuera del sandbox no sobreviva a la aplicacion.
|
||||
|
||||
Una tarea larga corre en el host por flatpak-spawn, y ahi el proceso ya no
|
||||
es hijo de nadie que se entere de que la app se ha cerrado: un escaneo
|
||||
lanzado y olvidado seguiria comiendo maquina despues de cerrar la ventana,
|
||||
sin ventana desde la que pararlo. `--watch-bus` ata su vida a la conexion
|
||||
de bus de quien lo lanzo, que es justo lo que se quiere.
|
||||
"""
|
||||
prefijo = list(prefijo)
|
||||
if not prefijo:
|
||||
return prefijo
|
||||
if any("flatpak-spawn" in p for p in prefijo) and "--watch-bus" not in prefijo:
|
||||
# detras del propio flatpak-spawn, antes de sus argumentos
|
||||
for i, p in enumerate(prefijo):
|
||||
if "flatpak-spawn" in p:
|
||||
prefijo.insert(i + 1, "--watch-bus")
|
||||
break
|
||||
return prefijo
|
||||
|
||||
|
||||
def _mata_grupo(proc):
|
||||
"""SIGTERM al grupo, y SIGKILL a los tres segundos si sigue ahi.
|
||||
|
||||
Se da margen a proposito: un `wget` a medias cierra el fichero y un `nmap`
|
||||
escupe lo que llevaba. Matar a lo bruto de entrada deja restos.
|
||||
"""
|
||||
try:
|
||||
grupo = os.getpgid(proc.pid)
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
os.killpg(grupo, signal.SIGTERM)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
def remata():
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
os.killpg(grupo, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
t = threading.Timer(3.0, remata)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
|
||||
def _titulo_de(accion, argumento) -> str:
|
||||
"""El rotulo de la tarjeta: corto, y con el argumento si lo hay.
|
||||
|
||||
El id de la accion con guiones bajos ("buscar_fichero") es de programador;
|
||||
en la tarjeta se lee "buscar fichero", y con lo que se pidio detras, que es
|
||||
lo que distingue dos busquedas a la vez.
|
||||
"""
|
||||
base = (accion.id or "accion").replace("_", " ")
|
||||
if argumento:
|
||||
arg = argumento.strip()
|
||||
if len(arg) > 22:
|
||||
arg = arg[:21] + "…"
|
||||
return f"{base}: {arg}"
|
||||
return base
|
||||
Loading…
Add table
Add a link
Reference in a new issue