feat(short): narración — el grounding la cubre, el contrato la admite y el prompt la guía
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s

El comprobador va primero, antes que el campo (fase 2 §12): la narración es
prosa que el modelo redacta, no una etiqueta que copia, y es donde se cuela
una cifra sin fuente. De paso, la huella de una cifra pasa a ser número +
unidad canónica: con la voz repitiendo la pantalla, '35,000 FT' y '35,000
feet' son el mismo dato y contarlos dos veces inflaría el informe del que
depende la revisión humana.

editorial_notes estima la duración CON la voz: la declarada es un suelo y sin
esto el modelo escribiría 40 s de shots, les colgaría narración y se enteraría
del Short de 65 s cuando ya está pagado.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-08-06 15:38:34 +00:00
co-authored by Claude Fable 5
parent 93a506b636
commit a13c3062b6
9 changed files with 375 additions and 13 deletions
+16 -3
View File
@@ -464,12 +464,25 @@ def _claims_message(result) -> str:
else "no se llegó a escribir un spec")
lines.append(f"⚠️ Sin comprobación de fundamento: {why}.")
if result.render_warnings:
# shortsmith manda dos cosas por el mismo canal: textos que no cupieron al
# dibujar y avisos de la narración. Se separan aquí porque piden acciones
# distintas — uno se arregla acortando una cadena, el otro puede significar
# que el Short salió mudo.
trimmed = [w for w in (result.render_warnings or []) if not w.get("kind")]
spoken = [w for w in (result.render_warnings or []) if w.get("kind")]
if trimmed:
lines.append("")
lines.append(f"✂️ {len(result.render_warnings)} textos recortados al dibujar:")
for w in result.render_warnings[:5]:
lines.append(f"✂️ {len(trimmed)} textos recortados al dibujar:")
for w in trimmed[:5]:
lines.append(f" • [{w.get('template', '?')}] {str(w.get('text', ''))[:60]}")
if spoken:
lines.append("")
for w in spoken[:5]:
icon = "🔇" if w.get("kind") == "narration" else ""
lines.append(f"{icon} {str(w.get('text', ''))[:160]}")
if result.notes:
lines.append("")
lines.extend(f"📏 {n}" for n in result.notes)
+34 -2
View File
@@ -168,6 +168,24 @@ class Claim:
def norm(self) -> str:
return normalize(self.text)
@property
def fingerprint(self) -> tuple:
"""Identidad del dato, no de su redacción — para no contar dos veces.
Una cifra se identifica por su número y su unidad CANÓNICA: "35,000 FT"
dibujado en pantalla y "35,000 feet" dicho en la narración son el mismo
dato, y con la narración esa coincidencia pasa a ser lo normal, no la
excepción. Contarlos por separado inflaría justo el informe del que
depende la revisión humana.
Lo demás se identifica por su forma normalizada: una cita reformulada
NO es la misma cita, y ahí la literalidad es el criterio correcto.
"""
if self.kind == "figure":
number = normalize(self.text.split()[0]) if self.text.split() else ""
return ("figure", number, self.unit)
return (self.kind, self.norm)
_QUOTED = re.compile(r'["“„‟«]([^"“”„‟«»]{3,})'
r'["”„‟»]')
@@ -315,6 +333,16 @@ def extract_claims(spec: dict) -> list[Claim]:
`meta` queda fuera a propósito: el título del spec no se dibuja en ningún
fotograma, es el nombre del fichero.
`narration` entra, y es de lo más importante que entra: lo que se dibuja
en pantalla son etiquetas cortas que el modelo copia, pero la narración es
prosa que redacta el sitio natural para deslizar una cifra de más. Se
trata como cualquier prosa del spec: aporta sus citas, cifras y fechas. No
aporta nombres propios, por lo mismo que no los aportan `headline` o
`caption`: una frase entera no es una etiqueta identificadora, y sacar
nombres de dentro de la prosa exigiría adivinar por mayúsculas y llenaría
el informe de ruido. La cifra y la cita, que son lo que se fabrica, están
cubiertas.
"""
claims: list[Claim] = []
for i, shot in enumerate(spec.get("shots") or []):
@@ -328,12 +356,16 @@ def extract_claims(spec: dict) -> list[Claim]:
# 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)
narration = shot.get("narration")
if isinstance(narration, str):
shot_claims.extend(
_claims_from_text(narration, f"shots.{i}.narration", "narration"))
claims.extend(shot_claims)
seen: set[tuple[str, str]] = set()
seen: set[tuple] = set()
unique: list[Claim] = []
for claim in claims:
fingerprint = (claim.kind, claim.norm)
fingerprint = claim.fingerprint
if not claim.norm or fingerprint in seen:
continue
seen.add(fingerprint)
+25
View File
@@ -101,6 +101,31 @@ title a human reads, not a filename.
- version is 1. Keep meta at 1080x1920.
- audio.preset pick the one whose mood fits the shape you chose:
{presets}
# 3b. Narration — the voice-over
Every shot takes an optional `narration`: one or two spoken sentences, read \
aloud by the renderer and burned in as captions. Write it for the ear.
- **The first line is the whole hook.** Two seconds decide whether anyone \
watches the rest, and the opening shot's narration is those two seconds. Lead \
with the strangest true thing you have, not with a preamble.
- Keep a line under 25 words. Long sentences lose the listener and stretch the \
shot; the renderer will not cut your voice off, it will make the shot longer \
instead, and a Short that drifts past 45 seconds is a Short people leave.
- **Do not read the screen aloud.** The captions already show your words and \
the template already shows its own. If the shot draws "35,000 FT", the voice \
says what that altitude meant, not the number again.
- Spoken register, not caption register: normal sentence case, ordinary \
punctuation, whole words. The on-screen props are terse and uppercase; the \
narration is a person talking. Write "seventeenth of November" rather than \
"17 NOV" the voice reads exactly what you type, and it will say "one seven \
N-O-V" if you make it.
- Not every shot needs one. Silence over a single strong image is a choice, \
and a wall of continuous talking is not.
- Everything in section 4 applies to narration word for word. It is prose you \
compose rather than a label you copy, which makes it the easiest place to \
slip in a figure no source gave you and it is checked exactly like the rest.
- The closing shot carries the domain, uppercase, no protocol: {domain}
# 4. Grounding — this is the part that matters
+62 -7
View File
@@ -27,6 +27,7 @@ __all__ = [
"SpecInvalid",
"validate_spec",
"editorial_notes",
"estimated_duration",
"describe_templates",
"TARGET_MIN_DURATION",
"TARGET_MAX_DURATION",
@@ -224,6 +225,22 @@ def _check_audio(audio: Any, total: float,
return errors
#: Tope de la narración de un shot, el mismo que aplica shortsmith. Rechazarla
#: aquí cuesta un reintento del modelo; rechazarla allí cuesta el render entero.
MAX_NARRATION_CHARS = 320
def _check_narration(narration: Any, path: str) -> list[str]:
if narration is None:
return []
if not isinstance(narration, str):
return [f"{path}.narration: se esperaba texto"]
if len(narration) > MAX_NARRATION_CHARS:
return [f"{path}.narration: {len(narration)} caracteres, el máximo es "
f"{MAX_NARRATION_CHARS}"]
return []
def _total_duration(spec: dict) -> float:
total = 0.0
for shot in spec.get("shots") or []:
@@ -275,9 +292,10 @@ def validate_spec(spec: Any, templates: dict[str, dict],
f"(las plantillas son: {known})")
continue
for key in shot:
if key not in ("template", "duration", "props"):
if key not in ("template", "duration", "props", "narration"):
errors.append(f"{path}.{key}: campo no permitido "
"(las válidas son: template, duration, props)")
"(las válidas son: template, duration, props, narration)")
errors.extend(_check_narration(shot.get("narration"), path))
duration = shot.get("duration")
if not isinstance(duration, (int, float)) or isinstance(duration, bool):
errors.append(f"{path}.duration: obligatoria y numérica")
@@ -303,6 +321,36 @@ def validate_spec(spec: Any, templates: dict[str, dict],
raise SpecInvalid(errors)
#: Caracteres por segundo de la voz (Piper `en_US-lessac-medium` a length_scale
#: 1.0). Medido el 2026-08-06: 82 caracteres en 5.78 s. Sirve para ESTIMAR aquí
#: lo que shortsmith sabrá exacto al sintetizar.
NARRATION_CHARS_PER_SECOND = 14.2
#: El respiro que shortsmith deja tras cada línea antes de permitir el corte.
NARRATION_PAD = 0.45
def estimated_duration(spec: dict) -> float:
"""Lo que durará el vídeo, no lo que suman las duraciones declaradas.
Con narración, la duración declarada es un suelo: shortsmith estira el shot
si la frase no cabe. Sin esta estimación el modelo escribiría 40 s de shots,
les colgaría narración a todos y recibiría un Short de 55 s sin que nada le
hubiera avisado el aviso llegaría del render, cuando ya está pagado.
"""
total = 0.0
for shot in spec.get("shots") or []:
if not isinstance(shot, dict):
continue
declared = shot.get("duration")
declared = float(declared) if isinstance(declared, (int, float)) else 0.0
narration = shot.get("narration")
if isinstance(narration, str) and narration.strip():
spoken = len(narration.strip()) / NARRATION_CHARS_PER_SECOND + NARRATION_PAD
declared = max(declared, spoken)
total += declared
return total
def editorial_notes(spec: dict) -> list[str]:
"""Lo que no viola el contrato pero sí el encargo.
@@ -311,15 +359,22 @@ def editorial_notes(spec: dict) -> list[str]:
si insiste, se renderiza igual antes que tirar la generación a la basura.
"""
notes = []
total = _total_duration(spec)
declared = _total_duration(spec)
total = estimated_duration(spec)
stretched = total > declared + 0.5
how = (f"la duración estimada son {total:.1f}s con la narración "
f"({declared:.1f}s de shots)" if stretched
else f"la duración total son {total:.1f}s")
if total < TARGET_MIN_DURATION:
notes.append(f"la duración total son {total:.1f}s y el objetivo es "
notes.append(f"{how} 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")
fix = ("recorta narración: la voz manda sobre la duración declarada"
if stretched else "recorta shots o acorta duraciones")
notes.append(f"{how} y el objetivo es "
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: {fix}")
return notes