02ea026851
Remove spotify integration; add TASTE-FROM-SCREENSHOTS guide; ship Ozan settings.json and taste_seeds.json as Cursor examples plus new wandering dervish track. Co-authored-by: Cursor <cursoragent@cursor.com>
152 lines
5.2 KiB
Python
152 lines
5.2 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import uuid
|
||
from dataclasses import dataclass
|
||
|
||
import httpx
|
||
|
||
from ozan_radio.config import Config
|
||
from ozan_radio.settings import ListenerSettings, load_settings
|
||
from ozan_radio.taste import TasteSeeds
|
||
|
||
CHAT_SYSTEM = """You are the on-air DJ for Live Ozan Radio. Chat with the listener in a warm,
|
||
concise radio-host voice. You never play catalog music — only AI-generated tracks via Lyria 3.
|
||
|
||
When the listener asks for a vibe, mood, genre, or "play something X", set action to "generate"
|
||
and include a vibe_hint Lyria can use. Otherwise action is "none".
|
||
|
||
Respond with JSON only:
|
||
{
|
||
"reply": "your on-air response (1-3 sentences)",
|
||
"action": "none" or "generate",
|
||
"vibe_hint": "optional — detailed direction for the next composed track"
|
||
}
|
||
"""
|
||
|
||
DJ_SYSTEM = """You are the DJ for Live Ozan Radio — a personal AI station that never plays
|
||
catalog music. Every track is generated fresh by Google Lyria 3 based on your prompts.
|
||
|
||
Your job:
|
||
1. Read settings.json and taste_seeds.json (listener profile from screenshots or manual edits).
|
||
2. Pick a mood, tempo, and genre blend that feels like a natural next track.
|
||
3. Write a Lyria prompt that produces a 1–2 minute instrumental or vocal track.
|
||
4. Keep variety — don't repeat the same vibe twice in a row.
|
||
5. Follow settings.json taste profile when provided — it overrides generic defaults.
|
||
6. Stay in the listener's lane unless they ask for something else in chat.
|
||
|
||
Respond with JSON only:
|
||
{
|
||
"title": "short track title",
|
||
"mood": "one-line mood",
|
||
"dj_line": "what you'd say on air (1 sentence)",
|
||
"lyria_prompt": "detailed prompt for Lyria 3 Pro — structure, instruments, tempo, energy"
|
||
}
|
||
"""
|
||
|
||
|
||
@dataclass
|
||
class TrackPlan:
|
||
id: str
|
||
title: str
|
||
mood: str
|
||
dj_line: str
|
||
lyria_prompt: str
|
||
|
||
|
||
@dataclass
|
||
class ChatReply:
|
||
reply: str
|
||
action: str
|
||
vibe_hint: str
|
||
|
||
|
||
class DeepSeekDJ:
|
||
"""DeepSeek orchestrates what plays next — taste in, Lyria prompt out."""
|
||
|
||
def __init__(self, config: Config) -> None:
|
||
self._config = config
|
||
|
||
def _taste_block(self) -> str:
|
||
settings = load_settings()
|
||
if settings:
|
||
return settings.dj_context()
|
||
return "No settings.json — freestyle eclectic world groove."
|
||
|
||
async def _completion(self, messages: list[dict], *, json_mode: bool = False) -> str:
|
||
self._config.require_deepseek()
|
||
payload: dict = {
|
||
"model": self._config.deepseek_model,
|
||
"messages": messages,
|
||
"temperature": 0.85,
|
||
}
|
||
if json_mode:
|
||
payload["response_format"] = {"type": "json_object"}
|
||
|
||
url = f"{self._config.deepseek_base_url.rstrip('/')}/chat/completions"
|
||
async with httpx.AsyncClient(timeout=120) as client:
|
||
resp = await client.post(
|
||
url,
|
||
headers={"Authorization": f"Bearer {self._config.deepseek_api_key}"},
|
||
json=payload,
|
||
)
|
||
resp.raise_for_status()
|
||
return resp.json()["choices"][0]["message"]["content"]
|
||
|
||
async def chat(
|
||
self,
|
||
message: str,
|
||
history: list[dict],
|
||
*,
|
||
now_playing: str | None = None,
|
||
) -> ChatReply:
|
||
context = [f"Listener says: {message}"]
|
||
if now_playing:
|
||
context.append(f"Currently playing: {now_playing}")
|
||
|
||
chat_system = f"{CHAT_SYSTEM}\n\nStation taste:\n{self._taste_block()}"
|
||
messages = [{"role": "system", "content": chat_system}]
|
||
for turn in history[-8:]:
|
||
role = "assistant" if turn["role"] == "dj" else turn["role"]
|
||
messages.append({"role": role, "content": turn["content"]})
|
||
messages.append({"role": "user", "content": "\n".join(context)})
|
||
|
||
data = json.loads(await self._completion(messages, json_mode=True))
|
||
return ChatReply(
|
||
reply=data.get("reply", "Copy that — stay tuned."),
|
||
action=data.get("action", "none"),
|
||
vibe_hint=data.get("vibe_hint", ""),
|
||
)
|
||
|
||
async def plan_next(
|
||
self,
|
||
recent_titles: list[str],
|
||
seeds: TasteSeeds | None = None,
|
||
request: str | None = None,
|
||
) -> TrackPlan:
|
||
self._config.require_deepseek()
|
||
|
||
user_parts = [
|
||
"Plan the next generated track for Live Ozan Radio.",
|
||
f"Station taste:\n{self._taste_block()}",
|
||
]
|
||
if seeds:
|
||
user_parts.append(f"Taste seeds:\n{seeds.summary}")
|
||
if recent_titles:
|
||
user_parts.append(f"Already played (avoid repeating): {', '.join(recent_titles[-5:])}")
|
||
if request:
|
||
user_parts.append(f"Listener request: {request}")
|
||
|
||
messages = [
|
||
{"role": "system", "content": DJ_SYSTEM},
|
||
{"role": "user", "content": "\n".join(user_parts)},
|
||
]
|
||
data = json.loads(await self._completion(messages, json_mode=True))
|
||
return TrackPlan(
|
||
id=str(uuid.uuid4())[:8],
|
||
title=data.get("title", "Untitled Transmission"),
|
||
mood=data.get("mood", ""),
|
||
dj_line=data.get("dj_line", "Coming up — something new, just for you."),
|
||
lyria_prompt=data.get("lyria_prompt", "An upbeat instrumental groove."),
|
||
)
|