c5dcbdb997
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 <noreply@anthropic.com>
169 lines
6.3 KiB
Python
Executable File
169 lines
6.3 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.
|
|
"""
|
|
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())
|