fix(short): una nota de duración vale una reescritura, no dos
Build & Deploy ResearchOwl / build-and-push (push) Successful in 39s
Build & Deploy ResearchOwl / build-and-push (push) Successful in 39s
`editorial_notes` documentaba "se comenta una vez y, si insiste, se renderiza igual", pero el bucle reintentaba dos veces: las sesiones 166, 167 y 168 gastaron los tres intentos y las tres acabaron renderizando un spec que seguía pasándose. Un spec que ya cumple el contrato es renderizable; los intentos que quedan son para el contrato, que sí es binario. Y de dos specs válidos se guarda el que menos se sale del objetivo, no el primero. Obedecer a medias es obedecer: antes, una reescritura que bajaba de 70 s a 50 s se tiraba entera y salía el largo. El prompt, que era la mitad que faltaba. Decirle "20-45 segundos" no le sirve de nada: la duración real no está escrita en ninguna parte del spec, sale de sumar plano a plano el mayor entre lo declarado y lo que tarda la voz, y eso no lo puede calcular quien no sabe a qué velocidad se le lee. Ahora lleva el ritmo (2,8 palabras por segundo), la cuenta por plano, un presupuesto de 80 palabras de narración, y dos topes que salen del propio ejemplo en vez de estar inventados: 12 palabras por línea (tope 18) y 6 s por plano. El anterior — "una línea de menos de 25 palabras" — no describía nada que el canal hubiera publicado, y el modelo escribía líneas de 25 y 27 sin saltarse ninguna regla. Medido con cinco generaciones reales sobre el material de la sesión 168: antes 3 intentos siempre, con el vídeo fuera del objetivo (47,5 s y 45,4 s). Ahora 2, y el borrador cae dentro: 39,8 / 37,3 s. En dos de las cinco el reintento ya no fue por duración sino por un fallo de contrato. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+98
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user