From c5dcbdb9977bdf8dde07ef06ef1045ac829ce3cf Mon Sep 17 00:00:00 2001 From: rafa Date: Sun, 2 Aug 2026 18:53:26 -0400 Subject: [PATCH] TTS backlog: arreglar el gate de cuota y anadir el reporte diario (#188) Gate: `int(v or 100)` trataba `five_h_pct = 0.0` como ausencia de dato y devolvia 100, o sea que la tanda abortaba justo cuando la ventana de 5h estaba entera libre. Cazado en la primera ventana real. Ahora se distingue 0.0 de None. Reporte diario (F2): scripts/fea_tts_backlog_report.py, solo lectura. Cuenta los audios de las ultimas 24h por autor (atribuidos por fea_audio_voice), lo que queda por autor, la cuota de MiniMax y las ventanas ejecutadas/saltadas. Avisa aparte si en 24h no hubo NI ventana ejecutada NI saltada, que es el sintoma de que el cron no llego a correr (fue lo que paso el 2-ago con el bit +x). Vive en el repo y ~/.hermes/scripts/ lo ve por symlink: sin copia duplicada que se desincronice. Job: `hermes cron feadulta-tts-backlog-daily`, 07:30, no-agent, deliver origin. Co-Authored-By: Claude Opus 5 --- scripts/fea_tts_backlog_report.py | 168 ++++++++++++++++++++++++++++++ scripts/tts_backlog_cron.sh | 6 +- 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100755 scripts/fea_tts_backlog_report.py diff --git a/scripts/fea_tts_backlog_report.py b/scripts/fea_tts_backlog_report.py new file mode 100755 index 0000000..c8c52e9 --- /dev/null +++ b/scripts/fea_tts_backlog_report.py @@ -0,0 +1,168 @@ +#!/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. +""" +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()) diff --git a/scripts/tts_backlog_cron.sh b/scripts/tts_backlog_cron.sh index 2afcc9b..e0774b1 100755 --- a/scripts/tts_backlog_cron.sh +++ b/scripts/tts_backlog_cron.sh @@ -52,10 +52,14 @@ echo "[$(ts)] === cron TTS backlog start (autor=$AUTOR $DESDE-$HASTA batch=$BATC # 1) Gate de cuota MiniMax (ventana de 5 h y semanal) read -r PCT5 PCTW <<< "$(python3 "$QUOTA" --json --no-local 2>/dev/null | python3 -c ' import json, sys +def pct(v): + # OJO: 0.0 es un valor legítimo (ventana entera libre) y es falsy en Python. + # Un `v or 100` aquí aborta la tanda justo cuando hay toda la cuota disponible. + return int(v) if v is not None else 100 try: d = json.load(sys.stdin) m = next(p for p in d["providers"] if p["provider"] == "minimax" and p.get("ok")) - print(int(m.get("five_h_pct") or 100), int(m.get("week_pct") or 100)) + print(pct(m.get("five_h_pct")), pct(m.get("week_pct"))) except Exception: print(100, 100) # sin lectura fiable de cuota, no se gasta ' 2>/dev/null || echo "100 100")"