This commit is contained in:
team3
2026-09-17 19:10:10 +02:00
parent ea54a86db4
commit 33b3975b61
8 changed files with 620 additions and 3 deletions

190
backend/app/ki.py Normal file
View File

@@ -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_)]

View File

@@ -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."""

Binary file not shown.

115
backend/tests/test_ki.py Normal file
View File

@@ -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 = (
"<think>Der Nutzer mag ruhige Filme.</think>\n"
"Hier meine Vorschläge:\n"
'{"filme": [{"titel": "The Witch", "jahr": 2015, "grund": "Langsam, düster."},'
' {"titel": "", "jahr": 1, "grund": "leer"},'
' {"titel": "Lamb", "jahr": "2021"}]}\nViel Spaß!'
)
assert ki.parsen(text) == [
{"titel": "The Witch", "jahr": 2015, "grund": "Langsam, düster."},
{"titel": "Lamb", "jahr": 2021, "grund": ""},
]
def test_parsen_bei_muell_leer():
assert ki.parsen("Keine Ahnung.") == []
assert ki.parsen('{"filme": "nix"}') == []
assert ki.parsen("{kaputt") == []
def test_prompt_nennt_sammlung_und_gesehenes():
text = ki.prompt("blutig", ["Alien (1979)"], ["Nobody (2021)"])
assert "Gesucht: blutig" in text
assert "- Alien (1979)" in text
assert "- Nobody (2021)" in text
# Ohne Beschreibung soll die Sammlung den Ton angeben
assert "zu dieser Sammlung passt" in ki.prompt("", ["Alien (1979)"], [])
def test_vorgaben_aus_der_leiste():
zeilen = ki.vorgaben(
{"include": ["Horror", "Thriller"], "exclude": ["Komödie"],
"jahr_min": 2010, "note_min": 6.5, "dauer_max": 120, "stimmen_min": None}
)
assert zeilen == [
"Genre: Horror, Thriller",
"Auf keinen Fall: Komödie",
"Ab 2010",
"Mindestens 6.5 von 10 auf TMDB",
"Höchstens 120 Minuten",
]
assert ki.vorgaben({"jahr_min": 1980, "jahr_max": 1989}) == ["Zwischen 1980 und 1989"]
assert ki.vorgaben({}) == []
assert "unbedingt einhalten" in ki.prompt("x", [], [], {"include": ["Horror"]})
def test_route_fragt_und_loest_auf(client, film, monkeypatch):
aufrufe = {}
async def fragen(beschreibung, sammlung, gesehen, filter_):
aufrufe.update(
beschreibung=beschreibung, sammlung=sammlung, gesehen=gesehen, filter=filter_
)
return [{"titel": "The Witch", "jahr": 2015, "grund": "Passt."}]
async def aufloesen(vorschlaege, filter_, ausser=()):
aufrufe["ausser"] = set(ausser)
return [{"id": 310131, "title": "The Witch", "grund": v["grund"]} for v in vorschlaege]
monkeypatch.setattr(main.ki, "fragen", fragen)
monkeypatch.setattr(main.ki, "aufloesen", aufloesen)
# Ein Film in der Sammlung, einer gesehen
client.post("/api/wishlist", json={"movie_id": film(1, "Alien")})
client.post("/api/watched", json={"movie_id": film(2)})
antwort = client.post(
"/api/ki-suche",
json={"beschreibung": " Folk-Horror ", "mit_sammlung": True,
"include": "Horror,Mystery", "jahr_min": 2010},
)
assert antwort.status_code == 200, antwort.text
assert antwort.json()[0]["grund"] == "Passt."
assert aufrufe["beschreibung"] == "Folk-Horror"
assert aufrufe["sammlung"] == ["Alien"]
assert len(aufrufe["gesehen"]) == 1
assert aufrufe["ausser"] == {film(2)}
assert aufrufe["filter"]["include"] == ["Horror", "Mystery"]
assert aufrufe["filter"]["jahr_min"] == 2010
assert aufrufe["filter"]["note_max"] is None
# Gesehene dürfen auch drinbleiben — dann zeigt die Karte es an
client.post("/api/ki-suche", json={"beschreibung": "x", "ohne_gesehene": False})
assert aufrufe["ausser"] == set()
def test_nur_filter_reichen_als_frage(client, monkeypatch):
async def fragen(beschreibung, sammlung, gesehen, filter_):
return []
async def aufloesen(vorschlaege, filter_, ausser=()):
return []
monkeypatch.setattr(main.ki, "fragen", fragen)
monkeypatch.setattr(main.ki, "aufloesen", aufloesen)
assert client.post("/api/ki-suche", json={"include": "Horror"}).status_code == 200
def test_route_ohne_beschreibung_und_sammlung(client):
assert client.post("/api/ki-suche", json={"beschreibung": " "}).status_code == 400
def test_route_wenn_die_ki_schweigt(client, monkeypatch):
import httpx
async def fragen(*_):
raise httpx.ConnectError("weg")
monkeypatch.setattr(main.ki, "fragen", fragen)
assert client.post("/api/ki-suche", json={"beschreibung": "x"}).status_code == 502