fix(short): cerrar el bucle de edición y el desfase spec/vídeo en la subida
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m17s

Dos huecos de la revisión:

1. /short_spec prometía re-renderizar el spec editado pero no existía el
   camino de vuelta. Ahora un .json adjunto se valida contra el contrato
   vivo (errores con su ruta verbatim), se re-comprueba el fundamento (la
   edición pudo meter una cifra nueva), se guarda como output nuevo y se
   renderiza. Sin LLM: este camino es gratis. La sesión sale del nombre
   del fichero (short_{id}_spec.json), que Telegram conserva al reenviar.

2. /upload_short podía subir un vídeo viejo con metadatos nuevos: produce
   guarda el spec ANTES de renderizar, así que un re-intento con render
   fallido deja en disco el MP4 de la vuelta anterior. Ahora se compara el
   mtime del vídeo con el created_at del spec y se niega (force lo salta).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-08-05 15:46:06 +00:00
co-authored by Claude Fable 5
parent ec3e6d05c0
commit cc1f0cab93
5 changed files with 363 additions and 34 deletions
+32 -1
View File
@@ -6,7 +6,7 @@ tiene test propio aparte del pipeline.
"""
import json
from src.bot.bot import _claims_message
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
@@ -109,3 +109,34 @@ def test_upload_message_is_plain_text():
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
+89
View File
@@ -361,3 +361,92 @@ async def test_the_youtube_url_never_passes_for_an_article_url(tmp_path):
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)))