This commit is contained in:
team3
2026-09-18 22:12:01 +02:00
parent 33b3975b61
commit db77c1f985
10 changed files with 488 additions and 298 deletions

View File

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

View File

@@ -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 = {}