Author SHA1 Message Date
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
ChemaVXandClaude Opus 5 91ceb3b1ca fix(short): una nota de duración vale una reescritura, no dos
Build & Deploy ResearchOwl / build-and-push (push) Successful in 39s
`editorial_notes` documentaba "se comenta una vez y, si insiste, se renderiza
igual", pero el bucle reintentaba dos veces: las sesiones 166, 167 y 168
gastaron los tres intentos y las tres acabaron renderizando un spec que seguía
pasándose. Un spec que ya cumple el contrato es renderizable; los intentos que
quedan son para el contrato, que sí es binario.

Y de dos specs válidos se guarda el que menos se sale del objetivo, no el
primero. Obedecer a medias es obedecer: antes, una reescritura que bajaba de
70 s a 50 s se tiraba entera y salía el largo.

El prompt, que era la mitad que faltaba. Decirle "20-45 segundos" no le sirve
de nada: la duración real no está escrita en ninguna parte del spec, sale de
sumar plano a plano el mayor entre lo declarado y lo que tarda la voz, y eso no
lo puede calcular quien no sabe a qué velocidad se le lee. Ahora lleva el
ritmo (2,8 palabras por segundo), la cuenta por plano, un presupuesto de 80
palabras de narración, y dos topes que salen del propio ejemplo en vez de estar
inventados: 12 palabras por línea (tope 18) y 6 s por plano. El anterior — "una
línea de menos de 25 palabras" — no describía nada que el canal hubiera
publicado, y el modelo escribía líneas de 25 y 27 sin saltarse ninguna regla.

Medido con cinco generaciones reales sobre el material de la sesión 168:
antes 3 intentos siempre, con el vídeo fuera del objetivo (47,5 s y 45,4 s).
Ahora 2, y el borrador cae dentro: 39,8 / 37,3 s. En dos de las cinco el
reintento ya no fue por duración sino por un fallo de contrato.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:37:17 +00:00
ChemaVXandClaude Opus 5 9bfa0fdac2 fix(short): la voz se midió con una sola frase, y por eso el bot reescribía Shorts que ya cabían
`NARRATION_CHARS_PER_SECOND` era 14,2, sacado de una única línea de 82
caracteres. Sintetizando de verdad las 28 líneas que el bot ha escrito hasta
hoy — mismo Piper, mismo modelo, mismas banderas deterministas — la voz lee a
18,5 car/s y se calla 0,25 s en cada punto. Contar las frases aparte es lo que
arregla el caso raro: "Witness identities. Sensor details. Locations redacted."
son tres cuartos de segundo de silencio que un modelo de caracteres a secas
regala.

El error del modelo viejo era de cuatro a seis segundos sobre un Short entero,
siempre por arriba, y con eso el aviso de duración saltaba en vídeos que
estaban dentro del objetivo. Contrastado ahora contra los tres MP4 que hay
renderizados: 39,42 / 47,19 / 45,81 s estimados contra 39,57 / 47,53 / 45,40
reales.

Dos cosas más, del mismo tirón:

- Un margen de 1,5 s antes de avisar. La estimación acierta dentro de un
  segundo por línea, así que medio segundo de exceso puede ser del estimador y
  no del spec; la sesión 168 se llevó una generación entera por ochocientas
  milésimas. El objetivo sigue siendo 20-45.
- El consejo va en palabras, no en "recorta narración", y señala el plano que
  más habla. Las tres veces que saltó, el modelo devolvió un spec que seguía
  pasándose: no sabía cuánto.

Los segundos medidos entran en los tests como tabla, no como número redondo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:37:17 +00:00
10 changed files with 643 additions and 44 deletions
+11
View File
@@ -86,6 +86,17 @@ What actually landed, and where it differs from the plan below:
thing that order revealed: with the voice repeating on-screen figures, claims had to thing that order revealed: with the voice repeating on-screen figures, claims had to
be de-duplicated by *canonical unit* ("35,000 FT" and "35,000 feet" are one claim) or be de-duplicated by *canonical unit* ("35,000 FT" and "35,000 feet" are one claim) or
every narrated Short would double its own review report. every narrated Short would double its own review report.
- **researchowl's estimate of the voice had to be measured, not assumed** (2026-08-12).
It shipped with 14.2 characters per second, taken from a single line, and that
overshot every narration by about a fifth — four to six seconds on a whole Short,
enough to make the spec writer rewrite videos that were already inside the target.
Every generation since narration shipped had spent all three attempts on it.
Synthesizing the 28 narration lines the bot had actually written gave 18.5 char/s
**plus 0.25 s at every full stop**, which is the term that matters: Piper's
`SENTENCE_SILENCE` is per sentence, so "Witness identities. Sensor details. Locations
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
older one restated: a constant taken from one sample is a guess with a decimal point.
Original plan, kept for the record: Original plan, kept for the record:
+1 -1
View File
@@ -23,7 +23,7 @@ aiosqlite==0.22.1
# Processing # Processing
tiktoken==0.7.0 tiktoken==0.7.0
numpy==2.5.2 numpy==1.26.4
scikit-learn==1.5.1 scikit-learn==1.5.1
# Claude API (scoring) # Claude API (scoring)
+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.")
+109 -19
View File
@@ -22,7 +22,8 @@ from typing import Any, Awaitable, Callable, Optional
import structlog import structlog
from src.generator.spec_contract import ( from src.generator.spec_contract import (
SpecInvalid, describe_templates, editorial_notes, validate_spec, SpecInvalid, describe_templates, editorial_notes, estimated_duration,
validate_spec, NARRATION_WORDS_PER_SECOND,
TARGET_MAX_DURATION, TARGET_MIN_DURATION, TARGET_MAX_DURATION, TARGET_MIN_DURATION,
) )
@@ -32,6 +33,36 @@ logger = structlog.get_logger()
#: que arreglar es el prompt, no este número. #: que arreglar es el prompt, no este número.
MAX_ATTEMPTS = 3 MAX_ATTEMPTS = 3
#: Reescrituras que se gastan en una nota editorial, no en un fallo de contrato.
#: UNA. Un spec que ya cumple el contrato y sólo se pasa de duración es
#: renderizable: la segunda reescritura no compraba un Short mejor, compraba una
#: generación más. Medido sobre las sesiones 166, 167 y 168 — las tres gastaron
#: los tres intentos por duración y las tres acabaron renderizando un spec que
#: seguía pasándose. Los intentos que quedan son para el contrato, que sí es
#: binario. Ver `_how_to_trim` en `spec_contract`: si la nota no se obedece a la
#: primera, lo que hay que arreglar es la nota.
NOTE_ATTEMPTS = 1
#: Cuánta narración cabe en un Short entero. Comprobación cruzada de la regla
#: de arriba, en la unidad que el modelo escribe: 80 palabras son unos 29 s de
#: voz, y con los respiros y algún plano mudo eso deja el vídeo cerca de 40 s.
#: El ejemplo de referencia habla 74. La sesión 167 habló 97 y salió a 47,5 s.
NARRATION_WORD_BUDGET = 80
#: Lo que mide una línea. No es preferencia de estilo: son las líneas del
#: ejemplo (11, 12, 10, 14, 12, 15 palabras). El tope anterior — "menos de 25" —
#: 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
#: 50 s las tres veces que se midió, y hacía falta una reescritura entera para
#: bajarlo.
MAX_SHOT_DURATION = 6.0
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"]
@@ -84,10 +115,18 @@ exists, and a prop name that is not listed is a parse error, not a nuance.
# 3. Rules # 3. Rules
- Total duration 20-45 seconds. The contract allows 180; that is a ceiling, \ - **Total duration {target_min:.0f}-{target_max:.0f} seconds, and the voice is \
not a target. Aim for {target_min:.0f}-{target_max:.0f}. what decides it, not the durations you declare.** The contract allows 180; that \
- Typically 6-9 shots. Give a shot the seconds its content needs to be read: \ is a ceiling, not a target. A narrated shot runs as long as its line takes to \
a card with four rows needs longer than a headline. say the renderer never cuts the voice off, it grows the shot so the whole \
video is really about {word_budget} words of narration and no more. That is the \
number to hold: **count the words of every `narration` you write, and stop at \
{word_budget}.** Section 3b has the arithmetic behind it.
- Typically 6-9 shots, and **none of them longer than {max_shot:.0f} seconds** \
that is the example's longest, and its average is 5.6. Give a shot the \
seconds its content needs to be read: a card with four rows needs longer than a \
headline. A {max_shot:.0f}-second shot with a short line on it is not a \
generous shot, it is a shot the viewer has already finished reading.
- Every string is drawn as given. Write them the way they should appear: \ - Every string is drawn as given. Write them the way they should appear: \
SHORT, UPPERCASE, no trailing punctuation. A headline is 2-5 words. SHORT, UPPERCASE, no trailing punctuation. A headline is 2-5 words.
- **"CABE ~N caracteres dibujados" is a width, and it is the one limit nothing \ - **"CABE ~N caracteres dibujados" is a width, and it is the one limit nothing \
@@ -116,9 +155,12 @@ aloud by the renderer and burned in as captions. Write it for the ear.
- **The first line is the whole hook.** Two seconds decide whether anyone \ - **The first line is the whole hook.** Two seconds decide whether anyone \
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 under 25 words. Long sentences lose the listener and stretch the \ - **Keep a line to {words_per_line} words, hard stop at {words_per_line_max}.** \
shot; the renderer will not cut your voice off, it will make the shot longer \ That is the example's own average, and it is not a style preference: a 25-word \
instead, and a Short that drifts past 45 seconds is a Short people leave. line is three seconds of your whole budget spent on one shot. Long sentences lose the listener and \
stretch the shot; the renderer will not cut your voice off, it will make 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.
@@ -132,10 +174,22 @@ silent exactly the two that draw a quotation, where the voice would only be \
competing with words already on the frame. Chosen silence is an edit; a spec \ 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.
- Narration costs seconds. A shot is never cut short to fit the voice it \ - **Give every narrated shot enough time for its own line, and work it out \
grows instead so a line that needs six seconds in a four-second shot pushes \ rather than guessing.** The voice reads about {words_per_second:.1f} words a \
your whole total past the target. Write the line, then give the shot the time \ second and pauses a quarter second at every full stop, so:
the line actually takes.
duration words ÷ {words_per_second:.1f} + half a second
A twelve-word line needs five seconds; give that shot 5.0, not 4.0. 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 \
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 \
declare IS the video's length; break it once and nothing you counted means \
anything.
- As a cross-check, all the narration in the spec together should come to about \
{word_budget} words. The example below speaks 74. A spec that spoke 97 rendered \
at 47.5 seconds and had to be cut.
- Everything in section 4 applies to narration word for word. It is prose you \ - Everything in section 4 applies to narration word for word. It is prose you \
compose rather than a label you copy, which makes it the easiest place to \ compose rather than a label you copy, which makes it the easiest place to \
slip in a figure no source gave you and it is checked exactly like the rest. slip in a figure no source gave you and it is checked exactly like the rest.
@@ -359,6 +413,25 @@ def _format_notes(notes: list[str]) -> str:
"Return the adjusted JSON object.") "Return the adjusted JSON object.")
def _off_target(spec: dict) -> float:
"""Segundos fuera de la ventana editorial. 0 = dentro."""
total = estimated_duration(spec)
return max(0.0, TARGET_MIN_DURATION - total, total - TARGET_MAX_DURATION)
def _closer_to_target(a: Optional[SpecResult], b: SpecResult) -> SpecResult:
"""De dos specs válidos, el que menos se sale del objetivo.
Antes se guardaba el PRIMERO válido y punto, con lo que una reescritura que
obedecía la nota a medias 53 s en vez de 58 se tiraba entera y salía el
largo. El empate se lo lleva el anterior: sin razón para cambiar, no se
cambia.
"""
if a is None:
return b
return a if _off_target(a.spec) <= _off_target(b.spec) else b
#: (system, prompt) -> texto del modelo. #: (system, prompt) -> texto del modelo.
LLMCall = Callable[[str, str], Awaitable[str]] LLMCall = Callable[[str, str], Awaitable[str]]
@@ -388,6 +461,11 @@ class ShortSpecWriter:
domain=domain, domain=domain,
target_min=TARGET_MIN_DURATION, target_min=TARGET_MIN_DURATION,
target_max=TARGET_MAX_DURATION, target_max=TARGET_MAX_DURATION,
words_per_second=NARRATION_WORDS_PER_SECOND,
word_budget=NARRATION_WORD_BUDGET,
words_per_line=NARRATION_WORDS_PER_LINE,
words_per_line_max=NARRATION_WORDS_PER_LINE_MAX,
max_shot=MAX_SHOT_DURATION,
example=_load_example(), example=_load_example(),
article=article, article=article,
context=context, context=context,
@@ -404,6 +482,8 @@ class ShortSpecWriter:
#: Un spec que cumple el contrato pero se pasa de duración. Se guarda #: Un spec que cumple el contrato pero se pasa de duración. Se guarda
#: para que un intento posterior peor no lo tire: es renderizable. #: para que un intento posterior peor no lo tire: es renderizable.
best: Optional[SpecResult] = None best: Optional[SpecResult] = None
#: Reescrituras ya gastadas en notas editoriales.
note_rounds = 0
for attempt in range(1, MAX_ATTEMPTS + 1): for attempt in range(1, MAX_ATTEMPTS + 1):
if on_progress and attempt > 1: if on_progress and attempt > 1:
@@ -440,23 +520,33 @@ class ShortSpecWriter:
notes = editorial_notes(spec) notes = editorial_notes(spec)
result = SpecResult(spec=spec, attempts=attempt, notes=notes, result = SpecResult(spec=spec, attempts=attempt, notes=notes,
history=list(history)) history=list(history))
if notes and attempt < MAX_ATTEMPTS: if not notes:
# Nota editorial, no violación del contrato: se comenta una vez logger.info("short spec válido", attempts=attempt,
# y, si insiste, se renderiza igual. shots=len(spec.get("shots", [])), notes=0)
best = best or result return result
best = _closer_to_target(best, result)
if note_rounds < NOTE_ATTEMPTS and attempt < MAX_ATTEMPTS:
# Nota editorial, no violación del contrato: se comenta y, si
# insiste, se renderiza el intento que menos se pase.
note_rounds += 1
history.append(notes) history.append(notes)
feedback = _format_notes(notes) feedback = _format_notes(notes)
continue continue
logger.info("short spec válido", attempts=attempt, logger.info("short spec válido pero fuera de objetivo",
shots=len(spec.get("shots", [])), notes=len(notes)) attempts=attempt, shots=len(best.spec.get("shots", [])),
return result off_target=round(_off_target(best.spec), 1))
best.attempts = attempt
best.history = history
return best
if best is not None: if best is not None:
# Un intento anterior sí cumplía el contrato. Vale más un Short # Un intento anterior sí cumplía el contrato. Vale más un Short
# largo que ningún Short. # largo que ningún Short.
logger.info("short spec: se recupera el intento válido anterior", logger.info("short spec: se recupera el intento válido anterior",
attempts=MAX_ATTEMPTS, notes=best.notes) attempts=MAX_ATTEMPTS, notes=best.notes)
best.attempts = MAX_ATTEMPTS
best.history = history best.history = history
return best return best
+88 -11
View File
@@ -28,6 +28,7 @@ __all__ = [
"validate_spec", "validate_spec",
"editorial_notes", "editorial_notes",
"estimated_duration", "estimated_duration",
"spoken_seconds",
"describe_templates", "describe_templates",
"TARGET_MIN_DURATION", "TARGET_MIN_DURATION",
"TARGET_MAX_DURATION", "TARGET_MAX_DURATION",
@@ -47,6 +48,15 @@ META_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
TARGET_MIN_DURATION = 20.0 TARGET_MIN_DURATION = 20.0
TARGET_MAX_DURATION = 45.0 TARGET_MAX_DURATION = 45.0
#: Lo que se le perdona al objetivo antes de gastar una reescritura. La
#: estimación de la voz acierta dentro de un segundo por línea, así que un
#: exceso de medio segundo puede ser del estimador y no del spec — y una
#: reescritura cuesta cuatro céntimos y un minuto para ahorrar un segundo que
#: nadie ve. El objetivo sigue siendo 20-45: esto sólo decide cuándo vale la
#: pena decirlo. Sin este margen, la sesión 168 (45,8 s estimados) se llevaba
#: una generación entera por ochocientas milésimas.
TARGET_GRACE = 1.5
class SpecInvalid(Exception): class SpecInvalid(Exception):
"""El spec no cumple el contrato. `errors` son rutas + motivo, verbatim.""" """El spec no cumple el contrato. `errors` son rutas + motivo, verbatim."""
@@ -321,12 +331,47 @@ def validate_spec(spec: Any, templates: dict[str, dict],
raise SpecInvalid(errors) raise SpecInvalid(errors)
#: Caracteres por segundo de la voz (Piper `en_US-lessac-medium` a length_scale #: Caracteres por segundo de la voz, sin contar las pausas. Medido el
#: 1.0). Medido el 2026-08-06: 82 caracteres en 5.78 s. Sirve para ESTIMAR aquí #: 2026-08-12 sintetizando de verdad las 28 líneas de narración que el bot ha
#: lo que shortsmith sabrá exacto al sintetizar. #: escrito hasta hoy con el mismo Piper y las mismas banderas que usa shortsmith
NARRATION_CHARS_PER_SECOND = 14.2 #: (`en_US-lessac-medium`, length_scale 1.0, --noise_scale 0 --noise_w 0):
#: 2429 caracteres en 140,91 s de audio.
NARRATION_CHARS_PER_SECOND = 18.5
#: Piper añade este silencio DESPUÉS DE CADA FRASE, no sólo al final de la
#: línea, y es un valor que shortsmith fija a propósito (`voice.SENTENCE_SILENCE`).
#: Contarlo por separado es lo que arregla el caso raro: "Witness identities.
#: Sensor details. Locations redacted." son tres frases cortas que valen 0,75 s
#: de pausa, y un modelo de caracteres a secas las da por rápidas.
NARRATION_SENTENCE_SILENCE = 0.25
#: El respiro que shortsmith deja tras cada línea antes de permitir el corte. #: El respiro que shortsmith deja tras cada línea antes de permitir el corte.
NARRATION_PAD = 0.45 NARRATION_PAD = 0.45
#: Palabras por segundo de la misma medida (387 palabras en 140,91 s). Sólo se
#: usa para traducir un exceso de segundos a palabras en el aviso: al modelo se
#: le pide que recorte texto, no tiempo.
NARRATION_WORDS_PER_SECOND = 2.75
#: 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.
_SENTENCE_END = re.compile(r"[.!?](?=\s|$)")
def spoken_seconds(line: str) -> float:
"""Lo que tarda la voz en decir una línea, sin el respiro final.
Dos términos porque la voz tiene dos: lee a ritmo casi constante y se calla
un cuarto de segundo en cada punto. La versión anterior sólo tenía el
primero y con un ritmo medido sobre una única frase 14,2 car/s , así que
sobreestimaba cada línea alrededor de un 20 %. Sobre un Short entero eso son
de cuatro a seis segundos de duración que no existen, suficientes para que
el bucle de reescritura se disparara con vídeos que estaban dentro del
objetivo.
"""
line = " ".join(line.split())
if not line:
return 0.0
sentences = max(1, len(_SENTENCE_END.findall(line)))
return (len(line) / NARRATION_CHARS_PER_SECOND
+ sentences * NARRATION_SENTENCE_SILENCE)
def estimated_duration(spec: dict) -> float: def estimated_duration(spec: dict) -> float:
@@ -336,6 +381,9 @@ def estimated_duration(spec: dict) -> float:
si la frase no cabe. Sin esta estimación el modelo escribiría 40 s de shots, si la frase no cabe. Sin esta estimación el modelo escribiría 40 s de shots,
les colgaría narración a todos y recibiría un Short de 55 s sin que nada le les colgaría narración a todos y recibiría un Short de 55 s sin que nada le
hubiera avisado el aviso llegaría del render, cuando ya está pagado. hubiera avisado el aviso llegaría del render, cuando ya está pagado.
Contrastada contra los tres MP4 que hay renderizados (sesiones 166, 167 y
168): 39,42 / 47,19 / 45,81 s estimados contra 39,57 / 47,53 / 45,40 reales.
""" """
total = 0.0 total = 0.0
for shot in spec.get("shots") or []: for shot in spec.get("shots") or []:
@@ -345,8 +393,7 @@ def estimated_duration(spec: dict) -> float:
declared = float(declared) if isinstance(declared, (int, float)) else 0.0 declared = float(declared) if isinstance(declared, (int, float)) else 0.0
narration = shot.get("narration") narration = shot.get("narration")
if isinstance(narration, str) and narration.strip(): if isinstance(narration, str) and narration.strip():
spoken = len(narration.strip()) / NARRATION_CHARS_PER_SECOND + NARRATION_PAD declared = max(declared, spoken_seconds(narration) + NARRATION_PAD)
declared = max(declared, spoken)
total += declared total += declared
return total return total
@@ -366,18 +413,48 @@ def editorial_notes(spec: dict) -> list[str]:
f"({declared:.1f}s de shots)" if stretched f"({declared:.1f}s de shots)" if stretched
else f"la duración total son {total:.1f}s") else f"la duración total son {total:.1f}s")
if total < TARGET_MIN_DURATION: if total < TARGET_MIN_DURATION - TARGET_GRACE:
notes.append(f"{how} y el objetivo es " notes.append(f"{how} y el objetivo es "
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: " f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
"queda corto, añade un shot o alarga los que tienes") "queda corto, añade un shot o alarga los que tienes")
elif total > TARGET_MAX_DURATION: elif total > TARGET_MAX_DURATION + TARGET_GRACE:
fix = ("recorta narración: la voz manda sobre la duración declarada"
if stretched else "recorta shots o acorta duraciones")
notes.append(f"{how} y el objetivo es " notes.append(f"{how} y el objetivo es "
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: {fix}") f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
+ _how_to_trim(spec, total - TARGET_MAX_DURATION, stretched))
return notes return notes
def _how_to_trim(spec: dict, excess: float, stretched: bool) -> str:
"""El consejo, en la unidad en la que el modelo puede obedecerlo.
"Recorta narración" no dice cuánta, y las tres veces que se ha disparado
esto el modelo devolvió un spec que seguía pasándose. Un exceso en segundos
tampoco le sirve, porque no escribe segundos: escribe frases. Así que el
aviso va en palabras y señala DÓNDE están las más largas.
"""
if not stretched:
return (f"sobran {excess:.1f}s: recorta un shot o baja las duraciones "
"declaradas")
words = max(3, round(excess * NARRATION_WORDS_PER_SECOND))
advice = (f"sobran {excess:.1f}s, unas {words} palabras de narración — la voz "
"manda sobre la duración declarada, así que acortar los shots no "
"quita ni un segundo")
spoken = sorted(
((i, len((s.get("narration") or "").split()))
for i, s in enumerate(spec.get("shots") or []) if isinstance(s, dict)),
key=lambda pair: -pair[1])
spoken = [pair for pair in spoken if pair[1]]
if not spoken:
return advice
# Sólo las que de verdad son largas: señalar una línea de dos palabras al
# lado de una de veinte convierte el consejo en ruido.
named = [f"shots.{i} ({n} palabras)"
for i, n in spoken[:2] if n * 2 >= spoken[0][1]]
return advice + f"; {'las líneas más largas son' if len(named) > 1 else 'la línea más larga es'} {' y '.join(named)}"
# --- el contrato en prosa, para el prompt ----------------------------------- # --- el contrato en prosa, para el prompt -----------------------------------
def _describe_field(name: str, schema: dict, required: bool, defs: dict, def _describe_field(name: str, schema: dict, required: bool, defs: dict,
+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."""
+98 -6
View File
@@ -134,15 +134,57 @@ 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_CHARS_PER_SECOND from src.generator.spec_contract import NARRATION_WORDS_PER_SECOND, spoken_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"]):
narration = shot.get("narration", "") narration = shot.get("narration", "")
if not narration: if not narration:
continue continue
needs = len(narration) / NARRATION_CHARS_PER_SECOND # La cuenta que el prompt le pide al modelo, aplicada al ejemplo que le
assert shot["duration"] >= needs, f"shot {i} declara menos de lo que habla" # pone delante. Si no cuadran, la regla en prosa pierde.
rule = len(narration.split()) / NARRATION_WORDS_PER_SECOND + 0.5
assert shot["duration"] >= rule, f"shot {i} declara menos de lo que habla"
assert shot["duration"] >= spoken_seconds(narration), \
f"shot {i} se quedaría corto para su propia voz"
def test_the_prompt_gives_a_budget_the_model_can_count():
""""20-45 segundos" no es accionable: la duración real no está escrita en el
spec, sale de sumar el mayor entre lo declarado y lo que tarda la voz. El
modelo puede contar sus `duration` y sus palabras, así que el encargo se
le da en esas dos unidades."""
from src.generator.shortspec import NARRATION_WORD_BUDGET
from src.generator.spec_contract import NARRATION_WORDS_PER_SECOND
w, _ = writer("{}")
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
assert f"{NARRATION_WORDS_PER_SECOND:.1f} words a second" in prompt, \
"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"{NARRATION_WORD_BUDGET} words" in prompt
def test_the_worked_example_obeys_the_budget_it_preaches():
"""El ejemplo es la señal más fuerte del prompt — más que cualquier regla en
prosa. Uno que hablara de más enseñaría a hablar de más, dijera lo que
dijera la sección 3b."""
from src.generator.shortspec import (
MAX_SHOT_DURATION, NARRATION_WORDS_PER_LINE,
NARRATION_WORDS_PER_LINE_MAX, NARRATION_WORD_BUDGET,
)
example = json.loads(EXAMPLE.read_text())
lines = [len(s["narration"].split()) for s in example["shots"] if s.get("narration")]
assert max(s["duration"] for s in example["shots"]) == MAX_SHOT_DURATION
assert sum(lines) <= NARRATION_WORD_BUDGET
assert max(lines) <= NARRATION_WORDS_PER_LINE_MAX
# El tope corto se anuncia como "la media del ejemplo": si deja de serlo, la
# regla en prosa se convierte en un número inventado y el modelo la nota.
assert round(sum(lines) / len(lines)) == NARRATION_WORDS_PER_LINE
def test_prompt_says_out_loud_that_there_is_no_article_yet(): def test_prompt_says_out_loud_that_there_is_no_article_yet():
@@ -220,15 +262,20 @@ async def test_three_failures_raise_but_keep_the_last_attempt():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_an_off_target_duration_is_commented_once_then_accepted(): async def test_an_off_target_duration_is_commented_once_then_accepted():
"""70 s cumple el contrato pero no el encargo: se comenta y, si el modelo """70 s cumple el contrato pero no el encargo: se comenta UNA vez y, si el
insiste, se renderiza igual antes que tirar la generación.""" modelo insiste, se renderiza igual antes que tirar la generación.
Una y no dos. El tercer intento se reserva para el contrato, que es
binario: un spec largo se ve, uno malformado no se puede ni renderizar.
"""
long_spec = json.loads(json.dumps(GOOD)) long_spec = json.loads(json.dumps(GOOD))
long_spec["shots"][0]["duration"] = 55.0 # 70 s en total long_spec["shots"][0]["duration"] = 55.0 # 70 s en total
w, llm = writer(json.dumps(long_spec)) w, llm = writer(json.dumps(long_spec))
result = await w.write("Caso X", "material") result = await w.write("Caso X", "material")
assert result.attempts == MAX_ATTEMPTS assert result.attempts == 2, "una nota no vale dos reescrituras"
assert len(llm.prompts) == 2
assert result.notes and "recorta" in result.notes[0] assert result.notes and "recorta" in result.notes[0]
assert "off-brief" in llm.prompts[1] assert "off-brief" in llm.prompts[1]
@@ -245,6 +292,51 @@ async def test_a_valid_attempt_is_not_thrown_away_by_a_worse_one():
assert result.notes assert result.notes
@pytest.mark.asyncio
async def test_the_rewrite_is_kept_when_it_obeys_the_note_only_halfway():
"""Obedecer a medias es obedecer. Antes se guardaba el PRIMER intento válido
y se descartaba la reescritura entera, así que un spec que había bajado de
70 s a 50 s salía a 70."""
long_spec = json.loads(json.dumps(GOOD))
long_spec["shots"][0]["duration"] = 55.0 # 70 s
better = json.loads(json.dumps(GOOD))
better["shots"][0]["duration"] = 35.0 # 50 s: sigue pasándose, pero menos
w, _ = writer(json.dumps(long_spec), json.dumps(better))
result = await w.write("Caso X", "material")
assert result.spec["shots"][0]["duration"] == 35.0
assert result.attempts == 2
@pytest.mark.asyncio
async def test_a_rewrite_that_makes_it_worse_is_discarded():
long_spec = json.loads(json.dumps(GOOD))
long_spec["shots"][0]["duration"] = 55.0 # 70 s
worse = json.loads(json.dumps(GOOD))
worse["shots"][0]["duration"] = 90.0 # 105 s
w, _ = writer(json.dumps(long_spec), json.dumps(worse))
result = await w.write("Caso X", "material")
assert result.spec["shots"][0]["duration"] == 55.0
assert result.attempts == 2, "se pagaron dos generaciones aunque valga la primera"
@pytest.mark.asyncio
async def test_a_note_does_not_eat_the_attempt_the_contract_needs():
"""Si la reescritura sale malformada, aún queda un intento para arreglarla."""
long_spec = json.loads(json.dumps(GOOD))
long_spec["shots"][0]["duration"] = 55.0
w, llm = writer(json.dumps(long_spec), "esto no es JSON", json.dumps(GOOD))
result = await w.write("Caso X", "material")
assert result.attempts == 3 and result.notes == []
assert "off-brief" in llm.prompts[1]
assert "not a valid JSON" in llm.prompts[2] or "no es un objeto JSON" in llm.prompts[2]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_the_contract_is_refetched_after_a_validation_failure(): async def test_the_contract_is_refetched_after_a_validation_failure():
"""Si el renderizador se actualizó a mitad de la run, la plantilla nueva """Si el renderizador se actualizó a mitad de la run, la plantilla nueva
+107 -2
View File
@@ -283,15 +283,73 @@ def test_an_unknown_shot_key_still_names_the_valid_ones():
assert any("narration" in e for e in errors_of(doc)) assert any("narration" in e for e in errors_of(doc))
#: Líneas de narración de specs que se renderizaron de verdad, con lo que tarda
#: Piper en decirlas. Medido el 2026-08-12 con el binario, el modelo y las
#: banderas de shortsmith (`en_US-lessac-medium`, length_scale 1.0,
#: --noise_scale 0 --noise_w 0), que son deterministas: estos segundos se
#: reproducen. Se eligieron los extremos del muestreo de 28 líneas — la más
#: rápida, la más lenta y las dos más largas — porque son las que rompen un
#: modelo mal calibrado; la media la aguanta cualquiera.
MEASURED = [
("Eight FBI witness interviews. Five digital renderings. All describe the "
"same shape flying across America for twenty-four years.", 7.809),
("The files are public now, but sections remain blacked out. Witness "
"identities. Sensor details. Locations redacted.", 8.140),
("Three hundred seventy-eight files released. Hundreds of incidents "
"documented. And the government still cannot explain what those shapes "
"were.", 7.681),
("The files came out. The numbers stayed classified.", 3.310),
("Nothing should have been able to hold station beside them up there.", 3.396),
("The Air Force's own investigators called it unexplained.", 2.990),
]
@pytest.mark.parametrize("line,real", MEASURED)
def test_the_estimate_lands_within_a_second_of_the_voice(line, real):
"""La estimación es lo único que separa un aviso útil de una reescritura
inventada, así que se contrasta contra audio medido, no contra misma.
El margen es un segundo. Más apretado sería falso esto estima, no
sintetiza y más ancho deja de decir nada: el error del modelo anterior
sobre un Short entero era de cuatro a seis segundos, y de ahí salían los
tres intentos que se gastaban en cada generación.
"""
from src.generator.spec_contract import spoken_seconds
assert spoken_seconds(line) == pytest.approx(real, abs=1.0)
def test_a_line_of_short_sentences_is_not_taken_for_fast_prose():
"""Piper calla un cuarto de segundo en cada punto. Cuatro frases cortas son
un segundo de silencio, y contarlas como texto corrido las da por rápidas:
es el caso donde más se equivocaba el modelo de sólo caracteres."""
from src.generator.spec_contract import spoken_seconds
chopped = "The files are public now, but sections remain blacked out. " \
"Witness identities. Sensor details. Locations redacted."
flowing = "The files are public now but sections remain blacked out with " \
"witness identities sensor details and locations redacted"
assert len(chopped) < len(flowing)
assert spoken_seconds(chopped) > spoken_seconds(flowing)
def test_a_decimal_point_is_not_the_end_of_a_sentence():
from src.generator.spec_contract import spoken_seconds
assert spoken_seconds("It climbed to 1.5 miles") == \
pytest.approx(spoken_seconds("It climbed to 155 miles"))
def test_the_estimate_counts_the_voice_not_just_the_declared_seconds(): def test_the_estimate_counts_the_voice_not_just_the_declared_seconds():
"""La duración declarada es un suelo: shortsmith estira el shot si la frase """La duración declarada es un suelo: shortsmith estira el shot si la frase
no cabe, y el modelo tiene que enterarse ANTES de pagar el render.""" no cabe, y el modelo tiene que enterarse ANTES de pagar el render."""
from src.generator.spec_contract import estimated_duration from src.generator.spec_contract import estimated_duration
doc = spec_with(shot(duration=3.0)) doc = spec_with(shot(duration=3.0))
doc["shots"][0]["narration"] = "A" * 142 # ~10 s de voz doc["shots"][0]["narration"] = MEASURED[0][0] # 7,81 s de voz medidos
assert estimated_duration(doc) > 10.0 assert estimated_duration(doc) > 8.0
def test_a_shot_with_room_for_its_line_is_estimated_as_declared(): def test_a_shot_with_room_for_its_line_is_estimated_as_declared():
@@ -320,6 +378,53 @@ def test_narration_that_overshoots_the_target_is_flagged_as_narration():
assert "narración" in note and "estimada" in note assert "narración" in note and "estimada" in note
def test_a_second_over_the_target_is_not_worth_a_rewrite():
"""El objetivo sigue siendo 45 s, pero la estimación tiene un segundo de
error por línea: avisar por medio segundo es avisar del estimador. Caso
real la sesión 168 salió a 45,4 s y se pagó una generación por ello."""
from src.generator.spec_contract import TARGET_GRACE, TARGET_MAX_DURATION
justo = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE - 0.1))
pasado = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE + 0.1))
assert editorial_notes(justo) == []
assert editorial_notes(pasado)
# Y el consejo se mide contra el objetivo, no contra el margen: se pide
# bajar hasta 45, no hasta 46,5.
assert "sobran 1.6s" in editorial_notes(pasado)[0]
def test_the_grace_works_at_both_ends():
from src.generator.spec_contract import TARGET_GRACE, TARGET_MIN_DURATION
assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION
- TARGET_GRACE + 0.1))) == []
assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION
- TARGET_GRACE - 0.1)))
def test_the_advice_says_how_much_to_cut_and_from_where():
""""Recorta narración" no dice cuánta, y las tres veces que saltó este aviso
el modelo devolvió un spec que seguía pasándose. El exceso va en palabras
porque es lo que el modelo escribe, y señalando el plano que más habla."""
doc = spec_with(shot(duration=4.0), shot(duration=4.0))
doc["shots"][0]["narration"] = "Short line."
doc["shots"][1]["narration"] = " ".join(["word"] * 200)
note = editorial_notes(doc)[0]
assert "palabras de narración" in note
assert "shots.1" in note and "shots.0" not in note
def test_the_advice_for_a_silent_spec_never_mentions_narration():
"""Sin voz, pedir que recorte narración es mandarlo a arreglar algo que no
existe: lo que sobra son duraciones declaradas."""
note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]))[0]
assert "narración" not in note and "duraciones declaradas" in note
def test_a_spec_without_narration_keeps_the_old_wording(): def test_a_spec_without_narration_keeps_the_old_wording():
note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]))[0] note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]))[0]
assert "duración total" in note and "estimada" not in note assert "duración total" in note and "estimada" not in note
+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 == []