update
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user