14 Commits
Author SHA1 Message Date
ChemaVXandClaude Opus 5 7c18ae68f1 fix(db): 90 días, porque 30 no era una política sino una suposición
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
30 días no medía cuánto hay que guardar: medía cuánto se creía que duraba el
material. Los 17 specs `short_en` del catálogo se escribieron entre el 2 y el
13 de agosto y seguían siendo el material de trabajo el 1 de septiembre —tres
semanas después de que su ventana empezara a correr—, y el 31 de agosto se
re-renderizaron tres de ellos. Con 90 días, lo que se purga es lo que de
verdad nadie va a volver a mirar.

El plazo se escribe UNA vez. Estaba en el arranque, en `/purge` y en la firma
por defecto: tres copias de un plazo son tres plazos esperando a divergir, y
ese es el mismo error de clase que tenía el tope de palabras por línea antes de
derivarse de la duración del plano.

El test nuevo fija la ventana por COMPORTAMIENTO y no por el número: un output
de 45 días sobrevive y uno de 200 no. Estrecharla por debajo de mes y medio lo
tumba, que es donde empieza a llevarse cosas que aún se usan.

Y el fixture de purga pasa a llamar al valor por defecto cuando no se le dice
otro, porque pasarle el plazo a mano en cada test dejaba el de producción sin
que ninguna prueba lo mirara.

Suite: 279 pasan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:30:01 +00:00
ChemaVXandClaude Opus 5 a14e5b99f9 fix(db): un spec no envejece con la investigación que lo originó
La retención iba por la edad de la SESIÓN y cascadeaba
`DELETE FROM outputs WHERE session_id = ?`, así que un output generado ayer
sobre una sesión de julio moría en el siguiente arranque del bot.

Pasó hoy, 2026-09-01, al desplegar 198b0e62:

    Startup purge done  sessions=12 outputs=27 chunks=2030 sources=3466
                        api_usage=1118 shorts=6

El pod llevaba 18 días sin reiniciarse, así que tres semanas de material
cumplieron los 30 días de golpe. Y lo que se llevó por delante no fue lo viejo:
los outputs 132-139 tenían 18,8 días —los tres Shorts re-renderizados el día
antes entre ellos— pero colgaban de las sesiones 161-165, de hace 40. Murieron
por la edad de su madre mientras 128-131, más antiguos, sobrevivían.

Ahora la purga va en dos fases:

1. Outputs por SU propia fecha, vivan en la sesión que vivan.
2. Sesiones viejas que ya no sostienen ningún output.

El orden importa: la sesión cuyos outputs eran todos viejos se queda sin
ninguno en la fase 1 y resulta purgable en la fase 2, así que el caso normal
—sesión vieja, material viejo— sigue limpiándose entero en UNA pasada. Hay un
test que lo fija, y es el único de los cuatro nuevos que pasa también con la
lógica anterior: está para probar que no se rompió lo que funcionaba.

Una sesión con un output vivo sobrevive entera, con sus sources y sus chunks.
No es generosidad: los chunks son contra lo que se comprueba el fundamento de
ese output, y conservar el spec tirando aquello con lo que se verifica deja
algo que ya no se puede auditar. El precio es que la retención afloja, y se
paga a sabiendas.

Dos cosas más que salieron al mirarlo:

- `_purge_on_startup` sólo escribía en el log `if result["sessions"] > 0`. Con
  la retención por output, una pasada puede borrar 27 outputs y CERO sesiones,
  y eso no habría dejado ni una línea. Una purga silenciosa es como se
  descubre tres semanas tarde. Ahora informa si borró cualquier cosa.
- El MP4 se llama por sesión, así que cuando la fase 1 se lleva el último
  short_en de una sesión que sigue viva, el fichero queda sin nada que lo
  nombre. Se borra ahí también, o el PVC acumula vídeos que no aparecen en
  ninguna fila.

`purge_old_sessions` pasa a llamarse `purge_old_data`: ya no purga sólo por
sesiones y el nombre viejo describía justo el defecto.

La BD anterior a la purga está a salvo y verificada en
~/rescates/researchowl-purga-2026-09-01 (integrity_check ok, 31 outputs, los
17 short_en). Con esta lógica, un restore conserva 16 de los 17.

Suite: 278 pasan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:27:21 +00:00
ChemaVXandClaude Opus 5 198b0e6238 fix(short): los textos ilegibles se avisaban con el render ya pagado
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m18s
La nota que teníamos era falsa: los avisos `severe` de shortsmith SÍ llegan a
un humano — el informe de Telegram los saca en rojo. Lo que pasa es que llegan
colgados del render TERMINADO, así que hacerles caso significa editar el spec a
mano y pagar un segundo. Por eso nadie actuó nunca.

`x-fits` tampoco podía ser la comprobación, y a propósito: es guía blanda que el
propio ejemplo de referencia se salta por uno o tres caracteres viéndose bien.
Parar ahí sería gritar con specs buenos, y un aviso que grita se ignora — que es
justo cómo sobrevivieron los graves. Así que shortsmith publica desde 289d50e un
segundo número medido por campo, `x-fits-hard`, y esto lo comprueba antes de
gastar el render: cuesta un reintento del modelo en vez de un render. El mismo
movimiento que hizo `MAX_CUE_CHARS` con los captions en e32c59f.

Auditado contra los 17 short_en de producción: OCHO llevan al menos un texto que
se dibuja ilegible.

- El ya conocido, Cash-Landrum: `ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH
  RADIATION EXPOSURE`, 36 px pedidos y 20 dibujados. Salta con 63 caracteres
  contra un presupuesto de 61 — así de vertical es la curva pegada al muro.
- El que nadie había visto es peor y más común: el CARTEL DE CIERRE.
  `counter_close.lines` pide 110 px y se dibujó a 28 en el peor caso, y por
  debajo de 64 en cinco de los ocho. Es la llamada a la acción, y en un tercio
  del catálogo es el texto más pequeño del fotograma.
- Y la tolerancia aguanta sobre datos reales: `STILL UNEXPLAINED` encoge de 110
  a 84 px y no se avisa, que es lo correcto.

El desempate entre intentos pasa a contar averías —gancho y texto ilegible—
antes que segundos, por la misma razón que en 6d9b602: una reescritura que
arregla un rótulo pero se pasa un segundo perdía contra la que no lo arreglaba.

Un campo sin presupuesto propio (`Bar.unit`, que se dibuja dentro de la cadena
de `value_label`) se salta a conciencia: reconstruir esa cadena aquí pediría
conocer el formato de la plantilla, que es lo que este repo no sabe ni debe.

Y contra un shortsmith anterior a 289d50e esto se calla — no puede inventarse el
número —, así que de que el contrato traiga el campo se encarga
test_shortsmith_live.py, que es quien habla con el servicio.

Suite: 274 pasan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:31:05 +00:00
ChemaVXandClaude Opus 5 6d9b6025ba fix(short): el primer plano gastaba el gancho en la fecha
El gancho de un Short es lo que se DIBUJA, no solo lo que se dice. El prompt
llevaba la mitad hablada desde 4b («the first line is the whole hook») y no
tenía nada sobre la mitad vista. De los diecisiete short_en generados, once
abren mal: diez ponen la fecha en el titular —el texto más grande del vídeo—
mientras el subline de debajo ya lleva el sitio, y el output 131 abre con
document_quote.

Medido sobre el renderizador a 5,5 s de plano: las cinco plantillas con
`headline` lo ponen a tinta plena en 0,33-0,40 s, y shortsmith lo garantiza a
cualquier duración desde db1ac7e. Las tres que no lo tienen dejan la banda
superior del fotograma al nivel del fondo TODO el plano —su texto se escribe a
máquina más abajo y no está entero hasta 2,6-2,8 s—. Con 6,9 s de visionado
medio, abrir con una de esas regala el tercio de la ventana en el que se
decide todo.

La costura es `headline`, preguntada al esquema y no a una lista de nombres:
una plantilla nueva de shortsmith con titular podrá abrir sin tocar este repo,
y una sin él no podrá. El mismo pacto que el resto del contrato.

Y `_closer_to_target` ordenaba SOLO por segundos, lo que hacía decorativo el
aviso nuevo: una reescritura que arreglaba el titular pero se pasaba un
segundo perdía contra el intento que abría con una fecha. Ahora el gancho va
delante — los segundos son un gradiente, el gancho es binario.

Comprobado contra los 17 specs reales de producción: avisa de once y de
ninguno más. Los que pasan son los titulares que se quieren (62 CHILDREN,
23 HELICOPTERS, RELEASE 05).

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

Pero la unión se derrota poniéndole a cada línea su propio par de comillas.
Entonces son DOS citas, cada una fundamentada por su lado, y pasa en silencio
mientras el fotograma dibuja la frase de nadie. Medido sobre los cinco Shorts
generados hoy: le pasaba a dos.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:47:07 +00:00
ChemaVXandClaude Opus 5 818533c86f docs(short): dónde corre una comprobación decide lo que cuesta
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:39:28 +00:00
ChemaVXandClaude Opus 5 6f960c303d fix(short): las reglas que cruzan campos costaban una generación entera
Build & Deploy ResearchOwl / build-and-push (push) Successful in 12s
El 422 de shortsmith llega al RENDERIZAR, cuando el bucle de reintentos ya ha
terminado: un spec que las viola no se reescribe, se devuelve a mano. La sesión
162 (Trans-en-Provence) se perdió así — spec válido a la primera, 39 claims
fundamentados, y sin vídeo porque tenía 3 barras y una cita.

Se replican las tres a mano, como ya se hacía con el sobre, porque pydantic no
las serializa en el JSON Schema publicado:

- scale_bars: 3 barras no dejan sitio para una cita
- track_map: una ventana fijada tiene que contener sus waypoints
- track_map: bounds con max <= min

El texto del error es el de shortsmith palabra por palabra: se le devuelve
verbatim al modelo, y dos redacciones del mismo fallo según dónde se cace es
justo lo que hace inútil un mensaje de error.

Verificado contra el spec real de la 162, recuperado de la BD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:32:10 +00:00
ChemaVXandClaude Opus 5 4099e3eecb docs(short): el ejemplo que rompe una regla enseña a romperla
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m18s
Segunda vez que aparece la misma lección sobre el ejemplo del prompt.

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

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

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

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

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

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

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

Dos decisiones que van con esto:

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:09:39 +00:00
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
14 changed files with 1917 additions and 106 deletions
+111
View File
@@ -86,6 +86,64 @@ 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.
- **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:
@@ -198,6 +256,59 @@ Available any time, zero researchowl changes, because the contract is fetched li
hook → evidence → unresolved question → CTA arc. Costs one commit, no deploy risk hook → evidence → unresolved question → CTA arc. Costs one commit, no deploy risk
beyond a prompt change. beyond a prompt change.
### The visual hook — **done 2026-09-01**
The narration hook was already in the prompt (§3b, "the first line is the whole hook").
What nobody had written down is that **the hook is also what is drawn**, and the specs
show it: of the seventeen `short_en` generated, eleven open badly. Ten spend the
headline — the largest text in the video — on a date, while the `subline` right below
already carries the place; one (output 131) opens with `document_quote`.
Measured on the renderer at a 5.5 s shot: the five templates with a `headline` prop put
it at full ink in **0.330.40 s**, and shortsmith guarantees that at any shot length
since `db1ac7e`. The three without one leave the top band of the frame at background
level for the *whole shot* — their content types in lower down and is not complete until
**2.62.8 s**. With an average view of 6.9 s, opening with one of those spends a third
of the window on a frame that has not said anything.
So the seam is `headline`, asked of the schema rather than of a hardcoded list: a new
shortsmith template with a headline may open a video, one without may not, and neither
case needs a change here. Prompt §3c states both halves; `opening_notes()` in
`spec_contract.py` enforces them as editorial notes, and `_closer_to_target` now ranks
the hook above the duration — without that, a rewrite that fixed the headline but ran a
second long would lose to the attempt that opened with a date, and the note would be
decoration.
Still open on the generator side: `document_quote` is also weak as the *second* shot for
the same reason.
### Text drawn unreadable — **done 2026-09-01**
The `severe: true` auto-fit warnings were never the problem we had written down. They
*do* reach a human: the Telegram report prints them in red with "quedaron ILEGIBLES".
They just arrive attached to the **finished render**, so acting on one means editing the
spec by hand and paying for a second one — which is why nobody ever did.
`x-fits` could not be the check either, and deliberately so: it is soft guidance the
reference example itself exceeds by a character or three while looking right. A check
there would fire on good specs and get ignored, which is how the genuinely bad ones
survived. So shortsmith now publishes a second measured number per field (`289d50e`):
`x-fits-hard`, the length past which auto-fit's shrink turns severe. `unreadable_notes()`
checks it before the render, at the cost of one model retry instead of one render —
the same move `MAX_CUE_CHARS` made for captions in `e32c59f`.
Audited against the seventeen `short_en` in production: **eight carry at least one text
that is drawn unreadable.** The one already known — Cash-Landrum's
`ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH RADIATION EXPOSURE`, 36 px requested and
20 px drawn — is flagged at 63 characters against a budget of 61, which is how steep the
curve is near the wall. The one nobody had noticed is worse and more common: the
**closing card**, `counter_close.lines`, asks for 110 px and was drawn at **28** in the
worst case and under 64 in five of the eight. That is the call to action, and in a third
of the catalogue it is the smallest type on the frame.
The tolerance holds on real data too: `STILL UNEXPLAINED` is shrunk 110 → 84 px and is
correctly left alone.
--- ---
## Implementation order ## Implementation order
+28 -7
View File
@@ -21,7 +21,9 @@ from telegram.ext import (
from telegram.constants import ParseMode from telegram.constants import ParseMode
from src.config import settings from src.config import settings
from src.db.database import get_db, close_db, ResearchDB, ResearchStatus, OutputType from src.db.database import (
get_db, close_db, ResearchDB, ResearchStatus, OutputType, RETENTION_DAYS,
)
from src.scraper.exhaustive import ExhaustiveScraper from src.scraper.exhaustive import ExhaustiveScraper
from src.processor.processor import OllamaClient, ContentProcessor from src.processor.processor import OllamaClient, ContentProcessor
from src.generator.generator import OutputGenerator from src.generator.generator import OutputGenerator
@@ -854,7 +856,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 +871,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.")
@@ -1332,8 +1348,12 @@ async def _purge_on_startup(app: Application) -> None:
db_conn = await get_db() db_conn = await get_db()
try: try:
db = ResearchDB(db_conn) db = ResearchDB(db_conn)
result = await db.purge_old_sessions(30) result = await db.purge_old_data(RETENTION_DAYS)
if result["sessions"] > 0: # Cualquier borrado, no sólo el de sesiones. Con la retención por fecha
# de output, una pasada puede llevarse 27 outputs y CERO sesiones — y
# con la condición anterior eso no dejaba ni una línea de log. Una purga
# silenciosa es como se descubre tres semanas tarde.
if any(result.values()):
logger.info("Startup purge done", **result) logger.info("Startup purge done", **result)
except Exception as e: except Exception as e:
logger.warning("Startup purge failed — bot continues", error=str(e)) logger.warning("Startup purge failed — bot continues", error=str(e))
@@ -1572,7 +1592,7 @@ async def cmd_purge(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
args = ctx.args or [] args = ctx.args or []
if not args: if not args:
days = 30 days = RETENTION_DAYS
else: else:
try: try:
days = int(args[0]) days = int(args[0])
@@ -1586,7 +1606,8 @@ async def cmd_purge(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
return return
if days == 0 and not (len(args) >= 2 and args[1] == "confirm"): if days == 0 and not (len(args) >= 2 and args[1] == "confirm"):
await update.message.reply_text( await update.message.reply_text(
"⚠️ Esto borrará *todas* las sesiones completadas.\n" "⚠️ Esto borrará *todos* los outputs y *todas* las sesiones "
"completadas.\n"
"Envía `/purge 0 confirm` para confirmar.", "Envía `/purge 0 confirm` para confirmar.",
parse_mode=ParseMode.MARKDOWN parse_mode=ParseMode.MARKDOWN
) )
@@ -1595,7 +1616,7 @@ async def cmd_purge(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
db_conn = await get_db() db_conn = await get_db()
try: try:
db = ResearchDB(db_conn) db = ResearchDB(db_conn)
result = await db.purge_old_sessions(days) result = await db.purge_old_data(days)
await update.message.reply_text( await update.message.reply_text(
f"🗑️ Purged: {result['sessions']} sessions, " f"🗑️ Purged: {result['sessions']} sessions, "
f"{result['sources']} sources, " f"{result['sources']} sources, "
+84 -22
View File
@@ -12,6 +12,18 @@ from src.config import settings
logger = structlog.get_logger() logger = structlog.get_logger()
#: Cuánto se guarda. 90 días, no 30, y el motivo es que 30 no era una política
#: de retención: era una suposición sobre cuánto duraba el material. Los 17
#: specs `short_en` del catálogo se escribieron entre el 2 y el 13 de agosto y
#: seguían siendo el material de trabajo el 1 de septiembre — tres semanas
#: después de que su ventana de 30 días empezara a correr. Con 90, lo que se
#: purga es lo que de verdad nadie va a volver a mirar.
#:
#: Escrito una sola vez a propósito: estaba en el arranque, en `/purge` y en la
#: firma por defecto, y tres copias de un plazo son tres plazos esperando a
#: divergir.
RETENTION_DAYS = 90
class ResearchStatus(str, Enum): class ResearchStatus(str, Enum):
RUNNING = "running" RUNNING = "running"
@@ -648,41 +660,75 @@ class ResearchDB:
# --- Maintenance --- # --- Maintenance ---
async def purge_old_sessions(self, max_age_days: int = 30) -> dict: async def purge_old_data(self, max_age_days: int = RETENTION_DAYS) -> dict:
"""Retención en dos fases: primero los outputs por SU fecha, luego las
sesiones que ya no sostienen nada.
Antes iba todo por la edad de la sesión, y la cascada
`DELETE FROM outputs WHERE session_id = ?` se llevaba por delante lo
generado ayer si colgaba de una sesión de hace dos meses. El
2026-09-01 eso borró 27 outputs, entre ellos los tres Shorts
re-renderizados el día antes: sobrevivieron los outputs 128-131 y
murieron los 132-139, que eran **más nuevos**. Un spec no envejece con
la investigación que lo originó.
Las dos fases van en este orden por una razón: la sesión cuyos outputs
eran todos viejos se queda sin ninguno en la fase 1 y resulta purgable
en la fase 2, así que el caso normal —sesión vieja, material viejo—
sigue limpiándose igual que antes en una sola pasada.
**Y una sesión con un output vivo sobrevive entera**, con sus sources y
sus chunks. No es generosidad: los `chunks` son contra lo que se
comprueba el fundamento de ese output, así que conservar el spec y tirar
aquello con lo que se verifica deja algo que ya no se puede auditar. El
precio es que la retención afloja — una sesión de julio con un Short de
ayer mantiene vivos sus cientos de sources — y ese precio se paga a
sabiendas.
"""
await self.db.execute("PRAGMA foreign_keys = ON") await self.db.execute("PRAGMA foreign_keys = ON")
threshold = time.time() - max_age_days * 86400 threshold = time.time() - max_age_days * 86400
counts = {"sessions": 0, "sources": 0, "chunks": 0, "outputs": 0,
"api_usage": 0, "shorts": 0}
# --- fase 1: outputs por su propia fecha, vivan donde vivan ---------
# Se apuntan las sesiones tocadas antes de borrar: si una se queda sin
# ningún short_en, su MP4 no lo referencia ya nadie.
cursor = await self.db.execute( cursor = await self.db.execute(
"SELECT id FROM research_sessions WHERE created_at < ? AND status != 'running'", "SELECT DISTINCT session_id FROM outputs WHERE created_at < ?",
(threshold,)
)
touched = [row[0] for row in await cursor.fetchall()]
cur = await self.db.execute("DELETE FROM outputs WHERE created_at < ?",
(threshold,))
counts["outputs"] += cur.rowcount
for sid in touched:
cursor = await self.db.execute(
"SELECT 1 FROM outputs WHERE session_id = ? AND output_type = ?"
" LIMIT 1", (sid, "short_en")
)
if await cursor.fetchone() is None:
counts["shorts"] += self._drop_short(sid)
# --- fase 2: sesiones viejas que ya no sostienen ningún output ------
cursor = await self.db.execute(
"SELECT id FROM research_sessions WHERE created_at < ?"
" AND status != 'running'"
" AND NOT EXISTS (SELECT 1 FROM outputs WHERE session_id ="
" research_sessions.id)",
(threshold,) (threshold,)
) )
session_ids = [row[0] for row in await cursor.fetchall()] session_ids = [row[0] for row in await cursor.fetchall()]
counts = {"sessions": 0, "sources": 0, "chunks": 0, "outputs": 0,
"api_usage": 0, "shorts": 0}
for sid in session_ids: for sid in session_ids:
# El MP4 del Short vive en disco (los blobs en SQLite hacen counts["shorts"] += self._drop_short(sid)
# patológico el WAL), así que su borrado no lo arrastra ninguna FK:
# se hace aquí, que es el único sitio que sabe qué sesiones
# desaparecen. Best-effort — un fichero que no se puede borrar no
# va a impedir purgar la sesión.
try:
video = Path(settings.shorts_dir) / f"{sid}.mp4"
if video.is_file():
video.unlink()
counts["shorts"] += 1
except OSError as e:
logger.warning("No se pudo borrar el Short de una sesión purgada",
session_id=sid, error=str(e))
await self.db.execute( await self.db.execute(
"DELETE FROM source_contents WHERE source_id IN (SELECT id FROM sources WHERE session_id = ?)", "DELETE FROM source_contents WHERE source_id IN (SELECT id FROM sources WHERE session_id = ?)",
(sid,) (sid,)
) )
cur = await self.db.execute("DELETE FROM chunks WHERE session_id = ?", (sid,)) cur = await self.db.execute("DELETE FROM chunks WHERE session_id = ?", (sid,))
counts["chunks"] += cur.rowcount counts["chunks"] += cur.rowcount
cur = await self.db.execute("DELETE FROM outputs WHERE session_id = ?", (sid,))
counts["outputs"] += cur.rowcount
cur = await self.db.execute("DELETE FROM api_usage WHERE session_id = ?", (sid,)) cur = await self.db.execute("DELETE FROM api_usage WHERE session_id = ?", (sid,))
counts["api_usage"] += cur.rowcount counts["api_usage"] += cur.rowcount
cur = await self.db.execute("DELETE FROM sources WHERE session_id = ?", (sid,)) cur = await self.db.execute("DELETE FROM sources WHERE session_id = ?", (sid,))
@@ -691,6 +737,22 @@ class ResearchDB:
counts["sessions"] += cur.rowcount counts["sessions"] += cur.rowcount
await self.db.commit() await self.db.commit()
logger.info("Purged sessions older than days", logger.info("Purga por antigüedad", days=max_age_days, **counts)
sessions=counts["sessions"], days=max_age_days)
return counts return counts
def _drop_short(self, sid: int) -> int:
"""Borra el MP4 de una sesión, si queda. Devuelve 1 si borró algo.
El vídeo vive en disco (los blobs en SQLite hacen patológico el WAL),
así que su borrado no lo arrastra ninguna FK y hay que hacerlo aquí.
Best-effort: un fichero que no se puede borrar no va a impedir la purga.
"""
try:
video = Path(settings.shorts_dir) / f"{sid}.mp4"
if video.is_file():
video.unlink()
return 1
except OSError as e:
logger.warning("No se pudo borrar el Short de una sesión purgada",
session_id=sid, error=str(e))
return 0
+2 -2
View File
@@ -139,7 +139,7 @@
"caption": "CONTACT HOLDS RELATIVE POSITION", "caption": "CONTACT HOLDS RELATIVE POSITION",
"turn_deg": 360 "turn_deg": 360
}, },
"narration": "He tried to shake it. Full circle, steep descent, and it was still there." "narration": "He tried to shake it. Full circle, steep descent, and it stayed there."
}, },
{ {
"template": "signal_strips", "template": "signal_strips",
@@ -207,7 +207,7 @@
"url": "THEEXCLUSIONZONE.COM", "url": "THEEXCLUSIONZONE.COM",
"show_mark": true "show_mark": true
}, },
"narration": "The file was never closed. It was filed, and left where anyone can read it." "narration": "The file was never closed. It was left where anyone can read it."
} }
] ]
} }
+1 -1
View File
@@ -277,7 +277,7 @@ class ShortProducer:
logger.warning("Rerender rechazado por el contrato", logger.warning("Rerender rechazado por el contrato",
session_id=session_id, errors=e.errors[:6]) session_id=session_id, errors=e.errors[:6])
return result return result
result.notes = editorial_notes(spec) result.notes = editorial_notes(spec, templates)
result.title = spec.get("meta", {}).get("title", topic) result.title = spec.get("meta", {}).get("title", topic)
result.duration_s = sum(s.get("duration", 0) for s in spec["shots"]) result.duration_s = sum(s.get("duration", 0) for s in spec["shots"])
+172 -24
View File
@@ -22,7 +22,9 @@ 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,
defect_notes, 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,
) )
@@ -32,6 +34,42 @@ 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 dura un plano como mucho. 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
#: 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"]
@@ -84,18 +122,31 @@ 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, not a character count you can \
will catch for you.** Nothing rejects a longer string: the renderer shrinks the \ argue with.** Nothing rejects a longer string: the renderer shrinks the type \
type until it fits, so a string at twice its budget is drawn at a fraction of \ until it fits, so a string at twice its budget is drawn at a fraction of its \
its size and ends up the smallest text on a frame it was supposed to dominate. \ size and ends up the smallest text on a frame it was supposed to dominate. \
Stay at or under N. On a quote that means picking a shorter verbatim span, \ Stay at or under N. On a quote that means picking a shorter verbatim span, \
never squeezing the whole sentence in. never squeezing the whole sentence in.
- **"ILEGIBLE por encima de M" is the line that is actually checked**, before \
anything renders. Between N and M the text is drawn a little smaller and looks \
fine that tolerance is deliberate. Past M it is not a smaller headline, it is \
an unreadable one: one real caption asked for 36 px and was drawn at 20 on a \
1080-wide frame. Aim at N; M is the wall.
- Respect every max length and list-length limit above. They are enforced. - Respect every max length and list-length limit above. They are enforced.
- Colours are palette names ({colors}) never hex. - Colours are palette names ({colors}) never hex.
- Quotes carry the typographic quote marks: SPLIT RADAR IMAGE, with U+201C \ - Quotes carry the typographic quote marks: SPLIT RADAR IMAGE, with U+201C \
@@ -116,9 +167,13 @@ 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 the hard stop is not a style preference \
instead, and a Short that drifts past 45 seconds is a Short people leave. either it is exactly as much as fits in the longest shot you are allowed to \
declare. {words_per_line_max} words is {max_shot:.0f} seconds; the same limit, \
written twice. Go past it and the shot has to grow, because the renderer will \
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.
@@ -132,15 +187,59 @@ 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:g} 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 **count the words AND \
the line actually takes. count the sentences**:
duration words ÷ {words_per_second:g} + {sentence_pause} × sentences + \
{rounded_pad}
The second term is the one that catches people out. "Witness identities. \
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 \
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.
- The closing shot carries the domain, uppercase, no protocol: {domain} - The closing shot carries the domain, uppercase, no protocol: {domain}
# 3c. The opening shot
The first shot is the hook, and the hook is what is *drawn*, not only what is \
said. Both of these are measured on the renderer you are writing for:
- **Open with a template that has a `headline`.** Those put a display-size line \
across the top of the frame within 0.4 seconds, whatever length you give the \
shot. The templates without one draw nothing up there at all: their content \
types in lower down and is not complete until about 2.6 seconds. The average \
view of a Short on this channel is under seven seconds, so opening with one of \
those spends a third of it on a frame that has not said anything yet. Those \
templates are good shots; they are not opening shots.
- **The headline carries the strangest concrete thing you have a count, a \
quantity, an object and never the date.** The date and the place have a home \
one size down in `subline`, and that is the right size for them. A headline \
reading "8 JAN 1981" tells someone who has not decided to watch anything at \
all; "62 CHILDREN" over a subline of "ONE SILVER CRAFT" tells them what the \
video is. Both are real openings from this channel, and the second one is the \
shape to copy.
# 4. Grounding — this is the part that matters # 4. Grounding — this is the part that matters
Every figure, quote, date, and proper noun in your spec must appear in the \ Every figure, quote, date, and proper noun in your spec must appear in the \
@@ -359,6 +458,36 @@ 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,
templates: dict[str, dict]) -> SpecResult:
"""De dos specs válidos, el mejor: primero las averías, luego la duración.
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.
Las averías van delante de los segundos, y no por gusto: ordenar sólo por
duración deja pasar el caso que hace inútiles los avisos un segundo
intento que arregla el titular, o acorta un rótulo que iba a salir
ilegible, pero se pasa un segundo, perdería contra el primero, y el modelo
habría obedecido la nota para nada. Los segundos fuera de objetivo son un
gradiente; abrir con una fecha, o dibujar un texto que no se lee, es
binario y cuesta más.
"""
if a is None:
return b
def rank(r: SpecResult) -> tuple[int, float]:
return (len(defect_notes(r.spec, templates)), _off_target(r.spec))
return a if rank(a) <= rank(b) 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 +517,13 @@ 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,
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,
@@ -404,6 +540,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:
@@ -437,26 +575,36 @@ class ShortSpecWriter:
error=str(refresh_err)) error=str(refresh_err))
continue continue
notes = editorial_notes(spec) notes = editorial_notes(spec, self.templates)
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, self.templates)
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
+471 -19
View File
@@ -27,7 +27,14 @@ __all__ = [
"SpecInvalid", "SpecInvalid",
"validate_spec", "validate_spec",
"editorial_notes", "editorial_notes",
"opening_notes",
"unreadable_notes",
"defect_notes",
"estimated_duration", "estimated_duration",
"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",
@@ -47,6 +54,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."""
@@ -158,6 +174,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]:
@@ -305,8 +441,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:
@@ -321,12 +462,90 @@ 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
#: 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.
#: El decimal de "1.5" no cuenta, y por eso mira lo que va detrás.
_SENTENCE_END = re.compile(r"[.!?](?=\s|$)")
def sentence_count(line: str) -> int:
"""Frases de una línea, contadas como las cuenta Piper para sus pausas."""
return max(1, len(_SENTENCE_END.findall(line))) if line.strip() else 0
def teachable_seconds(words: int, sentences: int = 1) -> float:
"""Lo que hay que DECLARAR para una línea, en las unidades que el modelo cuenta.
Es `spoken_seconds` traducido de caracteres a palabras. La traducción hace
falta porque un LLM no cuenta caracteres de fiar, pero cuenta palabras y
puntos y la regla tiene que ser computable por quien debe obedecerla, o no
es una regla, es un deseo.
Los dos términos son los mismos que los de la voz. La versión anterior del
prompt colapsaba el segundo en un "+ medio segundo" fijo, y ese es el mismo
error de clase que tenía el estimador antes del 2026-08-12: sin pausa por
frase, una línea troceada en frases cortas se da por rápida. Medido contra
las 28 líneas reales, aquella regla se quedaba corta en 14 y hasta 2,27 s
o sea que un modelo que la obedeciera al pie de la letra seguiría
infradeclarando la mitad de sus planos. Con el término por frase el peor
caso baja a 1,27 s y sólo en 5 de 28.
"""
return (words / NARRATION_WORDS_PER_SECOND
+ sentences * NARRATION_SENTENCE_SILENCE
+ NARRATION_ROUNDED_PAD)
def max_words_in(seconds: float, sentences: int = 1) -> int:
"""Cuántas palabras caben en un plano de esa duración, según la regla de arriba.
Existe para que el tope de palabras por línea y el tope de duración de plano
no puedan volver a contradecirse: se deriva uno del otro en vez de escribir
los dos a mano.
"""
room = seconds - sentences * NARRATION_SENTENCE_SILENCE - NARRATION_ROUNDED_PAD
return max(1, int(room * NARRATION_WORDS_PER_SECOND))
def spoken_seconds(line: str) -> float:
"""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 +555,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,20 +567,214 @@ 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
def editorial_notes(spec: dict) -> list[str]: #: Meses como los escribe el modelo — el spec se genera en inglés — enteros y
#: abreviados. Sólo sirven para reconocer una fecha, nunca para rechazar nada.
_MONTHS = frozenset(
"JANUARY FEBRUARY MARCH APRIL MAY JUNE JULY AUGUST SEPTEMBER OCTOBER "
"NOVEMBER DECEMBER JAN FEB MAR APR JUN JUL AUG SEP SEPT OCT NOV DEC".split()
)
#: Un número de cuatro cifras en rango de año. Un titular que es sólo "1947" es
#: una fecha; uno que es sólo "62" es una cifra, y esa es exactamente la
#: diferencia que decide si esto avisa.
_YEAR = re.compile(r"\A(1[4-9]\d\d|20\d\d)\Z")
def _headline_is_only_a_date(headline: str) -> bool:
"""El titular no dice más que cuándo.
Pide dos cosas a la vez, y la segunda es la que evita el falso positivo que
importa: que TODOS los tokens sean mes o número, y que haya un mes o un año
entre ellos. "62 CHILDREN" tiene una palabra que no es ninguna de las dos y
se salva por la primera; "62" a secas pasa la primera y se salva por la
segunda, que es lo correcto una cifra desnuda es justo el titular que se
quiere.
"""
tokens = [t for t in re.split(r"[^A-Za-z0-9]+", headline.upper()) if t]
if not tokens or not all(t in _MONTHS or t.isdigit() for t in tokens):
return False
return any(t in _MONTHS or _YEAR.match(t) for t in tokens)
def opening_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
"""El primer plano es el gancho, y el gancho es lo que se DIBUJA.
Dos avisos, los dos medidos sobre este renderizador a 5,5 s de plano:
**Abrir con una plantilla que tenga `headline`.** Las cinco que lo tienen
ponen una línea a tamaño de display en la banda superior del fotograma en
0,33-0,40 s, y shortsmith lo garantiza a cualquier duración de plano desde
`db1ac7e` (`draw.entrance`). En las tres que no lo tienen esa banda se queda
en el nivel del fondo *todo el plano*: su contenido se escribe a máquina más
abajo y no está entero hasta 2,6-2,8 s. Con 6,9 s de visionado medio, abrir
con una de esas tres regala el tercio de la ventana en el que se decide todo.
Ocurrió: la sesión del output 131 abrió con `document_quote` y su primera
letra no aparecía hasta 1,33 s.
Deliberadamente NO es una lista de nombres. Se pregunta al esquema que
publica shortsmith, así que una plantilla nueva con titular podrá abrir sin
tocar esto y una sin él no podrá el mismo pacto que el resto del contrato.
**Y el titular lleva la cifra, no la fecha.** De los once casos distintos
generados hasta hoy, cinco abren con una fecha por titular mientras el
`subline` de debajo ya lleva el sitio: el texto más grande del fotograma se
gasta en metadatos. "8 JAN 1981" no le dice nada a quien aún no ha decidido
quedarse; "62 CHILDREN" sobre "ONE SILVER CRAFT" le dice de qué va el vídeo.
Los dos son titulares reales del canal.
"""
shots = spec.get("shots") or []
if not shots or not isinstance(shots[0], dict):
return []
first = shots[0]
schema = templates.get(first.get("template")) if isinstance(templates, dict) else None
if not isinstance(schema, dict):
# Plantilla desconocida: de eso ya se queja `validate_spec`, y con más
# razón. Aquí callar es lo correcto — un aviso editorial sobre algo que
# ni siquiera renderiza es ruido encima de un error.
return []
if "headline" not in (schema.get("properties") or {}):
return [f"el primer plano usa {first.get('template')!r}, que no dibuja "
"titular: su texto se escribe a máquina y no está entero hasta "
"pasados ~2,6s, que es cuando media audiencia ya se ha ido. Abre "
"con una plantilla que tenga `headline` y deja ésta para más "
"adelante en el vídeo"]
headline = (first.get("props") or {}).get("headline")
if isinstance(headline, str) and _headline_is_only_a_date(headline):
return [f"el titular del primer plano es sólo una fecha ({headline!r}): "
"es el texto más grande del vídeo y el único que se ve antes de "
"que decidan quedarse. Ponle la cifra o el objeto más raro que "
"tengas y baja la fecha al `subline`, que es donde ya está el "
"sitio"]
return []
#: Cuántos textos ilegibles se nombran antes de resumir. Cinco caben en un
#: mensaje de Telegram y son de sobra para que el modelo entienda el patrón; la
#: lista entera sólo entierra el consejo.
MAX_NAMED_UNREADABLE = 5
def _budget_of(node: Any) -> Optional[int]:
"""`x-fits-hard` del nodo, resuelto por si el campo es una lista de textos."""
if not isinstance(node, dict):
return None
hard = node.get("x-fits-hard")
return hard if isinstance(hard, int) else None
def _too_long(value: Any, node: Any, path: str) -> list[tuple[str, int, int]]:
"""(ruta, longitud, presupuesto) de cada cadena que pasa de `x-fits-hard`."""
hard = _budget_of(node)
if hard is None:
return []
if isinstance(value, str):
return [(path, len(value), hard)] if len(value) > hard else []
if isinstance(value, list):
# El presupuesto de una lista de textos es POR LÍNEA: el campo se dibuja
# como varias líneas, no como una cadena unida. Medirlo sobre el total
# avisaría de una cita bien partida en dos.
return [(f"{path}[{i}]", len(v), hard)
for i, v in enumerate(value)
if isinstance(v, str) and len(v) > hard]
return []
def unreadable_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
"""Los textos que shortsmith va a dibujar ilegibles, dichos ANTES del render.
`x-fits` es guía blanda y está bien que lo sea: el ejemplo de referencia se
pasa de varios de sus propios presupuestos por uno o tres caracteres y se ve
bien. Parar ahí sería gritar con specs buenos. `x-fits-hard` es la otra
línea que shortsmith publica desde 289d50e: pasada ella la cadena no es un
titular más pequeño, es uno ilegible, y ahí hay algo que decir.
El caso real: `ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH RADIATION
EXPOSURE` pidió 36 px y se dibujó a **20** en un fotograma de 1080 de ancho.
El aviso existía shortsmith lo manda con `severe`, y el informe de Telegram
lo saca en rojo pero salía del render TERMINADO, y hacerle caso significaba
editar el spec a mano y pagar un segundo render. Comprobarlo aquí cuesta un
reintento del modelo. Es exactamente el mismo movimiento que `MAX_CUE_CHARS`
hizo con los captions.
Nota editorial y no error de contrato, y a propósito: los caracteres son un
proxy de los píxeles y el número es una medida sobre inglés en mayúsculas
realista, así que una cadena estrecha puede pasarse de la cuenta y caber.
Rechazarla sería el error que `x-fits` evita a conciencia.
Los campos con `x-fits-part-of` se saltan: no tienen presupuesto propio
porque se dibujan dentro de la cadena de otro (`Bar.unit` va en
`f"{value} {unit}"`), y reconstruir esa cadena aquí pediría conocer el
formato de la plantilla, que es justo lo que este repo no sabe ni debe.
"""
found: list[tuple[str, int, int]] = []
for i, shot in enumerate(spec.get("shots") or []):
if not isinstance(shot, dict):
continue
schema = templates.get(shot.get("template")) if isinstance(templates, dict) else None
if not isinstance(schema, dict):
continue
defs = schema.get("$defs", {})
props = shot.get("props")
if not isinstance(props, dict):
continue
for name, value in props.items():
node = (schema.get("properties") or {}).get(name)
if not isinstance(node, dict) or "x-fits-part-of" in node:
continue
path = f"shots.{i}.{name}"
found.extend(_too_long(value, node, path))
# Listas de objetos: el presupuesto vive en el submodelo.
item = _resolve(node.get("items", {}), defs) if node.get("type") == "array" else {}
if item.get("type") == "object" and isinstance(value, list):
for k, entry in enumerate(value):
if not isinstance(entry, dict):
continue
for sub, sub_value in entry.items():
sub_node = (item.get("properties") or {}).get(sub)
if isinstance(sub_node, dict) and "x-fits-part-of" not in sub_node:
found.extend(_too_long(sub_value, sub_node,
f"{path}[{k}].{sub}"))
if not found:
return []
named = ", ".join(f"{path} ({n} caracteres, caben {hard})"
for path, n, hard in found[:MAX_NAMED_UNREADABLE])
rest = f" y {len(found) - MAX_NAMED_UNREADABLE} más" if len(found) > MAX_NAMED_UNREADABLE else ""
return [f"{len(found)} texto{'s' if len(found) > 1 else ''} se va{'n' if len(found) > 1 else ''} "
"a dibujar ILEGIBLE: el renderizador encoge lo que no cabe y a esta "
"longitud queda por debajo de la mitad del tamaño de diseño. Acorta "
f"{named}{rest}"]
def defect_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
"""Lo que está MAL, frente a lo que está fuera de objetivo.
La distinción decide dos cosas: qué se dice primero y, sobre todo, cómo se
eligen los intentos entre . Un vídeo que se pasa cinco segundos se ve; uno
que abre con una fecha o lleva un rótulo ilegible no se arregla durando
menos.
"""
return opening_notes(spec, templates) + unreadable_notes(spec, templates)
def editorial_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
"""Lo que no viola el contrato pero sí el encargo. """Lo que no viola el contrato pero sí el encargo.
Va aparte de `validate_spec` justo porque no impide renderizar: un Short de Va aparte de `validate_spec` justo porque no impide renderizar: un Short de
70 s se ve, sólo que peor. Se le devuelve al modelo como comentario una vez; 70 s se ve, sólo que peor. Se le devuelve al modelo como comentario una vez;
si insiste, se renderiza igual antes que tirar la generación a la basura. si insiste, se renderiza igual antes que tirar la generación a la basura.
""" """
notes = [] # Primero los defectos: si el vídeo abre mal o lleva un texto ilegible, eso
# va antes que su duración, que es un objetivo y no una avería.
notes = defect_notes(spec, templates)
declared = _total_duration(spec) declared = _total_duration(spec)
total = estimated_duration(spec) total = estimated_duration(spec)
stretched = total > declared + 0.5 stretched = total > declared + 0.5
@@ -366,18 +782,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,
@@ -406,12 +852,18 @@ def _describe_field(name: str, schema: dict, required: bool, defs: dict,
bits.append("no vacío") bits.append("no vacío")
if "maxLength" in schema: if "maxLength" in schema:
bits.append(f"máx {schema['maxLength']} caracteres") bits.append(f"máx {schema['maxLength']} caracteres")
# `x-fits` es cuánto texto cabe DIBUJADO al tamaño de diseño, medido por # `x-fits` es cuánto texto cabe DIBUJADO al tamaño de diseño y `x-fits-hard`
# shortsmith contra sus propias fuentes. No se valida — los caracteres son # dónde deja de leerse, los dos medidos por shortsmith contra sus propias
# un proxy de los píxeles — pero es lo único que evita que el modelo escriba # fuentes. Ninguno se valida — los caracteres son un proxy de los píxeles —
# una cita de 58 caracteres en un hueco de 16 y salga dibujada ilegible. # pero el segundo sí se comprueba antes de renderizar (`unreadable_notes`),
# así que se le enseñan los dos: el objetivo y la línea roja. Sin el segundo,
# el modelo lee "~16" como una sugerencia sin consecuencia y escribe 58.
if "x-fits" in schema: if "x-fits" in schema:
bits.append(f"CABE ~{schema['x-fits']} caracteres dibujados") bits.append(f"CABE ~{schema['x-fits']} caracteres dibujados")
if "x-fits-hard" in schema:
bits.append(f"ILEGIBLE por encima de {schema['x-fits-hard']}")
if "x-fits-part-of" in schema:
bits.append(f"se dibuja dentro de {schema['x-fits-part-of']}, comparte su sitio")
for key, text in (("minimum", ""), ("maximum", ""), for key, text in (("minimum", ""), ("maximum", ""),
("exclusiveMinimum", ">"), ("exclusiveMaximum", "<")): ("exclusiveMinimum", ">"), ("exclusiveMaximum", "<")):
if key in schema: if key in schema:
+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."""
+145 -1
View File
@@ -331,7 +331,7 @@ async def test_purging_a_session_takes_its_video_with_it(tmp_path, monkeypatch):
" created_at, updated_at) VALUES (2,'nuevo','saturated',1,?,?)", (now, now)) " created_at, updated_at) VALUES (2,'nuevo','saturated',1,?,?)", (now, now))
await conn.commit() await conn.commit()
counts = await ResearchDB(conn).purge_old_sessions(30) counts = await ResearchDB(conn).purge_old_data(30)
await conn.close() await conn.close()
assert counts["shorts"] == 1 assert counts["shorts"] == 1
@@ -339,6 +339,150 @@ async def test_purging_a_session_takes_its_video_with_it(tmp_path, monkeypatch):
assert (shorts / "2.mp4").exists(), "la sesión reciente conserva su vídeo" assert (shorts / "2.mp4").exists(), "la sesión reciente conserva su vídeo"
async def purge_fixture(tmp_path, monkeypatch, sessions, outputs, days=None):
"""Una BD con sesiones y outputs de las edades que se le pidan, purgada.
`sessions` y `outputs` llevan la edad en días: positiva es pasado. Devuelve
(counts, short_en supervivientes, ids de sesión supervivientes).
"""
import time
import aiosqlite
from src.db import database
from src.db.database import ResearchDB
shorts = tmp_path / "shorts"
shorts.mkdir()
monkeypatch.setattr(settings, "shorts_dir", str(shorts))
conn = await aiosqlite.connect(tmp_path / "p.db")
conn.row_factory = aiosqlite.Row
await conn.executescript(database.SCHEMA)
now = time.time()
for sid, age in sessions:
t = now - age * 86400
await conn.execute(
"INSERT INTO research_sessions (id, topic, status, telegram_chat_id,"
" created_at, updated_at) VALUES (?,?, 'saturated', 1, ?, ?)",
(sid, f"s{sid}", t, t))
(shorts / f"{sid}.mp4").write_bytes(b"x")
# Un source por sesión, para ver si la cascada la alcanza o no.
await conn.execute(
"INSERT INTO sources (session_id, url, title, scraped_at)"
" VALUES (?,?,?,?)", (sid, f"http://x/{sid}", "t", t))
for oid, sid, age in outputs:
await conn.execute(
"INSERT INTO outputs (id, session_id, output_type, content, created_at)"
" VALUES (?,?, 'short_en', '{}', ?)", (oid, sid, now - age * 86400))
await conn.commit()
try:
db = ResearchDB(conn)
# `days=None` deja hablar al valor por defecto, que es lo que corre en
# producción — pasarlo a mano en cada test convierte la ventana real en
# algo que ninguna prueba mira.
counts = await (db.purge_old_data(days) if days else db.purge_old_data())
vivos = [r[0] for r in await (await conn.execute(
"SELECT id FROM outputs ORDER BY id")).fetchall()]
sesiones = [r[0] for r in await (await conn.execute(
"SELECT id FROM research_sessions ORDER BY id")).fetchall()]
finally:
# Sin esto, un fallo dentro del `try` deja el hilo de aiosqlite vivo y
# pytest no termina NUNCA: el error se presenta como un cuelgue, que es
# la forma más cara de leer un fallo.
await conn.close()
return counts, vivos, sesiones, shorts
@pytest.mark.asyncio
async def test_a_new_output_on_an_old_session_survives(tmp_path, monkeypatch):
"""El fallo del 2026-09-01, exacto. La cascada iba por la edad de la SESIÓN,
así que los outputs 132-139 generados el día antes sobre casos viejos se
borraron mientras 128-131, más antiguos, sobrevivían. Un spec no envejece
con la investigación que lo originó.
"""
counts, vivos, sesiones, shorts = await purge_fixture(
tmp_path, monkeypatch,
sessions=[(1, 90)], # sesión de hace tres meses
outputs=[(139, 1, 1)]) # con un spec de ayer
assert vivos == [139], "el spec de ayer murió con su sesión"
assert sesiones == [1], "la sesión tiene que sobrevivir o la FK se rompe"
assert counts["outputs"] == 0
assert (shorts / "1.mp4").exists(), "y su vídeo con ella"
@pytest.mark.asyncio
async def test_an_old_output_dies_even_on_a_live_session(tmp_path, monkeypatch):
"""La otra mitad, y sin ella lo de arriba no es retención por fecha de
output: es no purgar outputs nunca."""
counts, vivos, sesiones, _ = await purge_fixture(
tmp_path, monkeypatch,
sessions=[(1, 2)], # sesión de anteayer
outputs=[(1, 1, 90), (2, 1, 2)]) # un spec viejo y uno reciente
assert vivos == [2]
assert counts["outputs"] == 1
assert sesiones == [1], "la sesión reciente no se toca"
@pytest.mark.asyncio
async def test_the_ordinary_case_still_cleans_up_whole(tmp_path, monkeypatch):
"""Sesión vieja con material viejo: se limpia entera en UNA pasada. La fase 1
la deja sin outputs y la fase 2 se la lleva si el orden se invirtiera, la
sesión sobreviviría a su propio material hasta el arranque siguiente."""
counts, vivos, sesiones, shorts = await purge_fixture(
tmp_path, monkeypatch,
sessions=[(1, 90), (2, 2)],
outputs=[(1, 1, 90), (2, 2, 2)])
assert vivos == [2] and sesiones == [2]
assert counts["sessions"] == 1 and counts["outputs"] == 1
assert counts["sources"] == 1, "la cascada alcanza a la sesión purgada"
assert not (shorts / "1.mp4").exists() and (shorts / "2.mp4").exists()
@pytest.mark.asyncio
async def test_a_session_that_loses_its_last_short_loses_its_video(tmp_path,
monkeypatch):
"""El MP4 se llama por sesión, así que cuando la fase 1 se lleva el último
short_en de una sesión que sigue viva, el fichero queda sin nada que lo
nombre. Sin esto el PVC acumula vídeos que ya no aparecen en ninguna fila."""
counts, vivos, sesiones, shorts = await purge_fixture(
tmp_path, monkeypatch,
sessions=[(1, 2)], # la sesión sigue viva
outputs=[(1, 1, 90)]) # pero su único short se va
assert vivos == [] and sesiones == [1]
assert counts["shorts"] == 1
assert not (shorts / "1.mp4").exists()
@pytest.mark.asyncio
async def test_the_default_window_keeps_material_still_in_use(tmp_path, monkeypatch):
"""La ventana por defecto, fijada por comportamiento y no por el número.
30 días no era una política de retención, era una suposición sobre cuánto
dura el material: los 17 specs del catálogo se escribieron entre el 2 y el
13 de agosto y seguían siendo el material de trabajo el 1 de septiembre.
Este test falla si alguien vuelve a estrechar la ventana por debajo de mes
y medio, que es donde empieza a llevarse cosas que aún se usan.
"""
from src.db.database import RETENTION_DAYS
counts, vivos, _, _ = await purge_fixture(
tmp_path, monkeypatch,
sessions=[(1, 120)],
outputs=[(1, 1, 45), (2, 1, 200)]) # uno de mes y medio, uno de siete meses
assert RETENTION_DAYS >= 45
assert vivos == [1], "un output de 45 días sigue siendo material de trabajo"
assert counts["outputs"] == 1, "y uno de 200 días no"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_the_youtube_url_never_passes_for_an_article_url(tmp_path): async def test_the_youtube_url_never_passes_for_an_article_url(tmp_path):
"""Subir un Short escribe su URL de YouTube en `published_url`. Si """Subir un Short escribe su URL de YouTube en `published_url`. Si
+13
View File
@@ -42,6 +42,19 @@ async def test_healthz_and_templates():
assert schema.get("type") == "object", f"{name} no publica un esquema de objeto" assert schema.get("type") == "object", f"{name} no publica un esquema de objeto"
assert "properties" in schema assert "properties" in schema
# Los dos presupuestos de texto, en el contrato SERVIDO. `unreadable_notes`
# se calla contra un esquema que no los traiga —no puede inventarse el
# número—, así que un shortsmith anterior a 289d50e apagaría la comprobación
# entera sin un solo error. Esto es lo único que lo nota.
sin_tope = [
f"{name}.{prop}"
for name, schema in templates.items()
for owner in [schema, *(schema.get("$defs") or {}).values()]
for prop, node in (owner.get("properties") or {}).items()
if "x-fits" in node and "x-fits-hard" not in node
]
assert sin_tope == [], f"campos con x-fits pero sin x-fits-hard: {sin_tope}"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_render_the_reference_example_end_to_end(tmp_path): async def test_render_the_reference_example_end_to_end(tmp_path):
+230 -6
View File
@@ -105,6 +105,29 @@ def test_prompt_states_the_editorial_constraints():
assert "material" in prompt assert "material" in prompt
def test_the_prompt_states_what_the_opening_shot_has_to_do():
"""Las dos mitades del gancho visual. Sin la primera el modelo abre con una
plantilla que aún se está escribiendo; sin la segunda gasta el texto más
grande del vídeo en la fecha, que es lo que hizo en cinco de once casos."""
w, _ = writer("{}")
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
assert "`headline`" in prompt and "0.4 seconds" in prompt
assert "never the date" in prompt and "subline" in prompt
def test_the_prompt_states_both_text_budgets():
"""El primero es el objetivo y el segundo la línea roja, y hacen falta los
dos: con sólo "CABE ~16" el modelo lee una sugerencia sin consecuencia y
escribe 58. Con sólo la línea roja, apunta a ella y todo sale encogido."""
w, _ = writer("{}")
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
assert "CABE ~13 caracteres dibujados" in prompt # del esquema
assert "ILEGIBLE por encima de 21" in prompt
assert "is the line that is actually checked" in prompt
def test_prompt_includes_the_worked_example_in_full(): def test_prompt_includes_the_worked_example_in_full():
w, _ = writer("{}") w, _ = writer("{}")
prompt = w.build_prompt("Caso X", "material", None, "X.TEST") prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
@@ -134,15 +157,128 @@ 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_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"]):
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: el ejemplo es
# la señal más fuerte. Dos de estas seis líneas NO cumplían — y ese es
# exactamente el defecto que el modelo copiaba.
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"
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")
# 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"
assert f"words ÷ {NARRATION_WORDS_PER_SECOND:g}" 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_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():
@@ -220,15 +356,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 +386,89 @@ 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_fixed_hook_beats_a_closer_duration():
"""El desempate entre intentos válidos ordenaba SÓLO por segundos, y eso
hacía inútil el aviso de apertura: el modelo obedecía la nota, se pasaba un
poco de largo al reescribir, y se elegía igualmente el intento que abría con
una fecha. Los segundos son un gradiente; el gancho es binario y cuesta más.
"""
fecha = json.loads(json.dumps(GOOD))
fecha["shots"][0]["props"]["headline"] = "8 JAN 1981" # 30 s: en objetivo
arreglado = json.loads(json.dumps(GOOD))
arreglado["shots"][0]["props"]["headline"] = "62 CHILDREN"
for shot in arreglado["shots"]:
shot["duration"] = 24.0 # 48 s: se pasa
w, _ = writer(json.dumps(fecha), json.dumps(arreglado))
result = await w.write("Caso X", "material")
assert result.spec["shots"][0]["props"]["headline"] == "62 CHILDREN"
assert result.notes and "objetivo" in result.notes[0]
@pytest.mark.asyncio
async def test_a_shortened_label_also_beats_a_closer_duration():
"""El mismo desempate que el gancho, por la otra avería: un rótulo que se iba
a dibujar ilegible no se arregla durando menos."""
ilegible = json.loads(json.dumps(GOOD))
ilegible["shots"][0]["props"]["subline"] = "X" * 60 # 30 s: en objetivo
corto = json.loads(json.dumps(GOOD))
corto["shots"][0]["props"]["subline"] = "SOCORRO, NEW MEXICO"
for shot in corto["shots"]:
shot["duration"] = 24.0 # 48 s: se pasa
w, _ = writer(json.dumps(ilegible), json.dumps(corto))
result = await w.write("Caso X", "material")
assert result.spec["shots"][0]["props"]["subline"] == "SOCORRO, NEW MEXICO"
@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
+446 -20
View File
@@ -12,7 +12,8 @@ from pathlib import Path
import pytest import pytest
from src.generator.spec_contract import ( from src.generator.spec_contract import (
SpecInvalid, describe_templates, editorial_notes, validate_spec, SpecInvalid, describe_templates, editorial_notes, opening_notes,
unreadable_notes, validate_spec,
) )
EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json" EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json"
@@ -22,8 +23,10 @@ TEMPLATES = {
"type": "object", "additionalProperties": False, "type": "object", "additionalProperties": False,
"required": ["headline"], "required": ["headline"],
"properties": { "properties": {
"headline": {"type": "string", "minLength": 1}, "headline": {"type": "string", "minLength": 1,
"subline": {"type": "string", "default": ""}, "x-fits": 13, "x-fits-hard": 21},
"subline": {"type": "string", "default": "",
"x-fits": 27, "x-fits-hard": 42},
"contact_bearing_deg": {"type": "number", "minimum": 0, "contact_bearing_deg": {"type": "number", "minimum": 0,
"exclusiveMaximum": 360, "default": 210.0}, "exclusiveMaximum": 360, "default": 210.0},
"sweeps": {"type": "number", "exclusiveMinimum": 0, "maximum": 10, "sweeps": {"type": "number", "exclusiveMinimum": 0, "maximum": 10,
@@ -37,20 +40,26 @@ TEMPLATES = {
"type": "object", "additionalProperties": False, "type": "object", "additionalProperties": False,
"required": ["label", "value"], "required": ["label", "value"],
"properties": { "properties": {
"label": {"type": "string", "minLength": 1}, "label": {"type": "string", "minLength": 1,
"x-fits": 32, "x-fits-hard": 53},
"value": {"type": "number", "exclusiveMinimum": 0}, "value": {"type": "number", "exclusiveMinimum": 0},
"unit": {"type": "string", "default": ""}, "unit": {"type": "string", "default": "",
"x-fits-part-of": "value_label"},
"color": {"enum": ["ink", "amber", "amber_dark", "muted", "dim", "red"], "color": {"enum": ["ink", "amber", "amber_dark", "muted", "dim", "red"],
"type": "string", "default": "ink"}, "type": "string", "default": "ink"},
"value_label": {"type": "string", "default": ""}, "value_label": {"type": "string", "default": "",
"x-fits": 30, "x-fits-hard": 49},
}, },
}}, }},
"properties": { "properties": {
"headline": {"type": "string", "minLength": 1}, "headline": {"type": "string", "minLength": 1,
"x-fits": 16, "x-fits-hard": 26},
"bars": {"type": "array", "items": {"$ref": "#/$defs/Bar"}, "bars": {"type": "array", "items": {"$ref": "#/$defs/Bar"},
"minItems": 1, "maxItems": 3}, "minItems": 1, "maxItems": 3},
"quote": {"type": "array", "items": {"type": "string"}, "maxItems": 2}, "quote": {"type": "array", "items": {"type": "string"}, "maxItems": 2,
"attribution": {"type": "string", "default": ""}, "x-fits": 33, "x-fits-hard": 49},
"attribution": {"type": "string", "default": "",
"x-fits": 46, "x-fits-hard": 73},
}, },
}, },
} }
@@ -167,7 +176,7 @@ def test_total_duration_ceiling_is_the_contract_not_the_target():
invalida el spec eso es una nota, no un error.""" invalida el spec eso es una nota, no un error."""
long_spec = spec_with(*[shot(duration=10.0) for _ in range(6)]) # 60 s long_spec = spec_with(*[shot(duration=10.0) for _ in range(6)]) # 60 s
validate_spec(long_spec, TEMPLATES) validate_spec(long_spec, TEMPLATES)
assert editorial_notes(long_spec) assert editorial_notes(long_spec, TEMPLATES)
too_long = spec_with(*[shot(duration=30.0) for _ in range(7)]) # 210 s too_long = spec_with(*[shot(duration=30.0) for _ in range(7)]) # 210 s
assert any("pasa del límite" in e for e in errors_of(too_long)) assert any("pasa del límite" in e for e in errors_of(too_long))
@@ -178,6 +187,127 @@ def test_total_duration_floor():
for e in errors_of(spec_with(shot(duration=2.0)))) for e in errors_of(spec_with(shot(duration=2.0))))
class TestCrossFieldRules:
"""Las reglas de pydantic que cruzan campos, replicadas a mano.
No salen en el JSON Schema publicado, así que antes se dejaban al 422 del
servidor y ese 422 llega al RENDERIZAR, cuando el bucle de reintentos ya
ha terminado. O sea que no costaban un reintento: costaban la generación
entera y no daban vídeo. Pasó de verdad con la sesión 162 el 2026-08-13.
"""
def test_three_bars_and_a_quote_do_not_fit(self):
# El fallo exacto de la 162, con el texto exacto de shortsmith.
errors = errors_of(spec_with(shot(
"scale_bars", duration=25.0,
bars=[{"label": "A", "value": 1}, {"label": "B", "value": 2},
{"label": "C", "value": 3}],
quote=["“UNA CITA”"])))
assert any("3 bars leave no room for a quote" in e for e in errors)
assert any(e.startswith("shots.0.scale_bars.props") for e in errors)
def test_three_bars_without_a_quote_are_fine(self):
"""La regla es sobre el hueco, no sobre el número de barras."""
validate_spec(spec_with(shot(
"scale_bars", duration=25.0,
bars=[{"label": "A", "value": 1}, {"label": "B", "value": 2},
{"label": "C", "value": 3}])), TEMPLATES)
def test_two_bars_with_a_quote_are_fine(self):
validate_spec(spec_with(shot(
"scale_bars", duration=25.0,
bars=[{"label": "A", "value": 1}, {"label": "B", "value": 2}],
quote=["“UNA CITA”"])), TEMPLATES)
def test_a_waypoint_outside_a_pinned_window_is_rejected(self):
"""La proyección no recorta: un waypoint fuera se dibuja donde diga la
aritmética, a veces fuera del encuadre."""
templates = {"track_map": {"type": "object"}}
spec = spec_with({
"template": "track_map", "duration": 25.0,
"props": {
"headline": "RUTA",
"waypoints": [{"label": "DENTRO", "lat": 62.0, "lon": -148.0},
{"label": "FUERA", "lat": 20.0, "lon": -148.0}],
"bounds": {"lat_min": 60.0, "lat_max": 67.0,
"lon_min": -152.0, "lon_max": -143.0}}})
errors = errors_of(spec, templates)
assert any("waypoints outside the map bounds: FUERA" in e for e in errors)
def test_bounds_with_max_below_min_are_rejected(self):
templates = {"track_map": {"type": "object"}}
spec = spec_with({
"template": "track_map", "duration": 25.0,
"props": {
"headline": "RUTA",
"waypoints": [{"label": "A", "lat": 62.0, "lon": -148.0}],
"bounds": {"lat_min": 67.0, "lat_max": 60.0,
"lon_min": -152.0, "lon_max": -143.0}}})
assert any("max greater than min" in e for e in errors_of(spec, templates))
def test_a_fitted_window_needs_no_check(self):
"""Sin `bounds`, shortsmith ajusta la ventana a la ruta: están dentro
por construcción y no hay nada que comprobar."""
templates = {"track_map": {"type": "object"}}
validate_spec(spec_with({
"template": "track_map", "duration": 25.0,
"props": {"headline": "RUTA",
"waypoints": [{"label": "A", "lat": 2.0, "lon": -1.0}]}}),
templates)
def test_two_quotes_welded_into_one_field_are_rejected(self):
"""El peor fallo del sistema: una frase que nadie dijo, hecha con
material auténtico y firmada por alguien con nombre y apellidos.
El comprobador de fundamento une las líneas antes de buscarlas, y eso
caza la forma con la que falló Socorro. Pero la unión se derrota
poniéndole a cada línea su propio par de comillas: entonces son dos
citas, cada una fundamentada por su lado, y pasa en silencio. Caso real
de la sesión 162.
"""
errors = errors_of(spec_with(shot(
"scale_bars", duration=25.0,
bars=[{"label": "A", "value": 1}],
quote=["“GRAY, LIKE ZINC”", "“TWO SAUCERS GLUED AT THE RIM”"])))
assert any("es UNA cita partida en líneas" in e for e in errors)
def test_a_span_broken_across_lines_is_the_normal_case(self):
"""La forma buena: abre en la primera línea y cierra en la última. Es
como está escrito el ejemplo de referencia, así que rechazarla rompería
el propio prompt."""
validate_spec(spec_with(shot(
"scale_bars", duration=25.0,
bars=[{"label": "A", "value": 1}],
quote=["“TWICE THE SIZE OF", "AN AIRCRAFT CARRIER”"])), TEMPLATES)
def test_a_quote_without_marks_is_left_alone(self):
validate_spec(spec_with(shot(
"scale_bars", duration=25.0,
bars=[{"label": "A", "value": 1}],
quote=["LANDING TRACE", "CONFIRMED BY LAB"])), TEMPLATES)
def test_the_reference_example_survives_the_quote_rule(self):
"""Si el ejemplo no pasara su propia regla, volveríamos a enseñar el
fallo que la regla intenta evitar."""
spec = json.loads(EXAMPLE.read_text())
permissive = {name: {"type": "object"} for name in
{s["template"] for s in spec["shots"]}}
validate_spec(spec, permissive)
def test_a_bad_schema_hides_the_cross_field_noise(self):
"""Con props mal tipadas, la regla cruzada diría algo que no es el fallo
real y taparía el que lo es."""
# Tres barras (la regla cruzada dispararía) pero a las que les falta el
# campo obligatorio: el fallo que hay que arreglar es ese, no el hueco
# de la cita, que puede desaparecer al arreglarlo.
errors = errors_of(spec_with(shot(
"scale_bars", duration=25.0,
bars=[{"label": "A"}, {"label": "B"}, {"label": "C"}],
quote=["“X”"])))
assert any("value: falta y es obligatorio" in e for e in errors)
assert not any("leave no room" in e for e in errors)
def test_silence_window_cannot_run_past_the_end(): def test_silence_window_cannot_run_past_the_end():
bad = spec_with(shot(duration=25.0)) bad = spec_with(shot(duration=25.0))
bad["audio"] = {"preset": "sonar", "silence": [[20.0, 40.0]]} bad["audio"] = {"preset": "sonar", "silence": [[20.0, 40.0]]}
@@ -215,10 +345,10 @@ def test_extra_root_key_is_rejected():
def test_editorial_notes_flag_both_ends(): def test_editorial_notes_flag_both_ends():
assert "queda corto" in editorial_notes(spec_with(shot(duration=8.0)))[0] assert "queda corto" in editorial_notes(spec_with(shot(duration=8.0)), TEMPLATES)[0]
assert "recorta" in editorial_notes( assert "recorta" in editorial_notes(
spec_with(*[shot(duration=10.0) for _ in range(6)]))[0] spec_with(*[shot(duration=10.0) for _ in range(6)]), TEMPLATES)[0]
assert editorial_notes(spec_with(shot(duration=30.0))) == [] assert editorial_notes(spec_with(shot(duration=30.0)), TEMPLATES) == []
def test_a_spec_that_is_not_even_a_dict(): def test_a_spec_that_is_not_even_a_dict():
@@ -231,10 +361,16 @@ def test_a_spec_that_is_not_even_a_dict():
def test_describe_templates_is_driven_by_what_the_service_publishes(): def test_describe_templates_is_driven_by_what_the_service_publishes():
text = describe_templates(TEMPLATES) text = describe_templates(TEMPLATES)
assert "radar_sweep:" in text and "scale_bars:" in text assert "radar_sweep:" in text and "scale_bars:" in text
assert "headline: string, no vacío, OBLIGATORIO" in text
assert "1-3 elementos" in text # los límites llegan al prompt assert "1-3 elementos" in text # los límites llegan al prompt
assert "ink, amber, amber_dark, muted, dim, red" in text assert "ink, amber, amber_dark, muted, dim, red" in text
assert "label: string, no vacío, OBLIGATORIO" in text # despliega los objetos anidados # Los dos presupuestos, y el que no tiene por dibujarse dentro de otro: el
# modelo apunta al primero, y el segundo es el que se le comprueba.
assert "headline: string, no vacío, CABE ~13 caracteres dibujados, " \
"ILEGIBLE por encima de 21, OBLIGATORIO" in text
assert "unit: string, se dibuja dentro de value_label, comparte su sitio" in text
# Y despliega los objetos anidados, con sus presupuestos de `$defs`.
assert "label: string, no vacío, CABE ~32 caracteres dibujados, " \
"ILEGIBLE por encima de 53, OBLIGATORIO" in text
def test_a_template_nobody_wrote_here_still_gets_described(): def test_a_template_nobody_wrote_here_still_gets_described():
@@ -283,15 +419,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():
@@ -309,22 +503,254 @@ def test_narration_that_overshoots_the_target_is_flagged_as_narration():
# 3 shots de 8 s = 24 s declarados, dentro del objetivo y sin avisos. Con # 3 shots de 8 s = 24 s declarados, dentro del objetivo y sin avisos. Con
# ~21 s de voz cada uno se van a 65 s: sin la estimación, silencio absoluto. # ~21 s de voz cada uno se van a 65 s: sin la estimación, silencio absoluto.
quiet = spec_with(*[shot(duration=8.0) for _ in range(3)]) quiet = spec_with(*[shot(duration=8.0) for _ in range(3)])
assert editorial_notes(quiet) == [] assert editorial_notes(quiet, TEMPLATES) == []
doc = copy.deepcopy(quiet) doc = copy.deepcopy(quiet)
for s in doc["shots"]: for s in doc["shots"]:
s["narration"] = "A" * 300 s["narration"] = "A" * 300
note = editorial_notes(doc)[0] note = editorial_notes(doc, TEMPLATES)[0]
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, TEMPLATES) == []
assert editorial_notes(pasado, TEMPLATES)
# 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, TEMPLATES)[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)), TEMPLATES) == []
assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION
- TARGET_GRACE - 0.1)), TEMPLATES)
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, TEMPLATES)[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)]), TEMPLATES)[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)]), TEMPLATES)[0]
assert "duración total" in note and "estimada" not in note assert "duración total" in note and "estimada" not in note
# --- el gancho: lo que se ve en el primer plano ------------------------------
# Los titulares de abajo son los reales de los once casos distintos que el bot ha
# escrito. Se copian aquí en vez de generarlos porque el detector no se juzga
# contra ejemplos cómodos: se juzga contra lo que el modelo escribe de verdad.
#: Plantilla sin `headline`: su contenido se escribe a máquina más abajo y la
#: banda superior del fotograma se queda en el fondo todo el plano.
SIN_TITULAR = {"document_quote": {
"type": "object", "required": ["quote_a"],
"properties": {"source": {"type": "string"},
"quote_a": {"type": "string", "minLength": 1}}}}
FECHAS = ["APRIL 24 1964", "APRIL 24, 1964", "APRIL 24", "8 JAN 1981",
"OCT 16 1957", "NOVEMBER 12", "1947"]
NO_FECHAS = ["62 CHILDREN", "62 WITNESSES", "23 HELICOPTERS", "TRIANGLES",
"RELEASE 05", "LANDING TRACE", "62"]
def opening(template="radar_sweep", templates=None, **props):
spec = spec_with({"template": template, "duration": 6.0, "props": props},
)
return opening_notes(spec, templates if templates is not None else TEMPLATES)
def test_the_opening_shot_has_to_draw_a_headline():
"""Medido sobre el renderizador a 5,5 s de plano: las cinco plantillas con
`headline` lo ponen a tinta plena en 0,33-0,40 s; en las tres que no lo
tienen la banda superior no pasa del fondo en todo el plano y su texto no
está entero hasta 2,6-2,8 s. Con 6,9 s de visionado medio eso es un tercio
de la ventana. Ocurrió de verdad el output 131 abrió con `document_quote`.
"""
note = opening(template="document_quote", templates=SIN_TITULAR,
quote_a="“NO CONTACT”")[0]
assert "document_quote" in note and "titular" in note
# Y la misma plantilla más adelante en el vídeo no molesta a nadie: lo que
# se juzga es la apertura, no el catálogo.
permisivo = {**TEMPLATES, **SIN_TITULAR}
tarde = spec_with(shot(), {"template": "document_quote", "duration": 6.0,
"props": {"quote_a": "“NO CONTACT”"}})
assert opening_notes(tarde, permisivo) == []
def test_the_rule_is_asked_of_the_schema_not_of_a_list_of_names():
"""El corte no puede ser una lista de plantillas escrita a mano: shortsmith
añade plantillas sin avisar a este repo. Una inventada CON titular abre sin
tocar nada, y una inventada SIN él queda cubierta igual."""
nuevas = {
"plantilla_nueva_con_titular": {
"type": "object", "required": ["headline"],
"properties": {"headline": {"type": "string"}}},
"plantilla_nueva_sin_titular": {
"type": "object", "properties": {"body": {"type": "string"}}},
}
assert opening(template="plantilla_nueva_con_titular", templates=nuevas,
headline="62 CHILDREN") == []
assert opening(template="plantilla_nueva_sin_titular", templates=nuevas,
body="lo que sea")
@pytest.mark.parametrize("headline", FECHAS)
def test_a_headline_that_is_only_a_date_is_flagged(headline):
"""Cinco de los once casos abrieron así, con el sitio ya puesto en el
`subline` de debajo: el texto más grande del vídeo gastado en metadatos."""
note = opening(headline=headline)[0]
assert "fecha" in note and headline in note
@pytest.mark.parametrize("headline", NO_FECHAS)
def test_a_figure_is_not_mistaken_for_a_date(headline):
"""El control, y no es un adorno: sin él, un detector que marcara cualquier
titular con un número dentro pasaría todos los casos de arriba y estaría
rechazando exactamente los titulares que se quieren. "62" a secas es el que
lo decide es una cifra desnuda, que es el gancho ideal, no una fecha."""
assert opening(headline=headline) == []
def test_the_hook_note_comes_before_the_duration_one():
"""Los dos avisos pueden salir a la vez y quien los lee coge `[0]`. Primero
el gancho: un Short que se pasa cinco segundos se ve; uno cuya apertura no
dice nada no se ve entero de todas formas."""
largo = spec_with({"template": "radar_sweep", "duration": 90.0,
"props": {"headline": "8 JAN 1981"}})
notes = editorial_notes(largo, TEMPLATES)
assert len(notes) == 2
assert "fecha" in notes[0] and "objetivo" in notes[1]
# --- textos que se van a dibujar ilegibles -----------------------------------
# `x-fits` es guía blanda y tiene que serlo: el ejemplo de referencia se pasa de
# varios de sus propios presupuestos por uno o tres caracteres y se ve bien.
# `x-fits-hard` es la otra línea, y esa sí se comprueba antes de gastar el
# render — que es donde el aviso llegaba antes, con el vídeo ya pagado.
def one_shot(template="radar_sweep", **props):
return spec_with({"template": template, "duration": 25.0, "props": props})
def test_a_text_past_the_hard_budget_is_flagged():
"""El caso real, medido sobre Cash-Landrum: un texto pidió 36 px y se dibujó
a 20 en un fotograma de 1080 de ancho."""
largo = "ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH RADIATION EXPOSURE"
note = unreadable_notes(one_shot(headline=largo), TEMPLATES)[0]
assert "ILEGIBLE" in note
assert "shots.0.headline" in note and f"{len(largo)} caracteres" in note
assert "caben 21" in note
def test_a_text_between_the_two_budgets_is_left_alone():
"""El control, y es la mitad del diseño: entre `x-fits` y `x-fits-hard` el
texto sale un poco más pequeño y se ve bien. Avisar ahí sería gritar con
specs buenos, y un aviso que grita se acaba ignorando que es exactamente
cómo el de verdad grave se pasó meses sin que nadie actuara."""
assert len("3 RADARS TRACKING") > 13 # por encima del x-fits
assert len("3 RADARS TRACKING") < 21 # por debajo del ilegible
assert unreadable_notes(one_shot(headline="3 RADARS TRACKING"), TEMPLATES) == []
def test_the_budget_of_a_list_of_lines_is_per_line():
"""Una cita se dibuja partida en líneas, así que el presupuesto es por línea.
Medirlo sobre el texto unido avisaría de una cita bien partida en dos."""
dos = ["A QUOTE SPLIT WHERE IT HAS TO", "BREAK SO THAT IT FITS ON SCREEN"]
assert sum(len(x) for x in dos) > 49 and all(len(x) < 49 for x in dos)
ok = spec_with(shot("scale_bars", quote=dos))
assert unreadable_notes(ok, TEMPLATES) == []
larga = spec_with(shot("scale_bars", quote=["X" * 60]))
assert "shots.0.quote[0]" in unreadable_notes(larga, TEMPLATES)[0]
def test_the_budget_inside_a_list_of_objects_is_found():
"""Los presupuestos de un submodelo viven en `$defs`, y saltárselos fue justo
el agujero por el que shortsmith se pasó meses sin medir nueve campos."""
doc = spec_with(shot("scale_bars",
bars=[{"label": "BOEING 747", "value": 232},
{"label": "X" * 60, "value": 100}]))
note = unreadable_notes(doc, TEMPLATES)[0]
assert "shots.0.bars[1].label" in note
def test_a_field_drawn_inside_another_is_not_judged_alone():
"""`unit` no tiene presupuesto propio: se dibuja dentro de la cadena de
`value_label`. Juzgarlo solo sería inventarse un límite que el contrato dice
expresamente que no existe."""
doc = spec_with(shot("scale_bars",
bars=[{"label": "BOEING 747", "value": 232,
"unit": "X" * 60}]))
assert unreadable_notes(doc, TEMPLATES) == []
def test_an_old_contract_without_the_hard_budget_says_nothing():
"""shortsmith publicó `x-fits-hard` en 289d50e. Contra uno anterior esto no
puede inventarse el número: se calla, y de que el contrato lo traiga se
encarga `test_shortsmith_live.py`, que es quien habla con el servicio."""
viejo = {"radar_sweep": {"type": "object", "required": ["headline"],
"properties": {"headline": {"type": "string",
"x-fits": 13}}}}
assert unreadable_notes(one_shot(headline="X" * 90), viejo) == []
def test_the_defects_come_before_the_duration():
"""Los tres avisos pueden salir juntos y quien los lee coge `[0]`. Primero lo
que está roto, después lo que está fuera de objetivo."""
doc = spec_with({"template": "radar_sweep", "duration": 90.0,
"props": {"headline": "8 JAN 1981",
"subline": "X" * 60}})
notes = editorial_notes(doc, TEMPLATES)
assert len(notes) == 3
assert "fecha" in notes[0] and "ILEGIBLE" in notes[1] and "objetivo" in notes[2]
def test_the_prompt_carries_how_much_text_actually_fits(): def test_the_prompt_carries_how_much_text_actually_fits():
"""`x-fits` es el único límite que nada rechaza: si no llega al prompt, el """`x-fits` es el único límite que nada rechaza: si no llega al prompt, el
modelo escribe una cita de 58 caracteres para un hueco de 16.""" modelo escribe una cita de 58 caracteres para un hueco de 16."""
+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 == []