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
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:
co-authored by
Claude Fable 5
parent
ec3e6d05c0
commit
cc1f0cab93
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user