feat(short): generación y render de Shorts vía shortsmith
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
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:
@@ -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
|
||||
Reference in New Issue
Block a user