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

View File

@@ -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)
/>
</div>
<div v-else-if="activeTab === 'ki'" class="layout">
<KiTab
:angemeldet="angemeldet"
:gesehen-ids="gesehenIds"
:ohne-gesehene="ohneGesehene"
:filter="suchFilter"
:ansicht="ansicht"
@open="selected = $event"
/>
<FilterSidebar
v-model:ansicht="ansicht"
v-model:ohne-gesehene="ohneGesehene"
v-model:ranges="ranges"
:class="{ offen: filterOffen }"
:regler="REGLER"
:grenzen="grenzen"
:options="genreOptions"
:include="include"
:exclude="exclude"
:ohne-suche="true"
:ohne-sort="true"
@toggle="toggleGenre"
@all="toggleAll"
@reset="resetGenres"
/>
</div>
<WheelTab
v-else-if="activeTab === 'wheel'"
ref="rad"

View File

@@ -12,6 +12,8 @@ const props = defineProps({
ohneSuche: Boolean,
// Nur wo TMDB die Reihenfolge liefert: Suche und Ähnlichkeitsuche
mitRelevanz: Boolean,
// Die KI-Suche hat keine Sortierung — ihre Reihenfolge ist die Relevanz
ohneSort: Boolean,
})
const emit = defineEmits(['toggle', 'all', 'reset'])
@@ -57,7 +59,7 @@ function beschriften(regler) {
type="search"
placeholder="Titel suchen…"
/>
<select v-model="sort">
<select v-if="!ohneSort" v-model="sort">
<option v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
<option value="random">Zufällig</option>
<option value="rating">Beste Bewertung</option>

View File

@@ -0,0 +1,225 @@
<script setup>
// Filme finden, indem man beschreibt, was man sucht. Die KI schlägt vor,
// TMDB bestätigt — was es dort nicht gibt, kommt nicht an. Auf Wunsch
// geht die eigene Sammlung mit, dann kennt die KI den Geschmack. Die
// Filterleiste geht als Vorgabe mit und siebt hinterher noch einmal.
import { computed, ref } from 'vue'
import MovieCard from './MovieCard.vue'
import { filterParameter } from '../filter.js'
const props = defineProps({
angemeldet: Boolean,
gesehenIds: { type: Set, default: () => new Set() },
ohneGesehene: { type: Boolean, default: true },
filter: { type: Object, default: () => ({}) },
ansicht: { type: String, default: 'kompakt' },
})
const emit = defineEmits(['open'])
const MAX = 1000
const beschreibung = ref('')
const mitSammlung = ref(false)
const vorschlaege = ref([])
const laden = ref(false)
const fehler = ref('')
const gefragt = ref(false)
// Eine verstellte Leiste ist schon eine Frage: "Horror ab 2015" reicht
const leisteVerstellt = computed(() => {
const { sort, ...rest } = filterParameter(props.filter)
return Object.values(rest).some((w) => w !== '' && w != null)
})
const kannFragen = computed(
() =>
!laden.value &&
(beschreibung.value.trim().length > 0 || mitSammlung.value || leisteVerstellt.value)
)
async function fragen() {
if (!kannFragen.value) return
laden.value = true
fehler.value = ''
try {
const resp = await fetch('/api/ki-suche', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
beschreibung: beschreibung.value.trim(),
mit_sammlung: props.angemeldet && mitSammlung.value,
ohne_gesehene: props.ohneGesehene,
...filterParameter(props.filter),
}),
})
if (!resp.ok) {
const daten = await resp.json().catch(() => ({}))
throw new Error(daten.detail || 'Das hat nicht geklappt.')
}
vorschlaege.value = await resp.json()
gefragt.value = true
} catch (err) {
fehler.value = err.message
} finally {
laden.value = false
}
}
async function open(movie) {
const resp = await fetch(`/api/search/${movie.id}`)
if (resp.ok) emit('open', await resp.json())
}
</script>
<template>
<div class="ki-tab">
<div class="frage">
<textarea
v-model="beschreibung"
:maxlength="MAX"
rows="3"
placeholder="Was suchst du? Zum Beispiel: langsamer Folk-Horror ohne viel Gore, gern aus Europa, ab 2010 …"
@keydown.ctrl.enter="fragen"
></textarea>
<div class="zeile">
<label v-if="angemeldet" class="schalter">
<input v-model="mitSammlung" type="checkbox" />
Meine Sammlung mitgeben
</label>
<span class="rest"></span>
<button type="button" class="los" :disabled="!kannFragen" @click="fragen">
{{ laden ? 'Überlegt …' : 'Vorschlagen' }}
</button>
</div>
</div>
<p v-if="laden" class="note">Die KI überlegt. Das dauert ein paar Sekunden.</p>
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
<p v-else-if="gefragt && !vorschlaege.length" class="note">
Nichts Brauchbares gefunden. Beschreib es anders.
</p>
<!-- Wo sonst die Inhaltsangabe steht, sagt hier die KI, warum der
Film passt -->
<div class="grid" :class="ansicht">
<MovieCard
v-for="movie in vorschlaege"
:key="movie.id"
:movie="{ ...movie, teaser: movie.grund }"
:ansicht="ansicht"
:gesehen="gesehenIds.has(movie.id)"
@open="open"
/>
</div>
</div>
</template>
<style scoped>
.ki-tab {
min-width: 0;
}
.frage {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1.5rem;
}
textarea {
width: 100%;
min-height: 5.5rem;
padding: 0.9rem 1.1rem;
color: var(--text);
font: inherit;
font-size: 1.05rem;
line-height: 1.5;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
resize: vertical;
}
textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
.zeile {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
}
.schalter {
display: flex;
gap: 0.45rem;
align-items: center;
color: var(--muted);
font-size: 0.9rem;
cursor: pointer;
}
.schalter input {
accent-color: var(--accent);
}
.rest {
flex: 1;
}
.los {
padding: 0.6rem 1.4rem;
color: #fff;
font-size: 0.95rem;
font-weight: 600;
background: var(--accent);
border-radius: 8px;
}
.los:hover:not(:disabled) {
filter: brightness(1.15);
}
.los:disabled {
opacity: 0.5;
cursor: default;
}
/* Wie in der Suche: Poster im Raster, Detailkarten zu zweit nebeneinander */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1.1rem;
}
.grid.details {
grid-template-columns: repeat(
auto-fill,
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
);
}
.note {
padding: 2.5rem 0;
color: var(--muted);
text-align: center;
}
.note.error {
color: var(--accent);
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
gap: 0.6rem;
}
.grid.details {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -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(() => {