Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4099e3eecb | ||
|
|
02e553fffa | ||
|
|
8000ef2145 |
@@ -97,6 +97,24 @@ What actually landed, and where it differs from the plan below:
|
||||
redacted." costs three quarters of a second that a characters-only model gives away.
|
||||
Estimates now land within half a second of the three rendered MP4s. The lesson is the
|
||||
older one restated: a constant taken from one sample is a guess with a decimal point.
|
||||
- **The spec writer was under-declaring because the prompt asked for three things that
|
||||
could not all be true** (2026-08-13). Every narrated spec the bot had written declared
|
||||
less time than its own narration needed on three or four shots out of five or six —
|
||||
3.8 to 8.2 seconds of drift per Short. The video still came out the right length,
|
||||
because `plan()` grows the shot; what was wrong was that the spec described a visual
|
||||
rhythm that never rendered. The cause was not a lazy model. The prompt asked at once
|
||||
for lines of up to 18 words, shots of at most 6 seconds, and enough declared time for
|
||||
the line — and 18 words need 7.3 s, so the set is unsatisfiable. The model broke the
|
||||
only one of the three that nothing checked. Three fixes, all deterministic and none
|
||||
costing a generation to find: the taught rule got the per-sentence term it was missing
|
||||
(`words/2.75 + 0.5` fell short on 14 of the 28 measured lines, by up to 2.27 s — so
|
||||
even perfect obedience under-declared); the word cap is now *derived* from the shot
|
||||
cap rather than written by hand, so the contradiction cannot come back; and the worked
|
||||
example, which violated its own rule on two of its six lines, was cut to obey it. That
|
||||
last one is the lesson worth keeping: **the example is the strongest signal in the
|
||||
prompt, so an example that breaks a rule teaches the breakage**, whatever the prose
|
||||
says. It is the same finding as "el ejemplo del prompt habla, y por eso los specs
|
||||
vuelven a hablar", arriving a second time.
|
||||
|
||||
Original plan, kept for the record:
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
"caption": "CONTACT HOLDS RELATIVE POSITION",
|
||||
"turn_deg": 360
|
||||
},
|
||||
"narration": "He tried to shake it. Full circle, steep descent, and it was still there."
|
||||
"narration": "He tried to shake it. Full circle, steep descent, and it stayed there."
|
||||
},
|
||||
{
|
||||
"template": "signal_strips",
|
||||
@@ -207,7 +207,7 @@
|
||||
"url": "THEEXCLUSIONZONE.COM",
|
||||
"show_mark": true
|
||||
},
|
||||
"narration": "The file was never closed. It was filed, and left where anyone can read it."
|
||||
"narration": "The file was never closed. It was left where anyone can read it."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+42
-20
@@ -23,7 +23,8 @@ import structlog
|
||||
|
||||
from src.generator.spec_contract import (
|
||||
SpecInvalid, describe_templates, editorial_notes, estimated_duration,
|
||||
validate_spec, NARRATION_WORDS_PER_SECOND,
|
||||
max_words_in, validate_spec, NARRATION_ROUNDED_PAD,
|
||||
NARRATION_SENTENCE_SILENCE, NARRATION_WORDS_PER_SECOND,
|
||||
TARGET_MAX_DURATION, TARGET_MIN_DURATION,
|
||||
)
|
||||
|
||||
@@ -49,20 +50,26 @@ NOTE_ATTEMPTS = 1
|
||||
#: 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
|
||||
#: Lo que dura un plano como mucho. 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
|
||||
|
||||
#: Lo que mide una línea. No es preferencia de estilo: es la media del ejemplo.
|
||||
NARRATION_WORDS_PER_LINE = 12
|
||||
|
||||
#: Y el tope duro NO se escribe a mano: es cuántas palabras caben en el plano
|
||||
#: más largo que se permite declarar. Escribirlos por separado fue el fallo que
|
||||
#: hizo que el modelo infradeclarase casi todos sus planos, y no por pereza: el
|
||||
#: prompt le pedía a la vez líneas de hasta 18 palabras, planos de 6 s como
|
||||
#: mucho y tiempo declarado suficiente para su propia voz. Las tres a la vez son
|
||||
#: imposibles — 18 palabras piden 7,3 s — así que el modelo rompía la única que
|
||||
#: nadie comprobaba, la duración declarada. Derivando el tope de la duración, la
|
||||
#: contradicción no puede volver.
|
||||
NARRATION_WORDS_PER_LINE_MAX = max_words_in(MAX_SHOT_DURATION, sentences=2)
|
||||
|
||||
EXAMPLE_PATH = Path(__file__).parent / "examples" / "jal1628.json"
|
||||
|
||||
__all__ = ["ShortSpecWriter", "SpecResult", "SpecWriteFailed", "NARRATIVE_SHAPES"]
|
||||
@@ -156,11 +163,12 @@ aloud by the renderer and burned in as captions. Write it for the ear.
|
||||
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 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.
|
||||
That is the example's own average, and the hard stop is not a style preference \
|
||||
either — it is exactly as much as fits in the longest shot you are allowed to \
|
||||
declare. {words_per_line_max} words is {max_shot:.0f} seconds; the same limit, \
|
||||
written twice. Go past it and the shot has to grow, because the renderer will \
|
||||
not cut your voice off — it makes 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.
|
||||
@@ -175,13 +183,25 @@ 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.
|
||||
- **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:
|
||||
rather than guessing.** The voice reads about {words_per_second:g} words a \
|
||||
second and pauses a quarter second at every full stop, so **count the words AND \
|
||||
count the sentences**:
|
||||
|
||||
duration ≥ words ÷ {words_per_second:.1f} + half a second
|
||||
duration ≥ words ÷ {words_per_second:g} + {sentence_pause} × sentences + \
|
||||
{rounded_pad}
|
||||
|
||||
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 \
|
||||
The second term is the one that catches people out. "Witness identities. \
|
||||
Sensor details. Locations redacted." is six words and three full stops: it is \
|
||||
not a fast line, it is three quarters of a second of silence on top. Two lines \
|
||||
of the same length do not take the same time if one of them is chopped.
|
||||
|
||||
Worked, on the example below: shot 1 speaks twelve words in one sentence, so \
|
||||
12 ÷ {words_per_second:g} + {sentence_pause} + {rounded_pad} = 5.1, and it \
|
||||
declares 5.5. Shot 4 speaks thirteen words in two sentences, so 13 ÷ \
|
||||
{words_per_second:g} + 0.5 + {rounded_pad} = 5.7, and it declares 6.0. Round \
|
||||
up, never down.
|
||||
|
||||
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 \
|
||||
@@ -466,6 +486,8 @@ class ShortSpecWriter:
|
||||
words_per_line=NARRATION_WORDS_PER_LINE,
|
||||
words_per_line_max=NARRATION_WORDS_PER_LINE_MAX,
|
||||
max_shot=MAX_SHOT_DURATION,
|
||||
sentence_pause=f"{NARRATION_SENTENCE_SILENCE:g}",
|
||||
rounded_pad=f"{NARRATION_ROUNDED_PAD:g}",
|
||||
example=_load_example(),
|
||||
article=article,
|
||||
context=context,
|
||||
|
||||
@@ -29,6 +29,9 @@ __all__ = [
|
||||
"editorial_notes",
|
||||
"estimated_duration",
|
||||
"spoken_seconds",
|
||||
"sentence_count",
|
||||
"teachable_seconds",
|
||||
"max_words_in",
|
||||
"describe_templates",
|
||||
"TARGET_MIN_DURATION",
|
||||
"TARGET_MAX_DURATION",
|
||||
@@ -350,11 +353,54 @@ NARRATION_PAD = 0.45
|
||||
#: le pide que recorte texto, no tiempo.
|
||||
NARRATION_WORDS_PER_SECOND = 2.75
|
||||
|
||||
#: El respiro redondeado hacia arriba, para la regla que se le enseña al modelo.
|
||||
#: `NARRATION_PAD` son 0,45 s; "medio segundo" se sostiene en la cabeza y va
|
||||
#: sobrado, que es la dirección correcta en la que equivocarse.
|
||||
NARRATION_ROUNDED_PAD = 0.5
|
||||
|
||||
#: Final de frase: un punto pegado a la palabra y seguido de espacio o de nada.
|
||||
#: El decimal de "1.5" no cuenta, y por eso mira lo que va detrás.
|
||||
_SENTENCE_END = re.compile(r"[.!?](?=\s|$)")
|
||||
|
||||
|
||||
def sentence_count(line: str) -> int:
|
||||
"""Frases de una línea, contadas como las cuenta Piper para sus pausas."""
|
||||
return max(1, len(_SENTENCE_END.findall(line))) if line.strip() else 0
|
||||
|
||||
|
||||
def teachable_seconds(words: int, sentences: int = 1) -> float:
|
||||
"""Lo que hay que DECLARAR para una línea, en las unidades que el modelo cuenta.
|
||||
|
||||
Es `spoken_seconds` traducido de caracteres a palabras. La traducción hace
|
||||
falta porque un LLM no cuenta caracteres de fiar, pero sí cuenta palabras y
|
||||
puntos — y la regla tiene que ser computable por quien debe obedecerla, o no
|
||||
es una regla, es un deseo.
|
||||
|
||||
Los dos términos son los mismos que los de la voz. La versión anterior del
|
||||
prompt colapsaba el segundo en un "+ medio segundo" fijo, y ese es el mismo
|
||||
error de clase que tenía el estimador antes del 2026-08-12: sin pausa por
|
||||
frase, una línea troceada en frases cortas se da por rápida. Medido contra
|
||||
las 28 líneas reales, aquella regla se quedaba corta en 14 y hasta 2,27 s —
|
||||
o sea que un modelo que la obedeciera al pie de la letra seguiría
|
||||
infradeclarando la mitad de sus planos. Con el término por frase el peor
|
||||
caso baja a 1,27 s y sólo en 5 de 28.
|
||||
"""
|
||||
return (words / NARRATION_WORDS_PER_SECOND
|
||||
+ sentences * NARRATION_SENTENCE_SILENCE
|
||||
+ NARRATION_ROUNDED_PAD)
|
||||
|
||||
|
||||
def max_words_in(seconds: float, sentences: int = 1) -> int:
|
||||
"""Cuántas palabras caben en un plano de esa duración, según la regla de arriba.
|
||||
|
||||
Existe para que el tope de palabras por línea y el tope de duración de plano
|
||||
no puedan volver a contradecirse: se deriva uno del otro en vez de escribir
|
||||
los dos a mano.
|
||||
"""
|
||||
room = seconds - sentences * NARRATION_SENTENCE_SILENCE - NARRATION_ROUNDED_PAD
|
||||
return max(1, int(room * NARRATION_WORDS_PER_SECOND))
|
||||
|
||||
|
||||
def spoken_seconds(line: str) -> float:
|
||||
"""Lo que tarda la voz en decir una línea, sin el respiro final.
|
||||
|
||||
|
||||
+78
-7
@@ -134,7 +134,9 @@ 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_WORDS_PER_SECOND, spoken_seconds
|
||||
from src.generator.spec_contract import (
|
||||
NARRATION_PAD, sentence_count, spoken_seconds, teachable_seconds,
|
||||
)
|
||||
|
||||
example = json.loads(EXAMPLE.read_text())
|
||||
for i, shot in enumerate(example["shots"]):
|
||||
@@ -142,10 +144,14 @@ def test_the_worked_example_declares_time_for_its_own_narration():
|
||||
if not narration:
|
||||
continue
|
||||
# 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), \
|
||||
# pone delante. Si no cuadran, la regla en prosa pierde: el ejemplo es
|
||||
# la señal más fuerte. Dos de estas seis líneas NO cumplían — y ese es
|
||||
# exactamente el defecto que el modelo copiaba.
|
||||
rule = teachable_seconds(len(narration.split()), sentence_count(narration))
|
||||
assert shot["duration"] >= rule - 1e-9, \
|
||||
f"shot {i} declara menos de lo que su propia regla pide"
|
||||
# Y contra la voz medida, no sólo contra la regla que la aproxima.
|
||||
assert shot["duration"] >= spoken_seconds(narration) + NARRATION_PAD - 1e-9, \
|
||||
f"shot {i} se quedaría corto para su propia voz"
|
||||
|
||||
|
||||
@@ -160,9 +166,11 @@ def test_the_prompt_gives_a_budget_the_model_can_count():
|
||||
w, _ = writer("{}")
|
||||
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
|
||||
|
||||
assert f"{NARRATION_WORDS_PER_SECOND:.1f} words a second" in prompt, \
|
||||
# La constante exacta, no redondeada: el prompt trae una cuenta trabajada, y
|
||||
# con "2.8" el divisor mostrado no reproduce el resultado mostrado.
|
||||
assert f"{NARRATION_WORDS_PER_SECOND:g} 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"words ÷ {NARRATION_WORDS_PER_SECOND:g}" in prompt
|
||||
assert f"{NARRATION_WORD_BUDGET} words" in prompt
|
||||
|
||||
|
||||
@@ -187,6 +195,69 @@ def test_the_worked_example_obeys_the_budget_it_preaches():
|
||||
assert round(sum(lines) / len(lines)) == NARRATION_WORDS_PER_LINE
|
||||
|
||||
|
||||
def test_the_longest_line_allowed_fits_in_the_longest_shot_allowed():
|
||||
"""La contradicción que hacía infradeclarar, convertida en test.
|
||||
|
||||
El prompt pedía a la vez líneas de hasta 18 palabras, planos de 6 s como
|
||||
mucho, y tiempo declarado suficiente para la propia voz. Las tres juntas son
|
||||
imposibles — 18 palabras piden 7,3 s — y el modelo rompía la única que nadie
|
||||
comprobaba. Si alguien vuelve a subir el tope de palabras a mano, esto salta.
|
||||
"""
|
||||
from src.generator.shortspec import (
|
||||
MAX_SHOT_DURATION, NARRATION_WORDS_PER_LINE_MAX,
|
||||
)
|
||||
from src.generator.spec_contract import teachable_seconds
|
||||
|
||||
# En el caso malo: una línea al tope, partida en dos frases (dos pausas).
|
||||
assert teachable_seconds(NARRATION_WORDS_PER_LINE_MAX, 2) <= MAX_SHOT_DURATION
|
||||
|
||||
# Y el tope es apretado, no una holgura cómoda que esconda otra vez el fallo:
|
||||
# una palabra más ya no cabría.
|
||||
assert teachable_seconds(NARRATION_WORDS_PER_LINE_MAX + 1, 2) > MAX_SHOT_DURATION
|
||||
|
||||
|
||||
def test_the_prompt_rule_counts_the_pauses_and_not_only_the_words():
|
||||
"""Palabras por segundo a secas es el mismo error que tenía el estimador.
|
||||
|
||||
Medido contra las 28 líneas que el bot ha narrado de verdad, `words/2.75 +
|
||||
0.5` se quedaba corta en 14 y hasta 2,27 s: obedecerla al pie de la letra
|
||||
seguía infradeclarando media docena de planos. La regla del prompt tiene que
|
||||
llevar el término por frase, y tiene que ser LA MISMA que aplican los tests.
|
||||
"""
|
||||
w, _ = writer("{}")
|
||||
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
|
||||
|
||||
assert "× sentences" in prompt
|
||||
assert "count the sentences" in prompt
|
||||
|
||||
# Una línea troceada cuesta más que una seguida con las mismas palabras.
|
||||
from src.generator.spec_contract import teachable_seconds
|
||||
assert teachable_seconds(12, 3) > teachable_seconds(12, 1)
|
||||
|
||||
|
||||
def test_the_worked_arithmetic_in_the_prompt_is_actually_right():
|
||||
"""Un ejemplo numérico equivocado enseña la cuenta equivocada, y se lee antes
|
||||
que la fórmula."""
|
||||
import re as _re
|
||||
from src.generator.spec_contract import sentence_count, teachable_seconds
|
||||
|
||||
w, _ = writer("{}")
|
||||
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
|
||||
example = json.loads(EXAMPLE.read_text())
|
||||
|
||||
worked = _re.findall(
|
||||
r"[Ss]hot (\d+) speaks \w+ words in \w+ sentences?, so [^=]+= ([\d.]+)",
|
||||
prompt)
|
||||
# Sin esto el test pasa en vacío si alguien reescribe el párrafo.
|
||||
assert len(worked) == 2, f"no se encontraron las cuentas trabajadas: {worked}"
|
||||
|
||||
for index, claimed in worked:
|
||||
narration = example["shots"][int(index)]["narration"]
|
||||
real = teachable_seconds(len(narration.split()), sentence_count(narration))
|
||||
assert abs(real - float(claimed)) < 0.05, \
|
||||
f"el prompt dice {claimed}s para shots.{index}, la regla da {real:.2f}s"
|
||||
|
||||
|
||||
def test_prompt_says_out_loud_that_there_is_no_article_yet():
|
||||
w, _ = writer("{}")
|
||||
assert "No article URL yet" in w.build_prompt("X", "m", None, "X.TEST")
|
||||
|
||||
Reference in New Issue
Block a user