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