fix(short): las reglas que cruzan campos costaban una generación entera
Build & Deploy ResearchOwl / build-and-push (push) Successful in 12s
Build & Deploy ResearchOwl / build-and-push (push) Successful in 12s
El 422 de shortsmith llega al RENDERIZAR, cuando el bucle de reintentos ya ha terminado: un spec que las viola no se reescribe, se devuelve a mano. La sesión 162 (Trans-en-Provence) se perdió así — spec válido a la primera, 39 claims fundamentados, y sin vídeo porque tenía 3 barras y una cita. Se replican las tres a mano, como ya se hacía con el sobre, porque pydantic no las serializa en el JSON Schema publicado: - scale_bars: 3 barras no dejan sitio para una cita - track_map: una ventana fijada tiene que contener sus waypoints - track_map: bounds con max <= min El texto del error es el de shortsmith palabra por palabra: se le devuelve verbatim al modelo, y dos redacciones del mismo fallo según dónde se cace es justo lo que hace inútil un mensaje de error. Verificado contra el spec real de la 162, recuperado de la BD. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -171,6 +171,84 @@ def _check_props(props: Any, schema: dict, path: str) -> list[str]:
|
||||
return _check(props, schema, path, schema.get("$defs", {}))
|
||||
|
||||
|
||||
# --- reglas que cruzan campos -----------------------------------------------
|
||||
# Espejo a mano de los `@model_validator` de shortsmith/spec.py, porque NO salen
|
||||
# en el JSON Schema publicado: pydantic no los serializa. Antes se dejaban al 422
|
||||
# del servidor, y eso costaba una generación entera — el 422 llega al RENDERIZAR,
|
||||
# cuando el bucle de reintentos ya ha terminado, así que el spec no se reescribe:
|
||||
# se devuelve a mano. La sesión 162 (Trans-en-Provence) se perdió justo así el
|
||||
# 2026-08-13. Comprobadas aquí, son un reintento normal.
|
||||
#
|
||||
# El texto del error es el de shortsmith palabra por palabra: al modelo se le
|
||||
# devuelve verbatim, y dos redacciones distintas del mismo fallo según dónde se
|
||||
# cace es exactamente el tipo de detalle que hace inútil un mensaje de error.
|
||||
|
||||
def _scale_bars_quote_needs_room(props: dict, path: str) -> list[str]:
|
||||
bars = props.get("bars")
|
||||
quote = props.get("quote")
|
||||
if isinstance(bars, list) and isinstance(quote, list) and len(bars) > 2 and quote:
|
||||
return [f"{path}: {len(bars)} bars leave no room for a quote — use at "
|
||||
"most 2 bars with a quote"]
|
||||
return []
|
||||
|
||||
|
||||
def _track_map_waypoints_inside(props: dict, path: str) -> list[str]:
|
||||
"""Una ventana fijada a mano tiene que contener la ruta que enmarca.
|
||||
|
||||
La proyección de shortsmith es lineal y sin recortar, así que un waypoint
|
||||
fuera de `bounds` no se dibuja en el borde: se dibuja donde lo ponga la
|
||||
aritmética, a veces fuera del encuadre. Se rechaza en vez de recortarse
|
||||
porque un mapa que miente sobre dónde pasó algo es peor que un spec que
|
||||
falla.
|
||||
"""
|
||||
bounds = props.get("bounds")
|
||||
waypoints = props.get("waypoints")
|
||||
if not isinstance(bounds, dict) or not isinstance(waypoints, list):
|
||||
return []
|
||||
try:
|
||||
lat_min, lat_max = float(bounds["lat_min"]), float(bounds["lat_max"])
|
||||
lon_min, lon_max = float(bounds["lon_min"]), float(bounds["lon_max"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return [] # incompleto o mal tipado: ya lo dijo el esquema
|
||||
|
||||
if lat_max <= lat_min or lon_max <= lon_min:
|
||||
return [f"{path}.bounds: map bounds must have max greater than min on "
|
||||
"both axes"]
|
||||
|
||||
outside = []
|
||||
for w in waypoints:
|
||||
if not isinstance(w, dict):
|
||||
continue
|
||||
try:
|
||||
lat, lon = float(w["lat"]), float(w["lon"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if not (lat_min <= lat <= lat_max and lon_min <= lon <= lon_max):
|
||||
outside.append(str(w.get("label", "?")))
|
||||
if outside:
|
||||
return [f"{path}: waypoints outside the map bounds: "
|
||||
f"{', '.join(outside)} — widen bounds or omit them to fit the "
|
||||
"window to the route"]
|
||||
return []
|
||||
|
||||
|
||||
#: template -> comprobaciones extra. Una plantilla sin entrada no tiene reglas
|
||||
#: cruzadas, que es el caso de casi todas.
|
||||
CROSS_FIELD_CHECKS = {
|
||||
"scale_bars": [_scale_bars_quote_needs_room],
|
||||
"track_map": [_track_map_waypoints_inside],
|
||||
}
|
||||
|
||||
|
||||
def _check_cross_field(template: str, props: Any, path: str) -> list[str]:
|
||||
if not isinstance(props, dict):
|
||||
return []
|
||||
errors: list[str] = []
|
||||
for check in CROSS_FIELD_CHECKS.get(template, ()):
|
||||
errors.extend(check(props, path))
|
||||
return errors
|
||||
|
||||
|
||||
# --- el sobre ---------------------------------------------------------------
|
||||
|
||||
def _check_meta(meta: Any) -> list[str]:
|
||||
@@ -318,8 +396,13 @@ def validate_spec(spec: Any, templates: dict[str, dict],
|
||||
if "props" not in shot:
|
||||
errors.append(f"{path}.props: falta y es obligatorio")
|
||||
continue
|
||||
errors.extend(_check_props(shot["props"], templates[template],
|
||||
f"{path}.{template}.props"))
|
||||
props_path = f"{path}.{template}.props"
|
||||
props_errors = _check_props(shot["props"], templates[template], props_path)
|
||||
errors.extend(props_errors)
|
||||
# Sólo si el esquema pasó: con props mal tipadas, una regla cruzada
|
||||
# diría algo que no es el fallo real y taparía el que sí lo es.
|
||||
if not props_errors:
|
||||
errors.extend(_check_cross_field(template, shot["props"], props_path))
|
||||
|
||||
total = _total_duration(spec)
|
||||
if total < MIN_TOTAL_DURATION:
|
||||
|
||||
@@ -178,6 +178,88 @@ def test_total_duration_floor():
|
||||
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_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]]}
|
||||
|
||||
Reference in New Issue
Block a user