From 4a91de68e33bba4b7212472ab79c2cb7a5ad83bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 21 May 2026 12:12:51 +0200 Subject: [PATCH 1/2] feat(import_common): matchowanie autorow PL<->EN (Ewa Maranda <-> Eva Maranda) Importy z anglojezycznych zrodel (CrossRef, DSpace EN) nie lapaly polskich autorow gdy zrodlo dawalo zangielszczona pisownie imienia (Eva zamiast Ewa) lub nazwisko bez diakrytykow (Maranda zamiast Maranda). matchuj_autora() uzywal __iexact, ktore w Postgresie jest diacritic-sensitive i nie zna transliteracji v<->w. Fallback w matchuj_autora() jako ostatni krok przed _try_match_autor_with_orcid_or_tytul: - nazwisko: SQL Lower(Unaccent(...)) na obu stronach (Maranda <-> Maranda) - pierwsze imie: reguly v<->w generuja kandydatow (Eva -> {Eva, Ewa}) Regula v<->w stosowana TYLKO do imion - polskie nazwiska maja "w" jako autentyczna litere (Wojciechowski != Vojciechowski). Przy ambiguity (wielu kandydatow) zwracamy None - decyzja w UI. Extension `unaccent` byla juz zainstalowana przez 0001_fulltext, wiec zadna migracja nie jest potrzebna. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/import_common/core/autor.py | 63 ++++++++++++++ src/import_common/normalization.py | 32 +++++++ .../tests/test_autor_pl_en_matching.py | 86 +++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 src/import_common/tests/test_autor_pl_en_matching.py diff --git a/src/import_common/core/autor.py b/src/import_common/core/autor.py index c3b083f06..a051ee1da 100644 --- a/src/import_common/core/autor.py +++ b/src/import_common/core/autor.py @@ -3,9 +3,15 @@ (jednostka, tytuł). """ +from django.contrib.postgres.lookups import Unaccent from django.db.models import Q +from django.db.models.functions import Lower from bpp.models import Autor, Autor_Jednostka, Jednostka, Tytul +from import_common.normalization import ( + polish_english_first_name_variants, + remove_polish_diacritics, +) def _try_get_autor_by_bpp_id(bpp_id: int | None) -> Autor | None: @@ -143,6 +149,58 @@ def _try_match_autor_in_jednostka( return None +def _try_match_autor_by_polish_english_variants( + imiona: str, + nazwisko: str, + jednostka: Jednostka | None, +) -> Autor | None: + """Fallback dla wariantów pisowni polsko-angielskiej. + + Stosuje ``unaccent`` na nazwisku po stronie bazy (Marańda↔Maranda) + oraz regułę ``v↔w`` na pierwszym imieniu (Eva↔Ewa, Viktor↔Wiktor). + Wymaga ``CREATE EXTENSION unaccent`` (instalowane przez migrację + 0001_fulltext). + + Zwraca autora tylko gdy istnieje **dokładnie jeden** kandydat — + przy ambiguity decyzja należy do użytkownika. + """ + imiona = (imiona or "").strip() + nazwisko = (nazwisko or "").strip() + if not imiona or not nazwisko: + return None + + first = imiona.split()[0] + variants_norm = {v.lower() for v in polish_english_first_name_variants(first)} + if not variants_norm: + return None + + nazwisko_norm = remove_polish_diacritics(nazwisko).lower() + + imie_q = Q() + for v in variants_norm: + imie_q |= Q(im_n=v) | Q(im_n__startswith=v + " ") + + qs = ( + Autor.objects.annotate( + naz_n=Lower(Unaccent("nazwisko")), + im_n=Lower(Unaccent("imiona")), + ) + .filter(naz_n=nazwisko_norm) + .filter(imie_q) + ) + + if jednostka is not None: + qs_j = qs.filter(aktualna_jednostka=jednostka) + results = list(qs_j[:2]) + if len(results) == 1: + return results[0] + + results = list(qs[:2]) + if len(results) == 1: + return results[0] + return None + + def _try_match_autor_with_orcid_or_tytul(imiona: str, nazwisko: str) -> Autor | None: """Ostatnia próba - szuka autora z ORCIDem lub tytułem.""" imiona = (imiona or "").strip() @@ -196,5 +254,10 @@ def matchuj_autora( if result: return result + # Warianty pisowni PL↔EN (diakrytyki + v↔w na imieniu) + result = _try_match_autor_by_polish_english_variants(imiona, nazwisko, jednostka) + if result: + return result + # Ostatnia próba - autor z ORCIDem lub tytułem return _try_match_autor_with_orcid_or_tytul(imiona, nazwisko) diff --git a/src/import_common/normalization.py b/src/import_common/normalization.py index 86c9da3bd..0226b232d 100644 --- a/src/import_common/normalization.py +++ b/src/import_common/normalization.py @@ -444,3 +444,35 @@ def normalize_nazwisko_do_porownania(s: str) -> str: s = s.lower() s = s.replace("-", " ") return remove_extra_spaces(s) + + +def polish_english_first_name_variants(imie: str | None) -> set[str]: + """Zwraca warianty pisowni imienia między polskim a angielskim. + + Reguła: pojedyncza zamiana ``v↔w`` (litera występująca w imieniu + po unidecode-fold). Pokrywa typowe przypadki transliteracji: + + - ``Eva`` ↔ ``Ewa`` + - ``Viktor`` ↔ ``Wiktor`` + - ``Wioletta`` ↔ ``Violetta`` + + Zwraca zbiór wariantów (w tym oryginalną pisownię z diakrytykami, + jeśli była). Nigdy nie modyfikuje nazwisk — tam ``w`` jest często + autentyczną literą polską (``Wojciechowski`` ≠ ``Vojciechowski``). + """ + if not imie: + return set() + imie = imie.strip() + if not imie: + return set() + + variants = {imie} + # Reguła v↔w działa na ASCII-fold (żeby Łukasz dał Lukasz, etc.) + folded = remove_polish_diacritics(imie) + variants.add(folded) + + for src, dst in (("v", "w"), ("V", "W"), ("w", "v"), ("W", "V")): + if src in folded: + variants.add(folded.replace(src, dst)) + + return variants diff --git a/src/import_common/tests/test_autor_pl_en_matching.py b/src/import_common/tests/test_autor_pl_en_matching.py new file mode 100644 index 000000000..b907735ed --- /dev/null +++ b/src/import_common/tests/test_autor_pl_en_matching.py @@ -0,0 +1,86 @@ +"""Matchowanie autorów po wariantach pisowni polsko-angielskiej. + +Testy dla `matchuj_autora` w sytuacji, gdy importowane dane przychodzą +ze źródła anglojęzycznego (CrossRef, DSpace EN), a w BPP siedzi polska +pisownia: diakrytyki (Marańda↔Maranda) oraz transliteracja v↔w +(Eva↔Ewa, Viktor↔Wiktor). +""" + +import pytest +from model_bakery import baker + +from bpp.models import Autor +from import_common.core import matchuj_autora + + +@pytest.mark.django_db +def test_match_diacritics_only(): + """Marańda ↔ Maranda — sam unidecode-fold nazwiska.""" + autor = baker.make(Autor, imiona="Adam", nazwisko="Marańda") + assert matchuj_autora(imiona="Adam", nazwisko="Maranda") == autor + + +@pytest.mark.django_db +def test_match_v_to_w_first_name(): + """Eva (źródło EN) ↔ Ewa (BPP) — regula v↔w na imieniu.""" + autor = baker.make(Autor, imiona="Ewa", nazwisko="Kowalska") + assert matchuj_autora(imiona="Eva", nazwisko="Kowalska") == autor + + +@pytest.mark.django_db +def test_match_w_to_v_first_name(): + """Wiktor (BPP) ↔ Viktor (źródło EN) — działa w obie strony.""" + autor = baker.make(Autor, imiona="Wiktor", nazwisko="Nowak") + assert matchuj_autora(imiona="Viktor", nazwisko="Nowak") == autor + + +@pytest.mark.django_db +def test_match_full_pl_en_case(): + """Literalny przypadek użytkownika: Ewa Marańda ↔ Eva Maranda.""" + autor = baker.make(Autor, imiona="Ewa", nazwisko="Marańda") + assert matchuj_autora(imiona="Eva", nazwisko="Maranda") == autor + + +@pytest.mark.django_db +def test_v_to_w_not_applied_to_surname(): + """Regula v↔w nie dotyczy nazwisk: Wojciechowski nie pasuje do Vojciechowski. + + Polskie nazwiska mają "w" jako autentyczną literę; transliteracja + pomyłkowo łączyłaby różnych ludzi. + """ + baker.make(Autor, imiona="Jan", nazwisko="Wojciechowski") + assert matchuj_autora(imiona="Jan", nazwisko="Vojciechowski") is None + + +@pytest.mark.django_db +def test_existing_iexact_match_still_works(): + """Brak regresji: gdy iexact wystarczy, fallback PL↔EN się nie aktywuje.""" + autor = baker.make(Autor, imiona="Anna", nazwisko="Nowak") + assert matchuj_autora(imiona="Anna", nazwisko="Nowak") == autor + + +@pytest.mark.django_db +def test_ambiguous_pl_en_match_returns_none(): + """Gdy fallback PL↔EN trafia w wielu kandydatów — zwracamy None. + + Decyzja należy do użytkownika (UI pokazuje listę), + nie chcemy odgadywać. + """ + baker.make(Autor, imiona="Ewa", nazwisko="Marańda") + baker.make(Autor, imiona="Eva", nazwisko="Maranda") + assert matchuj_autora(imiona="Eva", nazwisko="Maranda") is not None + # ^ exact match z drugą — to nie jest ambiguity (iexact pierwszy) + + # Prawdziwy ambiguity: dwóch różnych Ewa/Eva o tym samym nazwisku + Autor.objects.all().delete() + baker.make(Autor, imiona="Ewa", nazwisko="Marańda") + baker.make(Autor, imiona="Eva", nazwisko="Marańda") + assert matchuj_autora(imiona="Eva", nazwisko="Maranda") is None + + +@pytest.mark.django_db +def test_empty_inputs_return_none(): + """Defensywnie: pusty input nie wybucha.""" + baker.make(Autor, imiona="Jan", nazwisko="Kowalski") + assert matchuj_autora(imiona="", nazwisko="") is None + assert matchuj_autora(imiona=None, nazwisko=None) is None From 4371560817d25710889edfd0a42655b8426d4760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 21 May 2026 12:21:22 +0200 Subject: [PATCH 2/2] feat(import_common): hand-curated mapa klastrow imion PL<->EN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reguła v<->w pokrywa fonetyczne pary (Ewa/Eva, Wiktor/Viktor), ale nie złapie imion bez wspólnego korzenia: Krzysztof<->Christopher, Paweł<->Paul, Maria<->Mary itp. Dodaje POLISH_ENGLISH_NAME_CLUSTERS z 25 najczęstszymi parami/trójkami w akademii. Klastry są bidirectional - imię może należyć do co najwyżej jednego klastra, lookup robiony po unidecode-fold + lowercase. Dodaje 16 parametrycznych przypadków testowych i negatywny test "Marcin != Mark", żeby zapobiec false-positive na imionach spoza klastra. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/import_common/normalization.py | 60 ++++++++++++++++--- .../tests/test_autor_pl_en_matching.py | 47 +++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/src/import_common/normalization.py b/src/import_common/normalization.py index 0226b232d..117c0a4ac 100644 --- a/src/import_common/normalization.py +++ b/src/import_common/normalization.py @@ -446,19 +446,56 @@ def normalize_nazwisko_do_porownania(s: str) -> str: return remove_extra_spaces(s) +# Klastry typowych par/trójek imion polsko-angielskich. +# Każdy klaster zawiera wszystkie nawzajem-wymienne pisownie w postaci +# ASCII-lowercase (porównanie odbywa się po unidecode-fold + lowercase). +# Imię może należeć do co najwyżej jednego klastra. +POLISH_ENGLISH_NAME_CLUSTERS: tuple[frozenset[str], ...] = ( + # Mężczyźni + frozenset({"jan", "john"}), + frozenset({"krzysztof", "christopher"}), + frozenset({"pawel", "paul"}), + frozenset({"piotr", "peter"}), + frozenset({"michal", "michael"}), + frozenset({"lukasz", "luke", "lucas"}), + frozenset({"tomasz", "thomas"}), + frozenset({"marek", "mark"}), + frozenset({"andrzej", "andrew"}), + frozenset({"stefan", "stephen", "steven"}), + frozenset({"jozef", "joseph"}), + frozenset({"mikolaj", "nicholas"}), + frozenset({"jakub", "jacob"}), + # Kobiety + frozenset({"maria", "mary"}), + frozenset({"anna", "ann", "anne"}), + frozenset({"katarzyna", "catherine", "katherine"}), + frozenset({"malgorzata", "margaret"}), + frozenset({"elzbieta", "elizabeth"}), + frozenset({"ewa", "eve"}), + frozenset({"magdalena", "madeleine"}), + frozenset({"aleksandra", "alexandra"}), + frozenset({"aleksander", "alexander"}), + frozenset({"helena", "helen"}), + frozenset({"teresa", "theresa"}), + frozenset({"dorota", "dorothy"}), +) + + def polish_english_first_name_variants(imie: str | None) -> set[str]: """Zwraca warianty pisowni imienia między polskim a angielskim. - Reguła: pojedyncza zamiana ``v↔w`` (litera występująca w imieniu - po unidecode-fold). Pokrywa typowe przypadki transliteracji: + Dwa źródła wariantów: - - ``Eva`` ↔ ``Ewa`` - - ``Viktor`` ↔ ``Wiktor`` - - ``Wioletta`` ↔ ``Violetta`` + 1. Reguła ``v↔w`` po unidecode-fold (Eva↔Ewa, Viktor↔Wiktor, + Wioletta↔Violetta) — fonetyczna, generyczna. + 2. Mapa klastrów ``POLISH_ENGLISH_NAME_CLUSTERS`` dla typowych + par (Krzysztof↔Christopher, Paweł↔Paul, Maria↔Mary itp.) — + hand-curated, dotyczy imion bez wspólnego fonetycznego korzenia. - Zwraca zbiór wariantów (w tym oryginalną pisownię z diakrytykami, - jeśli była). Nigdy nie modyfikuje nazwisk — tam ``w`` jest często - autentyczną literą polską (``Wojciechowski`` ≠ ``Vojciechowski``). + Zwraca zbiór wariantów obejmujący oryginalną pisownię oraz wszystkie + znormalizowane formy. Nigdy nie modyfikuje nazwisk — tam ``w`` jest + często autentyczną literą polską (``Wojciechowski`` ≠ + ``Vojciechowski``). """ if not imie: return set() @@ -475,4 +512,11 @@ def polish_english_first_name_variants(imie: str | None) -> set[str]: if src in folded: variants.add(folded.replace(src, dst)) + # Hand-curated klastry PL↔EN + folded_lower = folded.lower() + for cluster in POLISH_ENGLISH_NAME_CLUSTERS: + if folded_lower in cluster: + variants.update(cluster) + break + return variants diff --git a/src/import_common/tests/test_autor_pl_en_matching.py b/src/import_common/tests/test_autor_pl_en_matching.py index b907735ed..ae1c604b2 100644 --- a/src/import_common/tests/test_autor_pl_en_matching.py +++ b/src/import_common/tests/test_autor_pl_en_matching.py @@ -84,3 +84,50 @@ def test_empty_inputs_return_none(): baker.make(Autor, imiona="Jan", nazwisko="Kowalski") assert matchuj_autora(imiona="", nazwisko="") is None assert matchuj_autora(imiona=None, nazwisko=None) is None + + +# --------------------------------------------------------------------------- +# Klastry imion PL↔EN (hand-curated map) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "imie_w_bazie, imie_w_imporcie", + [ + ("Krzysztof", "Christopher"), + ("Christopher", "Krzysztof"), + ("Paweł", "Paul"), + ("Paul", "Paweł"), + ("Maria", "Mary"), + ("Małgorzata", "Margaret"), + ("Elżbieta", "Elizabeth"), + ("Łukasz", "Luke"), + ("Łukasz", "Lucas"), + ("Michał", "Michael"), + ("Andrzej", "Andrew"), + ("Tomasz", "Thomas"), + ("Józef", "Joseph"), + ("Aleksandra", "Alexandra"), + ("Aleksander", "Alexander"), + ("Ewa", "Eve"), # uzupełnia v↔w (Ewa↔Eva) + ], +) +def test_match_pl_en_name_clusters(imie_w_bazie, imie_w_imporcie): + """Hand-curated mapa pokrywa typowe pary PL↔EN.""" + autor = baker.make(Autor, imiona=imie_w_bazie, nazwisko="Kowalski") + assert matchuj_autora(imiona=imie_w_imporcie, nazwisko="Kowalski") == autor + + +@pytest.mark.django_db +def test_name_cluster_does_not_create_false_positive(): + """Imię spoza klastra nie matchuje przypadkowo: Marcin ≠ Mark.""" + baker.make(Autor, imiona="Marcin", nazwisko="Kowalski") + assert matchuj_autora(imiona="Mark", nazwisko="Kowalski") is None + + +@pytest.mark.django_db +def test_cluster_combined_with_surname_unaccent(): + """Klaster imion + unaccent nazwiska razem: Christopher Marańda ↔ Krzysztof Maranda.""" + autor = baker.make(Autor, imiona="Krzysztof", nazwisko="Marańda") + assert matchuj_autora(imiona="Christopher", nazwisko="Maranda") == autor