Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
30 días no medía cuánto hay que guardar: medía cuánto se creía que 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 empezara a correr—, y el 31 de agosto se re-renderizaron tres de ellos. Con 90 días, lo que se purga es lo que de verdad nadie va a volver a mirar. El plazo se escribe UNA vez. Estaba en el arranque, en `/purge` y en la firma por defecto: tres copias de un plazo son tres plazos esperando a divergir, y ese es el mismo error de clase que tenía el tope de palabras por línea antes de derivarse de la duración del plano. El test nuevo fija la ventana por COMPORTAMIENTO y no por el número: un output de 45 días sobrevive y uno de 200 no. Estrecharla por debajo de mes y medio lo tumba, que es donde empieza a llevarse cosas que aún se usan. Y el fixture de purga pasa a llamar al valor por defecto cuando no se le dice otro, porque pasarle el plazo a mano en cada test dejaba el de producción sin que ninguna prueba lo mirara. Suite: 279 pasan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
655 lines
24 KiB
Python
655 lines
24 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_data(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"
|
|
|
|
|
|
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
|
|
`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
|