Files
researchowl/tests/test_short_producer.py
T
ChemaVXandClaude Fable 5 93a506b636
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
feat(short): la paleta de audio viva — GET /audio en el contrato, el prompt y el bucle de edición
shortsmith ya compone la banda sonora (sonar); ahora publica una paleta
(pulse, static) y este repo la consume en vivo: el prompt la ofrece con sus
notas de mood, validate_spec la usa como fuente de verdad para audio.preset,
y editar el preset en /short_spec es la manera gratis de escucharlas. Sin
/audio (404 o caída) todo cae a la paleta base y nada deja de renderizar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:41:27 +00:00

511 lines
18 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
PRESETS = {
"sonar": "low drone — the case-file mood",
"pulse": "sub-bass heartbeat — debunks",
"static": "shortwave static — document drops",
"none": "digital silence",
}
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 audio_presets(self, refresh=False):
if self.fail_at == "audio":
raise ShortsmithUnavailable("sin /audio")
return dict(PRESETS)
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/"
# --- re-render de un spec editado (la vuelta de /short_spec) ----------------
async def _llm_prohibido(system, prompt):
raise AssertionError("el re-render no debe llamar al LLM: este camino es gratis")
@pytest.mark.asyncio
async def test_rerender_writes_the_mp4_without_touching_the_llm(tmp_path, monkeypatch):
from src.db.database import OutputType
db = FakeDB(article_url="https://www.theexclusionzone.com/jal-1628/")
p = producer(tmp_path, monkeypatch, db=db, llm=_llm_prohibido)
result = await p.rerender(153, json.loads(json.dumps(SPEC)))
assert result.has_video
assert result.cost_usd == 0.0
assert result.article_url == "https://www.theexclusionzone.com/jal-1628/"
# El spec editado queda guardado ANTES del render: /upload_short saca los
# metadatos del último spec y tienen que describir este vídeo.
assert db.saved and db.saved[-1][1] == OutputType.SHORT_EN
@pytest.mark.asyncio
async def test_rerender_rechecks_the_grounding_of_the_edited_strings(tmp_path, monkeypatch):
"""La edición a mano puede meter una cifra nueva: el informe se rehace."""
edited = json.loads(json.dumps(SPEC))
edited["shots"][0]["props"]["headline"] = "41,000 FT"
p = producer(tmp_path, monkeypatch, llm=_llm_prohibido)
result = await p.rerender(153, edited)
assert result.has_video
assert [c.text for c in result.grounding.ungrounded] == ["41,000 FT"]
@pytest.mark.asyncio
async def test_an_edited_spec_that_breaks_the_contract_never_renders(tmp_path, monkeypatch):
"""Los errores vuelven con su ruta verbatim, igual que al modelo."""
broken = json.loads(json.dumps(SPEC))
broken["shots"][0]["template"] = "no_existe"
client = FakeClient()
p = producer(tmp_path, monkeypatch, client=client, llm=_llm_prohibido)
result = await p.rerender(153, broken)
assert not result.has_video
assert "shots.0.template" in result.failure
assert client.rendered is None
# El spec editado se conserva para poder corregirlo y reenviarlo.
assert json.loads(result.spec_json)["shots"][0]["template"] == "no_existe"
@pytest.mark.asyncio
async def test_rerender_on_a_purged_session_says_it_could_not_check(tmp_path, monkeypatch):
"""Sin chunks no hay contra qué mirar: se renderiza igual, avisando."""
p = producer(tmp_path, monkeypatch, processor=FakeProcessor(chunks=[]),
llm=_llm_prohibido)
result = await p.rerender(153, json.loads(json.dumps(SPEC)))
assert result.has_video
assert result.grounding is None
assert any("NO se ha comprobado" in n for n in result.notes)
@pytest.mark.asyncio
async def test_rerender_failures_keep_the_spec_like_produce_does(tmp_path, monkeypatch):
p = producer(tmp_path, monkeypatch, client=FakeClient(fail_at="render"),
llm=_llm_prohibido)
result = await p.rerender(153, json.loads(json.dumps(SPEC)))
assert not result.has_video
assert "shortsmith no responde" in result.failure
assert json.loads(result.spec_json)["meta"]["id"] == "jal1628"
@pytest.mark.asyncio
async def test_rerender_respects_the_kill_switch(tmp_path, monkeypatch):
from src.generator.short import ShortsDisabled
p = producer(tmp_path, monkeypatch, llm=_llm_prohibido)
monkeypatch.setattr(settings, "shortsmith_enabled", False)
with pytest.raises(ShortsDisabled):
await p.rerender(153, json.loads(json.dumps(SPEC)))
# --- la paleta de audio (GET /audio) ----------------------------------------
@pytest.mark.asyncio
async def test_an_edited_preset_from_the_live_palette_renders(tmp_path, monkeypatch):
"""El retoque para el que existe el bucle de edición: cambiar la banda
sonora a "pulse" sin pagar otra generación."""
edited = json.loads(json.dumps(SPEC))
edited["audio"] = {"preset": "pulse"}
client = FakeClient()
p = producer(tmp_path, monkeypatch, client=client, llm=_llm_prohibido)
result = await p.rerender(153, edited)
assert result.has_video
assert client.rendered["audio"]["preset"] == "pulse"
@pytest.mark.asyncio
async def test_a_preset_the_renderer_does_not_know_is_rejected_with_the_palette(
tmp_path, monkeypatch):
edited = json.loads(json.dumps(SPEC))
edited["audio"] = {"preset": "vaporwave"}
client = FakeClient()
p = producer(tmp_path, monkeypatch, client=client, llm=_llm_prohibido)
result = await p.rerender(153, edited)
assert not result.has_video
assert "audio.preset" in result.failure and "pulse" in result.failure
assert client.rendered is None
@pytest.mark.asyncio
async def test_a_dead_audio_endpoint_never_blocks_a_sonar_render(tmp_path, monkeypatch):
"""La paleta mejora el prompt, no lo define: sin /audio se cae a la base y
un spec con sonar renderiza igual."""
p = producer(tmp_path, monkeypatch, client=FakeClient(fail_at="audio"),
llm=_llm_prohibido)
result = await p.rerender(153, json.loads(json.dumps(SPEC)))
assert result.has_video