Author SHA1 Message Date
Renovate Bot 60b76f91ab chore(deps): update dependency lxml to v6 2026-08-19 06:00:13 +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
ChemaVXandClaude Opus 5 4099e3eecb docs(short): el ejemplo que rompe una regla enseña a romperla
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m18s
Segunda vez que aparece la misma lección sobre el ejemplo del prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:53:13 +00:00
ChemaVXandClaude Opus 5 02e553fffa fix(short): el divisor del prompt iba redondeado y no reproducía su propia cuenta
12 ÷ 2.8 no da 5.1, da 5.0. La cuenta trabajada la hace el modelo con el número
que ve, así que el número que ve tiene que ser el real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:41:02 +00:00
ChemaVXandClaude Opus 5 8000ef2145 fix(short): el prompt pedía tres cosas que no podían cumplirse a la vez
El modelo declaraba menos segundos de los que su propia línea necesitaba en 3 o
4 de cada 5 planos narrados — entre 3,8 y 8,2 s de deriva por Short. El vídeo
salía con la duración correcta porque shortsmith estira, pero el ritmo visual
que el spec escribía no era el que se renderizaba.

No era pereza del modelo: era un juego de reglas insatisfacible. El prompt pedía
a la vez líneas de hasta 18 palabras, planos de 6 s como mucho, y tiempo
declarado suficiente para la propia voz. 18 palabras piden 7,3 s, así que las
tres juntas son imposibles y el modelo rompía la única que nadie comprobaba.

Tres capas, las tres deterministas y sin gastar una generación:

- La regla enseñada llevaba el mismo error de clase que el estimador tenía antes
  del 2026-08-12: palabras por segundo a secas, sin el término de las pausas.
  Medida contra las 28 líneas que el bot ha narrado de verdad, `words/2.75 + 0.5`
  se quedaba corta en 14 y hasta 2,27 s — obedecerla al pie de la letra seguía
  infradeclarando. Ahora es de dos términos, como la voz, y el peor caso baja a
  1,27 s en 5 de 28.
- El tope de palabras por línea se DERIVA del plano más largo (`max_words_in`)
  en vez de escribirse a mano. La contradicción no puede volver.
- El ejemplo de referencia incumplía su propia regla en 2 de sus 6 líneas, y el
  ejemplo es la señal más fuerte del prompt: le estábamos enseñando el fallo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:38:51 +00:00
ChemaVXandClaude Opus 5 77029fa894 feat(youtube): la visibilidad se comprueba desde fuera, no se le cree a la API
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
`/upload_short` contaba lo que la respuesta de la subida decía del vídeo. Eso
es la palabra de la API sobre sí misma, y todo el flujo de revisión descansa en
ella: el informe de fundamento se lee ANTES de publicar sólo si subir no
publica. El 2026-08-12, mirando un vídeo recién subido, la respuesta decía
`privacyStatus: private` y el vídeo se veía sin sesión — resultó ser un clic
humano en Studio y no un fallo, pero el episodio dejó claro que no había forma
de distinguir un caso del otro.

Ahora se contrasta: oEmbed contesta 200 a un vídeo que se ve sin sesión y 404 a
uno que no. Sin credenciales, sin tocar el scope — `youtube.upload` no puede
preguntar por el estado de un vídeo, y ampliarlo a uno que sí pueda significa
darle a un token de subida permiso para vaciar el canal.

Dos decisiones que van con esto:

- **Sólo el 200 es una prueba.** Un 404 no demuestra que el vídeo sea privado:
  también lo devuelve uno que YouTube aún no ha indexado. Por eso el negativo
  se mira dos veces y, si sigue negativo, se cuenta como "no se ve desde
  fuera", no como "es privado".
- **No haber podido comprobar no es haber comprobado que no.** Un fallo de red
  deja `reachable=None` y el parte lo dice, en vez de heredar la garantía que
  no tiene.

Cuando la API dice privado y el vídeo se ve, el aviso va en la PRIMERA línea
del mensaje de Telegram: enterarse tiene que costar cero atención.

Verificado contra la realidad — el mismo vídeo daba True antes de ocultarlo y
False después; un vídeo borrado y uno privado dan False, y uno público True.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:09:39 +00:00
11 changed files with 693 additions and 37 deletions
+47
View File
@@ -97,6 +97,53 @@ What actually landed, and where it differs from the plan below:
redacted." costs three quarters of a second that a characters-only model gives away. redacted." costs three quarters of a second that a characters-only model gives away.
Estimates now land within half a second of the three rendered MP4s. The lesson is the Estimates now land within half a second of the three rendered MP4s. The lesson is the
older one restated: a constant taken from one sample is a guess with a decimal point. older one restated: a constant taken from one sample is a guess with a decimal point.
- **The spec writer was under-declaring because the prompt asked for three things that
could not all be true** (2026-08-13). Every narrated spec the bot had written declared
less time than its own narration needed on three or four shots out of five or six —
3.8 to 8.2 seconds of drift per Short. The video still came out the right length,
because `plan()` grows the shot; what was wrong was that the spec described a visual
rhythm that never rendered. The cause was not a lazy model. The prompt asked at once
for lines of up to 18 words, shots of at most 6 seconds, and enough declared time for
the line — and 18 words need 7.3 s, so the set is unsatisfiable. The model broke the
only one of the three that nothing checked. Three fixes, all deterministic and none
costing a generation to find: the taught rule got the per-sentence term it was missing
(`words/2.75 + 0.5` fell short on 14 of the 28 measured lines, by up to 2.27 s — so
even perfect obedience under-declared); the word cap is now *derived* from the shot
cap rather than written by hand, so the contradiction cannot come back; and the worked
example, which violated its own rule on two of its six lines, was cut to obey it. That
last one is the lesson worth keeping: **the example is the strongest signal in the
prompt, so an example that breaks a rule teaches the breakage**, whatever the prose
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: Original plan, kept for the record:
+1 -1
View File
@@ -10,7 +10,7 @@ aiohttp==3.14.1
# Scraping # Scraping
beautifulsoup4==4.15.0 beautifulsoup4==4.15.0
lxml==5.4.0 lxml==6.1.2
trafilatura==1.12.2 trafilatura==1.12.2
youtube-transcript-api==0.6.3 youtube-transcript-api==0.6.3
pdfplumber==0.11.10 pdfplumber==0.11.10
+15 -1
View File
@@ -854,7 +854,13 @@ def _upload_message(video, metadata: dict, article_url: Optional[str]) -> str:
lines = [f"🎬 {video.title}", "", f"Revisar y publicar: {video.studio_url}", lines = [f"🎬 {video.title}", "", f"Revisar y publicar: {video.studio_url}",
f"Enlace del vídeo: {video.watch_url}", ""] f"Enlace del vídeo: {video.watch_url}", ""]
if video.privacy_status == "private": if video.visibility_contradiction:
# Primera línea del mensaje, no una nota al pie: si esto pasa, el vídeo
# ya está en la calle mientras lees el informe de fundamento.
lines.insert(0, "🚨 EL VÍDEO SE VE SIN INICIAR SESIÓN, aunque YouTube "
"dijo que lo subía en privado. Ocúltalo en Studio antes "
"de nada — el enlace de abajo lleva ahí.\n")
elif video.privacy_status == "private":
lines.append( lines.append(
"🔒 Está PRIVADO. Los vídeos subidos por API desde un proyecto sin " "🔒 Está PRIVADO. Los vídeos subidos por API desde un proyecto sin "
"auditar se quedan así: el candado es del proyecto, no del vídeo, y " "auditar se quedan así: el candado es del proyecto, no del vídeo, y "
@@ -863,6 +869,14 @@ def _upload_message(video, metadata: dict, article_url: Optional[str]) -> str:
else: else:
lines.append(f"👁 Visibilidad: {video.privacy_status}") lines.append(f"👁 Visibilidad: {video.privacy_status}")
# Lo comprobado, aparte de lo que dijo la API: son dos cosas distintas y el
# 2026-08-12 se demostró que conviene no confundirlas.
if video.reachable is False:
lines.append("✔ Comprobado desde fuera: no se ve sin sesión.")
elif video.reachable is None:
lines.append("⚠️ No se pudo comprobar la visibilidad desde fuera; me "
"queda sólo lo que dijo la API. Míralo en Studio.")
if video.forced_private: if video.forced_private:
lines.append("⚠️ Pediste otra visibilidad y YouTube la forzó a privada. " lines.append("⚠️ Pediste otra visibilidad y YouTube la forzó a privada. "
"Es exactamente la firma de ese candado.") "Es exactamente la firma de ese candado.")
+2 -2
View File
@@ -139,7 +139,7 @@
"caption": "CONTACT HOLDS RELATIVE POSITION", "caption": "CONTACT HOLDS RELATIVE POSITION",
"turn_deg": 360 "turn_deg": 360
}, },
"narration": "He tried to shake it. Full circle, steep descent, and it was still there." "narration": "He tried to shake it. Full circle, steep descent, and it stayed there."
}, },
{ {
"template": "signal_strips", "template": "signal_strips",
@@ -207,7 +207,7 @@
"url": "THEEXCLUSIONZONE.COM", "url": "THEEXCLUSIONZONE.COM",
"show_mark": true "show_mark": true
}, },
"narration": "The file was never closed. It was filed, and left where anyone can read it." "narration": "The file was never closed. It was left where anyone can read it."
} }
] ]
} }
+42 -20
View File
@@ -23,7 +23,8 @@ import structlog
from src.generator.spec_contract import ( from src.generator.spec_contract import (
SpecInvalid, describe_templates, editorial_notes, estimated_duration, SpecInvalid, describe_templates, editorial_notes, estimated_duration,
validate_spec, NARRATION_WORDS_PER_SECOND, max_words_in, validate_spec, NARRATION_ROUNDED_PAD,
NARRATION_SENTENCE_SILENCE, NARRATION_WORDS_PER_SECOND,
TARGET_MAX_DURATION, TARGET_MIN_DURATION, TARGET_MAX_DURATION, TARGET_MIN_DURATION,
) )
@@ -49,20 +50,26 @@ NOTE_ATTEMPTS = 1
#: El ejemplo de referencia habla 74. La sesión 167 habló 97 y salió a 47,5 s. #: El ejemplo de referencia habla 74. La sesión 167 habló 97 y salió a 47,5 s.
NARRATION_WORD_BUDGET = 80 NARRATION_WORD_BUDGET = 80
#: Lo que mide una línea. No es preferencia de estilo: son las líneas del #: Lo que dura un plano como mucho. Del ejemplo (6,0 s el más largo, 5,6 de
#: ejemplo (11, 12, 10, 14, 12, 15 palabras). El tope anterior — "menos de 25" — #: media). Sin este tope el modelo declaraba 42 s en seis planos de siete
#: no describía nada que el canal hubiera publicado, y el modelo escribió líneas
#: de 25 y 27 palabras sin saltarse ninguna regla.
NARRATION_WORDS_PER_LINE = 12
NARRATION_WORDS_PER_LINE_MAX = 18
#: Lo que dura un plano como mucho. También del ejemplo (6,0 s el más largo,
#: 5,6 de media). Sin este tope el modelo declaraba 42 s en seis planos de siete
#: segundos y LUEGO les colgaba la narración encima: el primer borrador salía a #: segundos y LUEGO les colgaba la narración encima: el primer borrador salía a
#: 50 s las tres veces que se midió, y hacía falta una reescritura entera para #: 50 s las tres veces que se midió, y hacía falta una reescritura entera para
#: bajarlo. #: bajarlo.
MAX_SHOT_DURATION = 6.0 MAX_SHOT_DURATION = 6.0
#: Lo que mide una línea. No es preferencia de estilo: es la media del ejemplo.
NARRATION_WORDS_PER_LINE = 12
#: Y el tope duro NO se escribe a mano: es cuántas palabras caben en el plano
#: más largo que se permite declarar. Escribirlos por separado fue el fallo que
#: hizo que el modelo infradeclarase casi todos sus planos, y no por pereza: el
#: prompt le pedía a la vez líneas de hasta 18 palabras, planos de 6 s como
#: mucho y tiempo declarado suficiente para su propia voz. Las tres a la vez son
#: imposibles — 18 palabras piden 7,3 s — así que el modelo rompía la única que
#: nadie comprobaba, la duración declarada. Derivando el tope de la duración, la
#: contradicción no puede volver.
NARRATION_WORDS_PER_LINE_MAX = max_words_in(MAX_SHOT_DURATION, sentences=2)
EXAMPLE_PATH = Path(__file__).parent / "examples" / "jal1628.json" EXAMPLE_PATH = Path(__file__).parent / "examples" / "jal1628.json"
__all__ = ["ShortSpecWriter", "SpecResult", "SpecWriteFailed", "NARRATIVE_SHAPES"] __all__ = ["ShortSpecWriter", "SpecResult", "SpecWriteFailed", "NARRATIVE_SHAPES"]
@@ -156,11 +163,12 @@ aloud by the renderer and burned in as captions. Write it for the ear.
watches the rest, and the opening shot's narration is those two seconds. Lead \ watches the rest, and the opening shot's narration is those two seconds. Lead \
with the strangest true thing you have, not with a preamble. with the strangest true thing you have, not with a preamble.
- **Keep a line to {words_per_line} words, hard stop at {words_per_line_max}.** \ - **Keep a line to {words_per_line} words, hard stop at {words_per_line_max}.** \
That is the example's own average, and it is not a style preference: a 25-word \ That is the example's own average, and the hard stop is not a style preference \
line is three seconds of your whole budget spent on one shot. Long sentences lose the listener and \ either it is exactly as much as fits in the longest shot you are allowed to \
stretch the shot; the renderer will not cut your voice off, it will make the \ declare. {words_per_line_max} words is {max_shot:.0f} seconds; the same limit, \
shot longer instead, and a Short that drifts past {target_max:.0f} seconds is a \ written twice. Go past it and the shot has to grow, because the renderer will \
Short people leave. not cut your voice off it makes the shot longer instead, and a Short that \
drifts past {target_max:.0f} seconds is a Short people leave.
- **Do not read the screen aloud.** The captions already show your words and \ - **Do not read the screen aloud.** The captions already show your words and \
the template already shows its own. If the shot draws "35,000 FT", the voice \ the template already shows its own. If the shot draws "35,000 FT", the voice \
says what that altitude meant, not the number again. says what that altitude meant, not the number again.
@@ -175,13 +183,25 @@ competing with words already on the frame. Chosen silence is an edit; a spec \
with one narrated shot out of eight is not a Short with a voice, it is a Short \ with one narrated shot out of eight is not a Short with a voice, it is a Short \
that forgot to speak. that forgot to speak.
- **Give every narrated shot enough time for its own line, and work it out \ - **Give every narrated shot enough time for its own line, and work it out \
rather than guessing.** The voice reads about {words_per_second:.1f} words a \ rather than guessing.** The voice reads about {words_per_second:g} words a \
second and pauses a quarter second at every full stop, so: second and pauses a quarter second at every full stop, so **count the words AND \
count the sentences**:
duration words ÷ {words_per_second:.1f} + half a second duration words ÷ {words_per_second:g} + {sentence_pause} × sentences + \
{rounded_pad}
A twelve-word line needs five seconds; give that shot 5.0, not 4.0. This is \ The second term is the one that catches people out. "Witness identities. \
the one rule that makes your own arithmetic true: a shot runs for the LONGER of \ Sensor details. Locations redacted." is six words and three full stops: it is \
not a fast line, it is three quarters of a second of silence on top. Two lines \
of the same length do not take the same time if one of them is chopped.
Worked, on the example below: shot 1 speaks twelve words in one sentence, so \
12 ÷ {words_per_second:g} + {sentence_pause} + {rounded_pad} = 5.1, and it \
declares 5.5. Shot 4 speaks thirteen words in two sentences, so 13 ÷ \
{words_per_second:g} + 0.5 + {rounded_pad} = 5.7, and it declares 6.0. Round \
up, never down.
This is the one rule that makes your own arithmetic true: a shot runs for the LONGER of \
its declared duration and its line never shorter, the voice is never cut off \ its declared duration and its line never shorter, the voice is never cut off \
so a shot that declares less than its line silently grows, and the video ends \ so a shot that declares less than its line silently grows, and the video ends \
up longer than the durations you wrote. Hold this rule and the total you \ up longer than the durations you wrote. Hold this rule and the total you \
@@ -466,6 +486,8 @@ class ShortSpecWriter:
words_per_line=NARRATION_WORDS_PER_LINE, words_per_line=NARRATION_WORDS_PER_LINE,
words_per_line_max=NARRATION_WORDS_PER_LINE_MAX, words_per_line_max=NARRATION_WORDS_PER_LINE_MAX,
max_shot=MAX_SHOT_DURATION, max_shot=MAX_SHOT_DURATION,
sentence_pause=f"{NARRATION_SENTENCE_SILENCE:g}",
rounded_pad=f"{NARRATION_ROUNDED_PAD:g}",
example=_load_example(), example=_load_example(),
article=article, article=article,
context=context, context=context,
+173 -2
View File
@@ -29,6 +29,9 @@ __all__ = [
"editorial_notes", "editorial_notes",
"estimated_duration", "estimated_duration",
"spoken_seconds", "spoken_seconds",
"sentence_count",
"teachable_seconds",
"max_words_in",
"describe_templates", "describe_templates",
"TARGET_MIN_DURATION", "TARGET_MIN_DURATION",
"TARGET_MAX_DURATION", "TARGET_MAX_DURATION",
@@ -168,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]:
@@ -315,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:
@@ -350,11 +478,54 @@ NARRATION_PAD = 0.45
#: le pide que recorte texto, no tiempo. #: le pide que recorte texto, no tiempo.
NARRATION_WORDS_PER_SECOND = 2.75 NARRATION_WORDS_PER_SECOND = 2.75
#: El respiro redondeado hacia arriba, para la regla que se le enseña al modelo.
#: `NARRATION_PAD` son 0,45 s; "medio segundo" se sostiene en la cabeza y va
#: sobrado, que es la dirección correcta en la que equivocarse.
NARRATION_ROUNDED_PAD = 0.5
#: Final de frase: un punto pegado a la palabra y seguido de espacio o de nada. #: Final de frase: un punto pegado a la palabra y seguido de espacio o de nada.
#: El decimal de "1.5" no cuenta, y por eso mira lo que va detrás. #: El decimal de "1.5" no cuenta, y por eso mira lo que va detrás.
_SENTENCE_END = re.compile(r"[.!?](?=\s|$)") _SENTENCE_END = re.compile(r"[.!?](?=\s|$)")
def sentence_count(line: str) -> int:
"""Frases de una línea, contadas como las cuenta Piper para sus pausas."""
return max(1, len(_SENTENCE_END.findall(line))) if line.strip() else 0
def teachable_seconds(words: int, sentences: int = 1) -> float:
"""Lo que hay que DECLARAR para una línea, en las unidades que el modelo cuenta.
Es `spoken_seconds` traducido de caracteres a palabras. La traducción hace
falta porque un LLM no cuenta caracteres de fiar, pero cuenta palabras y
puntos y la regla tiene que ser computable por quien debe obedecerla, o no
es una regla, es un deseo.
Los dos términos son los mismos que los de la voz. La versión anterior del
prompt colapsaba el segundo en un "+ medio segundo" fijo, y ese es el mismo
error de clase que tenía el estimador antes del 2026-08-12: sin pausa por
frase, una línea troceada en frases cortas se da por rápida. Medido contra
las 28 líneas reales, aquella regla se quedaba corta en 14 y hasta 2,27 s
o sea que un modelo que la obedeciera al pie de la letra seguiría
infradeclarando la mitad de sus planos. Con el término por frase el peor
caso baja a 1,27 s y sólo en 5 de 28.
"""
return (words / NARRATION_WORDS_PER_SECOND
+ sentences * NARRATION_SENTENCE_SILENCE
+ NARRATION_ROUNDED_PAD)
def max_words_in(seconds: float, sentences: int = 1) -> int:
"""Cuántas palabras caben en un plano de esa duración, según la regla de arriba.
Existe para que el tope de palabras por línea y el tope de duración de plano
no puedan volver a contradecirse: se deriva uno del otro en vez de escribir
los dos a mano.
"""
room = seconds - sentences * NARRATION_SENTENCE_SILENCE - NARRATION_ROUNDED_PAD
return max(1, int(room * NARRATION_WORDS_PER_SECOND))
def spoken_seconds(line: str) -> float: def spoken_seconds(line: str) -> float:
"""Lo que tarda la voz en decir una línea, sin el respiro final. """Lo que tarda la voz en decir una línea, sin el respiro final.
+71 -1
View File
@@ -45,6 +45,15 @@ UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos"
#: del canal: si el token se filtra, lo peor que se puede hacer con él es subir. #: del canal: si el token se filtra, lo peor que se puede hacer con él es subir.
SCOPE = "https://www.googleapis.com/auth/youtube.upload" SCOPE = "https://www.googleapis.com/auth/youtube.upload"
#: Con `youtube.upload` no se le puede PREGUNTAR a la API por el estado de un
#: vídeo, así que la visibilidad se comprueba desde fuera y sin credenciales:
#: oEmbed contesta 200 a un vídeo que se ve sin sesión y 401/404 a uno que no.
#: Es la única forma de contrastar lo que dice la respuesta de la subida sin
#: cambiar un token que sólo sabe subir por uno que puede vaciar el canal.
OEMBED_URL = "https://www.youtube.com/oembed"
#: Segunda pasada por si YouTube aún no había indexado el vídeo recién subido.
_VISIBILITY_RECHECK_DELAY = 3.0
#: Márgen antes de que caduque el token de acceso (dura 3600 s). #: Márgen antes de que caduque el token de acceso (dura 3600 s).
_TOKEN_MARGIN = 120.0 _TOKEN_MARGIN = 120.0
#: Tokens de acceso en memoria por client_id. El bot crea un uploader nuevo en #: Tokens de acceso en memoria por client_id. El bot crea un uploader nuevo en
@@ -97,6 +106,16 @@ class UploadedVideo:
upload_status: str = "" upload_status: str = ""
#: Por qué YouTube marcó el vídeo como no reproducible, si lo hizo. #: Por qué YouTube marcó el vídeo como no reproducible, si lo hizo.
rejection_reason: str = "" rejection_reason: str = ""
#: Si el vídeo se ve sin iniciar sesión, comprobado desde fuera en vez de
#: creerle a la respuesta de la subida. None = no se pudo comprobar.
reachable: Optional[bool] = None
@property
def visibility_contradiction(self) -> bool:
"""La API dice privado y el vídeo se ve. Es el caso que hay que gritar:
todo el flujo de revisión informe de fundamento primero, publicar
después descansa en que subir NO publica."""
return self.reachable is True and self.privacy_status == "private"
@property @property
def watch_url(self) -> str: def watch_url(self) -> str:
@@ -336,11 +355,62 @@ class YouTubeUploader:
rejection_reason=(status.get("rejectionReason") rejection_reason=(status.get("rejectionReason")
or status.get("failureReason") or ""), or status.get("failureReason") or ""),
) )
await _report(on_progress, "🔎 Comprobando la visibilidad…")
result.reachable = await self.reachable(result.video_id)
logger.info("Short subido a YouTube", video_id=result.video_id, logger.info("Short subido a YouTube", video_id=result.video_id,
privacy=result.privacy_status, privacy=result.privacy_status,
forced_private=result.forced_private) forced_private=result.forced_private,
reachable=result.reachable)
if result.visibility_contradiction:
logger.error("El vídeo se ve sin sesión y la API lo dio por privado",
video_id=result.video_id)
return result return result
async def reachable(self, video_id: str) -> Optional[bool]:
"""¿Se ve este vídeo sin haber iniciado sesión?
True = cualquiera con el enlace lo ve. False = no. None = no se pudo
averiguar.
**La asimetría es deliberada.** Un 200 PRUEBA que el vídeo es accesible;
un 404 no prueba que sea privado, porque también lo devuelve un vídeo
que YouTube todavía no ha terminado de indexar segundos después de
subirlo. Por eso sólo el 200 dispara un aviso, y por eso el negativo se
reintenta una vez antes de darlo por bueno.
Nunca levanta: esto contrasta un dato, no lo produce. Si la red falla, el
vídeo ya está subido y lo que toca es decir que no se pudo comprobar
no convertir una comprobación en el motivo de que la subida parezca
haber fallado.
"""
if not video_id:
return None
params = {"url": f"https://www.youtube.com/watch?v={video_id}",
"format": "json"}
seen: Optional[bool] = None
for attempt in (1, 2):
try:
async with self._session(20) as sess:
async with sess.get(OEMBED_URL, params=params) as resp:
status = resp.status
except (aiohttp.ClientError, OSError) as e:
logger.warning("No se pudo comprobar la visibilidad",
video_id=video_id, error=str(e))
return seen
if status == 200:
return True
if status in (401, 403, 404):
seen = False
else:
logger.warning("oEmbed contestó algo inesperado",
video_id=video_id, status=status)
return seen
if attempt == 1:
import asyncio
await asyncio.sleep(_VISIBILITY_RECHECK_DELAY)
return seen
async def _start(self, token: str, metadata: dict, size: int) -> str: async def _start(self, token: str, metadata: dict, size: int) -> str:
"""Paso 1: los metadatos. Devuelve la URL de subida (cabecera Location).""" """Paso 1: los metadatos. Devuelve la URL de subida (cabecera Location)."""
headers = { headers = {
+34
View File
@@ -103,6 +103,40 @@ def test_upload_message_warns_when_the_description_has_no_article():
assert "force" in text assert "force" in text
def test_upload_message_shouts_when_the_video_is_already_watchable():
"""El caso serio, y va PRIMERO en el mensaje.
Si subir publica, el informe de fundamento se lee cuando el vídeo ya está en
la calle el orden entero del flujo deja de significar nada. Enterarse
tiene que costar cero atención: en la primera línea o no sirve.
"""
from src.bot.bot import _upload_message
text = _upload_message(_uploaded(reachable=True), {}, "https://x.test/")
assert "SE VE SIN INICIAR SESIÓN" in text.split("\n")[0]
assert "studio.youtube.com/video/abc123/edit" in text
# Y no se cuenta a la vez el cuento tranquilizador del candado.
assert "auditoría" not in text
def test_upload_message_confirms_a_video_nobody_can_watch():
from src.bot.bot import _upload_message
text = _upload_message(_uploaded(reachable=False), {}, "https://x.test/")
assert "no se ve sin sesión" in text
assert "SE VE SIN INICIAR SESIÓN" not in text
def test_upload_message_admits_when_it_could_not_check():
"""No haber podido comprobar no es haber comprobado que no. Decir "privado"
a secas aquí sería dar por garantía lo que sólo es la palabra de la API."""
from src.bot.bot import _upload_message
text = _upload_message(_uploaded(reachable=None), {}, "https://x.test/")
assert "No se pudo comprobar" in text
assert "Míralo en Studio" in text
def test_upload_message_is_plain_text(): def test_upload_message_is_plain_text():
"""Va sin parse_mode: lleva el título del modelo, y un Markdown roto haría """Va sin parse_mode: lleva el título del modelo, y un Markdown roto haría
que Telegram rechazara justo el mensaje que trae el enlace.""" que Telegram rechazara justo el mensaje que trae el enlace."""
+78 -7
View File
@@ -134,7 +134,9 @@ def test_the_worked_example_narrates_most_of_its_shots():
def test_the_worked_example_declares_time_for_its_own_narration(): def test_the_worked_example_declares_time_for_its_own_narration():
"""Un plano que se queda corto para su propia voz enseña a infradeclarar: el """Un plano que se queda corto para su propia voz enseña a infradeclarar: el
render no corta la voz, alarga el plano, y el total se va del objetivo.""" render no corta la voz, alarga el plano, y el total se va del objetivo."""
from src.generator.spec_contract import NARRATION_WORDS_PER_SECOND, spoken_seconds from src.generator.spec_contract import (
NARRATION_PAD, sentence_count, spoken_seconds, teachable_seconds,
)
example = json.loads(EXAMPLE.read_text()) example = json.loads(EXAMPLE.read_text())
for i, shot in enumerate(example["shots"]): for i, shot in enumerate(example["shots"]):
@@ -142,10 +144,14 @@ def test_the_worked_example_declares_time_for_its_own_narration():
if not narration: if not narration:
continue continue
# La cuenta que el prompt le pide al modelo, aplicada al ejemplo que le # La cuenta que el prompt le pide al modelo, aplicada al ejemplo que le
# pone delante. Si no cuadran, la regla en prosa pierde. # pone delante. Si no cuadran, la regla en prosa pierde: el ejemplo es
rule = len(narration.split()) / NARRATION_WORDS_PER_SECOND + 0.5 # la señal más fuerte. Dos de estas seis líneas NO cumplían — y ese es
assert shot["duration"] >= rule, f"shot {i} declara menos de lo que habla" # exactamente el defecto que el modelo copiaba.
assert shot["duration"] >= spoken_seconds(narration), \ rule = teachable_seconds(len(narration.split()), sentence_count(narration))
assert shot["duration"] >= rule - 1e-9, \
f"shot {i} declara menos de lo que su propia regla pide"
# Y contra la voz medida, no sólo contra la regla que la aproxima.
assert shot["duration"] >= spoken_seconds(narration) + NARRATION_PAD - 1e-9, \
f"shot {i} se quedaría corto para su propia voz" f"shot {i} se quedaría corto para su propia voz"
@@ -160,9 +166,11 @@ def test_the_prompt_gives_a_budget_the_model_can_count():
w, _ = writer("{}") w, _ = writer("{}")
prompt = w.build_prompt("Caso X", "material", None, "X.TEST") prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
assert f"{NARRATION_WORDS_PER_SECOND:.1f} words a second" in prompt, \ # La constante exacta, no redondeada: el prompt trae una cuenta trabajada, y
# con "2.8" el divisor mostrado no reproduce el resultado mostrado.
assert f"{NARRATION_WORDS_PER_SECOND:g} words a second" in prompt, \
"sin el ritmo de la voz no hay cuenta que el modelo pueda hacer" "sin el ritmo de la voz no hay cuenta que el modelo pueda hacer"
assert f"words ÷ {NARRATION_WORDS_PER_SECOND:.1f}" in prompt assert f"words ÷ {NARRATION_WORDS_PER_SECOND:g}" in prompt
assert f"{NARRATION_WORD_BUDGET} words" in prompt assert f"{NARRATION_WORD_BUDGET} words" in prompt
@@ -187,6 +195,69 @@ def test_the_worked_example_obeys_the_budget_it_preaches():
assert round(sum(lines) / len(lines)) == NARRATION_WORDS_PER_LINE assert round(sum(lines) / len(lines)) == NARRATION_WORDS_PER_LINE
def test_the_longest_line_allowed_fits_in_the_longest_shot_allowed():
"""La contradicción que hacía infradeclarar, convertida en test.
El prompt pedía a la vez líneas de hasta 18 palabras, planos de 6 s como
mucho, y tiempo declarado suficiente para la propia voz. Las tres juntas son
imposibles 18 palabras piden 7,3 s y el modelo rompía la única que nadie
comprobaba. Si alguien vuelve a subir el tope de palabras a mano, esto salta.
"""
from src.generator.shortspec import (
MAX_SHOT_DURATION, NARRATION_WORDS_PER_LINE_MAX,
)
from src.generator.spec_contract import teachable_seconds
# En el caso malo: una línea al tope, partida en dos frases (dos pausas).
assert teachable_seconds(NARRATION_WORDS_PER_LINE_MAX, 2) <= MAX_SHOT_DURATION
# Y el tope es apretado, no una holgura cómoda que esconda otra vez el fallo:
# una palabra más ya no cabría.
assert teachable_seconds(NARRATION_WORDS_PER_LINE_MAX + 1, 2) > MAX_SHOT_DURATION
def test_the_prompt_rule_counts_the_pauses_and_not_only_the_words():
"""Palabras por segundo a secas es el mismo error que tenía el estimador.
Medido contra las 28 líneas que el bot ha narrado de verdad, `words/2.75 +
0.5` se quedaba corta en 14 y hasta 2,27 s: obedecerla al pie de la letra
seguía infradeclarando media docena de planos. La regla del prompt tiene que
llevar el término por frase, y tiene que ser LA MISMA que aplican los tests.
"""
w, _ = writer("{}")
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
assert "× sentences" in prompt
assert "count the sentences" in prompt
# Una línea troceada cuesta más que una seguida con las mismas palabras.
from src.generator.spec_contract import teachable_seconds
assert teachable_seconds(12, 3) > teachable_seconds(12, 1)
def test_the_worked_arithmetic_in_the_prompt_is_actually_right():
"""Un ejemplo numérico equivocado enseña la cuenta equivocada, y se lee antes
que la fórmula."""
import re as _re
from src.generator.spec_contract import sentence_count, teachable_seconds
w, _ = writer("{}")
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
example = json.loads(EXAMPLE.read_text())
worked = _re.findall(
r"[Ss]hot (\d+) speaks \w+ words in \w+ sentences?, so [^=]+= ([\d.]+)",
prompt)
# Sin esto el test pasa en vacío si alguien reescribe el párrafo.
assert len(worked) == 2, f"no se encontraron las cuentas trabajadas: {worked}"
for index, claimed in worked:
narration = example["shots"][int(index)]["narration"]
real = teachable_seconds(len(narration.split()), sentence_count(narration))
assert abs(real - float(claimed)) < 0.05, \
f"el prompt dice {claimed}s para shots.{index}, la regla da {real:.2f}s"
def test_prompt_says_out_loud_that_there_is_no_article_yet(): def test_prompt_says_out_loud_that_there_is_no_article_yet():
w, _ = writer("{}") w, _ = writer("{}")
assert "No article URL yet" in w.build_prompt("X", "m", None, "X.TEST") assert "No article URL yet" in w.build_prompt("X", "m", None, "X.TEST")
+121
View File
@@ -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 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]]}
+109 -3
View File
@@ -6,6 +6,7 @@ sube un vídeo a un canal de verdad, y eso no es algo que deba pasar por teclear
""" """
import json import json
import aiohttp
import pytest import pytest
from src.generator import youtube as yt from src.generator import youtube as yt
@@ -75,6 +76,9 @@ class FakeSession:
def put(self, url, **kw): def put(self, url, **kw):
return self._next("PUT", url) return self._next("PUT", url)
def get(self, url, **kw):
return self._next("GET", url)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def clean_token_cache(): def clean_token_cache():
@@ -83,14 +87,33 @@ def clean_token_cache():
yt._token_cache.clear() yt._token_cache.clear()
@pytest.fixture(autouse=True)
def no_recheck_delay(monkeypatch):
"""La segunda pasada de la comprobación espera 3 s en producción, que es lo
que tarda YouTube en indexar. Aquí no se espera a nada."""
monkeypatch.setattr(yt, "_VISIBILITY_RECHECK_DELAY", 0)
@pytest.fixture @pytest.fixture
def uploader(): def uploader():
return YouTubeUploader(client_id="cid", client_secret="secret", return YouTubeUploader(client_id="cid", client_secret="secret",
refresh_token="refresh") refresh_token="refresh")
#: Lo que oEmbed contesta de un vídeo que no se ve sin sesión.
OEMBED_HIDDEN = FakeResp(404, body="Not Found")
#: Y de uno que sí.
OEMBED_VISIBLE = FakeResp(200, {"title": "JAL 1628", "type": "video"})
def patch(client, routes): def patch(client, routes):
session = FakeSession(routes) """El servidor falso, con la comprobación de visibilidad ya enrutada.
`upload()` la hace siempre, así que todo test que suba pasa por oEmbed. Por
defecto contesta "no se ve", que es lo que se espera de un vídeo privado; el
test que quiera el caso malo pone su propia ruta `/oembed`.
"""
session = FakeSession({"/oembed": OEMBED_HIDDEN, **routes})
client._session = lambda total: session client._session = lambda total: session
return session return session
@@ -170,8 +193,10 @@ async def test_upload_does_metadata_then_bytes(uploader, video):
assert result.video_id == "abc123" assert result.video_id == "abc123"
assert result.watch_url == "https://youtube.com/shorts/abc123" assert result.watch_url == "https://youtube.com/shorts/abc123"
assert result.studio_url.endswith("/abc123/edit") assert result.studio_url.endswith("/abc123/edit")
assert [c[0] for c in session.calls] == ["POST", "POST", "PUT"] # Token, metadatos, bytes, y la comprobación de visibilidad — que se
assert len(seen) == 3, "cada etapa avisa: autenticar, abrir, subir" # reintenta porque el primer 404 puede ser YouTube todavía indexando.
assert [c[0] for c in session.calls] == ["POST", "POST", "PUT", "GET", "GET"]
assert len(seen) == 4, "cada etapa avisa: autenticar, abrir, subir, comprobar"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -339,3 +364,84 @@ def test_uploaded_video_urls():
video = UploadedVideo(video_id="xyz", title="t", privacy_status="private") video = UploadedVideo(video_id="xyz", title="t", privacy_status="private")
assert video.watch_url == "https://youtube.com/shorts/xyz" assert video.watch_url == "https://youtube.com/shorts/xyz"
assert video.studio_url == "https://studio.youtube.com/video/xyz/edit" assert video.studio_url == "https://studio.youtube.com/video/xyz/edit"
# --- la visibilidad, comprobada en vez de creída ----------------------------
@pytest.mark.asyncio
async def test_a_video_anyone_can_watch_is_detected(uploader, video):
"""El caso que existe para pillar: la API dice privado y el vídeo se ve.
Todo el flujo de revisión informe de fundamento primero, publicar después
descansa en que subir NO publique. Si eso deja de ser cierto hay que
enterarse por el parte de la subida, no por una visita al canal.
"""
patch(uploader, {
"/token": TOKEN_OK,
"/upload/youtube": FakeResp(200, {}, headers={"Location": "https://up/x"}),
"https://up/x": FakeResp(200, VIDEO_OK),
"/oembed": OEMBED_VISIBLE,
})
result = await uploader.upload(video, build_metadata(SPEC, "x"))
assert result.privacy_status == "private", "la API sigue diciendo privado"
assert result.reachable is True
assert result.visibility_contradiction
@pytest.mark.asyncio
async def test_a_private_video_reports_no_contradiction(uploader, video):
patch(uploader, {
"/token": TOKEN_OK,
"/upload/youtube": FakeResp(200, {}, headers={"Location": "https://up/x"}),
"https://up/x": FakeResp(200, VIDEO_OK),
})
result = await uploader.upload(video, build_metadata(SPEC, "x"))
assert result.reachable is False
assert not result.visibility_contradiction
@pytest.mark.asyncio
async def test_a_video_visible_only_on_the_second_look_still_counts(uploader):
"""Segundos después de subirlo, oEmbed devuelve 404 de un vídeo que sí se
ve: aún no está indexado. Un solo vistazo daría por privado justo el vídeo
que hay que gritar."""
session = patch(uploader, {"/oembed": [OEMBED_HIDDEN, OEMBED_VISIBLE]})
assert await uploader.reachable("abc123") is True
assert len(session.calls) == 2
@pytest.mark.asyncio
async def test_two_hidden_looks_are_enough_to_stop_asking(uploader):
session = patch(uploader, {"/oembed": OEMBED_HIDDEN})
assert await uploader.reachable("abc123") is False
assert len(session.calls) == 2
@pytest.mark.asyncio
async def test_a_network_failure_is_not_knowing_rather_than_privacy(uploader):
"""No se pudo comprobar NO es lo mismo que no se ve. Devolver False aquí
sería inventarse una garantía a partir de un fallo de red."""
class Broken:
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
def get(self, url, **kw):
raise aiohttp.ClientError("sin red")
uploader._session = lambda total: Broken()
assert await uploader.reachable("abc123") is None
@pytest.mark.asyncio
async def test_an_upload_without_an_id_is_not_checked(uploader):
session = patch(uploader, {"/oembed": OEMBED_VISIBLE})
assert await uploader.reachable("") is None
assert session.calls == []