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
+375
View File
@@ -0,0 +1,375 @@
"""El contrato del spec, leído — no copiado — de shortsmith.
Dos cosas, las dos guiadas por lo que publica `GET /templates`:
* `describe_templates()` — el contrato en prosa compacta, para meterlo en el
prompt. Añadir una plantilla en shortsmith la deja descrita aquí sola.
* `validate_spec()` — validación local ANTES de renderizar, con las mismas
rutas de error que devolvería el servidor
(`shots.0.radar_sweep.props.sweeeps`). Hace falta que sea local porque el
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.
"""
from __future__ import annotations
import re
from typing import Any, Optional
__all__ = [
"SpecInvalid",
"validate_spec",
"editorial_notes",
"describe_templates",
"TARGET_MIN_DURATION",
"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 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.
TARGET_MIN_DURATION = 20.0
TARGET_MAX_DURATION = 45.0
class SpecInvalid(Exception):
"""El spec no cumple el contrato. `errors` son rutas + motivo, verbatim."""
def __init__(self, errors: list[str]):
self.errors = errors
super().__init__("; ".join(errors[:5]) or "spec inválido")
# --- validación contra el esquema publicado ---------------------------------
def _resolve(schema: dict, defs: dict) -> dict:
ref = schema.get("$ref")
if not ref:
return schema
name = ref.rsplit("/", 1)[-1]
return defs.get(name, {})
def _type_ok(value: Any, expected: str) -> bool:
if expected == "object":
return isinstance(value, dict)
if expected == "array":
return isinstance(value, list)
if expected == "string":
return isinstance(value, str)
if expected == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if expected == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if expected == "boolean":
return isinstance(value, bool)
if expected == "null":
return value is None
return True
def _check(value: Any, schema: dict, path: str, defs: dict) -> list[str]:
"""Subconjunto de JSON Schema que emite pydantic. Devuelve rutas de error."""
schema = _resolve(schema, defs)
if not schema:
return []
if "anyOf" in schema:
for branch in schema["anyOf"]:
if not _check(value, branch, path, defs):
return []
kinds = [_resolve(b, defs).get("type", "?") for b in schema["anyOf"]]
return [f"{path}: no casa con ninguna alternativa ({', '.join(kinds)})"]
errors: list[str] = []
expected = schema.get("type")
if expected and not _type_ok(value, expected):
return [f"{path}: se esperaba {expected}, llegó {type(value).__name__}"]
if "enum" in schema and value not in schema["enum"]:
allowed = ", ".join(repr(v) for v in schema["enum"])
return [f"{path}: {value!r} no es un valor permitido ({allowed})"]
if isinstance(value, str):
if len(value) < schema.get("minLength", 0):
errors.append(f"{path}: cadena vacía o más corta que "
f"{schema['minLength']} caracteres")
if "maxLength" in schema and len(value) > schema["maxLength"]:
errors.append(f"{path}: {len(value)} caracteres, el máximo es "
f"{schema['maxLength']}")
if isinstance(value, (int, float)) and not isinstance(value, bool):
for key, ok, text in (
("minimum", lambda v, lim: v >= lim, ">="),
("maximum", lambda v, lim: v <= lim, "<="),
("exclusiveMinimum", lambda v, lim: v > lim, ">"),
("exclusiveMaximum", lambda v, lim: v < lim, "<"),
):
if key in schema and not ok(value, schema[key]):
errors.append(f"{path}: {value} debe ser {text} {schema[key]}")
if isinstance(value, list):
if "minItems" in schema and len(value) < schema["minItems"]:
errors.append(f"{path}: {len(value)} elementos, el mínimo es "
f"{schema['minItems']}")
if "maxItems" in schema and len(value) > schema["maxItems"]:
errors.append(f"{path}: {len(value)} elementos, el máximo es "
f"{schema['maxItems']}")
item_schema = schema.get("items")
if item_schema:
for i, item in enumerate(value):
errors.extend(_check(item, item_schema, f"{path}.{i}", defs))
if isinstance(value, dict):
properties = schema.get("properties", {})
for required in schema.get("required", []):
if required not in value:
errors.append(f"{path}.{required}: falta y es obligatorio")
if schema.get("additionalProperties") is False:
for key in value:
if key not in properties:
allowed = ", ".join(sorted(properties)) or "ninguna"
errors.append(f"{path}.{key}: campo no permitido "
f"(las válidas son: {allowed})")
for key, sub in properties.items():
if key in value:
errors.extend(_check(value[key], sub, f"{path}.{key}", defs))
return errors
def _check_props(props: Any, schema: dict, path: str) -> list[str]:
return _check(props, schema, path, schema.get("$defs", {}))
# --- el sobre ---------------------------------------------------------------
def _check_meta(meta: Any) -> 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):
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))
errors.append(f"meta: {width}x{height} no es una resolución admitida ({allowed})")
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)}")
for key in meta:
if key not in ("id", "title", "width", "height", "fps", "theme"):
errors.append(f"meta.{key}: campo no permitido")
return errors
def _check_audio(audio: Any, total: float) -> list[str]:
if audio is None:
return []
if not isinstance(audio, dict):
return ["audio: se esperaba un objeto"]
errors = []
if audio.get("preset", "sonar") not in ("sonar", "none"):
errors.append(f"audio.preset: {audio.get('preset')!r} no es 'sonar' ni 'none'")
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")
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)):
errors.append(f"audio.silence.{i}: se esperaba [inicio, fin] numérico")
continue
start, end = rango
if start < 0:
errors.append(f"audio.silence.{i}: empieza antes de 0")
if end <= start:
errors.append(f"audio.silence.{i}: el fin no va después del inicio")
if end > total + 1e-9:
errors.append(f"audio.silence.{i}: [{start}, {end}] se sale de la "
f"duración total ({total:.2f}s)")
for key in audio:
if key not in ("preset", "silence"):
errors.append(f"audio.{key}: campo no permitido")
return errors
def _total_duration(spec: dict) -> float:
total = 0.0
for shot in spec.get("shots") or []:
if isinstance(shot, dict) and isinstance(shot.get("duration"), (int, float)):
total += float(shot["duration"])
return total
def validate_spec(spec: Any, templates: dict[str, dict]) -> 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.
"""
errors: list[str] = []
if not isinstance(spec, dict):
raise SpecInvalid([f"el spec debe ser un objeto JSON, llegó {type(spec).__name__}"])
if spec.get("version") != 1:
errors.append(f"version: debe ser 1 (llegó {spec.get('version')!r})")
for key in spec:
if key not in ("version", "meta", "audio", "shots"):
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")))
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}")
known = ", ".join(sorted(templates))
for i, shot in enumerate(shots):
path = f"shots.{i}"
if not isinstance(shot, dict):
errors.append(f"{path}: se esperaba un objeto")
continue
template = shot.get("template")
if template not in templates:
errors.append(f"{path}.template: {template!r} no existe "
f"(las plantillas son: {known})")
continue
for key in shot:
if key not in ("template", "duration", "props"):
errors.append(f"{path}.{key}: campo no permitido "
"(las válidas son: template, duration, props)")
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:
errors.append(f"{path}.duration: {duration} fuera de "
f"[{MIN_SHOT_DURATION}, {MAX_TOTAL_DURATION}]")
if "props" not in shot:
errors.append(f"{path}.props: falta y es obligatorio")
continue
errors.extend(_check_props(shot["props"], templates[template],
f"{path}.{template}.props"))
total = _total_duration(spec)
if total < MIN_TOTAL_DURATION:
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:
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))
if errors:
raise SpecInvalid(errors)
def editorial_notes(spec: dict) -> list[str]:
"""Lo que no viola el contrato pero sí el encargo.
Va aparte de `validate_spec` justo porque no impide renderizar: un Short de
70 s se ve, sólo que peor. Se le devuelve al modelo como comentario una vez;
si insiste, se renderiza igual antes que tirar la generación a la basura.
"""
notes = []
total = _total_duration(spec)
if total < TARGET_MIN_DURATION:
notes.append(f"la duración total son {total:.1f}s y el objetivo es "
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
"queda corto, añade un shot o alarga los que tienes")
elif total > TARGET_MAX_DURATION:
notes.append(f"la duración total son {total:.1f}s y el objetivo es "
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
"recorta shots o acorta duraciones")
return notes
# --- el contrato en prosa, para el prompt -----------------------------------
def _describe_field(name: str, schema: dict, required: bool, defs: dict,
indent: str = " ") -> list[str]:
schema = _resolve(schema, defs)
bits: list[str] = []
if "anyOf" in schema:
inner = [b for b in schema["anyOf"] if _resolve(b, defs).get("type") != "null"]
if inner:
return _describe_field(name, inner[0], required, defs, indent) + \
[f"{indent} (opcional, admite null)"]
kind = schema.get("type", "?")
if "enum" in schema:
bits.append("uno de: " + ", ".join(str(v) for v in schema["enum"]))
elif kind == "array":
item = _resolve(schema.get("items", {}), defs)
bits.append("lista")
if "minItems" in schema or "maxItems" in schema:
bits.append(f"{schema.get('minItems', 0)}-{schema.get('maxItems', '')} elementos")
else:
bits.append(kind)
if schema.get("minLength"):
bits.append("no vacío")
if "maxLength" in schema:
bits.append(f"máx {schema['maxLength']} caracteres")
for key, text in (("minimum", ""), ("maximum", ""),
("exclusiveMinimum", ">"), ("exclusiveMaximum", "<")):
if key in schema:
bits.append(f"{text} {schema[key]}")
bits.append("OBLIGATORIO" if required else f"opcional (por defecto {schema.get('default')!r})")
lines = [f"{indent}{name}: {', '.join(bits)}"]
# Los objetos (sueltos o dentro de una lista) se despliegan: si no, el
# modelo ve "waypoints: lista" y no sabe que cada uno lleva label/lat/lon.
nested = _resolve(schema.get("items", {}), defs) if kind == "array" else schema
if nested.get("type") == "object" and nested.get("properties"):
nested_required = set(nested.get("required", []))
for sub, sub_schema in nested["properties"].items():
lines.extend(_describe_field(sub, sub_schema, sub in nested_required,
defs, indent + " "))
return lines
def describe_templates(templates: dict[str, dict]) -> str:
"""El contrato tal cual lo publica el servicio, en prosa compacta.
Se describe lo recibido, sin lista de plantillas escrita a mano: una
plantilla nueva en shortsmith aparece aquí sin tocar este repo.
"""
blocks = []
for name in sorted(templates):
schema = templates[name] or {}
defs = schema.get("$defs", {})
required = set(schema.get("required", []))
lines = [f"{name}:"]
for field, field_schema in schema.get("properties", {}).items():
lines.extend(_describe_field(field, field_schema, field in required, defs))
blocks.append("\n".join(lines))
return "\n\n".join(blocks)