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
464
config/autoescuela.py
Executable file
464
config/autoescuela.py
Executable file
|
|
@ -0,0 +1,464 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Dos JARVIS hablando: uno examina, el otro ejecuta, el primero corrige.
|
||||
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py # una tanda
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py --horas 6 # toda la noche
|
||||
python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py --aplicar # guardar mejoras
|
||||
|
||||
La idea. Los bucles que teniamos probaban trozos: mejora_nocturna comprobaba
|
||||
solo si la frase llega a la accion; probar_acciones, solo si la accion corre.
|
||||
Esto los junta en una conversacion de punta a punta y por AUDIO:
|
||||
|
||||
Examinador -> dice una tarea en voz alta (Piper), con ruido y sala
|
||||
Jarvis -> la oye (Whisper), decide que accion es, la ejecuta,
|
||||
y contesta en voz alta
|
||||
Examinador -> comprueba tres cosas:
|
||||
1. ¿oyo la tarea correcta? (reconocimiento)
|
||||
2. ¿hizo la accion que tocaba? (emparejamiento)
|
||||
3. ¿el resultado tiene sentido? (ejecucion y respuesta)
|
||||
y pone nota
|
||||
|
||||
Lo que se ejecuta de verdad: solo lo de solo lectura. Lo que abre cosas, apaga
|
||||
o necesita root se comprueba hasta el emparejamiento, sin lanzarlo —una
|
||||
autoescuela no deberia apagarte la pantalla ni pedir tu contraseña de noche.
|
||||
|
||||
Lo que aprende: los fallos se separan por tipo. Un fallo de reconocimiento
|
||||
propone un alias (con el mismo filtro seguro de mejora_nocturna: sistematico,
|
||||
sin ambiguedad, conserva palabra clave). Un fallo de ejecucion o de respuesta
|
||||
se ANOTA para arreglarlo a mano —eso no se toca solo, que es codigo—.
|
||||
|
||||
Escenarios: casi todos son de un turno (una tarea, una respuesta), sacados del
|
||||
catalogo. Unos pocos son de varios turnos para probar lo que tiene estado:
|
||||
buscar -> "abre el segundo" -> "busca dentro X". Y hay charla que NO debe
|
||||
disparar ninguna accion, como control negativo.
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shlex
|
||||
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")
|
||||
SELECCION = os.path.join(JARVIS, "newelle/src/utility/seleccion.py")
|
||||
|
||||
SALA = os.path.join(JARVIS, "muestras/autoescuela")
|
||||
DIALOGO = os.path.join(SALA, "dialogo.log")
|
||||
INFORME = os.path.join(SALA, "informe.json")
|
||||
WAVS = os.path.join(CONFIG, "autoescuela_wavs")
|
||||
|
||||
VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"]
|
||||
VELOCIDADES = [0.9, 1.0, 1.12]
|
||||
RUIDOS = ["", "aecho=0.8:0.7:22:0.12", "volume=0.72,highpass=f=120",
|
||||
"highpass=f=150,lowpass=f=6500"]
|
||||
|
||||
# Lo que tiene efecto o pide permiso: se comprueba el emparejamiento pero no se
|
||||
# ejecuta. Igual que en probar_acciones.
|
||||
EFECTOS = ["firefox", "xdg-open", "loginctl", "xset", "pactl set", "pkill",
|
||||
"brightnessctl", "systemctl start", "systemctl stop", "systemctl restart",
|
||||
"import -window", "gnome-screenshot", "sudo", "rm ", "kill", "dpms"]
|
||||
|
||||
RELLENOS = ["notas", "el informe", "documentos", "reactor"]
|
||||
|
||||
|
||||
def carga(ruta, nombre):
|
||||
spec = importlib.util.spec_from_file_location(nombre, ruta)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def log(msg, a_dialogo=False):
|
||||
linea = f"[{time.strftime('%H:%M:%S')}] {msg}"
|
||||
print(linea, flush=True)
|
||||
fichero = DIALOGO if a_dialogo else DIALOGO
|
||||
try:
|
||||
with open(fichero, "a") as f:
|
||||
f.write((msg if a_dialogo else linea) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def dice(texto, voz, vel, ruido, destino):
|
||||
"""El examinador dice una tarea: la sintetiza a un wav de 16 kHz."""
|
||||
crudo = tempfile.mktemp(suffix=".wav")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"),
|
||||
"--length_scale", str(1 / vel), "--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 True
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
finally:
|
||||
if os.path.exists(crudo):
|
||||
os.remove(crudo)
|
||||
|
||||
|
||||
def oye(wavs, hilos=8, por_tanda=40):
|
||||
"""Jarvis oye: transcribe los wav. {fichero: texto}."""
|
||||
salida = {}
|
||||
ficheros = sorted(wavs)
|
||||
for i in range(0, len(ficheros), por_tanda):
|
||||
tanda = ficheros[i:i + por_tanda]
|
||||
lista = " ".join(f'"{WAVS}/{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 = subprocess.run(["flatpak", "run", "--command=bash", APP, "-c", orden],
|
||||
capture_output=True, text=True, errors="replace", timeout=1200)
|
||||
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 tiene_efecto(comando):
|
||||
return any(m in comando for m in EFECTOS)
|
||||
|
||||
|
||||
def ejecuta_lectura(comando, argumento=""):
|
||||
"""Ejecuta un comando de solo lectura y devuelve su salida."""
|
||||
c = comando.replace("{argumento}", shlex.quote(argumento))
|
||||
c = c.replace("{argumento_url}", shlex.quote(argumento))
|
||||
try:
|
||||
r = subprocess.run(["bash", "-lc", c], capture_output=True, text=True,
|
||||
errors="replace", timeout=15)
|
||||
return (r.stdout or r.stderr or "").strip(), r.returncode
|
||||
except subprocess.SubprocessError:
|
||||
return "", 1
|
||||
|
||||
|
||||
class Ejecutor:
|
||||
"""La tuberia de Jarvis: oye una frase, decide y hace. Replica el enrutado
|
||||
del panel (seleccion antes que catalogo) para poder probarlo sin GUI."""
|
||||
|
||||
def __init__(self, motor, seleccion):
|
||||
self.motor = motor
|
||||
self.sel = seleccion
|
||||
self.cat = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)]
|
||||
or [CATALOGO_FUENTE])
|
||||
self.lista = []
|
||||
self.fichero = None
|
||||
|
||||
def recarga(self):
|
||||
self.cat = self.motor.Catalogo(
|
||||
[p for p in (PROPIO, CATALOGO) if os.path.exists(p)] or [CATALOGO_FUENTE])
|
||||
|
||||
def procesa(self, oido):
|
||||
"""Devuelve (tipo, id_o_dato, salida). tipo:
|
||||
'seleccion' | 'dentro' | 'accion' | 'modelo'."""
|
||||
norm = self.motor.normaliza(oido)
|
||||
|
||||
s = self.sel.orden_de_seleccion(norm)
|
||||
if s is not None and self.lista:
|
||||
que, pos = s
|
||||
idx = len(self.lista) - 1 if pos == -1 else pos - 1
|
||||
if 0 <= idx < len(self.lista):
|
||||
self.fichero = self.lista[idx]
|
||||
return "seleccion", f"{que}:{pos}", self.fichero
|
||||
return "seleccion", "fuera_de_rango", ""
|
||||
|
||||
termino = self.sel.orden_buscar_dentro(norm)
|
||||
if termino and self.fichero:
|
||||
r, _ = ejecuta_lectura(f"grep -in {shlex.quote(termino)} "
|
||||
f"{shlex.quote(self.fichero)} | head", "")
|
||||
return "dentro", termino, r
|
||||
|
||||
accion, arg = self.cat.busca(oido)
|
||||
if accion is None:
|
||||
return "modelo", "", ""
|
||||
|
||||
# las de lista alimentan self.lista
|
||||
if accion.id in ("buscar_fichero", "buscar_carpeta", "grep_recursivo"):
|
||||
salida, _ = ejecuta_lectura(accion.comando, arg)
|
||||
rutas = [l.split(":", 1)[0] if accion.id == "grep_recursivo" else l
|
||||
for l in salida.splitlines() if l.strip()]
|
||||
self.lista = rutas
|
||||
if rutas:
|
||||
self.fichero = rutas[0]
|
||||
return "accion", accion.id, salida
|
||||
|
||||
if accion.confirmar or tiene_efecto(accion.comando):
|
||||
return "accion", accion.id, "(no se ejecuta; solo emparejamiento)"
|
||||
|
||||
salida, _ = ejecuta_lectura(accion.comando, arg)
|
||||
return "accion", accion.id, salida
|
||||
|
||||
|
||||
def escenarios(acciones):
|
||||
"""Construye las tareas de examen. Cada una: (texto, comprobacion)."""
|
||||
tareas = []
|
||||
|
||||
# un turno por accion: la tarea es su frase, se espera su id
|
||||
for a in acciones:
|
||||
if not a["frases"]:
|
||||
continue
|
||||
texto = a["frases"][0]
|
||||
if a.get("captura"):
|
||||
texto += " " + random.choice(RELLENOS)
|
||||
tareas.append({
|
||||
"tipo": "accion", "texto": texto, "espera_id": a["id"],
|
||||
"turnos": [texto],
|
||||
})
|
||||
|
||||
# multi-turno: probar el estado (lista -> elegir -> buscar dentro)
|
||||
tareas.append({
|
||||
"tipo": "multi", "texto": "buscar y elegir",
|
||||
"turnos": ["busca el fichero reactor", "abre el primero", "busca dentro def"],
|
||||
"espera": ["accion:grep_recursivo|accion:buscar_fichero", "seleccion", "dentro"],
|
||||
})
|
||||
|
||||
# control negativo: charla que NO debe disparar accion
|
||||
for frase in ["cuentame un chiste sobre gatos", "que opinas de todo esto",
|
||||
"estoy cansado hoy la verdad", "no se muy bien que hacer ahora"]:
|
||||
tareas.append({"tipo": "charla", "texto": frase, "turnos": [frase]})
|
||||
|
||||
return tareas
|
||||
|
||||
|
||||
def una_tanda(ejecutor, motor, ronda, muestras):
|
||||
acciones = ejecutor.cat.acciones
|
||||
base_acciones = json.load(open(CATALOGO if os.path.exists(CATALOGO)
|
||||
else CATALOGO_FUENTE))["acciones"]
|
||||
tareas = escenarios(base_acciones)
|
||||
|
||||
# sintetizar todos los turnos
|
||||
shutil.rmtree(WAVS, ignore_errors=True)
|
||||
os.makedirs(WAVS, exist_ok=True)
|
||||
plan = [] # (fichero, tarea_idx, turno_idx, texto)
|
||||
n = 0
|
||||
for ti, tarea in enumerate(tareas):
|
||||
for tj, texto in enumerate(tarea["turnos"]):
|
||||
n += 1
|
||||
nombre = f"{n:05d}.wav"
|
||||
if dice(texto, random.choice(VOCES), random.choice(VELOCIDADES),
|
||||
random.choice(RUIDOS), os.path.join(WAVS, nombre)):
|
||||
plan.append((nombre, ti, tj, texto))
|
||||
|
||||
log(f"ronda {ronda}: examinador dice {len(plan)} tareas, jarvis las oye...")
|
||||
oidos = oye([p[0] for p in plan])
|
||||
|
||||
# correr los escenarios en orden (el estado importa en los multi-turno)
|
||||
resultados = {"bien": 0, "reconoce_mal": 0, "accion_mal": 0,
|
||||
"ejecuta_mal": 0, "charla_colada": 0}
|
||||
candidatos = defaultdict(lambda: defaultdict(int))
|
||||
por_tarea = defaultdict(list)
|
||||
for nombre, ti, tj, texto in plan:
|
||||
por_tarea[ti].append((tj, nombre, texto))
|
||||
|
||||
for ti in sorted(por_tarea):
|
||||
tarea = tareas[ti]
|
||||
ejecutor.lista = [] # cada escenario empieza limpio
|
||||
ejecutor.fichero = None
|
||||
for tj, nombre, texto in sorted(por_tarea[ti]):
|
||||
oido = oidos.get(nombre, "").strip()
|
||||
if not oido:
|
||||
continue
|
||||
tipo, dato, salida = ejecutor.procesa(oido)
|
||||
|
||||
# dejar la conversacion en el log, legible
|
||||
log(f" examinador: «{texto}»", a_dialogo=True)
|
||||
log(f" jarvis oye: «{oido}» -> {tipo}:{dato}", a_dialogo=True)
|
||||
|
||||
veredicto = verifica(tarea, tj, texto, oido, tipo, dato, salida,
|
||||
motor, candidatos)
|
||||
log(f" veredicto: {veredicto}\n", a_dialogo=True)
|
||||
resultados[veredicto] = resultados.get(veredicto, 0) + 1
|
||||
|
||||
total = sum(resultados.values())
|
||||
bien = resultados["bien"]
|
||||
log(f"ronda {ronda}: {bien}/{total} bien · " +
|
||||
" · ".join(f"{k} {v}" for k, v in resultados.items() if k != "bien" and v))
|
||||
return resultados, candidatos
|
||||
|
||||
|
||||
def verifica(tarea, tj, texto, oido, tipo, dato, salida, motor, candidatos):
|
||||
"""Pone nota a un turno y, si es fallo de reconocimiento, anota el candidato."""
|
||||
if tarea["tipo"] == "charla":
|
||||
return "bien" if tipo == "modelo" else "charla_colada"
|
||||
|
||||
if tarea["tipo"] == "multi":
|
||||
esperado = tarea["espera"][tj]
|
||||
actual = f"{tipo}:{dato}"
|
||||
if any(actual.startswith(e.split("|")[0].split(":")[0] + ":") and
|
||||
(e == actual or e.split(":")[0] == tipo)
|
||||
for e in esperado.split("|")):
|
||||
# para multi basta con que el TIPO sea el correcto
|
||||
if tipo in [e.split(":")[0] for e in esperado.split("|")]:
|
||||
return "bien"
|
||||
return "accion_mal"
|
||||
|
||||
# tarea de un turno: se espera un id concreto
|
||||
espera_id = tarea["espera_id"]
|
||||
if tipo != "accion":
|
||||
# no reconocio la orden: candidato a alias si conserva sentido
|
||||
candidatos[espera_id][motor.normaliza(oido)] += 1
|
||||
return "reconoce_mal"
|
||||
if dato != espera_id:
|
||||
return "accion_mal"
|
||||
# emparejo bien; ¿la ejecucion dio algo con sentido?
|
||||
if salida and ("command not found" in salida or salida == "TIMEOUT"):
|
||||
return "ejecuta_mal"
|
||||
return "bien"
|
||||
|
||||
|
||||
# --- aprender: mismos filtros seguros que mejora_nocturna --------------------
|
||||
VACIAS = {"que", "de", "la", "el", "me", "se", "es", "esta", "estan", "hay",
|
||||
"tengo", "un", "una", "por", "con", "como", "cuanto", "cuanta"}
|
||||
|
||||
|
||||
def contenido(frase):
|
||||
return {p for p in frase.split() if len(p) > 3 and p not in VACIAS}
|
||||
|
||||
|
||||
def alias_seguros(candidatos, acciones, motor, cat):
|
||||
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():
|
||||
a = porid.get(ident)
|
||||
if a is None or a.get("captura"):
|
||||
continue
|
||||
orig = motor.normaliza(a["frases"][0] if a["frases"] else "")
|
||||
claves = contenido(orig)
|
||||
for f, veces in frases.items():
|
||||
if veces < 2 or len(porfrase[f]) > 1 or len(f.split()) < 2:
|
||||
continue
|
||||
if cat.busca(f)[0] is not None or motor.parecido(f, orig) < 0.6:
|
||||
continue
|
||||
pal = set(f.split())
|
||||
if claves and not any(
|
||||
c in pal or any(motor.parecido(c, w) > 0.8 for w in pal)
|
||||
for c in claves):
|
||||
continue
|
||||
propuestos[ident].append((f, veces))
|
||||
return propuestos
|
||||
|
||||
|
||||
def guarda_alias(propuestos, acciones):
|
||||
try:
|
||||
propio = json.load(open(PROPIO))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
propio = {"version": 1, "acciones": []}
|
||||
porid = {a["id"]: a for a in propio.get("acciones", [])}
|
||||
orig = {a["id"]: a for a in acciones}
|
||||
n = 0
|
||||
for ident, lista in propuestos.items():
|
||||
e = porid.get(ident)
|
||||
if e is None:
|
||||
e = dict(orig[ident]); porid[ident] = e
|
||||
propio.setdefault("acciones", []).append(e)
|
||||
for f, _ in lista:
|
||||
if f not in e["frases"]:
|
||||
e["frases"].append(f); n += 1
|
||||
json.dump(propio, open(PROPIO, "w"), indent=2, ensure_ascii=False)
|
||||
return n
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--horas", type=float, default=0)
|
||||
ap.add_argument("--aplicar", action="store_true", help="guardar los alias que encuentre")
|
||||
ap.add_argument("--muestras", type=int, default=1)
|
||||
args = ap.parse_args()
|
||||
|
||||
for ruta, q in ((PIPER, "piper"), (WHISPER, "whisper")):
|
||||
if not os.path.exists(ruta):
|
||||
print(f"falta {q}"); return 1
|
||||
|
||||
os.makedirs(SALA, exist_ok=True)
|
||||
motor = carga(MOTOR, "acciones")
|
||||
sel = carga(SELECCION, "seleccion")
|
||||
ejecutor = Ejecutor(motor, sel)
|
||||
|
||||
limite = time.time() + args.horas * 3600 if args.horas else None
|
||||
log("=" * 56)
|
||||
log("arranca la autoescuela (examinador + jarvis)")
|
||||
|
||||
historial = []
|
||||
ronda = 0
|
||||
total_alias = 0
|
||||
while True:
|
||||
ronda += 1
|
||||
ini = time.time()
|
||||
try:
|
||||
resultados, candidatos = una_tanda(ejecutor, motor, ronda, args.muestras)
|
||||
except Exception as e:
|
||||
log(f"ronda {ronda}: error, se salta — {type(e).__name__}: {e}")
|
||||
time.sleep(5); continue
|
||||
|
||||
# aprender de los fallos de reconocimiento
|
||||
base = json.load(open(CATALOGO if os.path.exists(CATALOGO)
|
||||
else CATALOGO_FUENTE))["acciones"]
|
||||
propuestos = alias_seguros(candidatos, base, motor, ejecutor.cat)
|
||||
nuevos = sum(len(v) for v in propuestos.values())
|
||||
if nuevos and args.aplicar:
|
||||
total_alias += guarda_alias(propuestos, base)
|
||||
ejecutor.recarga()
|
||||
log(f"ronda {ronda}: +{nuevos} alias aplicados")
|
||||
elif nuevos:
|
||||
log(f"ronda {ronda}: {nuevos} alias propuestos (usa --aplicar para guardarlos)")
|
||||
|
||||
total = sum(resultados.values())
|
||||
historial.append({
|
||||
"ronda": ronda,
|
||||
"acierto": round(100 * resultados["bien"] / max(1, total), 1),
|
||||
"fallos": {k: v for k, v in resultados.items() if k != "bien" and v},
|
||||
"minutos": round((time.time() - ini) / 60, 1),
|
||||
})
|
||||
json.dump({"actualizado": time.strftime("%Y-%m-%d %H:%M"),
|
||||
"rondas": ronda, "alias_aplicados": total_alias,
|
||||
"historial": historial[-30:]},
|
||||
open(INFORME, "w"), indent=2, ensure_ascii=False)
|
||||
|
||||
if not args.horas:
|
||||
break
|
||||
if time.time() > limite:
|
||||
log("se acabo el tiempo. Fin."); break
|
||||
|
||||
shutil.rmtree(WAVS, ignore_errors=True)
|
||||
log(f"terminado tras {ronda} rondas · ultimo acierto "
|
||||
f"{historial[-1]['acierto'] if historial else '?'} %")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue