Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a38c2e1eca |
@@ -91,23 +91,6 @@ jobs:
|
||||
fi
|
||||
echo "OK: chemavx/researchowl:${TAG} verified in registry"
|
||||
|
||||
- name: Run the suite inside the image
|
||||
run: |
|
||||
TAG=${{ steps.tag.outputs.TAG }}
|
||||
BASE=gitea.gitea.svc.cluster.local:3000/chemavx/researchowl:${TAG}
|
||||
# Contra la imagen recién construida, no contra el árbol de fuentes:
|
||||
# la suite tiene que ver las librerías que se van a desplegar. Antes
|
||||
# corría sólo en el portátil, con otro anthropic que el del pod, y por
|
||||
# eso un kwarg que el SDK ya no aceptaba pasó meses sin que nada lo
|
||||
# dijera.
|
||||
docker buildx build \
|
||||
--builder ci-builder \
|
||||
--load \
|
||||
--build-arg BASE=${BASE} \
|
||||
-t researchowl-test:${TAG} \
|
||||
-f Dockerfile.test .
|
||||
docker run --rm researchowl-test:${TAG}
|
||||
|
||||
- name: Update k8s manifests
|
||||
run: |
|
||||
pip3 install pyyaml -q
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# La imagen de runtime más los corredores de la suite.
|
||||
#
|
||||
# Parte de la imagen que se acaba de construir, no del árbol de fuentes, para
|
||||
# que los tests vean las MISMAS librerías que se despliegan. Sin esto la suite
|
||||
# corría en el portátil contra anthropic 0.102.0 mientras el pod ejecutaba
|
||||
# 1.2.0, y así es como un `temperature` que el SDK ya no aceptaba vivió meses
|
||||
# de reintentos que nunca ocurrieron.
|
||||
#
|
||||
# `tests/` no hace falta copiarlo: el Dockerfile de runtime hace `COPY . .` y ya
|
||||
# viaja dentro.
|
||||
ARG BASE
|
||||
FROM ${BASE}
|
||||
|
||||
RUN pip install --no-cache-dir pytest==9.0.3 pytest-asyncio==1.4.0
|
||||
|
||||
# `settings.telegram_bot_token` es obligatorio y en la imagen no hay `.env`:
|
||||
# sin esto la suite no llega ni a recolectar. Es un valor de mentira a propósito
|
||||
# — ningún test habla con Telegram.
|
||||
ENV TELEGRAM_BOT_TOKEN=ci-dummy
|
||||
ENV PYTEST_ADDOPTS="-p no:cacheprovider"
|
||||
|
||||
CMD ["python", "-m", "pytest", "tests", "-q"]
|
||||
@@ -86,64 +86,6 @@ 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
|
||||
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.
|
||||
- **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:
|
||||
|
||||
@@ -256,59 +198,6 @@ Available any time, zero researchowl changes, because the contract is fetched li
|
||||
hook → evidence → unresolved question → CTA arc. Costs one commit, no deploy risk
|
||||
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.33–0.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.6–2.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
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
# Los corredores de la suite. NO van en la imagen de runtime: los instala
|
||||
# `Dockerfile.test`, que parte de la imagen ya construida para que los tests
|
||||
# vean EXACTAMENTE las librerías que se despliegan.
|
||||
#
|
||||
# Pinneados por la misma razón que el resto: un suelo deja que una versión
|
||||
# distinta entre sin commit, y entonces "pasa en mi máquina" deja de significar
|
||||
# nada. Ver el comentario de `anthropic` en requirements.txt.
|
||||
# Solo para ejecutar la suite en local — NO va en la imagen (el Dockerfile
|
||||
# instala requirements.txt y nada más; la CI no corre pytest).
|
||||
# pip install -r requirements-dev.txt && make test
|
||||
-r requirements.txt
|
||||
pytest==9.0.3
|
||||
pytest-asyncio==1.4.0
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.24
|
||||
|
||||
+3
-14
@@ -10,7 +10,7 @@ aiohttp==3.14.1
|
||||
|
||||
# Scraping
|
||||
beautifulsoup4==4.15.0
|
||||
lxml==6.1.3
|
||||
lxml==5.4.0
|
||||
trafilatura==1.12.2
|
||||
youtube-transcript-api==0.6.3
|
||||
pdfplumber==0.11.10
|
||||
@@ -23,22 +23,11 @@ aiosqlite==0.22.1
|
||||
|
||||
# Processing
|
||||
tiktoken==0.7.0
|
||||
numpy==1.26.4
|
||||
numpy==2.5.2
|
||||
scikit-learn==1.5.1
|
||||
|
||||
# Claude API (scoring)
|
||||
#
|
||||
# Pinneado, no con suelo — y esta línea es la razón por la que el resto lo está.
|
||||
# Era `>=0.40.0`, y con eso entró **anthropic 1.2.0** en producción sin un solo
|
||||
# commit: una versión mayor que quitó `temperature` de `messages.create` (los
|
||||
# parámetros de muestreo se fueron a `output_config`). El reintento estricto del
|
||||
# SEO llevaba desde entonces muriendo con un TypeError que un `except` convertía
|
||||
# en aviso. Nadie lo vio porque la suite corría en otra máquina con 0.102.0.
|
||||
#
|
||||
# 1.2.0 es lo que había instalado en el pod el 2026-09-02, medido con
|
||||
# `pip freeze` dentro del contenedor. Para subirlo: cambiar aquí, y que la suite
|
||||
# de la CI —que ahora corre DENTRO de la imagen— diga si vale.
|
||||
anthropic==1.2.0
|
||||
anthropic>=0.40.0
|
||||
|
||||
# PDF export
|
||||
markdown==3.10.2
|
||||
|
||||
+7
-28
@@ -21,9 +21,7 @@ from telegram.ext import (
|
||||
from telegram.constants import ParseMode
|
||||
|
||||
from src.config import settings
|
||||
from src.db.database import (
|
||||
get_db, close_db, ResearchDB, ResearchStatus, OutputType, RETENTION_DAYS,
|
||||
)
|
||||
from src.db.database import get_db, close_db, ResearchDB, ResearchStatus, OutputType
|
||||
from src.scraper.exhaustive import ExhaustiveScraper
|
||||
from src.processor.processor import OllamaClient, ContentProcessor
|
||||
from src.generator.generator import OutputGenerator
|
||||
@@ -856,13 +854,7 @@ def _upload_message(video, metadata: dict, article_url: Optional[str]) -> str:
|
||||
lines = [f"🎬 {video.title}", "", f"Revisar y publicar: {video.studio_url}",
|
||||
f"Enlace del vídeo: {video.watch_url}", ""]
|
||||
|
||||
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":
|
||||
if video.privacy_status == "private":
|
||||
lines.append(
|
||||
"🔒 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 "
|
||||
@@ -871,14 +863,6 @@ def _upload_message(video, metadata: dict, article_url: Optional[str]) -> str:
|
||||
else:
|
||||
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:
|
||||
lines.append("⚠️ Pediste otra visibilidad y YouTube la forzó a privada. "
|
||||
"Es exactamente la firma de ese candado.")
|
||||
@@ -1348,12 +1332,8 @@ async def _purge_on_startup(app: Application) -> None:
|
||||
db_conn = await get_db()
|
||||
try:
|
||||
db = ResearchDB(db_conn)
|
||||
result = await db.purge_old_data(RETENTION_DAYS)
|
||||
# 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()):
|
||||
result = await db.purge_old_sessions(30)
|
||||
if result["sessions"] > 0:
|
||||
logger.info("Startup purge done", **result)
|
||||
except Exception as e:
|
||||
logger.warning("Startup purge failed — bot continues", error=str(e))
|
||||
@@ -1592,7 +1572,7 @@ async def cmd_purge(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
args = ctx.args or []
|
||||
|
||||
if not args:
|
||||
days = RETENTION_DAYS
|
||||
days = 30
|
||||
else:
|
||||
try:
|
||||
days = int(args[0])
|
||||
@@ -1606,8 +1586,7 @@ async def cmd_purge(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
return
|
||||
if days == 0 and not (len(args) >= 2 and args[1] == "confirm"):
|
||||
await update.message.reply_text(
|
||||
"⚠️ Esto borrará *todos* los outputs y *todas* las sesiones "
|
||||
"completadas.\n"
|
||||
"⚠️ Esto borrará *todas* las sesiones completadas.\n"
|
||||
"Envía `/purge 0 confirm` para confirmar.",
|
||||
parse_mode=ParseMode.MARKDOWN
|
||||
)
|
||||
@@ -1616,7 +1595,7 @@ async def cmd_purge(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
db_conn = await get_db()
|
||||
try:
|
||||
db = ResearchDB(db_conn)
|
||||
result = await db.purge_old_data(days)
|
||||
result = await db.purge_old_sessions(days)
|
||||
await update.message.reply_text(
|
||||
f"🗑️ Purged: {result['sessions']} sessions, "
|
||||
f"{result['sources']} sources, "
|
||||
|
||||
+22
-84
@@ -12,18 +12,6 @@ from src.config import settings
|
||||
|
||||
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):
|
||||
RUNNING = "running"
|
||||
@@ -660,75 +648,41 @@ class ResearchDB:
|
||||
|
||||
# --- Maintenance ---
|
||||
|
||||
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.
|
||||
"""
|
||||
async def purge_old_sessions(self, max_age_days: int = 30) -> dict:
|
||||
await self.db.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
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(
|
||||
"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)",
|
||||
"SELECT id FROM research_sessions WHERE created_at < ? AND status != 'running'",
|
||||
(threshold,)
|
||||
)
|
||||
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:
|
||||
counts["shorts"] += self._drop_short(sid)
|
||||
# El MP4 del Short vive en disco (los blobs en SQLite hacen
|
||||
# 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(
|
||||
"DELETE FROM source_contents WHERE source_id IN (SELECT id FROM sources WHERE session_id = ?)",
|
||||
(sid,)
|
||||
)
|
||||
cur = await self.db.execute("DELETE FROM chunks WHERE session_id = ?", (sid,))
|
||||
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,))
|
||||
counts["api_usage"] += cur.rowcount
|
||||
cur = await self.db.execute("DELETE FROM sources WHERE session_id = ?", (sid,))
|
||||
@@ -737,22 +691,6 @@ class ResearchDB:
|
||||
counts["sessions"] += cur.rowcount
|
||||
|
||||
await self.db.commit()
|
||||
logger.info("Purga por antigüedad", days=max_age_days, **counts)
|
||||
logger.info("Purged sessions older than days",
|
||||
sessions=counts["sessions"], days=max_age_days)
|
||||
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
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
"caption": "CONTACT HOLDS RELATIVE POSITION",
|
||||
"turn_deg": 360
|
||||
},
|
||||
"narration": "He tried to shake it. Full circle, steep descent, and it stayed there."
|
||||
"narration": "He tried to shake it. Full circle, steep descent, and it was still there."
|
||||
},
|
||||
{
|
||||
"template": "signal_strips",
|
||||
@@ -207,7 +207,7 @@
|
||||
"url": "THEEXCLUSIONZONE.COM",
|
||||
"show_mark": true
|
||||
},
|
||||
"narration": "The file was never closed. It was left where anyone can read it."
|
||||
"narration": "The file was never closed. It was filed, and left where anyone can read it."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+4
-18
@@ -32,9 +32,7 @@ from src.generator.shortsmith import (
|
||||
ShortsmithUnavailable,
|
||||
)
|
||||
from src.generator.shortspec import ShortSpecWriter, SpecWriteFailed
|
||||
from src.generator.spec_contract import (
|
||||
BASELINE_LIMITS, SpecInvalid, editorial_notes, validate_spec,
|
||||
)
|
||||
from src.generator.spec_contract import SpecInvalid, editorial_notes, validate_spec
|
||||
from src.llm import get_anthropic_client
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -150,16 +148,6 @@ class ShortProducer:
|
||||
logger.warning("GET /audio falló — paleta base", error=str(e))
|
||||
return dict(BASELINE_PRESETS)
|
||||
|
||||
async def _limits(self) -> dict:
|
||||
"""El sobre del contrato, en vivo. Tampoco tumba nada: sin él se valida
|
||||
contra el suelo, que es más estrecho que cualquier shortsmith y por eso
|
||||
no puede dejar pasar un spec que el servidor fuera a rechazar."""
|
||||
try:
|
||||
return await self.client.limits()
|
||||
except Exception as e:
|
||||
logger.warning("GET /limits falló — sobre base", error=str(e))
|
||||
return dict(BASELINE_LIMITS)
|
||||
|
||||
# --- pipeline -----------------------------------------------------------
|
||||
|
||||
async def produce(self, session_id: int,
|
||||
@@ -198,7 +186,6 @@ class ShortProducer:
|
||||
# ofrece la paleta base y el render sale igual.
|
||||
templates = await self.client.templates()
|
||||
presets = await self._presets()
|
||||
limits = await self._limits()
|
||||
|
||||
# 3. El spec.
|
||||
started = time.monotonic()
|
||||
@@ -206,7 +193,7 @@ class ShortProducer:
|
||||
writer = ShortSpecWriter(
|
||||
llm_call, templates,
|
||||
refresh_templates=lambda: self.client.templates(refresh=True),
|
||||
presets=presets, limits=limits)
|
||||
presets=presets)
|
||||
try:
|
||||
written = await writer.write(
|
||||
topic, context, article_url=result.article_url,
|
||||
@@ -282,16 +269,15 @@ class ShortProducer:
|
||||
# de retoque para el que existe este camino.
|
||||
templates = await self.client.templates()
|
||||
presets = await self._presets()
|
||||
limits = await self._limits()
|
||||
try:
|
||||
validate_spec(spec, templates, presets=presets, limits=limits)
|
||||
validate_spec(spec, templates, presets=presets)
|
||||
except SpecInvalid as e:
|
||||
result.failure = ("El spec editado no pasa el contrato: "
|
||||
+ "; ".join(e.errors[:6]))
|
||||
logger.warning("Rerender rechazado por el contrato",
|
||||
session_id=session_id, errors=e.errors[:6])
|
||||
return result
|
||||
result.notes = editorial_notes(spec, templates)
|
||||
result.notes = editorial_notes(spec)
|
||||
|
||||
result.title = spec.get("meta", {}).get("title", topic)
|
||||
result.duration_s = sum(s.get("duration", 0) for s in spec["shots"])
|
||||
|
||||
@@ -24,7 +24,6 @@ import aiohttp
|
||||
import structlog
|
||||
|
||||
from src.config import settings, SAFE_ACCEPT_ENCODING
|
||||
from src.generator.spec_contract import BASELINE_LIMITS
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@@ -98,7 +97,6 @@ class JobResult:
|
||||
#: mitad de una run.
|
||||
_templates_cache: dict[str, dict[str, Any]] = {}
|
||||
_presets_cache: dict[str, dict[str, str]] = {}
|
||||
_limits_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
class ShortsmithClient:
|
||||
@@ -180,39 +178,6 @@ class ShortsmithClient:
|
||||
logger.info("shortsmith audio presets fetched", presets=sorted(data))
|
||||
return data
|
||||
|
||||
async def limits(self, refresh: bool = False) -> dict[str, Any]:
|
||||
"""El sobre del contrato: resoluciones, fps, duraciones y topes.
|
||||
|
||||
La tercera mitad del contrato vivo, y la que faltaba. Las props se leen
|
||||
de `/templates` desde el principio y los presets de `/audio`; el sobre
|
||||
se copiaba a mano en `spec_contract.BASELINE_LIMITS`, y la copia derivó
|
||||
en cuanto shortsmith quitó 1920x1080. Un 404 es un shortsmith anterior
|
||||
al endpoint y devuelve el suelo, sin error — fallbacks siempre.
|
||||
"""
|
||||
if not refresh and self.base_url in _limits_cache:
|
||||
return _limits_cache[self.base_url]
|
||||
try:
|
||||
async with self._session(30) as sess:
|
||||
async with sess.get(f"{self.base_url}/limits") as resp:
|
||||
if resp.status == 404:
|
||||
data = dict(BASELINE_LIMITS)
|
||||
elif resp.status != 200:
|
||||
body = await resp.text()
|
||||
raise ShortsmithError(
|
||||
f"GET /limits devolvió {resp.status}: {body[:200]}")
|
||||
else:
|
||||
payload = await resp.json()
|
||||
data = payload if isinstance(payload, dict) and payload \
|
||||
else dict(BASELINE_LIMITS)
|
||||
except aiohttp.ClientError as e:
|
||||
raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e
|
||||
except asyncio.TimeoutError as e:
|
||||
raise ShortsmithUnavailable("shortsmith no respondió a /limits") from e
|
||||
_limits_cache[self.base_url] = data
|
||||
logger.info("shortsmith limits fetched",
|
||||
resolutions=data.get("resolutions"))
|
||||
return data
|
||||
|
||||
async def render(self, spec: dict[str, Any]) -> str:
|
||||
"""`POST /render`. Devuelve el job_id. 422 -> ShortsmithRejected."""
|
||||
try:
|
||||
|
||||
+26
-179
@@ -22,9 +22,7 @@ from typing import Any, Awaitable, Callable, Optional
|
||||
import structlog
|
||||
|
||||
from src.generator.spec_contract import (
|
||||
SpecInvalid, describe_templates, editorial_notes, estimated_duration,
|
||||
defect_notes, max_words_in, validate_spec, NARRATION_ROUNDED_PAD,
|
||||
NARRATION_SENTENCE_SILENCE, NARRATION_WORDS_PER_SECOND,
|
||||
SpecInvalid, describe_templates, editorial_notes, validate_spec,
|
||||
TARGET_MAX_DURATION, TARGET_MIN_DURATION,
|
||||
)
|
||||
|
||||
@@ -34,42 +32,6 @@ logger = structlog.get_logger()
|
||||
#: que arreglar es el prompt, no este número.
|
||||
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"
|
||||
|
||||
__all__ = ["ShortSpecWriter", "SpecResult", "SpecWriteFailed", "NARRATIVE_SHAPES"]
|
||||
@@ -122,31 +84,18 @@ exists, and a prop name that is not listed is a parse error, not a nuance.
|
||||
|
||||
# 3. Rules
|
||||
|
||||
- **Total duration {target_min:.0f}-{target_max:.0f} seconds, and the voice is \
|
||||
what decides it, not the durations you declare.** The contract allows 180; that \
|
||||
is a ceiling, not a target. A narrated shot runs as long as its line takes to \
|
||||
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.
|
||||
- Total duration 20-45 seconds. The contract allows 180; that is a ceiling, \
|
||||
not a target. Aim for {target_min:.0f}-{target_max:.0f}.
|
||||
- Typically 6-9 shots. Give a shot the seconds its content needs to be read: \
|
||||
a card with four rows needs longer than a headline.
|
||||
- Every string is drawn as given. Write them the way they should appear: \
|
||||
SHORT, UPPERCASE, no trailing punctuation. A headline is 2-5 words.
|
||||
- **"CABE ~N caracteres dibujados" is a width, not a character count you can \
|
||||
argue with.** Nothing rejects a longer string: the renderer shrinks the type \
|
||||
until it fits, so a string at twice its budget is drawn at a fraction of its \
|
||||
size and ends up the smallest text on a frame it was supposed to dominate. \
|
||||
- **"CABE ~N caracteres dibujados" is a width, and it is the one limit nothing \
|
||||
will catch for you.** Nothing rejects a longer string: the renderer shrinks the \
|
||||
type until it fits, so a string at twice its budget is drawn at a fraction of \
|
||||
its 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, \
|
||||
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.
|
||||
- Colours are palette names ({colors}) — never hex.
|
||||
- Quotes carry the typographic quote marks: “SPLIT RADAR IMAGE”, with U+201C \
|
||||
@@ -167,13 +116,9 @@ 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 \
|
||||
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.
|
||||
- **Keep a line to {words_per_line} words, hard stop at {words_per_line_max}.** \
|
||||
That is the example's own average, and the hard stop is not a style preference \
|
||||
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.
|
||||
- Keep a line under 25 words. Long sentences lose the listener and stretch the \
|
||||
shot; the renderer will not cut your voice off, it will make the shot longer \
|
||||
instead, and a Short that drifts past 45 seconds is a Short people leave.
|
||||
- **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 \
|
||||
says what that altitude meant, not the number again.
|
||||
@@ -187,59 +132,15 @@ 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 \
|
||||
with one narrated shot out of eight is not a Short with a voice, it is a Short \
|
||||
that forgot to speak.
|
||||
- **Give every narrated shot enough time for its own line, and work it out \
|
||||
rather than guessing.** The voice reads about {words_per_second:g} words a \
|
||||
second and pauses a quarter second at every full stop, so **count the words AND \
|
||||
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.
|
||||
- Narration costs seconds. A shot is never cut short to fit the voice — it \
|
||||
grows instead — so a line that needs six seconds in a four-second shot pushes \
|
||||
your whole total past the target. Write the line, then give the shot the time \
|
||||
the line actually takes.
|
||||
- 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 \
|
||||
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}
|
||||
|
||||
# 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
|
||||
|
||||
Every figure, quote, date, and proper noun in your spec must appear in the \
|
||||
@@ -458,36 +359,6 @@ def _format_notes(notes: list[str]) -> str:
|
||||
"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.
|
||||
LLMCall = Callable[[str, str], Awaitable[str]]
|
||||
|
||||
@@ -495,13 +366,9 @@ LLMCall = Callable[[str, str], Awaitable[str]]
|
||||
class ShortSpecWriter:
|
||||
def __init__(self, llm_call: LLMCall, templates: dict[str, dict],
|
||||
refresh_templates: Optional[Callable[[], Awaitable[dict]]] = None,
|
||||
presets: Optional[dict[str, str]] = None,
|
||||
limits: Optional[dict] = None):
|
||||
presets: Optional[dict[str, str]] = None):
|
||||
self.llm_call = llm_call
|
||||
self.templates = templates
|
||||
#: El sobre vivo de `GET /limits`. Sin él se valida contra el suelo,
|
||||
#: que nunca acepta nada que un shortsmith viejo fuera a rechazar.
|
||||
self.limits = limits
|
||||
#: Se vuelve a pedir el contrato si una validación falla: el
|
||||
#: renderizador puede haberse actualizado a mitad de la run.
|
||||
self.refresh_templates = refresh_templates
|
||||
@@ -521,13 +388,6 @@ class ShortSpecWriter:
|
||||
domain=domain,
|
||||
target_min=TARGET_MIN_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(),
|
||||
article=article,
|
||||
context=context,
|
||||
@@ -544,8 +404,6 @@ class ShortSpecWriter:
|
||||
#: 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.
|
||||
best: Optional[SpecResult] = None
|
||||
#: Reescrituras ya gastadas en notas editoriales.
|
||||
note_rounds = 0
|
||||
|
||||
for attempt in range(1, MAX_ATTEMPTS + 1):
|
||||
if on_progress and attempt > 1:
|
||||
@@ -564,8 +422,7 @@ class ShortSpecWriter:
|
||||
|
||||
last_spec = spec
|
||||
try:
|
||||
validate_spec(spec, self.templates, presets=self.presets,
|
||||
limits=self.limits)
|
||||
validate_spec(spec, self.templates, presets=self.presets)
|
||||
except SpecInvalid as e:
|
||||
history.append(e.errors)
|
||||
feedback = _format_errors(e.errors)
|
||||
@@ -580,36 +437,26 @@ class ShortSpecWriter:
|
||||
error=str(refresh_err))
|
||||
continue
|
||||
|
||||
notes = editorial_notes(spec, self.templates)
|
||||
notes = editorial_notes(spec)
|
||||
result = SpecResult(spec=spec, attempts=attempt, notes=notes,
|
||||
history=list(history))
|
||||
if not notes:
|
||||
logger.info("short spec válido", attempts=attempt,
|
||||
shots=len(spec.get("shots", [])), notes=0)
|
||||
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
|
||||
if notes and attempt < MAX_ATTEMPTS:
|
||||
# Nota editorial, no violación del contrato: se comenta una vez
|
||||
# y, si insiste, se renderiza igual.
|
||||
best = best or result
|
||||
history.append(notes)
|
||||
feedback = _format_notes(notes)
|
||||
continue
|
||||
|
||||
logger.info("short spec válido pero fuera de objetivo",
|
||||
attempts=attempt, shots=len(best.spec.get("shots", [])),
|
||||
off_target=round(_off_target(best.spec), 1))
|
||||
best.attempts = attempt
|
||||
best.history = history
|
||||
return best
|
||||
logger.info("short spec válido", attempts=attempt,
|
||||
shots=len(spec.get("shots", [])), notes=len(notes))
|
||||
return result
|
||||
|
||||
if best is not None:
|
||||
# Un intento anterior sí cumplía el contrato. Vale más un Short
|
||||
# largo que ningún Short.
|
||||
logger.info("short spec: se recupera el intento válido anterior",
|
||||
attempts=MAX_ATTEMPTS, notes=best.notes)
|
||||
best.attempts = MAX_ATTEMPTS
|
||||
best.history = history
|
||||
return best
|
||||
|
||||
|
||||
+67
-569
@@ -1,6 +1,6 @@
|
||||
"""El contrato del spec, leído — no copiado — de shortsmith.
|
||||
|
||||
Dos cosas, guiadas por lo que shortsmith publica:
|
||||
Dos cosas, las dos guiadas por lo que publica `GET /templates`:
|
||||
|
||||
* `describe_templates()` — el contrato en prosa compacta, para meterlo en el
|
||||
prompt. Añadir una plantilla en shortsmith la deja descrita aquí sola.
|
||||
@@ -10,18 +10,13 @@ Dos cosas, guiadas por lo que shortsmith publica:
|
||||
comprobador de fundamento va entre la validación y el render: mandar el spec
|
||||
a `POST /render` para validarlo ya encolaría el render.
|
||||
|
||||
**Nada del contrato se escribe aquí.** Las props se validan contra el esquema de
|
||||
`GET /templates`, los presets contra `GET /audio` y el sobre — resolución, fps,
|
||||
duraciones, topes — contra `GET /limits`. De cada uno hay una base local, pero
|
||||
sólo como suelo para cuando el servicio no conteste, nunca como fuente.
|
||||
|
||||
El sobre **sí** estuvo escrito a mano, y por eso hay tres endpoints en vez de
|
||||
dos: la copia decía «pequeña y estable» hasta que shortsmith quitó 1920x1080 y
|
||||
esta lista siguió aceptándolo días. Ver `BASELINE_LIMITS`.
|
||||
|
||||
Las reglas de pydantic que cruzan campos (los límites de MapBounds, "3 barras no
|
||||
dejan sitio para una cita") NO se replican: las coge el 422 del servidor al
|
||||
enviar, y ese camino también está cubierto.
|
||||
La mitad de props del contrato NO vive aquí: se valida contra el esquema
|
||||
recibido. Lo único escrito a mano es el sobre (version/meta/audio/shots), que
|
||||
es pequeño, estable, y está anotado con la regla equivalente de
|
||||
`shortsmith/src/shortsmith/spec.py`. Las reglas de pydantic que cruzan campos
|
||||
(los límites de MapBounds, "3 barras no dejan sitio para una cita") NO se
|
||||
replican: las coge el 422 del servidor al enviar, y ese camino también está
|
||||
cubierto.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -32,76 +27,26 @@ __all__ = [
|
||||
"SpecInvalid",
|
||||
"validate_spec",
|
||||
"editorial_notes",
|
||||
"opening_notes",
|
||||
"unreadable_notes",
|
||||
"defect_notes",
|
||||
"estimated_duration",
|
||||
"spoken_seconds",
|
||||
"sentence_count",
|
||||
"teachable_seconds",
|
||||
"max_words_in",
|
||||
"describe_templates",
|
||||
"TARGET_MIN_DURATION",
|
||||
"TARGET_MAX_DURATION",
|
||||
]
|
||||
|
||||
#: El sobre que aceptaba shortsmith antes de que `GET /limits` existiera, y el
|
||||
#: suelo cuando el endpoint no contesta.
|
||||
#:
|
||||
#: Esto **era** un espejo a mano, y se rompió como se rompen los espejos: el
|
||||
#: 2026-09-01 shortsmith quitó 1920x1080 (`358eec9`) porque ninguna plantilla lo
|
||||
#: componía, y esta lista siguió aceptándolo. Un spec que aquí pasaba, allí se
|
||||
#: rechazaba: una generación pagada y tirada. Nadie se equivocó al escribirlo —
|
||||
#: ése es el argumento. Ahora el sobre se lee de `GET /limits` igual que las
|
||||
#: props se leen de `GET /templates`, y esto es sólo el suelo.
|
||||
#:
|
||||
#: Conservador a propósito, como `BASELINE_PRESET_NAMES`: nunca acepta nada que
|
||||
#: un shortsmith viejo fuera a rechazar. Por eso la resolución es la estrecha.
|
||||
BASELINE_LIMITS: dict[str, Any] = {
|
||||
"resolutions": [[1080, 1920]],
|
||||
"fps": [24, 25, 30, 60],
|
||||
"meta_id_pattern": r"^[a-z0-9][a-z0-9_-]{0,63}$",
|
||||
"shot_duration": {"min": 0.5, "max": 180.0},
|
||||
"total_duration": {"min": 5.0, "max": 180.0}, # 180 s: límite de Shorts
|
||||
"shots": {"min": 1, "max": 64},
|
||||
"max_narration_chars": 320,
|
||||
"max_silence_ranges": 16,
|
||||
}
|
||||
|
||||
|
||||
def _limit(limits: Optional[dict], *path: str) -> Any:
|
||||
"""Un número del sobre vivo, con el suelo debajo.
|
||||
|
||||
Se resuelve clave a clave y no de golpe: un shortsmith que publique un sobre
|
||||
a medias — o uno más nuevo con una clave que aquí todavía no se lee — deja
|
||||
las demás en su sitio en vez de tirar la validación entera al suelo.
|
||||
"""
|
||||
for source in (limits, BASELINE_LIMITS):
|
||||
node: Any = source
|
||||
for key in path:
|
||||
if not isinstance(node, dict) or key not in node:
|
||||
node = None
|
||||
break
|
||||
node = node[key]
|
||||
if node is not None:
|
||||
return node
|
||||
raise KeyError(f"el sobre no publica {'.'.join(path)}")
|
||||
|
||||
# Límites del sobre — espejo de shortsmith/spec.py.
|
||||
RESOLUTIONS = {(1080, 1920), (1920, 1080)}
|
||||
FPS_VALUES = {24, 25, 30, 60}
|
||||
MIN_SHOT_DURATION = 0.5
|
||||
MIN_TOTAL_DURATION = 5.0
|
||||
MAX_TOTAL_DURATION = 180.0 # límite duro de YouTube Shorts
|
||||
MAX_SHOTS = 64
|
||||
META_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
#: El objetivo editorial, que NO es el techo del contrato. 180 s es lo que el
|
||||
#: renderizador acepta; 20-45 s es lo que se ve entero.
|
||||
TARGET_MIN_DURATION = 20.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):
|
||||
"""El spec no cumple el contrato. `errors` son rutas + motivo, verbatim."""
|
||||
@@ -213,149 +158,26 @@ def _check_props(props: Any, schema: dict, path: str) -> list[str]:
|
||||
return _check(props, schema, path, schema.get("$defs", {}))
|
||||
|
||||
|
||||
# --- reglas que cruzan campos -----------------------------------------------
|
||||
# Espejo a mano de los `@model_validator` de shortsmith/spec.py, porque NO salen
|
||||
# en el JSON Schema publicado: pydantic no los serializa. Antes se dejaban al 422
|
||||
# del servidor, y eso costaba una generación entera — el 422 llega al RENDERIZAR,
|
||||
# cuando el bucle de reintentos ya ha terminado, así que el spec no se reescribe:
|
||||
# se devuelve a mano. La sesión 162 (Trans-en-Provence) se perdió justo así el
|
||||
# 2026-08-13. Comprobadas aquí, son un reintento normal.
|
||||
#
|
||||
# El texto del error es el de shortsmith palabra por palabra: al modelo se le
|
||||
# devuelve verbatim, y dos redacciones distintas del mismo fallo según dónde se
|
||||
# cace es exactamente el tipo de detalle que hace inútil un mensaje de error.
|
||||
|
||||
def _scale_bars_quote_needs_room(props: dict, path: str) -> list[str]:
|
||||
bars = props.get("bars")
|
||||
quote = props.get("quote")
|
||||
if isinstance(bars, list) and isinstance(quote, list) and len(bars) > 2 and quote:
|
||||
return [f"{path}: {len(bars)} bars leave no room for a quote — use at "
|
||||
"most 2 bars with a quote"]
|
||||
return []
|
||||
|
||||
|
||||
def _track_map_waypoints_inside(props: dict, path: str) -> list[str]:
|
||||
"""Una ventana fijada a mano tiene que contener la ruta que enmarca.
|
||||
|
||||
La proyección de shortsmith es lineal y sin recortar, así que un waypoint
|
||||
fuera de `bounds` no se dibuja en el borde: se dibuja donde lo ponga la
|
||||
aritmética, a veces fuera del encuadre. Se rechaza en vez de recortarse
|
||||
porque un mapa que miente sobre dónde pasó algo es peor que un spec que
|
||||
falla.
|
||||
"""
|
||||
bounds = props.get("bounds")
|
||||
waypoints = props.get("waypoints")
|
||||
if not isinstance(bounds, dict) or not isinstance(waypoints, list):
|
||||
return []
|
||||
try:
|
||||
lat_min, lat_max = float(bounds["lat_min"]), float(bounds["lat_max"])
|
||||
lon_min, lon_max = float(bounds["lon_min"]), float(bounds["lon_max"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return [] # incompleto o mal tipado: ya lo dijo el esquema
|
||||
|
||||
if lat_max <= lat_min or lon_max <= lon_min:
|
||||
return [f"{path}.bounds: map bounds must have max greater than min on "
|
||||
"both axes"]
|
||||
|
||||
outside = []
|
||||
for w in waypoints:
|
||||
if not isinstance(w, dict):
|
||||
continue
|
||||
try:
|
||||
lat, lon = float(w["lat"]), float(w["lon"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if not (lat_min <= lat <= lat_max and lon_min <= lon <= lon_max):
|
||||
outside.append(str(w.get("label", "?")))
|
||||
if outside:
|
||||
return [f"{path}: waypoints outside the map bounds: "
|
||||
f"{', '.join(outside)} — widen bounds or omit them to fit the "
|
||||
"window to the route"]
|
||||
return []
|
||||
|
||||
|
||||
#: Comillas de apertura. Una cita bien partida abre UNA vez.
|
||||
_OPENING_QUOTES = "“«„‟"
|
||||
|
||||
|
||||
def _quote_is_one_span(props: dict, path: str) -> list[str]:
|
||||
"""Una `quote` de varias líneas es UN span partido, no dos citas.
|
||||
|
||||
Esta regla NO es de shortsmith: allí renderiza igual. Es del canal, y es de
|
||||
las duras, porque el fallo que evita es el peor que tiene este sistema —
|
||||
una cita fabricada con material auténtico y atribuida a una persona con
|
||||
nombre y apellidos.
|
||||
|
||||
El comprobador de fundamento ya une las líneas antes de buscarlas, y eso
|
||||
cerró la forma con la que falló Socorro en agosto (`“LIKE ALUMINUM` +
|
||||
`SMOOTH, NO WINDOWS”`): unidas son una sola frase, no aparece en ninguna
|
||||
fuente, y se rechaza. Pero la unión se puede derrotar poniéndole a cada
|
||||
línea su propio par de comillas: entonces son DOS citas, cada una
|
||||
fundamentada por su lado, y pasa en silencio — mientras el fotograma dibuja
|
||||
la frase de nadie. Le pasó a la sesión 162 el 2026-08-13 con
|
||||
`“GRAY, LIKE ZINC”` + `“TWO SAUCERS GLUED AT THE RIM”`.
|
||||
|
||||
Por eso se mira la FORMA y no el contenido: dos aperturas son dos citas,
|
||||
diga lo que diga la fuente.
|
||||
"""
|
||||
quote = props.get("quote")
|
||||
if not isinstance(quote, list) or len(quote) < 2:
|
||||
return []
|
||||
joined = " ".join(str(line) for line in quote)
|
||||
openings = sum(joined.count(glyph) for glyph in _OPENING_QUOTES)
|
||||
if openings < 2:
|
||||
return []
|
||||
return [f"{path}.quote: son {openings} citas, y este campo es UNA cita "
|
||||
"partida en líneas — leídas seguidas forman una frase que nadie "
|
||||
"dijo. Elige un solo span verbatim y pártelo donde tenga que "
|
||||
"partirse, o quita las comillas y cuenta el hecho en llano"]
|
||||
|
||||
|
||||
#: Comprobaciones que se aplican a TODAS las plantillas, por nombre de prop. Van
|
||||
#: aparte de las de abajo para que una plantilla nueva con un campo `quote` de
|
||||
#: varias líneas quede cubierta sin tocar nada — el mismo pacto que el contrato.
|
||||
UNIVERSAL_CHECKS = [_quote_is_one_span]
|
||||
|
||||
#: template -> comprobaciones extra. Una plantilla sin entrada no tiene reglas
|
||||
#: cruzadas, que es el caso de casi todas.
|
||||
CROSS_FIELD_CHECKS = {
|
||||
"scale_bars": [_scale_bars_quote_needs_room],
|
||||
"track_map": [_track_map_waypoints_inside],
|
||||
}
|
||||
|
||||
|
||||
def _check_cross_field(template: str, props: Any, path: str) -> list[str]:
|
||||
if not isinstance(props, dict):
|
||||
return []
|
||||
errors: list[str] = []
|
||||
for check in (*UNIVERSAL_CHECKS, *CROSS_FIELD_CHECKS.get(template, ())):
|
||||
errors.extend(check(props, path))
|
||||
return errors
|
||||
|
||||
|
||||
# --- el sobre ---------------------------------------------------------------
|
||||
|
||||
def _check_meta(meta: Any, limits: Optional[dict] = None) -> list[str]:
|
||||
def _check_meta(meta: Any) -> list[str]:
|
||||
if not isinstance(meta, dict):
|
||||
return ["meta: se esperaba un objeto"]
|
||||
errors = []
|
||||
spec_id = meta.get("id")
|
||||
pattern = re.compile(_limit(limits, "meta_id_pattern"))
|
||||
if not isinstance(spec_id, str) or not pattern.match(spec_id):
|
||||
if not isinstance(spec_id, str) or not META_ID.match(spec_id):
|
||||
errors.append("meta.id: minúsculas, dígitos, '_' y '-', empezando por "
|
||||
f"letra o dígito, hasta 64 caracteres (llegó {spec_id!r})")
|
||||
if not isinstance(meta.get("title"), str) or not meta.get("title"):
|
||||
errors.append("meta.title: obligatorio y no vacío")
|
||||
width = meta.get("width", 1080)
|
||||
height = meta.get("height", 1920)
|
||||
resolutions = [tuple(pair) for pair in _limit(limits, "resolutions")]
|
||||
if (width, height) not in resolutions:
|
||||
allowed = ", ".join(f"{w}x{h}" for w, h in sorted(resolutions))
|
||||
if (width, height) not in RESOLUTIONS:
|
||||
allowed = ", ".join(f"{w}x{h}" for w, h in sorted(RESOLUTIONS))
|
||||
errors.append(f"meta: {width}x{height} no es una resolución admitida ({allowed})")
|
||||
fps_values = _limit(limits, "fps")
|
||||
if meta.get("fps", 30) not in fps_values:
|
||||
if meta.get("fps", 30) not in FPS_VALUES:
|
||||
errors.append(f"meta.fps: {meta.get('fps')!r} no está entre "
|
||||
f"{sorted(fps_values)}")
|
||||
f"{sorted(FPS_VALUES)}")
|
||||
for key in meta:
|
||||
if key not in ("id", "title", "width", "height", "fps", "theme"):
|
||||
errors.append(f"meta.{key}: campo no permitido")
|
||||
@@ -369,8 +191,7 @@ BASELINE_PRESET_NAMES = ("sonar", "none")
|
||||
|
||||
|
||||
def _check_audio(audio: Any, total: float,
|
||||
presets: Optional[Iterable[str]] = None,
|
||||
limits: Optional[dict] = None) -> list[str]:
|
||||
presets: Optional[Iterable[str]] = None) -> list[str]:
|
||||
if audio is None:
|
||||
return []
|
||||
if not isinstance(audio, dict):
|
||||
@@ -383,9 +204,8 @@ def _check_audio(audio: Any, total: float,
|
||||
silence = audio.get("silence", [])
|
||||
if not isinstance(silence, list):
|
||||
return errors + ["audio.silence: se esperaba una lista de pares [inicio, fin]"]
|
||||
max_ranges = _limit(limits, "max_silence_ranges")
|
||||
if len(silence) > max_ranges:
|
||||
errors.append(f"audio.silence: {len(silence)} rangos, el máximo es {max_ranges}")
|
||||
if len(silence) > 16:
|
||||
errors.append(f"audio.silence: {len(silence)} rangos, el máximo es 16")
|
||||
for i, rango in enumerate(silence):
|
||||
if not (isinstance(rango, (list, tuple)) and len(rango) == 2
|
||||
and all(isinstance(v, (int, float)) for v in rango)):
|
||||
@@ -405,17 +225,19 @@ def _check_audio(audio: Any, total: float,
|
||||
return errors
|
||||
|
||||
|
||||
def _check_narration(narration: Any, path: str,
|
||||
limits: Optional[dict] = None) -> list[str]:
|
||||
"""El tope de narración de un shot, el mismo que aplica shortsmith.
|
||||
Rechazarla aquí cuesta un reintento del modelo; allí, el render entero."""
|
||||
#: Tope de la narración de un shot, el mismo que aplica shortsmith. Rechazarla
|
||||
#: aquí cuesta un reintento del modelo; rechazarla allí cuesta el render entero.
|
||||
MAX_NARRATION_CHARS = 320
|
||||
|
||||
|
||||
def _check_narration(narration: Any, path: str) -> list[str]:
|
||||
if narration is None:
|
||||
return []
|
||||
if not isinstance(narration, str):
|
||||
return [f"{path}.narration: se esperaba texto"]
|
||||
cap = _limit(limits, "max_narration_chars")
|
||||
if len(narration) > cap:
|
||||
return [f"{path}.narration: {len(narration)} caracteres, el máximo es {cap}"]
|
||||
if len(narration) > MAX_NARRATION_CHARS:
|
||||
return [f"{path}.narration: {len(narration)} caracteres, el máximo es "
|
||||
f"{MAX_NARRATION_CHARS}"]
|
||||
return []
|
||||
|
||||
|
||||
@@ -428,18 +250,15 @@ def _total_duration(spec: dict) -> float:
|
||||
|
||||
|
||||
def validate_spec(spec: Any, templates: dict[str, dict],
|
||||
presets: Optional[Iterable[str]] = None,
|
||||
limits: Optional[dict] = None) -> None:
|
||||
presets: Optional[Iterable[str]] = None) -> None:
|
||||
"""Lanza `SpecInvalid` con TODAS las rutas que fallan.
|
||||
|
||||
Se devuelven todos los errores de golpe a propósito: el bucle de reintento
|
||||
se los da al modelo verbatim y arreglar cinco de una vez sale más barato
|
||||
que cinco vueltas.
|
||||
|
||||
Las tres mitades del contrato llegan vivas y ninguna se escribe aquí:
|
||||
`templates` de `GET /templates`, `presets` de `GET /audio` y `limits` de
|
||||
`GET /limits`. Sin cada una se cae a su base, que nunca acepta nada que un
|
||||
shortsmith viejo no renderice.
|
||||
`presets` es la paleta viva de `GET /audio`; sin ella se valida contra la
|
||||
paleta base, que nunca acepta nada que un shortsmith viejo no renderice.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
if not isinstance(spec, dict):
|
||||
@@ -452,18 +271,15 @@ def validate_spec(spec: Any, templates: dict[str, dict],
|
||||
errors.append(f"{key}: campo no permitido en la raíz "
|
||||
"(las válidas son: version, meta, audio, shots)")
|
||||
|
||||
errors.extend(_check_meta(spec.get("meta"), limits))
|
||||
errors.extend(_check_meta(spec.get("meta")))
|
||||
|
||||
shots = spec.get("shots")
|
||||
if not isinstance(shots, list) or not shots:
|
||||
errors.append("shots: hace falta al menos un shot")
|
||||
raise SpecInvalid(errors)
|
||||
max_shots = _limit(limits, "shots", "max")
|
||||
if len(shots) > max_shots:
|
||||
errors.append(f"shots: {len(shots)} shots, el máximo es {max_shots}")
|
||||
if len(shots) > MAX_SHOTS:
|
||||
errors.append(f"shots: {len(shots)} shots, el máximo es {MAX_SHOTS}")
|
||||
|
||||
shot_min = _limit(limits, "shot_duration", "min")
|
||||
shot_max = _limit(limits, "shot_duration", "max")
|
||||
known = ", ".join(sorted(templates))
|
||||
for i, shot in enumerate(shots):
|
||||
path = f"shots.{i}"
|
||||
@@ -479,123 +295,38 @@ def validate_spec(spec: Any, templates: dict[str, dict],
|
||||
if key not in ("template", "duration", "props", "narration"):
|
||||
errors.append(f"{path}.{key}: campo no permitido "
|
||||
"(las válidas son: template, duration, props, narration)")
|
||||
errors.extend(_check_narration(shot.get("narration"), path, limits))
|
||||
errors.extend(_check_narration(shot.get("narration"), path))
|
||||
duration = shot.get("duration")
|
||||
if not isinstance(duration, (int, float)) or isinstance(duration, bool):
|
||||
errors.append(f"{path}.duration: obligatoria y numérica")
|
||||
elif not shot_min <= duration <= shot_max:
|
||||
elif not MIN_SHOT_DURATION <= duration <= MAX_TOTAL_DURATION:
|
||||
errors.append(f"{path}.duration: {duration} fuera de "
|
||||
f"[{shot_min}, {shot_max}]")
|
||||
f"[{MIN_SHOT_DURATION}, {MAX_TOTAL_DURATION}]")
|
||||
if "props" not in shot:
|
||||
errors.append(f"{path}.props: falta y es obligatorio")
|
||||
continue
|
||||
props_path = f"{path}.{template}.props"
|
||||
props_errors = _check_props(shot["props"], templates[template], props_path)
|
||||
errors.extend(props_errors)
|
||||
# Sólo si el esquema pasó: con props mal tipadas, una regla cruzada
|
||||
# diría algo que no es el fallo real y taparía el que sí lo es.
|
||||
if not props_errors:
|
||||
errors.extend(_check_cross_field(template, shot["props"], props_path))
|
||||
errors.extend(_check_props(shot["props"], templates[template],
|
||||
f"{path}.{template}.props"))
|
||||
|
||||
total = _total_duration(spec)
|
||||
total_min = _limit(limits, "total_duration", "min")
|
||||
total_max = _limit(limits, "total_duration", "max")
|
||||
if total < total_min:
|
||||
if total < MIN_TOTAL_DURATION:
|
||||
errors.append(f"shots: la duración total ({total:.2f}s) no llega al "
|
||||
f"mínimo de {total_min}s")
|
||||
if total > total_max:
|
||||
f"mínimo de {MIN_TOTAL_DURATION}s")
|
||||
if total > MAX_TOTAL_DURATION:
|
||||
errors.append(f"shots: la duración total ({total:.2f}s) pasa del límite "
|
||||
f"de {total_max}s")
|
||||
errors.extend(_check_audio(spec.get("audio"), total, presets, limits))
|
||||
f"de {MAX_TOTAL_DURATION}s")
|
||||
errors.extend(_check_audio(spec.get("audio"), total, presets))
|
||||
|
||||
if errors:
|
||||
raise SpecInvalid(errors)
|
||||
|
||||
|
||||
#: Caracteres por segundo de la voz, sin contar las pausas. Medido el
|
||||
#: 2026-08-12 sintetizando de verdad las 28 líneas de narración que el bot ha
|
||||
#: escrito hasta hoy con el mismo Piper y las mismas banderas que usa shortsmith
|
||||
#: (`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
|
||||
#: Caracteres por segundo de la voz (Piper `en_US-lessac-medium` a length_scale
|
||||
#: 1.0). Medido el 2026-08-06: 82 caracteres en 5.78 s. Sirve para ESTIMAR aquí
|
||||
#: lo que shortsmith sabrá exacto al sintetizar.
|
||||
NARRATION_CHARS_PER_SECOND = 14.2
|
||||
#: El respiro que shortsmith deja tras cada línea antes de permitir el corte.
|
||||
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 sí cuenta palabras y
|
||||
puntos — y la regla tiene que ser computable por quien debe obedecerla, o no
|
||||
es una regla, es un deseo.
|
||||
|
||||
Los dos términos son los mismos que los de la voz. La versión anterior del
|
||||
prompt colapsaba el segundo en un "+ medio segundo" fijo, y ese es el mismo
|
||||
error de clase que tenía el estimador antes del 2026-08-12: sin pausa por
|
||||
frase, una línea troceada en frases cortas se da por rápida. Medido contra
|
||||
las 28 líneas reales, aquella regla se quedaba corta en 14 y hasta 2,27 s —
|
||||
o sea que un modelo que la obedeciera al pie de la letra seguiría
|
||||
infradeclarando la mitad de sus planos. Con el término por frase el peor
|
||||
caso baja a 1,27 s y sólo en 5 de 28.
|
||||
"""
|
||||
return (words / NARRATION_WORDS_PER_SECOND
|
||||
+ sentences * NARRATION_SENTENCE_SILENCE
|
||||
+ NARRATION_ROUNDED_PAD)
|
||||
|
||||
|
||||
def max_words_in(seconds: float, sentences: int = 1) -> int:
|
||||
"""Cuántas palabras caben en un plano de esa duración, según la regla de arriba.
|
||||
|
||||
Existe para que el tope de palabras por línea y el tope de duración de plano
|
||||
no puedan volver a contradecirse: se deriva uno del otro en vez de escribir
|
||||
los dos a mano.
|
||||
"""
|
||||
room = seconds - sentences * NARRATION_SENTENCE_SILENCE - NARRATION_ROUNDED_PAD
|
||||
return max(1, int(room * NARRATION_WORDS_PER_SECOND))
|
||||
|
||||
|
||||
def spoken_seconds(line: str) -> float:
|
||||
"""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:
|
||||
@@ -605,9 +336,6 @@ def estimated_duration(spec: dict) -> float:
|
||||
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
|
||||
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
|
||||
for shot in spec.get("shots") or []:
|
||||
@@ -617,214 +345,20 @@ def estimated_duration(spec: dict) -> float:
|
||||
declared = float(declared) if isinstance(declared, (int, float)) else 0.0
|
||||
narration = shot.get("narration")
|
||||
if isinstance(narration, str) and narration.strip():
|
||||
declared = max(declared, spoken_seconds(narration) + NARRATION_PAD)
|
||||
spoken = len(narration.strip()) / NARRATION_CHARS_PER_SECOND + NARRATION_PAD
|
||||
declared = max(declared, spoken)
|
||||
total += declared
|
||||
return total
|
||||
|
||||
|
||||
#: 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í sí 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 sí. 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]:
|
||||
def editorial_notes(spec: dict) -> list[str]:
|
||||
"""Lo que no viola el contrato pero sí el encargo.
|
||||
|
||||
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;
|
||||
si insiste, se renderiza igual antes que tirar la generación a la basura.
|
||||
"""
|
||||
# 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)
|
||||
notes = []
|
||||
declared = _total_duration(spec)
|
||||
total = estimated_duration(spec)
|
||||
stretched = total > declared + 0.5
|
||||
@@ -832,48 +366,18 @@ def editorial_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
|
||||
f"({declared:.1f}s de shots)" if stretched
|
||||
else f"la duración total son {total:.1f}s")
|
||||
|
||||
if total < TARGET_MIN_DURATION - TARGET_GRACE:
|
||||
if total < TARGET_MIN_DURATION:
|
||||
notes.append(f"{how} y el objetivo es "
|
||||
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
|
||||
"queda corto, añade un shot o alarga los que tienes")
|
||||
elif total > TARGET_MAX_DURATION + TARGET_GRACE:
|
||||
elif total > TARGET_MAX_DURATION:
|
||||
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 "
|
||||
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
|
||||
+ _how_to_trim(spec, total - TARGET_MAX_DURATION, stretched))
|
||||
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: {fix}")
|
||||
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 -----------------------------------
|
||||
|
||||
def _describe_field(name: str, schema: dict, required: bool, defs: dict,
|
||||
@@ -902,18 +406,12 @@ def _describe_field(name: str, schema: dict, required: bool, defs: dict,
|
||||
bits.append("no vacío")
|
||||
if "maxLength" in schema:
|
||||
bits.append(f"máx {schema['maxLength']} caracteres")
|
||||
# `x-fits` es cuánto texto cabe DIBUJADO al tamaño de diseño y `x-fits-hard`
|
||||
# dónde deja de leerse, los dos medidos por shortsmith contra sus propias
|
||||
# fuentes. Ninguno se valida — los caracteres son un proxy de los píxeles —
|
||||
# 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.
|
||||
# `x-fits` es cuánto texto cabe DIBUJADO al tamaño de diseño, medido por
|
||||
# shortsmith contra sus propias fuentes. No se valida — los caracteres son
|
||||
# un proxy de los píxeles — pero es lo único que evita que el modelo escriba
|
||||
# una cita de 58 caracteres en un hueco de 16 y salga dibujada ilegible.
|
||||
if "x-fits" in schema:
|
||||
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", "≤"),
|
||||
("exclusiveMinimum", ">"), ("exclusiveMaximum", "<")):
|
||||
if key in schema:
|
||||
|
||||
+10
-141
@@ -45,15 +45,6 @@ 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.
|
||||
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).
|
||||
_TOKEN_MARGIN = 120.0
|
||||
#: Tokens de acceso en memoria por client_id. El bot crea un uploader nuevo en
|
||||
@@ -106,16 +97,6 @@ class UploadedVideo:
|
||||
upload_status: str = ""
|
||||
#: Por qué YouTube marcó el vídeo como no reproducible, si lo hizo.
|
||||
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
|
||||
def watch_url(self) -> str:
|
||||
@@ -148,73 +129,23 @@ def _clean(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", str(text)).strip()
|
||||
|
||||
|
||||
def _spec_text(spec: dict) -> str:
|
||||
"""Todo el texto que el vídeo enseña o dice, que es inglés por construcción.
|
||||
|
||||
El título y el `id` los escribe el modelo en inglés, y los props son lo que
|
||||
se dibuja en pantalla. Sirve de criba: una palabra que no está aquí no está
|
||||
en el vídeo.
|
||||
"""
|
||||
meta = spec.get("meta") or {}
|
||||
parts = [str(meta.get("title") or ""), str(meta.get("id") or "").replace("_", " ")]
|
||||
|
||||
def walk(node: Any) -> None:
|
||||
if isinstance(node, dict):
|
||||
for value in node.values():
|
||||
walk(value)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
walk(value)
|
||||
elif isinstance(node, str):
|
||||
parts.append(node)
|
||||
|
||||
walk(spec.get("shots"))
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _words(text: str) -> list[str]:
|
||||
return re.findall(r"[\w'-]+", text)
|
||||
|
||||
|
||||
def _tags_from(spec: dict, topic: str) -> list[str]:
|
||||
"""Etiquetas del vídeo, sin repetir las de base y sin pasarse de los 500
|
||||
def _tags_from(topic: str, spec_id: str = "") -> list[str]:
|
||||
"""Etiquetas del tema, sin repetir las de base y sin pasarse de los 500
|
||||
caracteres que YouTube cuenta sumando toda la lista.
|
||||
|
||||
**Salen del spec, no del `topic`.** El tema es la consulta de investigación
|
||||
y en la mitad de las sesiones está en español: el Short de Trans-en-Provence
|
||||
se subió al canal en inglés etiquetado `análisis`, `suelo`, `evidencia` y
|
||||
`física`, y el de Cash-Landrum con `quemaduras`, `radiación` y `gobierno`.
|
||||
|
||||
El tema no se tira, se **criba**: una palabra suya llega a etiqueta sólo si
|
||||
el vídeo la dice. Así sobreviven los nombres propios que sólo estaban en la
|
||||
consulta —Zimbabwe, Brazil, Texas, New Mexico— y se caen las palabras que
|
||||
nunca salieron de la caja de búsqueda. Y no hace falta lista de palabras en
|
||||
español ni detector de idioma, que serían el parche sin fin: el criterio no
|
||||
es el idioma, es si el vídeo lo dice. Por eso `BASE AÉREA TALAVERA` sí
|
||||
etiqueta — es el nombre de la base, y está dibujado en pantalla.
|
||||
|
||||
La etiqueta de frase sale del `meta.id`: es la que buscan de verdad
|
||||
("socorro 1964 zamora", "ariel school 1994"), y ya venía en inglés. Antes
|
||||
era el tema entero recortado a 60 caracteres, que en cinco de los ocho
|
||||
Shorts generados lo partía a media palabra —"...GEPAN CNES análisis suelo
|
||||
evi"— y una frase partida no la busca nadie.
|
||||
El tema entero va primero como una sola etiqueta: partido en palabras deja
|
||||
cosas como "New" y "Mexico" sueltas, que no buscan igual que "Socorro New
|
||||
Mexico 1964". Las palabras sueltas van detrás igualmente, que cuestan poco.
|
||||
"""
|
||||
seen = {t.casefold() for t in BASE_TAGS}
|
||||
tags = list(BASE_TAGS)
|
||||
meta = spec.get("meta") or {}
|
||||
|
||||
phrase = _clean(str(meta.get("id") or "").replace("_", " "))
|
||||
if len(phrase) > MAX_TAG:
|
||||
# Cortada por palabra: media palabra no la busca nadie.
|
||||
phrase = phrase[:MAX_TAG].rsplit(" ", 1)[0]
|
||||
phrase = _clean(topic)[:MAX_TAG]
|
||||
if phrase and phrase.casefold() not in seen:
|
||||
seen.add(phrase.casefold())
|
||||
tags.append(phrase)
|
||||
|
||||
spoken = _spec_text(spec).casefold()
|
||||
words = (_words(str(meta.get("id") or "").replace("_", " "))
|
||||
+ _words(str(meta.get("title") or ""))
|
||||
+ [w for w in _words(topic) if w.casefold() in spoken])
|
||||
words = re.findall(r"[\w'-]+", f"{topic} {spec_id.replace('_', ' ')}")
|
||||
for word in words:
|
||||
low = word.casefold()
|
||||
if low in seen or low in _STOPWORDS or len(word) < 3:
|
||||
@@ -261,22 +192,11 @@ def build_metadata(spec: dict, topic: str, article_url: Optional[str] = None,
|
||||
hace el título — sino ahorrarle a quien revisa teclear el enlace al artículo
|
||||
y las fuentes. Lo que falte se edita en Studio, que es donde va a estar de
|
||||
todas formas.
|
||||
|
||||
`topic` entra por el título (sólo como respaldo) y por las etiquetas, nunca
|
||||
por la descripción: es la consulta de investigación, y en la mitad de las
|
||||
sesiones está en español.
|
||||
"""
|
||||
meta = spec.get("meta") or {}
|
||||
title = _clean(meta.get("title") or topic)[:MAX_TITLE]
|
||||
|
||||
# El `topic` NO va en la descripción, y no es un olvido. Es la consulta de
|
||||
# investigación, no prosa: sopa de palabras clave y, en la mitad de las
|
||||
# sesiones, en español — "Trans-en-Provence Francia 1981 GEPAN CNES análisis
|
||||
# suelo evidencia física" encabezó la descripción de un vídeo en inglés del
|
||||
# canal en inglés. Lo que describe el vídeo es el título, que sale de
|
||||
# `meta.title` y lo escribe el modelo en inglés. El tema sigue alimentando
|
||||
# las etiquetas, donde una frase de búsqueda sí es lo que se quiere.
|
||||
parts: list[str] = []
|
||||
parts: list[str] = [_clean(topic)]
|
||||
if article_url:
|
||||
parts.append(f"Full investigation → {article_url}")
|
||||
|
||||
@@ -294,7 +214,7 @@ def build_metadata(spec: dict, topic: str, article_url: Optional[str] = None,
|
||||
"snippet": {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"tags": _tags_from(spec, topic),
|
||||
"tags": _tags_from(topic, str(meta.get("id") or "")),
|
||||
"categoryId": str(category_id or settings.youtube_category_id),
|
||||
"defaultLanguage": "en",
|
||||
"defaultAudioLanguage": "en",
|
||||
@@ -416,62 +336,11 @@ class YouTubeUploader:
|
||||
rejection_reason=(status.get("rejectionReason")
|
||||
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,
|
||||
privacy=result.privacy_status,
|
||||
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)
|
||||
forced_private=result.forced_private)
|
||||
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:
|
||||
"""Paso 1: los metadatos. Devuelve la URL de subida (cabecera Location)."""
|
||||
headers = {
|
||||
|
||||
+14
-41
@@ -117,36 +117,6 @@ async def fetch_published_menu(lang: str) -> list[dict]:
|
||||
|
||||
# ─── 2. SEO field generation (one Haiku JSON call) ──────────────────────────
|
||||
|
||||
def _create_kwargs(system: str, messages: list, max_tokens: int) -> dict:
|
||||
"""Los kwargs de `messages.create`, aparte para que un test los compare con
|
||||
la firma del SDK que de verdad está instalado.
|
||||
|
||||
Aquí vivía `temperature=0.0`, y el SDK dejó de aceptarlo: **anthropic 1.2.0
|
||||
no lleva `temperature` en `messages.create`** — los parámetros de muestreo
|
||||
se movieron a `output_config`, que sólo expone `effort` y `format`. El
|
||||
reintento estricto del SEO llevaba desde entonces muriendo con un TypeError
|
||||
que el `except` de abajo convertía en un aviso, así que **nunca se
|
||||
ejecutaba**: se quedaba el primer intento y entraba el recortador mecánico.
|
||||
Se vio en el artículo EN de Trans-en-Provence, con el `custom_excerpt` en
|
||||
389 caracteres contra un tope de 300.
|
||||
|
||||
Lo que hacía vincular el reintento no era la temperatura: es el turno de
|
||||
edición —se le devuelve su propio JSON para que lo acorte, en vez de
|
||||
volver a tirar de cero— y un `max_tokens` más corto que desanima la
|
||||
divagación. Los dos siguen en pie.
|
||||
|
||||
Una dependencia que estrecha su firma no rompe la importación ni los tests
|
||||
que la simulan: sólo se ve comparando con la instalada, que es lo que hace
|
||||
`test_los_kwargs_los_acepta_el_sdk_instalado`.
|
||||
"""
|
||||
return {
|
||||
"model": settings.claude_model,
|
||||
"max_tokens": max_tokens,
|
||||
"system": system,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
|
||||
def _system_prompt(lang: str) -> str:
|
||||
allow = ", ".join(ALLOWED_TAGS.get(lang, []))
|
||||
out_lang = "SPANISH" if lang == "es" else "ENGLISH"
|
||||
@@ -555,9 +525,17 @@ async def generate_seo_fields(
|
||||
system = _system_prompt(lang)
|
||||
user = _user_message(article_text, link_menu)
|
||||
|
||||
async def _raw(messages: list, max_tokens: int = 1024) -> str:
|
||||
msg = await client.messages.create(
|
||||
**_create_kwargs(system, messages, max_tokens))
|
||||
async def _raw(messages: list, max_tokens: int = 1024,
|
||||
temperature: float | None = None) -> str:
|
||||
kwargs = {
|
||||
"model": settings.claude_model,
|
||||
"max_tokens": max_tokens,
|
||||
"system": system,
|
||||
"messages": messages,
|
||||
}
|
||||
if temperature is not None:
|
||||
kwargs["temperature"] = temperature
|
||||
msg = await client.messages.create(**kwargs)
|
||||
if db is not None and session_id is not None:
|
||||
try:
|
||||
await db.log_api_call(
|
||||
@@ -593,8 +571,9 @@ async def generate_seo_fields(
|
||||
{"role": "assistant", "content": text1},
|
||||
{"role": "user", "content": instr},
|
||||
]
|
||||
# temperature=0 so the shorten instruction binds deterministically.
|
||||
retry = _coerce(_parse_json_object(
|
||||
await _raw(retry_msgs, max_tokens=768)), lang)
|
||||
await _raw(retry_msgs, max_tokens=768, temperature=0.0)), lang)
|
||||
retry["internal_links"] = _sanitize_links(retry["internal_links"], link_menu)
|
||||
rlinked, _ = insert_internal_links(
|
||||
_markdown_to_html(article_text), retry["internal_links"], link_menu, lang)
|
||||
@@ -605,13 +584,7 @@ async def generate_seo_fields(
|
||||
# Keep the retry's text but record it stayed over (never truncate).
|
||||
fields, violations, blocking = retry, rviol, _blocking(rviol)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Un TypeError aquí no es "la API tuvo un mal día": es que la
|
||||
# llamada está mal escrita. Así fue como `temperature` vivió
|
||||
# meses de reintentos que nunca ocurrieron, en un warning que
|
||||
# nadie leía.
|
||||
registra = logger.error if isinstance(e, TypeError) else logger.warning
|
||||
registra("seo.fields: retry failed, keeping first output",
|
||||
error=str(e), kind=type(e).__name__)
|
||||
logger.warning("seo.fields: retry failed, keeping first output", error=str(e))
|
||||
|
||||
# Final boundary-aware shortener — only for fields the LLM + retry left
|
||||
# over limit. Clean (sentence-drop / word-boundary), never mid-word, and
|
||||
|
||||
@@ -103,40 +103,6 @@ def test_upload_message_warns_when_the_description_has_no_article():
|
||||
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():
|
||||
"""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."""
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Lo que le pedimos al SDK de Anthropic contra lo que el SDK acepta.
|
||||
|
||||
Este fichero existe por un fallo concreto: `src/seo/autofill.py` mandaba
|
||||
`temperature=0.0` a `messages.create`, y anthropic 1.2.0 lo quitó de la firma
|
||||
—los parámetros de muestreo se movieron a `output_config`—. La llamada moría
|
||||
con un TypeError que un `except` convertía en aviso, así que el reintento
|
||||
estricto del SEO llevaba meses sin ejecutarse. Nada lo vio: la importación no
|
||||
falla, ningún test toca la API de verdad, y la suite corría en un portátil con
|
||||
anthropic 0.102.0 mientras el pod ejecutaba 1.2.0.
|
||||
|
||||
Comprobar el caso arreglado no habría bastado. Lo que hace falta es cerrar la
|
||||
CLASE: se leen TODAS las llamadas del árbol con `ast` y se comparan sus kwargs
|
||||
con la firma del SDK instalado. Un kwarg que el SDK ya no acepta falla aquí, en
|
||||
el sitio donde está escrito, sin que nadie tenga que ejercitar ese camino.
|
||||
|
||||
Junto con el pin de `requirements.txt` y el `pytest` que la CI corre DENTRO de
|
||||
la imagen, esto pasa a ejecutarse contra el SDK que de verdad se despliega —
|
||||
que es lo único que lo convierte en una prueba y no en un gesto.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SRC = Path(__file__).resolve().parents[1] / "src"
|
||||
|
||||
|
||||
def _llamadas_al_sdk() -> list[tuple[str, int, list[str], bool]]:
|
||||
"""(fichero, línea, kwargs, tiene_**kwargs) de cada `*.messages.create(...)`."""
|
||||
encontradas = []
|
||||
for fichero in sorted(SRC.rglob("*.py")):
|
||||
arbol = ast.parse(fichero.read_text(encoding="utf-8"), filename=str(fichero))
|
||||
for nodo in ast.walk(arbol):
|
||||
if not isinstance(nodo, ast.Call):
|
||||
continue
|
||||
fn = nodo.func
|
||||
if not (isinstance(fn, ast.Attribute) and fn.attr == "create"
|
||||
and isinstance(fn.value, ast.Attribute)
|
||||
and fn.value.attr == "messages"):
|
||||
continue
|
||||
nombres = [k.arg for k in nodo.keywords if k.arg is not None]
|
||||
estrella = any(k.arg is None for k in nodo.keywords)
|
||||
encontradas.append((str(fichero.relative_to(SRC)), nodo.lineno,
|
||||
nombres, estrella))
|
||||
return encontradas
|
||||
|
||||
|
||||
def _firma() -> set[str]:
|
||||
from anthropic import AsyncAnthropic
|
||||
return set(inspect.signature(
|
||||
AsyncAnthropic(api_key="test").messages.create).parameters)
|
||||
|
||||
|
||||
def test_hay_llamadas_que_revisar():
|
||||
"""Un barrido que no encuentra nada mide una lista vacía y pasa siempre."""
|
||||
assert len(_llamadas_al_sdk()) >= 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fichero,linea,kwargs,estrella", _llamadas_al_sdk(),
|
||||
ids=lambda v: str(v))
|
||||
def test_cada_llamada_usa_kwargs_que_el_sdk_acepta(fichero, linea, kwargs, estrella):
|
||||
if estrella and not kwargs:
|
||||
# Los kwargs se construyen aparte (p. ej. `_create_kwargs`): esa función
|
||||
# tiene su propio test, que compara el diccionario ya montado.
|
||||
pytest.skip(f"{fichero}:{linea} pasa **kwargs; cubierto en su propio test")
|
||||
firma = _firma()
|
||||
desconocidos = sorted(k for k in kwargs if k not in firma)
|
||||
assert desconocidos == [], (
|
||||
f"{fichero}:{linea} pasa {desconocidos} a messages.create y el SDK "
|
||||
f"instalado no lo acepta. Arregla la llamada, no relajes el assert.")
|
||||
@@ -9,8 +9,6 @@ BASE = {
|
||||
"image_context": "c",
|
||||
}
|
||||
|
||||
import src.seo.autofill as _autofill_module
|
||||
|
||||
|
||||
def test_coerce_es_drops_invented_tags():
|
||||
obj = dict(BASE, tags=["uap", "humanoides", "Desclasificados", "investigacion-2"])
|
||||
@@ -169,46 +167,3 @@ def test_un_idioma_desconocido_no_revienta_la_generacion():
|
||||
assert _check_con_sitio({"slug": "x"}, "pt") == []
|
||||
finally:
|
||||
R.check_post = orig
|
||||
|
||||
|
||||
# --- la llamada al SDK -------------------------------------------------------
|
||||
|
||||
def test_los_kwargs_los_acepta_el_sdk_instalado():
|
||||
"""Contra la firma REAL, no contra una copia nuestra ni contra un doble.
|
||||
|
||||
`temperature=0.0` viajaba en esta llamada y anthropic 1.2.0 dejó de
|
||||
aceptarlo (los parámetros de muestreo se fueron a `output_config`). Nada lo
|
||||
vio: la importación no falla, y un cliente simulado en un test acepta
|
||||
cualquier kwarg encantado. Sólo se ve preguntándole al SDK instalado qué
|
||||
admite — el mismo movimiento que publicar el contrato en vez de copiarlo.
|
||||
|
||||
Si esto falla tras subir el SDK, el arreglo es cambiar la llamada, no
|
||||
relajar el assert.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
from src.seo.autofill import _create_kwargs
|
||||
|
||||
kwargs = _create_kwargs("system", [{"role": "user", "content": "x"}], 768)
|
||||
firma = inspect.signature(AsyncAnthropic(api_key="test").messages.create)
|
||||
desconocidos = sorted(k for k in kwargs if k not in firma.parameters)
|
||||
|
||||
assert desconocidos == [], (
|
||||
f"el SDK instalado no acepta {desconocidos} en messages.create; "
|
||||
f"acepta {sorted(firma.parameters)}")
|
||||
|
||||
|
||||
def test_el_reintento_no_pide_nada_que_no_este_en_los_kwargs():
|
||||
"""El reintento usa la MISMA constructora, así que no puede divergir.
|
||||
|
||||
Antes tenía su propia rama —`temperature` sólo se añadía en el reintento—,
|
||||
y por eso el fallo sólo aparecía cuando el primer intento violaba un límite:
|
||||
el camino feliz nunca lo tocaba.
|
||||
"""
|
||||
normal = _autofill_module._create_kwargs("s", [], 1024)
|
||||
reintento = _autofill_module._create_kwargs("s", [], 768)
|
||||
|
||||
assert set(normal) == set(reintento)
|
||||
assert reintento["max_tokens"] == 768
|
||||
|
||||
@@ -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))
|
||||
await conn.commit()
|
||||
|
||||
counts = await ResearchDB(conn).purge_old_data(30)
|
||||
counts = await ResearchDB(conn).purge_old_sessions(30)
|
||||
await conn.close()
|
||||
|
||||
assert counts["shorts"] == 1
|
||||
@@ -339,150 +339,6 @@ 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"
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -42,59 +42,6 @@ async def test_healthz_and_templates():
|
||||
assert schema.get("type") == "object", f"{name} no publica un esquema de objeto"
|
||||
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
|
||||
async def test_the_envelope_served_is_the_envelope_validated_against():
|
||||
"""El sobre, contra el servicio de verdad — que es donde derivó.
|
||||
|
||||
La copia local decía «pequeña y estable» y aceptaba 1920x1080 días después
|
||||
de que shortsmith dejara de renderizarlo. Un test de unidad no podía verlo:
|
||||
los dos lados eran coherentes consigo mismos. Esto compara con el servido.
|
||||
|
||||
Contra un shortsmith anterior a `GET /limits` el cliente devuelve el suelo
|
||||
sin error, y entonces esto sólo comprueba que el suelo es el estrecho — que
|
||||
es exactamente lo que se quiere de un fallback.
|
||||
"""
|
||||
from src.generator.spec_contract import BASELINE_LIMITS, validate_spec, SpecInvalid
|
||||
|
||||
client = ShortsmithClient(LIVE_URL)
|
||||
limits = await client.limits(refresh=True)
|
||||
|
||||
resoluciones = [tuple(pair) for pair in limits["resolutions"]]
|
||||
assert (1080, 1920) in resoluciones, "el servicio ya no acepta el vertical"
|
||||
|
||||
# Lo que el sobre NO trae, el validador local tiene que rechazarlo. Si
|
||||
# shortsmith deja de aceptar una resolución, esto falla el día que pasa y no
|
||||
# la generación siguiente.
|
||||
spec = json.loads(EXAMPLE.read_text())
|
||||
templates = await client.templates(refresh=True)
|
||||
for size in [(1920, 1080), (1080, 1080)]:
|
||||
if size in resoluciones:
|
||||
continue
|
||||
doc = json.loads(json.dumps(spec))
|
||||
doc["meta"]["width"], doc["meta"]["height"] = size
|
||||
with pytest.raises(SpecInvalid):
|
||||
validate_spec(doc, templates, limits=limits)
|
||||
|
||||
# Y el suelo nunca puede ser más ancho que lo servido: si lo fuera, un
|
||||
# shortsmith caído dejaría pasar specs que el vivo rechaza.
|
||||
suelo = {tuple(pair) for pair in BASELINE_LIMITS["resolutions"]}
|
||||
assert suelo <= set(resoluciones), (
|
||||
f"el suelo {sorted(suelo)} acepta más que el servicio {sorted(resoluciones)}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_the_reference_example_end_to_end(tmp_path):
|
||||
|
||||
+6
-230
@@ -105,29 +105,6 @@ def test_prompt_states_the_editorial_constraints():
|
||||
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():
|
||||
w, _ = writer("{}")
|
||||
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
|
||||
@@ -157,128 +134,15 @@ def test_the_worked_example_narrates_most_of_its_shots():
|
||||
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
|
||||
render no corta la voz, alarga el plano, y el total se va del objetivo."""
|
||||
from src.generator.spec_contract import (
|
||||
NARRATION_PAD, sentence_count, spoken_seconds, teachable_seconds,
|
||||
)
|
||||
from src.generator.spec_contract import NARRATION_CHARS_PER_SECOND
|
||||
|
||||
example = json.loads(EXAMPLE.read_text())
|
||||
for i, shot in enumerate(example["shots"]):
|
||||
narration = shot.get("narration", "")
|
||||
if not narration:
|
||||
continue
|
||||
# La cuenta que el prompt le pide al modelo, aplicada al ejemplo que le
|
||||
# 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 sí 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"
|
||||
needs = len(narration) / NARRATION_CHARS_PER_SECOND
|
||||
assert shot["duration"] >= needs, f"shot {i} declara menos de lo que habla"
|
||||
|
||||
|
||||
def test_prompt_says_out_loud_that_there_is_no_article_yet():
|
||||
@@ -356,20 +220,15 @@ async def test_three_failures_raise_but_keep_the_last_attempt():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_off_target_duration_is_commented_once_then_accepted():
|
||||
"""70 s cumple el contrato pero no el encargo: se comenta UNA vez y, si el
|
||||
modelo insiste, se renderiza igual antes que tirar la generación.
|
||||
|
||||
Una y no dos. El tercer intento se reserva para el contrato, que sí es
|
||||
binario: un spec largo se ve, uno malformado no se puede ni renderizar.
|
||||
"""
|
||||
"""70 s cumple el contrato pero no el encargo: se comenta y, si el modelo
|
||||
insiste, se renderiza igual antes que tirar la generación."""
|
||||
long_spec = json.loads(json.dumps(GOOD))
|
||||
long_spec["shots"][0]["duration"] = 55.0 # 70 s en total
|
||||
w, llm = writer(json.dumps(long_spec))
|
||||
|
||||
result = await w.write("Caso X", "material")
|
||||
|
||||
assert result.attempts == 2, "una nota no vale dos reescrituras"
|
||||
assert len(llm.prompts) == 2
|
||||
assert result.attempts == MAX_ATTEMPTS
|
||||
assert result.notes and "recorta" in result.notes[0]
|
||||
assert "off-brief" in llm.prompts[1]
|
||||
|
||||
@@ -386,89 +245,6 @@ async def test_a_valid_attempt_is_not_thrown_away_by_a_worse_one():
|
||||
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
|
||||
async def test_the_contract_is_refetched_after_a_validation_failure():
|
||||
"""Si el renderizador se actualizó a mitad de la run, la plantilla nueva
|
||||
|
||||
+20
-500
@@ -12,8 +12,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from src.generator.spec_contract import (
|
||||
SpecInvalid, describe_templates, editorial_notes, opening_notes,
|
||||
unreadable_notes, validate_spec,
|
||||
SpecInvalid, describe_templates, editorial_notes, validate_spec,
|
||||
)
|
||||
|
||||
EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json"
|
||||
@@ -23,10 +22,8 @@ TEMPLATES = {
|
||||
"type": "object", "additionalProperties": False,
|
||||
"required": ["headline"],
|
||||
"properties": {
|
||||
"headline": {"type": "string", "minLength": 1,
|
||||
"x-fits": 13, "x-fits-hard": 21},
|
||||
"subline": {"type": "string", "default": "",
|
||||
"x-fits": 27, "x-fits-hard": 42},
|
||||
"headline": {"type": "string", "minLength": 1},
|
||||
"subline": {"type": "string", "default": ""},
|
||||
"contact_bearing_deg": {"type": "number", "minimum": 0,
|
||||
"exclusiveMaximum": 360, "default": 210.0},
|
||||
"sweeps": {"type": "number", "exclusiveMinimum": 0, "maximum": 10,
|
||||
@@ -40,26 +37,20 @@ TEMPLATES = {
|
||||
"type": "object", "additionalProperties": False,
|
||||
"required": ["label", "value"],
|
||||
"properties": {
|
||||
"label": {"type": "string", "minLength": 1,
|
||||
"x-fits": 32, "x-fits-hard": 53},
|
||||
"label": {"type": "string", "minLength": 1},
|
||||
"value": {"type": "number", "exclusiveMinimum": 0},
|
||||
"unit": {"type": "string", "default": "",
|
||||
"x-fits-part-of": "value_label"},
|
||||
"unit": {"type": "string", "default": ""},
|
||||
"color": {"enum": ["ink", "amber", "amber_dark", "muted", "dim", "red"],
|
||||
"type": "string", "default": "ink"},
|
||||
"value_label": {"type": "string", "default": "",
|
||||
"x-fits": 30, "x-fits-hard": 49},
|
||||
"value_label": {"type": "string", "default": ""},
|
||||
},
|
||||
}},
|
||||
"properties": {
|
||||
"headline": {"type": "string", "minLength": 1,
|
||||
"x-fits": 16, "x-fits-hard": 26},
|
||||
"headline": {"type": "string", "minLength": 1},
|
||||
"bars": {"type": "array", "items": {"$ref": "#/$defs/Bar"},
|
||||
"minItems": 1, "maxItems": 3},
|
||||
"quote": {"type": "array", "items": {"type": "string"}, "maxItems": 2,
|
||||
"x-fits": 33, "x-fits-hard": 49},
|
||||
"attribution": {"type": "string", "default": "",
|
||||
"x-fits": 46, "x-fits-hard": 73},
|
||||
"quote": {"type": "array", "items": {"type": "string"}, "maxItems": 2},
|
||||
"attribution": {"type": "string", "default": ""},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -80,12 +71,6 @@ def spec_with(*shots, **meta):
|
||||
}
|
||||
|
||||
|
||||
def errors_of_with_limits(spec, limits):
|
||||
with pytest.raises(SpecInvalid) as exc:
|
||||
validate_spec(spec, TEMPLATES, limits=limits)
|
||||
return exc.value.errors
|
||||
|
||||
|
||||
def errors_of(spec, templates=None):
|
||||
with pytest.raises(SpecInvalid) as exc:
|
||||
validate_spec(spec, templates if templates is not None else TEMPLATES)
|
||||
@@ -177,60 +162,12 @@ def test_resolution_must_be_a_shorts_one():
|
||||
for e in errors_of(spec_with(shot(), width=800, height=600)))
|
||||
|
||||
|
||||
def test_landscape_is_rejected_even_without_the_live_envelope():
|
||||
"""El fallo que dio origen a `GET /limits`, clavado.
|
||||
|
||||
shortsmith quitó 1920x1080 en `358eec9` porque ninguna plantilla lo componía.
|
||||
Esta copia siguió aceptándolo, así que un spec apaisado pasaba aquí y moría
|
||||
en el servidor: una generación pagada y tirada. El suelo tiene que ser el
|
||||
estrecho — si alguien lo vuelve a ensanchar «por compatibilidad», esto falla.
|
||||
"""
|
||||
assert any("no es una resolución admitida" in e
|
||||
for e in errors_of(spec_with(shot(), width=1920, height=1080)))
|
||||
|
||||
|
||||
def test_the_live_envelope_wins_over_the_floor():
|
||||
"""El suelo es suelo, no verdad: lo que diga `GET /limits` manda.
|
||||
|
||||
Con un shortsmith que publique el apaisado, el mismo spec pasa sin tocar una
|
||||
línea de este repo — que es exactamente lo que `GET /templates` hace ya con
|
||||
una plantilla nueva.
|
||||
"""
|
||||
apaisado = spec_with(shot(duration=25.0), width=1920, height=1080)
|
||||
with pytest.raises(SpecInvalid):
|
||||
validate_spec(apaisado, TEMPLATES)
|
||||
validate_spec(apaisado, TEMPLATES,
|
||||
limits={"resolutions": [[1080, 1920], [1920, 1080]]})
|
||||
|
||||
|
||||
def test_a_partial_envelope_falls_back_key_by_key():
|
||||
"""Un sobre a medias no tira la validación entera al suelo.
|
||||
|
||||
Un shortsmith que publique menos claves de las que se leen aquí — o una
|
||||
respuesta recortada — deja las demás en su sitio. Se resuelve clave a clave
|
||||
justamente para eso.
|
||||
"""
|
||||
solo_fps = {"fps": [30]}
|
||||
validate_spec(spec_with(shot(duration=25.0)), TEMPLATES, limits=solo_fps)
|
||||
assert any("no es una resolución admitida" in e
|
||||
for e in errors_of_with_limits(
|
||||
spec_with(shot(), width=1920, height=1080), solo_fps))
|
||||
|
||||
|
||||
def test_the_envelope_also_moves_the_caps_that_are_not_resolution():
|
||||
"""Resolución es la que se rompió, pero el sobre entero llega vivo."""
|
||||
corto = spec_with(shot(duration=6.0)) # 6 s: válido con el suelo
|
||||
validate_spec(corto, TEMPLATES)
|
||||
errors = errors_of_with_limits(corto, {"total_duration": {"min": 20.0, "max": 30.0}})
|
||||
assert any("no llega al" in e and "20.0" in e for e in errors)
|
||||
|
||||
|
||||
def test_total_duration_ceiling_is_the_contract_not_the_target():
|
||||
"""45 s es el objetivo editorial; 180 s es el límite duro. Pasarse de 45 no
|
||||
invalida el spec — eso es una nota, no un error."""
|
||||
long_spec = spec_with(*[shot(duration=10.0) for _ in range(6)]) # 60 s
|
||||
validate_spec(long_spec, TEMPLATES)
|
||||
assert editorial_notes(long_spec, TEMPLATES)
|
||||
assert editorial_notes(long_spec)
|
||||
|
||||
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))
|
||||
@@ -241,127 +178,6 @@ def test_total_duration_floor():
|
||||
for e in errors_of(spec_with(shot(duration=2.0))))
|
||||
|
||||
|
||||
class TestCrossFieldRules:
|
||||
"""Las reglas de pydantic que cruzan campos, replicadas a mano.
|
||||
|
||||
No salen en el JSON Schema publicado, así que antes se dejaban al 422 del
|
||||
servidor — y ese 422 llega al RENDERIZAR, cuando el bucle de reintentos ya
|
||||
ha terminado. O sea que no costaban un reintento: costaban la generación
|
||||
entera y no daban vídeo. Pasó de verdad con la sesión 162 el 2026-08-13.
|
||||
"""
|
||||
|
||||
def test_three_bars_and_a_quote_do_not_fit(self):
|
||||
# El fallo exacto de la 162, con el texto exacto de shortsmith.
|
||||
errors = errors_of(spec_with(shot(
|
||||
"scale_bars", duration=25.0,
|
||||
bars=[{"label": "A", "value": 1}, {"label": "B", "value": 2},
|
||||
{"label": "C", "value": 3}],
|
||||
quote=["“UNA CITA”"])))
|
||||
assert any("3 bars leave no room for a quote" in e for e in errors)
|
||||
assert any(e.startswith("shots.0.scale_bars.props") for e in errors)
|
||||
|
||||
def test_three_bars_without_a_quote_are_fine(self):
|
||||
"""La regla es sobre el hueco, no sobre el número de barras."""
|
||||
validate_spec(spec_with(shot(
|
||||
"scale_bars", duration=25.0,
|
||||
bars=[{"label": "A", "value": 1}, {"label": "B", "value": 2},
|
||||
{"label": "C", "value": 3}])), TEMPLATES)
|
||||
|
||||
def test_two_bars_with_a_quote_are_fine(self):
|
||||
validate_spec(spec_with(shot(
|
||||
"scale_bars", duration=25.0,
|
||||
bars=[{"label": "A", "value": 1}, {"label": "B", "value": 2}],
|
||||
quote=["“UNA CITA”"])), TEMPLATES)
|
||||
|
||||
def test_a_waypoint_outside_a_pinned_window_is_rejected(self):
|
||||
"""La proyección no recorta: un waypoint fuera se dibuja donde diga la
|
||||
aritmética, a veces fuera del encuadre."""
|
||||
templates = {"track_map": {"type": "object"}}
|
||||
spec = spec_with({
|
||||
"template": "track_map", "duration": 25.0,
|
||||
"props": {
|
||||
"headline": "RUTA",
|
||||
"waypoints": [{"label": "DENTRO", "lat": 62.0, "lon": -148.0},
|
||||
{"label": "FUERA", "lat": 20.0, "lon": -148.0}],
|
||||
"bounds": {"lat_min": 60.0, "lat_max": 67.0,
|
||||
"lon_min": -152.0, "lon_max": -143.0}}})
|
||||
errors = errors_of(spec, templates)
|
||||
assert any("waypoints outside the map bounds: FUERA" in e for e in errors)
|
||||
|
||||
def test_bounds_with_max_below_min_are_rejected(self):
|
||||
templates = {"track_map": {"type": "object"}}
|
||||
spec = spec_with({
|
||||
"template": "track_map", "duration": 25.0,
|
||||
"props": {
|
||||
"headline": "RUTA",
|
||||
"waypoints": [{"label": "A", "lat": 62.0, "lon": -148.0}],
|
||||
"bounds": {"lat_min": 67.0, "lat_max": 60.0,
|
||||
"lon_min": -152.0, "lon_max": -143.0}}})
|
||||
assert any("max greater than min" in e for e in errors_of(spec, templates))
|
||||
|
||||
def test_a_fitted_window_needs_no_check(self):
|
||||
"""Sin `bounds`, shortsmith ajusta la ventana a la ruta: están dentro
|
||||
por construcción y no hay nada que comprobar."""
|
||||
templates = {"track_map": {"type": "object"}}
|
||||
validate_spec(spec_with({
|
||||
"template": "track_map", "duration": 25.0,
|
||||
"props": {"headline": "RUTA",
|
||||
"waypoints": [{"label": "A", "lat": 2.0, "lon": -1.0}]}}),
|
||||
templates)
|
||||
|
||||
def test_two_quotes_welded_into_one_field_are_rejected(self):
|
||||
"""El peor fallo del sistema: una frase que nadie dijo, hecha con
|
||||
material auténtico y firmada por alguien con nombre y apellidos.
|
||||
|
||||
El comprobador de fundamento une las líneas antes de buscarlas, y eso
|
||||
caza la forma con la que falló Socorro. Pero la unión se derrota
|
||||
poniéndole a cada línea su propio par de comillas: entonces son dos
|
||||
citas, cada una fundamentada por su lado, y pasa en silencio. Caso real
|
||||
de la sesión 162.
|
||||
"""
|
||||
errors = errors_of(spec_with(shot(
|
||||
"scale_bars", duration=25.0,
|
||||
bars=[{"label": "A", "value": 1}],
|
||||
quote=["“GRAY, LIKE ZINC”", "“TWO SAUCERS GLUED AT THE RIM”"])))
|
||||
assert any("es UNA cita partida en líneas" in e for e in errors)
|
||||
|
||||
def test_a_span_broken_across_lines_is_the_normal_case(self):
|
||||
"""La forma buena: abre en la primera línea y cierra en la última. Es
|
||||
como está escrito el ejemplo de referencia, así que rechazarla rompería
|
||||
el propio prompt."""
|
||||
validate_spec(spec_with(shot(
|
||||
"scale_bars", duration=25.0,
|
||||
bars=[{"label": "A", "value": 1}],
|
||||
quote=["“TWICE THE SIZE OF", "AN AIRCRAFT CARRIER”"])), TEMPLATES)
|
||||
|
||||
def test_a_quote_without_marks_is_left_alone(self):
|
||||
validate_spec(spec_with(shot(
|
||||
"scale_bars", duration=25.0,
|
||||
bars=[{"label": "A", "value": 1}],
|
||||
quote=["LANDING TRACE", "CONFIRMED BY LAB"])), TEMPLATES)
|
||||
|
||||
def test_the_reference_example_survives_the_quote_rule(self):
|
||||
"""Si el ejemplo no pasara su propia regla, volveríamos a enseñar el
|
||||
fallo que la regla intenta evitar."""
|
||||
spec = json.loads(EXAMPLE.read_text())
|
||||
permissive = {name: {"type": "object"} for name in
|
||||
{s["template"] for s in spec["shots"]}}
|
||||
validate_spec(spec, permissive)
|
||||
|
||||
def test_a_bad_schema_hides_the_cross_field_noise(self):
|
||||
"""Con props mal tipadas, la regla cruzada diría algo que no es el fallo
|
||||
real y taparía el que sí lo es."""
|
||||
# Tres barras (la regla cruzada dispararía) pero a las que les falta el
|
||||
# campo obligatorio: el fallo que hay que arreglar es ese, no el hueco
|
||||
# de la cita, que puede desaparecer al arreglarlo.
|
||||
errors = errors_of(spec_with(shot(
|
||||
"scale_bars", duration=25.0,
|
||||
bars=[{"label": "A"}, {"label": "B"}, {"label": "C"}],
|
||||
quote=["“X”"])))
|
||||
assert any("value: falta y es obligatorio" in e for e in errors)
|
||||
assert not any("leave no room" in e for e in errors)
|
||||
|
||||
|
||||
def test_silence_window_cannot_run_past_the_end():
|
||||
bad = spec_with(shot(duration=25.0))
|
||||
bad["audio"] = {"preset": "sonar", "silence": [[20.0, 40.0]]}
|
||||
@@ -399,10 +215,10 @@ def test_extra_root_key_is_rejected():
|
||||
|
||||
|
||||
def test_editorial_notes_flag_both_ends():
|
||||
assert "queda corto" in editorial_notes(spec_with(shot(duration=8.0)), TEMPLATES)[0]
|
||||
assert "queda corto" in editorial_notes(spec_with(shot(duration=8.0)))[0]
|
||||
assert "recorta" in editorial_notes(
|
||||
spec_with(*[shot(duration=10.0) for _ in range(6)]), TEMPLATES)[0]
|
||||
assert editorial_notes(spec_with(shot(duration=30.0)), TEMPLATES) == []
|
||||
spec_with(*[shot(duration=10.0) for _ in range(6)]))[0]
|
||||
assert editorial_notes(spec_with(shot(duration=30.0))) == []
|
||||
|
||||
|
||||
def test_a_spec_that_is_not_even_a_dict():
|
||||
@@ -415,16 +231,10 @@ def test_a_spec_that_is_not_even_a_dict():
|
||||
def test_describe_templates_is_driven_by_what_the_service_publishes():
|
||||
text = describe_templates(TEMPLATES)
|
||||
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 "ink, amber, amber_dark, muted, dim, red" in text
|
||||
# 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
|
||||
assert "label: string, no vacío, OBLIGATORIO" in text # despliega los objetos anidados
|
||||
|
||||
|
||||
def test_a_template_nobody_wrote_here_still_gets_described():
|
||||
@@ -473,73 +283,15 @@ def test_an_unknown_shot_key_still_names_the_valid_ones():
|
||||
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 sí 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():
|
||||
"""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."""
|
||||
from src.generator.spec_contract import estimated_duration
|
||||
|
||||
doc = spec_with(shot(duration=3.0))
|
||||
doc["shots"][0]["narration"] = MEASURED[0][0] # 7,81 s de voz medidos
|
||||
doc["shots"][0]["narration"] = "A" * 142 # ~10 s de voz
|
||||
|
||||
assert estimated_duration(doc) > 8.0
|
||||
assert estimated_duration(doc) > 10.0
|
||||
|
||||
|
||||
def test_a_shot_with_room_for_its_line_is_estimated_as_declared():
|
||||
@@ -557,254 +309,22 @@ 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
|
||||
# ~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)])
|
||||
assert editorial_notes(quiet, TEMPLATES) == []
|
||||
assert editorial_notes(quiet) == []
|
||||
|
||||
doc = copy.deepcopy(quiet)
|
||||
for s in doc["shots"]:
|
||||
s["narration"] = "A" * 300
|
||||
|
||||
note = editorial_notes(doc, TEMPLATES)[0]
|
||||
note = editorial_notes(doc)[0]
|
||||
|
||||
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():
|
||||
note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]), TEMPLATES)[0]
|
||||
note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]))[0]
|
||||
assert "duración total" in note and "estimada" not in note
|
||||
|
||||
|
||||
# --- 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():
|
||||
"""`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."""
|
||||
|
||||
+12
-189
@@ -6,7 +6,6 @@ sube un vídeo a un canal de verdad, y eso no es algo que deba pasar por teclear
|
||||
"""
|
||||
import json
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from src.generator import youtube as yt
|
||||
@@ -76,9 +75,6 @@ class FakeSession:
|
||||
def put(self, url, **kw):
|
||||
return self._next("PUT", url)
|
||||
|
||||
def get(self, url, **kw):
|
||||
return self._next("GET", url)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_token_cache():
|
||||
@@ -87,33 +83,14 @@ def clean_token_cache():
|
||||
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
|
||||
def uploader():
|
||||
return YouTubeUploader(client_id="cid", client_secret="secret",
|
||||
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):
|
||||
"""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})
|
||||
session = FakeSession(routes)
|
||||
client._session = lambda total: session
|
||||
return session
|
||||
|
||||
@@ -193,10 +170,8 @@ async def test_upload_does_metadata_then_bytes(uploader, video):
|
||||
assert result.video_id == "abc123"
|
||||
assert result.watch_url == "https://youtube.com/shorts/abc123"
|
||||
assert result.studio_url.endswith("/abc123/edit")
|
||||
# Token, metadatos, bytes, y la comprobación de visibilidad — que se
|
||||
# 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"
|
||||
assert [c[0] for c in session.calls] == ["POST", "POST", "PUT"]
|
||||
assert len(seen) == 3, "cada etapa avisa: autenticar, abrir, subir"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -307,32 +282,7 @@ def test_metadata_carries_the_article_link_and_the_citations():
|
||||
def test_metadata_without_article_url_still_builds():
|
||||
description = build_metadata(SPEC, "JAL 1628")["snippet"]["description"]
|
||||
assert "Full investigation" not in description
|
||||
# Sigue habiendo descripción: las fuentes y los hashtags.
|
||||
assert "FAA · 5 MARCH 1987" in description
|
||||
assert "#Shorts" in description
|
||||
|
||||
|
||||
def test_the_research_topic_never_reaches_the_description():
|
||||
"""El `topic` es la consulta de investigación, no prosa que describa el vídeo.
|
||||
|
||||
Ocurrió: el Short de Trans-en-Provence subió al canal en INGLÉS con la
|
||||
descripción encabezada por "Trans-en-Provence Francia 1981 GEPAN CNES
|
||||
análisis suelo evidencia física" — la consulta tal cual, en español y en
|
||||
sopa de palabras clave. El título ya describe el vídeo y ése sí lo escribe
|
||||
el modelo en inglés (`meta.title`).
|
||||
"""
|
||||
topic = "Trans-en-Provence Francia 1981 GEPAN CNES análisis suelo evidencia física"
|
||||
meta = build_metadata(SPEC, topic)
|
||||
assert topic not in meta["snippet"]["description"]
|
||||
for palabra in ("análisis", "suelo", "evidencia"):
|
||||
assert palabra not in meta["snippet"]["description"]
|
||||
|
||||
|
||||
def test_the_description_survives_a_spec_with_nothing_to_cite():
|
||||
"""Sin tema y sin citas queda el mínimo, no una cadena vacía."""
|
||||
description = build_metadata({"meta": {"title": "T"}, "shots": []},
|
||||
"cualquier tema")["snippet"]["description"]
|
||||
assert description.strip() == "#Shorts #UAP #UFO"
|
||||
assert "JAL 1628" in description
|
||||
|
||||
|
||||
def test_title_comes_from_the_spec_and_is_truncated():
|
||||
@@ -346,67 +296,21 @@ def test_title_falls_back_to_the_topic():
|
||||
|
||||
|
||||
def test_tags_drop_stopwords_and_duplicates_and_respect_the_limit():
|
||||
tags = build_metadata(SPEC, "The Radars of the FAA in Alaska")["snippet"]["tags"]
|
||||
tags = build_metadata(SPEC, "The Landing of the UFO in Socorro New Mexico"
|
||||
)["snippet"]["tags"]
|
||||
lowered = [t.casefold() for t in tags]
|
||||
|
||||
assert "the" not in lowered and "of" not in lowered and "in" not in lowered
|
||||
assert len(lowered) == len(set(lowered))
|
||||
assert "radars" in lowered, "el vídeo lo dice en el título"
|
||||
assert "faa" in lowered, "el vídeo lo dibuja en un plano"
|
||||
assert "socorro" in lowered
|
||||
assert sum(len(t) + 1 for t in tags) <= yt.MAX_TAGS_CHARS
|
||||
|
||||
|
||||
def test_tags_never_carry_the_spanish_that_only_lived_in_the_search_query():
|
||||
"""El caso real: el Short de Trans-en-Provence, canal en inglés.
|
||||
|
||||
Se etiquetó con `análisis`, `suelo`, `evidencia` y `física` porque las
|
||||
etiquetas salían del `topic`, que es la consulta de investigación. No se
|
||||
arregla con una lista de palabras en español —eso es el parche sin fin—
|
||||
sino cambiando de fuente: ahora salen del spec, que es inglés por
|
||||
construcción, y del tema sólo lo que el vídeo de verdad dice.
|
||||
"""
|
||||
spec = {"meta": {"id": "trans_en_provence_1981",
|
||||
"title": "Trans-en-Provence: Ground Trace, Lab Analysis"},
|
||||
"shots": [{"template": "signal_strips",
|
||||
"props": {"headline": "GEPAN ANALYSIS · SOIL"}}]}
|
||||
topic = "Trans-en-Provence Francia 1981 GEPAN CNES análisis suelo evidencia física"
|
||||
tags = build_metadata(spec, topic)["snippet"]["tags"]
|
||||
lowered = {t.casefold() for t in tags}
|
||||
|
||||
for basura in ("análisis", "suelo", "evidencia", "física", "francia", "cnes"):
|
||||
assert basura not in lowered, f"{basura} sólo estaba en la consulta"
|
||||
# Y lo que el vídeo sí dice sobrevive.
|
||||
assert "gepan" in lowered
|
||||
assert "trans-en-provence" in lowered
|
||||
|
||||
|
||||
def test_a_topic_word_survives_if_the_video_says_it():
|
||||
"""La criba no es por idioma, es por si el vídeo lo dice.
|
||||
|
||||
Zimbabwe, Brazil y Texas sólo estaban en la consulta y son justo lo que se
|
||||
busca; `BASE AÉREA TALAVERA` está en español y también, porque es el nombre
|
||||
de la base y sale dibujado. Un filtro por idioma habría tirado los dos.
|
||||
"""
|
||||
spec = {"meta": {"id": "talavera_1976", "title": "Green Humanoid at the Air Base"},
|
||||
"shots": [{"template": "data_card",
|
||||
"props": {"card_title": "BASE AÉREA TALAVERA"}}]}
|
||||
tags = build_metadata(spec, "Talavera 1976 OVNI humanoide Base Aérea")["snippet"]["tags"]
|
||||
lowered = {t.casefold() for t in tags}
|
||||
|
||||
assert "aérea" in lowered, "está dibujado en pantalla"
|
||||
assert "ovni" not in lowered and "humanoide" not in lowered
|
||||
|
||||
|
||||
def test_the_phrase_tag_is_never_cut_mid_word():
|
||||
"""En cinco de los ocho Shorts generados la frase salía partida —"...GEPAN
|
||||
CNES análisis suelo evi"— y una frase partida no la busca nadie."""
|
||||
largo = "_".join(["palabra"] * 12) # muy por encima de MAX_TAG
|
||||
spec = {"meta": {"id": largo, "title": "T"}, "shots": []}
|
||||
frase = build_metadata(spec, "x")["snippet"]["tags"][len(yt.BASE_TAGS)]
|
||||
|
||||
assert len(frase) <= yt.MAX_TAG
|
||||
assert not frase.endswith("palabr"), "cortada a media palabra"
|
||||
assert frase.split()[-1] == "palabra"
|
||||
def test_the_whole_topic_is_one_tag():
|
||||
"""Partido en palabras deja "New" y "Mexico" sueltas, que no buscan igual."""
|
||||
tags = build_metadata(SPEC, "Socorro New Mexico 1964")["snippet"]["tags"]
|
||||
assert "Socorro New Mexico 1964" in tags
|
||||
assert "Socorro" in tags, "las sueltas también, que cuestan poco"
|
||||
|
||||
|
||||
def test_tags_stay_under_the_limit_with_an_absurd_topic():
|
||||
@@ -435,84 +339,3 @@ def test_uploaded_video_urls():
|
||||
video = UploadedVideo(video_id="xyz", title="t", privacy_status="private")
|
||||
assert video.watch_url == "https://youtube.com/shorts/xyz"
|
||||
assert video.studio_url == "https://studio.youtube.com/video/xyz/edit"
|
||||
|
||||
|
||||
# --- la visibilidad, comprobada en vez de creída ----------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_video_anyone_can_watch_is_detected(uploader, video):
|
||||
"""El caso que existe para pillar: la API dice privado y el vídeo se ve.
|
||||
|
||||
Todo el flujo de revisión — informe de fundamento primero, publicar después
|
||||
— descansa en que subir NO publique. Si eso deja de ser cierto hay que
|
||||
enterarse por el parte de la subida, no por una visita al canal.
|
||||
"""
|
||||
patch(uploader, {
|
||||
"/token": TOKEN_OK,
|
||||
"/upload/youtube": FakeResp(200, {}, headers={"Location": "https://up/x"}),
|
||||
"https://up/x": FakeResp(200, VIDEO_OK),
|
||||
"/oembed": OEMBED_VISIBLE,
|
||||
})
|
||||
|
||||
result = await uploader.upload(video, build_metadata(SPEC, "x"))
|
||||
|
||||
assert result.privacy_status == "private", "la API sigue diciendo privado"
|
||||
assert result.reachable is True
|
||||
assert result.visibility_contradiction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_private_video_reports_no_contradiction(uploader, video):
|
||||
patch(uploader, {
|
||||
"/token": TOKEN_OK,
|
||||
"/upload/youtube": FakeResp(200, {}, headers={"Location": "https://up/x"}),
|
||||
"https://up/x": FakeResp(200, VIDEO_OK),
|
||||
})
|
||||
|
||||
result = await uploader.upload(video, build_metadata(SPEC, "x"))
|
||||
|
||||
assert result.reachable is False
|
||||
assert not result.visibility_contradiction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_video_visible_only_on_the_second_look_still_counts(uploader):
|
||||
"""Segundos después de subirlo, oEmbed devuelve 404 de un vídeo que sí se
|
||||
ve: aún no está indexado. Un solo vistazo daría por privado justo el vídeo
|
||||
que hay que gritar."""
|
||||
session = patch(uploader, {"/oembed": [OEMBED_HIDDEN, OEMBED_VISIBLE]})
|
||||
|
||||
assert await uploader.reachable("abc123") is True
|
||||
assert len(session.calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_hidden_looks_are_enough_to_stop_asking(uploader):
|
||||
session = patch(uploader, {"/oembed": OEMBED_HIDDEN})
|
||||
|
||||
assert await uploader.reachable("abc123") is False
|
||||
assert len(session.calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_network_failure_is_not_knowing_rather_than_privacy(uploader):
|
||||
"""No se pudo comprobar NO es lo mismo que no se ve. Devolver False aquí
|
||||
sería inventarse una garantía a partir de un fallo de red."""
|
||||
class Broken:
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *a): return False
|
||||
|
||||
def get(self, url, **kw):
|
||||
raise aiohttp.ClientError("sin red")
|
||||
|
||||
uploader._session = lambda total: Broken()
|
||||
|
||||
assert await uploader.reachable("abc123") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_upload_without_an_id_is_not_checked(uploader):
|
||||
session = patch(uploader, {"/oembed": OEMBED_VISIBLE})
|
||||
|
||||
assert await uploader.reachable("") is None
|
||||
assert session.calls == []
|
||||
|
||||
Reference in New Issue
Block a user