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 **** 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)
+40
View File
@@ -56,6 +56,46 @@ async def test_healthz_and_templates():
assert sin_tope == [], f"campos con x-fits pero sin x-fits-hard: {sin_tope}"
@pytest.mark.asyncio
async def test_the_envelope_served_is_the_envelope_validated_against():
"""El sobre, contra el servicio de verdad — que es donde derivó.
La copia local decía «pequeña y estable» y aceptaba 1920x1080 días después
de que shortsmith dejara de renderizarlo. Un test de unidad no podía verlo:
los dos lados eran coherentes consigo mismos. Esto compara con el servido.
Contra un shortsmith anterior a `GET /limits` el cliente devuelve el suelo
sin error, y entonces esto sólo comprueba que el suelo es el estrecho que
es exactamente lo que se quiere de un fallback.
"""
from src.generator.spec_contract import BASELINE_LIMITS, validate_spec, SpecInvalid
client = ShortsmithClient(LIVE_URL)
limits = await client.limits(refresh=True)
resoluciones = [tuple(pair) for pair in limits["resolutions"]]
assert (1080, 1920) in resoluciones, "el servicio ya no acepta el vertical"
# Lo que el sobre NO trae, el validador local tiene que rechazarlo. Si
# shortsmith deja de aceptar una resolución, esto falla el día que pasa y no
# la generación siguiente.
spec = json.loads(EXAMPLE.read_text())
templates = await client.templates(refresh=True)
for size in [(1920, 1080), (1080, 1080)]:
if size in resoluciones:
continue
doc = json.loads(json.dumps(spec))
doc["meta"]["width"], doc["meta"]["height"] = size
with pytest.raises(SpecInvalid):
validate_spec(doc, templates, limits=limits)
# Y el suelo nunca puede ser más ancho que lo servido: si lo fuera, un
# shortsmith caído dejaría pasar specs que el vivo rechaza.
suelo = {tuple(pair) for pair in BASELINE_LIMITS["resolutions"]}
assert suelo <= set(resoluciones), (
f"el suelo {sorted(suelo)} acepta más que el servicio {sorted(resoluciones)}")
@pytest.mark.asyncio
async def test_render_the_reference_example_end_to_end(tmp_path):
client = ShortsmithClient(LIVE_URL)
+54
View File
@@ -80,6 +80,12 @@ def spec_with(*shots, **meta):
}
def errors_of_with_limits(spec, limits):
with pytest.raises(SpecInvalid) as exc:
validate_spec(spec, TEMPLATES, limits=limits)
return exc.value.errors
def errors_of(spec, templates=None):
with pytest.raises(SpecInvalid) as exc:
validate_spec(spec, templates if templates is not None else TEMPLATES)
@@ -171,6 +177,54 @@ def test_resolution_must_be_a_shorts_one():
for e in errors_of(spec_with(shot(), width=800, height=600)))
def test_landscape_is_rejected_even_without_the_live_envelope():
"""El fallo que dio origen a `GET /limits`, clavado.
shortsmith quitó 1920x1080 en `358eec9` porque ninguna plantilla lo componía.
Esta copia siguió aceptándolo, así que un spec apaisado pasaba aquí y moría
en el servidor: una generación pagada y tirada. El suelo tiene que ser el
estrecho si alguien lo vuelve a ensanchar «por compatibilidad», esto falla.
"""
assert any("no es una resolución admitida" in e
for e in errors_of(spec_with(shot(), width=1920, height=1080)))
def test_the_live_envelope_wins_over_the_floor():
"""El suelo es suelo, no verdad: lo que diga `GET /limits` manda.
Con un shortsmith que publique el apaisado, el mismo spec pasa sin tocar una
línea de este repo que es exactamente lo que `GET /templates` hace ya con
una plantilla nueva.
"""
apaisado = spec_with(shot(duration=25.0), width=1920, height=1080)
with pytest.raises(SpecInvalid):
validate_spec(apaisado, TEMPLATES)
validate_spec(apaisado, TEMPLATES,
limits={"resolutions": [[1080, 1920], [1920, 1080]]})
def test_a_partial_envelope_falls_back_key_by_key():
"""Un sobre a medias no tira la validación entera al suelo.
Un shortsmith que publique menos claves de las que se leen aquí o una
respuesta recortada deja las demás en su sitio. Se resuelve clave a clave
justamente para eso.
"""
solo_fps = {"fps": [30]}
validate_spec(spec_with(shot(duration=25.0)), TEMPLATES, limits=solo_fps)
assert any("no es una resolución admitida" in e
for e in errors_of_with_limits(
spec_with(shot(), width=1920, height=1080), solo_fps))
def test_the_envelope_also_moves_the_caps_that_are_not_resolution():
"""Resolución es la que se rompió, pero el sobre entero llega vivo."""
corto = spec_with(shot(duration=6.0)) # 6 s: válido con el suelo
validate_spec(corto, TEMPLATES)
errors = errors_of_with_limits(corto, {"total_duration": {"min": 20.0, "max": 30.0}})
assert any("no llega al" in e and "20.0" in e for e in errors)
def test_total_duration_ceiling_is_the_contract_not_the_target():
"""45 s es el objetivo editorial; 180 s es el límite duro. Pasarse de 45 no
invalida el spec eso es una nota, no un error."""