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
+107
View File
@@ -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"}