176 lines
8.0 KiB
Python
176 lines
8.0 KiB
Python
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]
|