feat(short): narración — el grounding la cubre, el contrato la admite y el prompt la guía
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s

El comprobador va primero, antes que el campo (fase 2 §12): la narración es
prosa que el modelo redacta, no una etiqueta que copia, y es donde se cuela
una cifra sin fuente. De paso, la huella de una cifra pasa a ser número +
unidad canónica: con la voz repitiendo la pantalla, '35,000 FT' y '35,000
feet' son el mismo dato y contarlos dos veces inflaría el informe del que
depende la revisión humana.

editorial_notes estima la duración CON la voz: la declarada es un suelo y sin
esto el modelo escribiría 40 s de shots, les colgaría narración y se enteraría
del Short de 65 s cuando ya está pagado.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-08-06 15:38:34 +00:00
co-authored by Claude Fable 5
parent 93a506b636
commit a13c3062b6
9 changed files with 375 additions and 13 deletions
+69
View File
@@ -254,3 +254,72 @@ def test_validation_accepts_a_template_nobody_wrote_here():
"properties": {"title": {"type": "string", "minLength": 1}}}}
validate_spec(spec_with({"template": "holo_scan", "duration": 30.0,
"props": {"title": "X"}}), templates)
# --- narración (fase 4b) ----------------------------------------------------
def test_a_shot_may_carry_narration():
doc = spec_with(shot(duration=25.0))
doc["shots"][0]["narration"] = "Three radars tracked it that night."
validate_spec(doc, TEMPLATES)
def test_an_overlong_narration_is_rejected_with_its_path():
doc = spec_with(shot(duration=25.0))
doc["shots"][0]["narration"] = "x" * 400
assert any("shots.0.narration" in e and "320" in e for e in errors_of(doc))
def test_narration_that_is_not_text_is_rejected():
doc = spec_with(shot(duration=25.0))
doc["shots"][0]["narration"] = ["a", "b"]
assert any("shots.0.narration" in e for e in errors_of(doc))
def test_an_unknown_shot_key_still_names_the_valid_ones():
doc = spec_with(shot(duration=25.0))
doc["shots"][0]["voiceover"] = "nope"
assert any("narration" in e for e in errors_of(doc))
def test_the_estimate_counts_the_voice_not_just_the_declared_seconds():
"""La duración declarada es un suelo: shortsmith estira el shot si la frase
no cabe, y el modelo tiene que enterarse ANTES de pagar el render."""
from src.generator.spec_contract import estimated_duration
doc = spec_with(shot(duration=3.0))
doc["shots"][0]["narration"] = "A" * 142 # ~10 s de voz
assert estimated_duration(doc) > 10.0
def test_a_shot_with_room_for_its_line_is_estimated_as_declared():
from src.generator.spec_contract import estimated_duration
doc = spec_with(shot(duration=30.0))
doc["shots"][0]["narration"] = "Short line."
assert estimated_duration(doc) == pytest.approx(30.0)
def test_narration_that_overshoots_the_target_is_flagged_as_narration():
"""El consejo tiene que decir QUÉ recortar: con la voz mandando, acortar
duraciones no arregla nada."""
# 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) == []
doc = copy.deepcopy(quiet)
for s in doc["shots"]:
s["narration"] = "A" * 300
note = editorial_notes(doc)[0]
assert "narración" in note and "estimada" 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]
assert "duración total" in note and "estimada" not in note