feat(short): generación y render de Shorts vía shortsmith
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
Añade /generate short_en y /short_spec. El pipeline genera un shot spec con Haiku, verifica cada cifra, fecha y cita contra los chunks de la sesión, lo renderiza en shortsmith y entrega el MP4 por Telegram junto a un informe de claims. - ShortsmithClient con sondeo y fallback al spec JSON si el render falla - Contrato de plantillas obtenido de GET /templates, no codificado - Comprobación de fundamento determinista, sin LLM - outputs.published_url para enlazar el artículo de Ghost - Normalización de comillas rectas a tipográficas (ver KNOWN-ISSUES.md) Lo que no aparece en los chunks se contrasta contra el ejemplo del prompt: si casa ahí es fuga, no invención, y se informa como tal. El purgado de sesiones se lleva también su MP4. La subida a YouTube queda fuera a propósito: fase 3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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.
|
||||
@@ -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 7–8 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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 sí 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 sí — 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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user