Files
researchowl/src/generator/short.py
T
ChemaVXandClaude Fable 5 93a506b636
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
feat(short): la paleta de audio viva — GET /audio en el contrato, el prompt y el bucle de edición
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>
2026-08-06 14:41:27 +00:00

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))