update
This commit is contained in:
190
backend/app/ki.py
Normal file
190
backend/app/ki.py
Normal 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_)]
|
||||||
@@ -4,6 +4,7 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
import httpx
|
||||||
from fastapi import Body, Cookie, FastAPI, HTTPException, Request, Response
|
from fastapi import Body, Cookie, FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -15,6 +16,7 @@ from . import (
|
|||||||
db,
|
db,
|
||||||
events,
|
events,
|
||||||
features,
|
features,
|
||||||
|
ki,
|
||||||
search,
|
search,
|
||||||
streamproxy,
|
streamproxy,
|
||||||
suggestions,
|
suggestions,
|
||||||
@@ -231,6 +233,59 @@ async def aehnliche_filme(
|
|||||||
return []
|
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}")
|
@app.get("/api/search/{movie_id}")
|
||||||
async def search_detail(movie_id: int):
|
async def search_detail(movie_id: int):
|
||||||
"""Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB."""
|
"""Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB."""
|
||||||
|
|||||||
Binary file not shown.
115
backend/tests/test_ki.py
Normal file
115
backend/tests/test_ki.py
Normal 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
|
||||||
@@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|||||||
import MovieCard from './components/MovieCard.vue'
|
import MovieCard from './components/MovieCard.vue'
|
||||||
import FilterSidebar from './components/FilterSidebar.vue'
|
import FilterSidebar from './components/FilterSidebar.vue'
|
||||||
import AehnlichTab from './components/AehnlichTab.vue'
|
import AehnlichTab from './components/AehnlichTab.vue'
|
||||||
|
import KiTab from './components/KiTab.vue'
|
||||||
import MerklisteTab from './components/MerklisteTab.vue'
|
import MerklisteTab from './components/MerklisteTab.vue'
|
||||||
import SammlungFilter from './components/SammlungFilter.vue'
|
import SammlungFilter from './components/SammlungFilter.vue'
|
||||||
import MovieDetail from './components/MovieDetail.vue'
|
import MovieDetail from './components/MovieDetail.vue'
|
||||||
@@ -85,6 +86,7 @@ const tabs = computed(() => [
|
|||||||
},
|
},
|
||||||
{ id: 'search', label: 'Suche', count: null },
|
{ id: 'search', label: 'Suche', count: null },
|
||||||
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
|
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
|
||||||
|
{ id: 'ki', label: 'KI-Suche', count: null },
|
||||||
...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
||||||
{ id: 'wheel', label: 'Rad', count: null },
|
{ id: 'wheel', label: 'Rad', count: null },
|
||||||
{ id: 'stream', label: 'Stream', count: null },
|
{ id: 'stream', label: 'Stream', count: null },
|
||||||
@@ -99,7 +101,7 @@ const tabs = computed(() => [
|
|||||||
// legt sie sich über einen Knopf über die Seite
|
// legt sie sich über einen Knopf über die Seite
|
||||||
const filterOffen = ref(false)
|
const filterOffen = ref(false)
|
||||||
const mitFilter = computed(() =>
|
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 —
|
// Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler —
|
||||||
@@ -269,6 +271,34 @@ onMounted(load)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<WheelTab
|
||||||
v-else-if="activeTab === 'wheel'"
|
v-else-if="activeTab === 'wheel'"
|
||||||
ref="rad"
|
ref="rad"
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const props = defineProps({
|
|||||||
ohneSuche: Boolean,
|
ohneSuche: Boolean,
|
||||||
// Nur wo TMDB die Reihenfolge liefert: Suche und Ähnlichkeitsuche
|
// Nur wo TMDB die Reihenfolge liefert: Suche und Ähnlichkeitsuche
|
||||||
mitRelevanz: Boolean,
|
mitRelevanz: Boolean,
|
||||||
|
// Die KI-Suche hat keine Sortierung — ihre Reihenfolge ist die Relevanz
|
||||||
|
ohneSort: Boolean,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['toggle', 'all', 'reset'])
|
const emit = defineEmits(['toggle', 'all', 'reset'])
|
||||||
@@ -57,7 +59,7 @@ function beschriften(regler) {
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder="Titel suchen…"
|
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 v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
|
||||||
<option value="random">Zufällig</option>
|
<option value="random">Zufällig</option>
|
||||||
<option value="rating">Beste Bewertung</option>
|
<option value="rating">Beste Bewertung</option>
|
||||||
|
|||||||
225
frontend/src/components/KiTab.vue
Normal file
225
frontend/src/components/KiTab.vue
Normal 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>
|
||||||
@@ -38,7 +38,7 @@ export function useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, o
|
|||||||
const include = ref([])
|
const include = ref([])
|
||||||
const exclude = 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 pool = () => (activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value)
|
||||||
|
|
||||||
const grenzen = computed(() => {
|
const grenzen = computed(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user