From c205fa8943a8f6adebe8ad0d6a55057031b8456a Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 28 Apr 2026 21:03:29 +0200 Subject: [PATCH] feat: Ollama + OpenRouter als LLM-Reasoning-Backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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 --- metadata_resolver.py | 147 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 120 insertions(+), 27 deletions(-) diff --git a/metadata_resolver.py b/metadata_resolver.py index 32a0642..af06b91 100644 --- a/metadata_resolver.py +++ b/metadata_resolver.py @@ -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]