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
|
||||
_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", "")
|
||||
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():
|
||||
|
|
@ -172,39 +177,126 @@ def _discogs_search(artist: Optional[str], album: Optional[str]) -> Optional[Dic
|
|||
# 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]:
|
||||
"""
|
||||
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:
|
||||
return None
|
||||
try:
|
||||
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
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]
|
||||
)
|
||||
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": ...}}"""
|
||||
|
||||
prompt = _build_resolve_prompt(hints, partial)
|
||||
message = client.messages.create(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=300,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
import json
|
||||
text = message.content[0].text.strip()
|
||||
# Extract JSON from response
|
||||
json_match = __import__("re").search(r"\{.*\}", text, __import__("re").DOTALL)
|
||||
if json_match:
|
||||
return json.loads(json_match.group())
|
||||
return _parse_json_response(text)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Claude-API-Fehler: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
|
@ -323,9 +415,10 @@ def resolve(
|
|||
confidence += 0.15
|
||||
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}
|
||||
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:
|
||||
cl = _claude_resolve(hints, partial)
|
||||
if cl:
|
||||
|
|
@ -335,7 +428,7 @@ def resolve(
|
|||
genre = genre or cl.get("genre")
|
||||
label = label or cl.get("label")
|
||||
confidence += 0.10
|
||||
sources.append("claude")
|
||||
sources.append("llm-resolve")
|
||||
|
||||
# Finalize albumartist
|
||||
track_artists = [t.artist for t in hints.tracks if t.artist]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue