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