6dc847a151
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>
175 lines
6.7 KiB
Python
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())
|