Files
feadulta/scripts/fea_tts_backlog_report.py
T

258 lines
9.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Reporte diario del backlog de TTS de Fe Adulta (issue rafa/feadulta#188).
SOLO LECTURA: no genera audio ni toca la BD. Cuenta lo hecho en las últimas 24 h,
lo que queda por autor, la cuota de MiniMax y las ventanas que se saltaron.
Entregado por Hermes en modo no-agent (el stdout va directo a Rafa).
Silencio deliberado si no hay nada que contar y todo está en orden.
Hermes solo ejecuta scripts que resuelvan DENTRO de ~/.hermes/scripts, y resuelve
los symlinks antes de comprobarlo: un enlace a este fichero se bloquea. Por eso
~/.hermes/scripts/fea_tts_backlog_report.py es un wrapper que lo llama por
subproceso (mismo patrón que feadulta_ga4_daily.py). Este de aquí es el único
sitio donde se edita la lógica.
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
REPO = Path("/home/rafa/joomla-migration")
TTS_DIR = REPO / "wordpress/wp-content/uploads/tts"
LOG_DIR = Path("/tmp/fea-tts-backlog")
QUOTA = Path("/home/rafa/ytsummaries/scripts/quota.py")
CONTAINER = "wordpress-web"
CRON = "/home/rafa/joomla-migration/scripts/tts_backlog_cron.sh"
# WP user_id -> (nombre, voz clonada). Mismo mapping que AUTHOR_VOICES en
# scripts/minimax_tts.py; si se clona una voz nueva, añadirla en los dos sitios.
AUTORES = {
382: ("Fray Marcos", "FrayMarcosFeadulta2026"),
383: ("Pagola", "PagolaFeadulta2026"),
774: ("Sicre", "SicreFeadulta2026"),
386: ("Arregi", "ArregiFeadulta2026"),
}
# Autor y rango que está locutando el cron ahora mismo. Debe seguir el crontab:
# solo afecta a qué línea se marca como «en curso» en el informe diario.
ACTIVO = (774, 2000, 2026)
def php(*args: str) -> str:
"""Llama al helper WP sin convertir un fallo de lectura en una cola vacía."""
r = subprocess.run(
["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", *args],
capture_output=True, text=True,
)
if r.returncode != 0:
detail = (r.stderr or r.stdout).strip().replace("\n", " ")[:300]
raise RuntimeError(f"consulta WP {' '.join(args)} falló (rc={r.returncode}): {detail}")
return r.stdout
def pendientes(autor: int, desde: int, hasta: int, voz: str) -> int:
salida = php("listpending", str(autor), str(desde), str(hasta), "0", voz)
return len([x for x in salida.split() if x.strip().isdigit()])
def hechos_24h() -> dict[int, list[tuple[int, str]]]:
"""mp3 escritos en las últimas 24 h, agrupados por autor.
El mtime del fichero es la fuente: es lo que se acaba de escribir, sin
depender de metas que puedan venir de una sincronización antigua.
"""
corte = time.time() - 24 * 3600
por_autor: dict[int, list[tuple[int, str]]] = {}
if not TTS_DIR.is_dir():
return por_autor
recientes = [f for f in TTS_DIR.glob("*.mp3")
if f.stat().st_mtime >= corte and f.stem.isdigit()]
for f in sorted(recientes, key=lambda p: p.stat().st_mtime):
pid = int(f.stem)
# No hay meta de autor; la voz sí se guarda (fea_audio_voice) y basta
# para atribuirlo, porque cada autor clonado tiene la suya.
voz = php("getmeta", str(pid), "fea_audio_voice").strip()
aid = next((a for a, (_, v) in AUTORES.items() if v == voz), 0)
por_autor.setdefault(aid, []).append((pid, voz or "?"))
return por_autor
def cuota() -> tuple[int | None, int | None]:
try:
r = subprocess.run([sys.executable, str(QUOTA), "--json", "--no-local"],
capture_output=True, text=True, timeout=40)
d = json.loads(r.stdout)
m = next(p for p in d["providers"] if p["provider"] == "minimax" and p.get("ok"))
return m.get("five_h_pct"), m.get("week_pct")
except Exception: # noqa: BLE001
return None, None
def logs_24h() -> tuple[int, int, list[str]]:
"""(ventanas ejecutadas, ventanas saltadas por gate, líneas de fallo)."""
hoy = datetime.now()
ficheros = [LOG_DIR / f"cron-{(hoy - timedelta(days=d)).strftime('%Y-%m-%d')}.log"
for d in (0, 1)]
corridas = saltadas = 0
fallos: list[str] = []
corte = hoy - timedelta(hours=24)
for f in ficheros:
if not f.is_file():
continue
for linea in f.read_text(errors="replace").splitlines():
m = re.match(r"\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)\]", linea)
if not m:
continue
try:
if datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S") < corte:
continue
except ValueError:
continue
if "cron TTS backlog start" in linea:
corridas += 1
elif "ABORT:" in linea:
saltadas += 1
fallos.append(linea.split("ABORT:", 1)[1].strip())
elif "FALLO rc=" in linea or "listpending falló" in linea:
fallos.append(linea.split("] ", 1)[-1].strip())
return corridas, saltadas, fallos
def _campo_cron(campo: str, valores: range) -> set[int]:
"""Expande un campo de crontab ('*', '*/5', '1,5,6,0', '0-4') a un set."""
if campo == "*":
return set(valores)
out: set[int] = set()
for trozo in campo.split(","):
paso = 1
if "/" in trozo:
trozo, p = trozo.split("/", 1)
paso = int(p)
if trozo == "*":
base = list(valores)
elif "-" in trozo:
a, b = (int(x) for x in trozo.split("-", 1))
base = list(range(a, b + 1))
else:
base = [int(trozo)]
out.update(base[::paso] if paso > 1 else base)
return out
def previstas_24h(ahora: datetime | None = None) -> int | None:
"""Cuántas ventanas tenían que haber corrido en las últimas 24 horas.
El backlog usa varias líneas de crontab (ritmo normal + sábado + domingo).
Se calcula la unión de sus instantes previstos, sin contar dos veces una
coincidencia entre líneas.
"""
try:
lineas = [
l for l in subprocess.run(["crontab", "-l"], text=True, capture_output=True,
check=True).stdout.splitlines()
if "tts_backlog_cron.sh" in l and not l.lstrip().startswith("#")
]
except Exception: # noqa: BLE001
return None
if not lineas:
return None
ahora = ahora or datetime.now()
inicio = ahora - timedelta(hours=24)
previstos: set[datetime] = set()
for linea in lineas:
campos = linea.split(None, 5)
if len(campos) < 5:
continue
try:
minutos = _campo_cron(campos[0], range(60))
horas = _campo_cron(campos[1], range(24))
dows = {d % 7 for d in _campo_cron(campos[4], range(7))}
except (TypeError, ValueError):
continue
for h in range(25):
t = (ahora - timedelta(hours=h)).replace(second=0, microsecond=0)
for m in minutos:
cand = t.replace(minute=m)
if inicio < cand <= ahora and cand.hour in horas and (cand.weekday() + 1) % 7 in dows:
previstos.add(cand)
return len(previstos)
def main() -> int:
# Nunca informar «0 pendientes» cuando la lectura de WordPress ha fallado.
# El cron no-agent entrega stdout/errores tal cual: un aviso explícito permite
# arreglar Docker/helper sin que parezca que el backlog está terminado.
try:
hechos = hechos_24h()
faltantes = {
aid: (pendientes(aid, 0, 9999, voz),
pendientes(aid, ACTIVO[1], ACTIVO[2], voz) if aid == ACTIVO[0] else None)
for aid, (_nombre, voz) in AUTORES.items()
}
except RuntimeError as exc:
print("⚠️ Fe Adulta — informe TTS inválido: no se pudo leer WordPress.")
print(f" {exc}")
print(" Los ceros no son datos reales; revisar wordpress-web y /tmp/fea_post_io.php.")
return 1
total = sum(len(v) for v in hechos.values())
corridas, saltadas, fallos = logs_24h()
p5, pw = cuota()
lineas = [f"Fe Adulta — backlog TTS (últimas 24 h): {total} audios"]
if hechos:
for aid, items in sorted(hechos.items(), key=lambda kv: -len(kv[1])):
nombre = AUTORES.get(aid, ("otros", ""))[0]
lineas.append(f" {nombre}: {len(items)} "
f"({', '.join('#%d' % p for p, _ in items[:8])}"
f"{'…' if len(items) > 8 else ''})")
lineas.append("")
lineas.append("Pendientes:")
for aid, (nombre, voz) in AUTORES.items():
falta_todo, falta_lote = faltantes[aid]
marca = ""
if aid == ACTIVO[0]:
marca = f" ← en curso, {falta_lote} del lote {ACTIVO[1]}-{ACTIVO[2]}"
lineas.append(f" {nombre}: {falta_todo}{marca}")
lineas.append("")
if p5 is None:
lineas.append("Cuota MiniMax: no se pudo leer")
else:
lineas.append(f"Cuota MiniMax: 5h {p5:.0f}% · semana {pw:.0f}%")
previstas = previstas_24h()
de = "" if previstas is None else f" de {previstas} previstas"
lineas.append(f"Ventanas 24 h: {corridas} ejecutadas{de}, {saltadas} saltadas por cuota")
if previstas == 0:
lineas.append(" (sin ventanas previstas: día sin cron, toca carta)")
# Una ventana que ni arranca ni se salta es un fallo mudo: el cron no llegó a
# correr (bit +x, WSL apagada...). Es exactamente lo que pasó el 2-ago.
# Solo es alarma si de verdad tocaba correr; si no, es martes.
if corridas == 0 and saltadas == 0 and previstas != 0:
lineas.append("")
lineas.append("⚠️ Ninguna ventana dejó rastro en 24 h. Si tocaba que corriera, "
f"comprobar: crontab -l | grep tts_backlog · ls -l {CRON}")
if fallos:
lineas.append("")
lineas.append("Avisos:")
for f in fallos[:10]:
lineas.append(f" {f}")
print("\n".join(lineas))
return 0
if __name__ == "__main__":
raise SystemExit(main())