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:
Guido
2026-08-26 22:10:41 +02:00
co-authored by Claude Opus 5
parent f53efc4767
commit beb1bb3419
5 changed files with 98 additions and 25 deletions
+41 -22
View File
@@ -65,6 +65,26 @@ def _client(db: Session, user: User) -> BankClient:
MAX_TREFFER = 200 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, def banken(db: Session, user: User, land: str | None = None,
suche: str | None = None) -> dict: suche: str | None = None) -> dict:
"""Banken suchen – in einem Land oder über alle hinweg. """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) roh = _client(db, user).banken(land)
begriff = (suche or "").strip().lower() begriff = (suche or "").strip().lower()
treffer = []
passend = []
for b in roh: for b in roh:
name = b.get("name") name = b.get("name")
if not name: if not name:
continue 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. # nicht immer so wie im Alltag.
heu = " ".join(str(x) for x in (name, b.get("bic"), b.get("group")) if x).lower() neben = " ".join(str(x) for x in (b.get("bic"), b.get("group")) if x).lower()
if begriff and begriff not in heu: if begriff and begriff not in name.lower() and begriff not in neben:
continue continue
treffer.append({"name": name, "country": b.get("country"), passend.append({
"logo": b.get("logo"), "psu_types": b.get("psu_types", []), "name": name, "country": b.get("country"), "logo": b.get("logo"),
"beta": bool(b.get("beta"))}) "psu_types": b.get("psu_types", []), "beta": bool(b.get("beta")),
treffer.sort(key=lambda b: (b["name"].lower(), b.get("country") or "")) "_rang": _rang(name, begriff, True) if begriff else 3,
# „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 # Erst nach Güte, dann alphabetisch – und ERST DANN kürzen.
# liefert. An der Kostprobe sieht man Letzteres sofort. passend.sort(key=lambda b: (b["_rang"], b["name"].lower(), b.get("country") or ""))
# Beispiele ueber den ganzen Bereich streuen, nicht nur den Anfang zeigen. gezeigt = [{k: v for k, v in b.items() if k != "_rang"} for b in passend[:MAX_TREFFER]]
# Endet die Liste alphabetisch bei „B", ist sie abgeschnitten – und man
# sucht sonst ewig nach einer Bank, die schlicht nicht mitgeliefert wurde.
namen = sorted({str(b.get("name")) for b in roh if b.get("name")}) 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:] beispiele = (namen[:3] + namen[len(namen) // 2: len(namen) // 2 + 2] + namen[-3:]
if len(namen) > 8 else namen) if len(namen) > 8 else namen)
return { return {
"gesamt": len(roh), "gesamt": len(roh),
"treffer": treffer[:MAX_TREFFER], # Die ECHTE Trefferzahl, nicht die gekürzte. Sonst steht dort immer
"gekuerzt": len(treffer) > MAX_TREFFER, # „200" und man haelt die Obergrenze für ein Suchergebnis.
"treffer_gesamt": len(passend),
"treffer": gezeigt,
"gekuerzt": len(passend) > MAX_TREFFER,
"beispiele": beispiele, "beispiele": beispiele,
"von": namen[0] if namen else None, "von": namen[0] if namen else None,
"bis": namen[-1] 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}), "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": ( "gleicher_buchstabe": (
[n for n in namen if begriff and n[:1].lower() == begriff[:1]][:40] [n for n in namen if begriff and n[:1].lower() == begriff[:1]][:40]
if begriff else [] if begriff else []
+48
View File
@@ -432,3 +432,51 @@ def test_leere_bankliste_ist_von_keinem_treffer_unterscheidbar(admin, fake_bank)
assert daten["treffer"] == [] assert daten["treffer"] == []
finally: finally:
bank_api_modul.BankClient._anfrage = urspruenglich 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"]
+2 -1
View File
@@ -609,7 +609,8 @@
"range": "Die Liste reicht von „{{von}}“ bis „{{bis}}“.", "range": "Die Liste reicht von „{{von}}“ bis „{{bis}}“.",
"letters": "Vorhandene Anfangsbuchstaben", "letters": "Vorhandene Anfangsbuchstaben",
"truncatedHint": "Fehlen ganze Buchstaben – etwa N oder R –, ist die Liste abgeschnitten und deine Bank wurde gar nicht mitgeliefert. Dann liegt es nicht am Suchbegriff, und ich muss die Abfrage blättern lassen.", "truncatedHint": "Fehlen ganze Buchstaben – etwa N oder R –, ist die Liste abgeschnitten und deine Bank wurde gar nicht mitgeliefert. Dann liegt es nicht am Suchbegriff, und ich muss die Abfrage blättern lassen.",
"sameLetter": "Alles, was in der Liste mit „{{buchstabe}}“ beginnt:" "sameLetter": "Alles, was in der Liste mit „{{buchstabe}}“ beginnt:",
"pickBankMore": "Die besten {{n}} von {{treffer}} Treffern – such genauer, wenn deine Bank nicht dabei ist:"
}, },
"bankReturn": { "bankReturn": {
"title": "Rückkehr von der Bank", "title": "Rückkehr von der Bank",
+2 -1
View File
@@ -609,7 +609,8 @@
"range": "The list runs from “{{von}}” to “{{bis}}”.", "range": "The list runs from “{{von}}” to “{{bis}}”.",
"letters": "Initial letters present", "letters": "Initial letters present",
"truncatedHint": "If whole letters are missing – N or R, say – the list is truncated and your bank was never delivered. Then the search term is innocent and the query needs paging.", "truncatedHint": "If whole letters are missing – N or R, say – the list is truncated and your bank was never delivered. Then the search term is innocent and the query needs paging.",
"sameLetter": "Everything in the list starting with “{{buchstabe}}”:" "sameLetter": "Everything in the list starting with “{{buchstabe}}”:",
"pickBankMore": "The best {{n}} of {{treffer}} matches – search more precisely if yours is not among them:"
}, },
"bankReturn": { "bankReturn": {
"title": "Back from your bank", "title": "Back from your bank",
+5 -1
View File
@@ -306,7 +306,11 @@ export default function Bankanbindung() {
) : ( ) : (
<> <>
<p className="muted small"> <p className="muted small">
{t("bank.pickBank", { n: banken.treffer.length, gesamt: banken.gesamt })} {banken.gekuerzt
? t("bank.pickBankMore", { n: banken.treffer.length,
treffer: banken.treffer_gesamt })
: t("bank.pickBank", { n: banken.treffer_gesamt,
gesamt: banken.gesamt })}
</p> </p>
<div className="account-list"> <div className="account-list">
{banken.treffer.map((b) => ( {banken.treffer.map((b) => (