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,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
|
||||
Reference in New Issue
Block a user