Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/import_common/core/autor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
76 changes: 76 additions & 0 deletions src/import_common/normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
133 changes: 133 additions & 0 deletions src/import_common/tests/test_autor_pl_en_matching.py
Original file line number Diff line number Diff line change
@@ -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
Loading