Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m18s
La nota que teníamos era falsa: los avisos `severe` de shortsmith SÍ llegan a
un humano — el informe de Telegram los saca en rojo. Lo que pasa es que llegan
colgados del render TERMINADO, así que hacerles caso significa editar el spec a
mano y pagar un segundo. Por eso nadie actuó nunca.
`x-fits` tampoco podía ser la comprobación, y a propósito: es guía blanda que el
propio ejemplo de referencia se salta por uno o tres caracteres viéndose bien.
Parar ahí sería gritar con specs buenos, y un aviso que grita se ignora — que es
justo cómo sobrevivieron los graves. Así que shortsmith publica desde 289d50e un
segundo número medido por campo, `x-fits-hard`, y esto lo comprueba antes de
gastar el render: cuesta un reintento del modelo en vez de un render. El mismo
movimiento que hizo `MAX_CUE_CHARS` con los captions en e32c59f.
Auditado contra los 17 short_en de producción: OCHO llevan al menos un texto que
se dibuja ilegible.
- El ya conocido, Cash-Landrum: `ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH
RADIATION EXPOSURE`, 36 px pedidos y 20 dibujados. Salta con 63 caracteres
contra un presupuesto de 61 — así de vertical es la curva pegada al muro.
- El que nadie había visto es peor y más común: el CARTEL DE CIERRE.
`counter_close.lines` pide 110 px y se dibujó a 28 en el peor caso, y por
debajo de 64 en cinco de los ocho. Es la llamada a la acción, y en un tercio
del catálogo es el texto más pequeño del fotograma.
- Y la tolerancia aguanta sobre datos reales: `STILL UNEXPLAINED` encoge de 110
a 84 px y no se avisa, que es lo correcto.
El desempate entre intentos pasa a contar averías —gancho y texto ilegible—
antes que segundos, por la misma razón que en 6d9b602: una reescritura que
arregla un rótulo pero se pasa un segundo perdía contra la que no lo arreglaba.
Un campo sin presupuesto propio (`Bar.unit`, que se dibuja dentro de la cadena
de `value_label`) se salta a conciencia: reconstruir esa cadena aquí pediría
conocer el formato de la plantilla, que es lo que este repo no sabe ni debe.
Y contra un shortsmith anterior a 289d50e esto se calla — no puede inventarse el
número —, así que de que el contrato traiga el campo se encarga
test_shortsmith_live.py, que es quien habla con el servicio.
Suite: 274 pasan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
776 lines
34 KiB
Python
776 lines
34 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, opening_notes,
|
|
unreadable_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,
|
|
"x-fits": 13, "x-fits-hard": 21},
|
|
"subline": {"type": "string", "default": "",
|
|
"x-fits": 27, "x-fits-hard": 42},
|
|
"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,
|
|
"x-fits": 32, "x-fits-hard": 53},
|
|
"value": {"type": "number", "exclusiveMinimum": 0},
|
|
"unit": {"type": "string", "default": "",
|
|
"x-fits-part-of": "value_label"},
|
|
"color": {"enum": ["ink", "amber", "amber_dark", "muted", "dim", "red"],
|
|
"type": "string", "default": "ink"},
|
|
"value_label": {"type": "string", "default": "",
|
|
"x-fits": 30, "x-fits-hard": 49},
|
|
},
|
|
}},
|
|
"properties": {
|
|
"headline": {"type": "string", "minLength": 1,
|
|
"x-fits": 16, "x-fits-hard": 26},
|
|
"bars": {"type": "array", "items": {"$ref": "#/$defs/Bar"},
|
|
"minItems": 1, "maxItems": 3},
|
|
"quote": {"type": "array", "items": {"type": "string"}, "maxItems": 2,
|
|
"x-fits": 33, "x-fits-hard": 49},
|
|
"attribution": {"type": "string", "default": "",
|
|
"x-fits": 46, "x-fits-hard": 73},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
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, TEMPLATES)
|
|
|
|
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))))
|
|
|
|
|
|
class TestCrossFieldRules:
|
|
"""Las reglas de pydantic que cruzan campos, replicadas a mano.
|
|
|
|
No salen en el JSON Schema publicado, así que antes se dejaban al 422 del
|
|
servidor — y ese 422 llega al RENDERIZAR, cuando el bucle de reintentos ya
|
|
ha terminado. O sea que no costaban un reintento: costaban la generación
|
|
entera y no daban vídeo. Pasó de verdad con la sesión 162 el 2026-08-13.
|
|
"""
|
|
|
|
def test_three_bars_and_a_quote_do_not_fit(self):
|
|
# El fallo exacto de la 162, con el texto exacto de shortsmith.
|
|
errors = errors_of(spec_with(shot(
|
|
"scale_bars", duration=25.0,
|
|
bars=[{"label": "A", "value": 1}, {"label": "B", "value": 2},
|
|
{"label": "C", "value": 3}],
|
|
quote=["“UNA CITA”"])))
|
|
assert any("3 bars leave no room for a quote" in e for e in errors)
|
|
assert any(e.startswith("shots.0.scale_bars.props") for e in errors)
|
|
|
|
def test_three_bars_without_a_quote_are_fine(self):
|
|
"""La regla es sobre el hueco, no sobre el número de barras."""
|
|
validate_spec(spec_with(shot(
|
|
"scale_bars", duration=25.0,
|
|
bars=[{"label": "A", "value": 1}, {"label": "B", "value": 2},
|
|
{"label": "C", "value": 3}])), TEMPLATES)
|
|
|
|
def test_two_bars_with_a_quote_are_fine(self):
|
|
validate_spec(spec_with(shot(
|
|
"scale_bars", duration=25.0,
|
|
bars=[{"label": "A", "value": 1}, {"label": "B", "value": 2}],
|
|
quote=["“UNA CITA”"])), TEMPLATES)
|
|
|
|
def test_a_waypoint_outside_a_pinned_window_is_rejected(self):
|
|
"""La proyección no recorta: un waypoint fuera se dibuja donde diga la
|
|
aritmética, a veces fuera del encuadre."""
|
|
templates = {"track_map": {"type": "object"}}
|
|
spec = spec_with({
|
|
"template": "track_map", "duration": 25.0,
|
|
"props": {
|
|
"headline": "RUTA",
|
|
"waypoints": [{"label": "DENTRO", "lat": 62.0, "lon": -148.0},
|
|
{"label": "FUERA", "lat": 20.0, "lon": -148.0}],
|
|
"bounds": {"lat_min": 60.0, "lat_max": 67.0,
|
|
"lon_min": -152.0, "lon_max": -143.0}}})
|
|
errors = errors_of(spec, templates)
|
|
assert any("waypoints outside the map bounds: FUERA" in e for e in errors)
|
|
|
|
def test_bounds_with_max_below_min_are_rejected(self):
|
|
templates = {"track_map": {"type": "object"}}
|
|
spec = spec_with({
|
|
"template": "track_map", "duration": 25.0,
|
|
"props": {
|
|
"headline": "RUTA",
|
|
"waypoints": [{"label": "A", "lat": 62.0, "lon": -148.0}],
|
|
"bounds": {"lat_min": 67.0, "lat_max": 60.0,
|
|
"lon_min": -152.0, "lon_max": -143.0}}})
|
|
assert any("max greater than min" in e for e in errors_of(spec, templates))
|
|
|
|
def test_a_fitted_window_needs_no_check(self):
|
|
"""Sin `bounds`, shortsmith ajusta la ventana a la ruta: están dentro
|
|
por construcción y no hay nada que comprobar."""
|
|
templates = {"track_map": {"type": "object"}}
|
|
validate_spec(spec_with({
|
|
"template": "track_map", "duration": 25.0,
|
|
"props": {"headline": "RUTA",
|
|
"waypoints": [{"label": "A", "lat": 2.0, "lon": -1.0}]}}),
|
|
templates)
|
|
|
|
def test_two_quotes_welded_into_one_field_are_rejected(self):
|
|
"""El peor fallo del sistema: una frase que nadie dijo, hecha con
|
|
material auténtico y firmada por alguien con nombre y apellidos.
|
|
|
|
El comprobador de fundamento une las líneas antes de buscarlas, y eso
|
|
caza la forma con la que falló Socorro. Pero la unión se derrota
|
|
poniéndole a cada línea su propio par de comillas: entonces son dos
|
|
citas, cada una fundamentada por su lado, y pasa en silencio. Caso real
|
|
de la sesión 162.
|
|
"""
|
|
errors = errors_of(spec_with(shot(
|
|
"scale_bars", duration=25.0,
|
|
bars=[{"label": "A", "value": 1}],
|
|
quote=["“GRAY, LIKE ZINC”", "“TWO SAUCERS GLUED AT THE RIM”"])))
|
|
assert any("es UNA cita partida en líneas" in e for e in errors)
|
|
|
|
def test_a_span_broken_across_lines_is_the_normal_case(self):
|
|
"""La forma buena: abre en la primera línea y cierra en la última. Es
|
|
como está escrito el ejemplo de referencia, así que rechazarla rompería
|
|
el propio prompt."""
|
|
validate_spec(spec_with(shot(
|
|
"scale_bars", duration=25.0,
|
|
bars=[{"label": "A", "value": 1}],
|
|
quote=["“TWICE THE SIZE OF", "AN AIRCRAFT CARRIER”"])), TEMPLATES)
|
|
|
|
def test_a_quote_without_marks_is_left_alone(self):
|
|
validate_spec(spec_with(shot(
|
|
"scale_bars", duration=25.0,
|
|
bars=[{"label": "A", "value": 1}],
|
|
quote=["LANDING TRACE", "CONFIRMED BY LAB"])), TEMPLATES)
|
|
|
|
def test_the_reference_example_survives_the_quote_rule(self):
|
|
"""Si el ejemplo no pasara su propia regla, volveríamos a enseñar el
|
|
fallo que la regla intenta evitar."""
|
|
spec = json.loads(EXAMPLE.read_text())
|
|
permissive = {name: {"type": "object"} for name in
|
|
{s["template"] for s in spec["shots"]}}
|
|
validate_spec(spec, permissive)
|
|
|
|
def test_a_bad_schema_hides_the_cross_field_noise(self):
|
|
"""Con props mal tipadas, la regla cruzada diría algo que no es el fallo
|
|
real y taparía el que sí lo es."""
|
|
# Tres barras (la regla cruzada dispararía) pero a las que les falta el
|
|
# campo obligatorio: el fallo que hay que arreglar es ese, no el hueco
|
|
# de la cita, que puede desaparecer al arreglarlo.
|
|
errors = errors_of(spec_with(shot(
|
|
"scale_bars", duration=25.0,
|
|
bars=[{"label": "A"}, {"label": "B"}, {"label": "C"}],
|
|
quote=["“X”"])))
|
|
assert any("value: falta y es obligatorio" in e for e in errors)
|
|
assert not any("leave no room" in e for e in errors)
|
|
|
|
|
|
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)), TEMPLATES)[0]
|
|
assert "recorta" in editorial_notes(
|
|
spec_with(*[shot(duration=10.0) for _ in range(6)]), TEMPLATES)[0]
|
|
assert editorial_notes(spec_with(shot(duration=30.0)), TEMPLATES) == []
|
|
|
|
|
|
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 "1-3 elementos" in text # los límites llegan al prompt
|
|
assert "ink, amber, amber_dark, muted, dim, red" in text
|
|
# Los dos presupuestos, y el que no tiene por dibujarse dentro de otro: el
|
|
# modelo apunta al primero, y el segundo es el que se le comprueba.
|
|
assert "headline: string, no vacío, CABE ~13 caracteres dibujados, " \
|
|
"ILEGIBLE por encima de 21, OBLIGATORIO" in text
|
|
assert "unit: string, se dibuja dentro de value_label, comparte su sitio" in text
|
|
# Y despliega los objetos anidados, con sus presupuestos de `$defs`.
|
|
assert "label: string, no vacío, CABE ~32 caracteres dibujados, " \
|
|
"ILEGIBLE por encima de 53, OBLIGATORIO" in text
|
|
|
|
|
|
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))
|
|
|
|
|
|
#: Líneas de narración de specs que se renderizaron de verdad, con lo que tarda
|
|
#: Piper en decirlas. Medido el 2026-08-12 con el binario, el modelo y las
|
|
#: banderas de shortsmith (`en_US-lessac-medium`, length_scale 1.0,
|
|
#: --noise_scale 0 --noise_w 0), que son deterministas: estos segundos se
|
|
#: reproducen. Se eligieron los extremos del muestreo de 28 líneas — la más
|
|
#: rápida, la más lenta y las dos más largas — porque son las que rompen un
|
|
#: modelo mal calibrado; la media la aguanta cualquiera.
|
|
MEASURED = [
|
|
("Eight FBI witness interviews. Five digital renderings. All describe the "
|
|
"same shape flying across America for twenty-four years.", 7.809),
|
|
("The files are public now, but sections remain blacked out. Witness "
|
|
"identities. Sensor details. Locations redacted.", 8.140),
|
|
("Three hundred seventy-eight files released. Hundreds of incidents "
|
|
"documented. And the government still cannot explain what those shapes "
|
|
"were.", 7.681),
|
|
("The files came out. The numbers stayed classified.", 3.310),
|
|
("Nothing should have been able to hold station beside them up there.", 3.396),
|
|
("The Air Force's own investigators called it unexplained.", 2.990),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("line,real", MEASURED)
|
|
def test_the_estimate_lands_within_a_second_of_the_voice(line, real):
|
|
"""La estimación es lo único que separa un aviso útil de una reescritura
|
|
inventada, así que se contrasta contra audio medido, no contra sí misma.
|
|
|
|
El margen es un segundo. Más apretado sería falso — esto estima, no
|
|
sintetiza — y más ancho deja de decir nada: el error del modelo anterior
|
|
sobre un Short entero era de cuatro a seis segundos, y de ahí salían los
|
|
tres intentos que se gastaban en cada generación.
|
|
"""
|
|
from src.generator.spec_contract import spoken_seconds
|
|
|
|
assert spoken_seconds(line) == pytest.approx(real, abs=1.0)
|
|
|
|
|
|
def test_a_line_of_short_sentences_is_not_taken_for_fast_prose():
|
|
"""Piper calla un cuarto de segundo en cada punto. Cuatro frases cortas son
|
|
un segundo de silencio, y contarlas como texto corrido las da por rápidas:
|
|
es el caso donde más se equivocaba el modelo de sólo caracteres."""
|
|
from src.generator.spec_contract import spoken_seconds
|
|
|
|
chopped = "The files are public now, but sections remain blacked out. " \
|
|
"Witness identities. Sensor details. Locations redacted."
|
|
flowing = "The files are public now but sections remain blacked out with " \
|
|
"witness identities sensor details and locations redacted"
|
|
|
|
assert len(chopped) < len(flowing)
|
|
assert spoken_seconds(chopped) > spoken_seconds(flowing)
|
|
|
|
|
|
def test_a_decimal_point_is_not_the_end_of_a_sentence():
|
|
from src.generator.spec_contract import spoken_seconds
|
|
|
|
assert spoken_seconds("It climbed to 1.5 miles") == \
|
|
pytest.approx(spoken_seconds("It climbed to 155 miles"))
|
|
|
|
|
|
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"] = MEASURED[0][0] # 7,81 s de voz medidos
|
|
|
|
assert estimated_duration(doc) > 8.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, TEMPLATES) == []
|
|
|
|
doc = copy.deepcopy(quiet)
|
|
for s in doc["shots"]:
|
|
s["narration"] = "A" * 300
|
|
|
|
note = editorial_notes(doc, TEMPLATES)[0]
|
|
|
|
assert "narración" in note and "estimada" in note
|
|
|
|
|
|
def test_a_second_over_the_target_is_not_worth_a_rewrite():
|
|
"""El objetivo sigue siendo 45 s, pero la estimación tiene un segundo de
|
|
error por línea: avisar por medio segundo es avisar del estimador. Caso
|
|
real — la sesión 168 salió a 45,4 s y se pagó una generación por ello."""
|
|
from src.generator.spec_contract import TARGET_GRACE, TARGET_MAX_DURATION
|
|
|
|
justo = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE - 0.1))
|
|
pasado = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE + 0.1))
|
|
|
|
assert editorial_notes(justo, TEMPLATES) == []
|
|
assert editorial_notes(pasado, TEMPLATES)
|
|
# Y el consejo se mide contra el objetivo, no contra el margen: se pide
|
|
# bajar hasta 45, no hasta 46,5.
|
|
assert "sobran 1.6s" in editorial_notes(pasado, TEMPLATES)[0]
|
|
|
|
|
|
def test_the_grace_works_at_both_ends():
|
|
from src.generator.spec_contract import TARGET_GRACE, TARGET_MIN_DURATION
|
|
|
|
assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION
|
|
- TARGET_GRACE + 0.1)), TEMPLATES) == []
|
|
assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION
|
|
- TARGET_GRACE - 0.1)), TEMPLATES)
|
|
|
|
|
|
def test_the_advice_says_how_much_to_cut_and_from_where():
|
|
""""Recorta narración" no dice cuánta, y las tres veces que saltó este aviso
|
|
el modelo devolvió un spec que seguía pasándose. El exceso va en palabras
|
|
porque es lo que el modelo escribe, y señalando el plano que más habla."""
|
|
doc = spec_with(shot(duration=4.0), shot(duration=4.0))
|
|
doc["shots"][0]["narration"] = "Short line."
|
|
doc["shots"][1]["narration"] = " ".join(["word"] * 200)
|
|
|
|
note = editorial_notes(doc, TEMPLATES)[0]
|
|
|
|
assert "palabras de narración" in note
|
|
assert "shots.1" in note and "shots.0" not in note
|
|
|
|
|
|
def test_the_advice_for_a_silent_spec_never_mentions_narration():
|
|
"""Sin voz, pedir que recorte narración es mandarlo a arreglar algo que no
|
|
existe: lo que sobra son duraciones declaradas."""
|
|
note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]), TEMPLATES)[0]
|
|
|
|
assert "narración" not in note and "duraciones declaradas" 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)]), TEMPLATES)[0]
|
|
assert "duración total" in note and "estimada" not in note
|
|
|
|
|
|
# --- el gancho: lo que se ve en el primer plano ------------------------------
|
|
# Los titulares de abajo son los reales de los once casos distintos que el bot ha
|
|
# escrito. Se copian aquí en vez de generarlos porque el detector no se juzga
|
|
# contra ejemplos cómodos: se juzga contra lo que el modelo escribe de verdad.
|
|
|
|
#: Plantilla sin `headline`: su contenido se escribe a máquina más abajo y la
|
|
#: banda superior del fotograma se queda en el fondo todo el plano.
|
|
SIN_TITULAR = {"document_quote": {
|
|
"type": "object", "required": ["quote_a"],
|
|
"properties": {"source": {"type": "string"},
|
|
"quote_a": {"type": "string", "minLength": 1}}}}
|
|
|
|
FECHAS = ["APRIL 24 1964", "APRIL 24, 1964", "APRIL 24", "8 JAN 1981",
|
|
"OCT 16 1957", "NOVEMBER 12", "1947"]
|
|
NO_FECHAS = ["62 CHILDREN", "62 WITNESSES", "23 HELICOPTERS", "TRIANGLES",
|
|
"RELEASE 05", "LANDING TRACE", "62"]
|
|
|
|
|
|
def opening(template="radar_sweep", templates=None, **props):
|
|
spec = spec_with({"template": template, "duration": 6.0, "props": props},
|
|
)
|
|
return opening_notes(spec, templates if templates is not None else TEMPLATES)
|
|
|
|
|
|
def test_the_opening_shot_has_to_draw_a_headline():
|
|
"""Medido sobre el renderizador a 5,5 s de plano: las cinco plantillas con
|
|
`headline` lo ponen a tinta plena en 0,33-0,40 s; en las tres que no lo
|
|
tienen la banda superior no pasa del fondo en todo el plano y su texto no
|
|
está entero hasta 2,6-2,8 s. Con 6,9 s de visionado medio eso es un tercio
|
|
de la ventana. Ocurrió de verdad — el output 131 abrió con `document_quote`.
|
|
"""
|
|
note = opening(template="document_quote", templates=SIN_TITULAR,
|
|
quote_a="“NO CONTACT”")[0]
|
|
|
|
assert "document_quote" in note and "titular" in note
|
|
# Y la misma plantilla más adelante en el vídeo no molesta a nadie: lo que
|
|
# se juzga es la apertura, no el catálogo.
|
|
permisivo = {**TEMPLATES, **SIN_TITULAR}
|
|
tarde = spec_with(shot(), {"template": "document_quote", "duration": 6.0,
|
|
"props": {"quote_a": "“NO CONTACT”"}})
|
|
assert opening_notes(tarde, permisivo) == []
|
|
|
|
|
|
def test_the_rule_is_asked_of_the_schema_not_of_a_list_of_names():
|
|
"""El corte no puede ser una lista de plantillas escrita a mano: shortsmith
|
|
añade plantillas sin avisar a este repo. Una inventada CON titular abre sin
|
|
tocar nada, y una inventada SIN él queda cubierta igual."""
|
|
nuevas = {
|
|
"plantilla_nueva_con_titular": {
|
|
"type": "object", "required": ["headline"],
|
|
"properties": {"headline": {"type": "string"}}},
|
|
"plantilla_nueva_sin_titular": {
|
|
"type": "object", "properties": {"body": {"type": "string"}}},
|
|
}
|
|
|
|
assert opening(template="plantilla_nueva_con_titular", templates=nuevas,
|
|
headline="62 CHILDREN") == []
|
|
assert opening(template="plantilla_nueva_sin_titular", templates=nuevas,
|
|
body="lo que sea")
|
|
|
|
|
|
@pytest.mark.parametrize("headline", FECHAS)
|
|
def test_a_headline_that_is_only_a_date_is_flagged(headline):
|
|
"""Cinco de los once casos abrieron así, con el sitio ya puesto en el
|
|
`subline` de debajo: el texto más grande del vídeo gastado en metadatos."""
|
|
note = opening(headline=headline)[0]
|
|
|
|
assert "fecha" in note and headline in note
|
|
|
|
|
|
@pytest.mark.parametrize("headline", NO_FECHAS)
|
|
def test_a_figure_is_not_mistaken_for_a_date(headline):
|
|
"""El control, y no es un adorno: sin él, un detector que marcara cualquier
|
|
titular con un número dentro pasaría todos los casos de arriba y estaría
|
|
rechazando exactamente los titulares que se quieren. "62" a secas es el que
|
|
lo decide — es una cifra desnuda, que es el gancho ideal, no una fecha."""
|
|
assert opening(headline=headline) == []
|
|
|
|
|
|
def test_the_hook_note_comes_before_the_duration_one():
|
|
"""Los dos avisos pueden salir a la vez y quien los lee coge `[0]`. Primero
|
|
el gancho: un Short que se pasa cinco segundos se ve; uno cuya apertura no
|
|
dice nada no se ve entero de todas formas."""
|
|
largo = spec_with({"template": "radar_sweep", "duration": 90.0,
|
|
"props": {"headline": "8 JAN 1981"}})
|
|
|
|
notes = editorial_notes(largo, TEMPLATES)
|
|
|
|
assert len(notes) == 2
|
|
assert "fecha" in notes[0] and "objetivo" in notes[1]
|
|
|
|
|
|
# --- textos que se van a dibujar ilegibles -----------------------------------
|
|
# `x-fits` es guía blanda y tiene que serlo: el ejemplo de referencia se pasa de
|
|
# varios de sus propios presupuestos por uno o tres caracteres y se ve bien.
|
|
# `x-fits-hard` es la otra línea, y esa sí se comprueba antes de gastar el
|
|
# render — que es donde el aviso llegaba antes, con el vídeo ya pagado.
|
|
|
|
def one_shot(template="radar_sweep", **props):
|
|
return spec_with({"template": template, "duration": 25.0, "props": props})
|
|
|
|
|
|
def test_a_text_past_the_hard_budget_is_flagged():
|
|
"""El caso real, medido sobre Cash-Landrum: un texto pidió 36 px y se dibujó
|
|
a 20 en un fotograma de 1080 de ancho."""
|
|
largo = "ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH RADIATION EXPOSURE"
|
|
|
|
note = unreadable_notes(one_shot(headline=largo), TEMPLATES)[0]
|
|
|
|
assert "ILEGIBLE" in note
|
|
assert "shots.0.headline" in note and f"{len(largo)} caracteres" in note
|
|
assert "caben 21" in note
|
|
|
|
|
|
def test_a_text_between_the_two_budgets_is_left_alone():
|
|
"""El control, y es la mitad del diseño: entre `x-fits` y `x-fits-hard` el
|
|
texto sale un poco más pequeño y se ve bien. Avisar ahí sería gritar con
|
|
specs buenos, y un aviso que grita se acaba ignorando — que es exactamente
|
|
cómo el de verdad grave se pasó meses sin que nadie actuara."""
|
|
assert len("3 RADARS TRACKING") > 13 # por encima del x-fits
|
|
assert len("3 RADARS TRACKING") < 21 # por debajo del ilegible
|
|
|
|
assert unreadable_notes(one_shot(headline="3 RADARS TRACKING"), TEMPLATES) == []
|
|
|
|
|
|
def test_the_budget_of_a_list_of_lines_is_per_line():
|
|
"""Una cita se dibuja partida en líneas, así que el presupuesto es por línea.
|
|
Medirlo sobre el texto unido avisaría de una cita bien partida en dos."""
|
|
dos = ["A QUOTE SPLIT WHERE IT HAS TO", "BREAK SO THAT IT FITS ON SCREEN"]
|
|
assert sum(len(x) for x in dos) > 49 and all(len(x) < 49 for x in dos)
|
|
|
|
ok = spec_with(shot("scale_bars", quote=dos))
|
|
assert unreadable_notes(ok, TEMPLATES) == []
|
|
|
|
larga = spec_with(shot("scale_bars", quote=["X" * 60]))
|
|
assert "shots.0.quote[0]" in unreadable_notes(larga, TEMPLATES)[0]
|
|
|
|
|
|
def test_the_budget_inside_a_list_of_objects_is_found():
|
|
"""Los presupuestos de un submodelo viven en `$defs`, y saltárselos fue justo
|
|
el agujero por el que shortsmith se pasó meses sin medir nueve campos."""
|
|
doc = spec_with(shot("scale_bars",
|
|
bars=[{"label": "BOEING 747", "value": 232},
|
|
{"label": "X" * 60, "value": 100}]))
|
|
|
|
note = unreadable_notes(doc, TEMPLATES)[0]
|
|
|
|
assert "shots.0.bars[1].label" in note
|
|
|
|
|
|
def test_a_field_drawn_inside_another_is_not_judged_alone():
|
|
"""`unit` no tiene presupuesto propio: se dibuja dentro de la cadena de
|
|
`value_label`. Juzgarlo solo sería inventarse un límite que el contrato dice
|
|
expresamente que no existe."""
|
|
doc = spec_with(shot("scale_bars",
|
|
bars=[{"label": "BOEING 747", "value": 232,
|
|
"unit": "X" * 60}]))
|
|
|
|
assert unreadable_notes(doc, TEMPLATES) == []
|
|
|
|
|
|
def test_an_old_contract_without_the_hard_budget_says_nothing():
|
|
"""shortsmith publicó `x-fits-hard` en 289d50e. Contra uno anterior esto no
|
|
puede inventarse el número: se calla, y de que el contrato lo traiga se
|
|
encarga `test_shortsmith_live.py`, que es quien habla con el servicio."""
|
|
viejo = {"radar_sweep": {"type": "object", "required": ["headline"],
|
|
"properties": {"headline": {"type": "string",
|
|
"x-fits": 13}}}}
|
|
|
|
assert unreadable_notes(one_shot(headline="X" * 90), viejo) == []
|
|
|
|
|
|
def test_the_defects_come_before_the_duration():
|
|
"""Los tres avisos pueden salir juntos y quien los lee coge `[0]`. Primero lo
|
|
que está roto, después lo que está fuera de objetivo."""
|
|
doc = spec_with({"template": "radar_sweep", "duration": 90.0,
|
|
"props": {"headline": "8 JAN 1981",
|
|
"subline": "X" * 60}})
|
|
|
|
notes = editorial_notes(doc, TEMPLATES)
|
|
|
|
assert len(notes) == 3
|
|
assert "fecha" in notes[0] and "ILEGIBLE" in notes[1] and "objetivo" in notes[2]
|
|
|
|
|
|
def test_the_prompt_carries_how_much_text_actually_fits():
|
|
"""`x-fits` es el único límite que nada rechaza: si no llega al prompt, el
|
|
modelo escribe una cita de 58 caracteres para un hueco de 16."""
|
|
templates = {"document_quote": {
|
|
"type": "object", "required": ["quote_a"],
|
|
"properties": {"quote_a": {"type": "string", "minLength": 1, "x-fits": 16}}}}
|
|
|
|
described = describe_templates(templates)
|
|
|
|
assert "CABE ~16 caracteres" in described
|
|
|
|
|
|
def test_a_string_longer_than_it_fits_is_still_valid():
|
|
"""Los caracteres son un proxy de los píxeles: rechazar por ancho estimado
|
|
tiraría specs que se dibujan perfectamente."""
|
|
templates = {"radar_sweep": {
|
|
"type": "object", "additionalProperties": False, "required": ["headline"],
|
|
"properties": {"headline": {"type": "string", "minLength": 1, "x-fits": 13}}}}
|
|
doc = spec_with({"template": "radar_sweep", "duration": 25.0,
|
|
"props": {"headline": "UN TITULAR BASTANTE MAS LARGO QUE ESO"}})
|
|
|
|
validate_spec(doc, templates)
|