fix(short): el primer plano gastaba el gancho en la fecha

El gancho de un Short es lo que se DIBUJA, no solo lo que se dice. El prompt
llevaba la mitad hablada desde 4b («the first line is the whole hook») y no
tenía nada sobre la mitad vista. De los diecisiete short_en generados, once
abren mal: diez ponen la fecha en el titular —el texto más grande del vídeo—
mientras el subline de debajo ya lleva el sitio, y el output 131 abre con
document_quote.

Medido sobre el renderizador a 5,5 s de plano: las cinco plantillas con
`headline` lo ponen a tinta plena en 0,33-0,40 s, y shortsmith lo garantiza a
cualquier duración desde db1ac7e. Las tres que no lo tienen dejan la banda
superior del fotograma al nivel del fondo TODO el plano —su texto se escribe a
máquina más abajo y no está entero hasta 2,6-2,8 s—. Con 6,9 s de visionado
medio, abrir con una de esas regala el tercio de la ventana en el que se
decide todo.

La costura es `headline`, preguntada al esquema y no a una lista de nombres:
una plantilla nueva de shortsmith con titular podrá abrir sin tocar este repo,
y una sin él no podrá. El mismo pacto que el resto del contrato.

Y `_closer_to_target` ordenaba SOLO por segundos, lo que hacía decorativo el
aviso nuevo: una reescritura que arreglaba el titular pero se pasaba un
segundo perdía contra el intento que abría con una fecha. Ahora el gancho va
delante — los segundos son un gradiente, el gancho es binario.

Comprobado contra los 17 specs reales de producción: avisa de once y de
ninguno más. Los que pasan son los titulares que se quieren (62 CHILDREN,
23 HELICOPTERS, RELEASE 05).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-09-01 12:39:08 +00:00
co-authored by Claude Opus 5
parent a17edf43b7
commit 6d9b6025ba
6 changed files with 299 additions and 24 deletions
+27
View File
@@ -256,6 +256,33 @@ Available any time, zero researchowl changes, because the contract is fetched li
hook → evidence → unresolved question → CTA arc. Costs one commit, no deploy risk hook → evidence → unresolved question → CTA arc. Costs one commit, no deploy risk
beyond a prompt change. beyond a prompt change.
### The visual hook — **done 2026-09-01**
The narration hook was already in the prompt (§3b, "the first line is the whole hook").
What nobody had written down is that **the hook is also what is drawn**, and the specs
show it: of the seventeen `short_en` generated, eleven open badly. Ten spend the
headline — the largest text in the video — on a date, while the `subline` right below
already carries the place; one (output 131) opens with `document_quote`.
Measured on the renderer at a 5.5 s shot: the five templates with a `headline` prop put
it at full ink in **0.330.40 s**, and shortsmith guarantees that at any shot length
since `db1ac7e`. The three without one leave the top band of the frame at background
level for the *whole shot* — their content types in lower down and is not complete until
**2.62.8 s**. With an average view of 6.9 s, opening with one of those spends a third
of the window on a frame that has not said anything.
So the seam is `headline`, asked of the schema rather than of a hardcoded list: a new
shortsmith template with a headline may open a video, one without may not, and neither
case needs a change here. Prompt §3c states both halves; `opening_notes()` in
`spec_contract.py` enforces them as editorial notes, and `_closer_to_target` now ranks
the hook above the duration — without that, a rewrite that fixed the headline but ran a
second long would lose to the attempt that opened with a date, and the note would be
decoration.
Still open on the generator side: `document_quote` is also weak as the *second* shot for
the same reason, and nothing yet reads the `severe: true` auto-fit warnings shortsmith
already emits (one real caption was drawn at 20 px).
--- ---
## Implementation order ## Implementation order
+1 -1
View File
@@ -277,7 +277,7 @@ class ShortProducer:
logger.warning("Rerender rechazado por el contrato", logger.warning("Rerender rechazado por el contrato",
session_id=session_id, errors=e.errors[:6]) session_id=session_id, errors=e.errors[:6])
return result return result
result.notes = editorial_notes(spec) result.notes = editorial_notes(spec, templates)
result.title = spec.get("meta", {}).get("title", topic) result.title = spec.get("meta", {}).get("title", topic)
result.duration_s = sum(s.get("duration", 0) for s in spec["shots"]) result.duration_s = sum(s.get("duration", 0) for s in spec["shots"])
+36 -6
View File
@@ -23,7 +23,7 @@ import structlog
from src.generator.spec_contract import ( from src.generator.spec_contract import (
SpecInvalid, describe_templates, editorial_notes, estimated_duration, SpecInvalid, describe_templates, editorial_notes, estimated_duration,
max_words_in, validate_spec, NARRATION_ROUNDED_PAD, max_words_in, opens_badly, validate_spec, NARRATION_ROUNDED_PAD,
NARRATION_SENTENCE_SILENCE, NARRATION_WORDS_PER_SECOND, NARRATION_SENTENCE_SILENCE, NARRATION_WORDS_PER_SECOND,
TARGET_MAX_DURATION, TARGET_MIN_DURATION, TARGET_MAX_DURATION, TARGET_MIN_DURATION,
) )
@@ -215,6 +215,26 @@ 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. 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}
# 3c. The opening shot
The first shot is the hook, and the hook is what is *drawn*, not only what is \
said. Both of these are measured on the renderer you are writing for:
- **Open with a template that has a `headline`.** Those put a display-size line \
across the top of the frame within 0.4 seconds, whatever length you give the \
shot. The templates without one draw nothing up there at all: their content \
types in lower down and is not complete until about 2.6 seconds. The average \
view of a Short on this channel is under seven seconds, so opening with one of \
those spends a third of it on a frame that has not said anything yet. Those \
templates are good shots; they are not opening shots.
- **The headline carries the strangest concrete thing you have — a count, a \
quantity, an object — and never the date.** The date and the place have a home \
one size down in `subline`, and that is the right size for them. A headline \
reading "8 JAN 1981" tells someone who has not decided to watch anything at \
all; "62 CHILDREN" over a subline of "ONE SILVER CRAFT" tells them what the \
video is. Both are real openings from this channel, and the second one is the \
shape to copy.
# 4. Grounding — this is the part that matters # 4. Grounding — this is the part that matters
Every figure, quote, date, and proper noun in your spec must appear in the \ Every figure, quote, date, and proper noun in your spec must appear in the \
@@ -439,17 +459,27 @@ def _off_target(spec: dict) -> float:
return max(0.0, TARGET_MIN_DURATION - total, total - TARGET_MAX_DURATION) return max(0.0, TARGET_MIN_DURATION - total, total - TARGET_MAX_DURATION)
def _closer_to_target(a: Optional[SpecResult], b: SpecResult) -> SpecResult: def _closer_to_target(a: Optional[SpecResult], b: SpecResult,
"""De dos specs válidos, el que menos se sale del objetivo. templates: dict[str, dict]) -> SpecResult:
"""De dos specs válidos, el mejor: primero el gancho, luego la duración.
Antes se guardaba el PRIMERO válido y punto, con lo que una reescritura que Antes se guardaba el PRIMERO válido y punto, con lo que una reescritura que
obedecía la nota a medias — 53 s en vez de 58 — se tiraba entera y salía el obedecía la nota a medias — 53 s en vez de 58 — se tiraba entera y salía el
largo. El empate se lo lleva el anterior: sin razón para cambiar, no se largo. El empate se lo lleva el anterior: sin razón para cambiar, no se
cambia. cambia.
El gancho va delante de los segundos, y no por gusto: ordenar sólo por
duración deja pasar el caso que hace inútil el aviso de apertura — un
segundo intento que arregla el titular pero se pasa un segundo perdería
contra el primero, y el modelo habría obedecido la nota para nada. Los
segundos fuera de objetivo son un gradiente; abrir con una fecha o con un
plano que aún se está escribiendo es binario, y cuesta más.
""" """
if a is None: if a is None:
return b return b
return a if _off_target(a.spec) <= _off_target(b.spec) else b def rank(r: SpecResult) -> tuple[bool, float]:
return (opens_badly(r.spec, templates), _off_target(r.spec))
return a if rank(a) <= rank(b) else b
#: (system, prompt) -> texto del modelo. #: (system, prompt) -> texto del modelo.
@@ -539,7 +569,7 @@ class ShortSpecWriter:
error=str(refresh_err)) error=str(refresh_err))
continue continue
notes = editorial_notes(spec) notes = editorial_notes(spec, self.templates)
result = SpecResult(spec=spec, attempts=attempt, notes=notes, result = SpecResult(spec=spec, attempts=attempt, notes=notes,
history=list(history)) history=list(history))
if not notes: if not notes:
@@ -547,7 +577,7 @@ class ShortSpecWriter:
shots=len(spec.get("shots", [])), notes=0) shots=len(spec.get("shots", [])), notes=0)
return result return result
best = _closer_to_target(best, result) best = _closer_to_target(best, result, self.templates)
if note_rounds < NOTE_ATTEMPTS and attempt < MAX_ATTEMPTS: if note_rounds < NOTE_ATTEMPTS and attempt < MAX_ATTEMPTS:
# Nota editorial, no violación del contrato: se comenta y, si # Nota editorial, no violación del contrato: se comenta y, si
# insiste, se renderiza el intento que menos se pase. # insiste, se renderiza el intento que menos se pase.
+95 -2
View File
@@ -27,6 +27,8 @@ __all__ = [
"SpecInvalid", "SpecInvalid",
"validate_spec", "validate_spec",
"editorial_notes", "editorial_notes",
"opening_notes",
"opens_badly",
"estimated_duration", "estimated_duration",
"spoken_seconds", "spoken_seconds",
"sentence_count", "sentence_count",
@@ -569,14 +571,105 @@ def estimated_duration(spec: dict) -> float:
return total return total
def editorial_notes(spec: dict) -> list[str]: #: Meses como los escribe el modelo — el spec se genera en inglés — enteros y
#: abreviados. Sólo sirven para reconocer una fecha, nunca para rechazar nada.
_MONTHS = frozenset(
"JANUARY FEBRUARY MARCH APRIL MAY JUNE JULY AUGUST SEPTEMBER OCTOBER "
"NOVEMBER DECEMBER JAN FEB MAR APR JUN JUL AUG SEP SEPT OCT NOV DEC".split()
)
#: Un número de cuatro cifras en rango de año. Un titular que es sólo "1947" es
#: una fecha; uno que es sólo "62" es una cifra, y esa es exactamente la
#: diferencia que decide si esto avisa.
_YEAR = re.compile(r"\A(1[4-9]\d\d|20\d\d)\Z")
def _headline_is_only_a_date(headline: str) -> bool:
"""El titular no dice más que cuándo.
Pide dos cosas a la vez, y la segunda es la que evita el falso positivo que
importa: que TODOS los tokens sean mes o número, y que haya un mes o un año
entre ellos. "62 CHILDREN" tiene una palabra que no es ninguna de las dos y
se salva por la primera; "62" a secas pasa la primera y se salva por la
segunda, que es lo correcto una cifra desnuda es justo el titular que se
quiere.
"""
tokens = [t for t in re.split(r"[^A-Za-z0-9]+", headline.upper()) if t]
if not tokens or not all(t in _MONTHS or t.isdigit() for t in tokens):
return False
return any(t in _MONTHS or _YEAR.match(t) for t in tokens)
def opening_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
"""El primer plano es el gancho, y el gancho es lo que se DIBUJA.
Dos avisos, los dos medidos sobre este renderizador a 5,5 s de plano:
**Abrir con una plantilla que tenga `headline`.** Las cinco que lo tienen
ponen una línea a tamaño de display en la banda superior del fotograma en
0,33-0,40 s, y shortsmith lo garantiza a cualquier duración de plano desde
`db1ac7e` (`draw.entrance`). En las tres que no lo tienen esa banda se queda
en el nivel del fondo *todo el plano*: su contenido se escribe a máquina más
abajo y no está entero hasta 2,6-2,8 s. Con 6,9 s de visionado medio, abrir
con una de esas tres regala el tercio de la ventana en el que se decide todo.
Ocurrió: la sesión del output 131 abrió con `document_quote` y su primera
letra no aparecía hasta 1,33 s.
Deliberadamente NO es una lista de nombres. Se pregunta al esquema que
publica shortsmith, así que una plantilla nueva con titular podrá abrir sin
tocar esto y una sin él no podrá el mismo pacto que el resto del contrato.
**Y el titular lleva la cifra, no la fecha.** De los once casos distintos
generados hasta hoy, cinco abren con una fecha por titular mientras el
`subline` de debajo ya lleva el sitio: el texto más grande del fotograma se
gasta en metadatos. "8 JAN 1981" no le dice nada a quien aún no ha decidido
quedarse; "62 CHILDREN" sobre "ONE SILVER CRAFT" le dice de qué va el vídeo.
Los dos son titulares reales del canal.
"""
shots = spec.get("shots") or []
if not shots or not isinstance(shots[0], dict):
return []
first = shots[0]
schema = templates.get(first.get("template")) if isinstance(templates, dict) else None
if not isinstance(schema, dict):
# Plantilla desconocida: de eso ya se queja `validate_spec`, y con más
# razón. Aquí callar es lo correcto — un aviso editorial sobre algo que
# ni siquiera renderiza es ruido encima de un error.
return []
if "headline" not in (schema.get("properties") or {}):
return [f"el primer plano usa {first.get('template')!r}, que no dibuja "
"titular: su texto se escribe a máquina y no está entero hasta "
"pasados ~2,6s, que es cuando media audiencia ya se ha ido. Abre "
"con una plantilla que tenga `headline` y deja ésta para más "
"adelante en el vídeo"]
headline = (first.get("props") or {}).get("headline")
if isinstance(headline, str) and _headline_is_only_a_date(headline):
return [f"el titular del primer plano es sólo una fecha ({headline!r}): "
"es el texto más grande del vídeo y el único que se ve antes de "
"que decidan quedarse. Ponle la cifra o el objeto más raro que "
"tengas y baja la fecha al `subline`, que es donde ya está el "
"sitio"]
return []
def opens_badly(spec: dict, templates: dict[str, dict]) -> bool:
"""Si el gancho está mal. Lo usa el desempate entre intentos válidos."""
return bool(opening_notes(spec, templates))
def editorial_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
"""Lo que no viola el contrato pero sí el encargo. """Lo que no viola el contrato pero sí el encargo.
Va aparte de `validate_spec` justo porque no impide renderizar: un Short de 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; 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. si insiste, se renderiza igual antes que tirar la generación a la basura.
""" """
notes = [] # Primero el gancho: si el vídeo se abre mal, es lo que hay que arreglar
# antes que su duración.
notes = opening_notes(spec, templates)
declared = _total_duration(spec) declared = _total_duration(spec)
total = estimated_duration(spec) total = estimated_duration(spec)
stretched = total > declared + 0.5 stretched = total > declared + 0.5
+32
View File
@@ -105,6 +105,17 @@ def test_prompt_states_the_editorial_constraints():
assert "material" in prompt assert "material" in prompt
def test_the_prompt_states_what_the_opening_shot_has_to_do():
"""Las dos mitades del gancho visual. Sin la primera el modelo abre con una
plantilla que aún se está escribiendo; sin la segunda gasta el texto más
grande del vídeo en la fecha, que es lo que hizo en cinco de once casos."""
w, _ = writer("{}")
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
assert "`headline`" in prompt and "0.4 seconds" in prompt
assert "never the date" in prompt and "subline" in prompt
def test_prompt_includes_the_worked_example_in_full(): def test_prompt_includes_the_worked_example_in_full():
w, _ = writer("{}") w, _ = writer("{}")
prompt = w.build_prompt("Caso X", "material", None, "X.TEST") prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
@@ -394,6 +405,27 @@ async def test_a_rewrite_that_makes_it_worse_is_discarded():
assert result.attempts == 2, "se pagaron dos generaciones aunque valga la primera" assert result.attempts == 2, "se pagaron dos generaciones aunque valga la primera"
@pytest.mark.asyncio
async def test_a_fixed_hook_beats_a_closer_duration():
"""El desempate entre intentos válidos ordenaba SÓLO por segundos, y eso
hacía inútil el aviso de apertura: el modelo obedecía la nota, se pasaba un
poco de largo al reescribir, y se elegía igualmente el intento que abría con
una fecha. Los segundos son un gradiente; el gancho es binario y cuesta más.
"""
fecha = json.loads(json.dumps(GOOD))
fecha["shots"][0]["props"]["headline"] = "8 JAN 1981" # 30 s: en objetivo
arreglado = json.loads(json.dumps(GOOD))
arreglado["shots"][0]["props"]["headline"] = "62 CHILDREN"
for shot in arreglado["shots"]:
shot["duration"] = 24.0 # 48 s: se pasa
w, _ = writer(json.dumps(fecha), json.dumps(arreglado))
result = await w.write("Caso X", "material")
assert result.spec["shots"][0]["props"]["headline"] == "62 CHILDREN"
assert result.notes and "objetivo" in result.notes[0]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_a_note_does_not_eat_the_attempt_the_contract_needs(): async def test_a_note_does_not_eat_the_attempt_the_contract_needs():
"""Si la reescritura sale malformada, aún queda un intento para arreglarla.""" """Si la reescritura sale malformada, aún queda un intento para arreglarla."""
+108 -15
View File
@@ -12,7 +12,8 @@ from pathlib import Path
import pytest import pytest
from src.generator.spec_contract import ( from src.generator.spec_contract import (
SpecInvalid, describe_templates, editorial_notes, validate_spec, SpecInvalid, describe_templates, editorial_notes, opening_notes,
validate_spec,
) )
EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json" EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json"
@@ -167,7 +168,7 @@ def test_total_duration_ceiling_is_the_contract_not_the_target():
invalida el spec eso es una nota, no un error.""" invalida el spec eso es una nota, no un error."""
long_spec = spec_with(*[shot(duration=10.0) for _ in range(6)]) # 60 s long_spec = spec_with(*[shot(duration=10.0) for _ in range(6)]) # 60 s
validate_spec(long_spec, TEMPLATES) validate_spec(long_spec, TEMPLATES)
assert editorial_notes(long_spec) assert editorial_notes(long_spec, TEMPLATES)
too_long = spec_with(*[shot(duration=30.0) for _ in range(7)]) # 210 s too_long = spec_with(*[shot(duration=30.0) for _ in range(7)]) # 210 s
assert any("pasa del límite" in e for e in errors_of(too_long)) assert any("pasa del límite" in e for e in errors_of(too_long))
@@ -336,10 +337,10 @@ def test_extra_root_key_is_rejected():
def test_editorial_notes_flag_both_ends(): def test_editorial_notes_flag_both_ends():
assert "queda corto" in editorial_notes(spec_with(shot(duration=8.0)))[0] assert "queda corto" in editorial_notes(spec_with(shot(duration=8.0)), TEMPLATES)[0]
assert "recorta" in editorial_notes( assert "recorta" in editorial_notes(
spec_with(*[shot(duration=10.0) for _ in range(6)]))[0] spec_with(*[shot(duration=10.0) for _ in range(6)]), TEMPLATES)[0]
assert editorial_notes(spec_with(shot(duration=30.0))) == [] assert editorial_notes(spec_with(shot(duration=30.0)), TEMPLATES) == []
def test_a_spec_that_is_not_even_a_dict(): def test_a_spec_that_is_not_even_a_dict():
@@ -488,13 +489,13 @@ def test_narration_that_overshoots_the_target_is_flagged_as_narration():
# 3 shots de 8 s = 24 s declarados, dentro del objetivo y sin avisos. Con # 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. # ~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)]) quiet = spec_with(*[shot(duration=8.0) for _ in range(3)])
assert editorial_notes(quiet) == [] assert editorial_notes(quiet, TEMPLATES) == []
doc = copy.deepcopy(quiet) doc = copy.deepcopy(quiet)
for s in doc["shots"]: for s in doc["shots"]:
s["narration"] = "A" * 300 s["narration"] = "A" * 300
note = editorial_notes(doc)[0] note = editorial_notes(doc, TEMPLATES)[0]
assert "narración" in note and "estimada" in note assert "narración" in note and "estimada" in note
@@ -508,20 +509,20 @@ def test_a_second_over_the_target_is_not_worth_a_rewrite():
justo = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE - 0.1)) justo = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE - 0.1))
pasado = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE + 0.1)) pasado = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE + 0.1))
assert editorial_notes(justo) == [] assert editorial_notes(justo, TEMPLATES) == []
assert editorial_notes(pasado) assert editorial_notes(pasado, TEMPLATES)
# Y el consejo se mide contra el objetivo, no contra el margen: se pide # Y el consejo se mide contra el objetivo, no contra el margen: se pide
# bajar hasta 45, no hasta 46,5. # bajar hasta 45, no hasta 46,5.
assert "sobran 1.6s" in editorial_notes(pasado)[0] assert "sobran 1.6s" in editorial_notes(pasado, TEMPLATES)[0]
def test_the_grace_works_at_both_ends(): def test_the_grace_works_at_both_ends():
from src.generator.spec_contract import TARGET_GRACE, TARGET_MIN_DURATION from src.generator.spec_contract import TARGET_GRACE, TARGET_MIN_DURATION
assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION
- TARGET_GRACE + 0.1))) == [] - TARGET_GRACE + 0.1)), TEMPLATES) == []
assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION
- TARGET_GRACE - 0.1))) - TARGET_GRACE - 0.1)), TEMPLATES)
def test_the_advice_says_how_much_to_cut_and_from_where(): def test_the_advice_says_how_much_to_cut_and_from_where():
@@ -532,7 +533,7 @@ def test_the_advice_says_how_much_to_cut_and_from_where():
doc["shots"][0]["narration"] = "Short line." doc["shots"][0]["narration"] = "Short line."
doc["shots"][1]["narration"] = " ".join(["word"] * 200) doc["shots"][1]["narration"] = " ".join(["word"] * 200)
note = editorial_notes(doc)[0] note = editorial_notes(doc, TEMPLATES)[0]
assert "palabras de narración" in note assert "palabras de narración" in note
assert "shots.1" in note and "shots.0" not in note assert "shots.1" in note and "shots.0" not in note
@@ -541,16 +542,108 @@ def test_the_advice_says_how_much_to_cut_and_from_where():
def test_the_advice_for_a_silent_spec_never_mentions_narration(): def test_the_advice_for_a_silent_spec_never_mentions_narration():
"""Sin voz, pedir que recorte narración es mandarlo a arreglar algo que no """Sin voz, pedir que recorte narración es mandarlo a arreglar algo que no
existe: lo que sobra son duraciones declaradas.""" existe: lo que sobra son duraciones declaradas."""
note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]))[0] note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]), TEMPLATES)[0]
assert "narración" not in note and "duraciones declaradas" in note assert "narración" not in note and "duraciones declaradas" in note
def test_a_spec_without_narration_keeps_the_old_wording(): def test_a_spec_without_narration_keeps_the_old_wording():
note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]))[0] note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]), TEMPLATES)[0]
assert "duración total" in note and "estimada" not in note assert "duración total" in note and "estimada" not in note
# --- el gancho: lo que se ve en el primer plano ------------------------------
# Los titulares de abajo son los reales de los once casos distintos que el bot ha
# escrito. Se copian aquí en vez de generarlos porque el detector no se juzga
# contra ejemplos cómodos: se juzga contra lo que el modelo escribe de verdad.
#: Plantilla sin `headline`: su contenido se escribe a máquina más abajo y la
#: banda superior del fotograma se queda en el fondo todo el plano.
SIN_TITULAR = {"document_quote": {
"type": "object", "required": ["quote_a"],
"properties": {"source": {"type": "string"},
"quote_a": {"type": "string", "minLength": 1}}}}
FECHAS = ["APRIL 24 1964", "APRIL 24, 1964", "APRIL 24", "8 JAN 1981",
"OCT 16 1957", "NOVEMBER 12", "1947"]
NO_FECHAS = ["62 CHILDREN", "62 WITNESSES", "23 HELICOPTERS", "TRIANGLES",
"RELEASE 05", "LANDING TRACE", "62"]
def opening(template="radar_sweep", templates=None, **props):
spec = spec_with({"template": template, "duration": 6.0, "props": props},
)
return opening_notes(spec, templates if templates is not None else TEMPLATES)
def test_the_opening_shot_has_to_draw_a_headline():
"""Medido sobre el renderizador a 5,5 s de plano: las cinco plantillas con
`headline` lo ponen a tinta plena en 0,33-0,40 s; en las tres que no lo
tienen la banda superior no pasa del fondo en todo el plano y su texto no
está entero hasta 2,6-2,8 s. Con 6,9 s de visionado medio eso es un tercio
de la ventana. Ocurrió de verdad el output 131 abrió con `document_quote`.
"""
note = opening(template="document_quote", templates=SIN_TITULAR,
quote_a="“NO CONTACT”")[0]
assert "document_quote" in note and "titular" in note
# Y la misma plantilla más adelante en el vídeo no molesta a nadie: lo que
# se juzga es la apertura, no el catálogo.
permisivo = {**TEMPLATES, **SIN_TITULAR}
tarde = spec_with(shot(), {"template": "document_quote", "duration": 6.0,
"props": {"quote_a": "“NO CONTACT”"}})
assert opening_notes(tarde, permisivo) == []
def test_the_rule_is_asked_of_the_schema_not_of_a_list_of_names():
"""El corte no puede ser una lista de plantillas escrita a mano: shortsmith
añade plantillas sin avisar a este repo. Una inventada CON titular abre sin
tocar nada, y una inventada SIN él queda cubierta igual."""
nuevas = {
"plantilla_nueva_con_titular": {
"type": "object", "required": ["headline"],
"properties": {"headline": {"type": "string"}}},
"plantilla_nueva_sin_titular": {
"type": "object", "properties": {"body": {"type": "string"}}},
}
assert opening(template="plantilla_nueva_con_titular", templates=nuevas,
headline="62 CHILDREN") == []
assert opening(template="plantilla_nueva_sin_titular", templates=nuevas,
body="lo que sea")
@pytest.mark.parametrize("headline", FECHAS)
def test_a_headline_that_is_only_a_date_is_flagged(headline):
"""Cinco de los once casos abrieron así, con el sitio ya puesto en el
`subline` de debajo: el texto más grande del vídeo gastado en metadatos."""
note = opening(headline=headline)[0]
assert "fecha" in note and headline in note
@pytest.mark.parametrize("headline", NO_FECHAS)
def test_a_figure_is_not_mistaken_for_a_date(headline):
"""El control, y no es un adorno: sin él, un detector que marcara cualquier
titular con un número dentro pasaría todos los casos de arriba y estaría
rechazando exactamente los titulares que se quieren. "62" a secas es el que
lo decide es una cifra desnuda, que es el gancho ideal, no una fecha."""
assert opening(headline=headline) == []
def test_the_hook_note_comes_before_the_duration_one():
"""Los dos avisos pueden salir a la vez y quien los lee coge `[0]`. Primero
el gancho: un Short que se pasa cinco segundos se ve; uno cuya apertura no
dice nada no se ve entero de todas formas."""
largo = spec_with({"template": "radar_sweep", "duration": 90.0,
"props": {"headline": "8 JAN 1981"}})
notes = editorial_notes(largo, TEMPLATES)
assert len(notes) == 2
assert "fecha" in notes[0] and "objetivo" in notes[1]
def test_the_prompt_carries_how_much_text_actually_fits(): def test_the_prompt_carries_how_much_text_actually_fits():
"""`x-fits` es el único límite que nada rechaza: si no llega al prompt, el """`x-fits` es el único límite que nada rechaza: si no llega al prompt, el
modelo escribe una cita de 58 caracteres para un hueco de 16.""" modelo escribe una cita de 58 caracteres para un hueco de 16."""