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,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))
|
||||
Reference in New Issue
Block a user