Files
researchowl/tests/test_spec_contract.py
T
ChemaVXandClaude Fable 5 a13c3062b6
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
feat(short): narración — el grounding la cubre, el contrato la admite y el prompt la guía
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>
2026-08-06 15:38:34 +00:00

326 lines
13 KiB
Python

"""Validación local del spec contra el esquema publicado por shortsmith.
Los esquemas de abajo son una COPIA REDUCIDA de lo que devuelve
`GET /templates`, sólo para los tests: en producción se piden en vivo. Si
shortsmith cambia el contrato, quien lo nota es `test_shortsmith_live.py`, no
esto.
"""
import copy
import json
from pathlib import Path
import pytest
from src.generator.spec_contract import (
SpecInvalid, describe_templates, editorial_notes, validate_spec,
)
EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json"
TEMPLATES = {
"radar_sweep": {
"type": "object", "additionalProperties": False,
"required": ["headline"],
"properties": {
"headline": {"type": "string", "minLength": 1},
"subline": {"type": "string", "default": ""},
"contact_bearing_deg": {"type": "number", "minimum": 0,
"exclusiveMaximum": 360, "default": 210.0},
"sweeps": {"type": "number", "exclusiveMinimum": 0, "maximum": 10,
"default": 2.0},
},
},
"scale_bars": {
"type": "object", "additionalProperties": False,
"required": ["headline", "bars"],
"$defs": {"Bar": {
"type": "object", "additionalProperties": False,
"required": ["label", "value"],
"properties": {
"label": {"type": "string", "minLength": 1},
"value": {"type": "number", "exclusiveMinimum": 0},
"unit": {"type": "string", "default": ""},
"color": {"enum": ["ink", "amber", "amber_dark", "muted", "dim", "red"],
"type": "string", "default": "ink"},
"value_label": {"type": "string", "default": ""},
},
}},
"properties": {
"headline": {"type": "string", "minLength": 1},
"bars": {"type": "array", "items": {"$ref": "#/$defs/Bar"},
"minItems": 1, "maxItems": 3},
"quote": {"type": "array", "items": {"type": "string"}, "maxItems": 2},
"attribution": {"type": "string", "default": ""},
},
},
}
def shot(template="radar_sweep", duration=6.0, **props):
base = {"radar_sweep": {"headline": "3 RADARS"},
"scale_bars": {"headline": "ESCALA",
"bars": [{"label": "BOEING 747", "value": 232}]}}[template]
return {"template": template, "duration": duration, "props": {**base, **props}}
def spec_with(*shots, **meta):
return {
"version": 1,
"meta": {"id": "caso", "title": "Un caso", **meta},
"shots": list(shots) or [shot()],
}
def errors_of(spec, templates=None):
with pytest.raises(SpecInvalid) as exc:
validate_spec(spec, templates if templates is not None else TEMPLATES)
return exc.value.errors
# --- lo que pasa ------------------------------------------------------------
def test_a_minimal_valid_spec_passes():
validate_spec(spec_with(shot(duration=25.0)), TEMPLATES)
def test_the_reference_example_passes_against_its_own_templates():
"""El ejemplo de referencia es válido; se comprueba con esquemas laxos para
las plantillas que este fichero no copia (lo estricto lo cubre el test vivo)."""
spec = json.loads(EXAMPLE.read_text())
permissive = {name: {"type": "object"} for name in
{s["template"] for s in spec["shots"]}}
permissive.update(TEMPLATES)
validate_spec(spec, permissive)
# --- rutas de error ---------------------------------------------------------
def test_unknown_prop_is_reported_with_its_full_path():
"""El typo en un nombre de prop es el bug más probable de un spec escrito
por un LLM, y la ruta exacta es lo que se le devuelve para arreglarlo."""
errors = errors_of(spec_with(shot(sweeeps=2)))
assert any(e.startswith("shots.0.radar_sweep.props.sweeeps: campo no permitido")
for e in errors), errors
assert "sweeps" in errors[0], "hay que decirle cuáles SÍ valen"
def test_unknown_template_lists_the_valid_names():
errors = errors_of(spec_with({"template": "radar_swep", "duration": 6.0,
"props": {"headline": "X"}}))
assert errors[0].startswith("shots.0.template:")
assert "radar_sweep" in errors[0] and "scale_bars" in errors[0]
def test_missing_required_prop():
bad = spec_with(shot()); del bad["shots"][0]["props"]["headline"]
assert "shots.0.radar_sweep.props.headline: falta y es obligatorio" in errors_of(bad)
def test_empty_string_where_a_non_empty_one_is_required():
assert any("shots.0.radar_sweep.props.headline" in e
for e in errors_of(spec_with(shot(headline=""))))
def test_numeric_bounds():
errors = errors_of(spec_with(shot(contact_bearing_deg=400)))
assert "shots.0.radar_sweep.props.contact_bearing_deg: 400 debe ser < 360" in errors
def test_list_length_limits_are_enforced():
bars = [{"label": f"B{i}", "value": i + 1} for i in range(4)]
errors = errors_of(spec_with(shot("scale_bars", bars=bars)))
assert "shots.0.scale_bars.props.bars: 4 elementos, el máximo es 3" in errors
def test_colour_must_be_a_palette_name_never_hex():
errors = errors_of(spec_with(shot("scale_bars", bars=[
{"label": "OBJETO", "value": 2000, "color": "#ffbf00"}])))
assert any("color" in e and "amber" in e for e in errors)
def test_nested_paths_survive_lists():
errors = errors_of(spec_with(shot("scale_bars", bars=[
{"label": "BOEING 747", "value": 232},
{"label": "OBJETO", "value": -5}])))
assert "shots.0.scale_bars.props.bars.1.value: -5 debe ser > 0" in errors
def test_every_error_comes_back_at_once():
"""Se devuelven todos: arreglar cinco de una vez sale más barato que cinco vueltas."""
errors = errors_of(spec_with(shot(headline="", sweeeps=1, contact_bearing_deg=999)))
assert len(errors) >= 3
# --- el sobre ---------------------------------------------------------------
def test_meta_id_pattern():
assert any(e.startswith("meta.id:") for e in errors_of(spec_with(id="Caso Roswell")))
def test_resolution_must_be_a_shorts_one():
assert any("no es una resolución admitida" in e
for e in errors_of(spec_with(shot(), width=800, height=600)))
def test_total_duration_ceiling_is_the_contract_not_the_target():
"""45 s es el objetivo editorial; 180 s es el límite duro. Pasarse de 45 no
invalida el spec — eso es una nota, no un error."""
long_spec = spec_with(*[shot(duration=10.0) for _ in range(6)]) # 60 s
validate_spec(long_spec, TEMPLATES)
assert editorial_notes(long_spec)
too_long = spec_with(*[shot(duration=30.0) for _ in range(7)]) # 210 s
assert any("pasa del límite" in e for e in errors_of(too_long))
def test_total_duration_floor():
assert any("no llega al mínimo" in e
for e in errors_of(spec_with(shot(duration=2.0))))
def test_silence_window_cannot_run_past_the_end():
bad = spec_with(shot(duration=25.0))
bad["audio"] = {"preset": "sonar", "silence": [[20.0, 40.0]]}
assert any("se sale de la duración total" in e for e in errors_of(bad))
def test_the_live_palette_widens_what_a_preset_may_be():
"""Con la paleta de GET /audio, un preset nuevo en shortsmith llega aquí
sin tocar este repo — el mismo pacto que las plantillas."""
doc = spec_with(shot(duration=25.0))
doc["audio"] = {"preset": "pulse"}
validate_spec(doc, TEMPLATES, presets=("sonar", "pulse", "static", "none"))
def test_without_the_palette_only_the_baseline_presets_pass():
"""El default es conservador a propósito: nunca acepta lo que un shortsmith
viejo no renderice."""
doc = spec_with(shot(duration=25.0))
doc["audio"] = {"preset": "pulse"}
assert any("audio.preset" in e for e in errors_of(doc))
def test_an_unknown_preset_error_names_the_palette():
doc = spec_with(shot(duration=25.0))
doc["audio"] = {"preset": "vaporwave"}
with pytest.raises(SpecInvalid) as exc:
validate_spec(doc, TEMPLATES, presets=("sonar", "pulse", "none"))
line = next(e for e in exc.value.errors if "audio.preset" in e)
assert "pulse" in line and "vaporwave" in line
def test_extra_root_key_is_rejected():
bad = spec_with(shot(duration=25.0)); bad["narrative_shape"] = "case_file"
assert any(e.startswith("narrative_shape:") for e in errors_of(bad))
def test_editorial_notes_flag_both_ends():
assert "queda corto" in editorial_notes(spec_with(shot(duration=8.0)))[0]
assert "recorta" in editorial_notes(
spec_with(*[shot(duration=10.0) for _ in range(6)]))[0]
assert editorial_notes(spec_with(shot(duration=30.0))) == []
def test_a_spec_that_is_not_even_a_dict():
with pytest.raises(SpecInvalid):
validate_spec([1, 2, 3], TEMPLATES)
# --- descripción para el prompt ---------------------------------------------
def test_describe_templates_is_driven_by_what_the_service_publishes():
text = describe_templates(TEMPLATES)
assert "radar_sweep:" in text and "scale_bars:" in text
assert "headline: string, no vacío, OBLIGATORIO" in text
assert "1-3 elementos" in text # los límites llegan al prompt
assert "ink, amber, amber_dark, muted, dim, red" in text
assert "label: string, no vacío, OBLIGATORIO" in text # despliega los objetos anidados
def test_a_template_nobody_wrote_here_still_gets_described():
"""La prueba de que el contrato no está copiado: una plantilla inventada,
que este repo no conoce, se describe igual."""
text = describe_templates({**TEMPLATES, "holo_scan": {
"type": "object", "required": ["title"],
"properties": {"title": {"type": "string", "minLength": 1},
"depth_m": {"type": "number", "maximum": 999}}}})
assert "holo_scan:" in text
assert "depth_m: number, ≤ 999" in text
def test_validation_accepts_a_template_nobody_wrote_here():
templates = {**TEMPLATES, "holo_scan": {
"type": "object", "additionalProperties": False, "required": ["title"],
"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