TTS: cola de backlog por autor con gate de cuota MiniMax (#188)

Aprovechar la cuota ociosa de MiniMax para locutar el backlog historico,
empezando por Fray Marcos (2026 pendientes + 2025 = 86 articulos).

- fea_post_io.php: accion `listpending <autor> <desde> <hasta> <limite> [voz]`.
  Devuelve los posts ES publicados de un autor que no tienen audio, mas
  recientes primero. Si se pasa la voz clonada del autor, tambien salen los
  locutados con otra voz, para rehacerlos.
  Esa consulta ES la idempotencia: no hay fichero de estado que mantener, lo ya
  locutado deja de salir solo. Relanzar o solapar no duplica ni gasta cuota.

- tts_produce.py: flags --autor/--desde/--hasta/--max/--dry-run. Sin flags, el
  modo cartas de la carta semanal no cambia (retrocompatible).
  --max acota la tanda a la ventana de 5h.

- tts_backlog_cron.sh: wrapper de cron con flock y gate de cuota MiniMax
  (semanal >=85% o ventana de 5h >=55% -> se salta la ventana, para no comerle
  la cuota a la carta semanal). Cola configurable por entorno.
  Crontab: 0 */5 * * 1,5,6,0 (lunes, viernes, sabado y domingo).

SOLO LOCAL: no toca produccion. Publicar el backlog en prod
(sync_audio_to_prod.py) queda bloqueado hasta despues del cutover a
Hetzner (#180).

De paso entran al historial los cambios de tts_produce.py que estaban sin
commitear desde el 24-jul (argparse con --ids/--cartas y QUOTA_OR_RATE_ERRORS
para no reintentar ante rc 2056/1039).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 07:56:16 -04:00
parent 39256c0f9b
commit 7c5330a528
3 changed files with 209 additions and 8 deletions
+72 -7
View File
@@ -6,9 +6,18 @@ Reanudable (meta fea_audio_done) y con freno ante la cuota (para tras N fallos
seguidos). NO toca el front; solo genera el mp3 y asocia la URL al post (meta
fea_audio_url).
Dos modos de cola:
- cartas (por defecto): FEA_TTS_CARTAS / --cartas / --ids. Es el flujo de la
carta semanal, que tiene prioridad y no cambia.
- backlog por autor: --autor 382 [--desde 2025] [--hasta 2026] [--max 15].
La cola sale de `listpending` en fea_post_io.php (posts ES publicados sin
audio, más recientes primero). Esa consulta ES la idempotencia: no hay
fichero de estado, lo ya locutado deja de salir solo.
Lanzar: nohup ~/tts-local/xtts-venv/bin/python scripts/tts_produce.py > /tmp/feadulta-tts-prod.out 2>&1 &
Log: /tmp/feadulta-tts-prod.log
"""
import argparse
import os
import shutil
import subprocess
@@ -26,8 +35,9 @@ CONTAINER = "wordpress-web"
PROD = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
LOG = Path("/tmp/feadulta-tts-prod.log")
INTERVAL = 180 # s entre cartas exitosas (reparte el ritmo)
BACKOFF = 1800 # s de espera ante fallo de cuota antes de reintentar
MAX_CONSEC_FAIL = 3 # fallos seguidos → parar (cuota probablemente agotada)
BACKOFF = 1800 # s de espera ante errores transitorios no clasificados
MAX_CONSEC_FAIL = 3 # fallos seguidos no clasificados → parar
QUOTA_OR_RATE_ERRORS = {2056, 1039} # MiniMax: no reintentar en este proceso
MIN_CHARS = 200 # por debajo, se considera sin contenido locutable
# Cola de cartas a locutar. Override por entorno (FEA_TTS_CARTAS) para priorizar
@@ -52,6 +62,18 @@ def meta(pid, key):
return php("getmeta", str(pid), key).stdout.strip()
def backlog_ids(autor, desde, hasta, limite):
"""Cola del backlog de un autor, delegada a la BD (ver listpending)."""
# Voz clonada del autor, si la tiene: los locutados con otra voz también
# cuentan como pendientes. Sin clon (""), pendiente = simplemente sin audio.
voz = mm.voice_for_author(autor, "")
r = php("listpending", str(autor), str(desde), str(hasta), str(limite), voz)
if r.returncode != 0:
log(f"listpending falló (rc={r.returncode}): {r.stderr.strip()[:200]}")
return []
return [int(x) for x in r.stdout.split() if x.strip().isdigit()]
def build_queue():
# Cola literal de IDs (ya filtrada/ordenada) para priorizar la carta nueva.
ids_override = os.environ.get("FEA_TTS_IDS", "").replace(",", " ").split()
@@ -67,11 +89,46 @@ def build_queue():
def main():
global CARTAS
parser = argparse.ArgumentParser(
description="Locuta posts ES de Fe Adulta con MiniMax; sin --ids conserva la cola programada."
)
parser.add_argument("--ids", help="CSV de IDs ES concretos, en el orden de locución deseado")
parser.add_argument("--cartas", help="CSV de cartas para construir la cola; sustituye FEA_TTS_CARTAS")
parser.add_argument("--autor", type=int,
help="WP user_id: cola del backlog de ese autor en vez de cartas")
parser.add_argument("--desde", type=int, default=0, help="año inicial del backlog (con --autor)")
parser.add_argument("--hasta", type=int, default=9999, help="año final del backlog (con --autor)")
parser.add_argument("--max", type=int, default=0,
help="para tras N audios OK en esta ejecución (0 = sin tope)")
parser.add_argument("--dry-run", action="store_true",
help="imprime la cola y sale, sin sintetizar ni gastar cuota")
args = parser.parse_args()
if args.ids:
os.environ["FEA_TTS_IDS"] = args.ids
if args.cartas:
os.environ["FEA_TTS_CARTAS"] = args.cartas
CARTAS = args.cartas.replace(",", " ").split()
PROD.mkdir(parents=True, exist_ok=True)
subprocess.run(["docker", "cp", "scripts/fea_post_io.php", f"{CONTAINER}:/tmp/fea_post_io.php"],
capture_output=True)
queue = build_queue()
log(f"=== INICIO orquestador TTS. Cola: {len(queue)} posts ES del gap ===")
if args.autor:
# Pide holgura sobre --max: parte de la cola puede caerse por contenido corto.
limite = args.max * 3 if args.max else 0
queue = backlog_ids(args.autor, args.desde, args.hasta, limite)
origen = (f"backlog autor {args.autor} ({args.desde}-{args.hasta}), "
f"voz {mm.voice_for_author(args.autor, VOICE)}")
else:
queue = build_queue()
origen = "cartas"
tope = f", tope {args.max} esta tanda" if args.max else ""
log(f"=== INICIO orquestador TTS. Cola: {len(queue)} posts ES [{origen}]{tope} ===")
if args.dry_run:
log("--dry-run: no sintetizo. Cola = " + (",".join(str(x) for x in queue) or "(vacía)"))
return
i = consec = ok = 0
while i < len(queue):
@@ -104,16 +161,24 @@ def main():
voice_tag = f" [{voice}]" if voice != VOICE else ""
log(f"#{pid} OK «{title[:45]}»{voice_tag} → tts/{pid}.mp3 (total {ok})")
i += 1
if args.max and ok >= args.max:
log(f"Tope de la tanda alcanzado ({args.max}). PARO. "
"Reanudable: la próxima ventana recalcula la cola y sigue.")
break
time.sleep(INTERVAL)
else:
consec += 1
log(f"#{pid} FALLO rc={rc} (fallo seguido {consec}/{MAX_CONSEC_FAIL})")
php("setflag", str(pid), "fea_audio_error", str(rc))
if consec >= MAX_CONSEC_FAIL:
log("Demasiados fallos seguidos → cuota agotada probablemente. PARO. "
if rc in QUOTA_OR_RATE_ERRORS:
log(f"MiniMax rc={rc}: cuota/rate limit explícito. PARO sin reintentar. "
"Reanudable: relanzar el script más tarde (salta lo ya hecho).")
break
time.sleep(BACKOFF) # reintenta el mismo post tras esperar
if consec >= MAX_CONSEC_FAIL:
log("Demasiados fallos seguidos no clasificados. PARO. "
"Reanudable: relanzar el script más tarde (salta lo ya hecho).")
break
time.sleep(BACKOFF) # solo errores transitorios no clasificados
log(f"=== FIN tanda. {ok} audios generados esta ejecución. ===")