feat(short): generación y render de Shorts vía shortsmith
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
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>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user