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
484
config/mejora_nocturna.py
Executable file
484
config/mejora_nocturna.py
Executable file
|
|
@ -0,0 +1,484 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Bucle de mejora que corre solo, ronda tras ronda, y se protege de empeorar.
|
||||
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py # hasta la mañana
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py --horas 2 # un rato
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py --ronda # una sola
|
||||
|
||||
Que hace en cada ronda:
|
||||
1. Dice en voz alta (Piper) las frases del catalogo, en varias voces,
|
||||
velocidades y con ruido y sala aleatorios, distintos cada ronda.
|
||||
2. Las transcribe con el mismo Whisper del asistente.
|
||||
3. Mira que porcentaje llega a su accion. Ese es el marcador de la ronda.
|
||||
4. De lo que NO llega, se queda con los fallos SISTEMATICOS (repetidos) y
|
||||
SIN AMBIGUEDAD (que no puedan ser dos acciones), y los propone como alias.
|
||||
5. GUARDA: antes de aplicar nada, comprueba en una copia que
|
||||
a) el acierto sube, y
|
||||
b) la conversacion normal SIGUE yendo al modelo (no se cuela ninguna).
|
||||
Solo si las dos cosas se cumplen, aplica los alias de verdad.
|
||||
6. Copia de seguridad del catalogo antes de tocarlo, y todo al registro.
|
||||
|
||||
Por que es seguro dejarlo sin mirar: nunca aplica algo que baje el acierto ni
|
||||
que haga que una frase de charla acabe ejecutando un comando. Si una ronda
|
||||
empeorara, revierte. Y cada cambio queda respaldado, asi que siempre se puede
|
||||
volver atras a mano.
|
||||
|
||||
Que NO hace: reentrenar el modelo (no cabe en 4 GB de VRAM) ni tocar Whisper o
|
||||
Piper. Mejora la capa de emparejamiento —alias y umbrales—, que es donde estan
|
||||
casi todos los fallos y donde una mejora es inmediata, legible y reversible.
|
||||
|
||||
En marcha se puede seguir con:
|
||||
tail -f ~/COFRE/CODERS/JARVIS/muestras/mejora/registro.log
|
||||
cat ~/COFRE/CODERS/JARVIS/muestras/mejora/marcador.json
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
CASA = os.path.expanduser("~")
|
||||
JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS")
|
||||
CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config")
|
||||
APP = "io.github.qwersyk.Newelle//master"
|
||||
|
||||
PIPER = os.path.join(JARVIS, "voz/piper/piper")
|
||||
MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos")
|
||||
WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli")
|
||||
MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models")
|
||||
|
||||
CATALOGO = os.path.expanduser(
|
||||
"~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/"
|
||||
"active/files/share/newelle/acciones/acciones.json")
|
||||
CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json")
|
||||
PROPIO = os.path.join(CONFIG, "jarvis-acciones.json")
|
||||
MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py")
|
||||
|
||||
SALA = os.path.join(JARVIS, "muestras/mejora")
|
||||
REGISTRO = os.path.join(SALA, "registro.log")
|
||||
MARCADOR = os.path.join(SALA, "marcador.json")
|
||||
COPIAS = os.path.join(SALA, "copias")
|
||||
WAVS = os.path.join(CONFIG, "mejora_wavs")
|
||||
|
||||
VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
|
||||
# Cada ronda escoge una rejilla distinta de velocidades y ruidos, para que no
|
||||
# se estanque midiendo siempre lo mismo y siga sacando fallos raros.
|
||||
VELOCIDADES = [0.85, 0.92, 1.0, 1.08, 1.18]
|
||||
RUIDOS = [
|
||||
"",
|
||||
"aecho=0.8:0.7:22:0.12",
|
||||
"volume=0.72,highpass=f=120",
|
||||
"aecho=0.6:0.5:35:0.2,volume=0.8",
|
||||
"highpass=f=150,lowpass=f=6500", # microfono cutre
|
||||
"volume=0.65", # lejos
|
||||
]
|
||||
RELLENOS = ["notas", "el informe", "documentos", "la reunion de ayer", "gatos"]
|
||||
|
||||
# Frases de charla que NUNCA deben acabar en una accion. Es el guardarrail: si
|
||||
# despues de aplicar alias alguna se cuela, la ronda se revierte.
|
||||
CHARLA = [
|
||||
"cuentame un chiste sobre gatos", "que opinas del proyecto",
|
||||
"hola que tal estas", "gracias por todo", "no se que hacer con esto",
|
||||
"me parece que esto no funciona bien", "buenas tardes que tal",
|
||||
"cuentame algo interesante", "estoy cansado de programar",
|
||||
"que raro todo esto la verdad", "a ver si esto va mejor ahora",
|
||||
"pues no tengo ni idea de que decir", "vale perfecto muchas gracias",
|
||||
"oye una cosa que te queria preguntar",
|
||||
]
|
||||
|
||||
TOPE_ALIAS_POR_ACCION = 25 # que una accion no acumule cientos de alias
|
||||
UMBRAL_SISTEMATICO = 2 # veces que hay que fallar igual para creerselo
|
||||
DRY_PARA_PARAR = 6 # rondas seguidas sin nada nuevo -> converge
|
||||
|
||||
|
||||
def log(msg):
|
||||
linea = f"[{time.strftime('%H:%M:%S')}] {msg}"
|
||||
print(linea, flush=True)
|
||||
try:
|
||||
with open(REGISTRO, "a") as f:
|
||||
f.write(linea + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def disco_libre_gb() -> float:
|
||||
"""GB libres en la particion del home. Guardarrail: aunque el bucle apenas
|
||||
ocupa 3 MB por ronda y los limpia, si algo mas llenara el disco durante la
|
||||
noche conviene parar en vez de dejar el sistema sin espacio."""
|
||||
try:
|
||||
s = os.statvfs(CASA)
|
||||
return s.f_bavail * s.f_frsize / 1e9
|
||||
except OSError:
|
||||
return 999.0
|
||||
|
||||
|
||||
def carga_motor():
|
||||
# se recarga cada ronda: el catalogo propio cambia y el motor debe verlo
|
||||
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def dentro(orden, limite=1200):
|
||||
return subprocess.run(
|
||||
["flatpak", "run", "--command=bash", APP, "-c", orden],
|
||||
capture_output=True, text=True, timeout=limite)
|
||||
|
||||
|
||||
def genera(texto, voz, velocidad, ruido, destino):
|
||||
crudo = tempfile.mktemp(suffix=".wav")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
|
||||
"--length_scale", str(1 / velocidad), "--output_file", crudo],
|
||||
input=texto, capture_output=True, text=True, timeout=60)
|
||||
if r.returncode != 0 or not os.path.exists(crudo):
|
||||
return False
|
||||
filtros = "aresample=16000" + ("," + ruido if ruido else "")
|
||||
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-i", crudo, "-ac", "1", "-af", filtros, "-y", destino],
|
||||
check=True, timeout=60)
|
||||
return os.path.exists(destino)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
finally:
|
||||
if os.path.exists(crudo):
|
||||
os.remove(crudo)
|
||||
|
||||
|
||||
def transcribe(carpeta, ficheros, hilos=8, por_tanda=40):
|
||||
salida = {}
|
||||
for i in range(0, len(ficheros), por_tanda):
|
||||
tanda = ficheros[i:i + por_tanda]
|
||||
lista = " ".join(f'"{carpeta}/{n}"' for n in tanda)
|
||||
orden = (f'for f in {lista}; do echo "### $(basename "$f")"; '
|
||||
f'{WHISPER} -m {MODELOS_STT}/ggml-base.bin -l es -nt -t {hilos} '
|
||||
f'-f "$f" 2>/dev/null | tr "\\n" " "; echo; done')
|
||||
try:
|
||||
r = dentro(orden)
|
||||
except subprocess.SubprocessError:
|
||||
continue
|
||||
actual = None
|
||||
for linea in r.stdout.splitlines():
|
||||
if linea.startswith("### "):
|
||||
actual = linea[4:].strip()
|
||||
elif actual:
|
||||
salida[actual] = linea.strip()
|
||||
actual = None
|
||||
return salida
|
||||
|
||||
|
||||
def catalogo_base():
|
||||
ruta = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE
|
||||
with open(ruta) as f:
|
||||
return json.load(f)["acciones"], ruta
|
||||
|
||||
|
||||
def evalua(motor, textos, indice):
|
||||
"""(aciertos, total, candidatos{id: {frase_mal: veces}})."""
|
||||
aciertos = 0
|
||||
candidatos = defaultdict(lambda: defaultdict(int))
|
||||
for nombre, (ident, frase) in indice.items():
|
||||
oido = textos.get(nombre, "").strip()
|
||||
if not oido:
|
||||
continue
|
||||
a, _ = _CAT.busca(oido)
|
||||
if a is not None and a.id == ident:
|
||||
aciertos += 1
|
||||
else:
|
||||
candidatos[ident][motor.normaliza(oido)] += 1
|
||||
return aciertos, len([1 for n in indice if textos.get(n)]), candidatos
|
||||
|
||||
|
||||
_CAT = None
|
||||
|
||||
|
||||
# Palabras de funcion: aparecen en cualquier frase y no distinguen una orden de
|
||||
# una conversacion. Un alias hecho solo de estas ("que me tengo") es una trampa.
|
||||
VACIAS = {
|
||||
"que", "de", "la", "el", "los", "las", "un", "una", "me", "te", "se", "lo",
|
||||
"mi", "tu", "su", "es", "esta", "estan", "hay", "por", "para", "con", "en",
|
||||
"y", "o", "a", "al", "del", "como", "cuanto", "cuanta", "muy", "ya", "he",
|
||||
"ha", "tengo", "tiene", "esto", "eso", "aqui", "ahi", "va", "van", "asi",
|
||||
}
|
||||
|
||||
|
||||
def contenido(frase: str) -> set:
|
||||
"""Las palabras con significado: las que de verdad identifican la orden."""
|
||||
return {p for p in frase.split() if len(p) > 3 and p not in VACIAS}
|
||||
|
||||
|
||||
def filtra_candidatos(motor, candidatos, acciones):
|
||||
"""Solo los alias que valen: sistematicos, sin ambiguedad, y que conservan
|
||||
una palabra distintiva de la orden original.
|
||||
|
||||
El guardarrail clave es el ultimo: una confusion del reconocedor que aun
|
||||
sirve de alias mantiene al menos una palabra con significado ("kernel",
|
||||
"wikipedia", "bateria"). Si las perdio todas y solo quedan palabras de
|
||||
relleno, es indistinguible de una frase de charla y se descarta, por mucho
|
||||
que se haya repetido. Es lo que separa "que kernel tengo" -> "que kernel me
|
||||
tengo" (vale) de "que me tengo" (no vale).
|
||||
"""
|
||||
porfrase = defaultdict(set)
|
||||
for ident, frases in candidatos.items():
|
||||
for f in frases:
|
||||
porfrase[f].add(ident)
|
||||
|
||||
porid = {a["id"]: a for a in acciones}
|
||||
|
||||
propuestos = defaultdict(list)
|
||||
for ident, frases in candidatos.items():
|
||||
accion = porid.get(ident)
|
||||
if accion is None:
|
||||
continue
|
||||
# Las de argumento no generan alias: el relleno de prueba ("notas") se
|
||||
# cuela en la transcripcion y quedaria horneado en el alias. Su
|
||||
# emparejamiento ya es por parecido del arranque, que las cubre.
|
||||
if accion.get("captura"):
|
||||
continue
|
||||
|
||||
orig = motor.normaliza(accion["frases"][0] if accion["frases"] else "")
|
||||
claves = contenido(orig)
|
||||
|
||||
for f, veces in frases.items():
|
||||
if veces < UMBRAL_SISTEMATICO:
|
||||
continue
|
||||
if len(porfrase[f]) > 1: # la misma frase, dos acciones
|
||||
continue
|
||||
if len(f.split()) < 2: # una palabra engancha de todo
|
||||
continue
|
||||
if _CAT.busca(f)[0] is not None: # ya lo coge algo
|
||||
continue
|
||||
if motor.parecido(f, orig) < 0.6: # se perdio del todo: ruido
|
||||
continue
|
||||
# conserva una palabra distintiva? exacta o casi (el reconocedor
|
||||
# cambia una letra: "wikipedia" -> "wikipeda")
|
||||
palabras_f = set(f.split())
|
||||
if claves and not any(
|
||||
c in palabras_f or any(motor.parecido(c, w) > 0.8 for w in palabras_f)
|
||||
for c in claves):
|
||||
continue
|
||||
propuestos[ident].append((f, veces))
|
||||
return propuestos
|
||||
|
||||
|
||||
def aplica(propuestos, acciones_orig):
|
||||
"""Escribe los alias en el catalogo propio. Devuelve cuantos."""
|
||||
try:
|
||||
with open(PROPIO) as f:
|
||||
propio = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
propio = {"version": 1, "acciones": []}
|
||||
porid = {a["id"]: a for a in propio.get("acciones", [])}
|
||||
original = {a["id"]: a for a in acciones_orig}
|
||||
|
||||
n = 0
|
||||
for ident, lista in propuestos.items():
|
||||
entrada = porid.get(ident)
|
||||
if entrada is None:
|
||||
entrada = dict(original[ident])
|
||||
porid[ident] = entrada
|
||||
propio.setdefault("acciones", []).append(entrada)
|
||||
if len(entrada["frases"]) >= TOPE_ALIAS_POR_ACCION:
|
||||
continue
|
||||
for f, _ in lista:
|
||||
if f not in entrada["frases"]:
|
||||
entrada["frases"].append(f)
|
||||
n += 1
|
||||
with open(PROPIO, "w") as f:
|
||||
json.dump(propio, f, indent=2, ensure_ascii=False)
|
||||
return n
|
||||
|
||||
|
||||
def charla_se_cuela(motor):
|
||||
"""True si alguna frase de charla acaba en una accion. El guardarrail."""
|
||||
for frase in CHARLA:
|
||||
a, _ = _CAT.busca(frase)
|
||||
if a is not None:
|
||||
return frase, a.id
|
||||
return None
|
||||
|
||||
|
||||
def marcador_guarda(datos):
|
||||
try:
|
||||
with open(MARCADOR, "w") as f:
|
||||
json.dump(datos, f, indent=2, ensure_ascii=False)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def copia_catalogo(ronda):
|
||||
if not os.path.exists(PROPIO):
|
||||
return
|
||||
os.makedirs(COPIAS, exist_ok=True)
|
||||
shutil.copy2(PROPIO, os.path.join(COPIAS, f"ronda_{ronda:03d}.json"))
|
||||
|
||||
|
||||
def una_ronda(motor, ronda, muestras_por_acap):
|
||||
global _CAT
|
||||
acciones, _ = catalogo_base()
|
||||
|
||||
# muestras de esta ronda: condiciones aleatorias, distintas cada vez
|
||||
trabajos = []
|
||||
for a in acciones:
|
||||
if not a["frases"]:
|
||||
continue
|
||||
frase = a["frases"][0]
|
||||
if a.get("captura"):
|
||||
frase += " " + random.choice(RELLENOS)
|
||||
for _ in range(muestras_por_acap):
|
||||
trabajos.append((a["id"], frase, random.choice(VOCES),
|
||||
random.choice(VELOCIDADES), random.choice(RUIDOS)))
|
||||
random.shuffle(trabajos)
|
||||
|
||||
shutil.rmtree(WAVS, ignore_errors=True)
|
||||
os.makedirs(WAVS, exist_ok=True)
|
||||
indice = {}
|
||||
for n, (ident, frase, voz, vel, ruido) in enumerate(trabajos, 1):
|
||||
nombre = f"{n:05d}.wav"
|
||||
if genera(frase, voz, vel, ruido, os.path.join(WAVS, nombre)):
|
||||
indice[nombre] = (ident, frase)
|
||||
|
||||
log(f"ronda {ronda}: {len(indice)} frases dichas, transcribiendo...")
|
||||
textos = transcribe(WAVS, sorted(indice))
|
||||
|
||||
# medir ANTES de tocar nada
|
||||
_CAT = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
|
||||
or [CATALOGO_FUENTE])
|
||||
aciertos, total, candidatos = evalua(motor, textos, indice)
|
||||
antes = 100 * aciertos / max(1, total)
|
||||
log(f"ronda {ronda}: acierto {antes:.1f} % ({aciertos}/{total})")
|
||||
|
||||
propuestos = filtra_candidatos(motor, candidatos, acciones)
|
||||
nuevos = sum(len(v) for v in propuestos.values())
|
||||
if not nuevos:
|
||||
log(f"ronda {ronda}: sin alias nuevos que valgan")
|
||||
return antes, 0, False
|
||||
|
||||
# aplicar en el catalogo propio, con copia de seguridad antes
|
||||
copia_catalogo(ronda)
|
||||
n = aplica(propuestos, acciones)
|
||||
|
||||
# revalidar: recargar el catalogo con los alias puestos
|
||||
_CAT = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
|
||||
or [CATALOGO_FUENTE])
|
||||
|
||||
# GUARDARRAIL 1: la charla no se cuela
|
||||
cuela = charla_se_cuela(motor)
|
||||
if cuela:
|
||||
frase, ident = cuela
|
||||
log(f"ronda {ronda}: REVERTIDA — '{frase}' se colaria en {ident}")
|
||||
shutil.copy2(os.path.join(COPIAS, f"ronda_{ronda:03d}.json"), PROPIO)
|
||||
return antes, 0, False
|
||||
|
||||
# GUARDARRAIL 2: el acierto sube en las mismas muestras
|
||||
aciertos2, _, _ = evalua(motor, textos, indice)
|
||||
despues = 100 * aciertos2 / max(1, total)
|
||||
if despues < antes - 0.5:
|
||||
log(f"ronda {ronda}: REVERTIDA — el acierto bajaria a {despues:.1f} %")
|
||||
shutil.copy2(os.path.join(COPIAS, f"ronda_{ronda:03d}.json"), PROPIO)
|
||||
return antes, 0, False
|
||||
|
||||
log(f"ronda {ronda}: +{n} alias · acierto {antes:.1f} -> {despues:.1f} %")
|
||||
for ident, lista in sorted(propuestos.items()):
|
||||
for f, v in lista[:2]:
|
||||
log(f" {ident} <- \"{f}\" ({v}x)")
|
||||
return despues, n, True
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--horas", type=float, default=0, help="parar tras N horas")
|
||||
ap.add_argument("--ronda", action="store_true", help="una sola ronda")
|
||||
ap.add_argument("--muestras", type=int, default=3,
|
||||
help="muestras por accion y ronda")
|
||||
ap.add_argument("--dry", type=int, default=DRY_PARA_PARAR,
|
||||
help="rondas secas seguidas antes de parar")
|
||||
args = ap.parse_args()
|
||||
|
||||
for ruta, que in ((PIPER, "piper"), (WHISPER, "whisper")):
|
||||
if not os.path.exists(ruta):
|
||||
print(f"falta {que}: {ruta}")
|
||||
return 1
|
||||
|
||||
os.makedirs(SALA, exist_ok=True)
|
||||
limite = time.time() + args.horas * 3600 if args.horas else None
|
||||
motor = carga_motor()
|
||||
|
||||
log("=" * 56)
|
||||
log(f"arranca la mejora nocturna · {args.muestras} muestras/accion/ronda")
|
||||
if limite:
|
||||
log(f"parara en {args.horas} h")
|
||||
else:
|
||||
log("corre hasta converger o hasta que la pares")
|
||||
|
||||
historial = []
|
||||
dry = 0
|
||||
ronda = 0
|
||||
try:
|
||||
while True:
|
||||
ronda += 1
|
||||
libre = disco_libre_gb()
|
||||
if libre < 10:
|
||||
log(f'PARADA: solo quedan {libre:.1f} GB de disco. Fin por seguridad.')
|
||||
break
|
||||
ini = time.time()
|
||||
try:
|
||||
score, nuevos, aplicado = una_ronda(motor, ronda, args.muestras)
|
||||
except Exception as e:
|
||||
log(f"ronda {ronda}: error, se salta — {type(e).__name__}: {e}")
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
historial.append({"ronda": ronda, "acierto": round(score, 1),
|
||||
"alias_nuevos": nuevos,
|
||||
"minutos": round((time.time() - ini) / 60, 1)})
|
||||
marcador_guarda({
|
||||
"actualizado": time.strftime("%Y-%m-%d %H:%M"),
|
||||
"rondas": ronda, "ultimo_acierto": round(score, 1),
|
||||
"alias_totales": alias_totales(),
|
||||
"historial": historial[-40:],
|
||||
})
|
||||
|
||||
dry = dry + 1 if nuevos == 0 else 0
|
||||
if args.ronda:
|
||||
break
|
||||
if dry >= args.dry:
|
||||
log(f"converge: {DRY_PARA_PARAR} rondas sin nada nuevo. Fin.")
|
||||
break
|
||||
if limite and time.time() > limite:
|
||||
log("se acabo el tiempo pedido. Fin.")
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
log("parada a mano")
|
||||
|
||||
shutil.rmtree(WAVS, ignore_errors=True)
|
||||
log(f"terminado tras {ronda} rondas · acierto final "
|
||||
f"{historial[-1]['acierto'] if historial else '?'} % · "
|
||||
f"{alias_totales()} alias en el catalogo propio")
|
||||
log("los cambios estan en jarvis-acciones.json; se aplican al abrir JARVIS")
|
||||
return 0
|
||||
|
||||
|
||||
def alias_totales():
|
||||
try:
|
||||
with open(PROPIO) as f:
|
||||
d = json.load(f)
|
||||
base = {a["id"]: len(a["frases"]) for a in
|
||||
json.load(open(CATALOGO if os.path.exists(CATALOGO)
|
||||
else CATALOGO_FUENTE))["acciones"]}
|
||||
return sum(max(0, len(a["frases"]) - base.get(a["id"], 0))
|
||||
for a in d.get("acciones", []))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue