feat(short): generación y render de Shorts vía shortsmith
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s

Añade /generate short_en y /short_spec. El pipeline genera un shot spec
con Haiku, verifica cada cifra, fecha y cita contra los chunks de la
sesión, lo renderiza en shortsmith y entrega el MP4 por Telegram junto
a un informe de claims.

- ShortsmithClient con sondeo y fallback al spec JSON si el render falla
- Contrato de plantillas obtenido de GET /templates, no codificado
- Comprobación de fundamento determinista, sin LLM
- outputs.published_url para enlazar el artículo de Ghost
- Normalización de comillas rectas a tipográficas (ver KNOWN-ISSUES.md)

Lo que no aparece en los chunks se contrasta contra el ejemplo del
prompt: si casa ahí es fuga, no invención, y se informa como tal. El
purgado de sesiones se lleva también su MP4.

La subida a YouTube queda fuera a propósito: fase 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-08-01 21:55:42 +00:00
co-authored by Claude Opus 5
parent 8b81ef87e4
commit 20c8d03aa7
27 changed files with 4350 additions and 23 deletions
+234
View File
@@ -0,0 +1,234 @@
"""Cliente HTTP de shortsmith — el renderizador de Shorts.
shortsmith vive en su propio repo y su propio pod (`shortsmith-svc`), y expone
cuatro cosas: el contrato (`GET /templates`), el envío (`POST /render`), el
estado (`GET /jobs/{id}`) y el MP4 (`GET /jobs/{id}/video`).
Regla de capas (convención del repo): esto vive en `generator/` y NO importa
nada de `bot/`. El progreso sale por un callable genérico.
El contrato NO se copia aquí. `GET /templates` publica el esquema de props de
cada plantilla y es la única fuente de verdad: añadir una plantilla en
shortsmith la deja disponible al generador sin tocar este repo. Copiar los
esquemas crearía una segunda fuente que se desincroniza en silencio — la misma
clase de fallo que el desfase de versión de ffmpeg que provocó el OOM de v1.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
import aiohttp
import structlog
from src.config import settings, SAFE_ACCEPT_ENCODING
logger = structlog.get_logger()
#: Cadencia del sondeo. Un Short de 42 s tarda ~32 s en renderizar y el techo
#: de 180 s del contrato tarda ~138 s: 2 s da una barra de progreso viva sin
#: martillear el servicio.
POLL_INTERVAL = 2.0
#: Techo del sondeo. Más allá de esto el job está atascado, no lento.
POLL_CEILING = 600.0
QUEUED, RUNNING, DONE, ERROR = "queued", "running", "done", "error"
__all__ = [
"ShortsmithClient",
"ShortsmithError",
"ShortsmithUnavailable",
"ShortsmithRejected",
"JobResult",
"POLL_INTERVAL",
"POLL_CEILING",
]
class ShortsmithError(Exception):
"""Cualquier fallo hablando con shortsmith."""
class ShortsmithUnavailable(ShortsmithError):
"""No se pudo contactar con el servicio (red, DNS, timeout de conexión)."""
class ShortsmithRejected(ShortsmithError):
"""422: el spec no pasó la validación del servidor.
`errors` son los errores de pydantic tal cual los devuelve shortsmith, con
su `loc` completo. Se propagan sin parafrasear: las rutas exactas
(`shots.0.radar_sweep.props.sweeeps`) son lo más útil que se le puede dar
al modelo para corregir.
"""
def __init__(self, errors: list[dict[str, Any]]):
self.errors = errors
super().__init__(f"shortsmith rechazó el spec ({len(errors)} error/es)")
@dataclass
class JobResult:
job_id: str
status: str
progress: float = 0.0
warnings: list[dict[str, Any]] = field(default_factory=list)
error: Optional[str] = None
@property
def ok(self) -> bool:
return self.status == DONE
#: Caché del contrato para la vida del proceso (clave: base_url). Se refresca a
#: petición cuando una validación falla, por si el renderizador se actualizó a
#: mitad de una run.
_templates_cache: dict[str, dict[str, Any]] = {}
class ShortsmithClient:
def __init__(self, base_url: str | None = None, timeout: float | None = None):
self.base_url = (base_url or settings.shortsmith_url).rstrip("/")
self.timeout = timeout if timeout is not None else settings.shortsmith_timeout
# --- transporte ---------------------------------------------------------
def _session(self, total: float) -> aiohttp.ClientSession:
# Accept-Encoding explícito SIEMPRE: el default de aiohttp anuncia br si
# hay backend instalado y su decode está roto en 3.14 (KNOWN-ISSUES.md).
return aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=total),
headers={"Accept-Encoding": SAFE_ACCEPT_ENCODING},
)
async def health(self) -> dict[str, Any]:
"""`GET /healthz`. Sirve de comprobación previa barata."""
try:
async with self._session(10) as sess:
async with sess.get(f"{self.base_url}/healthz") as resp:
if resp.status != 200:
raise ShortsmithError(f"healthz devolvió {resp.status}")
return await resp.json()
except aiohttp.ClientError as e:
raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e
except asyncio.TimeoutError as e:
raise ShortsmithUnavailable("shortsmith no respondió a healthz") from e
async def templates(self, refresh: bool = False) -> dict[str, Any]:
"""El contrato: nombre de plantilla -> JSON Schema de sus props."""
if not refresh and self.base_url in _templates_cache:
return _templates_cache[self.base_url]
try:
async with self._session(30) as sess:
async with sess.get(f"{self.base_url}/templates") as resp:
if resp.status != 200:
body = await resp.text()
raise ShortsmithError(
f"GET /templates devolvió {resp.status}: {body[:200]}")
data = await resp.json()
except aiohttp.ClientError as e:
raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e
except asyncio.TimeoutError as e:
raise ShortsmithUnavailable("shortsmith no respondió a /templates") from e
_templates_cache[self.base_url] = data
logger.info("shortsmith templates fetched", n=len(data))
return data
async def render(self, spec: dict[str, Any]) -> str:
"""`POST /render`. Devuelve el job_id. 422 -> ShortsmithRejected."""
try:
async with self._session(60) as sess:
async with sess.post(f"{self.base_url}/render", json=spec) as resp:
if resp.status == 422:
detail = (await resp.json()).get("detail")
raise ShortsmithRejected(
detail if isinstance(detail, list) else [{"msg": str(detail)}])
if resp.status not in (200, 202):
body = await resp.text()
raise ShortsmithError(
f"POST /render devolvió {resp.status}: {body[:300]}")
data = await resp.json()
except aiohttp.ClientError as e:
raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e
except asyncio.TimeoutError as e:
raise ShortsmithUnavailable("shortsmith no respondió a /render") from e
job_id = data.get("job_id")
if not job_id:
raise ShortsmithError(f"/render no devolvió job_id: {str(data)[:200]}")
logger.info("shortsmith job queued", job_id=job_id)
return job_id
async def job(self, job_id: str) -> JobResult:
try:
async with self._session(30) as sess:
async with sess.get(f"{self.base_url}/jobs/{job_id}") as resp:
if resp.status == 404:
raise ShortsmithError(f"job {job_id} no existe")
if resp.status != 200:
body = await resp.text()
raise ShortsmithError(
f"GET /jobs/{job_id} devolvió {resp.status}: {body[:200]}")
data = await resp.json()
except aiohttp.ClientError as e:
raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e
except asyncio.TimeoutError as e:
raise ShortsmithUnavailable(f"shortsmith no respondió por el job {job_id}") from e
return JobResult(
job_id=data.get("job_id", job_id),
status=data.get("status", ""),
progress=data.get("progress") or 0.0,
warnings=data.get("warnings") or [],
error=data.get("error"),
)
async def poll(self, job_id: str,
on_progress: Optional[Callable[[float, str], Any]] = None,
interval: float = POLL_INTERVAL,
ceiling: float | None = None) -> JobResult:
"""Sondea hasta done/error. Devuelve el JobResult final.
Un job en `error` se DEVUELVE, no se lanza: el caller decide (el spec
sigue valiendo aunque el render falle). Solo el atasco y los fallos de
transporte lanzan.
"""
deadline = time.monotonic() + min(
ceiling if ceiling is not None else POLL_CEILING, self.timeout)
last_reported = -1.0
while True:
result = await self.job(job_id)
if on_progress and result.progress != last_reported:
last_reported = result.progress
try:
await _maybe_await(on_progress(result.progress, result.status))
except Exception as e: # el progreso nunca tumba un render
logger.warning("shortsmith progress callback falló", error=str(e))
if result.status in (DONE, ERROR):
return result
if time.monotonic() >= deadline:
raise ShortsmithError(
f"job {job_id} sigue en '{result.status}' pasados "
f"{min(ceiling if ceiling is not None else POLL_CEILING, self.timeout):.0f}s "
"— está atascado, no lento")
await asyncio.sleep(interval)
async def fetch_video(self, job_id: str) -> bytes:
try:
async with self._session(self.timeout) as sess:
async with sess.get(f"{self.base_url}/jobs/{job_id}/video") as resp:
if resp.status != 200:
body = await resp.text()
raise ShortsmithError(
f"GET /jobs/{job_id}/video devolvió {resp.status}: {body[:200]}")
return await resp.read()
except aiohttp.ClientError as e:
raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e
except asyncio.TimeoutError as e:
raise ShortsmithUnavailable(f"descarga del vídeo {job_id} agotó el tiempo") from e
async def _maybe_await(value):
if asyncio.iscoroutine(value):
return await value
return value