refactor(contrato): el sobre se lee de GET /limits, ya no se copia a mano
Build & Deploy ResearchOwl / build-and-push (push) Successful in 17s

Las props se validaban contra `GET /templates` y los presets contra `GET /audio`,
pero el sobre —resolución, fps, duraciones, topes— estaba escrito aquí. El
comentario decía «pequeño, estable»; el 2026-09-01 shortsmith quitó 1920x1080
(358eec9) porque ninguna plantilla lo componía y esta lista siguió aceptándolo.
Un spec apaisado pasaba la validación local y lo rechazaba el servidor: una
generación pagada y tirada.

Ningún test lo veía, y no por falta de tests: los dos lados eran coherentes
consigo mismos. Sólo se ve comparando con lo servido, que es lo que hace ahora
`test_shortsmith_live.py`.

Lo que era el espejo sobrevive como `BASELINE_LIMITS`, y sólo como suelo para
cuando el servicio no conteste. Estrecho a propósito, igual que
`BASELINE_PRESET_NAMES`: un fallback nunca puede aceptar lo que el contrato vivo
rechaza. Con eso el fallo queda tapado aunque shortsmith no llegue a desplegarse,
y contra un shortsmith anterior al endpoint el 404 cae al suelo sin error.

`_limit()` resuelve clave a clave: un sobre a medias, o uno más nuevo con claves
que aquí todavía no se leen, deja las demás en su sitio en vez de tirar la
validación entera al suelo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EHakatofHeJAzdXL26Q5bq
This commit is contained in:
ChemaVX
2026-09-02 07:50:43 +00:00
co-authored by Claude Opus 5
parent 7c18ae68f1
commit 7b79e14fdc
6 changed files with 251 additions and 53 deletions
+17 -3
View File
@@ -32,7 +32,9 @@ from src.generator.shortsmith import (
ShortsmithUnavailable,
)
from src.generator.shortspec import ShortSpecWriter, SpecWriteFailed
from src.generator.spec_contract import SpecInvalid, editorial_notes, validate_spec
from src.generator.spec_contract import (
BASELINE_LIMITS, SpecInvalid, editorial_notes, validate_spec,
)
from src.llm import get_anthropic_client
logger = structlog.get_logger()
@@ -148,6 +150,16 @@ class ShortProducer:
logger.warning("GET /audio falló — paleta base", error=str(e))
return dict(BASELINE_PRESETS)
async def _limits(self) -> dict:
"""El sobre del contrato, en vivo. Tampoco tumba nada: sin él se valida
contra el suelo, que es más estrecho que cualquier shortsmith y por eso
no puede dejar pasar un spec que el servidor fuera a rechazar."""
try:
return await self.client.limits()
except Exception as e:
logger.warning("GET /limits falló — sobre base", error=str(e))
return dict(BASELINE_LIMITS)
# --- pipeline -----------------------------------------------------------
async def produce(self, session_id: int,
@@ -186,6 +198,7 @@ class ShortProducer:
# ofrece la paleta base y el render sale igual.
templates = await self.client.templates()
presets = await self._presets()
limits = await self._limits()
# 3. El spec.
started = time.monotonic()
@@ -193,7 +206,7 @@ class ShortProducer:
writer = ShortSpecWriter(
llm_call, templates,
refresh_templates=lambda: self.client.templates(refresh=True),
presets=presets)
presets=presets, limits=limits)
try:
written = await writer.write(
topic, context, article_url=result.article_url,
@@ -269,8 +282,9 @@ class ShortProducer:
# de retoque para el que existe este camino.
templates = await self.client.templates()
presets = await self._presets()
limits = await self._limits()
try:
validate_spec(spec, templates, presets=presets)
validate_spec(spec, templates, presets=presets, limits=limits)
except SpecInvalid as e:
result.failure = ("El spec editado no pasa el contrato: "
+ "; ".join(e.errors[:6]))
+35
View File
@@ -24,6 +24,7 @@ import aiohttp
import structlog
from src.config import settings, SAFE_ACCEPT_ENCODING
from src.generator.spec_contract import BASELINE_LIMITS
logger = structlog.get_logger()
@@ -97,6 +98,7 @@ class JobResult:
#: mitad de una run.
_templates_cache: dict[str, dict[str, Any]] = {}
_presets_cache: dict[str, dict[str, str]] = {}
_limits_cache: dict[str, dict[str, Any]] = {}
class ShortsmithClient:
@@ -178,6 +180,39 @@ class ShortsmithClient:
logger.info("shortsmith audio presets fetched", presets=sorted(data))
return data
async def limits(self, refresh: bool = False) -> dict[str, Any]:
"""El sobre del contrato: resoluciones, fps, duraciones y topes.
La tercera mitad del contrato vivo, y la que faltaba. Las props se leen
de `/templates` desde el principio y los presets de `/audio`; el sobre
se copiaba a mano en `spec_contract.BASELINE_LIMITS`, y la copia derivó
en cuanto shortsmith quitó 1920x1080. Un 404 es un shortsmith anterior
al endpoint y devuelve el suelo, sin error — fallbacks siempre.
"""
if not refresh and self.base_url in _limits_cache:
return _limits_cache[self.base_url]
try:
async with self._session(30) as sess:
async with sess.get(f"{self.base_url}/limits") as resp:
if resp.status == 404:
data = dict(BASELINE_LIMITS)
elif resp.status != 200:
body = await resp.text()
raise ShortsmithError(
f"GET /limits devolvió {resp.status}: {body[:200]}")
else:
payload = await resp.json()
data = payload if isinstance(payload, dict) and payload \
else dict(BASELINE_LIMITS)
except aiohttp.ClientError as e:
raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e
except asyncio.TimeoutError as e:
raise ShortsmithUnavailable("shortsmith no respondió a /limits") from e
_limits_cache[self.base_url] = data
logger.info("shortsmith limits fetched",
resolutions=data.get("resolutions"))
return data
async def render(self, spec: dict[str, Any]) -> str:
"""`POST /render`. Devuelve el job_id. 422 -> ShortsmithRejected."""
try:
+7 -2
View File
@@ -495,9 +495,13 @@ LLMCall = Callable[[str, str], Awaitable[str]]
class ShortSpecWriter:
def __init__(self, llm_call: LLMCall, templates: dict[str, dict],
refresh_templates: Optional[Callable[[], Awaitable[dict]]] = None,
presets: Optional[dict[str, str]] = None):
presets: Optional[dict[str, str]] = None,
limits: Optional[dict] = None):
self.llm_call = llm_call
self.templates = templates
#: El sobre vivo de `GET /limits`. Sin él se valida contra el suelo,
#: que nunca acepta nada que un shortsmith viejo fuera a rechazar.
self.limits = limits
#: Se vuelve a pedir el contrato si una validación falla: el
#: renderizador puede haberse actualizado a mitad de la run.
self.refresh_templates = refresh_templates
@@ -560,7 +564,8 @@ class ShortSpecWriter:
last_spec = spec
try:
validate_spec(spec, self.templates, presets=self.presets)
validate_spec(spec, self.templates, presets=self.presets,
limits=self.limits)
except SpecInvalid as e:
history.append(e.errors)
feedback = _format_errors(e.errors)
+98 -48
View File
@@ -1,6 +1,6 @@
"""El contrato del spec, leído — no copiado — de shortsmith.
Dos cosas, las dos guiadas por lo que publica `GET /templates`:
Dos cosas, guiadas por lo que shortsmith publica:
* `describe_templates()` — el contrato en prosa compacta, para meterlo en el
prompt. Añadir una plantilla en shortsmith la deja descrita aquí sola.
@@ -10,13 +10,18 @@ Dos cosas, las dos guiadas por lo que publica `GET /templates`:
comprobador de fundamento va entre la validación y el render: mandar el spec
a `POST /render` para validarlo ya encolaría el render.
La mitad de props del contrato NO vive aquí: se valida contra el esquema
recibido. Lo único escrito a mano es el sobre (version/meta/audio/shots), que
es pequeño, estable, y está anotado con la regla equivalente de
`shortsmith/src/shortsmith/spec.py`. Las reglas de pydantic que cruzan campos
(los límites de MapBounds, "3 barras no dejan sitio para una cita") NO se
replican: las coge el 422 del servidor al enviar, y ese camino también está
cubierto.
**Nada del contrato se escribe aquí.** Las props se validan contra el esquema de
`GET /templates`, los presets contra `GET /audio` y el sobre — resolución, fps,
duraciones, topes — contra `GET /limits`. De cada uno hay una base local, pero
sólo como suelo para cuando el servicio no conteste, nunca como fuente.
El sobre **sí** estuvo escrito a mano, y por eso hay tres endpoints en vez de
dos: la copia decía «pequeña y estable» hasta que shortsmith quitó 1920x1080 y
esta lista siguió aceptándolo días. Ver `BASELINE_LIMITS`.
Las reglas de pydantic que cruzan campos (los límites de MapBounds, "3 barras no
dejan sitio para una cita") NO se replican: las coge el 422 del servidor al
enviar, y ese camino también está cubierto.
"""
from __future__ import annotations
@@ -40,14 +45,48 @@ __all__ = [
"TARGET_MAX_DURATION",
]
# Límites del sobre — espejo de shortsmith/spec.py.
RESOLUTIONS = {(1080, 1920), (1920, 1080)}
FPS_VALUES = {24, 25, 30, 60}
MIN_SHOT_DURATION = 0.5
MIN_TOTAL_DURATION = 5.0
MAX_TOTAL_DURATION = 180.0 # límite duro de YouTube Shorts
MAX_SHOTS = 64
META_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
#: El sobre que aceptaba shortsmith antes de que `GET /limits` existiera, y el
#: suelo cuando el endpoint no contesta.
#:
#: Esto **era** un espejo a mano, y se rompió como se rompen los espejos: el
#: 2026-09-01 shortsmith quitó 1920x1080 (`358eec9`) porque ninguna plantilla lo
#: componía, y esta lista siguió aceptándolo. Un spec que aquí pasaba, allí se
#: rechazaba: una generación pagada y tirada. Nadie se equivocó al escribirlo —
#: ése es el argumento. Ahora el sobre se lee de `GET /limits` igual que las
#: props se leen de `GET /templates`, y esto es sólo el suelo.
#:
#: Conservador a propósito, como `BASELINE_PRESET_NAMES`: nunca acepta nada que
#: un shortsmith viejo fuera a rechazar. Por eso la resolución es la estrecha.
BASELINE_LIMITS: dict[str, Any] = {
"resolutions": [[1080, 1920]],
"fps": [24, 25, 30, 60],
"meta_id_pattern": r"^[a-z0-9][a-z0-9_-]{0,63}$",
"shot_duration": {"min": 0.5, "max": 180.0},
"total_duration": {"min": 5.0, "max": 180.0}, # 180 s: límite de Shorts
"shots": {"min": 1, "max": 64},
"max_narration_chars": 320,
"max_silence_ranges": 16,
}
def _limit(limits: Optional[dict], *path: str) -> Any:
"""Un número del sobre vivo, con el suelo debajo.
Se resuelve clave a clave y no de golpe: un shortsmith que publique un sobre
a medias — o uno más nuevo con una clave que aquí todavía no se lee — deja
las demás en su sitio en vez de tirar la validación entera al suelo.
"""
for source in (limits, BASELINE_LIMITS):
node: Any = source
for key in path:
if not isinstance(node, dict) or key not in node:
node = None
break
node = node[key]
if node is not None:
return node
raise KeyError(f"el sobre no publica {'.'.join(path)}")
#: El objetivo editorial, que NO es el techo del contrato. 180 s es lo que el
#: renderizador acepta; 20-45 s es lo que se ve entero.
@@ -296,24 +335,27 @@ def _check_cross_field(template: str, props: Any, path: str) -> list[str]:
# --- el sobre ---------------------------------------------------------------
def _check_meta(meta: Any) -> list[str]:
def _check_meta(meta: Any, limits: Optional[dict] = None) -> list[str]:
if not isinstance(meta, dict):
return ["meta: se esperaba un objeto"]
errors = []
spec_id = meta.get("id")
if not isinstance(spec_id, str) or not META_ID.match(spec_id):
pattern = re.compile(_limit(limits, "meta_id_pattern"))
if not isinstance(spec_id, str) or not pattern.match(spec_id):
errors.append("meta.id: minúsculas, dígitos, '_' y '-', empezando por "
f"letra o dígito, hasta 64 caracteres (llegó {spec_id!r})")
if not isinstance(meta.get("title"), str) or not meta.get("title"):
errors.append("meta.title: obligatorio y no vacío")
width = meta.get("width", 1080)
height = meta.get("height", 1920)
if (width, height) not in RESOLUTIONS:
allowed = ", ".join(f"{w}x{h}" for w, h in sorted(RESOLUTIONS))
resolutions = [tuple(pair) for pair in _limit(limits, "resolutions")]
if (width, height) not in resolutions:
allowed = ", ".join(f"{w}x{h}" for w, h in sorted(resolutions))
errors.append(f"meta: {width}x{height} no es una resolución admitida ({allowed})")
if meta.get("fps", 30) not in FPS_VALUES:
fps_values = _limit(limits, "fps")
if meta.get("fps", 30) not in fps_values:
errors.append(f"meta.fps: {meta.get('fps')!r} no está entre "
f"{sorted(FPS_VALUES)}")
f"{sorted(fps_values)}")
for key in meta:
if key not in ("id", "title", "width", "height", "fps", "theme"):
errors.append(f"meta.{key}: campo no permitido")
@@ -327,7 +369,8 @@ BASELINE_PRESET_NAMES = ("sonar", "none")
def _check_audio(audio: Any, total: float,
presets: Optional[Iterable[str]] = None) -> list[str]:
presets: Optional[Iterable[str]] = None,
limits: Optional[dict] = None) -> list[str]:
if audio is None:
return []
if not isinstance(audio, dict):
@@ -340,8 +383,9 @@ def _check_audio(audio: Any, total: float,
silence = audio.get("silence", [])
if not isinstance(silence, list):
return errors + ["audio.silence: se esperaba una lista de pares [inicio, fin]"]
if len(silence) > 16:
errors.append(f"audio.silence: {len(silence)} rangos, el máximo es 16")
max_ranges = _limit(limits, "max_silence_ranges")
if len(silence) > max_ranges:
errors.append(f"audio.silence: {len(silence)} rangos, el máximo es {max_ranges}")
for i, rango in enumerate(silence):
if not (isinstance(rango, (list, tuple)) and len(rango) == 2
and all(isinstance(v, (int, float)) for v in rango)):
@@ -361,19 +405,17 @@ def _check_audio(audio: Any, total: float,
return errors
#: Tope de la narración de un shot, el mismo que aplica shortsmith. Rechazarla
#: aquí cuesta un reintento del modelo; rechazarla allí cuesta el render entero.
MAX_NARRATION_CHARS = 320
def _check_narration(narration: Any, path: str) -> list[str]:
def _check_narration(narration: Any, path: str,
limits: Optional[dict] = None) -> list[str]:
"""El tope de narración de un shot, el mismo que aplica shortsmith.
Rechazarla aquí cuesta un reintento del modelo; allí, el render entero."""
if narration is None:
return []
if not isinstance(narration, str):
return [f"{path}.narration: se esperaba texto"]
if len(narration) > MAX_NARRATION_CHARS:
return [f"{path}.narration: {len(narration)} caracteres, el máximo es "
f"{MAX_NARRATION_CHARS}"]
cap = _limit(limits, "max_narration_chars")
if len(narration) > cap:
return [f"{path}.narration: {len(narration)} caracteres, el máximo es {cap}"]
return []
@@ -386,15 +428,18 @@ def _total_duration(spec: dict) -> float:
def validate_spec(spec: Any, templates: dict[str, dict],
presets: Optional[Iterable[str]] = None) -> None:
presets: Optional[Iterable[str]] = None,
limits: Optional[dict] = None) -> None:
"""Lanza `SpecInvalid` con TODAS las rutas que fallan.
Se devuelven todos los errores de golpe a propósito: el bucle de reintento
se los da al modelo verbatim y arreglar cinco de una vez sale más barato
que cinco vueltas.
`presets` es la paleta viva de `GET /audio`; sin ella se valida contra la
paleta base, que nunca acepta nada que un shortsmith viejo no renderice.
Las tres mitades del contrato llegan vivas y ninguna se escribe aquí:
`templates` de `GET /templates`, `presets` de `GET /audio` y `limits` de
`GET /limits`. Sin cada una se cae a su base, que nunca acepta nada que un
shortsmith viejo no renderice.
"""
errors: list[str] = []
if not isinstance(spec, dict):
@@ -407,15 +452,18 @@ def validate_spec(spec: Any, templates: dict[str, dict],
errors.append(f"{key}: campo no permitido en la raíz "
"(las válidas son: version, meta, audio, shots)")
errors.extend(_check_meta(spec.get("meta")))
errors.extend(_check_meta(spec.get("meta"), limits))
shots = spec.get("shots")
if not isinstance(shots, list) or not shots:
errors.append("shots: hace falta al menos un shot")
raise SpecInvalid(errors)
if len(shots) > MAX_SHOTS:
errors.append(f"shots: {len(shots)} shots, el máximo es {MAX_SHOTS}")
max_shots = _limit(limits, "shots", "max")
if len(shots) > max_shots:
errors.append(f"shots: {len(shots)} shots, el máximo es {max_shots}")
shot_min = _limit(limits, "shot_duration", "min")
shot_max = _limit(limits, "shot_duration", "max")
known = ", ".join(sorted(templates))
for i, shot in enumerate(shots):
path = f"shots.{i}"
@@ -431,13 +479,13 @@ def validate_spec(spec: Any, templates: dict[str, dict],
if key not in ("template", "duration", "props", "narration"):
errors.append(f"{path}.{key}: campo no permitido "
"(las válidas son: template, duration, props, narration)")
errors.extend(_check_narration(shot.get("narration"), path))
errors.extend(_check_narration(shot.get("narration"), path, limits))
duration = shot.get("duration")
if not isinstance(duration, (int, float)) or isinstance(duration, bool):
errors.append(f"{path}.duration: obligatoria y numérica")
elif not MIN_SHOT_DURATION <= duration <= MAX_TOTAL_DURATION:
elif not shot_min <= duration <= shot_max:
errors.append(f"{path}.duration: {duration} fuera de "
f"[{MIN_SHOT_DURATION}, {MAX_TOTAL_DURATION}]")
f"[{shot_min}, {shot_max}]")
if "props" not in shot:
errors.append(f"{path}.props: falta y es obligatorio")
continue
@@ -450,13 +498,15 @@ def validate_spec(spec: Any, templates: dict[str, dict],
errors.extend(_check_cross_field(template, shot["props"], props_path))
total = _total_duration(spec)
if total < MIN_TOTAL_DURATION:
total_min = _limit(limits, "total_duration", "min")
total_max = _limit(limits, "total_duration", "max")
if total < total_min:
errors.append(f"shots: la duración total ({total:.2f}s) no llega al "
f"mínimo de {MIN_TOTAL_DURATION}s")
if total > MAX_TOTAL_DURATION:
f"mínimo de {total_min}s")
if total > total_max:
errors.append(f"shots: la duración total ({total:.2f}s) pasa del límite "
f"de {MAX_TOTAL_DURATION}s")
errors.extend(_check_audio(spec.get("audio"), total, presets))
f"de {total_max}s")
errors.extend(_check_audio(spec.get("audio"), total, presets, limits))
if errors:
raise SpecInvalid(errors)