Compare commits
4
Commits
4099e3eecb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a17edf43b7 | ||
|
|
6e4b3e1379 | ||
|
|
818533c86f | ||
|
|
6f960c303d |
@@ -116,6 +116,35 @@ What actually landed, and where it differs from the plan below:
|
|||||||
says. It is the same finding as "el ejemplo del prompt habla, y por eso los specs
|
says. It is the same finding as "el ejemplo del prompt habla, y por eso los specs
|
||||||
vuelven a hablar", arriving a second time.
|
vuelven a hablar", arriving a second time.
|
||||||
|
|
||||||
|
- **A validation rule that fires too late costs a whole generation, not a retry**
|
||||||
|
(2026-08-13). researchowl deliberately did not replicate shortsmith's cross-field
|
||||||
|
`@model_validator`s — they are not in the published JSON Schema, and the reasoning was
|
||||||
|
that the server's 422 covers them. It does, but at the wrong moment: the 422 arrives
|
||||||
|
at *render* time, after the spec loop has finished, so the spec is not rewritten, it
|
||||||
|
is handed back to a human. Session 162 (Trans-en-Provence) died exactly there — valid
|
||||||
|
on the first attempt, 39 claims grounded, and no video because a `scale_bars` shot had
|
||||||
|
three bars and a quote. Replicated locally, the same spec cost one retry and rendered.
|
||||||
|
The rule to carry forward: **where a check runs decides what it costs**, and "the
|
||||||
|
server will catch it" is only true if the server catches it while you can still act.
|
||||||
|
The error strings are copied from shortsmith word for word, because they are handed to
|
||||||
|
the model verbatim and two wordings of one failure is how an error message stops
|
||||||
|
being useful.
|
||||||
|
|
||||||
|
- **A check can be defeated by the shape of the thing it checks** (2026-08-13). The
|
||||||
|
grounding checker joins a `quote` list before looking for it, which is what closed the
|
||||||
|
Socorro hole in August: `“LIKE ALUMINUM` + `SMOOTH, NO WINDOWS”` join into one
|
||||||
|
sentence, no source contains it, rejected. But the join is defeated by giving each
|
||||||
|
line its own pair of quote marks — then they are two quotes, each grounded on its own,
|
||||||
|
and the spec passes in silence while the frame draws a sentence nobody said. Two of
|
||||||
|
the five Shorts generated that day had it. The rule now checks the *shape* rather than
|
||||||
|
the content — two opening marks are two quotes, whatever the sources say — and it runs
|
||||||
|
in `validate_spec`, so it costs a retry. It is a hard error and not an editorial note
|
||||||
|
on purpose: a fabricated quote attributed to a named witness is the worst failure this
|
||||||
|
system has, and a retry is cheap against it. Worth watching: given the choice between
|
||||||
|
picking a shorter verbatim span and dropping the quote marks, both rewrites dropped
|
||||||
|
the marks. Truthful, but a paraphrase is weaker than a quote — if that becomes the
|
||||||
|
habit, the fix is in the prompt, not the check.
|
||||||
|
|
||||||
Original plan, kept for the record:
|
Original plan, kept for the record:
|
||||||
|
|
||||||
### 4b (as planned). Narration (TTS) + burned-in captions
|
### 4b (as planned). Narration (TTS) + burned-in captions
|
||||||
|
|||||||
@@ -171,6 +171,126 @@ def _check_props(props: Any, schema: dict, path: str) -> list[str]:
|
|||||||
return _check(props, schema, path, schema.get("$defs", {}))
|
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 []
|
||||||
|
|
||||||
|
|
||||||
|
#: Comillas de apertura. Una cita bien partida abre UNA vez.
|
||||||
|
_OPENING_QUOTES = "“«„‟"
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_is_one_span(props: dict, path: str) -> list[str]:
|
||||||
|
"""Una `quote` de varias líneas es UN span partido, no dos citas.
|
||||||
|
|
||||||
|
Esta regla NO es de shortsmith: allí renderiza igual. Es del canal, y es de
|
||||||
|
las duras, porque el fallo que evita es el peor que tiene este sistema —
|
||||||
|
una cita fabricada con material auténtico y atribuida a una persona con
|
||||||
|
nombre y apellidos.
|
||||||
|
|
||||||
|
El comprobador de fundamento ya une las líneas antes de buscarlas, y eso
|
||||||
|
cerró la forma con la que falló Socorro en agosto (`“LIKE ALUMINUM` +
|
||||||
|
`SMOOTH, NO WINDOWS”`): unidas son una sola frase, no aparece en ninguna
|
||||||
|
fuente, y se rechaza. Pero la unión se puede derrotar 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 — mientras el fotograma dibuja
|
||||||
|
la frase de nadie. Le pasó a la sesión 162 el 2026-08-13 con
|
||||||
|
`“GRAY, LIKE ZINC”` + `“TWO SAUCERS GLUED AT THE RIM”`.
|
||||||
|
|
||||||
|
Por eso se mira la FORMA y no el contenido: dos aperturas son dos citas,
|
||||||
|
diga lo que diga la fuente.
|
||||||
|
"""
|
||||||
|
quote = props.get("quote")
|
||||||
|
if not isinstance(quote, list) or len(quote) < 2:
|
||||||
|
return []
|
||||||
|
joined = " ".join(str(line) for line in quote)
|
||||||
|
openings = sum(joined.count(glyph) for glyph in _OPENING_QUOTES)
|
||||||
|
if openings < 2:
|
||||||
|
return []
|
||||||
|
return [f"{path}.quote: son {openings} citas, y este campo es UNA cita "
|
||||||
|
"partida en líneas — leídas seguidas forman una frase que nadie "
|
||||||
|
"dijo. Elige un solo span verbatim y pártelo donde tenga que "
|
||||||
|
"partirse, o quita las comillas y cuenta el hecho en llano"]
|
||||||
|
|
||||||
|
|
||||||
|
#: Comprobaciones que se aplican a TODAS las plantillas, por nombre de prop. Van
|
||||||
|
#: aparte de las de abajo para que una plantilla nueva con un campo `quote` de
|
||||||
|
#: varias líneas quede cubierta sin tocar nada — el mismo pacto que el contrato.
|
||||||
|
UNIVERSAL_CHECKS = [_quote_is_one_span]
|
||||||
|
|
||||||
|
#: 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 (*UNIVERSAL_CHECKS, *CROSS_FIELD_CHECKS.get(template, ())):
|
||||||
|
errors.extend(check(props, path))
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
# --- el sobre ---------------------------------------------------------------
|
# --- el sobre ---------------------------------------------------------------
|
||||||
|
|
||||||
def _check_meta(meta: Any) -> list[str]:
|
def _check_meta(meta: Any) -> list[str]:
|
||||||
@@ -318,8 +438,13 @@ def validate_spec(spec: Any, templates: dict[str, dict],
|
|||||||
if "props" not in shot:
|
if "props" not in shot:
|
||||||
errors.append(f"{path}.props: falta y es obligatorio")
|
errors.append(f"{path}.props: falta y es obligatorio")
|
||||||
continue
|
continue
|
||||||
errors.extend(_check_props(shot["props"], templates[template],
|
props_path = f"{path}.{template}.props"
|
||||||
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)
|
total = _total_duration(spec)
|
||||||
if total < MIN_TOTAL_DURATION:
|
if total < MIN_TOTAL_DURATION:
|
||||||
|
|||||||
@@ -178,6 +178,127 @@ def test_total_duration_floor():
|
|||||||
for e in errors_of(spec_with(shot(duration=2.0))))
|
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():
|
def test_silence_window_cannot_run_past_the_end():
|
||||||
bad = spec_with(shot(duration=25.0))
|
bad = spec_with(shot(duration=25.0))
|
||||||
bad["audio"] = {"preset": "sonar", "silence": [[20.0, 40.0]]}
|
bad["audio"] = {"preset": "sonar", "silence": [[20.0, 40.0]]}
|
||||||
|
|||||||
Reference in New Issue
Block a user