Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60b76f91ab | ||
|
|
a17edf43b7 | ||
|
|
6e4b3e1379 | ||
|
|
818533c86f | ||
|
|
6f960c303d | ||
|
|
4099e3eecb | ||
|
|
02e553fffa | ||
|
|
8000ef2145 | ||
|
|
77029fa894 |
@@ -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
@@ -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
@@ -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.")
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 sí 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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]]}
|
||||||
|
|||||||
+109
-3
@@ -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 == []
|
||||||
|
|||||||
Reference in New Issue
Block a user