fix(short): cerrar el bucle de edición y el desfase spec/vídeo en la subida
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m17s

Dos huecos de la revisión:

1. /short_spec prometía re-renderizar el spec editado pero no existía el
   camino de vuelta. Ahora un .json adjunto se valida contra el contrato
   vivo (errores con su ruta verbatim), se re-comprueba el fundamento (la
   edición pudo meter una cifra nueva), se guarda como output nuevo y se
   renderiza. Sin LLM: este camino es gratis. La sesión sale del nombre
   del fichero (short_{id}_spec.json), que Telegram conserva al reenviar.

2. /upload_short podía subir un vídeo viejo con metadatos nuevos: produce
   guarda el spec ANTES de renderizar, así que un re-intento con render
   fallido deja en disco el MP4 de la vuelta anterior. Ahora se compara el
   mtime del vídeo con el created_at del spec y se niega (force lo salta).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-08-05 15:46:06 +00:00
co-authored by Claude Fable 5
parent ec3e6d05c0
commit cc1f0cab93
5 changed files with 363 additions and 34 deletions
+152 -24
View File
@@ -5,6 +5,7 @@ Main user interface — all commands handled here
import asyncio
import json
import os
import re
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -155,7 +156,8 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
" Tipos: podcast|blog|report|thread\n"
" Extended: podcast_extended|blog_extended|report_extended\n"
"`/generate short_en` — Short vertical (vídeo) + informe de claims\n"
"`/short_spec` — Último shot spec como fichero JSON\n"
"`/short_spec` — Último shot spec como fichero JSON; edítalo y "
"mándamelo de vuelta para re-renderizar gratis\n"
"`/upload_short` — Subir el Short a YouTube (privado, a revisar)\n"
"`/sources` — List all sources found\n"
"`/outputs` — List generated outputs\n"
@@ -458,7 +460,9 @@ def _claims_message(result) -> str:
if result.grounding:
lines.append(result.grounding.summary())
else:
lines.append("⚠️ Sin comprobación de fundamento: no se llegó a escribir un spec.")
why = ("esta pasada no llegó a mirarlo" if result.spec
else "no se llegó a escribir un spec")
lines.append(f"⚠️ Sin comprobación de fundamento: {why}.")
if result.render_warnings:
lines.append("")
@@ -536,27 +540,7 @@ async def cmd_short(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"🚫 Los Shorts están desactivados (`SHORTSMITH_ENABLED=false`).")
return
if result.has_video:
await reporter.done("✅ Short renderizado")
caption = f"🎬 {result.title}"
if result.article_url:
caption += f"\n{result.article_url}"
caption += f"\n\n{session['topic']} · {result.duration_s:.0f}s"
with open(result.video_path, "rb") as f:
await update.message.reply_video(
video=f,
filename=f"short_{session_id}.mp4",
caption=caption[:1024],
supports_streaming=True,
write_timeout=180,
)
else:
await reporter.done("⚠️ Short sin vídeo — te devuelvo el spec")
await _send_spec_file(update.message, result, session_id,
result.failure or "razón desconocida")
# Informe de claims: SIEMPRE, y en su propio mensaje.
await update.message.reply_text(_claims_message(result))
await _deliver_short(update.message, reporter, result, session)
except Exception as e:
logger.error("Short generation failed", error=str(e), exc_info=True)
@@ -565,6 +549,31 @@ async def cmd_short(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await db_conn.close()
async def _deliver_short(message, reporter, result, session) -> None:
"""El final común de `/generate short_en` y del re-render: vídeo si lo hay,
spec de vuelta si no, e informe de claims SIEMPRE, en su propio mensaje."""
if result.has_video:
await reporter.done("✅ Short renderizado")
caption = f"🎬 {result.title}"
if result.article_url:
caption += f"\n{result.article_url}"
caption += f"\n\n{session['topic']} · {result.duration_s:.0f}s"
with open(result.video_path, "rb") as f:
await message.reply_video(
video=f,
filename=f"short_{session['id']}.mp4",
caption=caption[:1024],
supports_streaming=True,
write_timeout=180,
)
else:
await reporter.done("⚠️ Short sin vídeo — te devuelvo el spec")
await _send_spec_file(message, result, session["id"],
result.failure or "razón desconocida")
await message.reply_text(_claims_message(result))
async def cmd_short_spec(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Devuelve el último shot spec como fichero, para editarlo a mano y
volver a renderizar sin pagar otra generación."""
@@ -594,7 +603,9 @@ async def cmd_short_spec(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await update.message.reply_document(
document=io.BytesIO(output["content"].encode("utf-8")),
filename=f"short_{session['id']}_spec.json",
caption=f"🎬 Shot spec — {session['topic']}\n{created} UTC",
caption=f"🎬 Shot spec — {session['topic']}\n{created} UTC\n\n"
f"Edítalo y mándamelo de vuelta como fichero para "
f"re-renderizar sin pagar otra generación.",
)
except Exception as e:
logger.error("short_spec failed", error=str(e))
@@ -603,6 +614,92 @@ async def cmd_short_spec(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await db_conn.close()
#: Tope de tamaño de un spec adjunto. El de Socorro son ~6 KB; 256 KB ya no es
#: un shot spec, es otra cosa que ha llegado aquí por accidente.
MAX_SPEC_FILE_BYTES = 256 * 1024
_SPEC_FILENAME = re.compile(r"short_(\d+)")
def _session_from_filename(name: Optional[str]) -> Optional[int]:
"""La sesión que declara el nombre del fichero, si la declara.
`/short_spec` nombra el fichero `short_{id}_spec.json` y Telegram conserva
el nombre al reenviarlo, así que el id del nombre manda sobre la sesión
activa: el spec editado es de ESA sesión aunque el chat haya investigado
otra cosa entre medias.
"""
match = _SPEC_FILENAME.search(name or "")
return int(match.group(1)) if match else None
async def handle_spec_document(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Un `.json` adjunto es un shot spec editado: validar y re-renderizar.
La vuelta de `/short_spec`. Sin LLM en este camino — renderizar de nuevo
es gratis, la parte cara fue la generación.
"""
if not is_authorized(update.effective_user.id):
return
doc = update.message.document
if not doc:
return
if (doc.file_size or 0) > MAX_SPEC_FILE_BYTES:
await update.message.reply_text(
"Ese fichero pesa demasiado para ser un shot spec.")
return
db_conn = await get_db()
db = ResearchDB(db_conn)
try:
tg_file = await doc.get_file()
raw = bytes(await tg_file.download_as_bytearray())
try:
spec = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as e:
await update.message.reply_text(
f"No puedo leer ese JSON: {str(e)[:200]}")
return
if not isinstance(spec, dict) or "shots" not in spec:
await update.message.reply_text(
"Ese JSON no parece un shot spec (no tiene `shots`). El de "
"esta sesión te lo da /short_spec.")
return
session = None
declared = _session_from_filename(doc.file_name)
if declared:
session = await db.get_session(declared)
if not session:
session = await _session_row(db_conn, update.effective_chat.id)
if not session:
await update.message.reply_text(
"No hay sesiones. Empieza con /research <tema>")
return
from src.generator.short import ShortProducer, ShortsDisabled
reporter = ProgressReporter(update.message)
await reporter.start(
f"🎞 Re-renderizando spec editado — sesión #{session['id']}: "
f"{session['topic']}")
producer = ShortProducer(db, ContentProcessor(db, OllamaClient()))
try:
result = await producer.rerender(session["id"], spec, reporter.update)
except ShortsDisabled:
await reporter.done(
"🚫 Los Shorts están desactivados (`SHORTSMITH_ENABLED=false`).")
return
await _deliver_short(update.message, reporter, result, session)
except Exception as e:
logger.error("Spec re-render failed", error=str(e), exc_info=True)
await update.message.reply_text(f"❌ Re-render fallido: {str(e)[:300]}")
finally:
await db_conn.close()
async def cmd_upload_short(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""`/upload_short [force]` — sube a YouTube el Short ya renderizado.
@@ -658,6 +755,17 @@ async def cmd_upload_short(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
f"`/generate short_en`.", parse_mode=ParseMode.MARKDOWN)
return
if (_video_predates_spec(video_path.stat().st_mtime,
output["created_at"]) and not force):
await update.message.reply_text(
"⚠️ El vídeo en disco es ANTERIOR al último spec guardado: se "
"regeneró el spec pero el render no llegó a dejar vídeo nuevo. "
"Subirlo pondría metadatos nuevos a un vídeo viejo.\n\n"
"Re-renderiza con `/generate short_en` (o mándame el spec como "
"fichero `.json`). `/upload_short force` lo sube igualmente.",
parse_mode=ParseMode.MARKDOWN)
return
if output.get("published_url") and not force:
await update.message.reply_text(
f"Este Short ya está subido:\n{output['published_url']}\n\n"
@@ -700,6 +808,23 @@ async def cmd_upload_short(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await db_conn.close()
#: Margen para relojes y redondeos del filesystem. Un render legítimo escribe
#: el MP4 ~1 minuto DESPUÉS de guardarse el spec; el caso malo (spec regenerado
#: con render fallido) deja un vídeo horas más viejo, no segundos.
_STALE_VIDEO_MARGIN_S = 5.0
def _video_predates_spec(video_mtime: float, spec_created_at: float) -> bool:
"""True si el MP4 en disco es anterior al último spec guardado.
Pasa cuando `/generate short_en` se repite y el render falla: `produce`
guarda el spec ANTES de renderizar, así que en disco queda el vídeo de la
vuelta anterior. Subirlo con los metadatos del spec nuevo es un mismatch
silencioso — el vídeo dice una cosa y el título otra.
"""
return video_mtime + _STALE_VIDEO_MARGIN_S < spec_created_at
def _upload_message(video, metadata: dict, article_url: Optional[str]) -> str:
"""El parte de la subida. Texto plano: lleva el título del modelo, y un
Markdown desbalanceado haría que Telegram rechazara el mensaje que trae el
@@ -1714,6 +1839,9 @@ def create_bot() -> Application:
app.add_handler(CommandHandler("finish", cmd_finish))
app.add_handler(CommandHandler("generate", cmd_generate))
app.add_handler(CommandHandler("short_spec", cmd_short_spec))
# Un .json adjunto es un spec editado que vuelve para re-renderizarse.
app.add_handler(MessageHandler(filters.Document.FileExtension("json"),
handle_spec_document))
app.add_handler(CommandHandler("upload_short", cmd_upload_short))
app.add_handler(CommandHandler("sources", cmd_sources))
app.add_handler(CommandHandler("outputs", cmd_outputs))