Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
shortsmith ya compone la banda sonora (sonar); ahora publica una paleta (pulse, static) y este repo la consume en vivo: el prompt la ofrece con sus notas de mood, validate_spec la usa como fuente de verdad para audio.preset, y editar el preset en /short_spec es la manera gratis de escucharlas. Sin /audio (404 o caída) todo cae a la paleta base y nada deja de renderizar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
370 lines
16 KiB
Python
370 lines
16 KiB
Python
"""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 (
|
|
BASELINE_PRESETS, ShortsmithClient, ShortsmithError, ShortsmithRejected,
|
|
ShortsmithUnavailable,
|
|
)
|
|
from src.generator.shortspec import ShortSpecWriter, SpecWriteFailed
|
|
from src.generator.spec_contract import SpecInvalid, editorial_notes, validate_spec
|
|
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"
|
|
|
|
async def _presets(self) -> dict[str, str]:
|
|
"""La paleta de audio, en vivo. Nunca tumba nada: sin ella se ofrece la
|
|
base y el Short sale con sonar, que es lo que salía siempre."""
|
|
try:
|
|
return await self.client.audio_presets()
|
|
except Exception as e:
|
|
logger.warning("GET /audio falló — paleta base", error=str(e))
|
|
return dict(BASELINE_PRESETS)
|
|
|
|
# --- 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. La paleta
|
|
# de audio mejora el prompt pero no lo define: si /audio falla, se
|
|
# ofrece la paleta base y el render sale igual.
|
|
templates = await self.client.templates()
|
|
presets = await self._presets()
|
|
|
|
# 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),
|
|
presets=presets)
|
|
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.
|
|
await self._render_guarded(result, session_id, progress_callback)
|
|
|
|
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 rerender(self, session_id: int, spec: dict,
|
|
progress_callback: Optional[Callable[[str], Any]] = None
|
|
) -> ShortResult:
|
|
"""Renderiza un spec editado a mano, sin pagar otra generación.
|
|
|
|
Es la vuelta de `/short_spec`: el fichero sale, se retoca, y se manda
|
|
de nuevo. Cero LLM en este camino — se valida contra el contrato vivo,
|
|
se re-comprueba el fundamento (las cadenas han cambiado y el informe no
|
|
es decorativo) y se renderiza. El spec editado se guarda como output
|
|
nuevo ANTES del render, por la misma razón que en `produce` y por una
|
|
más: los metadatos de `/upload_short` salen del último spec guardado, y
|
|
tienen que describir el vídeo que de verdad se renderizó.
|
|
"""
|
|
if not settings.shortsmith_enabled:
|
|
raise ShortsDisabled(
|
|
"SHORTSMITH_ENABLED=false — el renderizador está apagado a propósito")
|
|
|
|
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, spec=spec)
|
|
|
|
# 1. El contrato, en vivo — las mismas rutas verbatim que ve el modelo.
|
|
# La paleta también: editar audio.preset a "pulse" es justo el tipo
|
|
# de retoque para el que existe este camino.
|
|
templates = await self.client.templates()
|
|
presets = await self._presets()
|
|
try:
|
|
validate_spec(spec, templates, presets=presets)
|
|
except SpecInvalid as e:
|
|
result.failure = ("El spec editado no pasa el contrato: "
|
|
+ "; ".join(e.errors[:6]))
|
|
logger.warning("Rerender rechazado por el contrato",
|
|
session_id=session_id, errors=e.errors[:6])
|
|
return result
|
|
result.notes = editorial_notes(spec)
|
|
|
|
result.title = spec.get("meta", {}).get("title", topic)
|
|
result.duration_s = sum(s.get("duration", 0) for s in spec["shots"])
|
|
result.article_url = await self.db.get_article_url(session_id)
|
|
|
|
# 2. Fundamento, otra vez: la edición pudo meter una cifra nueva.
|
|
await _report(progress_callback, "🔍 Checking claims against sources…")
|
|
chunks = await self.processor.rag_chunks(
|
|
session_id, f"{topic} key facts figures dates quotes witnesses",
|
|
top_k=CONTEXT_CHUNKS)
|
|
if chunks:
|
|
result.grounding = check_grounding(spec, chunks)
|
|
else:
|
|
# Sesión purgada o sin procesar: se renderiza igual, pero el
|
|
# informe tiene que decir que esta vez no hubo contra qué mirar.
|
|
result.notes.append("sin chunks en la sesión: el fundamento del "
|
|
"spec editado NO se ha comprobado")
|
|
|
|
# 3. Guardar antes de renderizar.
|
|
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 editado", error=str(e))
|
|
|
|
await self._render_guarded(result, session_id, progress_callback)
|
|
logger.info("Short re-renderizado", session_id=session_id,
|
|
video=result.video_path, failure=result.failure)
|
|
return result
|
|
|
|
async def _render_guarded(self, result: ShortResult, session_id: int,
|
|
progress_callback: Optional[Callable[[str], Any]]
|
|
) -> None:
|
|
"""`_render` con los fallos convertidos en `result.failure`."""
|
|
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}"
|
|
|
|
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))
|