Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
`/upload_short` contaba lo que la respuesta de la subida decía del vídeo. Eso es la palabra de la API sobre sí misma, y todo el flujo de revisión descansa en ella: el informe de fundamento se lee ANTES de publicar sólo si subir no publica. El 2026-08-12, mirando un vídeo recién subido, la respuesta decía `privacyStatus: private` y el vídeo se veía sin sesión — resultó ser un clic humano en Studio y no un fallo, pero el episodio dejó claro que no había forma de distinguir un caso del otro. Ahora se contrasta: oEmbed contesta 200 a un vídeo que se ve sin sesión y 404 a uno que no. Sin credenciales, sin tocar el scope — `youtube.upload` no puede preguntar por el estado de un vídeo, y ampliarlo a uno que sí pueda significa darle a un token de subida permiso para vaciar el canal. Dos decisiones que van con esto: - **Sólo el 200 es una prueba.** Un 404 no demuestra que el vídeo sea privado: también lo devuelve uno que YouTube aún no ha indexado. Por eso el negativo se mira dos veces y, si sigue negativo, se cuenta como "no se ve desde fuera", no como "es privado". - **No haber podido comprobar no es haber comprobado que no.** Un fallo de red deja `reachable=None` y el parte lo dice, en vez de heredar la garantía que no tiene. Cuando la API dice privado y el vídeo se ve, el aviso va en la PRIMERA línea del mensaje de Telegram: enterarse tiene que costar cero atención. Verificado contra la realidad — el mismo vídeo daba True antes de ocultarlo y False después; un vídeo borrado y uno privado dan False, y uno público True. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
8.7 KiB
Python
220 lines
8.7 KiB
Python
"""El mensaje de revisión del Short.
|
|
|
|
Es la puerta humana: si este mensaje no sale, o sale sin los avisos, se está
|
|
publicando lo que el modelo recuerde en vez de lo que dicen las fuentes. Por eso
|
|
tiene test propio aparte del pipeline.
|
|
"""
|
|
import json
|
|
|
|
from src.bot.bot import _claims_message, _session_from_filename, _video_predates_spec
|
|
from src.generator.grounding import check_grounding
|
|
from src.generator.short import ShortResult
|
|
|
|
SPEC = {
|
|
"version": 1,
|
|
"meta": {"id": "x", "title": "X"},
|
|
"shots": [{"template": "scale_bars", "duration": 30.0, "props": {
|
|
"headline": "REPORTED SCALE",
|
|
"bars": [{"label": "BOEING 747", "value": 232, "unit": "FT"}]}}],
|
|
}
|
|
CHUNKS = [{"content": "A Boeing 747 is 232 ft long.", "url": "https://a.test/1"}]
|
|
|
|
|
|
def result_with(**kw):
|
|
base = dict(topic="Caso X", spec=SPEC, title="X", attempts=1,
|
|
cost_usd=0.0042, duration_s=30.0,
|
|
article_url="https://www.theexclusionzone.com/caso-x/",
|
|
grounding=check_grounding(SPEC, CHUNKS))
|
|
base.update(kw)
|
|
return ShortResult(**base)
|
|
|
|
|
|
def test_a_clean_report_still_says_so():
|
|
"""Un éxito silencioso enseña al lector a dejar de mirar."""
|
|
text = _claims_message(result_with())
|
|
assert "0 sin encontrar" in text
|
|
assert "1 chunks de 1 URLs" in text
|
|
assert "Coste: $0.0042" in text
|
|
|
|
|
|
def test_ungrounded_claims_are_listed_one_by_one():
|
|
invented = json.loads(json.dumps(SPEC))
|
|
invented["shots"][0]["props"]["headline"] = "41,000 FT"
|
|
text = _claims_message(result_with(spec=invented,
|
|
grounding=check_grounding(invented, CHUNKS)))
|
|
assert "1 sin encontrar" in text
|
|
assert "41,000 FT" in text
|
|
|
|
|
|
def test_a_session_without_an_article_url_says_what_to_run():
|
|
text = _claims_message(result_with(article_url=None))
|
|
assert "/generate blog en" in text
|
|
|
|
|
|
def test_render_warnings_reach_the_human():
|
|
text = _claims_message(result_with(render_warnings=[
|
|
{"template": "data_card", "text": "UNA FILA DEMASIADO LARGA",
|
|
"requested": 44, "size": 38}]))
|
|
assert "recortados" in text and "data_card" in text
|
|
|
|
|
|
def test_there_is_a_report_even_when_there_was_no_spec():
|
|
text = _claims_message(ShortResult(topic="Caso X"))
|
|
assert "Sin comprobación de fundamento" in text
|
|
assert "Coste:" in text
|
|
|
|
|
|
# --- el parte de la subida a YouTube ----------------------------------------
|
|
|
|
def _uploaded(**kw):
|
|
from src.generator.youtube import UploadedVideo
|
|
base = dict(video_id="abc123", title="X", privacy_status="private")
|
|
base.update(kw)
|
|
return UploadedVideo(**base)
|
|
|
|
|
|
def test_upload_message_leads_with_the_studio_link():
|
|
"""El enlace de Studio es la acción; el de watch es sólo comprobación."""
|
|
from src.bot.bot import _upload_message
|
|
text = _upload_message(_uploaded(), {"snippet": {"tags": ["UAP"]}},
|
|
"https://theexclusionzone.com/x/")
|
|
assert "https://studio.youtube.com/video/abc123/edit" in text
|
|
assert "https://youtube.com/shorts/abc123" in text
|
|
|
|
|
|
def test_upload_message_explains_the_private_lock():
|
|
"""Que esté privado no es un fallo del bot, y hay que decir por qué."""
|
|
from src.bot.bot import _upload_message
|
|
text = _upload_message(_uploaded(), {}, "https://x.test/")
|
|
assert "PRIVADO" in text
|
|
assert "auditoría" in text
|
|
|
|
|
|
def test_upload_message_flags_a_forced_privacy_change():
|
|
from src.bot.bot import _upload_message
|
|
text = _upload_message(_uploaded(forced_private=True), {}, "https://x.test/")
|
|
assert "forzó" in text
|
|
|
|
|
|
def test_upload_message_warns_when_the_description_has_no_article():
|
|
from src.bot.bot import _upload_message
|
|
text = _upload_message(_uploaded(), {}, None)
|
|
assert "Sin URL de artículo" in text
|
|
assert "force" in text
|
|
|
|
|
|
def test_upload_message_shouts_when_the_video_is_already_watchable():
|
|
"""El caso serio, y va PRIMERO en el mensaje.
|
|
|
|
Si subir publica, el informe de fundamento se lee cuando el vídeo ya está en
|
|
la calle — el orden entero del flujo deja de significar nada. Enterarse
|
|
tiene que costar cero atención: en la primera línea o no sirve.
|
|
"""
|
|
from src.bot.bot import _upload_message
|
|
text = _upload_message(_uploaded(reachable=True), {}, "https://x.test/")
|
|
|
|
assert "SE VE SIN INICIAR SESIÓN" in text.split("\n")[0]
|
|
assert "studio.youtube.com/video/abc123/edit" in text
|
|
# Y no se cuenta a la vez el cuento tranquilizador del candado.
|
|
assert "auditoría" not in text
|
|
|
|
|
|
def test_upload_message_confirms_a_video_nobody_can_watch():
|
|
from src.bot.bot import _upload_message
|
|
text = _upload_message(_uploaded(reachable=False), {}, "https://x.test/")
|
|
|
|
assert "no se ve sin sesión" in text
|
|
assert "SE VE SIN INICIAR SESIÓN" not in text
|
|
|
|
|
|
def test_upload_message_admits_when_it_could_not_check():
|
|
"""No haber podido comprobar no es haber comprobado que no. Decir "privado"
|
|
a secas aquí sería dar por garantía lo que sólo es la palabra de la API."""
|
|
from src.bot.bot import _upload_message
|
|
text = _upload_message(_uploaded(reachable=None), {}, "https://x.test/")
|
|
|
|
assert "No se pudo comprobar" in text
|
|
assert "Míralo en Studio" in text
|
|
|
|
|
|
def test_upload_message_is_plain_text():
|
|
"""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."""
|
|
from src.bot.bot import _upload_message
|
|
text = _upload_message(_uploaded(title="JAL 1628: *three* radars_"), {}, None)
|
|
assert "*three*" in text and "radars_" in text
|
|
|
|
|
|
# --- guard de vídeo viejo en /upload_short ----------------------------------
|
|
|
|
class TestStaleVideoGuard:
|
|
"""`produce` guarda el spec ANTES de renderizar: si un re-intento falla,
|
|
en disco queda el vídeo de la vuelta anterior y subirlo le pondría los
|
|
metadatos del spec nuevo a un vídeo viejo."""
|
|
|
|
def test_a_fresh_render_is_never_stale(self):
|
|
# El MP4 se escribe ~1 min después de guardarse el spec.
|
|
assert not _video_predates_spec(1000.0 + 60, 1000.0)
|
|
|
|
def test_clock_jitter_does_not_cry_wolf(self):
|
|
assert not _video_predates_spec(1000.0 - 3, 1000.0)
|
|
|
|
def test_a_video_hours_older_than_the_spec_is_flagged(self):
|
|
assert _video_predates_spec(1000.0 - 3600, 1000.0)
|
|
|
|
|
|
class TestSessionFromFilename:
|
|
"""Telegram conserva el nombre del fichero al reenviarlo: el id que puso
|
|
/short_spec manda sobre la sesión activa del chat."""
|
|
|
|
def test_the_short_spec_filename_declares_its_session(self):
|
|
assert _session_from_filename("short_166_spec.json") == 166
|
|
|
|
def test_a_foreign_filename_falls_back_to_none(self):
|
|
assert _session_from_filename("myspec.json") is None
|
|
assert _session_from_filename("") is None
|
|
assert _session_from_filename(None) is None
|
|
|
|
|
|
class TestNarrationWarnings:
|
|
"""shortsmith manda por el mismo canal los textos recortados y los avisos
|
|
de la voz. Piden acciones distintas, así que se muestran distintos."""
|
|
|
|
def test_a_silent_shot_is_reported_as_such(self):
|
|
text = _claims_message(result_with(render_warnings=[
|
|
{"kind": "narration", "text": "shots.2.narration not spoken: piper exited 1"}]))
|
|
assert "🔇" in text and "shots.2.narration" in text
|
|
assert "recortados" not in text
|
|
|
|
def test_a_stretched_video_says_so(self):
|
|
text = _claims_message(result_with(render_warnings=[
|
|
{"kind": "timing",
|
|
"text": "narration stretched the video from 32.0s to 41.5s"}]))
|
|
assert "41.5s" in text
|
|
assert "recortados" not in text
|
|
|
|
def test_trimmed_text_and_narration_do_not_get_mixed_up(self):
|
|
text = _claims_message(result_with(render_warnings=[
|
|
{"template": "data_card", "text": "FILA LARGA", "requested": 44, "size": 38},
|
|
{"kind": "narration", "text": "no voice installed"}]))
|
|
assert "1 textos recortados" in text # sólo cuenta el de dibujo
|
|
assert "🔇 no voice installed" in text
|
|
|
|
|
|
class TestSevereShrink:
|
|
"""Un recorte leve es cosmético; uno grave deja el texto ilegible justo
|
|
donde importaba. Mezclarlos entrena al lector a ignorar los dos."""
|
|
|
|
def test_a_severe_shrink_is_called_out(self):
|
|
text = _claims_message(result_with(render_warnings=[
|
|
{"template": "document_quote", "text": "“UNA CITA MUY LARGA”",
|
|
"requested": 84, "size": 20, "severe": True}]))
|
|
assert "🔴" in text and "ILEGIBLES" in text
|
|
|
|
def test_a_cosmetic_shrink_stays_quiet(self):
|
|
text = _claims_message(result_with(render_warnings=[
|
|
{"template": "scale_bars", "text": "PHYSICAL EVIDENCE",
|
|
"requested": 92, "size": 84, "severe": False}]))
|
|
assert "recortados" in text
|
|
assert "ILEGIBLES" not in text and "🔴" not in text
|