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
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:
co-authored by
Claude Fable 5
parent
93a506b636
commit
a13c3062b6
@@ -140,3 +140,28 @@ class TestSessionFromFilename:
|
||||
assert _session_from_filename("myspec.json") is None
|
||||
assert _session_from_filename("") is None
|
||||
assert _session_from_filename(None) is None
|
||||
|
||||
|
||||
class TestNarrationWarnings:
|
||||
"""shortsmith manda por el mismo canal los textos recortados y los avisos
|
||||
de la voz. Piden acciones distintas, así que se muestran distintos."""
|
||||
|
||||
def test_a_silent_shot_is_reported_as_such(self):
|
||||
text = _claims_message(result_with(render_warnings=[
|
||||
{"kind": "narration", "text": "shots.2.narration not spoken: piper exited 1"}]))
|
||||
assert "🔇" in text and "shots.2.narration" in text
|
||||
assert "recortados" not in text
|
||||
|
||||
def test_a_stretched_video_says_so(self):
|
||||
text = _claims_message(result_with(render_warnings=[
|
||||
{"kind": "timing",
|
||||
"text": "narration stretched the video from 32.0s to 41.5s"}]))
|
||||
assert "41.5s" in text
|
||||
assert "recortados" not in text
|
||||
|
||||
def test_trimmed_text_and_narration_do_not_get_mixed_up(self):
|
||||
text = _claims_message(result_with(render_warnings=[
|
||||
{"template": "data_card", "text": "FILA LARGA", "requested": 44, "size": 38},
|
||||
{"kind": "narration", "text": "no voice installed"}]))
|
||||
assert "1 textos recortados" in text # sólo cuenta el de dibujo
|
||||
assert "🔇 no voice installed" in text
|
||||
|
||||
@@ -286,3 +286,110 @@ def test_summary_lists_every_ungrounded_string(spec):
|
||||
summary = check_grounding(corrupted, CHUNKS).summary()
|
||||
assert "⚠️ 1 sin encontrar" in summary
|
||||
assert '"12000"' in summary
|
||||
|
||||
|
||||
# --- narración (fase 4b) ----------------------------------------------------
|
||||
# La narración es prosa que el modelo REDACTA, no una etiqueta que copia: es
|
||||
# el sitio natural donde se cuela una cifra de más. Estos tests existen antes
|
||||
# que el campo, a propósito — el comprobador se escribe sin conocer lo
|
||||
# comprobado (fase 2 §12).
|
||||
|
||||
|
||||
def test_a_fabricated_figure_hiding_in_the_narration_is_caught(spec):
|
||||
"""El caso que justifica la fase entera: la pantalla dice la verdad y la
|
||||
voz añade una cifra que no está en ninguna fuente."""
|
||||
narrated = copy.deepcopy(spec)
|
||||
narrated["shots"][0]["narration"] = (
|
||||
"Three separate radars tracked the object at 41,000 feet.")
|
||||
|
||||
report = check_grounding(narrated, CHUNKS)
|
||||
|
||||
assert [c.text for c in report.ungrounded] == ["41,000 feet"]
|
||||
assert report.ungrounded[0].path == "shots.0.narration"
|
||||
|
||||
|
||||
def test_a_narration_that_stays_with_the_sources_is_clean(spec):
|
||||
narrated = copy.deepcopy(spec)
|
||||
narrated["shots"][0]["narration"] = (
|
||||
"On November 17, 1986, the crew was cruising at 35,000 feet over Alaska.")
|
||||
|
||||
assert check_grounding(narrated, CHUNKS).clean
|
||||
|
||||
|
||||
def test_a_quote_invented_for_the_voice_over_is_caught(spec):
|
||||
"""Una cita hablada es una cita: o es verbatim o no lo es."""
|
||||
narrated = copy.deepcopy(spec)
|
||||
narrated["shots"][0]["narration"] = (
|
||||
'The captain said it was “the size of two aircraft carriers”.')
|
||||
|
||||
report = check_grounding(narrated, CHUNKS)
|
||||
|
||||
assert any("two aircraft carriers" in c.text for c in report.ungrounded)
|
||||
|
||||
|
||||
def test_saying_out_loud_what_the_screen_already_shows_is_one_claim(spec):
|
||||
"""Deduplicado por (tipo, forma normalizada): la misma cifra dibujada y
|
||||
narrada no infla el informe ni se cuenta dos veces."""
|
||||
plain = check_grounding(spec, CHUNKS)
|
||||
narrated = copy.deepcopy(spec)
|
||||
narrated["shots"][0]["narration"] = "Three radars, 35,000 feet over Alaska."
|
||||
|
||||
report = check_grounding(narrated, CHUNKS)
|
||||
|
||||
assert report.total == plain.total
|
||||
|
||||
|
||||
def test_a_leak_from_the_example_is_still_diagnosed_as_a_leak_in_narration(spec):
|
||||
"""La narración no se libra del segundo diagnóstico: una cifra del ejemplo
|
||||
del prompt sigue siendo fuga, no invención."""
|
||||
narrated = copy.deepcopy(spec)
|
||||
narrated["shots"][0]["narration"] = "The aircraft itself measured 232 ft."
|
||||
|
||||
report = check_grounding(narrated, [{"url": "u", "content": "Nothing useful."}])
|
||||
|
||||
assert any(c.text == "232 ft" for c in report.contaminated)
|
||||
|
||||
|
||||
def test_narration_contributes_no_name_claims(spec):
|
||||
"""Una frase entera no es una etiqueta identificadora. Sacar nombres de la
|
||||
prosa exigiría adivinar por mayúsculas y llenaría el informe de ruido."""
|
||||
narrated = copy.deepcopy(spec)
|
||||
narrated["shots"][0]["narration"] = "Nobody at Hangar Eighteen ever confirmed it."
|
||||
|
||||
claims = [c for c in extract_claims(narrated) if c.path == "shots.0.narration"]
|
||||
|
||||
assert claims == []
|
||||
|
||||
|
||||
def test_a_shot_without_narration_behaves_exactly_as_before(spec):
|
||||
"""El campo es opcional: un spec de hoy tiene que dar el mismo informe."""
|
||||
before = check_grounding(spec, CHUNKS)
|
||||
with_empty = copy.deepcopy(spec)
|
||||
with_empty["shots"][0]["narration"] = ""
|
||||
|
||||
after = check_grounding(with_empty, CHUNKS)
|
||||
|
||||
assert after.total == before.total and after.clean == before.clean
|
||||
|
||||
|
||||
def test_the_same_figure_spelled_two_ways_is_one_claim():
|
||||
"""La huella de una cifra es su número y su unidad canónica. Sin esto, la
|
||||
voz repitiendo la pantalla duplicaría medio informe."""
|
||||
spec = {"shots": [{"template": "scale_bars", "props": {
|
||||
"headline": "CRUISE ALTITUDE 35,000 FT"},
|
||||
"narration": "They were cruising at 35,000 feet."}]}
|
||||
|
||||
claims = extract_claims(spec)
|
||||
|
||||
assert len([c for c in claims if c.kind == "figure"]) == 1
|
||||
|
||||
|
||||
def test_the_same_number_with_different_units_stays_two_claims():
|
||||
"""1,600 ft y 1,600 m no son el mismo dato, y confundirlos sería peor que
|
||||
duplicar: escondería una cifra sin comprobar."""
|
||||
spec = {"shots": [{"template": "x", "props": {
|
||||
"headline": "1,600 FT ACROSS", "footer": "1,600 m of runway"}}]}
|
||||
|
||||
figures = [c for c in extract_claims(spec) if c.kind == "figure"]
|
||||
|
||||
assert {c.unit for c in figures} == {"ft", "m"}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user