diff --git a/docs/shortsmith-phases-4-6.md b/docs/shortsmith-phases-4-6.md index 33cf7f1..461bd7c 100644 --- a/docs/shortsmith-phases-4-6.md +++ b/docs/shortsmith-phases-4-6.md @@ -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 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.33–0.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.6–2.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 diff --git a/src/generator/short.py b/src/generator/short.py index c1a6fa8..d8ae856 100644 --- a/src/generator/short.py +++ b/src/generator/short.py @@ -277,7 +277,7 @@ class ShortProducer: logger.warning("Rerender rechazado por el contrato", session_id=session_id, errors=e.errors[:6]) return result - result.notes = editorial_notes(spec) + result.notes = editorial_notes(spec, templates) result.title = spec.get("meta", {}).get("title", topic) result.duration_s = sum(s.get("duration", 0) for s in spec["shots"]) diff --git a/src/generator/shortspec.py b/src/generator/shortspec.py index 62c96d8..cb55524 100644 --- a/src/generator/shortspec.py +++ b/src/generator/shortspec.py @@ -23,7 +23,7 @@ import structlog from src.generator.spec_contract import ( 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, 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. - 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 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) -def _closer_to_target(a: Optional[SpecResult], b: SpecResult) -> SpecResult: - """De dos specs válidos, el que menos se sale del objetivo. +def _closer_to_target(a: Optional[SpecResult], b: SpecResult, + 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 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 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: 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. @@ -539,7 +569,7 @@ class ShortSpecWriter: error=str(refresh_err)) continue - notes = editorial_notes(spec) + notes = editorial_notes(spec, self.templates) result = SpecResult(spec=spec, attempts=attempt, notes=notes, history=list(history)) if not notes: @@ -547,7 +577,7 @@ class ShortSpecWriter: shots=len(spec.get("shots", [])), notes=0) 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: # Nota editorial, no violación del contrato: se comenta y, si # insiste, se renderiza el intento que menos se pase. diff --git a/src/generator/spec_contract.py b/src/generator/spec_contract.py index eb25330..386c1cb 100644 --- a/src/generator/spec_contract.py +++ b/src/generator/spec_contract.py @@ -27,6 +27,8 @@ __all__ = [ "SpecInvalid", "validate_spec", "editorial_notes", + "opening_notes", + "opens_badly", "estimated_duration", "spoken_seconds", "sentence_count", @@ -569,14 +571,105 @@ def estimated_duration(spec: dict) -> float: 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. 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; 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) total = estimated_duration(spec) stretched = total > declared + 0.5 diff --git a/tests/test_shortspec.py b/tests/test_shortspec.py index 6a38b52..f1393a4 100644 --- a/tests/test_shortspec.py +++ b/tests/test_shortspec.py @@ -105,6 +105,17 @@ def test_prompt_states_the_editorial_constraints(): 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(): w, _ = writer("{}") 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" +@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 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.""" diff --git a/tests/test_spec_contract.py b/tests/test_spec_contract.py index 411bd56..1048d7e 100644 --- a/tests/test_spec_contract.py +++ b/tests/test_spec_contract.py @@ -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."""