feat: Ollama + OpenRouter als LLM-Reasoning-Backends

_claude_resolve() nutzt jetzt Ollama lokal (kostenlos, RTX 3090) als
erste Wahl, dann OpenRouter/DeepSeek V3 (sehr günstig) und zuletzt
Claude API. Neue ENV-Variablen: OPENROUTER_API_KEY, OLLAMA_RESOLVE_MODEL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-04-28 21:03:29 +02:00
commit c205fa8943

View file

@ -34,9 +34,14 @@ except ImportError:
_MB_RATE_LIMIT = 1.1 # seconds between MusicBrainz requests _MB_RATE_LIMIT = 1.1 # seconds between MusicBrainz requests
_last_mb_call = 0.0 _last_mb_call = 0.0
ACOUSTID_API_KEY = os.getenv("ACOUSTID_API_KEY", "") ACOUSTID_API_KEY = os.getenv("ACOUSTID_API_KEY", "")
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
DISCOGS_TOKEN = os.getenv("DISCOGS_TOKEN", "") OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
DISCOGS_TOKEN = os.getenv("DISCOGS_TOKEN", "")
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
# Lokales Reasoning-Modell für Metadaten-Ergänzung (passt auf RTX 3090)
OLLAMA_RESOLVE_MODEL = os.getenv("OLLAMA_RESOLVE_MODEL", "qwen3.5:27b")
def _mb_wait(): def _mb_wait():
@ -172,39 +177,126 @@ def _discogs_search(artist: Optional[str], album: Optional[str]) -> Optional[Dic
# Claude API reasoning (optional) # Claude API reasoning (optional)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _build_resolve_prompt(hints: AlbumHints, partial: Dict) -> str:
tracks_summary = "\n".join(
f" - Track {t.track_number or '?'}: {t.title or t.path.stem}"
+ (f" [{t.artist}]" if t.artist else "")
for t in hints.tracks[:20]
)
return (
"Du bist ein Musikexperte. Analysiere diese Album-Daten und vervollständige die fehlenden Felder.\n\n"
f"Verzeichnisname: {hints.album_dir.name}\n"
f"Bekannte Artist: {hints.dir_artist or partial.get('artist', 'unbekannt')}\n"
f"Bekannter Albumtitel: {hints.dir_album or partial.get('album', 'unbekannt')}\n"
f"Jahr: {hints.dir_year or partial.get('year', 'unbekannt')}\n"
f"Tracklist-Hinweise:\n{tracks_summary}\n\n"
'Antworte NUR mit einem JSON-Objekt mit diesen Feldern (null wenn unbekannt):\n'
'{"artist": ..., "album": ..., "albumartist": ..., "year": ..., "genre": ..., "label": ...}'
)
def _parse_json_response(text: str) -> Optional[Dict]:
import json, re
m = re.search(r"\{.*\}", text, re.DOTALL)
if m:
try:
return json.loads(m.group())
except Exception:
pass
return None
def _resolve_via_ollama(hints: AlbumHints, partial: Dict) -> Optional[Dict]:
"""Lokales Reasoning via Ollama (kein API-Key nötig)."""
import urllib.request, json
prompt = _build_resolve_prompt(hints, partial)
payload = json.dumps({
"model": OLLAMA_RESOLVE_MODEL,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"format": "json",
"options": {"temperature": 0.1},
}).encode()
try:
req = urllib.request.Request(
f"{OLLAMA_HOST}/api/chat",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=120) as resp:
data = json.loads(resp.read())
text = data.get("message", {}).get("content", "").strip()
return _parse_json_response(text)
except Exception as e:
print(f" ⚠️ Ollama-Resolve-Fehler: {e}", file=sys.stderr)
return None
def _resolve_via_openrouter(hints: AlbumHints, partial: Dict) -> Optional[Dict]:
"""Reasoning via OpenRouter (günstige chinesische Modelle bevorzugt)."""
if not OPENROUTER_API_KEY:
return None
import urllib.request, json
prompt = _build_resolve_prompt(hints, partial)
# DeepSeek V3: extrem günstig, sehr kompetent
model = "deepseek/deepseek-chat-v3-0324"
payload = json.dumps({
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.1,
"max_tokens": 300,
}).encode()
try:
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"HTTP-Referer": "https://pi.local",
"X-Title": "MusicMetadataEnricher",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read())
text = data["choices"][0]["message"]["content"].strip()
return _parse_json_response(text)
except Exception as e:
print(f" ⚠️ OpenRouter-Resolve-Fehler: {e}", file=sys.stderr)
return None
def _claude_resolve(hints: AlbumHints, partial: Dict) -> Optional[Dict]: def _claude_resolve(hints: AlbumHints, partial: Dict) -> Optional[Dict]:
"""
Reihenfolge: Ollama (lokal, kostenlos) OpenRouter (günstig) Claude API.
Ollama wird versucht wenn OLLAMA_HOST erreichbar; kein Key nötig.
"""
# 1. Ollama lokal (bevorzugt — kostenlos, RTX 3090)
result = _resolve_via_ollama(hints, partial)
if result:
return result
# 2. OpenRouter (DeepSeek V3, günstig) wenn Key gesetzt
if OPENROUTER_API_KEY:
result = _resolve_via_openrouter(hints, partial)
if result:
return result
# 3. Claude API als letzter Fallback
if not HAS_ANTHROPIC or not ANTHROPIC_API_KEY: if not HAS_ANTHROPIC or not ANTHROPIC_API_KEY:
return None return None
try: try:
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY) client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
tracks_summary = "\n".join( prompt = _build_resolve_prompt(hints, partial)
f" - Track {t.track_number or '?'}: {t.title or t.path.stem}"
+ (f" [{t.artist}]" if t.artist else "")
for t in hints.tracks[:20]
)
prompt = f"""Du bist ein Musikexperte. Analysiere diese Album-Daten und vervollständige die fehlenden Felder.
Verzeichnisname: {hints.album_dir.name}
Bekannte Artist: {hints.dir_artist or partial.get('artist', 'unbekannt')}
Bekannter Albumtitel: {hints.dir_album or partial.get('album', 'unbekannt')}
Jahr: {hints.dir_year or partial.get('year', 'unbekannt')}
Tracklist-Hinweise:
{tracks_summary}
Antworte NUR mit einem JSON-Objekt mit diesen Feldern (null wenn unbekannt):
{{"artist": ..., "album": ..., "albumartist": ..., "year": ..., "genre": ..., "label": ...}}"""
message = client.messages.create( message = client.messages.create(
model="claude-haiku-4-5-20251001", model="claude-haiku-4-5-20251001",
max_tokens=300, max_tokens=300,
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
) )
import json
text = message.content[0].text.strip() text = message.content[0].text.strip()
# Extract JSON from response return _parse_json_response(text)
json_match = __import__("re").search(r"\{.*\}", text, __import__("re").DOTALL)
if json_match:
return json.loads(json_match.group())
except Exception as e: except Exception as e:
print(f" ⚠️ Claude-API-Fehler: {e}", file=sys.stderr) print(f" ⚠️ Claude-API-Fehler: {e}", file=sys.stderr)
return None return None
@ -323,9 +415,10 @@ def resolve(
confidence += 0.15 confidence += 0.15
sources.append("discogs") sources.append("discogs")
# Claude API for remaining gaps # LLM-Reasoning für verbleibende Lücken:
# Reihenfolge: Ollama lokal → OpenRouter (DeepSeek, günstig) → Claude API
partial = {"artist": artist, "album": album, "year": year} partial = {"artist": artist, "album": album, "year": year}
if use_claude and use_api and ANTHROPIC_API_KEY and HAS_ANTHROPIC: if use_claude and use_api:
if not artist or not album or confidence < 0.5: if not artist or not album or confidence < 0.5:
cl = _claude_resolve(hints, partial) cl = _claude_resolve(hints, partial)
if cl: if cl:
@ -335,7 +428,7 @@ def resolve(
genre = genre or cl.get("genre") genre = genre or cl.get("genre")
label = label or cl.get("label") label = label or cl.get("label")
confidence += 0.10 confidence += 0.10
sources.append("claude") sources.append("llm-resolve")
# Finalize albumartist # Finalize albumartist
track_artists = [t.artist for t in hints.tracks if t.artist] track_artists = [t.artist for t in hints.tracks if t.artist]