From 4504a76204e87278983da3006f3dabe2933ce7bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 21 May 2026 14:17:28 +0200 Subject: [PATCH 1/6] feat(importer): ranked autor candidates z najlepszym do preselekcji MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wizard importera publikacji (CrossRef) ma case zglaszany przez usera: w bazie istnieje 2 autorow o tym samym nazwisku (Lech-Marańda Ewa / Lech-Maranda Ewa), CrossRef daje "Eva Lech-Maranda". Stary kod zwracal None (ambiguity) → user wpadal na unmatched, mial probowac manualnie. Nowy framework discovery (znajdz_kandydatow_autora) zwraca POSORTOWANA liste kandydatow: - 1.00 — exact iexact pelne imiona + nazwisko - 0.95 — exact iexact pierwsze imie + nazwisko - 0.85 — PL↔EN (warianty v↔w + klastry imion + Unaccent nazwiska) Brak strategii "tylko nazwisko" — musi sie zgadzac przynajmniej imie w jakiejs formie, zeby nie zwracac 100 Kowalskich. Sortowanie DESC po (pewnosc, ORCID, tytul, liczba publikacji, pk). Komparator.porownaj_author zwraca WynikPorownania z .sugerowany + .kandydaci. _auto_match_authors zapisuje liste do nowej tabeli M2M ImportedAuthor_Candidate z metadanymi (pewnosc, powod, publikacji). UI wizardu: rozwijana lista alternatyw z badge'ami przy "Autor w BPP" gdy >1 kandydatów. Klikniecie alternatywy POSTuje author-match z innym pk → row sie odswieza. matchuj_autora przepisany jako thin wrapper z disambiguatorami (jednostka / tytul_str jako tie-breakery, NIE hard filtry) i fallbackami do historycznej jednostki / ORCID-tytulu. BC zachowane dla wszystkich istniejacych call-sites (PBN, polon, dyscypliny, crossref). Liczenie publikacji per kandydat: 3 zagregowane queries zamiast 30 per-instance count() (skalowanie z liczba kandydatow * 3 tabele). Testy: 614 passed (15 nowych dla znajdz_kandydatow_autora + 4 dla _auto_match_authors + 2 BC regresje znalezione przez self-review). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../+autor-kandydaci-ranked.feature.md | 6 + src/crossref_bpp/core.py | 76 ++--- src/import_common/core/__init__.py | 4 + src/import_common/core/autor.py | 269 ++++++++++++++++- .../tests/test_znajdz_kandydatow_autora.py | 279 ++++++++++++++++++ .../0007_importedauthor_candidate.py | 32 ++ src/importer_publikacji/models.py | 35 +++ .../partials/author_candidates.html | 60 ++++ .../partials/author_row.html | 5 + .../tests/test_auto_match_authors.py | 103 +++++++ src/importer_publikacji/views/authors.py | 84 ++++-- src/importer_publikacji/views/steps.py | 21 +- 12 files changed, 892 insertions(+), 82 deletions(-) create mode 100644 src/bpp/newsfragments/+autor-kandydaci-ranked.feature.md create mode 100644 src/import_common/tests/test_znajdz_kandydatow_autora.py create mode 100644 src/importer_publikacji/migrations/0007_importedauthor_candidate.py create mode 100644 src/importer_publikacji/templates/importer_publikacji/partials/author_candidates.html create mode 100644 src/importer_publikacji/tests/test_auto_match_authors.py 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..8c310e1d8 100644 --- a/src/importer_publikacji/models.py +++ b/src/importer_publikacji/models.py @@ -244,3 +244,38 @@ def __str__(self): def display_name(self): parts = [self.family_name, self.given_name] return " ".join(p for p in parts if p) + + +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:.2f} / {self.powod})" diff --git a/src/importer_publikacji/templates/importer_publikacji/partials/author_candidates.html b/src/importer_publikacji/templates/importer_publikacji/partials/author_candidates.html new file mode 100644 index 000000000..57f6d26e9 --- /dev/null +++ b/src/importer_publikacji/templates/importer_publikacji/partials/author_candidates.html @@ -0,0 +1,60 @@ +{# Rozwijana lista kandydatów na dopasowanie autora — używana gdy #} +{# znajdz_kandydatow_autora znalazł wielu pasujących. Kliknięcie #} +{# alternatywy POSTuje author-match z innym pk autora. #} +{# Kontekst: candidates = lista ImportedAuthor_Candidate (z prefetch). #} +
+ + + {{ candidates|length }} kandydatów + + +
    + {% for c in candidates %} +
  • + {% if c.autor == author.matched_autor %} + + ▶ {{ c.autor }} + wybrany + + {{ c.pewnosc|floatformat:2 }} + + {% if c.publikacji_count %} + + {{ c.publikacji_count }} publ. + + {% endif %} + {% if c.autor.orcid %} + ORCID + {% endif %} + + {% else %} +
    + {% csrf_token %} + + +
    + {% endif %} +
  • + {% endfor %} +
+
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..3d91fc5d5 100644 --- a/src/importer_publikacji/templates/importer_publikacji/partials/author_row.html +++ b/src/importer_publikacji/templates/importer_publikacji/partials/author_row.html @@ -34,6 +34,11 @@ {% else %} - {% endif %} + {% with candidates=author.candidates.all %} + {% if candidates|length > 1 %} + {% include "importer_publikacji/partials/author_candidates.html" %} + {% endif %} + {% endwith %} {% if author.matched_jednostka %} diff --git a/src/importer_publikacji/tests/test_auto_match_authors.py b/src/importer_publikacji/tests/test_auto_match_authors.py new file mode 100644 index 000000000..eee7a1b80 --- /dev/null +++ b/src/importer_publikacji/tests/test_auto_match_authors.py @@ -0,0 +1,103 @@ +"""Testy dla ``_auto_match_authors`` — zapis ``matched_autor`` i listy +``ImportedAuthor_Candidate`` w zależności od wyniku ``Komparator.porownaj_author``. +""" + +import pytest +from model_bakery import baker + +from bpp.models import Autor +from importer_publikacji.models import ImportedAuthor, ImportSession +from importer_publikacji.views.authors import _auto_match_authors + + +@pytest.fixture +def session(): + return baker.make(ImportSession) + + +@pytest.mark.django_db +def test_brak_dopasowania_nie_zapisuje_kandydatow(session): + """Pusta baza autorów → UNMATCHED, brak kandydatów w M2M.""" + _auto_match_authors( + session, [{"given": "Eva", "family": "Lech-Maranda"}], year=None + ) + imported = session.authors.get() + assert imported.matched_autor is None + assert imported.match_status == ImportedAuthor.MatchStatus.UNMATCHED + assert imported.candidates.count() == 0 + + +@pytest.mark.django_db +def test_jednoznaczny_exact_match_zapisuje_kandydata(session): + """1 kandydat z pewnosc=1.0 → AUTO_EXACT, 1 wpis kandydatów.""" + autor = baker.make(Autor, imiona="Jan", nazwisko="Kowalski") + _auto_match_authors(session, [{"given": "Jan", "family": "Kowalski"}], year=None) + imported = session.authors.get() + assert imported.matched_autor == autor + assert imported.match_status == ImportedAuthor.MatchStatus.AUTO_EXACT + candidates = list(imported.candidates.all()) + assert len(candidates) == 1 + assert candidates[0].autor == autor + assert candidates[0].pewnosc == 1.0 + assert candidates[0].powod == "iexact" + + +@pytest.mark.django_db +def test_lech_maranda_ambiguity_sugeruje_z_orcid(session): + """Reprodukcja zgłoszenia: 2 autorów w bazie → AUTO_LOOSE z sugerowanym. + + Eva Lech-Maranda (import) ↔ {Ewa Lech-Marańda, Ewa Lech-Maranda} (baza). + Oba kandydaci po PL↔EN (pewnosc=0.85). Sugerowany — ten z ORCID. + """ + z_orcid = baker.make( + Autor, + imiona="Ewa", + nazwisko="Lech-Marańda", + orcid="0000-0001-2345-6789", + ) + bez_orcid = baker.make(Autor, imiona="Ewa", nazwisko="Lech-Maranda") + + _auto_match_authors( + session, [{"given": "Eva", "family": "Lech-Maranda"}], year=None + ) + imported = session.authors.get() + assert imported.matched_autor == z_orcid + assert imported.match_status == ImportedAuthor.MatchStatus.AUTO_LOOSE + + candidates = list(imported.candidates.order_by("-pewnosc", "-publikacji_count")) + assert len(candidates) == 2 + assert {c.autor for c in candidates} == {z_orcid, bez_orcid} + assert all(c.pewnosc == 0.85 for c in candidates) + assert all(c.powod == "polish_english" for c in candidates) + + +@pytest.mark.django_db +def test_render_author_row_z_dropdownem_kandydatow(session, rf): + """Template author_row renderuje dropdown gdy są >1 kandydatów.""" + from django.template.loader import render_to_string + + z_orcid = baker.make( + Autor, + imiona="Ewa", + nazwisko="Lech-Marańda", + orcid="0000-0001-2345-6789", + ) + baker.make(Autor, imiona="Ewa", nazwisko="Lech-Maranda") + + _auto_match_authors( + session, [{"given": "Eva", "family": "Lech-Maranda"}], year=None + ) + imported = session.authors.get() + + html = render_to_string( + "importer_publikacji/partials/author_row.html", + {"session": session, "author": imported}, + ) + assert "2 kandydatów" in html + assert str(z_orcid) in html + assert "ORCID" in html + # hx-post celuje w endpoint author-match (URL kończy się na /match/) + assert "/match/" in html + # Tylko alternatywny kandydat (nie matched_autor) ma hidden input z pk + bez_orcid_pk = imported.candidates.exclude(autor=z_orcid).get().autor.pk + assert f'name="autor" value="{bez_orcid_pk}"' in html diff --git a/src/importer_publikacji/views/authors.py b/src/importer_publikacji/views/authors.py index 35b8bdfee..c53ab432e 100644 --- a/src/importer_publikacji/views/authors.py +++ b/src/importer_publikacji/views/authors.py @@ -13,7 +13,7 @@ from crossref_bpp.core import Komparator, StatusPorownania from import_common.normalization import normalize_doi -from ..models import ImportedAuthor +from ..models import ImportedAuthor, ImportedAuthor_Candidate def _orcid_settable_qs(session): @@ -65,8 +65,30 @@ def _get_dyscyplina(autor, year): return None +def _apply_dyscyplina(imported, bpp_autor, year): + if not (year and bpp_autor): + return + dyscyplina = _get_dyscyplina(bpp_autor, year) + imported.matched_dyscyplina = dyscyplina + if dyscyplina: + imported.dyscyplina_source = ImportedAuthor.DyscyplinaSource.AUTO_JEDYNA + + def _auto_match_authors(session, authors_data, year): - """Auto-dopasuj autorów z danych dostawcy.""" + """Auto-dopasuj autorów z danych dostawcy. + + Dla każdego importowanego autora: + + - DOKLADNE → AUTO_EXACT z preselectowanym bpp_autor + - LUZNE → AUTO_LOOSE z preselectowanym bpp_autor (niska pewność) + - WYMAGA_INGERENCJI → AUTO_LOOSE z sugerowanym bpp_autor (user + powinien potwierdzić; lista kandydatów dostępna w UI) + - BRAK → UNMATCHED (default) + + Kandydaci z metadanymi (pewnosc, powod, publikacji) są zapisywani + do ``ImportedAuthor_Candidate`` żeby UI mógł pokazać listę z + badge'ami. + """ for i, author_data in enumerate(authors_data): imported = ImportedAuthor.objects.create( session=session, @@ -77,36 +99,40 @@ def _auto_match_authors(session, authors_data, year): ) result = Komparator.porownaj_author(author_data) - - if result.status == StatusPorownania.DOKLADNE: - bpp_autor = result.rekord_po_stronie_bpp - if bpp_autor: - imported.matched_autor = bpp_autor - imported.match_status = ImportedAuthor.MatchStatus.AUTO_EXACT - imported.matched_jednostka = bpp_autor.aktualna_jednostka - if year: - dyscyplina = _get_dyscyplina(bpp_autor, year) - imported.matched_dyscyplina = dyscyplina - if dyscyplina: - imported.dyscyplina_source = ( - ImportedAuthor.DyscyplinaSource.AUTO_JEDYNA - ) - elif result.status == StatusPorownania.LUZNE: - bpp_autor = result.rekord_po_stronie_bpp - if bpp_autor: - imported.matched_autor = bpp_autor - imported.match_status = ImportedAuthor.MatchStatus.AUTO_LOOSE - imported.matched_jednostka = bpp_autor.aktualna_jednostka - if year: - dyscyplina = _get_dyscyplina(bpp_autor, year) - imported.matched_dyscyplina = dyscyplina - if dyscyplina: - imported.dyscyplina_source = ( - ImportedAuthor.DyscyplinaSource.AUTO_JEDYNA - ) + bpp_autor = result.sugerowany or result.rekord_po_stronie_bpp + + if result.status == StatusPorownania.DOKLADNE and bpp_autor: + imported.match_status = ImportedAuthor.MatchStatus.AUTO_EXACT + elif ( + result.status + in (StatusPorownania.LUZNE, StatusPorownania.WYMAGA_INGERENCJI) + and bpp_autor + ): + imported.match_status = ImportedAuthor.MatchStatus.AUTO_LOOSE + else: + bpp_autor = None + + if bpp_autor: + imported.matched_autor = bpp_autor + imported.matched_jednostka = bpp_autor.aktualna_jednostka + _apply_dyscyplina(imported, bpp_autor, year) imported.save() + if result.kandydaci: + ImportedAuthor_Candidate.objects.bulk_create( + [ + ImportedAuthor_Candidate( + imported_author=imported, + autor=k.autor, + pewnosc=k.pewnosc, + powod=k.powod, + publikacji_count=k.publikacji, + ) + for k in result.kandydaci + ] + ) + def _find_matching_zgloszenie(session): """Szukaj pasującego zgłoszenia publikacji po DOI lub tytule. diff --git a/src/importer_publikacji/views/steps.py b/src/importer_publikacji/views/steps.py index e4a8c1fde..aba983c2c 100644 --- a/src/importer_publikacji/views/steps.py +++ b/src/importer_publikacji/views/steps.py @@ -247,11 +247,22 @@ def _render_source_full(request, session, form=None): def _authors_context(request, session): """Przygotuj kontekst dla kroku autorów.""" - all_authors = session.authors.select_related( - "matched_autor", - "matched_jednostka", - "matched_dyscyplina", - ).all() + from django.db.models import Prefetch + + from ..models import ImportedAuthor_Candidate + + candidates_qs = ImportedAuthor_Candidate.objects.select_related("autor").order_by( + "-pewnosc", "-publikacji_count" + ) + all_authors = ( + session.authors.select_related( + "matched_autor", + "matched_jednostka", + "matched_dyscyplina", + ) + .prefetch_related(Prefetch("candidates", queryset=candidates_qs)) + .all() + ) total = all_authors.count() stats = { From cea1dfdf2d8097d68a26adb6a39fd25dd4ed1cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 22 May 2026 13:41:49 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(importer):=20UI=20polish=20=E2=80=94?= =?UTF-8?q?=20modal=20height,=20klik=20tylko=20Edytuj,=20scal=20kolumny?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trzy zmiany UX zglaszane przez usera po pierwszej iteracji: 1. Modal "Edycja autora" mial wymuszone height:70vh — przy malej zawartosci (3 pola formularza) byl wieksy niz potrzeba. Zmiana na height:auto + max-height:90vh + top:5vh (Foundation Reveal ma domyslnie bottom:0 ktore tez trzeba zresetowac do auto). 2. Klik w caly wiersz wczesniej tez otwieral modal. Konfliktowal z innymi klikalnymi elementami w wierszu — dropdown kandydatow, formy hx-post do przepiecia autora, przycisk ORCID. Wylaczono row-handler, modal otwiera tylko ".btn-edit-author". 3. Kolumny "Autor w BPP" + "Jednostka" zlaczone w jedna ("Autor / jednostka"). Po kliku na kandydata w dropdownie zmiana jednostki jest natychmiast widoczna w tej samej komorce. Format kazdego kandydata w dropdownie: "Autor · jednostka" zamiast samego autora — od razu widac "skad on jest", co pomaga przy disambiguacji homonimow z roznych wydzialow. Prefetch select_related rozszerzony o autor__aktualna_jednostka zeby nie zrobic N+1 przy renderowaniu kandydatow. Playwright test zaktualizowany — klika ".btn-edit-author" zamiast calego wiersza. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../partials/author_candidates.html | 6 +++++ .../partials/author_row.html | 14 +++++------ .../partials/step_authors.html | 24 +++++-------------- .../tests/test_auto_match_authors.py | 10 +++++++- .../tests/test_playwright_authors.py | 6 +++-- src/importer_publikacji/views/steps.py | 6 ++--- 6 files changed, 34 insertions(+), 32 deletions(-) diff --git a/src/importer_publikacji/templates/importer_publikacji/partials/author_candidates.html b/src/importer_publikacji/templates/importer_publikacji/partials/author_candidates.html index 57f6d26e9..d86496401 100644 --- a/src/importer_publikacji/templates/importer_publikacji/partials/author_candidates.html +++ b/src/importer_publikacji/templates/importer_publikacji/partials/author_candidates.html @@ -14,6 +14,9 @@ {% if c.autor == author.matched_autor %} ▶ {{ c.autor }} + {% if c.autor.aktualna_jednostka %} + · {{ c.autor.aktualna_jednostka }} + {% endif %} wybrany {{ c.pewnosc|floatformat:2 }} @@ -39,6 +42,9 @@ style="padding:0;margin:0;text-align:left"> {{ c.autor }} + {% if c.autor.aktualna_jednostka %} + · {{ c.autor.aktualna_jednostka }} + {% endif %} {{ c.pewnosc|floatformat:2 }} 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 3d91fc5d5..c161b4c8b 100644 --- a/src/importer_publikacji/templates/importer_publikacji/partials/author_row.html +++ b/src/importer_publikacji/templates/importer_publikacji/partials/author_row.html @@ -1,6 +1,5 @@ {% if author.matched_autor %} {{ author.matched_autor }} + {% if author.matched_jednostka %} +
+ + ↳ {{ author.matched_jednostka }} + + {% endif %} {% else %} - {% endif %} @@ -40,13 +45,6 @@ {% endif %} {% endwith %} - - {% if author.matched_jednostka %} - {{ author.matched_jednostka }} - {% else %} - - - {% endif %} - {% if author.matched_dyscyplina %} {{ author.matched_dyscyplina }} diff --git a/src/importer_publikacji/templates/importer_publikacji/partials/step_authors.html b/src/importer_publikacji/templates/importer_publikacji/partials/step_authors.html index 243333d2e..96853ef91 100644 --- a/src/importer_publikacji/templates/importer_publikacji/partials/step_authors.html +++ b/src/importer_publikacji/templates/importer_publikacji/partials/step_authors.html @@ -81,8 +81,7 @@

Autor (dane dostawcy) ORCID Dopasowanie - Autor w BPP - Jednostka + Autor w BPP / jednostka Dyscyplina Źródło dyscypliny Akcje @@ -163,7 +162,7 @@

{# ===== Modal edycji autora (Foundation Reveal) ===== #}
+ style="height:auto;max-height:90vh;overflow-y:auto;top:5vh;bottom:auto">