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
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:
co-authored by
Claude Fable 5
parent
93a506b636
commit
a13c3062b6
@@ -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
|
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.
|
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`.
|
Full spec of the phase: `docs/shortsmith-phase2-spec.md`.
|
||||||
|
|
||||||
## YouTube (`/upload_short`)
|
## YouTube (`/upload_short`)
|
||||||
|
|||||||
@@ -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
|
`Audio.transitions` flag with boundaries passed alongside `silence` — additive, and
|
||||||
`sonar`'s reference gate must not notice.
|
`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
|
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.
|
the voice — but both come from the same new field.
|
||||||
|
|||||||
+16
-3
@@ -464,12 +464,25 @@ def _claims_message(result) -> str:
|
|||||||
else "no se llegó a escribir un spec")
|
else "no se llegó a escribir un spec")
|
||||||
lines.append(f"⚠️ Sin comprobación de fundamento: {why}.")
|
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("")
|
||||||
lines.append(f"✂️ {len(result.render_warnings)} textos recortados al dibujar:")
|
lines.append(f"✂️ {len(trimmed)} textos recortados al dibujar:")
|
||||||
for w in result.render_warnings[:5]:
|
for w in trimmed[:5]:
|
||||||
lines.append(f" • [{w.get('template', '?')}] {str(w.get('text', ''))[:60]}")
|
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:
|
if result.notes:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.extend(f"📏 {n}" for n in result.notes)
|
lines.extend(f"📏 {n}" for n in result.notes)
|
||||||
|
|||||||
@@ -168,6 +168,24 @@ class Claim:
|
|||||||
def norm(self) -> str:
|
def norm(self) -> str:
|
||||||
return normalize(self.text)
|
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,})'
|
_QUOTED = re.compile(r'["“„‟«]([^"“”„‟«»]{3,})'
|
||||||
r'["”„‟»]')
|
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
|
`meta` queda fuera a propósito: el título del spec no se dibuja en ningún
|
||||||
fotograma, es el nombre del fichero.
|
fotograma, es el nombre del fichero.
|
||||||
|
|
||||||
|
`narration` SÍ 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] = []
|
claims: list[Claim] = []
|
||||||
for i, shot in enumerate(spec.get("shots") or []):
|
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"}.
|
# Reconstruye "232 FT" a partir de {value: 232, unit: "FT"}.
|
||||||
for path_prefix, sub in _dicts_with_value_and_unit(props, base):
|
for path_prefix, sub in _dicts_with_value_and_unit(props, base):
|
||||||
shot_claims = _attach_units(sub, shot_claims, path_prefix)
|
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)
|
claims.extend(shot_claims)
|
||||||
|
|
||||||
seen: set[tuple[str, str]] = set()
|
seen: set[tuple] = set()
|
||||||
unique: list[Claim] = []
|
unique: list[Claim] = []
|
||||||
for claim in claims:
|
for claim in claims:
|
||||||
fingerprint = (claim.kind, claim.norm)
|
fingerprint = claim.fingerprint
|
||||||
if not claim.norm or fingerprint in seen:
|
if not claim.norm or fingerprint in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(fingerprint)
|
seen.add(fingerprint)
|
||||||
|
|||||||
@@ -101,6 +101,31 @@ title a human reads, not a filename.
|
|||||||
- version is 1. Keep meta at 1080x1920.
|
- version is 1. Keep meta at 1080x1920.
|
||||||
- audio.preset — pick the one whose mood fits the shape you chose:
|
- audio.preset — pick the one whose mood fits the shape you chose:
|
||||||
{presets}
|
{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}
|
- The closing shot carries the domain, uppercase, no protocol: {domain}
|
||||||
|
|
||||||
# 4. Grounding — this is the part that matters
|
# 4. Grounding — this is the part that matters
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ __all__ = [
|
|||||||
"SpecInvalid",
|
"SpecInvalid",
|
||||||
"validate_spec",
|
"validate_spec",
|
||||||
"editorial_notes",
|
"editorial_notes",
|
||||||
|
"estimated_duration",
|
||||||
"describe_templates",
|
"describe_templates",
|
||||||
"TARGET_MIN_DURATION",
|
"TARGET_MIN_DURATION",
|
||||||
"TARGET_MAX_DURATION",
|
"TARGET_MAX_DURATION",
|
||||||
@@ -224,6 +225,22 @@ def _check_audio(audio: Any, total: float,
|
|||||||
return errors
|
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:
|
def _total_duration(spec: dict) -> float:
|
||||||
total = 0.0
|
total = 0.0
|
||||||
for shot in spec.get("shots") or []:
|
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})")
|
f"(las plantillas son: {known})")
|
||||||
continue
|
continue
|
||||||
for key in shot:
|
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 "
|
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")
|
duration = shot.get("duration")
|
||||||
if not isinstance(duration, (int, float)) or isinstance(duration, bool):
|
if not isinstance(duration, (int, float)) or isinstance(duration, bool):
|
||||||
errors.append(f"{path}.duration: obligatoria y numérica")
|
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)
|
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]:
|
def editorial_notes(spec: dict) -> list[str]:
|
||||||
"""Lo que no viola el contrato pero sí el encargo.
|
"""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.
|
si insiste, se renderiza igual antes que tirar la generación a la basura.
|
||||||
"""
|
"""
|
||||||
notes = []
|
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:
|
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: "
|
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
|
||||||
"queda corto, añade un shot o alarga los que tienes")
|
"queda corto, añade un shot o alarga los que tienes")
|
||||||
elif total > TARGET_MAX_DURATION:
|
elif total > TARGET_MAX_DURATION:
|
||||||
notes.append(f"la duración total son {total:.1f}s y el objetivo es "
|
fix = ("recorta narración: la voz manda sobre la duración declarada"
|
||||||
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
|
if stretched else "recorta shots o acorta duraciones")
|
||||||
"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
|
return notes
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -140,3 +140,28 @@ class TestSessionFromFilename:
|
|||||||
assert _session_from_filename("myspec.json") is None
|
assert _session_from_filename("myspec.json") is None
|
||||||
assert _session_from_filename("") is None
|
assert _session_from_filename("") is None
|
||||||
assert _session_from_filename(None) 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
|
||||||
|
|||||||
@@ -286,3 +286,110 @@ def test_summary_lists_every_ungrounded_string(spec):
|
|||||||
summary = check_grounding(corrupted, CHUNKS).summary()
|
summary = check_grounding(corrupted, CHUNKS).summary()
|
||||||
assert "⚠️ 1 sin encontrar" in summary
|
assert "⚠️ 1 sin encontrar" in summary
|
||||||
assert '"12000"' 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"}
|
||||||
|
|||||||
@@ -254,3 +254,72 @@ def test_validation_accepts_a_template_nobody_wrote_here():
|
|||||||
"properties": {"title": {"type": "string", "minLength": 1}}}}
|
"properties": {"title": {"type": "string", "minLength": 1}}}}
|
||||||
validate_spec(spec_with({"template": "holo_scan", "duration": 30.0,
|
validate_spec(spec_with({"template": "holo_scan", "duration": 30.0,
|
||||||
"props": {"title": "X"}}), templates)
|
"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
|
||||||
|
|||||||
Reference in New Issue
Block a user