Fase 3, con una corrección sobre lo que decía la §11 de la spec de fase 2. El bloqueo no es OAuth. Los vídeos subidos por videos.insert desde un proyecto de API sin auditar quedan restringidos a privado, y el candado es del proyecto, no del vídeo: no se abre desde Studio, se abre pasando la auditoría de cumplimiento de Google. Así que esto no publica. Deja el vídeo en el canal con los metadatos puestos y devuelve el enlace de Studio para que una persona lo revise y le dé a publicar — la misma forma que /publish con los borradores de Ghost, y por la misma razón: el informe de fundamento no sirve de nada si el vídeo ya está subido cuando lo lees. Comando aparte, no un paso de /generate short_en. - src/generator/youtube.py: refresco de token contra oauth2.googleapis.com, subida resumable en dos pasos y metadatos derivados del shot spec ya guardado (título, enlace al artículo, fuentes que el Short cita en pantalla, etiquetas del tema). Sin google-api-python-client: es síncrono y bloquearía el loop del bot; son dos peticiones HTTP y el repo ya firma los JWT de Ghost a mano. aiohttp con SAFE_ACCEPT_ENCODING como todo lo demás. - Scope youtube.upload y nada más: un token filtrado no puede leer ni borrar nada del canal, sólo subir. - forced_private detecta que YouTube devolvió "private" cuando se pidió otra cosa, y el aviso lo dice. Es la firma del candado, y tragárselo haría creer que salió publicado. - invalid_grant se traduce a su causa real: la pantalla de consentimiento quedó en "Testing" y Google revoca esos tokens a los siete días. Es el fallo que menos se adivina y el que más probable es encontrarse. - get_article_url ahora excluye las filas short_en. Su published_url pasa a ser la URL de YouTube, y sin el filtro el siguiente Short de la sesión enlazaría al Short anterior: un bucle silencioso, porque la URL es válida y nadie la mira dos veces. - scripts/youtube_oauth.py, sólo stdlib: corre en el portátil, no en el contenedor, y no debería exigir instalar nada. - Las tres claves van optional:true en el Deployment. Sin eso, una clave que aún no está en Infisical deja el pod en CreateContainerConfigError y tira el bot entero por una función que nadie ha pedido todavía. 30 tests nuevos contra un servidor falso. No hay test en vivo a propósito: cualquier ejecución real sube un vídeo a un canal de verdad, y eso no es algo que deba pasar por teclear pytest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
4.0 KiB
Python
112 lines
4.0 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
|
|
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_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
|