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..117c0a4ac 100644 --- a/src/import_common/normalization.py +++ b/src/import_common/normalization.py @@ -444,3 +444,79 @@ def normalize_nazwisko_do_porownania(s: str) -> str: s = s.lower() s = s.replace("-", " ") 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. + + Dwa źródła wariantów: + + 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 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() + 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)) + + # 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 new file mode 100644 index 000000000..ae1c604b2 --- /dev/null +++ b/src/import_common/tests/test_autor_pl_en_matching.py @@ -0,0 +1,133 @@ +"""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 + + +# --------------------------------------------------------------------------- +# 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