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 = ( + "Der Nutzer mag ruhige Filme.\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 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5e83897..f61a394 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue' import MovieCard from './components/MovieCard.vue' import FilterSidebar from './components/FilterSidebar.vue' import AehnlichTab from './components/AehnlichTab.vue' +import KiTab from './components/KiTab.vue' import MerklisteTab from './components/MerklisteTab.vue' import SammlungFilter from './components/SammlungFilter.vue' import MovieDetail from './components/MovieDetail.vue' @@ -85,6 +86,7 @@ const tabs = computed(() => [ }, { id: 'search', label: 'Suche', count: null }, { id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null }, + { id: 'ki', label: 'KI-Suche', count: null }, ...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]), { id: 'wheel', label: 'Rad', count: null }, { id: 'stream', label: 'Stream', count: null }, @@ -99,7 +101,7 @@ const tabs = computed(() => [ // legt sie sich über einen Knopf über die Seite const filterOffen = ref(false) const mitFilter = computed(() => - ['netflix', 'canon', 'search', 'aehnlich', 'merkliste'].includes(activeTab.value) + ['netflix', 'canon', 'search', 'aehnlich', 'ki', 'merkliste'].includes(activeTab.value) ) // Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler — @@ -269,6 +271,34 @@ onMounted(load) /> +
+ + + +
+ - diff --git a/frontend/src/components/KiTab.vue b/frontend/src/components/KiTab.vue new file mode 100644 index 0000000..27be506 --- /dev/null +++ b/frontend/src/components/KiTab.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/frontend/src/composables/useFilter.js b/frontend/src/composables/useFilter.js index e1b62f5..d825b1d 100644 --- a/frontend/src/composables/useFilter.js +++ b/frontend/src/composables/useFilter.js @@ -38,7 +38,7 @@ export function useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, o const include = ref([]) const exclude = ref([]) - const suchReiter = () => activeTab.value === 'search' || activeTab.value === 'aehnlich' + const suchReiter = () => ['search', 'aehnlich', 'ki'].includes(activeTab.value) const pool = () => (activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value) const grenzen = computed(() => {