Author SHA1 Message Date
Renovate Bot 40391915c7 chore(deps): update dependency reportlab to v5 2026-08-20 18:00:14 +00:00
ChemaVXandClaude Opus 5 a17edf43b7 docs(short): un comprobador se puede derrotar por la forma de lo que comprueba
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:53:20 +00:00
ChemaVXandClaude Opus 5 6e4b3e1379 fix(short): dos citas en el campo de una son una frase que nadie dijo
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s
El comprobador de fundamento une las líneas de `quote` antes de buscarlas, y eso
cerró la forma con la que falló Socorro en agosto (`366ded1`): `“LIKE ALUMINUM` +
`SMOOTH, NO WINDOWS”` unidas son una sola frase, no aparece en ninguna fuente, y
se rechaza.

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
mientras el fotograma dibuja la frase de nadie. Medido sobre los cinco Shorts
generados hoy: le pasaba a dos.

Se comprueba la FORMA, no el contenido — dos aperturas son dos citas, diga lo
que diga la fuente — y se comprueba en `validate_spec`, o sea que cuesta un
reintento y no una revisión humana. Es regla del canal, no de shortsmith: allí
renderiza igual. Va como error duro y no como nota editorial porque el fallo que
evita es el peor que tiene este sistema: una cita fabricada con material
auténtico y atribuida a alguien con nombre y apellidos.

La comprobación es universal por nombre de prop, no por plantilla: una plantilla
nueva con un `quote` de varias líneas queda cubierta sin tocar nada.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:47:07 +00:00
ChemaVXandClaude Opus 5 818533c86f docs(short): dónde corre una comprobación decide lo que cuesta
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:39:28 +00:00
ChemaVXandClaude Opus 5 6f960c303d fix(short): las reglas que cruzan campos costaban una generación entera
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>
2026-08-13 21:32:10 +00:00
4 changed files with 278 additions and 3 deletions
+29
View File
@@ -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
View File
@@ -31,7 +31,7 @@ anthropic>=0.40.0
# PDF export
markdown==3.10.2
reportlab==4.2.5
reportlab==5.0.1
# Utilities
pydantic==2.13.4
+127 -2
View File
@@ -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:
+121
View File
@@ -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]]}