diff --git a/backend/app/ki.py b/backend/app/ki.py index e919d56..e80f0f4 100644 --- a/backend/app/ki.py +++ b/backend/app/ki.py @@ -9,6 +9,7 @@ erfundene oder verwechselte Filme draußen. import asyncio import json +from difflib import SequenceMatcher import httpx @@ -24,12 +25,45 @@ SYSTEM = ( "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. " + "Was in der Frage genannt ist, kennt die Person schon — auch nicht vorschlagen. " "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"}]}' + '{"merkmale": ["drei Stichpunkte: was macht das Gesuchte aus"], ' + '"kandidaten": ["20 Originaltitel mit Jahr, die dazu passen könnten"], ' + '"filme": [{"titel": "Originaltitel", "jahr": 1999, "grund": "..."}]}\n' + # Der Umweg über Merkmale und Kandidaten ersetzt das Nachdenken des + # Modells: es sortiert erst, dann wählt es — und trifft deutlich besser. + "Erst merkmale, dann kandidaten, dann die zwölf besten davon als filme.\n" + # Die Begründung lesen Leute, die einfach einen Film für den Abend + # suchen — nicht Kritiker. Sie muss auf vier kurze Zeilen passen. + "Für grund gilt: Höchstens 10 Wörter und 90 Zeichen, Deutsch in lateinischer " + "Schrift, für Laien. " + "Sag klar, was der Film mit dem Gesuchten gemeinsam hat. " + "Keine Fachwörter wie Whodunit, Neo-Noir, Ensemble, Slasher, Body-Horror, " + "Meta, Hommage, Genre-Namen auf Englisch. Keine Inhaltsangabe, kein Titel, " + "kein Land. Ein Regisseur nur, wenn er der Grund ist. " + "Jede Begründung nennt einen anderen Punkt, keine zwei gleich. " + 'Beispiel — gesucht "Ich mochte Knives Out": ' + '"Wieder rätselt eine zerstrittene Familie um einen Mord, mit Witz."' ) +# Auf der Karte hat die Begründung vier Zeilen à gut zwanzig Zeichen +MAX_GRUND = 95 + + +def kuerzen(grund): + """Das Modell zählt ungenau. Was nicht auf die Karte passt, endet am + letzten Komma oder Wort davor — ein halber Satz ist besser als ein + abgeschnittener.""" + grund = grund.strip() + if len(grund) <= MAX_GRUND: + return grund + kopf = grund[:MAX_GRUND] + schnitt = max(kopf.rfind(","), kopf.rfind(";"), kopf.rfind(" –"), kopf.rfind(".")) + if schnitt < 40: + schnitt = kopf.rfind(" ") + return kopf[:schnitt].rstrip(",; –.") + " …" + def etikett(film): """Titel (Jahr) — so kommt ein Film in den Prompt.""" @@ -71,6 +105,11 @@ def prompt(beschreibung, sammlung, gesehen, filter_=None): teile = [] if beschreibung: teile.append(f"Gesucht: {beschreibung}") + # In der Nutzernachricht hält sich das Modell eher daran als im + # Systemprompt — sonst steht die Fortsetzung des Gesuchten vorn + teile.append( + "Nicht vorschlagen: Filme, die oben genannt sind, und ihre Fortsetzungen." + ) regeln = vorgaben(filter_) if regeln: teile.append("Vorgaben, unbedingt einhalten:\n" + "\n".join(f"- {z}" for z in regeln)) @@ -110,7 +149,7 @@ def parsen(text): { "titel": titel, "jahr": int(jahr) if jahr.isdigit() else None, - "grund": str(f.get("grund") or "").strip(), + "grund": kuerzen(str(f.get("grund") or "")), } ) return aus[:ANZAHL] @@ -123,17 +162,37 @@ async def fragen(beschreibung, sammlung, gesehen, filter_=None): {"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", + # Ohne Denkblock: M3 grübelt sonst minutenlang über zwölf Filme + # und liefert am Token-Limit gar nichts. Die Merkmale- und + # Kandidatenfelder im Prompt leisten das Sortieren stattdessen, + # in gut zehn Sekunden. Niedrige Temperatur: weniger Ausreißer. + "thinking": {"type": "disabled"}, "max_tokens": 4000, - "temperature": 0.7, + "temperature": 0.3, } 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"]) + # Hin und wieder kommt kein brauchbares JSON zurück. Ein zweiter + # Anlauf kostet wenige Sekunden und sitzt fast immer. + for _ in range(2): + resp = await http.post(MINIMAX_URL, json=payload) + resp.raise_for_status() + filme = parsen(resp.json()["choices"][0]["message"]["content"]) + if filme: + return filme + return [] + + +def _aehnlich(gesucht, treffer): + """Passt der TMDB-Treffer zum genannten Titel? Die Suche liefert + auch Streifschüsse — "The Afterparty" findet ein Winx-Special. Der + deutsche oder der Originaltitel muss dem Genannten nahekommen.""" + soll = search.ohne_zeichen(gesucht).lower() + return any( + SequenceMatcher(None, soll, search.ohne_zeichen(t or "").lower()).ratio() >= 0.6 + for t in (treffer.get("title"), treffer.get("original_title")) + ) def _dauer_passt(karte, filter_): @@ -162,7 +221,9 @@ async def aufloesen(vorschlaege, filter_=None, ausser=()): 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_) + if t.get("poster_path") + and _aehnlich(v["titel"], t) + and search.passt_zur_leiste(t, filter_) ] except httpx.HTTPError: return None diff --git a/backend/tests/test_ki.py b/backend/tests/test_ki.py index 745353e..5775bad 100644 --- a/backend/tests/test_ki.py +++ b/backend/tests/test_ki.py @@ -17,6 +17,21 @@ def test_parsen_zieht_json_aus_geschwaetz(): ] +def test_lange_begruendung_endet_am_komma(): + kurz = "Wieder rätselt eine Familie um einen Mord, mit Witz." + assert ki.kuerzen(kurz) == kurz + lang = ( + "Zwei chaotische Ermittler stolpern durch einen Fall voller Wendungen, " + "schräger Figuren und schlagfertiger Sprüche im Los Angeles der Siebziger." + ) + gekuerzt = ki.kuerzen(lang) + assert gekuerzt == "Zwei chaotische Ermittler stolpern durch einen Fall voller Wendungen …" + assert len(gekuerzt) <= ki.MAX_GRUND + 2 + # Ohne Komma bricht es am letzten Wort + ohne = "Wort " * 30 + assert ki.kuerzen(ohne).endswith("Wort …") and len(ki.kuerzen(ohne)) <= ki.MAX_GRUND + 2 + + def test_parsen_bei_muell_leer(): assert ki.parsen("Keine Ahnung.") == [] assert ki.parsen('{"filme": "nix"}') == [] @@ -49,6 +64,16 @@ def test_vorgaben_aus_der_leiste(): assert "unbedingt einhalten" in ki.prompt("x", [], [], {"include": ["Horror"]}) +def test_titel_muss_zum_treffer_passen(): + assert ki._aehnlich("Clue", {"title": "Alle Mörder sind schon da", "original_title": "Clue"}) + assert ki._aehnlich("The Hateful 8", {"title": "The Hateful 8", "original_title": "The Hateful Eight"}) + assert ki._aehnlich("Murder by Death", {"title": "Eine Leiche zum Dessert", "original_title": "Murder by Death"}) + assert not ki._aehnlich( + "The Afterparty", + {"title": "Fate: The Winx Saga - Die Afterparty", "original_title": "Fate: The Winx Saga - The Afterparty"}, + ) + + def test_route_fragt_und_loest_auf(client, film, monkeypatch): aufrufe = {} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f61a394..4fe7939 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -21,7 +21,7 @@ import { matches, useKatalog } from './composables/useKatalog' import { REGLER, useFilter } from './composables/useFilter' import { useLangeListe } from './composables/useLangeListe' -const { ansicht, ohneGesehene, activeTab, host } = useEinstellungen() +const { ansicht, ohneGesehene, activeTab, quelle, suchArt, host } = useEinstellungen() const { runde, ich, michSetzen, @@ -44,7 +44,7 @@ const { const { search, sort, suchSort, include, exclude, grenzen, ranges, genreOptions, toggleGenre, toggleAll, resetGenres, nichtGesehen, passt, suchFilter, -} = useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, ohneGesehene }) +} = useFilter({ activeTab, quelle, netflixMovies, canonMovies, gesehenIds, ohneGesehene }) // Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über // ihren stärksten Teil @@ -69,24 +69,31 @@ const visibleCanon = computed(() => ) const aktuelleListe = computed(() => - activeTab.value === 'netflix' ? visible.value : visibleCanon.value + quelle.value === 'netflix' ? visible.value : visibleCanon.value ) +// Die zwei Listen des Katalogs, umschaltbar in der Filterleiste +const quellen = computed(() => [ + { id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length }, + { + id: 'canon', + label: 'Horror-Kanon', + count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length, + }, +]) + const { gezeigt, sichtbar, nachschub, obenKnopf, nachOben } = useLangeListe({ liste: aktuelleListe, ansicht, }) const tabs = computed(() => [ - { id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length }, { - id: 'canon', - label: 'Horror-Kanon', - count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length, + id: 'katalog', + label: 'Katalog', + count: quellen.value.find((q) => q.id === quelle.value)?.count ?? null, }, - { id: 'search', label: 'Suche', count: null }, - { id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null }, - { id: 'ki', label: 'KI-Suche', count: null }, + { id: 'suche', label: 'Suche', count: null }, ...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]), { id: 'wheel', label: 'Rad', count: null }, { id: 'stream', label: 'Stream', count: null }, @@ -101,7 +108,7 @@ const tabs = computed(() => [ // legt sie sich über einen Knopf über die Seite const filterOffen = ref(false) const mitFilter = computed(() => - ['netflix', 'canon', 'search', 'aehnlich', 'ki', 'merkliste'].includes(activeTab.value) + ['katalog', 'suche', 'merkliste'].includes(activeTab.value) ) // Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler — @@ -125,7 +132,7 @@ const leuteMitSammlung = computed(() => { // Fällt der offene Reiter weg — Hostmodus aus, Name abgewählt —, wäre // sonst keiner markiert und die Seite bliebe leer watch(tabs, (liste) => { - if (!liste.some((t) => t.id === activeTab.value)) activeTab.value = 'netflix' + if (!liste.some((t) => t.id === activeTab.value)) activeTab.value = 'katalog' }) // Passen die Reiter nicht in eine Zeile, soll der offene sichtbar sein @@ -184,9 +191,17 @@ const aehnlichStart = ref(null) function zurAehnlichkeitsuche(movie) { aehnlichStart.value = movie selected.value = null - activeTab.value = 'aehnlich' + activeTab.value = 'suche' + suchArt.value = 'aehnlich' } +// Die drei Suchen unter einem Reiter, umschaltbar über der Liste +const SUCHARTEN = [ + { id: 'search', label: 'Titel' }, + { id: 'aehnlich', label: 'Ähnliche Filme' }, + { id: 'ki', label: 'KI' }, +] + const rad = ref(null) function nachVorschlag() { @@ -232,26 +247,51 @@ onMounted(load) -
Noch nichts gesagt.
- Such einen Film. Danach zeigt dir dieser Reiter, was dazu passt. -