diff --git a/src/generator/shortspec.py b/src/generator/shortspec.py index 0c2d073..bc99b1f 100644 --- a/src/generator/shortspec.py +++ b/src/generator/shortspec.py @@ -22,7 +22,8 @@ from typing import Any, Awaitable, Callable, Optional import structlog from src.generator.spec_contract import ( - SpecInvalid, describe_templates, editorial_notes, validate_spec, + SpecInvalid, describe_templates, editorial_notes, estimated_duration, + validate_spec, NARRATION_WORDS_PER_SECOND, TARGET_MAX_DURATION, TARGET_MIN_DURATION, ) @@ -32,6 +33,36 @@ logger = structlog.get_logger() #: que arreglar es el prompt, no este número. MAX_ATTEMPTS = 3 +#: Reescrituras que se gastan en una nota editorial, no en un fallo de contrato. +#: UNA. Un spec que ya cumple el contrato y sólo se pasa de duración es +#: renderizable: la segunda reescritura no compraba un Short mejor, compraba una +#: generación más. Medido sobre las sesiones 166, 167 y 168 — las tres gastaron +#: los tres intentos por duración y las tres acabaron renderizando un spec que +#: seguía pasándose. Los intentos que quedan son para el contrato, que sí es +#: binario. Ver `_how_to_trim` en `spec_contract`: si la nota no se obedece a la +#: primera, lo que hay que arreglar es la nota. +NOTE_ATTEMPTS = 1 + +#: Cuánta narración cabe en un Short entero. Comprobación cruzada de la regla +#: de arriba, en la unidad que el modelo escribe: 80 palabras son unos 29 s de +#: voz, y con los respiros y algún plano mudo eso deja el vídeo cerca de 40 s. +#: El ejemplo de referencia habla 74. La sesión 167 habló 97 y salió a 47,5 s. +NARRATION_WORD_BUDGET = 80 + +#: Lo que mide una línea. No es preferencia de estilo: son las líneas del +#: ejemplo (11, 12, 10, 14, 12, 15 palabras). El tope anterior — "menos de 25" — +#: no describía nada que el canal hubiera publicado, y el modelo escribió líneas +#: de 25 y 27 palabras sin saltarse ninguna regla. +NARRATION_WORDS_PER_LINE = 12 +NARRATION_WORDS_PER_LINE_MAX = 18 + +#: Lo que dura un plano como mucho. También del ejemplo (6,0 s el más largo, +#: 5,6 de media). Sin este tope el modelo declaraba 42 s en seis planos de siete +#: segundos y LUEGO les colgaba la narración encima: el primer borrador salía a +#: 50 s las tres veces que se midió, y hacía falta una reescritura entera para +#: bajarlo. +MAX_SHOT_DURATION = 6.0 + EXAMPLE_PATH = Path(__file__).parent / "examples" / "jal1628.json" __all__ = ["ShortSpecWriter", "SpecResult", "SpecWriteFailed", "NARRATIVE_SHAPES"] @@ -84,10 +115,18 @@ exists, and a prop name that is not listed is a parse error, not a nuance. # 3. Rules -- Total duration 20-45 seconds. The contract allows 180; that is a ceiling, \ -not a target. Aim for {target_min:.0f}-{target_max:.0f}. -- Typically 6-9 shots. Give a shot the seconds its content needs to be read: \ -a card with four rows needs longer than a headline. +- **Total duration {target_min:.0f}-{target_max:.0f} seconds, and the voice is \ +what decides it, not the durations you declare.** The contract allows 180; that \ +is a ceiling, not a target. A narrated shot runs as long as its line takes to \ +say — the renderer never cuts the voice off, it grows the shot — so the whole \ +video is really about {word_budget} words of narration and no more. That is the \ +number to hold: **count the words of every `narration` you write, and stop at \ +{word_budget}.** Section 3b has the arithmetic behind it. +- Typically 6-9 shots, and **none of them longer than {max_shot:.0f} seconds** \ +— that is the example's longest, and its average is 5.6. Give a shot the \ +seconds its content needs to be read: a card with four rows needs longer than a \ +headline. A {max_shot:.0f}-second shot with a short line on it is not a \ +generous shot, it is a shot the viewer has already finished reading. - Every string is drawn as given. Write them the way they should appear: \ SHORT, UPPERCASE, no trailing punctuation. A headline is 2-5 words. - **"CABE ~N caracteres dibujados" is a width, and it is the one limit nothing \ @@ -116,9 +155,12 @@ 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. +- **Keep a line to {words_per_line} words, hard stop at {words_per_line_max}.** \ +That is the example's own average, and it is not a style preference: a 25-word \ +line is three seconds of your whole budget spent on one shot. 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 {target_max:.0f} 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. @@ -132,10 +174,22 @@ silent exactly the two that draw a quotation, where the voice would only be \ competing with words already on the frame. Chosen silence is an edit; a spec \ with one narrated shot out of eight is not a Short with a voice, it is a Short \ that forgot to speak. -- Narration costs seconds. A shot is never cut short to fit the voice — it \ -grows instead — so a line that needs six seconds in a four-second shot pushes \ -your whole total past the target. Write the line, then give the shot the time \ -the line actually takes. +- **Give every narrated shot enough time for its own line, and work it out \ +rather than guessing.** The voice reads about {words_per_second:.1f} words a \ +second and pauses a quarter second at every full stop, so: + + duration ≥ words ÷ {words_per_second:.1f} + half a second + + A twelve-word line needs five seconds; give that shot 5.0, not 4.0. This is \ +the one rule that makes your own arithmetic true: a shot runs for the LONGER of \ +its declared duration and its line — never shorter, the voice is never cut off \ +— so a shot that declares less than its line silently grows, and the video ends \ +up longer than the durations you wrote. Hold this rule and the total you \ +declare IS the video's length; break it once and nothing you counted means \ +anything. +- As a cross-check, all the narration in the spec together should come to about \ +{word_budget} words. The example below speaks 74. A spec that spoke 97 rendered \ +at 47.5 seconds and had to be cut. - 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. @@ -359,6 +413,25 @@ def _format_notes(notes: list[str]) -> str: "Return the adjusted JSON object.") +def _off_target(spec: dict) -> float: + """Segundos fuera de la ventana editorial. 0 = dentro.""" + total = estimated_duration(spec) + 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. + + 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. + """ + if a is None: + return b + return a if _off_target(a.spec) <= _off_target(b.spec) else b + + #: (system, prompt) -> texto del modelo. LLMCall = Callable[[str, str], Awaitable[str]] @@ -388,6 +461,11 @@ class ShortSpecWriter: domain=domain, target_min=TARGET_MIN_DURATION, target_max=TARGET_MAX_DURATION, + words_per_second=NARRATION_WORDS_PER_SECOND, + word_budget=NARRATION_WORD_BUDGET, + words_per_line=NARRATION_WORDS_PER_LINE, + words_per_line_max=NARRATION_WORDS_PER_LINE_MAX, + max_shot=MAX_SHOT_DURATION, example=_load_example(), article=article, context=context, @@ -404,6 +482,8 @@ class ShortSpecWriter: #: Un spec que cumple el contrato pero se pasa de duración. Se guarda #: para que un intento posterior peor no lo tire: es renderizable. best: Optional[SpecResult] = None + #: Reescrituras ya gastadas en notas editoriales. + note_rounds = 0 for attempt in range(1, MAX_ATTEMPTS + 1): if on_progress and attempt > 1: @@ -440,23 +520,33 @@ class ShortSpecWriter: notes = editorial_notes(spec) result = SpecResult(spec=spec, attempts=attempt, notes=notes, history=list(history)) - if notes and attempt < MAX_ATTEMPTS: - # Nota editorial, no violación del contrato: se comenta una vez - # y, si insiste, se renderiza igual. - best = best or result + if not notes: + logger.info("short spec válido", attempts=attempt, + shots=len(spec.get("shots", [])), notes=0) + return result + + best = _closer_to_target(best, result) + 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. + note_rounds += 1 history.append(notes) feedback = _format_notes(notes) continue - logger.info("short spec válido", attempts=attempt, - shots=len(spec.get("shots", [])), notes=len(notes)) - return result + logger.info("short spec válido pero fuera de objetivo", + attempts=attempt, shots=len(best.spec.get("shots", [])), + off_target=round(_off_target(best.spec), 1)) + best.attempts = attempt + best.history = history + return best if best is not None: # Un intento anterior sí cumplía el contrato. Vale más un Short # largo que ningún Short. logger.info("short spec: se recupera el intento válido anterior", attempts=MAX_ATTEMPTS, notes=best.notes) + best.attempts = MAX_ATTEMPTS best.history = history return best diff --git a/tests/test_shortspec.py b/tests/test_shortspec.py index abc26eb..1b4531f 100644 --- a/tests/test_shortspec.py +++ b/tests/test_shortspec.py @@ -134,15 +134,57 @@ def test_the_worked_example_narrates_most_of_its_shots(): def test_the_worked_example_declares_time_for_its_own_narration(): """Un plano que se queda corto para su propia voz enseña a infradeclarar: el render no corta la voz, alarga el plano, y el total se va del objetivo.""" - from src.generator.spec_contract import NARRATION_CHARS_PER_SECOND + from src.generator.spec_contract import NARRATION_WORDS_PER_SECOND, spoken_seconds example = json.loads(EXAMPLE.read_text()) for i, shot in enumerate(example["shots"]): narration = shot.get("narration", "") if not narration: continue - needs = len(narration) / NARRATION_CHARS_PER_SECOND - assert shot["duration"] >= needs, f"shot {i} declara menos de lo que habla" + # La cuenta que el prompt le pide al modelo, aplicada al ejemplo que le + # pone delante. Si no cuadran, la regla en prosa pierde. + rule = len(narration.split()) / NARRATION_WORDS_PER_SECOND + 0.5 + assert shot["duration"] >= rule, f"shot {i} declara menos de lo que habla" + assert shot["duration"] >= spoken_seconds(narration), \ + f"shot {i} se quedaría corto para su propia voz" + + +def test_the_prompt_gives_a_budget_the_model_can_count(): + """"20-45 segundos" no es accionable: la duración real no está escrita en el + spec, sale de sumar el mayor entre lo declarado y lo que tarda la voz. El + modelo sí puede contar sus `duration` y sus palabras, así que el encargo se + le da en esas dos unidades.""" + from src.generator.shortspec import NARRATION_WORD_BUDGET + from src.generator.spec_contract import NARRATION_WORDS_PER_SECOND + + w, _ = writer("{}") + prompt = w.build_prompt("Caso X", "material", None, "X.TEST") + + assert f"{NARRATION_WORDS_PER_SECOND:.1f} words a second" in prompt, \ + "sin el ritmo de la voz no hay cuenta que el modelo pueda hacer" + assert f"words ÷ {NARRATION_WORDS_PER_SECOND:.1f}" in prompt + assert f"{NARRATION_WORD_BUDGET} words" in prompt + + +def test_the_worked_example_obeys_the_budget_it_preaches(): + """El ejemplo es la señal más fuerte del prompt — más que cualquier regla en + prosa. Uno que hablara de más enseñaría a hablar de más, dijera lo que + dijera la sección 3b.""" + from src.generator.shortspec import ( + MAX_SHOT_DURATION, NARRATION_WORDS_PER_LINE, + NARRATION_WORDS_PER_LINE_MAX, NARRATION_WORD_BUDGET, + ) + + example = json.loads(EXAMPLE.read_text()) + lines = [len(s["narration"].split()) for s in example["shots"] if s.get("narration")] + + assert max(s["duration"] for s in example["shots"]) == MAX_SHOT_DURATION + + assert sum(lines) <= NARRATION_WORD_BUDGET + assert max(lines) <= NARRATION_WORDS_PER_LINE_MAX + # El tope corto se anuncia como "la media del ejemplo": si deja de serlo, la + # regla en prosa se convierte en un número inventado y el modelo la nota. + assert round(sum(lines) / len(lines)) == NARRATION_WORDS_PER_LINE def test_prompt_says_out_loud_that_there_is_no_article_yet(): @@ -220,15 +262,20 @@ async def test_three_failures_raise_but_keep_the_last_attempt(): @pytest.mark.asyncio async def test_an_off_target_duration_is_commented_once_then_accepted(): - """70 s cumple el contrato pero no el encargo: se comenta y, si el modelo - insiste, se renderiza igual antes que tirar la generación.""" + """70 s cumple el contrato pero no el encargo: se comenta UNA vez y, si el + modelo insiste, se renderiza igual antes que tirar la generación. + + Una y no dos. El tercer intento se reserva para el contrato, que sí es + binario: un spec largo se ve, uno malformado no se puede ni renderizar. + """ long_spec = json.loads(json.dumps(GOOD)) long_spec["shots"][0]["duration"] = 55.0 # 70 s en total w, llm = writer(json.dumps(long_spec)) result = await w.write("Caso X", "material") - assert result.attempts == MAX_ATTEMPTS + assert result.attempts == 2, "una nota no vale dos reescrituras" + assert len(llm.prompts) == 2 assert result.notes and "recorta" in result.notes[0] assert "off-brief" in llm.prompts[1] @@ -245,6 +292,51 @@ async def test_a_valid_attempt_is_not_thrown_away_by_a_worse_one(): assert result.notes +@pytest.mark.asyncio +async def test_the_rewrite_is_kept_when_it_obeys_the_note_only_halfway(): + """Obedecer a medias es obedecer. Antes se guardaba el PRIMER intento válido + y se descartaba la reescritura entera, así que un spec que había bajado de + 70 s a 50 s salía a 70.""" + long_spec = json.loads(json.dumps(GOOD)) + long_spec["shots"][0]["duration"] = 55.0 # 70 s + better = json.loads(json.dumps(GOOD)) + better["shots"][0]["duration"] = 35.0 # 50 s: sigue pasándose, pero menos + w, _ = writer(json.dumps(long_spec), json.dumps(better)) + + result = await w.write("Caso X", "material") + + assert result.spec["shots"][0]["duration"] == 35.0 + assert result.attempts == 2 + + +@pytest.mark.asyncio +async def test_a_rewrite_that_makes_it_worse_is_discarded(): + long_spec = json.loads(json.dumps(GOOD)) + long_spec["shots"][0]["duration"] = 55.0 # 70 s + worse = json.loads(json.dumps(GOOD)) + worse["shots"][0]["duration"] = 90.0 # 105 s + w, _ = writer(json.dumps(long_spec), json.dumps(worse)) + + result = await w.write("Caso X", "material") + + assert result.spec["shots"][0]["duration"] == 55.0 + assert result.attempts == 2, "se pagaron dos generaciones aunque valga la primera" + + +@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.""" + long_spec = json.loads(json.dumps(GOOD)) + long_spec["shots"][0]["duration"] = 55.0 + w, llm = writer(json.dumps(long_spec), "esto no es JSON", json.dumps(GOOD)) + + result = await w.write("Caso X", "material") + + assert result.attempts == 3 and result.notes == [] + assert "off-brief" in llm.prompts[1] + assert "not a valid JSON" in llm.prompts[2] or "no es un objeto JSON" in llm.prompts[2] + + @pytest.mark.asyncio async def test_the_contract_is_refetched_after_a_validation_failure(): """Si el renderizador se actualizó a mitad de la run, la plantilla nueva