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:
+108
-15
@@ -12,7 +12,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
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"
|
||||
@@ -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."""
|
||||
long_spec = spec_with(*[shot(duration=10.0) for _ in range(6)]) # 60 s
|
||||
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
|
||||
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():
|
||||
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(
|
||||
spec_with(*[shot(duration=10.0) for _ in range(6)]))[0]
|
||||
assert editorial_notes(spec_with(shot(duration=30.0))) == []
|
||||
spec_with(*[shot(duration=10.0) for _ in range(6)]), TEMPLATES)[0]
|
||||
assert editorial_notes(spec_with(shot(duration=30.0)), TEMPLATES) == []
|
||||
|
||||
|
||||
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
|
||||
# ~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) == []
|
||||
assert editorial_notes(quiet, TEMPLATES) == []
|
||||
|
||||
doc = copy.deepcopy(quiet)
|
||||
for s in doc["shots"]:
|
||||
s["narration"] = "A" * 300
|
||||
|
||||
note = editorial_notes(doc)[0]
|
||||
note = editorial_notes(doc, TEMPLATES)[0]
|
||||
|
||||
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))
|
||||
pasado = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE + 0.1))
|
||||
|
||||
assert editorial_notes(justo) == []
|
||||
assert editorial_notes(pasado)
|
||||
assert editorial_notes(justo, TEMPLATES) == []
|
||||
assert editorial_notes(pasado, TEMPLATES)
|
||||
# Y el consejo se mide contra el objetivo, no contra el margen: se pide
|
||||
# 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():
|
||||
from src.generator.spec_contract import TARGET_GRACE, 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
|
||||
- TARGET_GRACE - 0.1)))
|
||||
- TARGET_GRACE - 0.1)), TEMPLATES)
|
||||
|
||||
|
||||
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"][1]["narration"] = " ".join(["word"] * 200)
|
||||
|
||||
note = editorial_notes(doc)[0]
|
||||
note = editorial_notes(doc, TEMPLATES)[0]
|
||||
|
||||
assert "palabras de narración" 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():
|
||||
"""Sin voz, pedir que recorte narración es mandarlo a arreglar algo que no
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --- 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():
|
||||
"""`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."""
|
||||
|
||||
Reference in New Issue
Block a user