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
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:
co-authored by
Claude Fable 5
parent
1fd0c1b3d6
commit
93a506b636
@@ -59,6 +59,14 @@ class FakeProcessor:
|
||||
return self.chunks
|
||||
|
||||
|
||||
PRESETS = {
|
||||
"sonar": "low drone — the case-file mood",
|
||||
"pulse": "sub-bass heartbeat — debunks",
|
||||
"static": "shortwave static — document drops",
|
||||
"none": "digital silence",
|
||||
}
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""shortsmith de mentira. `fail_at` decide dónde se rompe."""
|
||||
|
||||
@@ -73,6 +81,11 @@ class FakeClient:
|
||||
raise ShortsmithUnavailable("no hay nadie al otro lado")
|
||||
return TEMPLATES
|
||||
|
||||
async def audio_presets(self, refresh=False):
|
||||
if self.fail_at == "audio":
|
||||
raise ShortsmithUnavailable("sin /audio")
|
||||
return dict(PRESETS)
|
||||
|
||||
async def render(self, spec):
|
||||
if self.fail_at == "render":
|
||||
raise ShortsmithUnavailable("conexión rechazada")
|
||||
@@ -450,3 +463,48 @@ async def test_rerender_respects_the_kill_switch(tmp_path, monkeypatch):
|
||||
|
||||
with pytest.raises(ShortsDisabled):
|
||||
await p.rerender(153, json.loads(json.dumps(SPEC)))
|
||||
|
||||
|
||||
# --- la paleta de audio (GET /audio) ----------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_edited_preset_from_the_live_palette_renders(tmp_path, monkeypatch):
|
||||
"""El retoque para el que existe el bucle de edición: cambiar la banda
|
||||
sonora a "pulse" sin pagar otra generación."""
|
||||
edited = json.loads(json.dumps(SPEC))
|
||||
edited["audio"] = {"preset": "pulse"}
|
||||
client = FakeClient()
|
||||
p = producer(tmp_path, monkeypatch, client=client, llm=_llm_prohibido)
|
||||
|
||||
result = await p.rerender(153, edited)
|
||||
|
||||
assert result.has_video
|
||||
assert client.rendered["audio"]["preset"] == "pulse"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_preset_the_renderer_does_not_know_is_rejected_with_the_palette(
|
||||
tmp_path, monkeypatch):
|
||||
edited = json.loads(json.dumps(SPEC))
|
||||
edited["audio"] = {"preset": "vaporwave"}
|
||||
client = FakeClient()
|
||||
p = producer(tmp_path, monkeypatch, client=client, llm=_llm_prohibido)
|
||||
|
||||
result = await p.rerender(153, edited)
|
||||
|
||||
assert not result.has_video
|
||||
assert "audio.preset" in result.failure and "pulse" in result.failure
|
||||
assert client.rendered is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_dead_audio_endpoint_never_blocks_a_sonar_render(tmp_path, monkeypatch):
|
||||
"""La paleta mejora el prompt, no lo define: sin /audio se cae a la base y
|
||||
un spec con sonar renderiza igual."""
|
||||
p = producer(tmp_path, monkeypatch, client=FakeClient(fail_at="audio"),
|
||||
llm=_llm_prohibido)
|
||||
|
||||
result = await p.rerender(153, json.loads(json.dumps(SPEC)))
|
||||
|
||||
assert result.has_video
|
||||
|
||||
@@ -10,8 +10,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from src.generator.shortsmith import (
|
||||
JobResult, ShortsmithClient, ShortsmithError, ShortsmithRejected,
|
||||
ShortsmithUnavailable, _templates_cache,
|
||||
BASELINE_PRESETS, JobResult, ShortsmithClient, ShortsmithError,
|
||||
ShortsmithRejected, ShortsmithUnavailable, _presets_cache, _templates_cache,
|
||||
)
|
||||
|
||||
EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json"
|
||||
@@ -100,6 +100,30 @@ async def test_templates_cached_per_process():
|
||||
_templates_cache.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audio_presets_come_from_the_service():
|
||||
_presets_cache.clear()
|
||||
client = ShortsmithClient("http://fake:8080")
|
||||
palette = {"sonar": "drone", "pulse": "heartbeat"}
|
||||
session = patch_session(client, {"/audio": FakeResp(200, {"presets": palette})})
|
||||
|
||||
assert await client.audio_presets() == palette
|
||||
await client.audio_presets()
|
||||
assert len(session.calls) == 1, "la segunda llamada debe salir de la caché"
|
||||
_presets_cache.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_shortsmith_without_audio_endpoint_yields_the_baseline():
|
||||
"""404 = shortsmith anterior a la paleta. Fallback, no error."""
|
||||
_presets_cache.clear()
|
||||
client = ShortsmithClient("http://fake:8080")
|
||||
patch_session(client, {"/audio": FakeResp(404, body="not found")})
|
||||
|
||||
assert await client.audio_presets() == BASELINE_PRESETS
|
||||
_presets_cache.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_returns_job_id(spec):
|
||||
client = ShortsmithClient("http://fake:8080")
|
||||
|
||||
@@ -118,6 +118,23 @@ def test_prompt_says_out_loud_that_there_is_no_article_yet():
|
||||
assert "No article URL yet" in w.build_prompt("X", "m", None, "X.TEST")
|
||||
|
||||
|
||||
def test_prompt_offers_the_live_audio_palette():
|
||||
"""La mitad de audio del contrato vivo: los presets y sus notas de mood
|
||||
vienen de GET /audio, no de este repo."""
|
||||
palette = {"sonar": "the case-file mood", "pulse": "tension, built for debunks"}
|
||||
w, _ = writer("{}", presets=palette)
|
||||
prompt = w.build_prompt("X", "m", None, "X.TEST")
|
||||
assert '"pulse" — tension, built for debunks' in prompt
|
||||
assert "whose mood fits the shape" in prompt
|
||||
|
||||
|
||||
def test_prompt_without_a_palette_only_offers_the_baseline():
|
||||
w, _ = writer("{}")
|
||||
prompt = w.build_prompt("X", "m", None, "X.TEST")
|
||||
assert '"sonar"' in prompt and '"none"' in prompt
|
||||
assert '"pulse"' not in prompt
|
||||
|
||||
|
||||
# --- bucle ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -184,6 +184,31 @@ def test_silence_window_cannot_run_past_the_end():
|
||||
assert any("se sale de la duración total" in e for e in errors_of(bad))
|
||||
|
||||
|
||||
def test_the_live_palette_widens_what_a_preset_may_be():
|
||||
"""Con la paleta de GET /audio, un preset nuevo en shortsmith llega aquí
|
||||
sin tocar este repo — el mismo pacto que las plantillas."""
|
||||
doc = spec_with(shot(duration=25.0))
|
||||
doc["audio"] = {"preset": "pulse"}
|
||||
validate_spec(doc, TEMPLATES, presets=("sonar", "pulse", "static", "none"))
|
||||
|
||||
|
||||
def test_without_the_palette_only_the_baseline_presets_pass():
|
||||
"""El default es conservador a propósito: nunca acepta lo que un shortsmith
|
||||
viejo no renderice."""
|
||||
doc = spec_with(shot(duration=25.0))
|
||||
doc["audio"] = {"preset": "pulse"}
|
||||
assert any("audio.preset" in e for e in errors_of(doc))
|
||||
|
||||
|
||||
def test_an_unknown_preset_error_names_the_palette():
|
||||
doc = spec_with(shot(duration=25.0))
|
||||
doc["audio"] = {"preset": "vaporwave"}
|
||||
with pytest.raises(SpecInvalid) as exc:
|
||||
validate_spec(doc, TEMPLATES, presets=("sonar", "pulse", "none"))
|
||||
line = next(e for e in exc.value.errors if "audio.preset" in e)
|
||||
assert "pulse" in line and "vaporwave" in line
|
||||
|
||||
|
||||
def test_extra_root_key_is_rejected():
|
||||
bad = spec_with(shot(duration=25.0)); bad["narrative_shape"] = "case_file"
|
||||
assert any(e.startswith("narrative_shape:") for e in errors_of(bad))
|
||||
|
||||
Reference in New Issue
Block a user