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
+10
View File
@@ -99,6 +99,16 @@ for case files, `pulse` for debunks, `static` for document drops. The model
picks one to match the narrative shape, and the cheapest way to audition them
is the edit loop — change `audio.preset` in the spec file and re-send it.
**Narration:** a shot may carry a `narration` line. shortsmith speaks it and
burns the words in as captions, and **the grounding check reads it like
everything else** — narration is prose the model composes rather than a label it
copies, which makes it the easiest place for an unsourced figure to appear.
Timing works the other way round from the rest of the spec: a shot's declared
`duration` becomes a floor, and the shot grows if the line needs longer, so the
claims report also carries how much the video stretched. The prompt tells the
model to lead with the hook, keep lines under 25 words, and never read the
screen aloud — the captions already show the words.
Full spec of the phase: `docs/shortsmith-phase2-spec.md`.
## YouTube (`/upload_short`)
+27 -1
View File
@@ -63,7 +63,33 @@ keeping until the palette itself proves its value. If it happens, it is an opt-i
`Audio.transitions` flag with boundaries passed alongside `silence` — additive, and
`sonar`'s reference gate must not notice.
### 4b. Narration (TTS) + burned-in captions
### 4b. Narration (TTS) + burned-in captions — **shipped 2026-08-06**
What actually landed, and where it differs from the plan below:
- **Piper as a standalone binary**, not the PyPI package: `piper-phonemize` is a
compiled extension whose wheels chase the interpreter version, and the image's
Python is pinned by digest. Binary, voice and config are all pinned by sha256 — the
voice model *is* the channel's sound.
- **`--noise_scale 0 --noise_w 0` is load-bearing.** Measured before building anything:
the same line twice gave 5.668 s and 5.796 s with different hashes. With the flags,
three runs and one hash. Without that probe the phase would have shipped a renderer
that quietly stopped being deterministic.
- **Declared duration became a floor**, as planned — plus a consequence the plan
missed: `audio.silence` windows are written in absolute seconds, so a stretched shot
slides them onto the wrong line. They are now remapped through the shot they pointed
at.
- **One caption size for the whole video**, fitted against the longest group. Per-group
fitting made the type jump between cues, which is the clearest tell of an
auto-captioned video.
- researchowl got the grounding extension first, as the order below demanded, and one
thing that order revealed: with the voice repeating on-screen figures, claims had to
be de-duplicated by *canonical unit* ("35,000 FT" and "35,000 feet" are one claim) or
every narrated Short would double its own review report.
Original plan, kept for the record:
### 4b (as planned). Narration (TTS) + burned-in captions
These two ship together: most Shorts are watched muted, so the captions matter more than
the voice — but both come from the same new field.
+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
+25
View File
@@ -140,3 +140,28 @@ class TestSessionFromFilename:
assert _session_from_filename("myspec.json") is None
assert _session_from_filename("") is None
assert _session_from_filename(None) is None
class TestNarrationWarnings:
"""shortsmith manda por el mismo canal los textos recortados y los avisos
de la voz. Piden acciones distintas, así que se muestran distintos."""
def test_a_silent_shot_is_reported_as_such(self):
text = _claims_message(result_with(render_warnings=[
{"kind": "narration", "text": "shots.2.narration not spoken: piper exited 1"}]))
assert "🔇" in text and "shots.2.narration" in text
assert "recortados" not in text
def test_a_stretched_video_says_so(self):
text = _claims_message(result_with(render_warnings=[
{"kind": "timing",
"text": "narration stretched the video from 32.0s to 41.5s"}]))
assert "41.5s" in text
assert "recortados" not in text
def test_trimmed_text_and_narration_do_not_get_mixed_up(self):
text = _claims_message(result_with(render_warnings=[
{"template": "data_card", "text": "FILA LARGA", "requested": 44, "size": 38},
{"kind": "narration", "text": "no voice installed"}]))
assert "1 textos recortados" in text # sólo cuenta el de dibujo
assert "🔇 no voice installed" in text
+107
View File
@@ -286,3 +286,110 @@ def test_summary_lists_every_ungrounded_string(spec):
summary = check_grounding(corrupted, CHUNKS).summary()
assert "⚠️ 1 sin encontrar" in summary
assert '"12000"' in summary
# --- narración (fase 4b) ----------------------------------------------------
# La narración es prosa que el modelo REDACTA, no una etiqueta que copia: es
# el sitio natural donde se cuela una cifra de más. Estos tests existen antes
# que el campo, a propósito — el comprobador se escribe sin conocer lo
# comprobado (fase 2 §12).
def test_a_fabricated_figure_hiding_in_the_narration_is_caught(spec):
"""El caso que justifica la fase entera: la pantalla dice la verdad y la
voz añade una cifra que no está en ninguna fuente."""
narrated = copy.deepcopy(spec)
narrated["shots"][0]["narration"] = (
"Three separate radars tracked the object at 41,000 feet.")
report = check_grounding(narrated, CHUNKS)
assert [c.text for c in report.ungrounded] == ["41,000 feet"]
assert report.ungrounded[0].path == "shots.0.narration"
def test_a_narration_that_stays_with_the_sources_is_clean(spec):
narrated = copy.deepcopy(spec)
narrated["shots"][0]["narration"] = (
"On November 17, 1986, the crew was cruising at 35,000 feet over Alaska.")
assert check_grounding(narrated, CHUNKS).clean
def test_a_quote_invented_for_the_voice_over_is_caught(spec):
"""Una cita hablada es una cita: o es verbatim o no lo es."""
narrated = copy.deepcopy(spec)
narrated["shots"][0]["narration"] = (
'The captain said it was “the size of two aircraft carriers”.')
report = check_grounding(narrated, CHUNKS)
assert any("two aircraft carriers" in c.text for c in report.ungrounded)
def test_saying_out_loud_what_the_screen_already_shows_is_one_claim(spec):
"""Deduplicado por (tipo, forma normalizada): la misma cifra dibujada y
narrada no infla el informe ni se cuenta dos veces."""
plain = check_grounding(spec, CHUNKS)
narrated = copy.deepcopy(spec)
narrated["shots"][0]["narration"] = "Three radars, 35,000 feet over Alaska."
report = check_grounding(narrated, CHUNKS)
assert report.total == plain.total
def test_a_leak_from_the_example_is_still_diagnosed_as_a_leak_in_narration(spec):
"""La narración no se libra del segundo diagnóstico: una cifra del ejemplo
del prompt sigue siendo fuga, no invención."""
narrated = copy.deepcopy(spec)
narrated["shots"][0]["narration"] = "The aircraft itself measured 232 ft."
report = check_grounding(narrated, [{"url": "u", "content": "Nothing useful."}])
assert any(c.text == "232 ft" for c in report.contaminated)
def test_narration_contributes_no_name_claims(spec):
"""Una frase entera no es una etiqueta identificadora. Sacar nombres de la
prosa exigiría adivinar por mayúsculas y llenaría el informe de ruido."""
narrated = copy.deepcopy(spec)
narrated["shots"][0]["narration"] = "Nobody at Hangar Eighteen ever confirmed it."
claims = [c for c in extract_claims(narrated) if c.path == "shots.0.narration"]
assert claims == []
def test_a_shot_without_narration_behaves_exactly_as_before(spec):
"""El campo es opcional: un spec de hoy tiene que dar el mismo informe."""
before = check_grounding(spec, CHUNKS)
with_empty = copy.deepcopy(spec)
with_empty["shots"][0]["narration"] = ""
after = check_grounding(with_empty, CHUNKS)
assert after.total == before.total and after.clean == before.clean
def test_the_same_figure_spelled_two_ways_is_one_claim():
"""La huella de una cifra es su número y su unidad canónica. Sin esto, la
voz repitiendo la pantalla duplicaría medio informe."""
spec = {"shots": [{"template": "scale_bars", "props": {
"headline": "CRUISE ALTITUDE 35,000 FT"},
"narration": "They were cruising at 35,000 feet."}]}
claims = extract_claims(spec)
assert len([c for c in claims if c.kind == "figure"]) == 1
def test_the_same_number_with_different_units_stays_two_claims():
"""1,600 ft y 1,600 m no son el mismo dato, y confundirlos sería peor que
duplicar: escondería una cifra sin comprobar."""
spec = {"shots": [{"template": "x", "props": {
"headline": "1,600 FT ACROSS", "footer": "1,600 m of runway"}}]}
figures = [c for c in extract_claims(spec) if c.kind == "figure"]
assert {c.unit for c in figures} == {"ft", "m"}
+69
View File
@@ -254,3 +254,72 @@ def test_validation_accepts_a_template_nobody_wrote_here():
"properties": {"title": {"type": "string", "minLength": 1}}}}
validate_spec(spec_with({"template": "holo_scan", "duration": 30.0,
"props": {"title": "X"}}), templates)
# --- narración (fase 4b) ----------------------------------------------------
def test_a_shot_may_carry_narration():
doc = spec_with(shot(duration=25.0))
doc["shots"][0]["narration"] = "Three radars tracked it that night."
validate_spec(doc, TEMPLATES)
def test_an_overlong_narration_is_rejected_with_its_path():
doc = spec_with(shot(duration=25.0))
doc["shots"][0]["narration"] = "x" * 400
assert any("shots.0.narration" in e and "320" in e for e in errors_of(doc))
def test_narration_that_is_not_text_is_rejected():
doc = spec_with(shot(duration=25.0))
doc["shots"][0]["narration"] = ["a", "b"]
assert any("shots.0.narration" in e for e in errors_of(doc))
def test_an_unknown_shot_key_still_names_the_valid_ones():
doc = spec_with(shot(duration=25.0))
doc["shots"][0]["voiceover"] = "nope"
assert any("narration" in e for e in errors_of(doc))
def test_the_estimate_counts_the_voice_not_just_the_declared_seconds():
"""La duración declarada es un suelo: shortsmith estira el shot si la frase
no cabe, y el modelo tiene que enterarse ANTES de pagar el render."""
from src.generator.spec_contract import estimated_duration
doc = spec_with(shot(duration=3.0))
doc["shots"][0]["narration"] = "A" * 142 # ~10 s de voz
assert estimated_duration(doc) > 10.0
def test_a_shot_with_room_for_its_line_is_estimated_as_declared():
from src.generator.spec_contract import estimated_duration
doc = spec_with(shot(duration=30.0))
doc["shots"][0]["narration"] = "Short line."
assert estimated_duration(doc) == pytest.approx(30.0)
def test_narration_that_overshoots_the_target_is_flagged_as_narration():
"""El consejo tiene que decir QUÉ recortar: con la voz mandando, acortar
duraciones no arregla nada."""
# 3 shots de 8 s = 24 s declarados, dentro del objetivo y sin avisos. Con
# ~21 s de voz cada uno se van a 65 s: sin la estimación, silencio absoluto.
quiet = spec_with(*[shot(duration=8.0) for _ in range(3)])
assert editorial_notes(quiet) == []
doc = copy.deepcopy(quiet)
for s in doc["shots"]:
s["narration"] = "A" * 300
note = editorial_notes(doc)[0]
assert "narración" in note and "estimada" in note
def test_a_spec_without_narration_keeps_the_old_wording():
note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]))[0]
assert "duración total" in note and "estimada" not in note