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
+78 -7
View File
@@ -31,6 +31,7 @@ from src.generator.shortsmith import (
ShortsmithClient, ShortsmithError, ShortsmithRejected, ShortsmithUnavailable,
)
from src.generator.shortspec import ShortSpecWriter, SpecWriteFailed
from src.generator.spec_contract import SpecInvalid, editorial_notes, validate_spec
from src.llm import get_anthropic_client
logger = structlog.get_logger()
@@ -217,6 +218,83 @@ class ShortProducer:
logger.warning("No se pudo guardar el spec en outputs", error=str(e))
# 6. Render.
await self._render_guarded(result, session_id, progress_callback)
if result.failure:
logger.warning("Short sin vídeo", session_id=session_id, why=result.failure)
logger.info("Short producido", session_id=session_id,
seconds=round(time.monotonic() - started, 1),
video=result.video_path, cost=round(result.cost_usd, 4))
return result
async def rerender(self, session_id: int, spec: dict,
progress_callback: Optional[Callable[[str], Any]] = None
) -> ShortResult:
"""Renderiza un spec editado a mano, sin pagar otra generación.
Es la vuelta de `/short_spec`: el fichero sale, se retoca, y se manda
de nuevo. Cero LLM en este camino — se valida contra el contrato vivo,
se re-comprueba el fundamento (las cadenas han cambiado y el informe no
es decorativo) y se renderiza. El spec editado se guarda como output
nuevo ANTES del render, por la misma razón que en `produce` y por una
más: los metadatos de `/upload_short` salen del último spec guardado, y
tienen que describir el vídeo que de verdad se renderizó.
"""
if not settings.shortsmith_enabled:
raise ShortsDisabled(
"SHORTSMITH_ENABLED=false — el renderizador está apagado a propósito")
session = await self.db.get_session(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
topic = session["topic"]
result = ShortResult(topic=topic, spec=spec)
# 1. El contrato, en vivo — las mismas rutas verbatim que ve el modelo.
templates = await self.client.templates()
try:
validate_spec(spec, templates)
except SpecInvalid as e:
result.failure = ("El spec editado no pasa el contrato: "
+ "; ".join(e.errors[:6]))
logger.warning("Rerender rechazado por el contrato",
session_id=session_id, errors=e.errors[:6])
return result
result.notes = editorial_notes(spec)
result.title = spec.get("meta", {}).get("title", topic)
result.duration_s = sum(s.get("duration", 0) for s in spec["shots"])
result.article_url = await self.db.get_article_url(session_id)
# 2. Fundamento, otra vez: la edición pudo meter una cifra nueva.
await _report(progress_callback, "🔍 Checking claims against sources…")
chunks = await self.processor.rag_chunks(
session_id, f"{topic} key facts figures dates quotes witnesses",
top_k=CONTEXT_CHUNKS)
if chunks:
result.grounding = check_grounding(spec, chunks)
else:
# Sesión purgada o sin procesar: se renderiza igual, pero el
# informe tiene que decir que esta vez no hubo contra qué mirar.
result.notes.append("sin chunks en la sesión: el fundamento del "
"spec editado NO se ha comprobado")
# 3. Guardar antes de renderizar.
try:
await self.db.save_output(session_id, OutputType.SHORT_EN,
result.spec_json)
except Exception as e:
logger.warning("No se pudo guardar el spec editado", error=str(e))
await self._render_guarded(result, session_id, progress_callback)
logger.info("Short re-renderizado", session_id=session_id,
video=result.video_path, failure=result.failure)
return result
async def _render_guarded(self, result: ShortResult, session_id: int,
progress_callback: Optional[Callable[[str], Any]]
) -> None:
"""`_render` con los fallos convertidos en `result.failure`."""
try:
await self._render(result, session_id, progress_callback)
except ShortsmithRejected as e:
@@ -232,13 +310,6 @@ class ShortProducer:
except OSError as e:
result.failure = f"no se pudo guardar el vídeo: {e}"
if result.failure:
logger.warning("Short sin vídeo", session_id=session_id, why=result.failure)
logger.info("Short producido", session_id=session_id,
seconds=round(time.monotonic() - started, 1),
video=result.video_path, cost=round(result.cost_usd, 4))
return result
async def _render(self, result: ShortResult, session_id: int,
progress_callback: Optional[Callable[[str], Any]]) -> None:
job_id = await self.client.render(result.spec)