diff --git a/backend/app/ki.py b/backend/app/ki.py
new file mode 100644
index 0000000..e919d56
--- /dev/null
+++ b/backend/app/ki.py
@@ -0,0 +1,190 @@
+"""Filmvorschläge von MiniMax.
+
+Das Modell kennt Filme nur aus seinem Training — keine Datenbank, kein
+Netz. Es bekommt eine Beschreibung und auf Wunsch die eigene Sammlung,
+nennt Titel mit Jahr und einem Satz Begründung. Jeder Titel wird danach
+bei TMDB nachgeschlagen. Was es dort nicht gibt, fällt weg: so bleiben
+erfundene oder verwechselte Filme draußen.
+"""
+
+import asyncio
+import json
+
+import httpx
+
+from . import search
+from .config import LANGUAGE, MINIMAX_KEY, MINIMAX_MODEL, MINIMAX_URL
+from .teaser import THINK_BLOCK
+
+ANZAHL = 12
+MAX_BESCHREIBUNG = 1000
+
+SYSTEM = (
+ "Du bist ein belesener Filmkenner mit Schwerpunkt Horror und Genrekino. "
+ "Du schlägst Filme vor, die zu dem passen, was gesucht wird. "
+ f"Nenne genau {ANZAHL} Filme, keine Serien. Nur Filme, die es wirklich gibt. "
+ "Nichts vorschlagen, was in der Sammlung steht oder schon gesehen wurde. "
+ "Mische Bekanntes mit Geheimtipps. "
+ "Antworte nur mit JSON in dieser Form, ohne Text davor oder danach: "
+ '{"filme": [{"titel": "Originaltitel", "jahr": 1999, '
+ '"grund": "ein Satz auf Deutsch, warum der Film passt"}]}'
+)
+
+
+def etikett(film):
+ """Titel (Jahr) — so kommt ein Film in den Prompt."""
+ jahr = film.get("year")
+ return f"{film['title']} ({jahr})" if jahr else film["title"]
+
+
+def _spanne(unten, oben, einheit="", ab="mindestens", bis="höchstens"):
+ if unten is not None and oben is not None:
+ return f"zwischen {unten}{einheit} und {oben}{einheit}"
+ if unten is not None:
+ return f"{ab} {unten}{einheit}"
+ if oben is not None:
+ return f"{bis} {oben}{einheit}"
+ return ""
+
+
+def vorgaben(filter_):
+ """Die Filterleiste als Sätze für den Prompt. Nur was verstellt ist,
+ kommt vor — die Leiste schickt auch nur das."""
+ f = filter_ or {}
+ zeilen = []
+ if f.get("include"):
+ zeilen.append("Genre: " + ", ".join(f["include"]))
+ if f.get("exclude"):
+ zeilen.append("Auf keinen Fall: " + ", ".join(f["exclude"]))
+ for text in (
+ _spanne(f.get("jahr_min"), f.get("jahr_max"), ab="ab", bis="bis"),
+ _spanne(f.get("note_min"), f.get("note_max"), " von 10 auf TMDB"),
+ _spanne(f.get("dauer_min"), f.get("dauer_max"), " Minuten"),
+ _spanne(f.get("stimmen_min"), f.get("stimmen_max"), " Stimmen auf TMDB"),
+ ):
+ if text:
+ zeilen.append(text[0].upper() + text[1:])
+ return zeilen
+
+
+def prompt(beschreibung, sammlung, gesehen, filter_=None):
+ teile = []
+ if beschreibung:
+ teile.append(f"Gesucht: {beschreibung}")
+ regeln = vorgaben(filter_)
+ if regeln:
+ teile.append("Vorgaben, unbedingt einhalten:\n" + "\n".join(f"- {z}" for z in regeln))
+ if sammlung:
+ teile.append("Meine Sammlung, so etwas mag ich:\n" + "\n".join(f"- {t}" for t in sammlung))
+ if not beschreibung and sammlung:
+ teile.append("Schlag vor, was zu dieser Sammlung passt.")
+ elif not beschreibung:
+ teile.append("Schlag vor, was zu den Vorgaben passt.")
+ if gesehen:
+ teile.append("Schon gesehen, nicht vorschlagen:\n" + "\n".join(f"- {t}" for t in gesehen))
+ return "\n\n".join(teile)
+
+
+def parsen(text):
+ """Das JSON aus der Antwort ziehen. Das Modell hängt gern einen
+ Denkblock oder einen Satz davor — es zählt, was zwischen der ersten
+ und der letzten Klammer steht."""
+ text = THINK_BLOCK.sub("", text)
+ a, b = text.find("{"), text.rfind("}")
+ if a < 0 or b < a:
+ return []
+ try:
+ daten = json.loads(text[a : b + 1])
+ except ValueError:
+ return []
+ filme = daten.get("filme") if isinstance(daten, dict) else None
+ aus = []
+ for f in filme or []:
+ if not isinstance(f, dict):
+ continue
+ titel = str(f.get("titel") or "").strip()
+ if not titel:
+ continue
+ jahr = str(f.get("jahr") or "")
+ aus.append(
+ {
+ "titel": titel,
+ "jahr": int(jahr) if jahr.isdigit() else None,
+ "grund": str(f.get("grund") or "").strip(),
+ }
+ )
+ return aus[:ANZAHL]
+
+
+async def fragen(beschreibung, sammlung, gesehen, filter_=None):
+ payload = {
+ "model": MINIMAX_MODEL,
+ "messages": [
+ {"role": "system", "content": SYSTEM},
+ {"role": "user", "content": prompt(beschreibung, sammlung, gesehen, filter_)},
+ ],
+ # Wie beim Teaser: sonst denkt M3 das Token-Limit voll
+ "reasoning_effort": "low",
+ "max_tokens": 4000,
+ "temperature": 0.7,
+ }
+ async with httpx.AsyncClient(
+ headers={"Authorization": f"Bearer {MINIMAX_KEY}"}, timeout=120.0
+ ) as http:
+ resp = await http.post(MINIMAX_URL, json=payload)
+ resp.raise_for_status()
+ return parsen(resp.json()["choices"][0]["message"]["content"])
+
+
+def _dauer_passt(karte, filter_):
+ dauer = karte.get("runtime")
+ if dauer is None:
+ return True
+ unten, oben = filter_.get("dauer_min"), filter_.get("dauer_max")
+ return (unten is None or dauer >= unten) and (oben is None or dauer <= oben)
+
+
+async def aufloesen(vorschlaege, filter_=None, ausser=()):
+ """Jeden Titel bei TMDB suchen. Der Treffer mit dem genannten Jahr
+ gewinnt — das Modell liegt gern ein Jahr daneben, also mit Spiel.
+ Sonst der beliebteste. Ohne Treffer war der Film wohl erfunden.
+
+ Die Filter der Leiste greifen hier noch einmal hart: was die KI
+ trotz Vorgabe danebenlegt, sieben die TMDB-Daten aus."""
+ filter_ = filter_ or {}
+
+ async def einer(http, v):
+ try:
+ resp = await http.get(
+ "/search/movie",
+ params={"query": v["titel"], "language": LANGUAGE, "include_adult": "false"},
+ )
+ resp.raise_for_status()
+ treffer = [
+ t for t in resp.json().get("results", [])
+ if t.get("poster_path") and search.passt_zur_leiste(t, filter_)
+ ]
+ except httpx.HTTPError:
+ return None
+ if v["jahr"]:
+ passend = [
+ t for t in treffer
+ if (t.get("release_date") or "")[:4].isdigit()
+ and abs(int(t["release_date"][:4]) - v["jahr"]) <= 1
+ ]
+ treffer = passend or treffer
+ if not treffer:
+ return None
+ bester = max(treffer, key=lambda t: t.get("popularity") or 0)
+ return {**search.as_result(bester), "grund": v["grund"]}
+
+ async with search.client() as http:
+ gefunden = await asyncio.gather(*(einer(http, v) for v in vorschlaege))
+ karten, dabei = [], set(ausser)
+ for k in gefunden:
+ if k and k["id"] not in dabei:
+ dabei.add(k["id"])
+ karten.append(k)
+ await search.details_nachladen(http, karten)
+ # Die Laufzeit kennt erst die Detailantwort
+ return [k for k in karten if _dauer_passt(k, filter_)]
diff --git a/backend/app/main.py b/backend/app/main.py
index 16caa0d..b307734 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -4,6 +4,7 @@ import secrets
import time
from apscheduler.schedulers.asyncio import AsyncIOScheduler
+import httpx
from fastapi import Body, Cookie, FastAPI, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
@@ -15,6 +16,7 @@ from . import (
db,
events,
features,
+ ki,
search,
streamproxy,
suggestions,
@@ -231,6 +233,59 @@ async def aehnliche_filme(
return []
+@app.post("/api/ki-suche")
+async def ki_suche(
+ response: Response,
+ beschreibung: str = Body("", embed=True),
+ mit_sammlung: bool = Body(False, embed=True),
+ ohne_gesehene: bool = Body(True, embed=True),
+ include: str = Body("", embed=True),
+ exclude: str = Body("", embed=True),
+ stimmen_min: int | None = Body(None, embed=True),
+ stimmen_max: int | None = Body(None, embed=True),
+ dauer_min: int | None = Body(None, embed=True),
+ dauer_max: int | None = Body(None, embed=True),
+ jahr_min: int | None = Body(None, embed=True),
+ jahr_max: int | None = Body(None, embed=True),
+ note_min: float | None = Body(None, embed=True),
+ note_max: float | None = Body(None, embed=True),
+ filmabend_sid: str | None = Cookie(None),
+):
+ """Vorschläge von der KI, bei TMDB nachgeschlagen. Die eigene Sammlung
+ geht auf Wunsch mit, die gesehenen Filme als Ausschluss. Die Filter
+ der Leiste sind Vorgaben an die KI und danach ein Sieb."""
+ sid = sitzung(response, filmabend_sid)
+ filter_ = {
+ "include": [g for g in include.split(",") if g],
+ "exclude": [g for g in exclude.split(",") if g],
+ "stimmen_min": stimmen_min,
+ "stimmen_max": stimmen_max,
+ "dauer_min": dauer_min,
+ "dauer_max": dauer_max,
+ "jahr_min": jahr_min,
+ "jahr_max": jahr_max,
+ "note_min": note_min,
+ "note_max": note_max,
+ }
+ beschreibung = beschreibung.strip()[: ki.MAX_BESCHREIBUNG]
+ sammlung = (
+ [ki.etikett(e) for e in wishlist.alle(sid) if e["mine"]] if mit_sammlung else []
+ )
+ if not beschreibung and not sammlung and not ki.vorgaben(filter_):
+ raise HTTPException(
+ status_code=400, detail="Beschreib, was du suchst, oder gib die Sammlung mit."
+ )
+ gesehen = watched.alle(sid)
+ try:
+ vorschlaege = await ki.fragen(
+ beschreibung, sammlung, [ki.etikett(g) for g in gesehen], filter_
+ )
+ except (httpx.HTTPError, KeyError, IndexError):
+ raise HTTPException(status_code=502, detail="Die KI antwortet gerade nicht.")
+ ausser = {g["movie_id"] for g in gesehen} if ohne_gesehene else set()
+ return await ki.aufloesen(vorschlaege, filter_, ausser=ausser)
+
+
@app.get("/api/search/{movie_id}")
async def search_detail(movie_id: int):
"""Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB."""
diff --git a/backend/horror.db.bak b/backend/horror.db.bak
index 0ce1620..8565272 100644
Binary files a/backend/horror.db.bak and b/backend/horror.db.bak differ
diff --git a/backend/tests/test_ki.py b/backend/tests/test_ki.py
new file mode 100644
index 0000000..745353e
--- /dev/null
+++ b/backend/tests/test_ki.py
@@ -0,0 +1,115 @@
+"""KI-Suche: Antwort des Modells lesen, Titel bei TMDB bestätigen."""
+
+from app import ki, main
+
+
+def test_parsen_zieht_json_aus_geschwaetz():
+ text = (
+ "