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
|
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 []
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user