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
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:
co-authored by
Claude Fable 5
parent
ec3e6d05c0
commit
cc1f0cab93
+78
-7
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user