feat(wave-v2): add LLM reranking, mood modes, auto-rotation scheduler and Navidrome auth
This commit is contained in:
1 parent
3b3d591bfd
commit
fb1a5e01dd
11 files changed
+736
-37
No files matched your search
@@ -0,0 +1,175 @@
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import List, Dict, Any, Optional
|
||||
from app.config import settings
|
||||
from app.providers.base import TrackMetadata
|
||||
|
||||
class WaveLlmReranker:
|
||||
def __init__(self, ollama_url: Optional[str] = None, model: Optional[str] = None):
|
||||
self.ollama_url = (ollama_url or settings.OLLAMA_URL).rstrip("/")
|
||||
self.model = model or settings.OLLAMA_MODEL
|
||||
|
||||
def get_time_of_day_context(self) -> Dict[str, str]:
|
||||
"""Detect current time of day and musical context."""
|
||||
hour = datetime.now().hour
|
||||
if 5 <= hour < 12:
|
||||
return {
|
||||
"period": "утро",
|
||||
"label": "🌅 Утренний чилл",
|
||||
"desc": "мягкое плавное пробуждение, мелодичный чилл, акустическое или лёгкое звучание"
|
||||
}
|
||||
elif 12 <= hour < 18:
|
||||
return {
|
||||
"period": "день",
|
||||
"label": "☀️ Дневной фокус",
|
||||
"desc": "бодрый дневной ритм, концентрация, динамичный и сбалансированный флоу"
|
||||
}
|
||||
elif 18 <= hour < 23:
|
||||
return {
|
||||
"period": "вечер",
|
||||
"label": "🌆 Вечерний вайб",
|
||||
"desc": "атмосферный глубокий звук, эмоциональные треки, плотный бас и хип-хоп/рок"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"period": "ночь",
|
||||
"label": "🌙 Ночной андеграунд",
|
||||
"desc": "ночной драйв, меланхолия, глубокий бас, lo-fi и атмосферный андеграунд"
|
||||
}
|
||||
|
||||
def resolve_mood_context(self, mood: str) -> Dict[str, str]:
|
||||
"""Map user-selected mood string to descriptive context."""
|
||||
mood_lower = (mood or "auto").lower()
|
||||
if mood_lower == "energy":
|
||||
return {
|
||||
"period": "драйв",
|
||||
"label": "⚡ Энергия & Спорт",
|
||||
"desc": "взрывной ритм, максимальная динамика, бодрящий кач и быстрый темп"
|
||||
}
|
||||
elif mood_lower == "chill":
|
||||
return {
|
||||
"period": "релакс",
|
||||
"label": "☕ Спокойствие & Чилл",
|
||||
"desc": "расслабляющая музыка, мягкий ненавязчивый ритм, плавные переходы"
|
||||
}
|
||||
elif mood_lower == "dark":
|
||||
return {
|
||||
"period": "андеграунд",
|
||||
"label": "🏴 Мрачный вайб",
|
||||
"desc": "мрачный звук, глубокий саб-бас, андеграундный рэп и сырой плотный саунд"
|
||||
}
|
||||
# Default: auto by time of day
|
||||
return self.get_time_of_day_context()
|
||||
|
||||
def rerank_candidates(
|
||||
self,
|
||||
candidates: List[TrackMetadata],
|
||||
anchor_profile: Dict[str, Any],
|
||||
mood: str = "auto",
|
||||
target_count: int = 25
|
||||
) -> List[TrackMetadata]:
|
||||
"""
|
||||
Rerank and select candidates using Ollama qwen2.5 model.
|
||||
Falls back smoothly to heuristic ranking if Ollama is unreachable or times out.
|
||||
"""
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
if len(candidates) <= target_count:
|
||||
return candidates
|
||||
|
||||
context = self.resolve_mood_context(mood)
|
||||
|
||||
# Build compact candidate list representation
|
||||
# Format: 1. Artist - Title
|
||||
candidate_lines = []
|
||||
for idx, c in enumerate(candidates, 1):
|
||||
candidate_lines.append(f"{idx}. {c.artist} - {c.title}")
|
||||
|
||||
candidates_text = "\n".join(candidate_lines)
|
||||
|
||||
# Extract anchor artist names for context
|
||||
top_artists = [a.get("artist") for a in anchor_profile.get("top_artists", [])[:5] if a.get("artist")]
|
||||
artists_str = ", ".join(top_artists) if top_artists else "Разнообразный русский и мировой рэп/рок"
|
||||
|
||||
prompt = (
|
||||
f"Ты — опытный музыкальный куратор радио.\n"
|
||||
f"Контекст: {context['label']} ({context['desc']}).\n"
|
||||
f"Любимые артисты пользователя: {artists_str}.\n\n"
|
||||
f"Список кандидатов:\n{candidates_text}\n\n"
|
||||
f"Задача: выбери ровно {target_count} номеров треков из списка, которые лучше всего подходят под этот вайб "
|
||||
f"и обеспечат гармоничную последовательность прослушивания.\n"
|
||||
f"Ответь ИСКЛЮЧИТЕЛЬНО в формате JSON списка номеров, например: [1, 5, 8, 12, ...]. Без лишнего текста."
|
||||
)
|
||||
|
||||
print(f"[WaveLLM] Sourcing {target_count} tracks via {self.model} for mood '{context['label']}'...")
|
||||
|
||||
try:
|
||||
req_data = json.dumps({
|
||||
"model": self.model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"keep_alive": 0,
|
||||
"options": {
|
||||
"temperature": 0.25,
|
||||
"num_predict": 150
|
||||
}
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{self.ollama_url}/api/generate",
|
||||
data=req_data,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
# 45 second timeout for local CPU inference
|
||||
with urllib.request.urlopen(req, timeout=45) as resp:
|
||||
resp_json = json.loads(resp.read().decode("utf-8"))
|
||||
raw_response = resp_json.get("response", "").strip()
|
||||
|
||||
print(f"[WaveLLM] Model output: {raw_response[:100]}...")
|
||||
|
||||
# Extract JSON list of indices from response
|
||||
match = re.search(r"\[([0-9,\s]+)\]", raw_response)
|
||||
if match:
|
||||
selected_indices = [int(x.strip()) for x in match.group(1).split(",") if x.strip().isdigit()]
|
||||
|
||||
# Filter valid 1-based indices
|
||||
valid_tracks = []
|
||||
seen_indices = set()
|
||||
for idx in selected_indices:
|
||||
if 1 <= idx <= len(candidates) and idx not in seen_indices:
|
||||
seen_indices.add(idx)
|
||||
valid_tracks.append(candidates[idx - 1])
|
||||
|
||||
if len(valid_tracks) >= min(5, target_count):
|
||||
# If model returned fewer than target_count, fill remaining from unselected
|
||||
if len(valid_tracks) < target_count:
|
||||
for idx, c in enumerate(candidates, 1):
|
||||
if idx not in seen_indices:
|
||||
valid_tracks.append(c)
|
||||
if len(valid_tracks) >= target_count:
|
||||
break
|
||||
print(f"[WaveLLM] Successfully reranked {len(valid_tracks)} tracks using {self.model}")
|
||||
return valid_tracks[:target_count]
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WaveLLM] Notice: Ollama reranker fallback ({e}). Applying heuristic selection.")
|
||||
|
||||
# Deadair Fallback Principle: Heuristic selection
|
||||
# Interleave tracks from diverse artists
|
||||
artist_buckets: Dict[str, List[TrackMetadata]] = {}
|
||||
for c in candidates:
|
||||
artist_buckets.setdefault(c.artist, []).append(c)
|
||||
|
||||
heuristic_tracks: List[TrackMetadata] = []
|
||||
while len(heuristic_tracks) < target_count and any(artist_buckets.values()):
|
||||
for artist, tracks in list(artist_buckets.items()):
|
||||
if tracks and len(heuristic_tracks) < target_count:
|
||||
heuristic_tracks.append(tracks.pop(0))
|
||||
|
||||
return heuristic_tracks[:target_count]
|
||||
+41
-6
@@ -8,6 +8,7 @@ from app.wave.recommender import WaveRecommender
|
||||
from app.wave.playlist import ensure_smart_playlist
|
||||
from app.core.tagger import get_track_dest_path
|
||||
from app.core.lyrics import fetch_and_save_lyrics
|
||||
from app.wave.llm_reranker import WaveLlmReranker
|
||||
from app.providers.yandex import YandexMusicProvider
|
||||
from app.providers.universal import UniversalProvider
|
||||
|
||||
@@ -28,6 +29,7 @@ class WaveRotator:
|
||||
self.yandex_provider = YandexMusicProvider()
|
||||
self.universal_provider = UniversalProvider()
|
||||
self.recommender = WaveRecommender(self.yandex_provider)
|
||||
self.reranker = WaveLlmReranker()
|
||||
|
||||
def get_current_buffer(self) -> List[Dict[str, Any]]:
|
||||
"""List all audio files currently in the Wave rotation buffer."""
|
||||
@@ -134,8 +136,8 @@ class WaveRotator:
|
||||
|
||||
return purged
|
||||
|
||||
async def fill_buffer(self) -> Dict[str, Any]:
|
||||
"""Run rotation (promotion + purge) and download candidates up to target capacity."""
|
||||
async def fill_buffer(self, mood: str = "auto", refresh_count: int = 5) -> Dict[str, Any]:
|
||||
"""Run rotation (promotion + purge) and download candidates up to target capacity using LLM reranker."""
|
||||
promoted = self.promote_starred_tracks()
|
||||
purged = self.purge_expired_tracks()
|
||||
|
||||
@@ -143,22 +145,50 @@ class WaveRotator:
|
||||
current_count = len(current_items)
|
||||
needed = max(0, self.target_capacity - current_count)
|
||||
|
||||
# If buffer is full and user requests generation, rotate out oldest unstarred tracks to inject fresh recommendations
|
||||
if needed == 0 and refresh_count > 0:
|
||||
sorted_by_age = sorted(current_items, key=lambda x: x["mtime"])
|
||||
to_remove = sorted_by_age[:refresh_count]
|
||||
for item in to_remove:
|
||||
try:
|
||||
os.remove(item["path"])
|
||||
lrc = os.path.splitext(item["path"])[0] + ".lrc"
|
||||
if os.path.exists(lrc):
|
||||
os.remove(lrc)
|
||||
print(f"[WaveRotator] Rotating out oldest wave track: {item['filename']}")
|
||||
except Exception as e:
|
||||
print(f"[WaveRotator] Failed to remove {item['filename']}: {e}")
|
||||
current_items = self.get_current_buffer()
|
||||
current_count = len(current_items)
|
||||
needed = max(0, self.target_capacity - current_count)
|
||||
|
||||
added: List[Dict[str, str]] = []
|
||||
context = self.reranker.resolve_mood_context(mood)
|
||||
|
||||
if needed > 0:
|
||||
print(f"[WaveRotator] Wave buffer at {current_count}/{self.target_capacity}. Sourcing {needed} new tracks...")
|
||||
print(f"[WaveRotator] Wave buffer at {current_count}/{self.target_capacity}. Sourcing candidates for {needed} slots (mood: {context['label']})...")
|
||||
profile = self.collector.get_anchor_profile()
|
||||
existing_keys = self.collector.get_existing_track_keys()
|
||||
wave_keys = self.get_wave_track_keys()
|
||||
|
||||
candidates = self.recommender.get_candidate_tracks(
|
||||
# Source a larger candidate pool for LLM contextual reranking
|
||||
candidate_pool_size = max(needed * 2, settings.WAVE_CANDIDATE_POOL)
|
||||
raw_candidates = self.recommender.get_candidate_tracks(
|
||||
anchor_profile=profile,
|
||||
existing_keys=existing_keys,
|
||||
wave_keys=wave_keys,
|
||||
target_count=candidate_pool_size
|
||||
)
|
||||
|
||||
# Contextual LLM Reranker (Qwen 2.5 / deadair fallback)
|
||||
selected_candidates = self.reranker.rerank_candidates(
|
||||
candidates=raw_candidates,
|
||||
anchor_profile=profile,
|
||||
mood=mood,
|
||||
target_count=needed
|
||||
)
|
||||
|
||||
for cand in candidates:
|
||||
for cand in selected_candidates:
|
||||
filename = f"{cand.artist} - {cand.title}.mp3"
|
||||
# Clean unsafe filesystem chars
|
||||
filename = "".join(c for c in filename if c not in '<>:"/\\|?*')
|
||||
@@ -187,6 +217,8 @@ class WaveRotator:
|
||||
return {
|
||||
"target_capacity": self.target_capacity,
|
||||
"total_in_buffer": len(self.get_current_buffer()),
|
||||
"mood": mood,
|
||||
"mood_label": context["label"],
|
||||
"added_count": len(added),
|
||||
"added_tracks": added,
|
||||
"promoted_count": len(promoted),
|
||||
@@ -201,6 +233,7 @@ class WaveRotator:
|
||||
total_size_mb = round(sum(t["size_mb"] for t in buffer_tracks), 2)
|
||||
profile = self.collector.get_anchor_profile()
|
||||
anchor_artists = [a["artist"] for a in profile.get("top_artists", [])[:6]]
|
||||
context = self.reranker.resolve_mood_context("auto")
|
||||
|
||||
return {
|
||||
"capacity": self.target_capacity,
|
||||
@@ -208,7 +241,9 @@ class WaveRotator:
|
||||
"total_size_mb": total_size_mb,
|
||||
"expire_days": self.expire_days,
|
||||
"anchor_artists": anchor_artists,
|
||||
"tracks": buffer_tracks[:15]
|
||||
"current_context": context,
|
||||
"llm_model": settings.OLLAMA_MODEL,
|
||||
"tracks": buffer_tracks[:25]
|
||||
}
|
||||
|
||||
wave_rotator = WaveRotator()
|
||||
Reference in new issue
Block a user