diff --git a/src/bpp/newsfragments/+autor-kandydaci-ranked.feature.md b/src/bpp/newsfragments/+autor-kandydaci-ranked.feature.md new file mode 100644 index 000000000..b82c4440a --- /dev/null +++ b/src/bpp/newsfragments/+autor-kandydaci-ranked.feature.md @@ -0,0 +1,6 @@ +Wizard importera publikacji (CrossRef): gdy dopasowanie autora trafia +na **wielu** kandydatów (np. dwóch autorów o tym samym nazwisku — z +diakrytykiem i bez), system pre-zaznacza najlepszego (więcej publikacji, +ORCID), zapisuje pełną listę z metadanymi i wyświetla rozwijaną listę +alternatyw z badge'ami `pewność` / `liczba publikacji` / `ORCID`. +Kliknięcie kandydata przepina dopasowanie jednym requestem. diff --git a/src/crossref_bpp/core.py b/src/crossref_bpp/core.py index b4aa427df..e6bc90c49 100644 --- a/src/crossref_bpp/core.py +++ b/src/crossref_bpp/core.py @@ -18,13 +18,13 @@ ) from crossref_bpp.utils import json_format_with_wrap, perform_trigram_search from import_common.core import ( - matchuj_autora, matchuj_wydawce, normalize_zrodlo_nazwa_for_db_lookup, normalize_zrodlo_skrot_for_db_lookup, normalized_db_title, normalized_db_zrodlo_nazwa, normalized_db_zrodlo_skrot, + znajdz_kandydatow_autora, ) from import_common.normalization import ( normalize_doi, @@ -54,10 +54,19 @@ def __init__( status: StatusPorownania, opis: str = "", rekordy: [list[models.Model,] | None] = None, + *, + sugerowany=None, + kandydaci=None, ): self.status = status self.opis = opis self.rekordy = rekordy + # `sugerowany`: best-of-N rekord wskazany jako preselekcja w UI + # (ustawiany przez Komparator przy WYMAGA_INGERENCJI). `kandydaci`: + # opcjonalna lista obiektów z metadanymi (np. KandydatAutora) do + # zapisu w tabeli kandydatów importowanego autora. + self.sugerowany = sugerowany + self.kandydaci = kandydaci @cached_property def rekord_po_stronie_bpp(self): @@ -353,45 +362,40 @@ def porownaj_author(cls, wartosc): nazwisko = normalize_last_name(wartosc.get("family")) imiona = normalize_first_name(wartosc.get("given")) - if nazwisko and imiona: - ret = matchuj_autora(imiona, nazwisko) - if ret: - return WynikPorownania( - StatusPorownania.LUZNE, - "znaleziono dokładnie jednego autora po imieniu i nazwisku", - rekordy=[ - ret, - ], - ) + if not (nazwisko and imiona): + return BRAK_DOPASOWANIA - # Brak jednego autora, ale moze jest wielu? Funkcja matchuj_autora nie zwraca - # takich danych: + kandydaci = znajdz_kandydatow_autora(imiona, nazwisko, max_wyniki=10) + if not kandydaci: + return BRAK_DOPASOWANIA - q = Autor.objects.filter( - nazwisko__icontains=nazwisko, imiona__icontains=imiona + best = kandydaci[0] + if len(kandydaci) == 1: + # Pojedynczy kandydat. DOKLADNE tylko gdy wpadliśmy w iexact full, + # inaczej LUZNE — sygnalizujemy że pewność jest niższa. + status = ( + StatusPorownania.DOKLADNE + if best.pewnosc >= 1.0 + else StatusPorownania.LUZNE + ) + return WynikPorownania( + status, + f"jeden autor ({best.powod}, pewność {best.pewnosc:.2f})", + rekordy=[best.autor], + sugerowany=best.autor, + kandydaci=kandydaci, ) - if q.exists(): - addendum = "" - MAX_AUT = 10 - if q.count() >= MAX_AUT: - addendum = ( - f" - w sumie {q.count()} rekordow, pokazuje pierwsze {MAX_AUT}" - ) - - msg = "kilku autorów - luźne porównanie po imieniu i nazwisku" - status = StatusPorownania.WYMAGA_INGERENCJI - if q.count() == 1: - msg = "jeden autor - luźne porównanie po imieniu i nazwisku" - status = StatusPorownania.LUZNE - - return WynikPorownania( - status, - f"{msg}{addendum}", - rekordy=q[:MAX_AUT], - ) - - return BRAK_DOPASOWANIA + info_best = f"{best.powod}, {best.publikacji} publikacji" + if best.autor.orcid: + info_best += ", ORCID" + return WynikPorownania( + StatusPorownania.WYMAGA_INGERENCJI, + f"{len(kandydaci)} kandydatów; sugerowany: {best.autor} ({info_best})", + rekordy=[k.autor for k in kandydaci], + sugerowany=best.autor, + kandydaci=kandydaci, + ) @classmethod def porownaj_short_container_title(cls, wartosc): diff --git a/src/import_common/core/__init__.py b/src/import_common/core/__init__.py index 7c55a52c4..85f85dd20 100644 --- a/src/import_common/core/__init__.py +++ b/src/import_common/core/__init__.py @@ -21,6 +21,7 @@ """ from .autor import ( + KandydatAutora, _build_autor_name_query, _try_get_autor_by_bpp_id, _try_get_autor_by_orcid, @@ -32,6 +33,7 @@ _try_match_autor_in_jednostka, _try_match_autor_with_orcid_or_tytul, matchuj_autora, + znajdz_kandydatow_autora, ) from .dyscyplina import ( matchuj_aktualna_dyscypline_pbn, @@ -86,6 +88,8 @@ __all__ = [ # autor "matchuj_autora", + "znajdz_kandydatow_autora", + "KandydatAutora", "_build_autor_name_query", "_try_get_autor_by_bpp_id", "_try_get_autor_by_orcid", diff --git a/src/import_common/core/autor.py b/src/import_common/core/autor.py index a051ee1da..4de6af358 100644 --- a/src/import_common/core/autor.py +++ b/src/import_common/core/autor.py @@ -3,6 +3,8 @@ (jednostka, tytuł). """ +from dataclasses import dataclass + from django.contrib.postgres.lookups import Unaccent from django.db.models import Q from django.db.models.functions import Lower @@ -14,6 +16,32 @@ ) +@dataclass(frozen=True) +class KandydatAutora: + """Pojedynczy kandydat zwracany przez ``znajdz_kandydatow_autora``. + + ``pewnosc`` to wartość 0.0–1.0 odpowiadająca strategii, którą udało + się dopasować autora; ``powod`` to human-readable etykieta strategii + (do wyświetlenia w UI). ``publikacji`` to liczba publikacji autora + (sygnał jakości używany do rankingu przy równej pewności). + """ + + autor: Autor + pewnosc: float + powod: str + publikacji: int + + +# Etykiety strategii i ich pewnosc — punkt prawdy do wszystkich callsite'ów. +POWOD_IEXACT = "iexact" +POWOD_IEXACT_PIERWSZE_IMIE = "iexact_pierwsze_imie" +POWOD_POLISH_ENGLISH = "polish_english" + +PEWNOSC_IEXACT = 1.0 +PEWNOSC_IEXACT_PIERWSZE_IMIE = 0.95 +PEWNOSC_POLISH_ENGLISH = 0.85 + + def _try_get_autor_by_bpp_id(bpp_id: int | None) -> Autor | None: """Próbuje pobrać autora po bpp_id.""" if bpp_id is None: @@ -225,6 +253,214 @@ def _try_match_autor_with_orcid_or_tytul(imiona: str, nazwisko: str) -> Autor | return None +def _strategia_iexact_pelne(imiona: str, nazwisko: str) -> dict[int, tuple[float, str]]: + """Pełne ``imiona`` + nazwisko/poprzednie_nazwiska (iexact).""" + qs = Autor.objects.filter( + Q(nazwisko__iexact=nazwisko) | Q(poprzednie_nazwiska__icontains=nazwisko), + imiona__iexact=imiona, + ) + return { + pk: (PEWNOSC_IEXACT, POWOD_IEXACT) for pk in qs.values_list("pk", flat=True) + } + + +def _strategia_iexact_pierwsze_imie( + imiona: str, nazwisko: str +) -> dict[int, tuple[float, str]]: + """Tylko pierwsze imię (iexact) — gdy w bazie jest "Jan Adam" a w + danych "Jan" (lub odwrotnie).""" + parts = imiona.split() + if not parts: + return {} + pierwsze = parts[0] + # Dwa kierunki: w danych może być więcej imion niż w bazie (qs), albo + # w bazie więcej niż w danych (qs2). Dedup z _strategia_iexact_pelne + # wybierze potem pewnosc=1.0 dla pełnego matchu. + qs = Autor.objects.filter( + Q(nazwisko__iexact=nazwisko) | Q(poprzednie_nazwiska__icontains=nazwisko), + imiona__iexact=pierwsze, + ) + qs2 = Autor.objects.filter( + Q(nazwisko__iexact=nazwisko) | Q(poprzednie_nazwiska__icontains=nazwisko), + imiona__istartswith=pierwsze + " ", + ) + pks = set(qs.values_list("pk", flat=True)) | set(qs2.values_list("pk", flat=True)) + return { + pk: (PEWNOSC_IEXACT_PIERWSZE_IMIE, POWOD_IEXACT_PIERWSZE_IMIE) for pk in pks + } + + +def _strategia_polish_english( + imiona: str, nazwisko: str +) -> dict[int, tuple[float, str]]: + """PL↔EN: Unaccent(nazwisko) + warianty v↔w / klastry na pierwszym imieniu.""" + pierwsze = imiona.split()[0] if imiona.split() else "" + if not pierwsze: + return {} + variants_norm = {v.lower() for v in polish_english_first_name_variants(pierwsze)} + if not variants_norm: + return {} + + 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) + ) + return { + pk: (PEWNOSC_POLISH_ENGLISH, POWOD_POLISH_ENGLISH) + for pk in qs.values_list("pk", flat=True) + } + + +def _publikacji_counts_bulk(pks: list[int]) -> dict[int, int]: + """Zwraca {autor_pk: liczba_publikacji} dla podanych pk autorów. + + Trzy osobne agregacje (po jednej na typ publikacji) zamiast jednej + z wieloma JOINami — przy próbie zliczania ``Count("ciagle") + + Count("zwarte") + Count("patent")`` w jednym querysecie Django robi + cross-JOIN i kardynalności się mnożą. Tu mamy 3 round-tripy na cały + request, niezależnie od liczby kandydatów. + """ + from collections import defaultdict + + from django.db.models import Count + + totals: dict[int, int] = defaultdict(int) + for relation in ( + "wydawnictwo_ciagle_autor", + "wydawnictwo_zwarte_autor", + "patent_autor", + ): + rows = ( + Autor.objects.filter(pk__in=pks) + .annotate(_n=Count(relation)) + .values_list("pk", "_n") + ) + for pk, n in rows: + totals[pk] += n + return dict(totals) + + +def znajdz_kandydatow_autora( + imiona: str | None, + nazwisko: str | None, + *, + max_wyniki: int = 10, +) -> list[KandydatAutora]: + """Zwraca posortowaną listę kandydatów (najlepszy pierwszy). + + Stosuje strategie kolejno; każda kolejna ma niższą pewnosc. Brak + strategii "tylko nazwisko" — żeby nie zwracać listy 100 Kowalskich + z różnymi imionami. Musi się zgadzać przynajmniej imię w jakiejś + formie (iexact, pierwsze imię iexact, albo wariant PL↔EN). + + Strategie: + + - 1.00 — exact iexact pełne imiona + nazwisko + - 0.95 — exact iexact pierwsze imię + nazwisko + - 0.85 — PL↔EN: warianty v↔w + klastry imion + Unaccent nazwiska + + Autor wpadający w kilka strategii zwracany jest **raz**, z najwyższą + pewnością. Sortowanie DESC po: (pewnosc, ma ORCID, ma tytuł, liczba + publikacji, pk) — ORCID/tytuł/publikacje to sygnały jakości używane + przy równej pewności; pk jako stabilny tiebreaker. + + Discovery jest świadomie niezależne od kontekstu (jednostka/tytuł + autora) — te sygnały służą jako tie-breakery w ``matchuj_autora``, + nie jako twarde filtry. Inaczej wycielibyśmy autorów, którzy + nie mają ``aktualna_jednostka`` ustawionej. + """ + imiona = (imiona or "").strip() + nazwisko = (nazwisko or "").strip() + if not imiona or not nazwisko: + return [] + + # Akumulator {pk: (pewnosc, powod)} — wyższa pewność wygrywa przy + # nakładaniu się strategii (dedup po pk). + scores: dict[int, tuple[float, str]] = {} + for strategia in ( + _strategia_iexact_pelne(imiona, nazwisko), + _strategia_iexact_pierwsze_imie(imiona, nazwisko), + _strategia_polish_english(imiona, nazwisko), + ): + for pk, (pewnosc, powod) in strategia.items(): + existing = scores.get(pk) + if existing is None or pewnosc > existing[0]: + scores[pk] = (pewnosc, powod) + + if not scores: + return [] + + pks = list(scores.keys()) + publikacji_counts = _publikacji_counts_bulk(pks) + autorzy_by_pk = Autor.objects.filter(pk__in=pks).in_bulk() + + kandydaci = [ + KandydatAutora( + autor=autor, + pewnosc=scores[pk][0], + powod=scores[pk][1], + publikacji=publikacji_counts.get(pk, 0), + ) + for pk, autor in autorzy_by_pk.items() + ] + kandydaci.sort( + key=lambda k: ( + k.pewnosc, + 1 if k.autor.orcid else 0, + 1 if k.autor.tytul_id else 0, + k.publikacji, + k.autor.pk, + ), + reverse=True, + ) + return kandydaci[:max_wyniki] + + +def _disambiguate_kandydatow( + kandydaci: list[KandydatAutora], + jednostka: Jednostka | None, + tytul_str: str | None, +) -> Autor | None: + """Spróbuj rozstrzygnąć ambiguity gdy ≥2 kandydatów ma identyczną pewność. + + Stosuje tie-breakery kolejno (każdy wymaga **dokładnie jednego** + wyniku, żeby uznać za jednoznaczny): + + 1. ``aktualna_jednostka == jednostka`` — autor pracuje w żądanej + jednostce. + 2. ``tytul.skrot == tytul_str`` — autor ma żądany tytuł. + + Pominięcie obu sygnałów oznacza prawdziwą ambiguity — caller dostaje + None i fallback do historycznej jednostki / ORCID-tytułu. + """ + top_pewnosc = kandydaci[0].pewnosc + top = [k for k in kandydaci if k.pewnosc == top_pewnosc] + + if jednostka is not None: + w_jednostce = [k for k in top if k.autor.aktualna_jednostka_id == jednostka.pk] + if len(w_jednostce) == 1: + return w_jednostce[0].autor + + if tytul_str: + z_tytulem = [ + k for k in top if k.autor.tytul_id and k.autor.tytul.skrot == tytul_str + ] + if len(z_tytulem) == 1: + return z_tytulem[0].autor + + return None + + def matchuj_autora( imiona: str | None, nazwisko: str | None, @@ -236,28 +472,37 @@ def matchuj_autora( orcid: str | None = None, tytul_str: Tytul | None = None, ): - # Najpierw próba po identyfikatorach + """Zwraca jednoznacznego autora albo None. + + Thin wrapper nad ``znajdz_kandydatow_autora`` z disambiguatorami + dla ambiguity (jednostka, tytuł) oraz fallbackami do historycznej + jednostki i wyboru po ORCID/tytule. + """ + # 1. Po identyfikatorach — najpewniejsza ścieżka result = _try_match_autor_by_direct_ids( bpp_id, orcid, pbn_uid_id, system_kadrowy_id, pbn_id ) if result: return result - # Próba po imieniu i nazwisku - result = _try_match_autor_by_name(imiona, nazwisko, jednostka, tytul_str) - if result: - return result + # 2. Discovery + kandydaci = znajdz_kandydatow_autora(imiona, nazwisko, max_wyniki=10) + if len(kandydaci) == 1: + return kandydaci[0].autor + if len(kandydaci) >= 2: + # Najlepszy z wyższą pewnością niż reszta wygrywa bez disambiguacji + if kandydaci[0].pewnosc > kandydaci[1].pewnosc: + return kandydaci[0].autor + # Inaczej spróbuj kontekstem (jednostka/tytuł) + result = _disambiguate_kandydatow(kandydaci, jednostka, tytul_str) + if result: + return result - # Szukanie w jednostce (niekoniecznie aktualnej) + # 3. Historyczna jednostka — autor był kiedyś w tej jednostce if jednostka: result = _try_match_autor_in_jednostka(imiona, nazwisko, jednostka, tytul_str) 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 + # 4. Tie-breaker dla ambiguity bez jednostki: preferuj autora z ORCID/tytułem return _try_match_autor_with_orcid_or_tytul(imiona, nazwisko) diff --git a/src/import_common/tests/test_znajdz_kandydatow_autora.py b/src/import_common/tests/test_znajdz_kandydatow_autora.py new file mode 100644 index 000000000..e38b767b7 --- /dev/null +++ b/src/import_common/tests/test_znajdz_kandydatow_autora.py @@ -0,0 +1,279 @@ +"""Testy dla ``znajdz_kandydatow_autora`` — discovery API zwracającego +listę kandydatów z rankingiem, używane przez wizard importera publikacji +gdy chce pokazać użytkownikowi kilku potencjalnych autorów do wyboru. + +Filozofia: discovery (lista, z najlepszym pierwszym) jest oddzielone od +picking (wybór jednoznacznego). ``matchuj_autora`` to thin wrapper nad +tym API i wciąż zwraca pojedynczego Autora lub None. + +Strategie i ich pewnosc: +- 1.00 — iexact pełne imiona + nazwisko +- 0.95 — iexact pierwsze imię + nazwisko +- 0.85 — PL↔EN (warianty v↔w + klastry + Unaccent nazwiska) + +Brak strategii "tylko nazwisko bez imienia" — żeby uniknąć N Kowalskich +z różnymi imionami w wynikach. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Autor +from import_common.core import matchuj_autora + + +@pytest.fixture +def kandydat_autora_cls(): + """Załadowany dataclass KandydatAutora — pojedyncze miejsce importu.""" + from import_common.core import KandydatAutora + + return KandydatAutora + + +@pytest.fixture +def znajdz_fn(): + """Pojedyncze miejsce importu funkcji discovery.""" + from import_common.core import znajdz_kandydatow_autora + + return znajdz_kandydatow_autora + + +# --------------------------------------------------------------------------- +# Strategie matchingu +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_pusta_baza_zwraca_pusta_liste(znajdz_fn): + assert znajdz_fn("Jan", "Kowalski") == [] + + +@pytest.mark.django_db +def test_exact_match_pewnosc_1(znajdz_fn): + autor = baker.make(Autor, imiona="Jan", nazwisko="Kowalski") + kandydaci = znajdz_fn("Jan", "Kowalski") + assert len(kandydaci) == 1 + assert kandydaci[0].autor == autor + assert kandydaci[0].pewnosc == 1.0 + assert kandydaci[0].powod == "iexact" + + +@pytest.mark.django_db +def test_pierwsze_imie_match_pewnosc_095(znajdz_fn): + """Szukamy 'Jan', w bazie 'Jan Adam' — strategia iexact pierwsze imię.""" + autor = baker.make(Autor, imiona="Jan Adam", nazwisko="Kowalski") + kandydaci = znajdz_fn("Jan", "Kowalski") + assert len(kandydaci) == 1 + assert kandydaci[0].autor == autor + assert kandydaci[0].pewnosc == 0.95 + assert kandydaci[0].powod == "iexact_pierwsze_imie" + + +@pytest.mark.django_db +def test_pl_en_variants_pewnosc_085(znajdz_fn): + """Eva ↔ Ewa + Marańda ↔ Maranda przez Unaccent.""" + autor = baker.make(Autor, imiona="Ewa", nazwisko="Marańda") + kandydaci = znajdz_fn("Eva", "Maranda") + assert len(kandydaci) == 1 + assert kandydaci[0].autor == autor + assert kandydaci[0].pewnosc == 0.85 + assert kandydaci[0].powod == "polish_english" + + +@pytest.mark.django_db +def test_inne_imie_bez_pl_en_pomijane(znajdz_fn): + """Brak strategii 'tylko nazwisko' — Edward Maranda nie pasuje do Eva Maranda. + + Reguła użytkownika: musi się zgadzać przynajmniej imię w jakiejś formie + (iexact, pierwsze imię iexact, albo wariant PL↔EN). 'Edward' nie ma + wariantów wspólnych z 'Eva' → brak kandydata. + """ + baker.make(Autor, imiona="Edward", nazwisko="Maranda") + assert znajdz_fn("Eva", "Maranda") == [] + + +@pytest.mark.django_db +def test_imie_z_inna_pierwsza_litera_bez_klastra_pomijane(znajdz_fn): + """Anna ≠ Eva — różne pierwsze litery, brak klastra → pusta lista.""" + baker.make(Autor, imiona="Anna", nazwisko="Kowalska") + assert znajdz_fn("Eva", "Kowalska") == [] + + +# --------------------------------------------------------------------------- +# Ranking i sortowanie +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_sort_pewnosc_desc(znajdz_fn): + """Wyższa pewność idzie pierwsza.""" + # 1.0 - exact "Jan Kowalski" + exact = baker.make(Autor, imiona="Jan", nazwisko="Kowalski") + # 0.95 - "Jan Adam Kowalski" — szukamy "Jan" + first_only = baker.make(Autor, imiona="Jan Adam", nazwisko="Kowalski") + + kandydaci = znajdz_fn("Jan", "Kowalski") + assert len(kandydaci) == 2 + assert kandydaci[0].autor == exact + assert kandydaci[1].autor == first_only + assert kandydaci[0].pewnosc > kandydaci[1].pewnosc + + +@pytest.mark.django_db +def test_sort_orcid_przed_brakiem(znajdz_fn): + """Przy równej pewności — autor z ORCID przed autorem bez.""" + bez_orcid = baker.make(Autor, imiona="Ewa", nazwisko="Marańda", orcid="") + z_orcid = baker.make( + Autor, imiona="Ewa", nazwisko="Maranda", orcid="0000-0001-2345-6789" + ) + + kandydaci = znajdz_fn("Eva", "Maranda") + # Oba wpadają przez PL↔EN (pewnosc=0.85), ORCID rozstrzyga + assert len(kandydaci) == 2 + assert kandydaci[0].autor == z_orcid + assert kandydaci[1].autor == bez_orcid + + +@pytest.mark.django_db +def test_sort_liczba_publikacji_desc(znajdz_fn): + """Przy równej pewności i braku ORCID — więcej publikacji = wyżej.""" + from bpp.models import Wydawnictwo_Ciagle, Wydawnictwo_Ciagle_Autor + + malo_publikacji = baker.make(Autor, imiona="Ewa", nazwisko="Marańda") + duzo_publikacji = baker.make(Autor, imiona="Ewa", nazwisko="Maranda") + + # 1 publikacja dla mało, 3 dla dużo + pub_a = baker.make(Wydawnictwo_Ciagle) + baker.make(Wydawnictwo_Ciagle_Autor, autor=malo_publikacji, rekord=pub_a) + for _ in range(3): + pub_b = baker.make(Wydawnictwo_Ciagle) + baker.make(Wydawnictwo_Ciagle_Autor, autor=duzo_publikacji, rekord=pub_b) + + kandydaci = znajdz_fn("Eva", "Maranda") + assert len(kandydaci) == 2 + assert kandydaci[0].autor == duzo_publikacji + assert kandydaci[0].publikacji == 3 + assert kandydaci[1].autor == malo_publikacji + assert kandydaci[1].publikacji == 1 + + +@pytest.mark.django_db +def test_max_wyniki_obcina_liste(znajdz_fn): + """Limit max_wyniki — nie zwracamy nieograniczonej listy.""" + for i in range(5): + baker.make(Autor, imiona=f"Ewa {i}", nazwisko="Maranda") + baker.make(Autor, imiona="Ewa", nazwisko="Marańda") # +1 przez PL↔EN + + kandydaci = znajdz_fn("Ewa", "Marańda", max_wyniki=3) + assert len(kandydaci) == 3 + + +@pytest.mark.django_db +def test_deduplikacja_po_pk_najwyzsza_pewnosc(znajdz_fn): + """Autor wpadający w wiele strategii zwracany raz, z najwyższą pewnością. + + 'Jan Kowalski' jest exact (1.0) ORAZ pierwsze imię (0.95) ORAZ + PL↔EN match self (Jan↔Jan, 0.85). Powinien zwrócić się 1 raz z 1.0. + """ + autor = baker.make(Autor, imiona="Jan", nazwisko="Kowalski") + kandydaci = znajdz_fn("Jan", "Kowalski") + assert len(kandydaci) == 1 + assert kandydaci[0].autor == autor + assert kandydaci[0].pewnosc == 1.0 + + +# --------------------------------------------------------------------------- +# Case z zgłoszenia: Lech-Maranda +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_case_lech_maranda_dwoch_kandydatow(znajdz_fn): + """Reprodukcja zgłoszenia: 2 autorów w bazie, CrossRef daje 'Eva Lech-Maranda'.""" + z_diakrytykiem = baker.make(Autor, imiona="Ewa", nazwisko="Lech-Marańda") + bez_diakrytyku = baker.make(Autor, imiona="Ewa", nazwisko="Lech-Maranda") + + kandydaci = znajdz_fn("Eva", "Lech-Maranda") + autorzy = {k.autor for k in kandydaci} + assert autorzy == {z_diakrytykiem, bez_diakrytyku} + # Każdy ma pewność 0.85 (PL↔EN przez v↔w + Unaccent) + assert all(k.pewnosc == 0.85 for k in kandydaci) + + +# --------------------------------------------------------------------------- +# BC: matchuj_autora jako thin wrapper +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_matchuj_autora_jeden_kandydat_zwraca_autora(): + autor = baker.make(Autor, imiona="Jan", nazwisko="Kowalski") + assert matchuj_autora("Jan", "Kowalski") == autor + + +@pytest.mark.django_db +def test_matchuj_autora_ambiguity_zwraca_none(): + """BC: gdy ≥2 kandydatów, zwraca None (decyzja w UI).""" + baker.make(Autor, imiona="Ewa", nazwisko="Lech-Marańda") + baker.make(Autor, imiona="Ewa", nazwisko="Lech-Maranda") + assert matchuj_autora("Eva", "Lech-Maranda") is None + + +@pytest.mark.django_db +def test_matchuj_autora_jednostka_nie_jest_hard_filtrem(): + """BC: jednostka nie wyklucza autorów którzy nie mają aktualna_jednostka. + + Stary kod: jeden Jan Kowalski (aktualna_jednostka=None) + jednostka=j1 → + zwracał autora (jednostka jako disambiguator, nie hard filter). Nowy + thin wrapper musi zachować to zachowanie. + """ + from bpp.models import Jednostka + + j1 = baker.make(Jednostka) + autor = baker.make(Autor, imiona="Jan", nazwisko="Kowalski") + # aktualna_jednostka domyślnie None, brak historycznego przypisania + assert autor.aktualna_jednostka is None + + assert matchuj_autora("Jan", "Kowalski", jednostka=j1) == autor + + +@pytest.mark.django_db +def test_matchuj_autora_tytul_jako_disambiguator(): + """BC: gdy 2 homonimów z różnymi tytułami — tytul_str rozstrzyga. + + Stary kod używał ``Q(tytul__skrot=tytul_str)`` jako filtra w + ``_try_match_autor_by_name`` żeby rozróżnić "Jan Kowalski dr" od + "Jan Kowalski prof". Thin wrapper musi to zachować przez + disambiguator. + """ + from bpp.models import Tytul + + foo, _ = Tytul.objects.get_or_create( + nazwa="test-tyt-foo", defaults={"skrot": "foo-skr"} + ) + bar, _ = Tytul.objects.get_or_create( + nazwa="test-tyt-bar", defaults={"skrot": "bar-skr"} + ) + + a_foo = baker.make(Autor, imiona="Jan", nazwisko="Kowalski", tytul=foo) + baker.make(Autor, imiona="Jan", nazwisko="Kowalski", tytul=bar) + + assert matchuj_autora("Jan", "Kowalski", tytul_str="foo-skr") == a_foo + + +# --------------------------------------------------------------------------- +# KandydatAutora dataclass +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_kandydat_autora_pola(kandydat_autora_cls): + """Dataclass ma wymagane pola.""" + autor = baker.make(Autor, imiona="Jan", nazwisko="Kowalski") + k = kandydat_autora_cls( + autor=autor, pewnosc=0.85, powod="polish_english", publikacji=5 + ) + assert k.autor == autor + assert k.pewnosc == 0.85 + assert k.powod == "polish_english" + assert k.publikacji == 5 diff --git a/src/importer_publikacji/migrations/0007_importedauthor_candidate.py b/src/importer_publikacji/migrations/0007_importedauthor_candidate.py new file mode 100644 index 000000000..41b946aea --- /dev/null +++ b/src/importer_publikacji/migrations/0007_importedauthor_candidate.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.14 on 2026-05-21 11:52 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bpp', '0416_rename_dynamic_columns_to_admin'), + ('importer_publikacji', '0006_merge_20260421_1100'), + ] + + operations = [ + migrations.CreateModel( + name='ImportedAuthor_Candidate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('pewnosc', models.FloatField(verbose_name='pewność')), + ('powod', models.CharField(max_length=32, verbose_name='powód dopasowania')), + ('publikacji_count', models.PositiveIntegerField(default=0, verbose_name='liczba publikacji')), + ('autor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bpp.autor', verbose_name='autor BPP')), + ('imported_author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='importer_publikacji.importedauthor', verbose_name='importowany autor')), + ], + options={ + 'verbose_name': 'kandydat na autora', + 'verbose_name_plural': 'kandydaci na autora', + 'ordering': ['-pewnosc', '-publikacji_count'], + 'unique_together': {('imported_author', 'autor')}, + }, + ), + ] diff --git a/src/importer_publikacji/models.py b/src/importer_publikacji/models.py index 75bf73ec3..1f1ad17f0 100644 --- a/src/importer_publikacji/models.py +++ b/src/importer_publikacji/models.py @@ -244,3 +244,57 @@ def __str__(self): def display_name(self): parts = [self.family_name, self.given_name] return " ".join(p for p in parts if p) + + +# Mapowanie technicznej etykiety strategii dopasowania na user-friendly +# tekst pokazywany w UI. Trzymane w models.py, nie w autor.py, żeby +# template-y (które importują tylko model) nie musiały sięgać do +# import_common.core. +POWOD_DISPLAY = { + "iexact": "dokładne", + "iexact_pierwsze_imie": "pierwsze imię", + "polish_english": "wariant PL/EN", +} + + +class ImportedAuthor_Candidate(models.Model): + """Kandydat na dopasowanie dla ``ImportedAuthor`` zwrócony przez + ``znajdz_kandydatow_autora``. + + Materializuje listę z metadanymi (pewność, powód strategii, liczba + publikacji) żeby UI wizardu mógł wyświetlić użytkownikowi pełny + kontekst — który autor ma więcej publikacji, ORCID, jaką strategią + został znaleziony. + """ + + imported_author = models.ForeignKey( + ImportedAuthor, + on_delete=models.CASCADE, + related_name="candidates", + verbose_name="importowany autor", + ) + autor = models.ForeignKey( + "bpp.Autor", + on_delete=models.CASCADE, + verbose_name="autor BPP", + ) + pewnosc = models.FloatField("pewność") + powod = models.CharField("powód dopasowania", max_length=32) + publikacji_count = models.PositiveIntegerField("liczba publikacji", default=0) + + class Meta: + verbose_name = "kandydat na autora" + verbose_name_plural = "kandydaci na autora" + ordering = ["-pewnosc", "-publikacji_count"] + unique_together = [("imported_author", "autor")] + + def __str__(self): + return f"{self.autor} ({self.pewnosc_procent}% / {self.powod_display})" + + @property + def pewnosc_procent(self) -> int: + return int(round(self.pewnosc * 100)) + + @property + def powod_display(self) -> str: + return POWOD_DISPLAY.get(self.powod, self.powod) diff --git a/src/importer_publikacji/templates/importer_publikacji/partials/author_row.html b/src/importer_publikacji/templates/importer_publikacji/partials/author_row.html index 98656129e..d762ee702 100644 --- a/src/importer_publikacji/templates/importer_publikacji/partials/author_row.html +++ b/src/importer_publikacji/templates/importer_publikacji/partials/author_row.html @@ -31,6 +31,19 @@
+ Znalezieni kandydaci ({{ candidates|length }}) — + kliknij, aby wybrać: +
+