#!/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 _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ÍA que haber corrido el cron en las últimas 24 h. Sin esto, los martes/miércoles/jueves (días de carta, sin cron) el informe daba la alarma de 'ninguna ventana dejó rastro' estando todo correcto. La verdad está en el crontab, no aquí: si Rafa cambia los días, esto le sigue. """ try: linea = next( 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 campos = linea.split(None, 5) if len(campos) < 5: return None 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))} # cron: 0 y 7 = domingo ahora = ahora or datetime.now() n = 0 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 not (ahora - timedelta(hours=24) < cand <= ahora): continue if cand.hour in horas and (cand.weekday() + 1) % 7 in dows: n += 1 return n 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}%") 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())