Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
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>
135 lines
5.3 KiB
Python
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
|