feat: Spiele-Empfehlungs-Engine mit Scoring-Algorithmus
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 11m56s
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 11m56s
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
"""Empfehlungs-Service für Brettspiele."""
|
||||||
|
|
||||||
|
from game_database import SPIEL
|
||||||
|
|
||||||
|
FACTOR_KOMPLEXITAET: float = 33.34
|
||||||
|
FACTOR_DAUER: float = 33.33
|
||||||
|
FACTOR_INTERAKTION: float = 33.33
|
||||||
|
|
||||||
|
|
||||||
|
def score_berechnen(
|
||||||
|
spiel: SPIEL, praeferenzen: dict[str, float]
|
||||||
|
) -> float:
|
||||||
|
"""Berechnet den Match-Score für ein Spiel.
|
||||||
|
|
||||||
|
Bewertet das Spiel nach drei Dimensionen:
|
||||||
|
- Komplexität: je näher an Wunsch, desto besser
|
||||||
|
- Dauer: je kuerzer unter Wunsch, desto besser
|
||||||
|
- Interaktion (via max_spieler): je naeher an Wunsch, desto besser
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spiel: Das zu bewertende Spiel-Dict.
|
||||||
|
praeferenzen: Nutzerpraefereenzen mit Schlüsseln
|
||||||
|
'komplexitaet', 'dauer', 'interaktion'.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Score von 0 bis 100, 100 = perfekte Uebereinstimmung.
|
||||||
|
"""
|
||||||
|
punkte: float = 0.0
|
||||||
|
max_punkte: float = 0.0
|
||||||
|
|
||||||
|
if "komplexitaet" in praeferenzen:
|
||||||
|
wunsch = praeferenzen["komplexitaet"]
|
||||||
|
max_punkte += FACTOR_KOMPLEXITAET
|
||||||
|
diff = abs(spiel["komplexitaet"] - wunsch)
|
||||||
|
punkte += FACTOR_KOMPLEXITAET * max(0.0, 1.0 - diff * 0.5)
|
||||||
|
|
||||||
|
if "dauer" in praeferenzen:
|
||||||
|
wunsch = praeferenzen["dauer"]
|
||||||
|
max_punkte += FACTOR_DAUER
|
||||||
|
actual = spiel["dauer_min"]
|
||||||
|
if actual <= wunsch:
|
||||||
|
punkte += FACTOR_DAUER * (1.0 - (wunsch - actual) / max(wunsch, 1.0))
|
||||||
|
else:
|
||||||
|
punkte += FACTOR_DAUER * 0.15
|
||||||
|
|
||||||
|
if "interaktion" in praeferenzen:
|
||||||
|
wunsch = praeferenzen["interaktion"]
|
||||||
|
max_punkte += FACTOR_INTERAKTION
|
||||||
|
diff = abs(spiel["max_spieler"] - wunsch)
|
||||||
|
punkte += FACTOR_INTERAKTION * max(0.0, 1.0 - diff / 6.0)
|
||||||
|
|
||||||
|
if max_punkte == 0.0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return round(punkte / max_punkte * 100.0, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def empfehlungen_erstellen(
|
||||||
|
spiele: list[SPIEL],
|
||||||
|
praeferenzen: dict[str, float],
|
||||||
|
limit: int = 5,
|
||||||
|
) -> list[tuple[str, float]]:
|
||||||
|
"""Erstellt Empfehlungen basierend auf Spielerpraefereenzen.
|
||||||
|
|
||||||
|
Berechnet fuer jedes Spiel einen Score und gibt die Top-N
|
||||||
|
Spiele zurueck, sortiert nach Score absteigend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spiele: Liste aller verfügbaren Spiele.
|
||||||
|
praeferenzen: Nutzerpraefereenzen fuer das Scoring.
|
||||||
|
limit: Maximale Anzahl zurückgegebener Empfehlungen.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste von (Name, Score)-Tuples, sortiert nach Score absteigend.
|
||||||
|
"""
|
||||||
|
scored: list[tuple[str, float]] = []
|
||||||
|
|
||||||
|
for spiel in spiele:
|
||||||
|
name: str = spiel.get("name", "Unbekannt")
|
||||||
|
punktzahl: float = score_berechnen(spiel, praeferenzen)
|
||||||
|
scored.append((name, punktzahl))
|
||||||
|
|
||||||
|
scored.sort(key=lambda entry: entry[1], reverse=True)
|
||||||
|
return scored[:limit]
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Spiele-Datenbank fuer die Empfehlungs-Engine."""
|
||||||
|
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class SPIEL(TypedDict):
|
||||||
|
"""Struktur einer Spiel-Zeile."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
min_spieler: int
|
||||||
|
max_spieler: int
|
||||||
|
dauer_min: int
|
||||||
|
komplexitaet: float
|
||||||
|
kategorien: list[str]
|
||||||
|
|
||||||
|
MIN_SPIELER_DEFAULT = 1
|
||||||
|
MAX_SPIELER_DEFAULT = 10
|
||||||
|
|
||||||
|
|
||||||
|
def spiele_laden() -> list[SPIEL]:
|
||||||
|
"""Lädt eine vordefinierte Liste von Brettspielen.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste von Spiel-Dictionaries mit mindestens 8 Spielen.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "Catan",
|
||||||
|
"min_spieler": 3,
|
||||||
|
"max_spieler": 4,
|
||||||
|
"dauer_min": 60,
|
||||||
|
"komplexitaet": 2.5,
|
||||||
|
"kategorien": ["Strategie", "Aufbau", "Handel"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Carcassonne",
|
||||||
|
"min_spieler": 2,
|
||||||
|
"max_spieler": 5,
|
||||||
|
"dauer_min": 45,
|
||||||
|
"komplexitaet": 1.5,
|
||||||
|
"kategorien": ["Familienspiel", "Bauen", "Karte"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pandemic",
|
||||||
|
"min_spieler": 2,
|
||||||
|
"max_spieler": 4,
|
||||||
|
"dauer_min": 45,
|
||||||
|
"komplexitaet": 2.4,
|
||||||
|
"kategorien": ["Strategie", "Kooperativ", "Abenteuer"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Codenames",
|
||||||
|
"min_spieler": 4,
|
||||||
|
"max_spieler": 8,
|
||||||
|
"dauer_min": 15,
|
||||||
|
"komplexitaet": 1.3,
|
||||||
|
"kategorien": ["Partyspiel", "Worte", "Team"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wunderland",
|
||||||
|
"min_spieler": 2,
|
||||||
|
"max_spieler": 4,
|
||||||
|
"dauer_min": 60,
|
||||||
|
"komplexitaet": 2.5,
|
||||||
|
"kategorien": ["Strategie", "Worte", "Kreativ"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ticket to Ride",
|
||||||
|
"min_spieler": 2,
|
||||||
|
"max_spieler": 5,
|
||||||
|
"dauer_min": 60,
|
||||||
|
"komplexitaet": 1.8,
|
||||||
|
"kategorien": ["Familienspiel", "Reisen", "Bauen"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dixit",
|
||||||
|
"min_spieler": 3,
|
||||||
|
"max_spieler": 6,
|
||||||
|
"dauer_min": 30,
|
||||||
|
"komplexitaet": 1.2,
|
||||||
|
"kategorien": ["Partyspiel", "Träume", "Kreativ"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wingspan",
|
||||||
|
"min_spieler": 1,
|
||||||
|
"max_spieler": 5,
|
||||||
|
"dauer_min": 70,
|
||||||
|
"komplexitaet": 3.1,
|
||||||
|
"kategorien": ["Strategie", "Aufbau", "Natur"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def spiele_filtern(
|
||||||
|
spiele: list[SPIEL],
|
||||||
|
spieler_count: int | None = None,
|
||||||
|
kategorien: list[str] | None = None,
|
||||||
|
max_dauer: int | None = None,
|
||||||
|
) -> list[SPIEL]:
|
||||||
|
"""Filtert Spiele nach Spieleranzahl, Kategorien und Dauer.
|
||||||
|
|
||||||
|
Jeder Filter ist optional (None = kein Filter). Nur Spiele,
|
||||||
|
die alle aktiven Filter erfüllen, werden zurückgegeben.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spiele: Liste aller Spiele, die gefiltert werden sollen.
|
||||||
|
spieler_count: Gesuchte Spieleranzahl.
|
||||||
|
kategorien: Liste der Kategorien, die vorhanden sein müssen.
|
||||||
|
max_dauer: Maximale Spieldauer in Minuten.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Gefilterte Liste der Spiele.
|
||||||
|
"""
|
||||||
|
ergebnis: list[SPIEL] = []
|
||||||
|
|
||||||
|
for spiel in spiele:
|
||||||
|
if spieler_count is not None:
|
||||||
|
if not (spiel["min_spieler"] <= spieler_count <= spiel["max_spieler"]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if kategorien is not None:
|
||||||
|
spiel_kat = spiel.get("kategorien", [])
|
||||||
|
if not any(kat in spiel_kat for kat in kategorien):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if max_dauer is not None:
|
||||||
|
if spiel.get("dauer_min", 999) > max_dauer:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ergebnis.append(spiel)
|
||||||
|
|
||||||
|
return ergebnis
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Tests fuer die Spiele-Empfehlungs-Engine."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from game_database import SPIEL, spiele_laden, spiele_filtern
|
||||||
|
from empfehlung_service import score_berechnen, empfehlungen_erstellen
|
||||||
|
|
||||||
|
EXAKT_SPIEL: SPIEL = {
|
||||||
|
"name": "Testspiel",
|
||||||
|
"min_spieler": 2,
|
||||||
|
"max_spieler": 4,
|
||||||
|
"dauer_min": 45,
|
||||||
|
"komplexitaet": 2.5,
|
||||||
|
"kategorien": ["Strategie"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpielFilter:
|
||||||
|
"""Tests fuer spiele_filtern."""
|
||||||
|
|
||||||
|
def test_filter_spieler_count(self) -> None:
|
||||||
|
"""Spieler count=4: alle Spiele mit passendem Bereich."""
|
||||||
|
alle = spiele_laden()
|
||||||
|
ergebnis = spiele_filtern(alle, spieler_count=4)
|
||||||
|
|
||||||
|
assert len(ergebnis) > 0
|
||||||
|
for spiel in ergebnis:
|
||||||
|
assert spiel["min_spieler"] <= 4 <= spiel["max_spieler"]
|
||||||
|
|
||||||
|
def test_filter_kategorien(self) -> None:
|
||||||
|
"""Kategorie-Filter: nur Spiele mit Strategie-Kategorie."""
|
||||||
|
alle = spiele_laden()
|
||||||
|
ergebnis = spiele_filtern(alle, kategorien=["Strategie"])
|
||||||
|
|
||||||
|
assert len(ergebnis) > 0
|
||||||
|
for spiel in ergebnis:
|
||||||
|
assert "Strategie" in spiel.get("kategorien", [])
|
||||||
|
|
||||||
|
def test_filter_max_dauer(self) -> None:
|
||||||
|
"""Maximaldauer-Filter: keine Spiele laenger als 60 Minuten."""
|
||||||
|
alle = spiele_laden()
|
||||||
|
ergebnis = spiele_filtern(alle, max_dauer=60)
|
||||||
|
|
||||||
|
for spiel in ergebnis:
|
||||||
|
assert spiel.get("dauer_min", 999) <= 60
|
||||||
|
|
||||||
|
def test_filter_kombiniert(self) -> None:
|
||||||
|
"""Kombinierter Filter: Spieler, Kategorie, Dauer."""
|
||||||
|
alle = spiele_laden()
|
||||||
|
ergebnis = spiele_filtern(
|
||||||
|
alle,
|
||||||
|
spieler_count=4,
|
||||||
|
kategorien=["Strategie"],
|
||||||
|
max_dauer=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(ergebnis) > 0
|
||||||
|
for spiel in ergebnis:
|
||||||
|
assert spiel["min_spieler"] <= 4 <= spiel["max_spieler"]
|
||||||
|
assert "Strategie" in spiel.get("kategorien", [])
|
||||||
|
assert spiel.get("dauer_min", 999) <= 60
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoreBerechnen:
|
||||||
|
"""Tests fuer score_berechnen."""
|
||||||
|
|
||||||
|
def test_score_exakte_uebereinstimmung(self) -> None:
|
||||||
|
"""Exakte Uebereinstimmung: Testspiel scoret perfekt (100 Punkte)."""
|
||||||
|
alle = spiele_laden()
|
||||||
|
wuensche = {
|
||||||
|
"komplexitaet": 2.5,
|
||||||
|
"dauer": 45,
|
||||||
|
"interaktion": 4.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
exakt_score = score_berechnen(EXAKT_SPIEL, wuensche)
|
||||||
|
|
||||||
|
assert exakt_score == pytest.approx(100.0, abs=0.1)
|
||||||
|
|
||||||
|
for spiel in alle:
|
||||||
|
punktzahl = score_berechnen(spiel, wuensche)
|
||||||
|
assert punktzahl < exakt_score
|
||||||
|
|
||||||
|
def test_score_ferne_werte(self) -> None:
|
||||||
|
"""Fernte Werte: deutlich geringerer Score als passende Werte."""
|
||||||
|
ferne = {"komplexitaet": 5.0, "dauer": 5, "interaktion": 10}
|
||||||
|
exakte_prefs = {"komplexitaet": 2.5, "dauer": 45, "interaktion": 4}
|
||||||
|
|
||||||
|
ferne_score = score_berechnen(EXAKT_SPIEL, ferne)
|
||||||
|
exakte_score = score_berechnen(EXAKT_SPIEL, exakte_prefs)
|
||||||
|
|
||||||
|
assert ferne_score < 20
|
||||||
|
assert ferne_score < exakte_score
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmpfehlungenErstellen:
|
||||||
|
"""Tests fuer empfehlungen_erstellen."""
|
||||||
|
|
||||||
|
def test_empfehlungen_sind_sortiert(self) -> None:
|
||||||
|
"""Empfehlungen: Score-Werte sind absteigend sortiert."""
|
||||||
|
alle = spiele_laden()
|
||||||
|
wuensche = {"komplexitaet": 2.5, "dauer": 45, "interaktion": 4}
|
||||||
|
ergebnis = empfehlungen_erstellen(alle, wuensche)
|
||||||
|
|
||||||
|
for i in range(len(ergebnis) - 1):
|
||||||
|
assert ergebnis[i][1] >= ergebnis[i + 1][1]
|
||||||
|
|
||||||
|
def test_empfehlungen_respektiert_limit(self) -> None:
|
||||||
|
"""Empfehlungen: Anzahl entspricht dem Limit."""
|
||||||
|
alle = spiele_laden()
|
||||||
|
wuensche = {"komplexitaet": 2.5, "dauer": 45, "interaktion": 4}
|
||||||
|
|
||||||
|
ergebnis_3 = empfehlungen_erstellen(alle, wuensche, limit=3)
|
||||||
|
ergebnis_5 = empfehlungen_erstellen(alle, wuensche, limit=5)
|
||||||
|
|
||||||
|
assert len(ergebnis_3) == 3
|
||||||
|
assert len(ergebnis_5) == 5
|
||||||
Reference in New Issue
Block a user