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
+15
View File
@@ -0,0 +1,15 @@
# Ejemplos vendorizados
`jal1628.json` es una copia de `examples/jal1628.json` del repo
`git.chemavx.xyz/chemavx/shortsmith` (sha256
`eb3fe669b714e56db669638d3c02c25aca6df04823dcbc5b1da1a3cb938efda1`, copiado el
2026-08-01).
Se copia **el ejemplo, no el contrato**. Los esquemas de props se leen en vivo
de `GET /templates` (ver `src/generator/shortsmith.py`): duplicarlos aquí sería
una segunda fuente de verdad. Este fichero es material de prompt — el
`case_file` que ya produjo un vídeo bueno — y el patrón de referencia de la
eval dorada.
Si el ejemplo cambia en shortsmith, recopiarlo es opcional: que se desincronice
solo empeora un poco el prompt, no rompe nada.
+137
View File
@@ -0,0 +1,137 @@
{
"version": 1,
"meta": {
"id": "jal1628",
"title": "JAL 1628: Three Radars, One Object, Zero Explanation",
"width": 1080,
"height": 1920,
"fps": 30,
"theme": "exclusion-zone"
},
"audio": {
"preset": "sonar",
"silence": [[31.0, 36.0]]
},
"shots": [
{
"template": "radar_sweep",
"duration": 4.0,
"props": {
"headline": "3 RADARS",
"subline": "1 UNEXPLAINED RETURN",
"contact_bearing_deg": 210,
"sweeps": 2
}
},
{
"template": "track_map",
"duration": 4.0,
"props": {
"headline": "17 NOV 1986",
"subline": "35,000 FT · 600 MPH",
"waypoints": [
{"label": "FORT YUKON", "lat": 66.57, "lon": -145.27},
{"label": "FAIRBANKS", "lat": 64.84, "lon": -147.72},
{"label": "TALKEETNA", "lat": 62.32, "lon": -150.11},
{"label": "ANCHORAGE", "lat": 61.22, "lon": -149.90}
],
"bounds": {"lat_min": 60.4, "lat_max": 67.4, "lon_min": -152.0, "lon_max": -143.5}
}
},
{
"template": "data_card",
"duration": 5.0,
"props": {
"card_title": "FLIGHT CREW",
"rows": [
{"key": "CAPT. KENJU TERAUCHI", "value": "PILOT IN COMMAND"},
{"key": "EX-FIGHTER PILOT", "value": "JASDF"},
{"key": "29 YEARS", "value": "FLYING EXPERIENCE"},
{"key": "10,000+", "value": "FLIGHT HOURS"}
],
"footer": "REPORTS TWO LIGHTS PACING THE AIRCRAFT"
}
},
{
"template": "scale_bars",
"duration": 5.0,
"props": {
"headline": "REPORTED SCALE",
"bars": [
{"label": "BOEING 747", "value": 232, "unit": "FT", "color": "ink"},
{
"label": "ESTIMATED OBJECT",
"value": 2000,
"unit": "FT",
"color": "amber",
"value_label": "~1,600 2,000 FT"
}
],
"quote": ["“TWICE THE SIZE OF", "AN AIRCRAFT CARRIER”"],
"attribution": "— CAPT. TERAUCHI, ESTIMATE"
}
},
{
"template": "orbit_track",
"duration": 6.0,
"props": {
"headline": "EVASIVE MANEUVER",
"subline": "360° TURN · 4,000 FT",
"legend": [
{"label": "JAL 1628", "color": "ink"},
{"label": "UNIDENTIFIED CONTACT", "color": "amber"}
],
"caption": "CONTACT HOLDS RELATIVE POSITION",
"turn_deg": 360
}
},
{
"template": "signal_strips",
"duration": 7.0,
"props": {
"headline": "THREE INDEPENDENT SOURCES",
"strips": [
{
"label": "ONBOARD RADAR",
"sublabel": "CONTACT 78 NM · 10 O'CLOCK",
"markers": [0.26, 0.48, 0.63, 0.81]
},
{
"label": "ANCHORAGE CENTER",
"sublabel": "PRIMARY RETURNS THROUGH TURNS",
"markers": [0.26, 0.48, 0.63, 0.81]
},
{
"label": "ELMENDORF ROCC",
"sublabel": "TRACKED “FLIGHT OF TWO”",
"markers": [0.26, 0.48, 0.63, 0.81]
}
],
"footnote": "FAIRBANKS RADAR: NOTHING"
}
},
{
"template": "document_quote",
"duration": 5.0,
"props": {
"source": "FAA · 5 MARCH 1987",
"label_a": "OFFICIAL FINDING:",
"quote_a": "“SPLIT RADAR IMAGE”",
"label_b": "AARTCC CONTROLLER:",
"quote_b": "“RARELY, IF EVER”",
"tail": "IN THAT AIRSPACE."
}
},
{
"template": "counter_close",
"duration": 6.0,
"props": {
"count_to": 1500,
"count_label": "PAGES OF FAA DOCUMENTATION",
"lines": ["40 YEARS", "STILL OPEN"],
"url": "THEEXCLUSIONZONE.COM",
"show_mark": true
}
}
]
}
+26 -5
View File
@@ -651,8 +651,27 @@ class OutputGenerator:
# never buried inside the long .md document). None on the flag-off path.
self.last_publish_notice: str | None = None
async def _remember_article_url(self, ghost: "GhostPublisher", post: dict,
output_id: int | None) -> None:
"""Guarda en la fila de `outputs` la URL que tendrá el artículo.
Se construye desde el slug (`{sitio}/{slug}/`) y no desde el `url` que
devuelve Ghost, porque el post es un DRAFT y ese campo trae la URL de
previsualización. Si Jose cambia el slug al publicar, el enlace habrá
que rehacerlo — el Short lo enseña en la descripción, no en el vídeo.
Best-effort: nunca bloquea la publicación.
"""
if output_id is None or not post.get("slug"):
return
try:
await self.db.set_output_url(output_id, f"{ghost.url}/{post['slug']}/")
except Exception as e:
logger.warning("No se pudo guardar la URL del artículo", error=str(e))
async def _publish_blog_to_ghost(self, lang: str, full_output: str, topic: str,
session_id: int, seo_override: str | None) -> str:
session_id: int, seo_override: str | None,
output_id: int | None = None) -> str:
"""Publish a blog DRAFT to Ghost, gated by the SEO autofill mode.
Returns the ghost_notice to APPEND to the returned document (flag-off /
@@ -703,6 +722,7 @@ class OutputGenerator:
self.last_publish_notice = (
_seo_live_message(ghost, post, seo, inserted_pairs)
+ collision_note)
await self._remember_article_url(ghost, post, output_id)
logger.info("Auto-published blog to Ghost",
mode=mode, post_id=post["id"], links=len(inserted_pairs))
return ""
@@ -720,6 +740,7 @@ class OutputGenerator:
try:
result = await ghost.publish_draft(title, full_output)
post = result["posts"][0]
await self._remember_article_url(ghost, post, output_id)
logger.info("Auto-published blog to Ghost (bare)", post_id=post["id"])
return _bare_ghost_notice(ghost, post) + collision_note
except Exception as e:
@@ -797,13 +818,13 @@ class OutputGenerator:
full_output = header + "\n\n" + output
# Save to DB
await self.db.save_output(session_id, output_type, full_output)
output_id = await self.db.save_output(session_id, output_type, full_output)
# Auto-publish to Ghost for blog outputs (autofill mode gated inside helper).
ghost_notice = ""
if output_type in (OutputType.BLOG, OutputType.BLOG_EXTENDED):
ghost_notice = await self._publish_blog_to_ghost(
lang, full_output, topic, session_id, seo_override)
lang, full_output, topic, session_id, seo_override, output_id)
logger.info("Output generated", type=output_type, length=len(full_output))
return full_output + ghost_notice
@@ -960,13 +981,13 @@ class OutputGenerator:
header = self._build_header(topic, output_type, session, stats)
full_output = header + "\n\n" + full_content
await self.db.save_output(session_id, output_type, full_output)
output_id = await self.db.save_output(session_id, output_type, full_output)
# Auto-publish to Ghost for extended blog outputs (autofill mode gated inside).
ghost_notice = ""
if output_type == OutputType.BLOG_EXTENDED:
ghost_notice = await self._publish_blog_to_ghost(
lang, full_output, topic, session_id, seo_override)
lang, full_output, topic, session_id, seo_override, output_id)
logger.info("Extended output generated", type=output_type,
sections=len(sections), length=len(full_output))
+533
View File
@@ -0,0 +1,533 @@
"""Comprobador de fundamento: ¿cada dato del spec sale de las fuentes?
Un spec de Short no es prosa que un humano juzgue al leerla: es un contrato
tipado que se convierte en vídeo publicado. La premisa entera del canal es que
sus cifras vienen de fuentes primarias, así que una cifra de radar inventada en
un vídeo de 40 segundos es peor que no publicar vídeo.
Esto extrae del spec todo lo que afirma un hecho — citas, cifras, fechas y
nombres propios — y confirma que aparece en al menos un chunk de la sesión.
**Aquí no entra ningún LLM.** Tiene que ser determinista y gratis: si el
comprobador alucinara, no comprobaría nada. Todo es normalización + substring.
El sesgo es deliberado: se esperan falsos positivos (una cita reformulada no
casa aunque el hecho esté en la fuente). Un falso positivo cuesta un vistazo;
un falso negativo cuesta la credibilidad del canal.
"""
from __future__ import annotations
import json
import re
import unicodedata
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Any, Iterable, Optional
__all__ = [
"Claim",
"GroundingReport",
"check_grounding",
"extract_claims",
"normalize",
]
#: El ejemplo trabajado que viaja en el prompt. Se contrasta contra él para
#: poder distinguir dos diagnósticos que no piden lo mismo (ver
#: `check_grounding`).
EXAMPLE_PATH = Path(__file__).parent / "examples" / "jal1628.json"
# --- normalización ----------------------------------------------------------
#: Glifos de comilla que hay que unificar antes de comparar: el spec lleva
#: tipográficas («“ ”») y las fuentes scrapeadas, cualquier cosa.
_QUOTE_GLYPHS = "“”„‟«»″ʺ"
_APOSTROPHES = "‘’ʼ′"
_DASHES = "‐‑‒–—―−"
_SEPARATORS = "·•∙|\t "
#: Separador de millares dentro de un número: 35,000 y 35.000 -> 35000. El
#: lookahead exige exactamente tres dígitos, así que 1.5 y 61.22 se quedan
#: como están. "1.234" en el sentido decimal se convertiría en 1234, que es un
#: precio asumido: en este dominio los millares son mucho más frecuentes.
_THOUSANDS = re.compile(r"(?<=\d)[.,](?=\d{3}(?!\d))")
def normalize(text: str) -> str:
"""Forma canónica para comparar los dos lados. Idempotente."""
if not text:
return ""
out = unicodedata.normalize("NFKC", text)
out = out.translate({ord(c): '"' for c in _QUOTE_GLYPHS})
out = out.translate({ord(c): "'" for c in _APOSTROPHES})
out = out.translate({ord(c): "-" for c in _DASHES})
out = out.translate({ord(c): " " for c in _SEPARATORS})
out = _THOUSANDS.sub("", out)
out = out.casefold()
return " ".join(out.split())
# --- vocabulario ------------------------------------------------------------
#: Unidades reconocidas -> alias equivalentes. Sirven para dos cosas: decidir
#: si un número "lleva unidad" (y por tanto afirma algo) y para casar "35,000
#: FT" con una fuente que escribe "35,000 feet".
_UNIT_ALIASES: dict[str, set[str]] = {
"ft": {"ft", "feet", "foot", "pies", "pie"},
"m": {"m", "meter", "meters", "metre", "metres", "metro", "metros"},
"km": {"km", "kilometer", "kilometers", "kilometre", "kilometres",
"kilometro", "kilometros"},
"mi": {"mi", "mile", "miles", "milla", "millas"},
"nm": {"nm", "nmi", "nautical", "naut"},
"mph": {"mph"},
"kt": {"kt", "kts", "knot", "knots", "nudo", "nudos"},
"kph": {"kph", "kmh"},
"%": {"%", "percent", "pct", "porciento"},
"deg": {"deg", "degree", "degrees", "grado", "grados", "°"},
"sec": {"sec", "secs", "second", "seconds", "segundo", "segundos"},
"min": {"min", "mins", "minute", "minutes", "minuto", "minutos"},
"hour": {"hour", "hours", "hr", "hrs", "hora", "horas"},
"day": {"day", "days", "dia", "dias"},
"week": {"week", "weeks", "semana", "semanas"},
"month": {"month", "months", "mes", "meses"},
"year": {"year", "years", "yr", "yrs", "ano", "anos"},
"page": {"page", "pages", "pagina", "paginas"},
"kg": {"kg", "kilo", "kilos", "kilogram", "kilograms"},
"lb": {"lb", "lbs", "pound", "pounds", "libra", "libras"},
"ton": {"ton", "tons", "tonne", "tonnes", "tonelada", "toneladas"},
"mhz": {"mhz"},
"ghz": {"ghz"},
"km2": {"km2"},
}
_UNIT_LOOKUP: dict[str, str] = {
alias: canon for canon, aliases in _UNIT_ALIASES.items() for alias in aliases
}
_MONTHS: dict[str, int] = {}
for _i, _names in enumerate([
("january", "jan", "enero", "ene"),
("february", "feb", "febrero"),
("march", "mar", "marzo"),
("april", "apr", "abril", "abr"),
("may", "mayo"),
("june", "jun", "junio"),
("july", "jul", "julio"),
("august", "aug", "agosto", "ago"),
("september", "sep", "sept", "septiembre", "setiembre"),
("october", "oct", "octubre"),
("november", "nov", "noviembre"),
("december", "dec", "diciembre", "dic"),
], start=1):
for _n in _names:
_MONTHS[_n] = _i
#: Palabras función que no identifican nada. Se usan sólo para el respaldo por
#: tokens de los nombres: un nombre cuyos tokens significativos aparecen todos
#: en las fuentes se da por fundamentado aunque la frase entera no case.
_STOPWORDS = {
"the", "a", "an", "and", "or", "of", "in", "on", "at", "to", "for", "with",
"by", "from", "as", "is", "was", "were", "are", "be", "been", "that",
"this", "these", "those", "it", "its", "his", "her", "their", "no", "not",
"el", "la", "los", "las", "un", "una", "unos", "unas", "de", "del", "y",
"o", "en", "con", "por", "para", "que", "se", "su", "sus", "al", "es",
"son", "fue", "fueron", "lo",
}
#: Claves cuyo valor de texto es una etiqueta identificadora — donde viven los
#: nombres propios ("ELMENDORF ROCC", "CAPT. KENJU TERAUCHI"). La prosa
#: (headline, caption, footnote…) no entra entera: de ella se sacan citas,
#: cifras y fechas, que es lo que afirma hechos.
_NAME_KEYS = {"label", "key", "card_title", "source", "attribution",
"sublabel", "count_label"}
#: Claves numéricas que AFIRMAN un dato. El resto de números del spec son
#: geometría o tiempo de render (lat, lon, duration, sweeps, turn_deg,
#: contact_bearing_deg, markers, bounds…) y no se comprueban: no dicen nada
#: sobre el mundo. Si una plantilla nueva añade un número que sí afirma algo,
#: se añade aquí.
_FACT_NUMBER_KEYS = {"value", "count_to"}
#: Listas de texto que forman UNA frase continua al dibujarse (el renderizador
#: no envuelve: el caller parte la cita en líneas). Se unen antes de comprobar.
_JOINED_LIST_KEYS = {"quote"}
# --- extracción -------------------------------------------------------------
@dataclass(frozen=True)
class Claim:
"""Un dato afirmado por el spec, con dónde vive."""
text: str # tal cual aparece en el spec, para enseñárselo a un humano
kind: str # quote | figure | date | name
path: str # shots.3.scale_bars.props.bars.1.value_label
unit: Optional[str] = None # canónica, cuando la cifra la lleva
_date: Optional[tuple] = None # (dia|None, mes, año) para el respaldo de fechas
@property
def norm(self) -> str:
return normalize(self.text)
_QUOTED = re.compile(r'["“„‟«]([^"“”„‟«»]{3,})'
r'["”„‟»]')
_DATE_DMY = re.compile(r"\b(\d{1,2})\s+([A-Za-zÀ-ž]{3,12})\.?,?\s+(\d{4})\b")
_DATE_MDY = re.compile(r"\b([A-Za-zÀ-ž]{3,12})\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})\b")
_DATE_ISO = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b")
_DATE_SLASH = re.compile(r"\b(\d{1,2})[/](\d{1,2})[/](\d{2,4})\b")
_YEAR = re.compile(r"\b(1[5-9]\d{2}|20\d{2})\b")
_NUMBER = re.compile(
r"(?P<num>\d{1,3}(?:[.,]\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)\s*"
r"(?P<unit>%|°|[A-Za-zÀ-ž]{1,10})?")
_TOKEN = re.compile(r"[A-Za-zÀ-ž][A-Za-zÀ-ž'.\-]*")
def _mask(text: str, start: int, end: int) -> str:
"""Tapa un tramo ya extraído para que no lo vuelva a coger otra regla."""
return text[:start] + " " * (end - start) + text[end:]
def _month_number(token: str) -> Optional[int]:
return _MONTHS.get(normalize(token).strip(". "))
def _claims_from_text(text: str, path: str, key: str) -> list[Claim]:
"""Todo lo que afirma un hecho dentro de una cadena del spec.
El orden importa: las fechas se extraen y se tapan antes que los números,
porque si no "17 NOV 1986" produciría además la cifra suelta 1986.
"""
if not text or not text.strip():
return []
claims: list[Claim] = []
rest = text
# 1. Citas entrecomilladas. Son verbatim por definición: se comprueban tal cual.
for m in _QUOTED.finditer(text):
inner = m.group(1).strip()
if inner:
claims.append(Claim(inner, "quote", path))
rest = _mask(rest, m.start(), m.end())
# 2. Fechas, con sus componentes para el respaldo (día+mes+año en el mismo chunk).
for regex, order in ((_DATE_DMY, "dmy"), (_DATE_MDY, "mdy"),
(_DATE_ISO, "ymd"), (_DATE_SLASH, "dmy_num")):
for m in regex.finditer(rest):
if order == "dmy":
day, month, year = m.group(1), _month_number(m.group(2)), m.group(3)
elif order == "mdy":
month, day, year = _month_number(m.group(1)), m.group(2), m.group(3)
elif order == "ymd":
year, month, day = m.group(1), int(m.group(2)), m.group(3)
else:
day, month, year = m.group(1), int(m.group(2)), m.group(3)
if not 1 <= month <= 12:
continue
if not month:
continue # "5 RADARS 1986" no es una fecha: el token no es un mes
claims.append(Claim(m.group(0).strip(), "date", path,
_date=(int(day), int(month), int(year))))
rest = _mask(rest, m.start(), m.end())
# 3. Cifras: las que llevan unidad reconocida, o las de magnitud (separador
# de millares o >= 1000). "3 RADARS" no afirma una medida y se deja pasar;
# "35,000 FT", "1,500" y "50 minutes" sí.
for m in _NUMBER.finditer(rest):
raw_num, raw_unit = m.group("num"), m.group("unit")
unit = _UNIT_LOOKUP.get(normalize(raw_unit or ""))
had_separator = bool(re.search(r"\d[.,]\d{3}", raw_num))
try:
magnitude = float(normalize(raw_num))
except ValueError:
continue
if not unit and not had_separator and magnitude < 1000:
continue
if not unit or not raw_unit:
shown = raw_num
elif raw_unit in "%°":
shown = f"{raw_num}{raw_unit}" # 360°, no 360 °
else:
shown = f"{raw_num} {raw_unit}"
claims.append(Claim(shown.strip(), "figure", path, unit=unit))
rest = _mask(rest, m.start(), m.end("num") if not unit else m.end())
# 4. Años sueltos que hayan sobrevivido ("40 YEARS" no; "SINCE 1986" sí).
for m in _YEAR.finditer(rest):
claims.append(Claim(m.group(0), "date", path,
_date=(None, None, int(m.group(0)))))
rest = _mask(rest, m.start(), m.end())
# 5. Nombres propios: sólo en posiciones de etiqueta, y sólo si queda algo
# que identifique. "10,000+" (sin letras) y "29 YEARS" (cuya única
# palabra es una unidad) ya viajaron como cifra; repetirlos como nombre
# sólo alarga el informe.
if key in _NAME_KEYS and _name_worth_checking(text):
claims.append(Claim(text.strip(), "name", path))
return claims
def _name_worth_checking(text: str) -> bool:
"""¿Queda algún token que identifique a alguien o algo? Las unidades y los
meses no cuentan: ya viajan dentro de la cifra o de la fecha."""
return any(t not in _UNIT_LOOKUP and t not in _MONTHS
for t in _significant_tokens(text))
def _walk(node: Any, path: str, key: str, claims: list[Claim]) -> None:
if isinstance(node, dict):
for k, v in node.items():
_walk(v, f"{path}.{k}", k, claims)
elif isinstance(node, list):
if key in _JOINED_LIST_KEYS and all(isinstance(x, str) for x in node):
# Una cita partida en líneas es UNA cita.
_walk(" ".join(node), path, key, claims)
return
for i, v in enumerate(node):
_walk(v, f"{path}.{i}", key, claims)
elif isinstance(node, str):
claims.extend(_claims_from_text(node, path, key))
elif isinstance(node, bool):
return
elif isinstance(node, (int, float)) and key in _FACT_NUMBER_KEYS:
claims.append(Claim(_pretty_number(node), "figure", path))
def _pretty_number(value: float) -> str:
return str(int(value)) if float(value).is_integer() else str(value)
def _attach_units(props: dict, claims: list[Claim], base_path: str) -> list[Claim]:
"""Un dict con `value` numérico y `unit` de texto (una barra de escala) dibuja
los dos juntos: "232 FT". Se detecta por forma, no por plantilla."""
out = []
for claim in claims:
if claim.kind == "figure" and claim.path == f"{base_path}.value" and props.get("unit"):
out.append(Claim(f"{claim.text} {props['unit']}", "figure", claim.path,
unit=_UNIT_LOOKUP.get(normalize(str(props["unit"])))))
else:
out.append(claim)
return out
def extract_claims(spec: dict) -> list[Claim]:
"""Todos los datos afirmados por los shots del spec, sin duplicados.
`meta` queda fuera a propósito: el título del spec no se dibuja en ningún
fotograma, es el nombre del fichero.
"""
claims: list[Claim] = []
for i, shot in enumerate(spec.get("shots") or []):
if not isinstance(shot, dict):
continue
template = shot.get("template", "?")
props = shot.get("props") or {}
base = f"shots.{i}.{template}.props"
shot_claims: list[Claim] = []
_walk(props, base, "props", shot_claims)
# Reconstruye "232 FT" a partir de {value: 232, unit: "FT"}.
for path_prefix, sub in _dicts_with_value_and_unit(props, base):
shot_claims = _attach_units(sub, shot_claims, path_prefix)
claims.extend(shot_claims)
seen: set[tuple[str, str]] = set()
unique: list[Claim] = []
for claim in claims:
fingerprint = (claim.kind, claim.norm)
if not claim.norm or fingerprint in seen:
continue
seen.add(fingerprint)
unique.append(claim)
return unique
def _dicts_with_value_and_unit(node: Any, path: str) -> Iterable[tuple[str, dict]]:
if isinstance(node, dict):
if isinstance(node.get("value"), (int, float)) and node.get("unit"):
yield path, node
for k, v in node.items():
yield from _dicts_with_value_and_unit(v, f"{path}.{k}")
elif isinstance(node, list):
for i, v in enumerate(node):
yield from _dicts_with_value_and_unit(v, f"{path}.{i}")
# --- comprobación -----------------------------------------------------------
@dataclass
class GroundingReport:
grounded: list[Claim] = field(default_factory=list)
ungrounded: list[Claim] = field(default_factory=list)
#: Ni en las fuentes ni inventado: copiado del ejemplo del prompt.
contaminated: list[Claim] = field(default_factory=list)
chunk_count: int = 0
url_count: int = 0
@property
def total(self) -> int:
return len(self.grounded) + len(self.ungrounded) + len(self.contaminated)
@property
def unsupported(self) -> list[Claim]:
"""Todo lo que no se apoya en las fuentes, sea cual sea el diagnóstico."""
return self.ungrounded + self.contaminated
@property
def clean(self) -> bool:
return not self.unsupported
def summary(self) -> str:
"""Texto del informe de claims (§8 del spec de fase 2).
Con cero claims sin fundamento TAMBIÉN se informa: un éxito silencioso
enseña al lector a dejar de mirar.
"""
lines = [f"{len(self.grounded)} claims casados con las fuentes"]
if self.ungrounded:
lines.append(f"⚠️ {len(self.ungrounded)} sin encontrar:")
for claim in self.ungrounded:
lines.append(f" • [{claim.kind}] \"{claim.text}\"")
else:
lines.append("✅ 0 sin encontrar")
if self.contaminated:
lines.append("")
lines.append(f"🧪 {len(self.contaminated)} copiados del EJEMPLO del prompt "
"(fuga, no invención — bórralos o sustitúyelos por datos "
"de esta sesión):")
for claim in self.contaminated:
lines.append(f" • [{claim.kind}] \"{claim.text}\"")
lines.append("")
lines.append(f"Fuentes: {self.chunk_count} chunks de {self.url_count} URLs")
return "\n".join(lines)
def _unit_near(haystack: str, number: str, unit: str, window: int = 40) -> bool:
"""¿Aparece la unidad (o un alias) cerca de esa cifra en el texto?"""
aliases = _UNIT_ALIASES.get(unit, {unit})
for m in re.finditer(rf"(?<!\d){re.escape(number)}(?!\d)", haystack):
tail = haystack[m.end():m.end() + window]
if any(re.search(rf"\b{re.escape(a)}", tail) for a in aliases):
return True
return False
def _number_present(haystack: str, number: str) -> bool:
return re.search(rf"(?<![\d.,]){re.escape(number)}(?![\d])", haystack) is not None
def _date_present(haystack: str, parts: tuple) -> bool:
"""Respaldo de fechas: "17 NOV 1986" contra una fuente que escribe
"November 17, 1986". Los tres componentes en el mismo chunk bastan."""
day, month, year = parts
if str(year) not in haystack:
return False
if month is None:
return True
names = [n for n, num in _MONTHS.items() if num == month]
if not any(re.search(rf"\b{n}", haystack) for n in names) and \
not re.search(rf"(?<!\d){month:02d}(?!\d)", haystack) and \
not re.search(rf"(?<!\d){month}(?!\d)", haystack):
return False
return day is None or _number_present(haystack, str(day))
def _significant_tokens(text: str) -> list[str]:
"""Tokens que identifican algo. La puntuación de cola se cae para que
"CAPT." case por substring contra "captain"."""
tokens = (t.strip(".-'") for t in _TOKEN.findall(normalize(text)))
return [t for t in tokens if len(t) >= 3 and t not in _STOPWORDS]
def _is_grounded(claim: Claim, haystacks: list[str]) -> bool:
needle = claim.norm
if any(needle in h for h in haystacks):
return True
if claim.kind == "quote":
return False # una cita o es verbatim o no es una cita
if claim.kind == "figure":
number = normalize(claim.text.split()[0]) if claim.text else ""
if not number:
return False
if claim.unit:
return any(_unit_near(h, number, claim.unit) for h in haystacks)
return any(_number_present(h, number) for h in haystacks)
if claim.kind == "date":
return any(_date_present(h, claim._date) for h in haystacks) if claim._date else False
# name: cada token significativo tiene que aparecer en las fuentes. Casa
# "CAPT. KENJU TERAUCHI" con "Captain Kenju Terauchi" sin dejar pasar un
# "ELMENDORF ROCC" donde ninguna fuente menciona ROCC.
tokens = _significant_tokens(claim.text)
if not tokens:
return False
return all(any(t in h for h in haystacks) for t in tokens)
@lru_cache(maxsize=1)
def _example_haystacks() -> tuple[str, ...]:
"""El ejemplo del prompt, troceado para poder buscar dentro.
Se junta lo que el ejemplo DIBUJA (sus claims, con la cita reconstruida) y
sus cadenas sueltas. **Un trozo por campo, no un texto único**: el respaldo
de fechas casa día, mes y año dentro del MISMO pajar, y en un ejemplo con
"17 NOV 1986" y "5 MARCH 1987" pegados, un "17 NOV 1987" inventado parecería
venir de ahí.
"""
try:
example = json.loads(EXAMPLE_PATH.read_text(encoding="utf-8"))
except Exception:
return ()
pieces = [c.text for c in extract_claims(example)]
for shot in example.get("shots", []):
_collect_strings(shot.get("props") or {}, pieces)
return tuple(sorted({normalize(p) for p in pieces if normalize(p)}))
def _collect_strings(node: Any, out: list[str]) -> None:
if isinstance(node, dict):
for v in node.values():
_collect_strings(v, out)
elif isinstance(node, list):
for v in node:
_collect_strings(v, out)
elif isinstance(node, str) and node.strip():
out.append(node)
def check_grounding(spec: dict, chunks: list[dict],
example_haystacks: Optional[tuple[str, ...]] = None
) -> GroundingReport:
"""Comprueba el spec contra el material de la sesión.
`chunks` son filas de la tabla `chunks` (con `content` y, si viene del join
con `sources`, `url`). No se descarta ningún shot ni se reintenta a ciegas:
se devuelve el informe y decide un humano si es una fabricación real o un
artefacto de formato.
Lo que no aparece en los chunks se contrasta ADEMÁS contra el ejemplo
trabajado que viaja en el prompt. Si casa ahí, no es una invención: es una
fuga del prompt — el modelo copió una cifra del ejemplo en vez de sacarla
de la sesión (medido el 2026-08-01: "232 FT", el largo de un 747, en cero
de 126 chunks). Son dos diagnósticos distintos y piden acciones distintas:
una invención hay que verificarla, una fuga hay que borrarla.
El ejemplo se queda como está — con cifras reales — a propósito: uno
sintético enseña peor la forma, y la defensa estructural es esto.
"""
haystacks = [normalize(c.get("content") or "") for c in chunks]
haystacks = [h for h in haystacks if h]
urls = {c.get("url") for c in chunks if c.get("url")}
from_example = _example_haystacks() if example_haystacks is None else example_haystacks
report = GroundingReport(chunk_count=len(chunks), url_count=len(urls))
for claim in extract_claims(spec):
if _is_grounded(claim, haystacks):
report.grounded.append(claim)
elif from_example and _is_grounded(claim, list(from_example)):
report.contaminated.append(claim)
else:
report.ungrounded.append(claim)
return report
+281
View File
@@ -0,0 +1,281 @@
"""Producción de un Short: spec → fundamento → render → MP4 en disco.
Orquesta las tres piezas que ya existen (`shortspec`, `grounding`,
`shortsmith`) y no añade lógica propia salvo el orden, que es deliberado:
escribir el spec comprobar los datos renderizar
La comprobación va ANTES del render porque el informe de claims es la puerta de
revisión humana, y llega a Telegram junto al vídeo. No bloquea el render: un
dato sin encontrar puede ser una fabricación o un artefacto de formato, y eso
lo decide una persona, no esto.
**Fallbacks siempre** (convención del repo): si shortsmith no responde, si el
job falla o si el spec no valida, se devuelve el spec igualmente. La parte cara
es la generación, no el render. No se tira nunca.
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Optional
import structlog
from src.config import settings
from src.db.database import OutputType, ResearchDB
from src.generator.grounding import GroundingReport, check_grounding
from src.generator.shortsmith import (
ShortsmithClient, ShortsmithError, ShortsmithRejected, ShortsmithUnavailable,
)
from src.generator.shortspec import ShortSpecWriter, SpecWriteFailed
from src.llm import get_anthropic_client
logger = structlog.get_logger()
__all__ = ["ShortProducer", "ShortResult", "ShortsDisabled"]
#: Cuántos chunks se le dan al modelo. Es también el material contra el que se
#: comprueba el fundamento: se comprueba contra EXACTAMENTE lo que se le pasó.
CONTEXT_CHUNKS = 40
#: Tope de caracteres del contexto. Un Short son 40 segundos: más material no
#: mejora el guion, sólo la factura.
CONTEXT_BUDGET = 90_000
class ShortsDisabled(Exception):
"""SHORTSMITH_ENABLED=false. El interruptor de emergencia del renderizador."""
@dataclass
class ShortResult:
topic: str
spec: Optional[dict] = None
title: str = ""
article_url: Optional[str] = None
grounding: Optional[GroundingReport] = None
video_path: Optional[str] = None
render_warnings: list[dict] = field(default_factory=list)
attempts: int = 0
notes: list[str] = field(default_factory=list)
#: Por qué no hay vídeo. None = lo hay.
failure: Optional[str] = None
#: La respuesta cruda del modelo cuando ni siquiera llegó a ser JSON. Se
#: conserva para poder mandarla a Telegram y editarla a mano.
raw_response: str = ""
cost_usd: float = 0.0
duration_s: float = 0.0
@property
def spec_json(self) -> str:
return json.dumps(self.spec, indent=2, ensure_ascii=False) if self.spec else ""
@property
def has_video(self) -> bool:
return bool(self.video_path)
class ShortProducer:
def __init__(self, db: ResearchDB, processor,
client: Optional[ShortsmithClient] = None,
llm_call: Optional[Callable] = None):
self.db = db
self.processor = processor
self.client = client or ShortsmithClient()
#: Sustituto del callable a Claude. Sólo lo usan los tests: el bucle de
#: reintento y los fallbacks se prueban sin gastar tokens.
self.llm_override = llm_call
# --- piezas -------------------------------------------------------------
def _llm_call(self, session_id: int):
"""Un callable (system, prompt) -> texto que además apunta el gasto."""
async def call(system: str, prompt: str) -> str:
client = get_anthropic_client()
msg = await client.messages.create(
model=settings.claude_model,
max_tokens=8000,
system=system,
messages=[{"role": "user", "content": prompt}],
)
try:
await self.db.log_api_call(
session_id, "short_spec", settings.claude_model,
msg.usage.input_tokens, msg.usage.output_tokens)
in_price, out_price = ResearchDB._price_for_model(settings.claude_model)
call.cost += (msg.usage.input_tokens * in_price
+ msg.usage.output_tokens * out_price) / 1_000_000
except Exception as e:
logger.warning("No se pudo apuntar el gasto del spec", error=str(e))
return msg.content[0].text.strip()
call.cost = 0.0
return call
def _domain(self) -> str:
"""El dominio que va dibujado en el shot de cierre, en mayúsculas y sin
protocolo (así lo escribe el ejemplo de referencia)."""
raw = (settings.ghost_url_en or "https://theexclusionzone.com")
return raw.split("://")[-1].strip("/").removeprefix("www.").upper()
def _context(self, chunks: list[dict]) -> str:
parts, size = [], 0
for chunk in chunks:
label = f"[{(chunk.get('source_type') or 'web').upper()}] " \
f"{chunk.get('title') or chunk.get('url') or 'Unknown'}"
piece = f"{label}:\n{chunk['content']}"
if size + len(piece) > CONTEXT_BUDGET:
break
parts.append(piece)
size += len(piece)
return "\n\n---\n\n".join(parts)
def _video_path(self, session_id: int) -> Path:
directory = Path(settings.shorts_dir)
directory.mkdir(parents=True, exist_ok=True)
return directory / f"{session_id}.mp4"
# --- pipeline -----------------------------------------------------------
async def produce(self, session_id: int,
progress_callback: Optional[Callable[[str], Any]] = None
) -> ShortResult:
if not settings.shortsmith_enabled:
raise ShortsDisabled(
"SHORTSMITH_ENABLED=false — el renderizador está apagado a propósito")
if not settings.anthropic_api_key and not self.llm_override:
raise ValueError(
"Escribir un shot spec necesita Claude: es JSON con un contrato "
"estricto, no prosa. Configura ANTHROPIC_API_KEY.")
session = await self.db.get_session(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
topic = session["topic"]
result = ShortResult(topic=topic)
# 1. Material. Los mismos chunks alimentan el prompt y el comprobador.
await _report(progress_callback, "🎬 Writing shot spec…")
chunks = await self.processor.rag_chunks(
session_id, f"{topic} key facts figures dates quotes witnesses",
top_k=CONTEXT_CHUNKS)
if not chunks:
raise ValueError("No processed content available. Run /process first.")
context = self._context(chunks)
result.article_url = await self.db.get_article_url(session_id)
if not result.article_url:
logger.warning("Short sin URL de artículo — se sigue con el dominio pelado",
session_id=session_id)
# 2. El contrato, en vivo. Sin él no hay prompt que escribir.
templates = await self.client.templates()
# 3. El spec.
started = time.monotonic()
llm_call = self.llm_override or self._llm_call(session_id)
writer = ShortSpecWriter(
llm_call, templates,
refresh_templates=lambda: self.client.templates(refresh=True))
try:
written = await writer.write(
topic, context, article_url=result.article_url,
domain=self._domain(), on_progress=progress_callback)
except SpecWriteFailed as e:
result.cost_usd = getattr(llm_call, "cost", 0.0)
result.spec = e.last_spec
result.raw_response = e.last_raw
result.attempts = e.attempts
result.failure = ("El spec no pasó la validación en "
f"{e.attempts} intentos: " + "; ".join(e.errors[:4]))
logger.warning("Short sin vídeo: spec inválido", session_id=session_id,
errors=e.errors[:4])
return result
result.spec = written.spec
result.attempts = written.attempts
result.notes = written.notes
result.cost_usd = getattr(llm_call, "cost", 0.0)
result.title = written.spec.get("meta", {}).get("title", topic)
result.duration_s = sum(s.get("duration", 0) for s in written.spec["shots"])
# 4. Fundamento, ANTES de renderizar. No descarta ningún shot: informa.
await _report(progress_callback, "🔍 Checking claims against sources…")
result.grounding = check_grounding(written.spec, chunks)
logger.info("Short grounding", session_id=session_id,
matched=len(result.grounding.grounded),
ungrounded=len(result.grounding.ungrounded),
from_example=len(result.grounding.contaminated))
# 5. El spec se guarda ANTES del render: si el render falla, la parte
# cara ya está a salvo en la DB y `/short_spec` la puede devolver.
try:
await self.db.save_output(session_id, OutputType.SHORT_EN, result.spec_json)
except Exception as e:
logger.warning("No se pudo guardar el spec en outputs", error=str(e))
# 6. Render.
try:
await self._render(result, session_id, progress_callback)
except ShortsmithRejected as e:
# El validador local no replica las reglas de pydantic que cruzan
# campos (los límites de MapBounds, "3 barras no dejan sitio para
# una cita"): las coge el servidor y se cuentan tal cual.
result.failure = ("shortsmith rechazó el spec: "
+ "; ".join(_error_line(x) for x in e.errors[:4]))
except ShortsmithUnavailable as e:
result.failure = f"shortsmith no responde: {e}"
except ShortsmithError as e:
result.failure = f"el render falló: {e}"
except OSError as e:
result.failure = f"no se pudo guardar el vídeo: {e}"
if result.failure:
logger.warning("Short sin vídeo", session_id=session_id, why=result.failure)
logger.info("Short producido", session_id=session_id,
seconds=round(time.monotonic() - started, 1),
video=result.video_path, cost=round(result.cost_usd, 4))
return result
async def _render(self, result: ShortResult, session_id: int,
progress_callback: Optional[Callable[[str], Any]]) -> None:
job_id = await self.client.render(result.spec)
async def on_progress(fraction: float, status: str) -> None:
if status == "queued":
await _report(progress_callback, "🎞 Queued at the renderer…")
else:
await _report(progress_callback, f"🎞 Rendering… {fraction * 100:.0f}%")
job = await self.client.poll(job_id, on_progress=on_progress)
result.render_warnings = job.warnings
if not job.ok:
result.failure = f"el render terminó en error: {job.error}"
return
await _report(progress_callback, "📤 Uploading…")
video = await self.client.fetch_video(job_id)
path = self._video_path(session_id)
path.write_bytes(video)
result.video_path = str(path)
def _error_line(error: Any) -> str:
"""Un error de pydantic del servidor, con su ruta completa."""
if not isinstance(error, dict):
return str(error)
loc = ".".join(str(p) for p in error.get("loc", []))
return f"{loc}: {error.get('msg', '')}" if loc else str(error.get("msg", error))
async def _report(callback: Optional[Callable[[str], Any]], text: str) -> None:
if not callback:
return
try:
value = callback(text)
if hasattr(value, "__await__"):
await value
except Exception as e:
logger.warning("Progreso del Short no enviado", error=str(e))
+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
+411
View File
@@ -0,0 +1,411 @@
"""Escritura del shot spec: prompt, inyección del contrato y bucle de reintento.
Generar un spec no es como generar prosa. La prosa mala se lee y se juzga; un
spec malformado no se puede usar. De ahí las tres mitigaciones, cada una en su
sitio: la validación con reintento vive aquí, el comprobador de fundamento en
`grounding.py`, y la revisión humana en el mensaje de Telegram.
El contrato se INYECTA (`GET /templates` `describe_templates`), no se copia.
Lo que es de este repo son las tres formas narrativas: son decisiones
editoriales del canal, no del renderizador.
Sin dependencias de `bot/`: el LLM entra como un callable y el progreso también.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable, Optional
import structlog
from src.generator.spec_contract import (
SpecInvalid, describe_templates, editorial_notes, validate_spec,
TARGET_MAX_DURATION, TARGET_MIN_DURATION,
)
logger = structlog.get_logger()
#: Tres intentos. Si la media de intentos-hasta-válido sube de 1.5, lo que hay
#: que arreglar es el prompt, no este número.
MAX_ATTEMPTS = 3
EXAMPLE_PATH = Path(__file__).parent / "examples" / "jal1628.json"
__all__ = ["ShortSpecWriter", "SpecResult", "SpecWriteFailed", "NARRATIVE_SHAPES"]
#: Las tres formas que se publican de verdad. Dejar la elección libre produce
#: papilla: el modelo elige UNA y la sigue.
NARRATIVE_SHAPES = """\
case_file hook date/place witness credentials escalation
evidence the official explanation and its problem close.
Fits a single documented encounter (JAL 1628, Belgium, Ariel School).
debunk the claim why it spread the method the finding
what it means close.
Fits a claim that dissolves under examination (a mislabelled
crater video, a star mistaken for a craft).
document_drop what was released the standout item context
what is still missing close.
Fits a release of records (PURSUE and similar)."""
SHORT_SYSTEM = """\
You write shot specs for The Exclusion Zone, a documentary channel about UAP \
cases and declassified records. A shot spec is JSON that a renderer turns \
directly into a vertical video: every string you write is drawn on screen \
exactly as you typed it.
You answer with ONE JSON object and nothing else no prose, no explanation, \
no markdown fences.
You never state a figure, a quote, a date or a name that is not in the research \
material you were given. Not even one you happen to know is true. The channel's \
entire premise is that its numbers come from primary sources."""
PROMPT = """\
Write a shot spec for a Short about: "{topic}"
# 1. Pick one narrative shape and follow its arc
{shapes}
# 2. The contract — these are the templates the renderer accepts
Each shot names a template and supplies its props. Nothing outside this list \
exists, and a prop name that is not listed is a parse error, not a nuance.
{templates}
# 3. Rules
- Total duration 20-45 seconds. The contract allows 180; that is a ceiling, \
not a target. Aim for {target_min:.0f}-{target_max:.0f}.
- Typically 6-9 shots. Give a shot the seconds its content needs to be read: \
a card with four rows needs longer than a headline.
- Every string is drawn as given. Write them the way they should appear: \
SHORT, UPPERCASE, no trailing punctuation. A headline is 2-5 words.
- Respect every max length and list-length limit above. They are enforced.
- Colours are palette names ({colors}) never hex.
- Quotes carry the typographic quote marks: SPLIT RADAR IMAGE, with U+201C \
and U+201D. Never a straight " inside a string — that closes the JSON string \
and your whole answer becomes unparseable. This is the single most common way \
this task fails.
- meta.id is a lowercase slug (letters, digits, - and _). meta.title is the \
title a human reads, not a filename.
- version is 1. Keep meta at 1080x1920 and audio preset "sonar".
- The closing shot carries the domain, uppercase, no protocol: {domain}
# 4. Grounding — this is the part that matters
Every figure, quote, date, and proper noun in your spec must appear in the \
research material below. An automated check runs against these exact sources \
before anything is rendered, and every string it cannot find is shown to a \
human next to your spec.
If the material does not support a number, do not write the number. A shot with \
one solid fact beats a shot with three plausible ones. This applies to the \
worked example in section 5 as much as to your own knowledge: a figure that is \
only in the example is a figure you cannot use.
Anything you put inside quote marks must be a word-for-word span of the \
material. Copy it; do not compress it. "WALNUT SHAPED WIDE RIM" is not a quote \
when the source says "walnut shaped with a wide rim around its circumference" \
pick a shorter span that is still verbatim, or drop the quote marks and \
state the fact plainly.
# 5. A worked example — FORMAT ONLY
This is a case_file that produced a good video. Read it for shape: how long a \
shot runs, how a headline is worded, how the shots build.
It is not source material. Do not reuse its strings, figures, coordinates, \
quotes or waypoints not even if it covers the same case you were asked \
about. Every value in your spec comes from section 7 and nowhere else. A \
number copied from here is a fabrication, and the grounding check will find it.
{example}
# 6. The article this Short accompanies
{article}
# 7. Research material — the only facts you may use
{context}
Return the JSON object now."""
PALETTE_FALLBACK = "ink, amber, amber_dark, muted, dim, red"
@dataclass
class SpecResult:
spec: dict
attempts: int
notes: list[str] = field(default_factory=list)
#: Errores de cada intento fallido, en orden. Sirve de métrica y de pista
#: cuando un spec sale a la primera pero raro.
history: list[list[str]] = field(default_factory=list)
class SpecWriteFailed(Exception):
"""Tres intentos y ninguno válido.
Lleva el último intento aunque no valga: la parte cara es la generación, no
el render, y un spec inválido se edita a mano y se reenvía. Nunca se tira.
"""
def __init__(self, errors: list[str], last_raw: str = "",
last_spec: Optional[dict] = None, attempts: int = 0):
self.errors = errors
self.last_raw = last_raw
self.last_spec = last_spec
self.attempts = attempts
super().__init__("; ".join(errors[:5]) or "no se pudo escribir el spec")
def _load_example() -> str:
try:
return json.dumps(json.loads(EXAMPLE_PATH.read_text(encoding="utf-8")),
indent=2, ensure_ascii=False)
except Exception as e: # nunca bloquea: el ejemplo mejora el prompt, no lo define
logger.warning("ejemplo de spec no legible — se sigue sin él", error=str(e))
return "(no example available)"
def _palette(templates: dict[str, dict]) -> str:
"""Los nombres de color, sacados del propio contrato."""
found: list[str] = []
def walk(node: Any):
if isinstance(node, dict):
enum = node.get("enum")
if enum and node.get("type") == "string" and "ink" in enum:
for name in enum:
if name not in found:
found.append(name)
for v in node.values():
walk(v)
elif isinstance(node, list):
for v in node:
walk(v)
walk(templates)
return ", ".join(found) or PALETTE_FALLBACK
#: Lo que puede seguir legítimamente al cierre de una cadena JSON.
_AFTER_STRING = set(',:}] \t\r\n')
#: Antes de una comilla de apertura hay hueco, un guion o el propio inicio.
_BEFORE_OPENING = set(' \t\n([-–—‑:')
def _typographic_inner_quotes(body: str) -> str:
"""Convierte en “ ” las comillas rectas que van DENTRO de una cadena JSON.
Se recorre el texto sabiendo dónde empieza y acaba cada cadena: una `"` que
no vaya seguida de `,`, `:`, `}`, `]` o espacio no cierra nada, es una
comilla del texto. Decidir apertura o cierre por el carácter anterior.
Sólo se llama tras un fallo de parseo: un JSON correcto no pasa por aquí.
"""
out: list[str] = []
in_string = False
escaped = False
for i, char in enumerate(body):
if escaped:
out.append(char)
escaped = False
continue
if char == "\\":
out.append(char)
escaped = in_string
continue
if char != '"':
out.append(char)
continue
if not in_string:
in_string = True
out.append(char)
continue
nxt = next((c for c in body[i + 1:] if not c.isspace()), "")
if nxt in ",:}]" or nxt == "":
in_string = False
out.append(char)
else:
previous = out[-1] if out else ""
out.append("" if previous in _BEFORE_OPENING or previous == '"' else "")
return "".join(out)
def extract_json(text: str) -> dict:
"""El objeto JSON de la respuesta del modelo, con o sin valla de markdown.
Un error de parseo se cuenta CON el trozo que lo provocó. "Expecting ','
delimiter: line 189 column 22" no le sirve de nada al modelo, que no ve su
salida numerada; el fragmento y el fallo típico es una comilla recta
dentro de una cadena, que cierra la cadena antes de tiempo.
"""
cleaned = text.strip()
fenced = re.search(r"```(?:json)?\s*(.+?)```", cleaned, re.DOTALL)
if fenced:
cleaned = fenced.group(1).strip()
start, end = cleaned.find("{"), cleaned.rfind("}")
if start == -1 or end <= start:
raise ValueError("la respuesta no contiene ningún objeto JSON")
body = cleaned[start:end + 1]
try:
return json.loads(body)
except json.JSONDecodeError:
pass
# Reparación determinista de LA forma en que esto falla: comillas rectas
# dentro de una cadena (`"quote_a": ""CREDIBLE PEOPLE""`). Medido el
# 2026-08-01 contra la sesión de Bélgica: el modelo lo repitió en los tres
# intentos aunque el prompt lo prohíbe y el error se le devolvía con el
# fragmento. Arreglarlo aquí es además lo que se quiere dibujar: las citas
# del canal van con las tipográficas.
repaired = _typographic_inner_quotes(body)
try:
return json.loads(repaired)
except json.JSONDecodeError as e:
snippet = repaired[max(0, e.pos - 60):e.pos + 60].replace("\n", " ")
raise ValueError(
f"{e.msg} — aquí: …{snippet}"
"(si es una comilla recta dentro de una cadena, cierra la cadena: "
"las citas van con las tipográficas “ ”)") from None
def _format_errors(errors: list[str]) -> str:
"""Las rutas, verbatim. Son más útiles para el modelo que cualquier paráfrasis."""
listed = "\n".join(f"- {e}" for e in errors)
return (f"\n\n# Your previous attempt was rejected\n\n{listed}\n\n"
"Fix exactly these and return the corrected JSON object. "
"Keep everything else as it was.")
def _format_notes(notes: list[str]) -> str:
listed = "\n".join(f"- {n}" for n in notes)
return (f"\n\n# Your previous attempt is valid but off-brief\n\n{listed}\n\n"
"Return the adjusted JSON object.")
#: (system, prompt) -> texto del modelo.
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):
self.llm_call = llm_call
self.templates = templates
#: 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
def build_prompt(self, topic: str, context: str, article_url: Optional[str],
domain: str) -> str:
article = (f"The article is published at {article_url} — the Short points at it."
if article_url
else "No article URL yet. Use the bare domain on the closing shot.")
return PROMPT.format(
topic=topic,
shapes=NARRATIVE_SHAPES,
templates=describe_templates(self.templates),
colors=_palette(self.templates),
domain=domain,
target_min=TARGET_MIN_DURATION,
target_max=TARGET_MAX_DURATION,
example=_load_example(),
article=article,
context=context,
)
async def write(self, topic: str, context: str, *,
article_url: Optional[str] = None,
domain: str = "THEEXCLUSIONZONE.COM",
on_progress: Optional[Callable[[str], Any]] = None) -> SpecResult:
base_prompt = self.build_prompt(topic, context, article_url, domain)
feedback = ""
history: list[list[str]] = []
last_raw, last_spec = "", None
#: Un spec que cumple el contrato pero se pasa de duración. Se guarda
#: para que un intento posterior peor no lo tire: es renderizable.
best: Optional[SpecResult] = None
for attempt in range(1, MAX_ATTEMPTS + 1):
if on_progress and attempt > 1:
await _maybe_await(on_progress(
f"🎬 Rewriting the shot spec (attempt {attempt}/{MAX_ATTEMPTS})…"))
last_raw = await self.llm_call(SHORT_SYSTEM, base_prompt + feedback)
try:
spec = extract_json(last_raw)
except (ValueError, json.JSONDecodeError) as e:
errors = [f"la respuesta no es un objeto JSON válido: {e}"]
history.append(errors)
feedback = _format_errors(errors)
continue
last_spec = spec
try:
validate_spec(spec, self.templates)
except SpecInvalid as e:
history.append(e.errors)
feedback = _format_errors(e.errors)
# El contrato puede haber cambiado bajo los pies: se refresca
# una vez antes de volver a intentarlo.
if self.refresh_templates and attempt == 1:
try:
self.templates = await self.refresh_templates()
base_prompt = self.build_prompt(topic, context, article_url, domain)
except Exception as refresh_err:
logger.warning("no se pudo refrescar el contrato",
error=str(refresh_err))
continue
notes = editorial_notes(spec)
result = SpecResult(spec=spec, attempts=attempt, notes=notes,
history=list(history))
if notes and attempt < MAX_ATTEMPTS:
# Nota editorial, no violación del contrato: se comenta una vez
# y, si insiste, se renderiza igual.
best = best or result
history.append(notes)
feedback = _format_notes(notes)
continue
logger.info("short spec válido", attempts=attempt,
shots=len(spec.get("shots", [])), notes=len(notes))
return result
if best is not None:
# Un intento anterior sí cumplía el contrato. Vale más un Short
# largo que ningún Short.
logger.info("short spec: se recupera el intento válido anterior",
attempts=MAX_ATTEMPTS, notes=best.notes)
best.history = history
return best
logger.warning("short spec inválido tras todos los intentos",
attempts=MAX_ATTEMPTS, errors=history[-1] if history else [])
raise SpecWriteFailed(history[-1] if history else ["sin errores registrados"],
last_raw=last_raw, last_spec=last_spec,
attempts=MAX_ATTEMPTS)
async def _maybe_await(value):
import asyncio
if asyncio.iscoroutine(value):
return await value
return value
+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)