feat(short): la paleta de audio viva — GET /audio en el contrato, el prompt y el bucle de edición
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>
This commit is contained in:
ChemaVX
2026-08-06 14:41:27 +00:00
co-authored by Claude Fable 5
parent 1fd0c1b3d6
commit 93a506b636
11 changed files with 259 additions and 34 deletions
+21 -4
View File
@@ -28,7 +28,8 @@ 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,
BASELINE_PRESETS, ShortsmithClient, ShortsmithError, ShortsmithRejected,
ShortsmithUnavailable,
)
from src.generator.shortspec import ShortSpecWriter, SpecWriteFailed
from src.generator.spec_contract import SpecInvalid, editorial_notes, validate_spec
@@ -138,6 +139,15 @@ class ShortProducer:
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,
@@ -171,15 +181,19 @@ class ShortProducer:
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.
# 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))
refresh_templates=lambda: self.client.templates(refresh=True),
presets=presets)
try:
written = await writer.write(
topic, context, article_url=result.article_url,
@@ -251,9 +265,12 @@ class ShortProducer:
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)
validate_spec(spec, templates, presets=presets)
except SpecInvalid as e:
result.failure = ("El spec editado no pasa el contrato: "
+ "; ".join(e.errors[:6]))
+42
View File
@@ -42,10 +42,20 @@ __all__ = [
"ShortsmithUnavailable",
"ShortsmithRejected",
"JobResult",
"BASELINE_PRESETS",
"POLL_INTERVAL",
"POLL_CEILING",
]
#: La paleta que existía antes de que shortsmith publicara `GET /audio`. Es el
#: fallback cuando el endpoint no está (404 = shortsmith anterior) o no se pudo
#: consultar: la paleta mejora el spec, no lo define, y quedarse en sonar nunca
#: rompe un render.
BASELINE_PRESETS = {
"sonar": "low drone and sonar pings tightening toward the close",
"none": "digital silence",
}
class ShortsmithError(Exception):
"""Cualquier fallo hablando con shortsmith."""
@@ -86,6 +96,7 @@ class JobResult:
#: petición cuando una validación falla, por si el renderizador se actualizó a
#: mitad de una run.
_templates_cache: dict[str, dict[str, Any]] = {}
_presets_cache: dict[str, dict[str, str]] = {}
class ShortsmithClient:
@@ -136,6 +147,37 @@ class ShortsmithClient:
logger.info("shortsmith templates fetched", n=len(data))
return data
async def audio_presets(self, refresh: bool = False) -> dict[str, str]:
"""La paleta de audio: nombre de preset -> nota de una línea (`GET /audio`).
La mitad de audio del contrato vivo: shortsmith añade un preset y el
prompt lo ofrece sin tocar este repo. Un 404 es un shortsmith anterior
al endpoint y devuelve la paleta base, sin error — fallbacks siempre.
"""
if not refresh and self.base_url in _presets_cache:
return _presets_cache[self.base_url]
try:
async with self._session(30) as sess:
async with sess.get(f"{self.base_url}/audio") as resp:
if resp.status == 404:
data = dict(BASELINE_PRESETS)
elif resp.status != 200:
body = await resp.text()
raise ShortsmithError(
f"GET /audio devolvió {resp.status}: {body[:200]}")
else:
payload = await resp.json()
presets = payload.get("presets")
data = (presets if isinstance(presets, dict) and presets
else dict(BASELINE_PRESETS))
except aiohttp.ClientError as e:
raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e
except asyncio.TimeoutError as e:
raise ShortsmithUnavailable("shortsmith no respondió a /audio") from e
_presets_cache[self.base_url] = data
logger.info("shortsmith audio presets fetched", presets=sorted(data))
return data
async def render(self, spec: dict[str, Any]) -> str:
"""`POST /render`. Devuelve el job_id. 422 -> ShortsmithRejected."""
try:
+16 -3
View File
@@ -98,7 +98,9 @@ and your whole answer becomes unparseable. This is the single most common way \
this task fails.
- meta.id is a lowercase slug (letters, digits, - and _). meta.title is the \
title a human reads, not a filename.
- version is 1. Keep meta at 1080x1920 and audio preset "sonar".
- version is 1. Keep meta at 1080x1920.
- audio.preset — pick the one whose mood fits the shape you chose:
{presets}
- The closing shot carries the domain, uppercase, no protocol: {domain}
# 4. Grounding — this is the part that matters
@@ -144,6 +146,14 @@ Return the JSON object now."""
PALETTE_FALLBACK = "ink, amber, amber_dark, muted, dim, red"
#: Si el caller no trae la paleta de audio, el prompt solo ofrece lo que
#: cualquier shortsmith renderiza.
PRESETS_FALLBACK = {"sonar": "low drone and sonar pings", "none": "digital silence"}
def _describe_presets(presets: dict[str, str]) -> str:
return "\n".join(f' "{name}"{note}' for name, note in sorted(presets.items()))
@dataclass
class SpecResult:
@@ -305,12 +315,14 @@ LLMCall = Callable[[str, str], Awaitable[str]]
class ShortSpecWriter:
def __init__(self, llm_call: LLMCall, templates: dict[str, dict],
refresh_templates: Optional[Callable[[], Awaitable[dict]]] = None):
refresh_templates: Optional[Callable[[], Awaitable[dict]]] = None,
presets: Optional[dict[str, str]] = None):
self.llm_call = llm_call
self.templates = templates
#: Se vuelve a pedir el contrato si una validación falla: el
#: renderizador puede haberse actualizado a mitad de la run.
self.refresh_templates = refresh_templates
self.presets = presets or dict(PRESETS_FALLBACK)
def build_prompt(self, topic: str, context: str, article_url: Optional[str],
domain: str) -> str:
@@ -322,6 +334,7 @@ class ShortSpecWriter:
shapes=NARRATIVE_SHAPES,
templates=describe_templates(self.templates),
colors=_palette(self.templates),
presets=_describe_presets(self.presets),
domain=domain,
target_min=TARGET_MIN_DURATION,
target_max=TARGET_MAX_DURATION,
@@ -359,7 +372,7 @@ class ShortSpecWriter:
last_spec = spec
try:
validate_spec(spec, self.templates)
validate_spec(spec, self.templates, presets=self.presets)
except SpecInvalid as e:
history.append(e.errors)
feedback = _format_errors(e.errors)
+19 -6
View File
@@ -21,7 +21,7 @@ cubierto.
from __future__ import annotations
import re
from typing import Any, Optional
from typing import Any, Iterable, Optional
__all__ = [
"SpecInvalid",
@@ -183,14 +183,23 @@ def _check_meta(meta: Any) -> list[str]:
return errors
def _check_audio(audio: Any, total: float) -> list[str]:
#: Los presets que existían antes de `GET /audio`. Solo es el default cuando el
#: caller no pasa la paleta viva; con ella, un preset nuevo en shortsmith llega
#: aquí sin tocar este repo — el mismo pacto que las plantillas.
BASELINE_PRESET_NAMES = ("sonar", "none")
def _check_audio(audio: Any, total: float,
presets: Optional[Iterable[str]] = None) -> list[str]:
if audio is None:
return []
if not isinstance(audio, dict):
return ["audio: se esperaba un objeto"]
errors = []
if audio.get("preset", "sonar") not in ("sonar", "none"):
errors.append(f"audio.preset: {audio.get('preset')!r} no es 'sonar' ni 'none'")
known = tuple(presets) if presets else BASELINE_PRESET_NAMES
if audio.get("preset", "sonar") not in known:
errors.append(f"audio.preset: {audio.get('preset')!r} no existe "
f"(los presets son: {', '.join(sorted(known))})")
silence = audio.get("silence", [])
if not isinstance(silence, list):
return errors + ["audio.silence: se esperaba una lista de pares [inicio, fin]"]
@@ -223,12 +232,16 @@ def _total_duration(spec: dict) -> float:
return total
def validate_spec(spec: Any, templates: dict[str, dict]) -> None:
def validate_spec(spec: Any, templates: dict[str, dict],
presets: Optional[Iterable[str]] = None) -> None:
"""Lanza `SpecInvalid` con TODAS las rutas que fallan.
Se devuelven todos los errores de golpe a propósito: el bucle de reintento
se los da al modelo verbatim y arreglar cinco de una vez sale más barato
que cinco vueltas.
`presets` es la paleta viva de `GET /audio`; sin ella se valida contra la
paleta base, que nunca acepta nada que un shortsmith viejo no renderice.
"""
errors: list[str] = []
if not isinstance(spec, dict):
@@ -284,7 +297,7 @@ def validate_spec(spec: Any, templates: dict[str, dict]) -> None:
if total > MAX_TOTAL_DURATION:
errors.append(f"shots: la duración total ({total:.2f}s) pasa del límite "
f"de {MAX_TOTAL_DURATION}s")
errors.extend(_check_audio(spec.get("audio"), total))
errors.extend(_check_audio(spec.get("audio"), total, presets))
if errors:
raise SpecInvalid(errors)