feat(short): generación y render de Shorts vía shortsmith
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:
ChemaVX
2026-08-01 21:55:42 +00:00
co-authored by Claude Opus 5
parent 8b81ef87e4
commit 20c8d03aa7
27 changed files with 4350 additions and 23 deletions
+281
View File
@@ -0,0 +1,281 @@
"""Producción de un Short: spec → fundamento → render → MP4 en disco.
Orquesta las tres piezas que ya existen (`shortspec`, `grounding`,
`shortsmith`) y no añade lógica propia salvo el orden, que es deliberado:
escribir el spec → comprobar los datos → renderizar
La comprobación va ANTES del render porque el informe de claims es la puerta de
revisión humana, y llega a Telegram junto al vídeo. No bloquea el render: un
dato sin encontrar puede ser una fabricación o un artefacto de formato, y eso
lo decide una persona, no esto.
**Fallbacks siempre** (convención del repo): si shortsmith no responde, si el
job falla o si el spec no valida, se devuelve el spec igualmente. La parte cara
es la generación, no el render. No se tira nunca.
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Optional
import structlog
from src.config import settings
from src.db.database import OutputType, ResearchDB
from src.generator.grounding import GroundingReport, check_grounding
from src.generator.shortsmith import (
ShortsmithClient, ShortsmithError, ShortsmithRejected, ShortsmithUnavailable,
)
from src.generator.shortspec import ShortSpecWriter, SpecWriteFailed
from src.llm import get_anthropic_client
logger = structlog.get_logger()
__all__ = ["ShortProducer", "ShortResult", "ShortsDisabled"]
#: Cuántos chunks se le dan al modelo. Es también el material contra el que se
#: comprueba el fundamento: se comprueba contra EXACTAMENTE lo que se le pasó.
CONTEXT_CHUNKS = 40
#: Tope de caracteres del contexto. Un Short son 40 segundos: más material no
#: mejora el guion, sólo la factura.
CONTEXT_BUDGET = 90_000
class ShortsDisabled(Exception):
"""SHORTSMITH_ENABLED=false. El interruptor de emergencia del renderizador."""
@dataclass
class ShortResult:
topic: str
spec: Optional[dict] = None
title: str = ""
article_url: Optional[str] = None
grounding: Optional[GroundingReport] = None
video_path: Optional[str] = None
render_warnings: list[dict] = field(default_factory=list)
attempts: int = 0
notes: list[str] = field(default_factory=list)
#: Por qué no hay vídeo. None = lo hay.
failure: Optional[str] = None
#: La respuesta cruda del modelo cuando ni siquiera llegó a ser JSON. Se
#: conserva para poder mandarla a Telegram y editarla a mano.
raw_response: str = ""
cost_usd: float = 0.0
duration_s: float = 0.0
@property
def spec_json(self) -> str:
return json.dumps(self.spec, indent=2, ensure_ascii=False) if self.spec else ""
@property
def has_video(self) -> bool:
return bool(self.video_path)
class ShortProducer:
def __init__(self, db: ResearchDB, processor,
client: Optional[ShortsmithClient] = None,
llm_call: Optional[Callable] = None):
self.db = db
self.processor = processor
self.client = client or ShortsmithClient()
#: Sustituto del callable a Claude. Sólo lo usan los tests: el bucle de
#: reintento y los fallbacks se prueban sin gastar tokens.
self.llm_override = llm_call
# --- piezas -------------------------------------------------------------
def _llm_call(self, session_id: int):
"""Un callable (system, prompt) -> texto que además apunta el gasto."""
async def call(system: str, prompt: str) -> str:
client = get_anthropic_client()
msg = await client.messages.create(
model=settings.claude_model,
max_tokens=8000,
system=system,
messages=[{"role": "user", "content": prompt}],
)
try:
await self.db.log_api_call(
session_id, "short_spec", settings.claude_model,
msg.usage.input_tokens, msg.usage.output_tokens)
in_price, out_price = ResearchDB._price_for_model(settings.claude_model)
call.cost += (msg.usage.input_tokens * in_price
+ msg.usage.output_tokens * out_price) / 1_000_000
except Exception as e:
logger.warning("No se pudo apuntar el gasto del spec", error=str(e))
return msg.content[0].text.strip()
call.cost = 0.0
return call
def _domain(self) -> str:
"""El dominio que va dibujado en el shot de cierre, en mayúsculas y sin
protocolo (así lo escribe el ejemplo de referencia)."""
raw = (settings.ghost_url_en or "https://theexclusionzone.com")
return raw.split("://")[-1].strip("/").removeprefix("www.").upper()
def _context(self, chunks: list[dict]) -> str:
parts, size = [], 0
for chunk in chunks:
label = f"[{(chunk.get('source_type') or 'web').upper()}] " \
f"{chunk.get('title') or chunk.get('url') or 'Unknown'}"
piece = f"{label}:\n{chunk['content']}"
if size + len(piece) > CONTEXT_BUDGET:
break
parts.append(piece)
size += len(piece)
return "\n\n---\n\n".join(parts)
def _video_path(self, session_id: int) -> Path:
directory = Path(settings.shorts_dir)
directory.mkdir(parents=True, exist_ok=True)
return directory / f"{session_id}.mp4"
# --- pipeline -----------------------------------------------------------
async def produce(self, session_id: int,
progress_callback: Optional[Callable[[str], Any]] = None
) -> ShortResult:
if not settings.shortsmith_enabled:
raise ShortsDisabled(
"SHORTSMITH_ENABLED=false — el renderizador está apagado a propósito")
if not settings.anthropic_api_key and not self.llm_override:
raise ValueError(
"Escribir un shot spec necesita Claude: es JSON con un contrato "
"estricto, no prosa. Configura ANTHROPIC_API_KEY.")
session = await self.db.get_session(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
topic = session["topic"]
result = ShortResult(topic=topic)
# 1. Material. Los mismos chunks alimentan el prompt y el comprobador.
await _report(progress_callback, "🎬 Writing shot spec…")
chunks = await self.processor.rag_chunks(
session_id, f"{topic} key facts figures dates quotes witnesses",
top_k=CONTEXT_CHUNKS)
if not chunks:
raise ValueError("No processed content available. Run /process first.")
context = self._context(chunks)
result.article_url = await self.db.get_article_url(session_id)
if not result.article_url:
logger.warning("Short sin URL de artículo — se sigue con el dominio pelado",
session_id=session_id)
# 2. El contrato, en vivo. Sin él no hay prompt que escribir.
templates = await self.client.templates()
# 3. El spec.
started = time.monotonic()
llm_call = self.llm_override or self._llm_call(session_id)
writer = ShortSpecWriter(
llm_call, templates,
refresh_templates=lambda: self.client.templates(refresh=True))
try:
written = await writer.write(
topic, context, article_url=result.article_url,
domain=self._domain(), on_progress=progress_callback)
except SpecWriteFailed as e:
result.cost_usd = getattr(llm_call, "cost", 0.0)
result.spec = e.last_spec
result.raw_response = e.last_raw
result.attempts = e.attempts
result.failure = ("El spec no pasó la validación en "
f"{e.attempts} intentos: " + "; ".join(e.errors[:4]))
logger.warning("Short sin vídeo: spec inválido", session_id=session_id,
errors=e.errors[:4])
return result
result.spec = written.spec
result.attempts = written.attempts
result.notes = written.notes
result.cost_usd = getattr(llm_call, "cost", 0.0)
result.title = written.spec.get("meta", {}).get("title", topic)
result.duration_s = sum(s.get("duration", 0) for s in written.spec["shots"])
# 4. Fundamento, ANTES de renderizar. No descarta ningún shot: informa.
await _report(progress_callback, "🔍 Checking claims against sources…")
result.grounding = check_grounding(written.spec, chunks)
logger.info("Short grounding", session_id=session_id,
matched=len(result.grounding.grounded),
ungrounded=len(result.grounding.ungrounded),
from_example=len(result.grounding.contaminated))
# 5. El spec se guarda ANTES del render: si el render falla, la parte
# cara ya está a salvo en la DB y `/short_spec` la puede devolver.
try:
await self.db.save_output(session_id, OutputType.SHORT_EN, result.spec_json)
except Exception as e:
logger.warning("No se pudo guardar el spec en outputs", error=str(e))
# 6. Render.
try:
await self._render(result, session_id, progress_callback)
except ShortsmithRejected as e:
# El validador local no replica las reglas de pydantic que cruzan
# campos (los límites de MapBounds, "3 barras no dejan sitio para
# una cita"): las coge el servidor y se cuentan tal cual.
result.failure = ("shortsmith rechazó el spec: "
+ "; ".join(_error_line(x) for x in e.errors[:4]))
except ShortsmithUnavailable as e:
result.failure = f"shortsmith no responde: {e}"
except ShortsmithError as e:
result.failure = f"el render falló: {e}"
except OSError as e:
result.failure = f"no se pudo guardar el vídeo: {e}"
if result.failure:
logger.warning("Short sin vídeo", session_id=session_id, why=result.failure)
logger.info("Short producido", session_id=session_id,
seconds=round(time.monotonic() - started, 1),
video=result.video_path, cost=round(result.cost_usd, 4))
return result
async def _render(self, result: ShortResult, session_id: int,
progress_callback: Optional[Callable[[str], Any]]) -> None:
job_id = await self.client.render(result.spec)
async def on_progress(fraction: float, status: str) -> None:
if status == "queued":
await _report(progress_callback, "🎞 Queued at the renderer…")
else:
await _report(progress_callback, f"🎞 Rendering… {fraction * 100:.0f}%")
job = await self.client.poll(job_id, on_progress=on_progress)
result.render_warnings = job.warnings
if not job.ok:
result.failure = f"el render terminó en error: {job.error}"
return
await _report(progress_callback, "📤 Uploading…")
video = await self.client.fetch_video(job_id)
path = self._video_path(session_id)
path.write_bytes(video)
result.video_path = str(path)
def _error_line(error: Any) -> str:
"""Un error de pydantic del servidor, con su ruta completa."""
if not isinstance(error, dict):
return str(error)
loc = ".".join(str(p) for p in error.get("loc", []))
return f"{loc}: {error.get('msg', '')}" if loc else str(error.get("msg", error))
async def _report(callback: Optional[Callable[[str], Any]], text: str) -> None:
if not callback:
return
try:
value = callback(text)
if hasattr(value, "__await__"):
await value
except Exception as e:
logger.warning("Progreso del Short no enviado", error=str(e))