Files
researchowl/tests/test_short_golden.py
ChemaVXandClaude Opus 5 20c8d03aa7
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
feat(short): generación y render de Shorts vía shortsmith
Añade /generate short_en y /short_spec. El pipeline genera un shot spec
con Haiku, verifica cada cifra, fecha y cita contra los chunks de la
sesión, lo renderiza en shortsmith y entrega el MP4 por Telegram junto
a un informe de claims.

- ShortsmithClient con sondeo y fallback al spec JSON si el render falla
- Contrato de plantillas obtenido de GET /templates, no codificado
- Comprobación de fundamento determinista, sin LLM
- outputs.published_url para enlazar el artículo de Ghost
- Normalización de comillas rectas a tipográficas (ver KNOWN-ISSUES.md)

Lo que no aparece en los chunks se contrasta contra el ejemplo del
prompt: si casa ahí es fuga, no invención, y se informa como tal. El
purgado de sesiones se lleva también su MP4.

La subida a YouTube queda fuera a propósito: fase 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:55:42 +00:00

135 lines
5.3 KiB
Python

"""Eval dorada: ¿podría este pipeline haber producido el vídeo que ya sabemos
que está bien?
Corre el generador entero contra una sesión REAL de investigación y compara el
spec resultante con `examples/jal1628.json` — que es el que produjo el primer
Short — de forma ESTRUCTURAL: número de shots, plantillas elegidas, duración
total y claims sin fundamento. Nunca por igualdad de cadenas: el modelo
redactará distinto y eso no es un fallo.
Necesita una sesión de verdad, así que se salta salvo que se le dé todo:
RESEARCHOWL_GOLDEN_DB=/ruta/a/researchowl.db \\
RESEARCHOWL_GOLDEN_SESSION=153 \\
SHORTSMITH_LIVE_URL=http://10.43.86.57:8080 \\
ANTHROPIC_API_KEY=... \\
pytest tests/test_short_golden.py -v -s
Para sacar la sesión del cluster sin arrastrar la DB entera, `make golden-db`.
"""
import json
import os
from pathlib import Path
import pytest
EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json"
GOLDEN_DB = os.environ.get("RESEARCHOWL_GOLDEN_DB")
GOLDEN_SESSION = os.environ.get("RESEARCHOWL_GOLDEN_SESSION")
LIVE_URL = os.environ.get("SHORTSMITH_LIVE_URL")
pytestmark = pytest.mark.skipif(
not (GOLDEN_DB and GOLDEN_SESSION and LIVE_URL and os.environ.get("ANTHROPIC_API_KEY")),
reason="la eval dorada necesita una sesión real, shortsmith vivo y clave de Claude")
def structure(spec: dict) -> dict:
"""Lo comparable de un spec: forma, no palabras."""
shots = spec.get("shots", [])
return {
"shots": len(shots),
"templates": [s["template"] for s in shots],
"duration": sum(s["duration"] for s in shots),
"distinct_templates": len({s["template"] for s in shots}),
}
@pytest.fixture(scope="module")
def produced():
"""Genera UNA vez (cuesta dinero) y reparte el resultado a los tests."""
import asyncio
from src.config import settings
settings.db_path = GOLDEN_DB
settings.shortsmith_url = LIVE_URL
settings.shortsmith_enabled = True
from src.db.database import ResearchDB, close_db, get_db
from src.generator.short import ShortProducer
from src.processor.processor import ContentProcessor, OllamaClient
async def run():
conn = await get_db()
try:
db = ResearchDB(conn)
producer = ShortProducer(db, ContentProcessor(db, OllamaClient()))
return await producer.produce(int(GOLDEN_SESSION),
lambda text: print(" ", text))
finally:
await close_db()
return asyncio.run(run())
def test_the_pipeline_produces_a_renderable_short(produced):
assert produced.spec is not None, produced.failure
assert produced.failure is None, produced.failure
assert produced.has_video
assert Path(produced.video_path).stat().st_size > 100_000
def test_the_shape_matches_the_reference(produced):
reference = structure(json.loads(EXAMPLE.read_text()))
got = structure(produced.spec)
print(f"\nreferencia: {reference}\nobtenido: {got}")
# El vídeo bueno son 8 shots; ±3 sigue siendo la misma forma narrativa.
assert abs(got["shots"] - reference["shots"]) <= 3
assert got["distinct_templates"] >= 4, "un Short de una sola plantilla es un cartel"
assert 20.0 <= got["duration"] <= 45.0
# Un case_file abre con contexto y cierra con el contador: no se exige la
# misma lista de plantillas, sí que el cierre sea un cierre.
assert got["templates"][-1] == reference["templates"][-1]
def test_no_claim_was_invented(produced):
"""Lo que de verdad decide si esto se puede publicar.
Medido el 2026-08-01 contra la sesión 153, en dos tiradas: 36-37 claims de
38 casan. Lo que se escapa es de dos clases conocidas y ninguna se arregla
endureciendo este assert:
* "232 FT" (el largo de un 747) copiado del ejemplo de la sección 5, que no
está en NINGUNO de los 126 chunks de la sesión — el comprobador lo
etiqueta ya como fuga del ejemplo, no como invención;
* una cita comprimida — "WALNUT SHAPED WIDE RIM" donde la fuente dice
"walnut shaped with a wide rim around its circumference".
El prompt ataca las dos, pero el muestreo del modelo varía entre tiradas, y
un test que gasta $0.05 y depende del muestreo no sirve de puerta. **La
puerta de verdad es el informe de claims en Telegram**, que se manda
siempre. Esto sólo vigila que el fundamento no se desplome.
"""
report = produced.grounding
print("\n" + report.summary())
assert len(report.grounded) >= 30, "el spec dejó de apoyarse en las fuentes"
assert len(report.unsupported) <= 3, \
"sin fundamento: " + "; ".join(f"[{c.kind}] {c.text}" for c in report.unsupported)
def test_it_did_not_take_many_attempts(produced):
"""Métrica del §4: si esto sube de 1.5 de media, lo que hay que arreglar es
el prompt, no el número de reintentos."""
print(f"\nintentos hasta válido: {produced.attempts}; coste ${produced.cost_usd:.4f}")
assert produced.attempts <= 2
def test_it_costs_what_a_blog_costs(produced):
"""Medido, no estimado: 40 chunks de contexto son ~25k tokens de entrada, y
un reintento los paga otra vez. El §9 del spec calculaba $0.003-0.008 con un
contexto mucho más corto; con este, dos intentos salen por ~$0.05. Sigue
siendo lo que cuesta un /generate blog, que era el punto."""
assert produced.cost_usd < 0.08