Files
feadulta/scripts/fea_tts_backlog_report.py
T
rafa 6dc847a151 TTS backlog: nota sobre el wrapper de Hermes (#188)
Hermes bloquea los scripts cuyo path resuelve fuera de ~/.hermes/scripts y
resuelve los symlinks antes de comprobarlo, asi que el enlace que habia puesto
al script del repo hacia fallar el job diario. Sustituido por un wrapper fino
que lo llama por subproceso, igual que feadulta_ga4_daily.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:36:26 -04:00

175 lines
6.7 KiB
Python

#!/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 (los valores por defecto
# de tts_backlog_cron.sh). Solo afecta a qué línea se marca como "en curso".
ACTIVO = (382, 2025, 2026)
def php(*args: str) -> str:
"""fea_post_io.php dentro del contenedor. stderr fuera: WP escupe warnings."""
r = subprocess.run(
["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", *args],
capture_output=True, text=True,
)
return r.stdout if r.returncode == 0 else ""
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 main() -> int:
hechos = hechos_24h()
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 = pendientes(aid, 0, 9999, voz)
marca = ""
if aid == ACTIVO[0]:
falta_lote = pendientes(aid, ACTIVO[1], ACTIVO[2], voz)
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}%")
lineas.append(f"Ventanas 24 h: {corridas} ejecutadas, {saltadas} saltadas por cuota")
# 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.
if corridas == 0 and saltadas == 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())