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:
+109
-19
@@ -22,7 +22,8 @@ from typing import Any, Awaitable, Callable, Optional
|
|||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from src.generator.spec_contract import (
|
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,
|
TARGET_MAX_DURATION, TARGET_MIN_DURATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,6 +33,36 @@ logger = structlog.get_logger()
|
|||||||
#: que arreglar es el prompt, no este número.
|
#: que arreglar es el prompt, no este número.
|
||||||
MAX_ATTEMPTS = 3
|
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"
|
EXAMPLE_PATH = Path(__file__).parent / "examples" / "jal1628.json"
|
||||||
|
|
||||||
__all__ = ["ShortSpecWriter", "SpecResult", "SpecWriteFailed", "NARRATIVE_SHAPES"]
|
__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
|
# 3. Rules
|
||||||
|
|
||||||
- Total duration 20-45 seconds. The contract allows 180; that is a ceiling, \
|
- **Total duration {target_min:.0f}-{target_max:.0f} seconds, and the voice is \
|
||||||
not a target. Aim for {target_min:.0f}-{target_max:.0f}.
|
what decides it, not the durations you declare.** The contract allows 180; that \
|
||||||
- Typically 6-9 shots. Give a shot the seconds its content needs to be read: \
|
is a ceiling, not a target. A narrated shot runs as long as its line takes to \
|
||||||
a card with four rows needs longer than a headline.
|
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: \
|
- Every string is drawn as given. Write them the way they should appear: \
|
||||||
SHORT, UPPERCASE, no trailing punctuation. A headline is 2-5 words.
|
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 \
|
- **"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 \
|
- **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 \
|
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.
|
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 \
|
- **Keep a line to {words_per_line} words, hard stop at {words_per_line_max}.** \
|
||||||
shot; the renderer will not cut your voice off, it will make the shot longer \
|
That is the example's own average, and it is not a style preference: a 25-word \
|
||||||
instead, and a Short that drifts past 45 seconds is a Short people leave.
|
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 \
|
- **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 \
|
the template already shows its own. If the shot draws "35,000 FT", the voice \
|
||||||
says what that altitude meant, not the number again.
|
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 \
|
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 \
|
with one narrated shot out of eight is not a Short with a voice, it is a Short \
|
||||||
that forgot to speak.
|
that forgot to speak.
|
||||||
- Narration costs seconds. A shot is never cut short to fit the voice — it \
|
- **Give every narrated shot enough time for its own line, and work it out \
|
||||||
grows instead — so a line that needs six seconds in a four-second shot pushes \
|
rather than guessing.** The voice reads about {words_per_second:.1f} words a \
|
||||||
your whole total past the target. Write the line, then give the shot the time \
|
second and pauses a quarter second at every full stop, so:
|
||||||
the line actually takes.
|
|
||||||
|
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 \
|
- 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 \
|
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.
|
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.")
|
"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.
|
#: (system, prompt) -> texto del modelo.
|
||||||
LLMCall = Callable[[str, str], Awaitable[str]]
|
LLMCall = Callable[[str, str], Awaitable[str]]
|
||||||
|
|
||||||
@@ -388,6 +461,11 @@ class ShortSpecWriter:
|
|||||||
domain=domain,
|
domain=domain,
|
||||||
target_min=TARGET_MIN_DURATION,
|
target_min=TARGET_MIN_DURATION,
|
||||||
target_max=TARGET_MAX_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(),
|
example=_load_example(),
|
||||||
article=article,
|
article=article,
|
||||||
context=context,
|
context=context,
|
||||||
@@ -404,6 +482,8 @@ class ShortSpecWriter:
|
|||||||
#: Un spec que cumple el contrato pero se pasa de duración. Se guarda
|
#: 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.
|
#: para que un intento posterior peor no lo tire: es renderizable.
|
||||||
best: Optional[SpecResult] = None
|
best: Optional[SpecResult] = None
|
||||||
|
#: Reescrituras ya gastadas en notas editoriales.
|
||||||
|
note_rounds = 0
|
||||||
|
|
||||||
for attempt in range(1, MAX_ATTEMPTS + 1):
|
for attempt in range(1, MAX_ATTEMPTS + 1):
|
||||||
if on_progress and attempt > 1:
|
if on_progress and attempt > 1:
|
||||||
@@ -440,23 +520,33 @@ class ShortSpecWriter:
|
|||||||
notes = editorial_notes(spec)
|
notes = editorial_notes(spec)
|
||||||
result = SpecResult(spec=spec, attempts=attempt, notes=notes,
|
result = SpecResult(spec=spec, attempts=attempt, notes=notes,
|
||||||
history=list(history))
|
history=list(history))
|
||||||
if notes and attempt < MAX_ATTEMPTS:
|
if not notes:
|
||||||
# Nota editorial, no violación del contrato: se comenta una vez
|
logger.info("short spec válido", attempts=attempt,
|
||||||
# y, si insiste, se renderiza igual.
|
shots=len(spec.get("shots", [])), notes=0)
|
||||||
best = best or result
|
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)
|
history.append(notes)
|
||||||
feedback = _format_notes(notes)
|
feedback = _format_notes(notes)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info("short spec válido", attempts=attempt,
|
logger.info("short spec válido pero fuera de objetivo",
|
||||||
shots=len(spec.get("shots", [])), notes=len(notes))
|
attempts=attempt, shots=len(best.spec.get("shots", [])),
|
||||||
return result
|
off_target=round(_off_target(best.spec), 1))
|
||||||
|
best.attempts = attempt
|
||||||
|
best.history = history
|
||||||
|
return best
|
||||||
|
|
||||||
if best is not None:
|
if best is not None:
|
||||||
# Un intento anterior sí cumplía el contrato. Vale más un Short
|
# Un intento anterior sí cumplía el contrato. Vale más un Short
|
||||||
# largo que ningún Short.
|
# largo que ningún Short.
|
||||||
logger.info("short spec: se recupera el intento válido anterior",
|
logger.info("short spec: se recupera el intento válido anterior",
|
||||||
attempts=MAX_ATTEMPTS, notes=best.notes)
|
attempts=MAX_ATTEMPTS, notes=best.notes)
|
||||||
|
best.attempts = MAX_ATTEMPTS
|
||||||
best.history = history
|
best.history = history
|
||||||
return best
|
return best
|
||||||
|
|
||||||
|
|||||||
+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():
|
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
|
"""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."""
|
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())
|
example = json.loads(EXAMPLE.read_text())
|
||||||
for i, shot in enumerate(example["shots"]):
|
for i, shot in enumerate(example["shots"]):
|
||||||
narration = shot.get("narration", "")
|
narration = shot.get("narration", "")
|
||||||
if not narration:
|
if not narration:
|
||||||
continue
|
continue
|
||||||
needs = len(narration) / NARRATION_CHARS_PER_SECOND
|
# La cuenta que el prompt le pide al modelo, aplicada al ejemplo que le
|
||||||
assert shot["duration"] >= needs, f"shot {i} declara menos de lo que habla"
|
# 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():
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_an_off_target_duration_is_commented_once_then_accepted():
|
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
|
"""70 s cumple el contrato pero no el encargo: se comenta UNA vez y, si el
|
||||||
insiste, se renderiza igual antes que tirar la generación."""
|
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 = json.loads(json.dumps(GOOD))
|
||||||
long_spec["shots"][0]["duration"] = 55.0 # 70 s en total
|
long_spec["shots"][0]["duration"] = 55.0 # 70 s en total
|
||||||
w, llm = writer(json.dumps(long_spec))
|
w, llm = writer(json.dumps(long_spec))
|
||||||
|
|
||||||
result = await w.write("Caso X", "material")
|
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 result.notes and "recorta" in result.notes[0]
|
||||||
assert "off-brief" in llm.prompts[1]
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_the_contract_is_refetched_after_a_validation_failure():
|
async def test_the_contract_is_refetched_after_a_validation_failure():
|
||||||
"""Si el renderizador se actualizó a mitad de la run, la plantilla nueva
|
"""Si el renderizador se actualizó a mitad de la run, la plantilla nueva
|
||||||
|
|||||||
Reference in New Issue
Block a user