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:
+72
-7
@@ -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. ===")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user