"""Vision-LLM: Bild direkt an ein multimodales LLM senden, ohne OCR-Zwischenschritt."""
from __future__ import annotations
import base64
import json
import logging
import re
from pathlib import Path
import httpx
from pydantic import ValidationError
from musiksammlung.models import Album
logger = logging.getLogger(__name__)
VISION_PROMPT = """\
Lies das Foto einer CD-Rückseite oder eines Booklets ab. Das Bild kann gedreht sein.
Extrahiere daraus die Metadaten und die vollständige Trackliste.
WICHTIG:
- "artist" ist der Hauptinterpret oder "Various Artists" bei Samplern/Compilations.
- "album" ist der Albumtitel (z.B. "Deutsche Volkslieder", "Abbey Road").
- "year" ist das Erscheinungsjahr (Zahl oder null wenn nicht sichtbar).
- Lies die Tracktitel GENAU so ab, wie sie auf der CD stehen.
- Achte besonders auf korrekte deutsche Umlaute (ä, ö, ü, ß).
- Wenn "CD 1", "CD 2", "Disc 1" etc. sichtbar sind, erstelle mehrere Einträge in "discs".
- Ohne Disc-Angabe: eine Disc mit disc_number=1.
- Lasse Zeitangaben (z.B. "3:12") und Interpretenangaben pro Track weg.
- MEHRSPALTIGE LAYOUTS: CD-Rückseiten haben oft 2, 3 oder 4 Spalten nebeneinander.
Lies ALLE Spalten vollständig von oben nach unten, bevor du zur nächsten Spalte gehst.
Überspringen oder Auslassen von Spalten ist ein häufiger Fehler — lies jede Spalte komplett.
Antworte NUR mit dem JSON, ohne Erklärung. Beispiel:
{"artist":"Various Artists","album":"Deutsche Volkslieder","year":null,""" # noqa: E501
VISION_PROMPT += """"discs":[{"disc_number":1,"name":null,"tracks":["""
VISION_PROMPT += """{"track_number":1,"title":"Erster Song"},"""
VISION_PROMPT += """{"track_number":2,"title":"Zweiter Song"}]}]}"""
VISION_PROMPT += """
Jetzt lies das Bild ab und gib das vollständige JSON aus. /no_think"""
def _encode_image(image_path: Path) -> str:
"""Liest ein Bild und gibt es als Base64-String zurück."""
return base64.b64encode(image_path.read_bytes()).decode("utf-8")
def _extract_json(text: str) -> str:
"""Extrahiert JSON aus einer LLM-Antwort.
Behandelt:
- Reines JSON
- JSON in Markdown-Codeblöcken (```json ... ```)
- Thinking-Tags (...) vor dem JSON
- Sonstiger Text vor/nach dem JSON
"""
if not text or not text.strip():
raise ValueError("Leere Antwort vom Vision-LLM")
# Thinking-Tags entfernen
text = re.sub(r".*?", "", text, flags=re.DOTALL).strip()
# Markdown-Codeblock extrahieren
md_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if md_match:
return md_match.group(1)
# Äußerstes JSON-Objekt finden
brace_match = re.search(r"\{.*\}", text, re.DOTALL)
if brace_match:
return brace_match.group(0)
raise ValueError(f"Kein JSON in Antwort gefunden: {text[:200]}")
def parse_image(
image_paths: list[Path],
model: str = "qwen3-vl:latest",
base_url: str = "http://localhost:11434",
max_retries: int = 3,
) -> Album:
"""Sendet Bilder direkt an ein Vision-LLM und extrahiert Album-Daten.
Args:
image_paths: Liste von Bilddateien (Cover-Rückseite, Booklet, etc.)
model: Ollama Vision-Modell
base_url: Ollama-API-URL
max_retries: Anzahl Wiederholungsversuche bei ungültigem JSON
Returns:
Validiertes Album-Objekt
"""
images_b64 = [_encode_image(p) for p in image_paths]
messages = [
{
"role": "user",
"content": VISION_PROMPT,
"images": images_b64,
}
]
last_error: Exception | None = None
for attempt in range(max_retries + 1):
try:
response = httpx.post(
f"{base_url}/api/chat",
json={
"model": model,
"messages": messages,
"stream": False,
},
timeout=300.0,
)
response.raise_for_status()
raw_text = response.json()["message"]["content"]
logger.info(
"Vision-LLM Antwort (Versuch %d, %d Zeichen)",
attempt + 1, len(raw_text),
)
logger.debug("Rohantwort: %s", raw_text[:1000])
json_str = _extract_json(raw_text)
data = json.loads(json_str)
album = Album.model_validate(data)
logger.info(
"Vision-LLM erfolgreich: %s - %s (%d Discs, %d Tracks)",
album.artist,
album.album,
len(album.discs),
sum(len(d.tracks) for d in album.discs),
)
return album
except (json.JSONDecodeError, ValidationError, ValueError) as e:
last_error = e
logger.warning(
"Versuch %d/%d fehlgeschlagen: %s",
attempt + 1, max_retries + 1, e,
)
except httpx.HTTPStatusError as e:
logger.error("HTTP-Fehler vom Vision-LLM: %s", e)
raise
msg = f"Vision-LLM lieferte nach {max_retries + 1} Versuchen kein valides Ergebnis"
raise ValueError(msg) from last_error