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:
parent
f7cf520dbe
commit
c205fa8943
1 changed files with 120 additions and 27 deletions
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue