fix: Bank-Suche verlor die besten Treffer beim Kuerzen
Der Fehler, den der Nutzer gefunden hat: Die Suche nach "n" lieferte "genau 200" Treffer und darunter nie N26. Ursache war die Reihenfolge – alphabetisch sortiert und ERST DANN bei 200 gekuerzt. "N26" liegt hinter "Aachener Bank", fiel also hinter zweihundert Banken heraus, die irgendwo ein n im Namen tragen. Die Bank war die ganze Zeit da. Jetzt wird zuerst bewertet (genauer Name > Namensanfang > Wortanfang > Teiltreffer > nur BIC/Gruppe) und erst danach gekuerzt. Ausserdem wird die ECHTE Trefferzahl gemeldet statt der gekuerzten – "genau 200" sah aus wie ein Suchergebnis, war aber die Obergrenze. Ein Regressionstest stellt den Fall nach: 300 Banken mit "n" im Namen, alphabetisch vor N26. Ohne den Fix faellt er um. Nebenbei geprueft und ausgeschlossen: /aspsps blaettert nicht, die Liste ist also vollstaendig; Sandbox und alphabetische Kuerzung waren es auch nicht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,26 @@ def _client(db: Session, user: User) -> BankClient:
|
||||
MAX_TREFFER = 200
|
||||
|
||||
|
||||
def _rang(name: str, begriff: str, in_name: bool) -> int:
|
||||
"""Wie gut passt ein Name zum Suchbegriff? Kleiner ist besser.
|
||||
|
||||
Ohne Rangfolge entscheidet das Alphabet, welche Treffer die Obergrenze
|
||||
überleben – und dann fällt „N26" bei der Suche nach „n" hinter 200 Banken
|
||||
heraus, deren Name irgendwo ein n enthält. Genau dieser Fehler hat hier
|
||||
einen Abend gekostet.
|
||||
"""
|
||||
klein = name.lower()
|
||||
if klein == begriff:
|
||||
return 0
|
||||
if klein.startswith(begriff):
|
||||
return 1
|
||||
if any(wort.startswith(begriff) for wort in klein.replace("-", " ").split()):
|
||||
return 2
|
||||
if begriff in klein:
|
||||
return 3
|
||||
return 4 if not in_name else 3
|
||||
|
||||
|
||||
def banken(db: Session, user: User, land: str | None = None,
|
||||
suche: str | None = None) -> dict:
|
||||
"""Banken suchen – in einem Land oder über alle hinweg.
|
||||
@@ -75,43 +95,42 @@ def banken(db: Session, user: User, land: str | None = None,
|
||||
"""
|
||||
roh = _client(db, user).banken(land)
|
||||
begriff = (suche or "").strip().lower()
|
||||
treffer = []
|
||||
|
||||
passend = []
|
||||
for b in roh:
|
||||
name = b.get("name")
|
||||
if not name:
|
||||
continue
|
||||
# Auch im BIC und im Gruppennamen suchen: Banken heissen in der Liste
|
||||
name = str(name)
|
||||
# Auch BIC und Gruppenname durchsuchen: Banken heissen in der Liste
|
||||
# nicht immer so wie im Alltag.
|
||||
heu = " ".join(str(x) for x in (name, b.get("bic"), b.get("group")) if x).lower()
|
||||
if begriff and begriff not in heu:
|
||||
neben = " ".join(str(x) for x in (b.get("bic"), b.get("group")) if x).lower()
|
||||
if begriff and begriff not in name.lower() and begriff not in neben:
|
||||
continue
|
||||
treffer.append({"name": name, "country": b.get("country"),
|
||||
"logo": b.get("logo"), "psu_types": b.get("psu_types", []),
|
||||
"beta": bool(b.get("beta"))})
|
||||
treffer.sort(key=lambda b: (b["name"].lower(), b.get("country") or ""))
|
||||
# „gesamt" und eine Kostprobe mitliefern: Sonst sieht „keine Treffer"
|
||||
# gleich aus, egal ob die Liste leer kam, der Suchbegriff nicht passte –
|
||||
# oder die Anwendung als Sandbox angelegt wurde und lauter Test-Institute
|
||||
# liefert. An der Kostprobe sieht man Letzteres sofort.
|
||||
# Beispiele ueber den ganzen Bereich streuen, nicht nur den Anfang zeigen.
|
||||
# Endet die Liste alphabetisch bei „B", ist sie abgeschnitten – und man
|
||||
# sucht sonst ewig nach einer Bank, die schlicht nicht mitgeliefert wurde.
|
||||
passend.append({
|
||||
"name": name, "country": b.get("country"), "logo": b.get("logo"),
|
||||
"psu_types": b.get("psu_types", []), "beta": bool(b.get("beta")),
|
||||
"_rang": _rang(name, begriff, True) if begriff else 3,
|
||||
})
|
||||
|
||||
# Erst nach Güte, dann alphabetisch – und ERST DANN kürzen.
|
||||
passend.sort(key=lambda b: (b["_rang"], b["name"].lower(), b.get("country") or ""))
|
||||
gezeigt = [{k: v for k, v in b.items() if k != "_rang"} for b in passend[:MAX_TREFFER]]
|
||||
|
||||
namen = sorted({str(b.get("name")) for b in roh if b.get("name")})
|
||||
beispiele = (namen[:3] + namen[len(namen) // 2: len(namen) // 2 + 2] + namen[-3:]
|
||||
if len(namen) > 8 else namen)
|
||||
return {
|
||||
"gesamt": len(roh),
|
||||
"treffer": treffer[:MAX_TREFFER],
|
||||
"gekuerzt": len(treffer) > MAX_TREFFER,
|
||||
# Die ECHTE Trefferzahl, nicht die gekürzte. Sonst steht dort immer
|
||||
# „200" und man haelt die Obergrenze für ein Suchergebnis.
|
||||
"treffer_gesamt": len(passend),
|
||||
"treffer": gezeigt,
|
||||
"gekuerzt": len(passend) > MAX_TREFFER,
|
||||
"beispiele": beispiele,
|
||||
"von": namen[0] if namen else None,
|
||||
"bis": namen[-1] if namen else None,
|
||||
# Fängt überhaupt eine Bank mit diesen Buchstaben an? Wenn nicht, fehlt
|
||||
# der Teil des Alphabets, und der Suchbegriff ist unschuldig.
|
||||
"anfangsbuchstaben": sorted({n[:1].upper() for n in namen}),
|
||||
# Alles mit demselben Anfangsbuchstaben wie der Suchbegriff. Damit
|
||||
# sieht man, unter welchem Namen die gesuchte Bank dort wirklich steht
|
||||
# – Raten über Namensvarianten hat lange genug gedauert.
|
||||
"gleicher_buchstabe": (
|
||||
[n for n in namen if begriff and n[:1].lower() == begriff[:1]][:40]
|
||||
if begriff else []
|
||||
|
||||
@@ -432,3 +432,51 @@ def test_leere_bankliste_ist_von_keinem_treffer_unterscheidbar(admin, fake_bank)
|
||||
assert daten["treffer"] == []
|
||||
finally:
|
||||
bank_api_modul.BankClient._anfrage = urspruenglich
|
||||
|
||||
|
||||
def test_treffer_werden_nach_guete_gekuerzt_nicht_alphabetisch(admin, monkeypatch):
|
||||
"""Der Fehler, der einen Abend gekostet hat.
|
||||
|
||||
Die Trefferliste wird bei 200 gekürzt. Wurde vorher alphabetisch sortiert,
|
||||
fiel „N26" bei der Suche nach „n" hinter zweihundert Banken heraus, die
|
||||
irgendwo ein n im Namen tragen – die Bank war da, tauchte aber nie auf.
|
||||
Gekürzt wird deshalb erst NACH der Bewertung.
|
||||
"""
|
||||
_app(admin)
|
||||
|
||||
# Dreihundert Banken, die alle ein „n" enthalten und alphabetisch vor
|
||||
# „N26" liegen – plus die gesuchte Bank.
|
||||
viele = [{"name": f"Aachener Bank {i:03d}", "country": "DE"} for i in range(300)]
|
||||
viele.append({"name": "N26", "country": "DE"})
|
||||
viele.append({"name": "Revolut", "country": "LT"})
|
||||
|
||||
def _anfrage(self, methode, pfad, **rest):
|
||||
if pfad.startswith("/aspsps"):
|
||||
return {"aspsps": viele}
|
||||
raise AssertionError(f"unerwartet: {pfad}")
|
||||
|
||||
monkeypatch.setattr(bank_api.BankClient, "_anfrage", _anfrage)
|
||||
|
||||
daten = admin.get("/api/bank/banks?search=n").json()
|
||||
namen = [b["name"] for b in daten["treffer"]]
|
||||
assert "N26" in namen, "N26 muss trotz Kürzung sichtbar sein"
|
||||
assert namen[0] == "N26", "Der beste Treffer gehört nach oben"
|
||||
# Die echte Trefferzahl, nicht die gekürzte – sonst steht dort immer 200.
|
||||
# 300 Aachener + N26; "Revolut" enthält kein n und zählt nicht mit.
|
||||
assert daten["treffer_gesamt"] == 301
|
||||
assert len(daten["treffer"]) == 200
|
||||
assert daten["gekuerzt"] is True
|
||||
|
||||
|
||||
def test_genauer_name_schlaegt_teiltreffer(admin, monkeypatch):
|
||||
_app(admin)
|
||||
liste = [{"name": "Revolut Bank UAB", "country": "LT"},
|
||||
{"name": "Revolut", "country": "LT"},
|
||||
{"name": "Banca Revoluta", "country": "IT"}]
|
||||
|
||||
def _anfrage(self, methode, pfad, **rest):
|
||||
return {"aspsps": liste}
|
||||
|
||||
monkeypatch.setattr(bank_api.BankClient, "_anfrage", _anfrage)
|
||||
namen = [b["name"] for b in admin.get("/api/bank/banks?search=revolut").json()["treffer"]]
|
||||
assert namen == ["Revolut", "Revolut Bank UAB", "Banca Revoluta"]
|
||||
|
||||
Reference in New Issue
Block a user