Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60b76f91ab | ||
|
|
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
|
||||
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:
|
||||
|
||||
### 4b (as planned). Narration (TTS) + burned-in captions
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ aiohttp==3.14.1
|
||||
|
||||
# Scraping
|
||||
beautifulsoup4==4.15.0
|
||||
lxml==5.4.0
|
||||
lxml==6.1.2
|
||||
trafilatura==1.12.2
|
||||
youtube-transcript-api==0.6.3
|
||||
pdfplumber==0.11.10
|
||||
|
||||
@@ -171,6 +171,126 @@ 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 []
|
||||
|
||||
|
||||
#: 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 ---------------------------------------------------------------
|
||||
|
||||
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:
|
||||
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,127 @@ 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_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]]}
|
||||
|
||||
Reference in New Issue
Block a user