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>
364 lines
12 KiB
Python
364 lines
12 KiB
Python
"""El pipeline del Short: orden de los pasos y fallbacks.
|
|
|
|
Todo con dobles: ni Claude ni shortsmith ni SQLite. Lo que se comprueba aquí es
|
|
que el fundamento se mira ANTES de renderizar y que ningún camino de fallo se
|
|
come el spec.
|
|
"""
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from src.config import settings
|
|
from src.generator.short import ShortProducer, ShortResult, ShortsDisabled
|
|
from src.generator.shortsmith import JobResult, ShortsmithError, ShortsmithUnavailable
|
|
from tests.test_spec_contract import TEMPLATES
|
|
|
|
SPEC = {
|
|
"version": 1,
|
|
"meta": {"id": "jal1628", "title": "JAL 1628"},
|
|
"shots": [
|
|
{"template": "radar_sweep", "duration": 15.0, "props": {"headline": "3 RADARS"}},
|
|
{"template": "scale_bars", "duration": 15.0, "props": {
|
|
"headline": "REPORTED SCALE",
|
|
"bars": [{"label": "BOEING 747", "value": 232, "unit": "FT"}]}},
|
|
],
|
|
}
|
|
|
|
CHUNKS = [{
|
|
"content": "Three radars tracked the object. A Boeing 747 is 232 ft long.",
|
|
"url": "https://faa.example/jal1628",
|
|
"title": "FAA file",
|
|
"source_type": "web",
|
|
}]
|
|
|
|
|
|
class FakeDB:
|
|
def __init__(self, article_url=None):
|
|
self.article_url = article_url
|
|
self.saved: list[tuple] = []
|
|
|
|
async def get_session(self, session_id):
|
|
return {"id": session_id, "topic": "JAL 1628 Alaska 1986"}
|
|
|
|
async def get_article_url(self, session_id):
|
|
return self.article_url
|
|
|
|
async def save_output(self, session_id, output_type, content):
|
|
self.saved.append((session_id, output_type, content))
|
|
return len(self.saved)
|
|
|
|
async def log_api_call(self, *a, **kw):
|
|
return None
|
|
|
|
|
|
class FakeProcessor:
|
|
def __init__(self, chunks=None):
|
|
self.chunks = CHUNKS if chunks is None else chunks
|
|
|
|
async def rag_chunks(self, session_id, query, top_k=20):
|
|
return self.chunks
|
|
|
|
|
|
class FakeClient:
|
|
"""shortsmith de mentira. `fail_at` decide dónde se rompe."""
|
|
|
|
def __init__(self, fail_at=None, job_status="done", warnings=None):
|
|
self.fail_at = fail_at
|
|
self.job_status = job_status
|
|
self.warnings = warnings or []
|
|
self.rendered = None
|
|
|
|
async def templates(self, refresh=False):
|
|
if self.fail_at == "templates":
|
|
raise ShortsmithUnavailable("no hay nadie al otro lado")
|
|
return TEMPLATES
|
|
|
|
async def render(self, spec):
|
|
if self.fail_at == "render":
|
|
raise ShortsmithUnavailable("conexión rechazada")
|
|
self.rendered = spec
|
|
return "job-1"
|
|
|
|
async def poll(self, job_id, on_progress=None, **kw):
|
|
if self.fail_at == "poll":
|
|
raise ShortsmithError("job atascado")
|
|
if on_progress:
|
|
await on_progress(0.5, "running")
|
|
return JobResult(job_id, self.job_status, 1.0, self.warnings,
|
|
"OOMKilled" if self.job_status == "error" else None)
|
|
|
|
async def fetch_video(self, job_id):
|
|
if self.fail_at == "fetch":
|
|
raise ShortsmithError("404 del vídeo")
|
|
return b"\x00\x00\x00 ftypisom" + b"\x00" * 2048
|
|
|
|
|
|
def llm_returning(*responses):
|
|
queue = list(responses)
|
|
|
|
async def call(system, prompt):
|
|
call.prompts.append(prompt)
|
|
return queue.pop(0) if len(queue) > 1 else queue[0]
|
|
|
|
call.prompts = []
|
|
return call
|
|
|
|
|
|
def producer(tmp_path, monkeypatch, *, client=None, llm=None, db=None, processor=None):
|
|
monkeypatch.setattr(settings, "shorts_dir", str(tmp_path / "shorts"))
|
|
monkeypatch.setattr(settings, "shortsmith_enabled", True)
|
|
return ShortProducer(
|
|
db or FakeDB(),
|
|
processor or FakeProcessor(),
|
|
client=client or FakeClient(),
|
|
llm_call=llm or llm_returning(json.dumps(SPEC)),
|
|
)
|
|
|
|
|
|
# --- camino feliz -----------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_happy_path_writes_the_mp4_to_disk(tmp_path, monkeypatch):
|
|
db = FakeDB(article_url="https://www.theexclusionzone.com/jal-1628/")
|
|
p = producer(tmp_path, monkeypatch, db=db)
|
|
|
|
result = await p.produce(153)
|
|
|
|
assert result.has_video
|
|
assert result.video_path.endswith("153.mp4")
|
|
assert open(result.video_path, "rb").read()[:12].endswith(b"ftypisom")
|
|
assert result.title == "JAL 1628"
|
|
assert result.duration_s == 30.0
|
|
assert result.article_url.endswith("/jal-1628/")
|
|
assert result.failure is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_spec_is_saved_before_the_render(tmp_path, monkeypatch):
|
|
"""Si el render se cae, la parte cara ya está en la DB y /short_spec la
|
|
devuelve."""
|
|
db = FakeDB()
|
|
p = producer(tmp_path, monkeypatch, db=db, client=FakeClient(fail_at="render"))
|
|
|
|
result = await p.produce(153)
|
|
|
|
assert db.saved and db.saved[0][1] == "short_en"
|
|
assert json.loads(db.saved[0][2])["meta"]["id"] == "jal1628"
|
|
assert not result.has_video
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_grounding_runs_before_rendering(tmp_path, monkeypatch):
|
|
"""El informe existe aunque el render no llegue a empezar: ese es el orden
|
|
del §12 y es lo que hace que la revisión humana llegue igual."""
|
|
p = producer(tmp_path, monkeypatch, client=FakeClient(fail_at="render"))
|
|
|
|
result = await p.produce(153)
|
|
|
|
assert result.grounding is not None
|
|
assert result.grounding.total > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ungrounded_claims_do_not_block_the_render(tmp_path, monkeypatch):
|
|
"""Un dato sin encontrar puede ser una fabricación o un artefacto de
|
|
formato. Lo decide una persona: el vídeo se entrega con el aviso al lado."""
|
|
invented = json.loads(json.dumps(SPEC))
|
|
invented["shots"][0]["props"]["headline"] = "41,000 FT"
|
|
p = producer(tmp_path, monkeypatch, llm=llm_returning(json.dumps(invented)))
|
|
|
|
result = await p.produce(153)
|
|
|
|
assert result.has_video
|
|
assert [c.text for c in result.grounding.ungrounded] == ["41,000 FT"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_article_url_reaches_the_prompt(tmp_path, monkeypatch):
|
|
llm = llm_returning(json.dumps(SPEC))
|
|
p = producer(tmp_path, monkeypatch,
|
|
db=FakeDB(article_url="https://www.theexclusionzone.com/jal-1628/"),
|
|
llm=llm)
|
|
|
|
await p.produce(153)
|
|
|
|
assert "https://www.theexclusionzone.com/jal-1628/" in llm.prompts[0]
|
|
|
|
|
|
# --- fallbacks --------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("fail_at", ["render", "poll", "fetch"])
|
|
async def test_every_render_failure_still_returns_the_spec(tmp_path, monkeypatch, fail_at):
|
|
p = producer(tmp_path, monkeypatch, client=FakeClient(fail_at=fail_at))
|
|
|
|
result = await p.produce(153)
|
|
|
|
assert not result.has_video
|
|
assert result.failure
|
|
assert json.loads(result.spec_json)["meta"]["id"] == "jal1628"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_job_that_errors_is_reported_with_its_reason(tmp_path, monkeypatch):
|
|
p = producer(tmp_path, monkeypatch, client=FakeClient(job_status="error"))
|
|
|
|
result = await p.produce(153)
|
|
|
|
assert not result.has_video
|
|
assert "OOMKilled" in result.failure
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unwritable_spec_still_returns_the_last_attempt(tmp_path, monkeypatch):
|
|
"""Tres intentos fallidos no son motivo para tirar la generación."""
|
|
broken = json.loads(json.dumps(SPEC))
|
|
broken["meta"]["id"] = "MAYÚSCULAS Y ESPACIOS"
|
|
p = producer(tmp_path, monkeypatch, llm=llm_returning(json.dumps(broken)))
|
|
|
|
result = await p.produce(153)
|
|
|
|
assert not result.has_video
|
|
assert result.attempts == 3
|
|
assert result.spec["meta"]["id"] == "MAYÚSCULAS Y ESPACIOS"
|
|
assert "no pasó la validación" in result.failure
|
|
assert result.grounding is None # no hay spec válido que comprobar
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_response_that_is_not_json_at_all_comes_back_raw(tmp_path, monkeypatch):
|
|
p = producer(tmp_path, monkeypatch,
|
|
llm=llm_returning("Lo siento, no puedo ayudarte con eso."))
|
|
|
|
result = await p.produce(153)
|
|
|
|
assert result.spec is None
|
|
assert "Lo siento" in result.raw_response
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_render_warnings_travel_with_the_result(tmp_path, monkeypatch):
|
|
warnings = [{"template": "data_card", "text": "UNA FILA MUY LARGA",
|
|
"requested": 44, "size": 38}]
|
|
p = producer(tmp_path, monkeypatch, client=FakeClient(warnings=warnings))
|
|
|
|
result = await p.produce(153)
|
|
|
|
assert result.has_video
|
|
assert result.render_warnings[0]["template"] == "data_card"
|
|
|
|
|
|
# --- interruptores y precondiciones -----------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_kill_switch_says_so_instead_of_crashing(tmp_path, monkeypatch):
|
|
p = producer(tmp_path, monkeypatch)
|
|
monkeypatch.setattr(settings, "shortsmith_enabled", False)
|
|
|
|
with pytest.raises(ShortsDisabled):
|
|
await p.produce(153)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unreachable_renderer_is_named_clearly(tmp_path, monkeypatch):
|
|
"""Sin contrato no hay prompt que escribir: aquí no hay fallback posible y
|
|
el mensaje lo dice."""
|
|
p = producer(tmp_path, monkeypatch, client=FakeClient(fail_at="templates"))
|
|
|
|
with pytest.raises(ShortsmithUnavailable):
|
|
await p.produce(153)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_session_without_chunks_says_what_to_run(tmp_path, monkeypatch):
|
|
p = producer(tmp_path, monkeypatch, processor=FakeProcessor(chunks=[]))
|
|
|
|
with pytest.raises(ValueError, match="/process"):
|
|
await p.produce(153)
|
|
|
|
|
|
def test_domain_is_drawn_without_protocol_or_www(monkeypatch):
|
|
monkeypatch.setattr(settings, "ghost_url_en", "https://www.theexclusionzone.com")
|
|
assert ShortProducer(FakeDB(), FakeProcessor(), client=FakeClient())._domain() \
|
|
== "THEEXCLUSIONZONE.COM"
|
|
|
|
|
|
def test_short_result_without_a_spec_has_an_empty_json():
|
|
assert ShortResult(topic="x").spec_json == ""
|
|
|
|
|
|
# --- limpieza ---------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_purging_a_session_takes_its_video_with_it(tmp_path, monkeypatch):
|
|
"""Un MP4 huérfano en el PVC es negligible contra 5 Gi y es arqueología
|
|
dentro de un año."""
|
|
import time
|
|
|
|
import aiosqlite
|
|
|
|
from src.db import database
|
|
from src.db.database import ResearchDB
|
|
|
|
shorts = tmp_path / "shorts"
|
|
shorts.mkdir()
|
|
(shorts / "1.mp4").write_bytes(b"viejo")
|
|
(shorts / "2.mp4").write_bytes(b"reciente")
|
|
monkeypatch.setattr(settings, "shorts_dir", str(shorts))
|
|
|
|
conn = await aiosqlite.connect(tmp_path / "t.db")
|
|
conn.row_factory = aiosqlite.Row
|
|
await conn.executescript(database.SCHEMA)
|
|
old, now = time.time() - 90 * 86400, time.time()
|
|
await conn.execute(
|
|
"INSERT INTO research_sessions (id, topic, status, telegram_chat_id,"
|
|
" created_at, updated_at) VALUES (1,'viejo','saturated',1,?,?)", (old, old))
|
|
await conn.execute(
|
|
"INSERT INTO research_sessions (id, topic, status, telegram_chat_id,"
|
|
" created_at, updated_at) VALUES (2,'nuevo','saturated',1,?,?)", (now, now))
|
|
await conn.commit()
|
|
|
|
counts = await ResearchDB(conn).purge_old_sessions(30)
|
|
await conn.close()
|
|
|
|
assert counts["shorts"] == 1
|
|
assert not (shorts / "1.mp4").exists()
|
|
assert (shorts / "2.mp4").exists(), "la sesión reciente conserva su vídeo"
|
|
|
|
|
|
@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
|
|
`get_article_url` no filtrara las filas short_en, el siguiente Short de esa
|
|
sesión enlazaría al Short anterior: un bucle silencioso, porque la URL es
|
|
válida y nadie la mira dos veces."""
|
|
import time
|
|
|
|
import aiosqlite
|
|
|
|
from src.db import database
|
|
from src.db.database import OutputType, ResearchDB
|
|
|
|
conn = await aiosqlite.connect(tmp_path / "urls.db")
|
|
conn.row_factory = aiosqlite.Row
|
|
await conn.executescript(database.SCHEMA)
|
|
now = time.time()
|
|
await conn.execute(
|
|
"INSERT INTO research_sessions (id, topic, status, telegram_chat_id,"
|
|
" created_at, updated_at) VALUES (1,'x','saturated',1,?,?)", (now, now))
|
|
await conn.execute(
|
|
"INSERT INTO outputs (session_id, output_type, content, created_at,"
|
|
" published_url) VALUES (1,?,'...',?,?)",
|
|
(OutputType.BLOG.value, now, "https://theexclusionzone.com/x/"))
|
|
# El Short, subido DESPUÉS: es la fila más reciente con URL.
|
|
await conn.execute(
|
|
"INSERT INTO outputs (session_id, output_type, content, created_at,"
|
|
" published_url) VALUES (1,?,'{}',?,?)",
|
|
(OutputType.SHORT_EN.value, now + 60, "https://youtube.com/shorts/abc"))
|
|
await conn.commit()
|
|
|
|
url = await ResearchDB(conn).get_article_url(1)
|
|
await conn.close()
|
|
|
|
assert url == "https://theexclusionzone.com/x/"
|