Files
researchowl/tests/test_bot_short_report.py
T
ChemaVXandClaude Fable 5 a13c3062b6
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
feat(short): narración — el grounding la cubre, el contrato la admite y el prompt la guía
El comprobador va primero, antes que el campo (fase 2 §12): la narración es
prosa que el modelo redacta, no una etiqueta que copia, y es donde se cuela
una cifra sin fuente. De paso, la huella de una cifra pasa a ser número +
unidad canónica: con la voz repitiendo la pantalla, '35,000 FT' y '35,000
feet' son el mismo dato y contarlos dos veces inflaría el informe del que
depende la revisión humana.

editorial_notes estima la duración CON la voz: la declarada es un suelo y sin
esto el modelo escribiría 40 s de shots, les colgaría narración y se enteraría
del Short de 65 s cuando ya está pagado.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:38:34 +00:00

168 lines
6.5 KiB
Python

"""El mensaje de revisión del Short.
Es la puerta humana: si este mensaje no sale, o sale sin los avisos, se está
publicando lo que el modelo recuerde en vez de lo que dicen las fuentes. Por eso
tiene test propio aparte del pipeline.
"""
import json
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
SPEC = {
"version": 1,
"meta": {"id": "x", "title": "X"},
"shots": [{"template": "scale_bars", "duration": 30.0, "props": {
"headline": "REPORTED SCALE",
"bars": [{"label": "BOEING 747", "value": 232, "unit": "FT"}]}}],
}
CHUNKS = [{"content": "A Boeing 747 is 232 ft long.", "url": "https://a.test/1"}]
def result_with(**kw):
base = dict(topic="Caso X", spec=SPEC, title="X", attempts=1,
cost_usd=0.0042, duration_s=30.0,
article_url="https://www.theexclusionzone.com/caso-x/",
grounding=check_grounding(SPEC, CHUNKS))
base.update(kw)
return ShortResult(**base)
def test_a_clean_report_still_says_so():
"""Un éxito silencioso enseña al lector a dejar de mirar."""
text = _claims_message(result_with())
assert "0 sin encontrar" in text
assert "1 chunks de 1 URLs" in text
assert "Coste: $0.0042" in text
def test_ungrounded_claims_are_listed_one_by_one():
invented = json.loads(json.dumps(SPEC))
invented["shots"][0]["props"]["headline"] = "41,000 FT"
text = _claims_message(result_with(spec=invented,
grounding=check_grounding(invented, CHUNKS)))
assert "1 sin encontrar" in text
assert "41,000 FT" in text
def test_a_session_without_an_article_url_says_what_to_run():
text = _claims_message(result_with(article_url=None))
assert "/generate blog en" in text
def test_render_warnings_reach_the_human():
text = _claims_message(result_with(render_warnings=[
{"template": "data_card", "text": "UNA FILA DEMASIADO LARGA",
"requested": 44, "size": 38}]))
assert "recortados" in text and "data_card" in text
def test_there_is_a_report_even_when_there_was_no_spec():
text = _claims_message(ShortResult(topic="Caso X"))
assert "Sin comprobación de fundamento" in text
assert "Coste:" in text
# --- el parte de la subida a YouTube ----------------------------------------
def _uploaded(**kw):
from src.generator.youtube import UploadedVideo
base = dict(video_id="abc123", title="X", privacy_status="private")
base.update(kw)
return UploadedVideo(**base)
def test_upload_message_leads_with_the_studio_link():
"""El enlace de Studio es la acción; el de watch es sólo comprobación."""
from src.bot.bot import _upload_message
text = _upload_message(_uploaded(), {"snippet": {"tags": ["UAP"]}},
"https://theexclusionzone.com/x/")
assert "https://studio.youtube.com/video/abc123/edit" in text
assert "https://youtube.com/shorts/abc123" in text
def test_upload_message_explains_the_private_lock():
"""Que esté privado no es un fallo del bot, y hay que decir por qué."""
from src.bot.bot import _upload_message
text = _upload_message(_uploaded(), {}, "https://x.test/")
assert "PRIVADO" in text
assert "auditoría" in text
def test_upload_message_flags_a_forced_privacy_change():
from src.bot.bot import _upload_message
text = _upload_message(_uploaded(forced_private=True), {}, "https://x.test/")
assert "forzó" in text
def test_upload_message_warns_when_the_description_has_no_article():
from src.bot.bot import _upload_message
text = _upload_message(_uploaded(), {}, None)
assert "Sin URL de artículo" in text
assert "force" in text
def test_upload_message_is_plain_text():
"""Va sin parse_mode: lleva el título del modelo, y un Markdown roto haría
que Telegram rechazara justo el mensaje que trae el enlace."""
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
class TestNarrationWarnings:
"""shortsmith manda por el mismo canal los textos recortados y los avisos
de la voz. Piden acciones distintas, así que se muestran distintos."""
def test_a_silent_shot_is_reported_as_such(self):
text = _claims_message(result_with(render_warnings=[
{"kind": "narration", "text": "shots.2.narration not spoken: piper exited 1"}]))
assert "🔇" in text and "shots.2.narration" in text
assert "recortados" not in text
def test_a_stretched_video_says_so(self):
text = _claims_message(result_with(render_warnings=[
{"kind": "timing",
"text": "narration stretched the video from 32.0s to 41.5s"}]))
assert "41.5s" in text
assert "recortados" not in text
def test_trimmed_text_and_narration_do_not_get_mixed_up(self):
text = _claims_message(result_with(render_warnings=[
{"template": "data_card", "text": "FILA LARGA", "requested": 44, "size": 38},
{"kind": "narration", "text": "no voice installed"}]))
assert "1 textos recortados" in text # sólo cuenta el de dibujo
assert "🔇 no voice installed" in text